Files
reader/docs/architecture.md
T
hectorqin 1c2d07ba4c fix(web,server): 让服务端只交章节,并去掉成对的 sync 请求
服务端仍在做排版:`chapter-html:<n>` 自己断段、自己提升标题、自己吐
`<p>`。于是 reader 设置里的缩进和段间距只能作用于服务端已经渲染好的那一份,
流式那条路(`chapter:<n>`)拿不到段落,同一本书因为走哪条传输而看起来不一样。
客户端的 `retypePlainText` 于是把服务端断好的段落再拆一遍——同一个判断被两个
地方执行,两次的结果就会分歧。

`POST /api/v1/sync` 的响应本来就是合并后的状态(服务端在两个接口上返回同一份
`pull`),但客户端把它丢掉,然后又为同一份数据发一个 `GET`。读者每翻一页都发
一个 POST 加一个 GET,网络面板里成对出现。另外 `setState` 每次调用都广播,而
阅读页的状态监听会重绘整棵 chrome 树,一个同步循环通知三次。

现在服务端只把整章字符交出来(`text/plain`,`chapter-html:` 不截断),断段、
去段前空格、提升标题全部由 `web/src/formats/segments.ts` 一处决定;push 的返回
值直接喂给 `applyPulled`,空闲轮询才真的发 GET;`setState` 值未变就不广播。

「这一章是不是纯文本」改由 manifest 声明(item 的 `format: 'html'` →
`Section.plainText` → `isPlainText()`),不再靠 `.txt-body` 嗅探:服务端现在发
裸字符,嗅探对一章节全是纯文本的章节会答「不是」,读者会看到一本没有段落、
没有缩进、样式表也没挂上的小说。`.txt-body` 标记降级为旧服务端/旧缓存的兜底。

放弃的方案:保留服务端渲染、只在客户端覆盖样式。缩进和段间距是阅读偏好,服务端
渲染就等于把每次调整变成一次往返,而这个往返正是问题本身。也考虑过让
`chapter-html:` 直接返回服务端渲染的裸 `<p>`(不带 wrapper),但那仍然有两个
地方在判断「一段是什么」。

Ref: #34
2026-09-18 23:39:55 +08:00

657 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构与数据边界
本文记录**为什么这样设计**,尤其是那些一旦写错就很难回头的决策。
产品设计文档(v0.2)里的约束在这里落成了实现,逐条对应。
---
## 1. 数据边界
| 位置 | 内容 | 读写 |
| --- | --- | --- |
| `BOOKS_DIR` (`/books`) | 用户自己的电子书 | **只读** |
| `DATA_DIR` (`/data`) | SQLite、封面缓存、令牌密钥 | 读写 |
| 客户端本地 | 离线书文件缓存、本地进度 | 读写 |
服务端是唯一真相源。客户端只承担「渲染 + 缓存」。
### 只读挂载的强制手段
`src/config/index.ts` 在启动时断言 `DATA_DIR` 不在 `BOOKS_DIR` 内,否则拒绝启动。
这不是文档里的君子协定,是运行时会拦下来的硬约束。
扫描器与所有文件访问都经过 `src/lib/paths.ts` 的 `resolveInside()`——
唯一的路径解析出口,`../` 逃逸会抛 `PATH_TRAVERSAL`。
扫描时也**不跟随符号链接**:指向挂载点外的软链会读到用户没打算共享的文件。
封面缓存写在 `DATA_DIR/covers/`,不写在书旁边。
---
## 2. 书籍主键
```ts
id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
```
### 为什么不用文件路径
用户的真实行为是**持续整理书库**:改名、分目录、加前缀。
| 场景 | 路径做主键 | 当前设计 |
| --- | --- | --- |
| 重命名 `三体.epub` → `刘慈欣 - 三体 (修订版).epub` | 进度全丢 | 进度保留 |
| 移动到另一个目录 | 进度全丢 | 进度保留 |
| 同一本书放两份 | 书架出现两条 | 合并成一条 |
| 重新编码的同名文件 | 误认为同一本 | 视为不同副本 |
第二列是自建书库产品最常见的差评来源,所以这条必须在上线前定死。
### 为什么哈希也要参与
只用 `dc:identifier` 不行:很多自制/盗版 EPUB 没有 identifier,或者多本书共用
一个占位 identifier。只用哈希也不行:同一本书不同来源的副本哈希不同,无法合并。
两者结合:identifier 提供稳定的锚点,哈希保证「字节不同就是不同的副本」。
`identifier` 缺失时退化为纯哈希,行为仍然正确(只是无法跨副本合并)。
### 文件表与书表的分离
`book_files` 记录磁盘上的文件(`rel_path`、`size`、`mtime`),`books` 记录书。
多对一:一本书可以有多份副本。
文件被删时 `missing=1`,不立即删记录——用户可能只是临时拔了移动硬盘。
但一旦这本书已经有了别的可用文件,旧的行就是纯噪声,会被清掉,
否则反复整理书库会让文件表无限增长。
只有「没有任何可用文件」且「用户没手动加进书架」的书才会被删除。
宁可显示一本暂时不可用的书,也不能删掉读者精心整理过的书架条目。
---
## 3. 增量扫描
两阶段变更检测(`Scanner.indexFile`):
1. **`mtime + size`**:便宜,跳过绝大多数文件
2. **内容哈希**:权威,确认字节真的变了
只有第一阶段判断"可能变了"才读文件算哈希。所以每 60 秒轮询一次也很轻。
`Scanner.scan()` 对并发调用做去重(共享同一个 Promise),
避免定时器和手动触发同时砸磁盘。
---
## 4. 元数据优先级链
```
1. 手动修改(metadata_overrides 表)
2. EPUB 内嵌(dc:* + calibre meta)
3. provider 补丁(只填空缺)
4. 文件名解析
```
### 关键实现细节
`books` 表存的是第 2/3/4 层的结果。第 1 层单独一张表,**读取时**叠加
(`ShelfService.toDto` → `applyOverrides`)。
这样做的原因:扫描器可以放心地刷新 `books` 表,因为手动修改根本不在那张表里,
不可能被覆盖。
`books.source` 记录的是**基础值的来源**(`embedded` / `filename` / `provider:x`),
不记录 `manual`。手动修改过的字段由 DTO 的 `manualFields` 单独返回。
保持来源干净,撤销单个字段时才能正确回落到原始值。
### 文件名解析的保守策略
中文电子书里 `书名 - 作者` 和 `作者 - 书名` 都极常见,没有标记时**无法区分**。
因此只在有证据时才切分:
- `《书名》` → 这一侧是书名
- `著` / `编` / `译` → 这一侧是作者
- 一侧是中文人名(2-4 字且首字为常见姓氏)→ 另一侧是书名
- 一侧是拉丁文人名 → 另一侧是书名
- **都不满足 → 整串保留为书名,作者留空**
最后一条是刻意的:漏一个作者,读者手动补一下就好;猜错一个作者,
书就被归到错误的人名下,而且他未必会发现。
其余正则也踩过坑,值得记下来:
- 系列正则 `[【\[]...[】\]]` 里 `\[` 在字符类中不转义,会把分隔符 `' - '` 吃掉,
导致 `金庸 - 射雕英雄传` 变成 `title=金庸, author=射雕英雄传`。改成显式括号对匹配。
- 「(第1卷)」这种卷标,书名是作品本身,不是「第」。要先剥离再切分作者,
且剥离要按完整括号组做,否则会留下 `射雕英雄传(第1卷` 这样的半截括号。
---
## 5. 同步与冲突合并
### 为什么是 last-writer-wins
一台设备属于一个读者。多设备之间不存在"编辑冲突",只存在"某台设备落后了"。
所以 LWW 是正确语义,不需要 CRDT 或向量时钟的复杂度。
### 墓碑
笔记删除写 `deleted=1` 而不是 `DELETE`。
否则:手机离线时删了一条笔记 → 平板(也离线,数据更旧)重连 → 把笔记推回来 →
已删除的笔记复活。墓碑让这个时序变得无害。
### 批次原子性
`SyncService.push` 整个批在**一个事务**里。断网不会留下写了一半的进度。
### 增量拉取
`GET /api/v1/sync?since=<上次的 serverTime>`。客户端记住上次返回的 `serverTime`,
每次只拿增量。移动网络下重连很便宜。
### 一次写入只发一个请求
`POST /api/v1/sync` 的响应**就是合并后的状态**(服务端在两个接口上返回同一份
`pull`)。所以推完之后**不要再拉一次**——`SyncEngine` 直接拿 push 的返回值喂给
`applyPulled`,没有东西可推(空闲轮询)时才真的发 `GET`。
之前不是这样:push 的返回值被丢掉,然后为了同一份数据再发一个 `GET`。于是读者每
翻一页都发一个 POST **加** 一个 GET,网络面板里就是成对出现的 `sync` ——这正是
「sync 接口在不停重复」的来源。
### 状态只在变化时广播
`setState` 先比对新旧 state/message,相同就直接返回。阅读页的状态监听会重绘整棵
chrome 树,而一个同步循环本来会通知三次(进门 `syncing`、出门 `idle`、再加一次
多余的 `emit()`),30 秒轮询让这件事在读者什么都不做时也持续发生。
---
## 6. 认证与安全
- **scrypt** 做口令哈希(内置模块,无原生依赖)。比较用 `timingSafeEqual`。
- 登录时即便用户不存在也跑一次哈希比较,避免用时间差枚举用户名。
- 访问令牌是 HS256 签名的无状态令牌;刷新令牌是不透明随机串存库,可按设备撤销。
- 改口令 / 停用账号会撤销该用户**所有**会话。
- 每个请求都从库里重读用户,不信任令牌里的 role——停用账号立即失效。
- 刷新令牌**轮转**:用过即废,降低泄露窗口。
### 不要重新引入原生依赖
镜像构建的稳定性直接决定部署成功率,而部署门槛是最大的获客阻力。
`better-sqlite3`、`argon2`、`bcrypt` 都需要构建时的 `make`/`gcc`,
在部分 NAS 环境会直接装不上。
`node:sqlite` 和 `node:crypto` 的 `scrypt` 都是内置的,所以当前依赖树里
**没有任何需要编译的包**。
---
## 7. 向后兼容承诺
自部署用户不会及时升级。所以:
- API 只做加法:加字段、加端点。不改已有字段语义、不删端点。
- 数据库 schema 用 `CREATE TABLE IF NOT EXISTS`,升级时新增列/表,不动旧的。
- 错误响应格式全站统一,客户端可以一直用同一套解析逻辑。
---
## 8. 客户端渲染
> 界面层的外观规范(图标、设计令牌、焦点、状态表达、文案)在
> [`ui.md`](ui.md)。§8 这一章讲的是**渲染**:分页、注入、量测、以及哪些界面
> 该用组件描述、哪些必须保持命令式。两者的边界是刻意分开的——排版层对书不动手
> (见 §8 与 §9),界面层的外观则必须统一。
已落地。产品设计里的关键决策:**不要直接套 Readium Mobile**。
Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品
「忠实还原精排书设计」的核心差异化直接冲突。
实际方案:
- 原生外壳(Kotlin)只负责:文件与缓存、手势、账号、连通性
- 渲染层是 **WebView + 自研 CSS 干预策略**,保留作者样式、只做最小覆盖
- 参考 readium-css 的变量组织方式,但**不引入它的默认接管行为**
### UI 框架选型:Preact,而且只用在「没有布局要量测」的那一半
界面原来是纯命令式 DOM:每个屏幕在构造函数里建好全部节点、把它们存成实例字段,
再由若干 `render*()` 方法在状态变化时挑选该改哪个。它有两个具体的坏味道:
1. **同一份状态有多个副本。** 分段控件的选中态既在 `settings` 里也在
`aria-pressed` 上,两者靠点击处理器手工同步;`LoginScreen` 的通知、错误、
注册开关、按钮文案是四个节点被五个地方改。改一处漏一处不会报错。
2. **重建与状态的矛盾。** 朗读引擎行必须在 HTTP 能力探测回来时重新生成,
而整块面板重建会丢掉读者在面板里的滚动位置,于是长出一套
「只替换 `[data-speech=group]`」的定点重建。
选型的硬约束有三个,都来自「一份产物两个宿主」:
- **单文件 bundle、`base: './'`**:WebView 从 `https://appassets...` 加载,
不能按需拉分片,所以框架必须能被 tree-shake 进一个文件。
- **体积**:这是要打进 APK、也要由 NAS 上的 Node 托管的资源。
- **不认识的东西不能碰布局**:阅读舞台的分页依赖 `getBoundingClientRect()`,
虚拟 DOM 不能在它和布局之间。
对比过的四个:
| 方案 | gzip 净成本 | 结论 |
| --- | --- | --- |
| Preact + `preact/hooks` | 最小 | **选用**(无 `preact/compat`、无 `react-dom`,仅这两个模块) |
| React + react-dom | ~45KB | 一个阅读器不该为 `react-dom` 付这个价钱 |
| Vue 3 runtime | ~34KB | 需要 SFC 预编译或运行时模板编译,产物形状与单文件 bundle 相冲 |
| 纯 Web Components / lit | ~5KB / ~7KB | 手写 `render()` 等于自建一个更窄的框架;lit 又把模板塞进字符串,失去类型检查 |
**边界写在 `ReaderScreen` 里,而不是靠自觉**:舞台(`stage`)由类持有、
交给 `ReaderView`,并通过一个 ref 回调挂进组件树;组件只决定它**放在哪**
(状态行与页脚之间,stacking context 来自这里),内容一概不碰。于是:
- 排版、分页、shadow root、手势、量测:**零框架**。
- 顶栏、页脚进度、目录、设置面板、朗读条、状态行、书架、登录、书库管理:
`ChromeState` 的函数。
副作用也是可见的:`settingsPanel` 那套「只重建朗读行」的定点更新没有了,
探测答案回来时只是改状态,Preact 的 diff 让读者的滚动位置天然保住。
组件只用了 `preact` 与 `preact/hooks`(`useState` / `useRef` / `useEffect`),
没有引入 `@preact/signals` 之类的额外状态层——屏幕自己就是状态的持有者,
`update()` 把整份状态交给树。少一个依赖,就少一处「值在哪」的歧义。
### 路由:URL 就是屏幕
界面的第二个结构性问题不是「怎么画」,而是「在哪」。最初的 shell 用
`showShelf()` / `openBook()` / `showManager()` 加一个 `pendingBook` 字段来换屏,
URL 从头到尾不变。手机上这一点几乎看不见——直到读者按返回键:Android 的返回手势
交给 Activity,Activity 看到 WebView 没有历史,于是**直接从书架上退出应用**。
浏览器上则更糟,地址栏是自托管书库唯一的分发方式,而它既不能分享也不能收藏:
一本书链接不出去,刷新永远回到书架。
所以路由是**值**,不是调用:`ui/router.ts` 把 fragment 解析成
`{ name: 'shelf' } | { name: 'library', path } | { name: 'book', bookId }`,
shell 渲染 router 报上来的那个 route,导航就是改 URL、让变化回流。
**为什么用 fragment 而不是 `history.pushState` 的路径。** 同一份产物要被两个宿主加载,
而两个宿主都服务不了任意路径:Android 从 `https://appassets.androidplatform.net/`
加载(那里没有服务端,除文档本身以外的路径全是 404),H5 由 reader 服务端托管,
而它没有「客户端路由回落到 `index.html`」这条规则。路径式路由会在 `vite dev` 里一切正常,
在两个生产宿主里 404。fragment 是 URL 里唯一保证属于客户端的一段,所以它是唯一
三处都成立的方案——代价是一个 `#`。
**返回走应用自己的轨迹,不是 `history.back()`。** 应用里的「返回」意思是「上一个屏幕」,
而浏览器历史里可能有登录表单、`vite dev` 的页面、甚至别的站点。`Router` 自己维护一条
屏幕轨迹,`back()` 从它上面弹;轨迹为空时(新标签页里粘贴的链接)就**替换**成父级路由,
而不是压入——否则那个粘贴的链接会永远压在书架上,返回键会一次次把读者送回书里。
**进入文件夹是 `replace`,不是 `push`。** 书库是一个带面包屑的屏幕,走进子目录是同一个
屏幕改自己的参数,所以它覆写当前条目而不是新增。压栈的话,读者走过四个文件夹再按返回,
会一个一个退出来——而面包屑存在的意义正是为了不用这样做。
**登录不是路由。** 「登录页」是一个瞬时状态,给它一个 URL 等于给门一个门牌。真正需要
活下来的东西是读者**本来要去的地方**,所以它存在 `App.pending` 里:带着 `#/book/<id>`
进来而尚未登录时,这个 route 会等在登录页后面,登录完成后直接落到那本书上,
而不是落到一个他从未见过的书架。
各屏仍然是有 `element` 和 `dispose()` 的类——路由不认识框架,屏幕也不认识 hash。
两者之间唯一的交汇点是 `RouteLocation`,屏幕通过它的 `back()` 请求离开。
### `mountUI`:类与组件之间唯一的接缝
屏幕仍然是类,仍然有 `element` 和 `dispose()`——路由不需要知道框架,
`ReaderScreen` 这种以命令式为主的屏幕也不必假装自己不是。
`mountUI(container, tree, initial)` 返回 `{ element, update, unmount }`,
`update` 传入「整棵树应该是什么状态的函数」的那个值。测试保持原样:
断言仍然打在真实 DOM 上(`test/manager.test.ts` 一行没改逻辑,只改了 import 后缀)。
**unmount 之后的 update 会被丢弃,这一点是有意的。** 每个屏幕都是异步加载的,
每个屏幕也都可能在加载完成前被离开——读者点开一本书又马上按返回,深链在书架还在取数据
时就把它换掉了。`dispose()` 取消不掉一个已经在飞的 promise,所以那个延续会跑在一个
已经不在屏幕上的屏幕上并调用 `patch()`。没有这道闸,它会往一个刚刚被 Preact 清空的容器
里重新画**整棵树**:书架那条迟到的响应会把自己一整屏封面画进书库管理器里,而之后的
`render(null)` 也清不掉它们,因为在 Preact 看来它们从来不是自己的孩子。
所以一个标志位就顶得上每个屏幕各写一个取消令牌,而且没法在哪一个屏幕里漏掉。
### 只有一份渲染层
Android 不重写渲染,它加载的是同一份 `web/` 产物。这不是为了省事,
而是因为排版策略一旦有第二份实现就一定会分叉,而排版正是差异点所在。
两边共用的抽象在 `web/src/core/platform.ts`:传输、键值存储、二进制存储、连通性。
Android 只覆盖它真正能做得更好的部分——连通性和设备名——其余全部复用。
### 有一条退出通道:整页图与文档可以由原生画
「只有一份渲染层」是默认,不是教条。有一类内容在 WebView 里是不划算的:
**整页就是一整张图**。渲染一页漫画,WebView 要付:字节跨进渲染进程、在渲染进程里
解码成位图、跑一次样式与布局、再合成一次。对一章文字这些都是必要的;对
「把这张图画满屏幕」则全是白付的,在手机上就是一次翻页卡顿。
所以有一条明确划出的通道:固定版式的整页图由原生画。
规则写死在契约里,也是它能成立的原因:
- **只画整页图。** 漫画页、单张扫描图。任何带文字排版的内容留在 WebView,
因为忠于精排是差异点,而它只有一份实现。
- **全有或全无。** 要么解出位图并显示,要么返回 `false` 让 WebView 照常渲染。
没有中间态,也不会出现「该有图的地方是空白」。宿主拒绝、格式不支持、
payload 坏了、桥接抛异常,四种情况都落到同一条回退路径上。
- **不在 UI 线程解码,位图有上限。** 先读文件头拿真实尺寸,再按视口下采样分配。
一张 4000 万像素的扫描图如果先分配再缩放,就是把目标机型 OOM 掉。
契约横跨三种语言,所以谁也无法用类型系统保证它们一致,`android/scripts/check-shell.sh`
里加了静态校验:渲染模式的词表(`RenderMode`)必须覆盖壳里分支的那几个值,
原生侧的可解码媒体类型必须包含客户端会产出的那些。词表写错一边,
结果不是编译失败,而是**所有页都静默走慢路径**——所以这条检查不是锦上添花。
壳版本 2 引入这套接口。客户端把「壳版本 < 2」当作「没有原生渲染器」处理,
照常在 WebView 里画一切:为了一点性能就拒绝在旧 APK 上运行是错的交换。
### Android 用 https 资产源,不用 file://
把打包好的 Web 应用塞进 assets,最直觉的加载方式是 `file:///android_asset/...`。
这条路对**这个**产品是走不通的:从 `file://` 源发出的 `fetch()` 是不透明的跨源请求,
所有访问服务端的调用都会被拦,而通常的绕法是把 web security 关掉——
那就等于把 WebView 变成一扇敞开的门。
用 `WebViewAssetLoader` 从 `https://appassets.androidplatform.net/` 提供同样的文件,
拿到的是一个正常的安全源:请求按普通 CORS 走,正好和服务端的 CORS 策略对上;
`isSecureContext` 为真,IndexedDB 等 API 与浏览器里行为一致。
这一个选择就是「一份 bundle 两个宿主」能成立的前提。
### 精排 CSS:干预策略
按重要程度排序的三条原则,写在 `web/src/styles/reader.css` 里:
1. **不设任何属性,除非不设会坏。**
2. **每个可调项都是默认 `inherit` 的可继承自定义属性**——「关」的意思是「不动它」,
不是「用我们的默认值」。这一点很关键:给行距写死一个具体值,会把一本按章节
变行距的书压平。
3. **任何改变作者意图的行为都是用户主动开启,且持久化**,绝不是默认。
实际 override 只有七处,每处都在 CSS 注释里写了为什么:图片/表格/pre 在窄屏会撑破
布局、ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。
对比一下被删掉的做法:reset 样式表、统一的「阅读主题」、给 `p` 加边距。
它们都能让排版「看起来一致」,而一致性恰恰是这里要避免的东西。
### 书的样式隔离在 shadow root 里
书里一条 `p { color: red }` 不能改到书架,App 的 `.topbar` 也不能被书撞上。
iframe 同样能隔离,但每次换章要重建文档:滚动位置丢失、Android 上内存翻几倍。
shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做分页和取选中。
关键是**不往 shadow root 里注入任何 reset**。
但隔离是双向的:**文档里的选择器进不去 shadow root**。这不是风格问题,
它决定了两条规则各自该写在哪儿,搞错了不会报错、只会静默失效:
- `.book-flow`(正文列:版心宽度、页边距、字号缩放、分页列)、`.fixed-page`、
朗读高亮、翻页动画——这些元素在 shadow root **里**,所以规则必须注入到
shadow root **里**(见 `ui/shadow.ts` 的 `FLOW_STYLESHEET`)。
写在 `styles/reader.css` 里等于删除:正文列会以浏览器默认样式渲染,
没有版心、没有边距、翻页模式没有列。而外围 chrome 在文档里、样式完全正常,
所以看上去不像样式表丢了,只像「阅读器没有页边距这个功能」。
- `.book-host`、亮度过场(`::after` 伪元素)、`book-content` 的 `display`
——这些是 shadow **host** 本身,属于文档树,所以规则写在 `styles/reader.css`。
- host 上的状态属性(`data-paginated`、`data-animating`)只能用 `:host(...)`
选择:从 shadow root 内部无法用类选择器匹配 host。
写成 `.book-host[data-paginated] .book-flow` 永远不会匹配,
于是翻页模式静静地不产生任何列,翻页也就无处可翻。
两条规则里的**值**不重复:全部是自定义属性,而属性本身定义在 `:root` 上,
shadow root 会继承。
### 窗口化的三个接缝
「一份产物两个宿主」和「一千二百章的书不能先下载整本」是同一件事的两面,
而窗口是这件事的传输边界。跨过它有三个必须写死的地方:
- **换窗口和落到章节是一次操作。** 只换窗口会留下*上一章*在屏幕上:
宿主内容没动、`currentSectionIndex` 还指着旧窗口的下标、页脚还在报读者刚离开的章节。
这正是「点击章节没有反应」的症状。
- **窗口接口只有一个名字。** 客户端建的 `StagedDoc` 叫 `setWindow`,
`StagedBook` 接口叫 `loadWindow`,屏幕曾经用鸭子类型去猜——猜错了,
于是每次跨窗口跳转都是静默的空操作。现在由 `adoptWindow()` 一处识别。
- **窗口由 manifest 的 `groups` 算出来,不是由「40 章」算出来。**
漫画按*卷*开窗,而卷不是 40 页。`spine / 40` 会请求一个不存在的组,
跳转就落到空处。
另外,位置必须按**整书坐标**判断,不能按窗口内的下标:
在窗口最后一章时下标是 39,拿它和窗口长度 40 比会得出「已经是最后一章」,
而后面还有八十章。窗口长度是传输细节,不该决定书在哪里结束。
### 阅读位置格式
### 书不能联网
加载时把章节里所有本地引用改写成内部 `reader-res:` 协议,渲染层从内存资源表解出;
绝对 URL 原样保留,然后被 CSP 挡掉。这样一本来路不明的书无法把阅读行为报到远端。
资源按需解出而不是预展开——不然打开一本带大图的 EPUB 会先把整个包塞进内存。
### 阅读位置格式
`r1:<偏移>:<章节 id>`。三点都是踩过之后定的:
- **版本前缀**:服务端把 locator 当不透明字符串长期保存,客户端将来改格式时必须
能认出「这不是我认识的格式」。`r1` 给了这个能力。
- **章节 id 放最后**:章节 id 是文件路径,会含冒号。只从前两个冒号切、剩下全保留,
是唯一不会丢数据的解析方式。
- **偏移是章节内的比例**,不是像素、不是 CSS 列号。列号会随字号变化,
于是读者一调字号,位置就跳到另一个句子上——阅读 App 能犯的最让人恼火的错误之一。
### 待发送队列,而不是「每次都全发」
离线队列记的是**脏集合**:哪些记录还没送到服务端,而不是「当前全部状态」。
两者在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
被修改了(读者在同步进行时翻了一页)。脏集合下,这次更新仍然是脏的,要等**它自己**
的确认才清除;「全发」模式下,它会被那个更早的响应顺手清掉,然后永远送不到服务端——
一个丢失的阅读位置,是这个产品最不能有的失败。
### 离线队列的抗崩
队列在本地镜像里,不在内存里。所以读到一半直接杀进程,位置不会丢——
下一次启动接着送。笔记删除同样写墓碑后再入队,避免离线设备让已删笔记复活。
### TXT 的编码与章节
本地书库里 TXT 占比很大,而它既没有结构也没有编码声明。
编码:先严格校验 UTF-8(自己实现,不用 `TextDecoder` 的宽松模式——宽松模式永远
「成功」,于是你永远发现不了自己解错了)。校验失败**不是**按顺序试
GB18030 再试 Big5,而是两种都解一遍再挑更干净的那个。原因值得写下来:
这两个编码的字节范围重叠得很厉害,同一串字节通常**两边都能解出合法字符**,
所以「先试 A 再试 B」永远不会走到 B,繁体 Big5 的书会以简体码表解出一整本乱码。
真正的证据在各自码表的空洞里——解出来变成私用区字符、替换字符、或者一片汉字里
冒出的假名。按这个打分选优,才能既救回繁体,也不牺牲占多数的简体。
结果里带 `guessed` 标记,界面上告诉用户选了哪个并允许手动覆盖。
章节:TXT 没有目录,只能从标题推断。策略是**宁可少认,不要错认**——
一个 800 字的段落以「第三章」开头时不能当标题,而正文里提到「他在第二章读过」
更不能当标题。两条都有测试固定住。推断不出章节时按长度分段:
一本五千章的网文如果变成一整节,分页会直接卡死。
### 漫画的页序
按自然序排(`1, 2, 10` 而不是 `1, 10, 2`)。排错不是观感问题,是故事被静默打乱。
文件名无信息量时用页序当标题,因为 `a1b2c3.png` 对读者没有意义。
### 目录书:什么算一页,谁说了算
一个目录变成一本书之后,「这本书有哪些文件」不能由 API 层临时拼:manifest 告诉客户端
哪些路径构成这本书,`/books/:id/file` 就服务哪些路径。两边必须是**同一份清单**。
这一条是被一个真实缺陷定出来的。此前 API 层自己拼路径,用的是 `items[].title`——
对散图恰好等于文件名,对**压缩包里的页**则是包内条目名(`001.png`),那不是一个库内路径。
于是这样一个卷的每一页都 404,漫画打开就是空白,而且服务端日志里只有 404,看不出原因。
现在清单由页面遍历给出,并且**一个压缩包算一页**:它的路径是 `第01卷.cbz`,
是客户端真能拿来发请求的东西;包内页码由 `/assets?ref=page:卷:页` 寻址,不是路径,
也就不该出现在 `files` 里。清单是契约,不是清单式的描述——多一条会被拒,少一条会 404。
顺带一个同类缺陷:`第01卷.cbz` 与 `第02卷/` 并列时,整卷系列在书架上会**整体消失**。
`.cbz` 本身注册为一种书,于是这个目录被判成「书架」而不再交给漫画目录处理器;
卷子目录里只有图片、不构成书,所以也没有留下任何条目。两边都不认,书就不见了。
判据因此改成按**上下文**问:一个 zip 在书架语境里是书,在漫画目录候选里是页材料。
同一个布局还有两处更细的坑,都会让整卷系列消失或读错页,值得一并记下:
- **「卷」的判据不能在最后加一句 `images === 0`。** 整卷系列里放一张封面扫描是常态,
而 `images === 0` 会把这种目录判成「不是漫画」——它就是上面那个消失缺陷的另一条路径。
判据改成逐个来源问:卷压缩包的页就在包里,**一个压缩包本身就是一卷**;卷子目录要往下看
才知道有没有页。一张散图既不能否定同级存在的整卷,也不该在只有它自己的时候被当成书
(那条由 `MIN_IMAGES` 管)。
- **页的引用是 `page:<卷>:<页>`,不是 `page:<下标>`。** manifest 与 `/file` 都从同一次
页面遍历取引用(`files[].ref`),而不是让 API 层从路径或下标反推。「一个压缩包算一页」
意味着它在列表里的下标和它在卷里的位置无关:按 `page:1` 去问一卷,格式处理器解不出这个
引用,异常回退到「当普通文件发」的分支,于是客户端请求一页、拿到的是那个压缩包——
状态码还是 200,只有翻页时才会发现内容不对。
### 一页一个 section
漫画和 PDF 是 `layout: 'fixed'`,每页一个 section,由分页器整屏推进。
固定布局的宿主不做滚动容器——在横向分页器里嵌一个滚动区会产生
Android 上经典的「页面自己往边上飘」bug。
---
## 9. 界面设置与朗读
### 为什么设置不跟着账号同步
同一份「设置」里其实住着两类东西:
| 类别 | 例子 | 真相源 |
| --- | --- | --- |
| 屏幕与环境的属性 | 字号、主题、亮度、页边距 | 本机 |
| 读者的属性 | 阅读位置、笔记、高亮 | 账号 |
把第一类推到服务端,结果就是把手机上的字号搬到平板上打开——这是所有人都讨厌的行为。
所以 `SettingsStore` 存在设备本地(`reader.settings.v1`),同步层完全不知道它存在。
### 新增的每一个排版开关,默认值都是「不动」
这条不是风格问题,是 §8 那个承诺的直接推论。「忠于精排」在实现上只有一种写法:
**除非不设就会坏,否则不设**。
所以行距的默认值是字符串 `inherit`,字体和文本对齐同样;它们经 CSS 自定义属性
继承进 shadow root,书里写死的值自然胜出。字号改的是容器的基准字号,不是每段的大小,
所以 `font-size: 14px` 的段落保持原样,`1.2em` 的段落跟着滑块走。
页边距是唯一一个「用户值必然生效」的排版项。它不是给书的,是给**行宽**的:
一行 40 个汉字在手机上没法读,而约束行宽不能靠改 `p` 的 margin——那正是要避免的编辑。
改成给容器加内边距,作者的样式一条都不用动。
亮度做成了 `mix-blend-mode: multiply` 的覆盖层而不是调色板:换主题应该保留
纸与字的对比关系,直接改颜色会把文字一起压暗,那正是「亮度」滑杆最常见的失败。
### 三个朗读引擎,一个队列
朗读有三条来源,而客户端的其余部分不需要知道正在用哪一条:
| 引擎 | 声音来自 | 什么时候用 |
| --- | --- | --- |
| `native` | Android 外壳的 `TextToSpeech` | 有 `speech` 接口的外壳(版本 ≥ 3) |
| `system` | WebView / 浏览器的 `speechSynthesis` | 默认。免费、离线、无往返 |
| `http` | 服务端的 `TTS_URL`,经 `/api/v1/tts` 代理 | 前两者都不可用时 |
`auto` 的优先级是 native → system → http,理由不是「谁更好」而是**谁知道得更多**:
- native 排在 system 前面,尽管 WebView 自己也有合成器。因为那是**两个不同的引擎**:
手机系统装好的中文语音经常不在 WebView 的 `getVoices()` 列表里,
于是读者听到英文或听不到声音,而 Web 层拿到的列表本身就是缺的,修不了。
这是「朗读没有中文声音」最常见的原因,唯一的解法是直接问平台。
- http 排最后,因为它的每一句都要一次请求,而且它需要一个配了 `TTS_URL` 的服务端。
三者的**暂停语义真的不同**,所以它们不是同一个基类:
- `speechSynthesis` 能暂停,`TextToSpeech` 不能(只能 stop),`<audio>` 能暂停但要重播整句。
- 改语速:`<audio>` 直接改 `playbackRate`(不用重读),另两个必须重读当前句。
- 音调:`<audio>` 根本没有,所以设置面板在 HTTP 引擎下**不显示这一行**,
而不是显示一个按了没反应的滑块。
派生的差异一并被显式处理:HTTP 引擎的单句上限是 800 字,而一段没有标点的中文段落
可能有两千字——切分发生在 `rememberSpeechChunks()` 里,只对引擎生效,
高亮仍然锚定在原来的整句上(碎片带着原句的 `start` 偏移)。
### Android 的原生朗读接口为什么被拆成第二个对象
`addJavascriptInterface` 只能注册**对象**,不能注册某个对象上的属性,
所以 `window.ReaderAndroid.speech` 实际上是 `window.ReaderAndroidSpeech` 这个独立注册的接口,
由 `promoteSpeechInterface()` 在 Web 层重新挂回第一个对象上。
契约(`android-bridge.d.ts`)对外只有一个嵌套对象,接线方式不出现在这一层之上。
和原生整页渲染一样,它按**外壳版本**判断而不是按方法存在与否判断:
版本低于 3 的外壳就是没有这个接口,客户端退回 `speechSynthesis`,
而不是到播放时才发现方法不存在。
### 朗读为什么按句排队
`speechSynthesis` 本身只有一行 API,难的是决定**下一句说什么**。按句而不是按段:
- 高亮能跟着声音走。`boundary` 事件(词级位置)在 Android WebView 上不存在,
在桌面 Chrome 上也不可靠,所以依赖它做高亮等于在目标平台上不做高亮。
- 一章拆成几十句,语速、音调、语音的修改只需重读**当前句**;按段修改则要重读整段。
- 章节切换发生在「一句读完」的回调里,是引擎唯一明确告诉我们「这句话说完了」
的时刻——除此之外的推进都可能把两句同时说出去。
切句规则刻意保守:`.` 后面不是空白就不算句末,`Mr.` / `Dr.` / `3.14` / `e.g.`
全部被这一条挡住。代价是 `end.Next`(句号后不加空格)只剩一句,这比把 `Mr.`
和它的名字切开要小得多。没有标点的长段落按 300 字硬切——Android 的合成器
遇到两千字的单条 utterance 会直接沉默。
### 高亮用 Range 画,不改书的 DOM
把正在朗读的句子包进 `<span>` 是最直觉的做法,也是最糟的:它改写了出版方的标记,
会让作者针对 `p > span` 的样式失效,并且因为行盒度量变化而**重新分页**——
读者一按朗读,页面就跳。所以高亮是 flow 里的一个绝对定位覆盖层,
位置来自 `Range.getBoundingClientRect()`,文字本身一个字节都没动。
代价是高亮必须自己跟随滚动、自己处理换行(一个跨行的句子只有一个矩形),
这比包 `<span>` 麻烦,但换来的是「按朗读不会动版」。
### 朗读队列与章节的边界
引擎只知道一件事:给我一批句子,说我读到哪里了。章节的事由屏幕层负责——
队列耗尽时引擎再问一次,屏幕层决定是「取下一章」还是「到此为止」。
这个分工让「读到章末停下」成为设置项而不是引擎里的分支。
队列的推进由事件驱动,绝不靠计时器:`speechSynthesis` 的 `onend`、`<audio>` 的 `ended`、
原生外壳的 `done`。计时器会漂移,而一次漂移就意味着两句同时被说出来或一句被吞掉。
每个引擎还各有一个看门狗,只在**引擎自己彻底安静了**(长时间熄屏后的
Android WebView 是真实会发生的)才推进,不是为了给朗读计时。
原生引擎的事件带一个**客户端自己生成的 utterance id**,外壳原样回传。
没有它,就分不清「我正在读的这句读完了」和「读者已经跳过的那句读完了」——
后者会让光标多走一格,也就是跳一句。
屏幕层还有一个必须走的公共路径:所有翻页都经过 `turnPage()`。原因是引擎锚定在
**当前章节的 DOM 节点**上,绕过这个方法的翻页会让引擎对着一个已经脱离文档的节点
继续朗读——听起来就像客户端在自言自语。
### 分窗是传输边界,目录是目录
`/manifest` 与 `/items` 都接受 `?group=N`,一次一个窗口(epub 40 章、漫画一卷)。
省略 `group` 等价于 `group=0`——这个默认值是后来改的,值得记一笔:最初省略参数
意味着「发全部条目」,于是一本 1200 章的书在它**最需要快的那个请求**(打开书)里
收到 1200 条记录。分窗如果只对显式传参的客户端生效,等于没做。
目录则相反:`/toc` 必须完整。两者要的性质相反,合成一个接口必然做错一个——
第一版就是这么做的,结果是目录面板显示「第 1 章 – 第 40 章」,服务端的分页边界
漏进了 UI。
### 章节的读取代价
一次章节读取 = 一次中央目录读(打开时)+ 一次条目的局部头 + 该条目的数据。
这是 `zip-reader.ts` 只解析中央目录、`openStream` 按偏移读单个条目的原因;
整包 `loadAsync` 会让「翻到下一章」变成「重新解压整本书」。
客户端的对等物是 `formats/remote-zip.ts`:同样的中央目录解析,走 HTTP Range。
它存在的意义是让**浏览器端**也能不下整本书——否则「服务端分章」只对原生端成立。
(注意:`check-shell.sh` 里新增的渲染模式词表校验,以及 `colors.xml` 里
`--` 在 XML 注释中非法、Kotlin 侧 `Uint8Array` 不存在这两处,
都是**第一次真正编译 Android 壳**时才暴露的。写不能编译的代码等于没写。)