Commit Graph
7 Commits
Author SHA1 Message Date
hectorqin 7bff77eaf8 feat(reader): deliver P0-P2 reading tools and ecosystem integrations
Add reliable reading progress, verified backup and restore, reading tools, offline downloads, source quality checks, personal credential status, OPDS serving and verified WebDAV backup uploads.
2026-09-24 07:47:32 +08:00
hectorqin a6fddab953 feat(reader): 接入插件章节阅读与目录刷新
接入远程章节出版物、稳定定位、服务端与设备端缓存及手动刷新;补充账号隔离、同步并发保护、API 和设计文档。
2026-09-20 18:40:09 +08:00
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
hectorqin cdc95d8d27 fix(web,server): 阅读页可读性与面板、翻页、同步频率、事件层级
用户报的八条里,这一提交覆盖了除图标对齐之外的七条。每一条都先做成能复现的
测试或量出来的数字,再改。

## 2. txt 没有处理就渲染,没有默认样式,也无法调整

三层都缺,所以修了三层。

服务端新增 `chapter-html:<n>`,把纯文本渲染成带 p 的片段
(`indexer/formats/text-html.ts`)。空行断段;「上一行以句末标点结尾、下一行以
句子开头」也断段——抓取来的网文常常每行一段且没有空行,少了这条整本书是一块。
章节标题行提升成 h3,否则正文会变成「第二章 落雨雨来了。」。引用仍是
`chapter:<n>`(它同时是阅读位置的标识,改它会把现有读者打回第一章),新增
`format` 字段说明取哪一份;`chapter:` 的 256KB 截断也不再作用于这份渲染结果——
那个截断会把长章节切一半。

客户端新增 `TXT_STYLESHEET`,只在 `format === 'txt'` 时注入 shadow root,作用域
`.txt-body`,所以 EPUB 作者自己的样式一点不受影响。默认两字符首行缩进,用
`2em` 而不是 `2rem`:缩进要跟字号缩放。

阅读设置新增「正文」一节(段落缩进 / 段间距 / 编码),只在 TXT 上出现,前两项
是写在 stage 上的自定义属性,所以调整不需要重新注入章节。

## 3. 翻页异常,往前翻页还会来回跳

`previous()` 用 `scrollTop > 8` 判断「还有没有地方可退」,`next()` 用
`scrollTop < limit - 8` 判断「还有没有地方可进」,然后两边都以 0.9 屏步进。
这三个数字互不相容:forward 落到的位置,back 不认为那是边界。读者看到的就是
来回跳。

改成按**页序号**算术:整屏步进,页边界由滚动**范围**(`scrollHeight -
clientHeight`)而不是内容高度推出。这一个区别是页数和可翻次数对不上的原因——
2.31 屏的一章,按内容高度算 3 页并请求 offset 0/723/1446/2169,而 2169 会被
夹到 1674,也就是第 2 个边界,于是第四次按下的位置计数器不认。

`stepSection` 改成 await:它在跨章时是 fire-and-forget,而 `turnPage` 在
`previous()` 返回后就播放动画——动画演的是读者刚离开的那一章。

## 4. sync 接口会不停的重复渲染,sync 接口调用太过频繁

两处。`onPosition` 每个位置调三次 `patch()`,而 `patch` 会重绘整棵 chrome
树,滚动时每帧三次;合并成一次。

`flushProgress` 无条件 `syncNow()`,于是 1.5 秒一轮 POST+GET,读者只要在读就
一直发。`SyncEngine.schedule()` 去抖并**保证尾次**——没有尾次的话,读者翻完
最后一页就关书,那一页永远发不出去。

## 5/6. 布局样式,以及目录/设置改半屏

面板从 `inset: 0` 改成底部半屏 + 遮罩 + 把手,上半屏正文仍然看得见:选章节、
调字号都是对下面那一页的调整,全屏让读者为了做调整而离开那一页。

状态行从 flex 列里的一行改成浮在正文上的药丸。横条每出现一次就把正文推下去
一行再收回来,也就是把读者正在读的那句挪走。`ui:review` 有一条断言盯着它。

页脚改成两行不同职责:控制行居中、读数是三个固定位置的等宽数字。目录行改成
「序号 + 标题」,当前章高亮一次而不是每行都划线;当前窗口到不了的行标记出来
而不是静静地没反应。

## 7. 点击页面的事件处理层次

一次触摸只属于一个目标,最里面那个赢。两个真缺陷:

- 必须用 `composedPath()` 而不是 `event.target`。书的正文在 shadow root 里,
  shadow 边界会**重定向**事件,所以 target 是 shadow host——用 target 写不出
  「段落」和「同一个 shadow 树里的按钮」的区别。
- **阅读面自己是个滚动容器**。滚动模式就是让 shadow host 滚起来,于是「可滚动
  的祖先拿走手势」这条规则让它吃掉了每一个手势:翻页层级完全正确,但永远
  到不了。这是「点击页面没反应」的真正原因。

另外,按到控件的触摸不再被吞掉 click——之前工具栏按钮可见、可点、没反应。

面板打开时它拥有点击:旁边露出的那一条正文不翻页。

## 8. 端到端测试要做 UI 评审

新增 `web/tools/ui-review/`:真实 Chromium(390×844、2x)加载**生产产物**,
对着替身 API 逐屏截图并测量。截图给人看(「只是丑」不是测试失败,但是缺陷),
尺寸给流水线断言(面板不遮住整页、留出 ≥120px 正文、有遮罩和把手、状态提示
不推动正文、控件 ≥28px 且有名字、没有横向溢出)。接进 `client-ui-review`
流水线,只在界面相关文件变化时触发。

这套工具第一次跑就发现了四个真缺陷:`api.toc()` 返回形状不符时 `openStaged`
用 TypeError 打死整个 open(现在回退到 manifest 自己的条目);状态行一旦进入
loading 就再也清不掉(`hideStatus` 的守卫挡住了唯一的出口);翻页步进的 0.9 屏;
以及上面那条阅读面吃掉手势。

## 测试

web 351 项、server 206 项全过。新增:`gestures.test.ts`(事件层级优先级)、
`reader-paging.test.ts`(页序号算术,含子像素取整与滚动范围)、
`mount.test.ts`(单次渲染与卸载后丢弃更新)、`text-html.test.ts`(分段、
转义、标题提升、不截断)。
2026-09-18 12:39:45 +08:00
cnb.dQC5J6ohAGA b7ffa42c80 feat(library): 支持上传书籍与批量管理 2026-09-17 16:50:47 +08:00
cnb.dQC5J6ohAGA c18664f1e3 feat(reader): 按窗口读取、真机可编译的 Android 壳,以及分章契约的收口
两件事:把 PR #8 里那四项合并进来(A 选项),然后把 issue 里一直没落地的
「一次只取一章」从服务端契约一路做到客户端。

## 一、PR #8 的四项(Android 外壳最值钱)

- Android 原生整页图渲染:NativePageView + 桥接 v2(renderPage/hidePage)。
  只画整页图,EPUB 永远留在 WebView;画不了就返回 false 回退,不出现空白页。
- 离线队列:脏集合语义固定为契约,补上「请求在途时位置被改写」的三条用例。
- TXT 编码探测:GB18030 与 Big5 都解一遍再按损坏证据评分选优。
  原来「先 GB18030 失败再 Big5」永远走不到第二步——两者字节范围重叠,
  同一串字节通常两边都能解出合法字符,繁体书会整本乱码。
- 格式嗅探:magic number 优先于扩展名,并明确拒绝非书籍的媒体容器。

## 二、Android 壳第一次真的编译通过

上一轮说「没有 JDK/SDK,只写了契约」,这一轮补上了环境并真的编译。
编译立刻暴露三处——都是只有编译才能发现的:

- colors.xml 的注释里写了 --reader-paper。XML 禁止注释中出现双连字符,
  AAPT 直接拒绝整个文件。Android 壳此前从未构建过,所以从未发现。
- NativePageView.kt 用了 Uint8Array。那是 JavaScript 类型,Kotlin 里不存在;
  桥接的另一侧是 ByteArray。
- AndroidManifest.xml 引用了 @mipmap/ic_launcher,而资源里没有这个文件。

三条都让 :app:assembleDebug 失败,也就是说这个 APK 此前根本产不出来。
现在是 BUILD SUCCESSFUL,产物 3.6MB。

## 三、分窗读取:从契约做到客户端

服务端:
- /manifest 省略 group 现在等价于 group=0。原来省略意味着「发全部条目」,
  于是一本 1200 章的书在它最需要快的那个请求(打开书)里收到 1200 条记录,
  等于分窗对不传参的客户端完全失效。要完整结构用 ?group=all。
- /toc 在没有 OPF 标题映射时回落到章节自身的第一个标题。手作 EPUB 大量
  没有标题映射,原来整本目录会读成「第 1 章 … 第 300 章」,等于没有目录。
- 扫描时的 spine 读取不再解析 NCX 与 nav:扫一遍全库本该只读一次包文档。
- 尾部 Range(bytes=-N)在超过 8MB 时退化为整包响应并撤回 Accept-Ranges。
  前向流无法跳到文件末尾,服务这种请求意味着为三个字节读完整个 400MB 漫画包。

客户端:
- 新增 formats/remote-zip.ts:走 HTTP Range 的只读 ZIP,让浏览器端也能不下整本书。
- 新增 formats/windowed.ts:分窗文档。分窗是传输边界,目录必须完整,两者分开
  —— 合在一起就是原来目录面板显示「第 1 章 – 第 40 章」的原因。
- reader-screen 接上 /toc,并且点击目录项会先取对应窗口再跳。

## 验证

server:  133/133(typecheck + test + build)
web:     179 vitest + 29 node:test(typecheck + test + build)
android: BUILD SUCCESSFUL,app-debug.apk 3.6MB
shell:   契约双向校验 + 渲染模式词表校验通过
2026-09-16 19:51:38 +08:00
CodeBuddy 78bfe557c3 feat: 客户端 Android 与 H5 双端,含精排渲染层
按产品设计 §5「原生外壳 + WebView 渲染」落地客户端,Android 与浏览器共用同一份
渲染层;同时把服务端的格式范围从 epub + pdf 扩展到 txt 与漫画。

## 为什么两端共用一份渲染层

Android 端不重写渲染。它是一个 Kotlin 原生外壳,只负责文件缓存、手势、账号与书架,
渲染复用 web/ 构建出的同一份产物。原因很直接:排版策略一旦有第二份实现就一定会分叉,
而「忠于精排」正是这个产品的差异点。

共用的抽象在 web/src/core/platform.ts(传输、键值存储、二进制存储、连通性)。
外壳只覆盖 WebView 做不到或做不好的三件事:

- 连通性:WebView 里的 navigator.onLine 只要有网卡就报「在线」,连上没有出口的
  Wi-Fi 时会骗人,客户端于是转圈而不是读缓存。改用平台的 NET_CAPABILITY_VALIDATED。
- 稳定的设备名:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
- 原生提示:一行 Kotlin 的 Toast,不用在 Web 层造通知 UI。

跨语言的接缝(Kotlin 桥接 ↔ TypeScript 声明)没法互相类型检查,因此加了
android/scripts/check-shell.sh 做双向契约校验,并接进 CI。

## 精排 CSS 策略

这是整个项目最产品化的一块 CSS,所以规则是写明的而不是凑出来的。

三条原则:不设任何属性除非不设会坏;每个可调项都是默认 inherit 的可继承自定义属性
(「关」的意思是「不动它」,不是「用我们的默认值」);任何改变作者意图的行为都是
用户主动开启且持久化。

实际 override 只有七处,每处都在注释里写了为什么:图片/表格/pre 在窄屏会撑破布局、
ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。

被刻意删掉的做法:reset 样式表、统一的「阅读主题」、给 p 加边距。它们能让排版
「看起来一致」,而一致性恰恰是这里要避免的。

书的样式装进 shadow root。书里一条 p { color: red } 不能改到书架,App 的 .topbar
也不能被书撞上。用 iframe 也能隔离,但每次换章要重建文档,会丢滚动位置并在 Android
上多花几倍内存。关键是**不往 shadow root 里注入任何 reset**。

## 格式层

服务端只负责流式给出原始文件,拆包、编码、章节与分页都是渲染层的事——
这些恰好是产品要自己拿主意的部分。

- EPUB:OPF/spine/nav 解析,作者样式全部保留,相对引用改写成内部 reader-res: 协议,
  绝对 URL 原样保留交给 CSP 挡掉。加载器按文档逐个容错,一个坏章节不会毁掉整本书。
- TXT:先严格校验 UTF-8(自己实现,不用宽松模式——宽松模式永远「成功」,于是永远
  发现不了解错了),失败再依次试 GB18030 与 Big5,并把选择告诉用户、允许覆盖。
  章节只能从标题推断,策略是宁可少认不要错认:800 字的段落以「第三章」开头不算标题,
  正文里提到「他在第二章读过」也不算。两条都有测试固定。
- CBZ / 图片目录:自然序排页(1,2,10 而不是 1,10,2),一页一个 fixed-layout section。
  排错不是观感问题,是故事被静默打乱。
- 检测以魔数优先:本地库里 .txt 其实是 EPUB、.cbz 里其实是 PDF 的情况很多,
  按扩展名开错加载器只会得到一个看不懂的报错。

服务端扫描器相应扩展:txt/cbz 直接索引;图片目录按文件夹识别为一本书,
文件夹路径作为主键锚点,所以**往漫画文件夹加一页不会丢进度**——这条有测试。

## 客户端三态与离线队列

离线队列记的是**脏集合**(哪些记录还没送到服务端),而不是「当前全部状态」。
两者只在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
被修改了。脏集合下这次更新仍是脏的,要等它自己的确认才清除;「全发」模式下它会被
那个更早的响应顺手清掉,然后永远送不到服务端——一个丢失的阅读位置,是这个产品
最不能有的失败。

阅读位置格式 r1:<偏移>:<章节 id>。三点都是踩过之后定的:版本前缀(服务端把 locator
当不透明字符串长期保存,客户端将来改格式必须能认出「这不是我认识的格式」)、
章节 id 放最后(它是文件路径,会含冒号,只从前两个冒号切才不会丢数据)、
偏移是章节内的比例而非像素或列号(列号会随字号变化,于是读者一调字号位置就跳)。

TXT、漫画、PDF 的支持也一并接上,PDF 用浏览器自带阅读器保底可读。

## 服务端

- GET /books/:id/file?path=… :按页取图。path 必须是这本书已登记的 rel_path,
  服务端按表精确匹配,不用 query 拼路径,所以穿越只会匹配到空。
- CORS 白名单(CORS_ORIGINS,默认回显任意来源)+ H5 静态托管(WEB_DIR)。
  托管在同一容器里,这才保住「一条命令跑起来」。
- Android 用 WebViewAssetLoader 从 https://appassets.androidplatform.net/ 提供
  打包好的客户端,而不是 file://。后者发出的 fetch 是不透明的跨源请求,所有访问
  服务端的调用都会被拦,而通常的绕法是把 web security 关掉——那等于把 WebView
  变成一扇敞开的门。用资产加载器拿到的是正常安全源,请求按普通 CORS 走,
  isSecureContext 为真,IndexedDB 等行为与浏览器一致。这一个选择是「一份 bundle
  两个宿主」能成立的前提。

## 过程中发现并修掉的问题

- **ETag 里塞了中文路径**:HTTP 头值必须是 ISO-8859-1,漫画页码路径是中文时会抛
  ERR_INVALID_CHAR,把一个正常的取图请求变成 500。改成对路径取散列。
- **漫画文件夹的主键用了页集合散列**:注释写的是「加一页不产生新书」,实现却是散列
  页集合,加一页就换 id、进度全丢。改成用文件夹路径作为锚点,和书用 dc:identifier
  作锚点是同一个道理。测试固定住了。
- **locator 解析与写出不对称**:写出是两段、解析却按三段切,导致位置根本存不回来。
  章节 id 含冒号时更糟。
- **离线队列的清理条件**:原本按「清空全部」处理,会把请求在途时产生的新位置一并
  抹掉。改成只清那一批、且只清时间戳吻合的条目。
- **测试自身的错误**:并发刷新去重的用例里,我一开始用 resolveRefresh?.() 调一个
  类型已收窄成 never 的变量,断言等于什么都没测;「outbox 为空时不推」的用例里把
  登录的 POST 也数了进去,断言恒不成立。两处都是测试写错了。
- **凭据用错了**:一个权限用例里我用 createAsAdmin 建账号却拿另一组凭据登录,
  401 被当成「越权被拒」而通过。这也是测试写错了。

## 验证方式

```
cd server && npm run typecheck && npm test && npm run build   # 37/37 通过
cd web    && npm run typecheck && npm test && npm run build   # 147/147 通过
sh android/scripts/check-shell.sh                             # 桥接契约双向校验通过
```

另用真实 HTTP 请求端到端验证过:注册 → 扫描 → 书架(四种格式全部可见)→ 漫画按页
取图(含中文路径 ETag)→ 列表页 → 进度同步 → 继续阅读;H5 静态托管、SPA 回落、
CORS 回显、路径穿越拒绝均按预期。

Android 壳本轮**没有真机编译验证**:环境里没有 JDK 和 Android SDK。Kotlin 侧做了
静态校验(包名与目录一致、项目内 import 可解析、桥接方法与 web 侧声明双向对齐),
并接进了 CI。第一次真机构建可能需要微调 Gradle 版本或 AGP 版本。

## 说明

- docker-compose.yml 与 Dockerfile 已改为从仓库根构建并打包 H5 产物;
  build 上下文变了,如果之前用了 `build: ./server` 需要改成根目录。
- iOS 未做设计(按设计文档 §7,等有赞助再说)。
- 移动端批注输入仍未做(先保证读,后补写)。

Ref: #1
2026-09-16 14:49:11 +08:00