Files
reader/docs/architecture.md
T
cnb.dQC5J6ohAGA 0370cd98e8 feat: HTTP TTS 与双端兼容朗读,书架体验与自定义设置
朗读(Closes #13 的朗读部分)

新增服务端 HTTP TTS 代理与客户端第二、第三个朗读引擎,三者在同一个
SpeechEngine 接口下可切换:

- 服务端 GET /api/v1/tts 代理 TTS_URL 指向的合成服务(edge-tts / Piper
  等兼容形态),只代理不内置合成模型;GET /api/v1/tts/voices 回答
  「这个实例能用什么」,未配置时返回 http:false 而不是 404。
  按句合成、磁盘缓存、上游返回非 audio/* 时明确报错(一个指到网页的
  TTS_URL 会以 200 返回 HTML,塞进 audio 里既没声音也没事件)。
  /tts 是精确的查询串令牌白名单项之一:<audio src> 无法带 Authorization。
- 客户端 HttpTtsEngine 用 <audio> 而不是 Web Audio,事件驱动推进队列。
- 客户端 NativeTtsEngine 走 Android 外壳新增的 speech 接口(shell v3):
  WebView 自带的合成器与系统的是两个引擎,手机系统装好的中文语音经常
  不在 getVoices() 里,这是「朗读没有中文声音」最常见的原因。
- speechAvailability 决定 auto 用哪个引擎:native → system → http。
- 原 TtsEngine 抽出 loadQueue/onChunk/onState 为公开字段以结构化满足接口。

书架

- 排序:最近更新 / 最近入库 / 书名 / 作者,中文按数字大小排。
  排序规则抽到 shelf-order.ts,离线路径与服务端用同一条规则。
- 密度、作者显隐、封面进度条;搜索一键清空 + 回车即搜;下拉刷新。
- 服务端 BookDto 增加 addedAt,否则客户端离线排序无法复现服务端结果。

自定义设置

- 朗读引擎可选(引擎多于一个时才显示该行);音调行只对有声调的引擎出现。
- 书架设置独立成 sheet,与阅读面板分开。

测试与文档

- server: tts.test.ts(10 例)。web: tts-http / speech / shelf-order
  新增 39 例,合计 245 passing。
- android/scripts/check-shell.sh 修正为同时校验嵌套的 speech 接口,
  之前它只能看到一个顶层接口。
- README / docs/api.md / docs/architecture.md 补齐三个引擎的取舍、
  环境变量与错误码。

Ref: #13
2026-09-16 23:29:41 +08:00

27 KiB
Raw Blame History

架构与数据边界

本文记录为什么这样设计,尤其是那些一旦写错就很难回头的决策。 产品设计文档(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. 书籍主键

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, 每次只拿增量。移动网络下重连很便宜。


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. 客户端渲染

已落地。产品设计里的关键决策:不要直接套 Readium Mobile。 Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品 「忠实还原精排书设计」的核心差异化直接冲突。

实际方案:

  • 原生外壳(Kotlin)只负责:文件与缓存、手势、账号、连通性
  • 渲染层是 WebView + 自研 CSS 干预策略,保留作者样式、只做最小覆盖
  • 参考 readium-css 的变量组织方式,但不引入它的默认接管行为

只有一份渲染层

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。

书不能联网

加载时把章节里所有本地引用改写成内部 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 壳时才暴露的。写不能编译的代码等于没写。)