Commit Graph
30 Commits
Author SHA1 Message Date
hectorqin 354e7eeb6d fix(web): 工具栏改为浮层,并给章节子资源带上令牌 2026-09-19 12:31:51 +08:00
hectorqin 69993d7b37 Merge remote-tracking branch 'origin/main' into auto/reader-paragraphs-layout-images-eb50 2026-09-19 09:13:54 +08:00
hectorqin 0551f21e4e fix(web,server): 段落不再合成一块,插图重新画出来,工具栏对齐 2026-09-19 09:09:12 +08:00
hectorqin d489c45cf3 Merge remote-tracking branch 'origin/main' into auto/shelf-library-pagination-d4fc 2026-09-19 08:49:21 +08:00
hectorqin c7d065bd52 feat(web): 书架与书库拆成两个可分页的屏幕 2026-09-19 08:45:37 +08:00
hectorqin b8fda56c34 fix(web,server): 底部滑杆量章节内页数,快捷列随工具栏收放,chapter-full 改名 2026-09-19 08:17:46 +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 29f7f6b56a feat(web): 顶栏底栏按参照稿重做,沉浸态保留章节与页码
现状问题。上一轮把顶栏和底栏做成"一起收放"之后,两个方向都还差一截。显示时
顶栏是"返回箭头 + 书名 + 一个 ☰",底栏是一条 3px 的进度条加五行图标;参照稿
要的是顶栏一排等重的"图标在上、文字在下"按钮、底栏一行可拖动的滑杆加一行
"上一章 / 阅读进度 / 下一章"。收起时屏幕上什么都不剩:读者收起工具栏是为了
看正文,不是为了不知道自己读到哪儿。

方案依据。顶栏改成 `.top-button`——图标在上、名称在下的等宽按钮,`space-evenly`
排布,按钮只保留这个屏幕真能做到的返回 / 目录 / 设置(参照稿里"首页/书架/书源"
含阅读页内部走不到的目的地,一个点了没处的按钮比少一个按钮更糟)。底栏第一行
换成 `input[type=range]`:滑杆一个手势就能到章末,而进度条只能报告位置,读者
想到一章末尾得连点四十次;原生控件的拇指、拖动惯性、方向键和 `aria-valuenow`
都白得。第二行是两端的上一章/下一章和中间的进度文字。收起态新增
`.reading-indicator`:章节名钉在左上、页码与总进度钉在左下,两个读数
`pointer-events: none`,让点它们的手势落到下面的翻页区,且只在收起时绘制。

放弃方案。把书名留在顶栏:读者找标题时眼睛去的是正文角上,不是工具栏,而且
标题随工具栏一起消失正是当初要一个常驻读数存在的原因。收起态只留侧边快捷列:
那解决了"能操作",没解决"知道在哪",而后者是沉浸阅读里更常被问到的那个。

顺带修掉一个真 bug:`chevron-right` 的字形路径与 `chevron-left` 逐字节相同,
所以"下一章"按钮一直指向左边。改路径、重生成字体,并补两条断言——同名字形
不得共用路径、两个 chevron 必须镜像——它们此前一条都没有,所以这个 bug 能在
所有测试全绿时存在。

改动点。`reader-chrome.tsx` 新增 `TopButton`、重写顶栏与底栏、新增
`.reading-indicator` 与 `onScrub` 通道;`reader-screen.tsx` 新增 `scrubTo`;
`reader.css` 重写顶栏/底栏/状态药丸定位,新增滑杆与阅读指示样式;`paths.mjs`
修 `chevron-right`;`docs/ui.md` §3.8 补顶栏、底栏滑杆、阅读指示三节;ui:review
新增"保留章节与页码指示 / 阅读指示不吃手势 / 进度是可拖动的滑杆 / 有页码与
阅读进度读数"四条断言。

验证方式。`cd web && npm test`(371 条)、`cd server && npm test`(205 条)
全绿;`npm run build` 通过;`npm run ui:review` 全绿,16 条尺寸断言全过,截图
见 `docs/ui-review/`。曾把 `chevron-right` 的两条新断言逐条还原验证:路径改回
旧值时两条都红。

Ref: #34
2026-09-18 22:48:03 +08:00
hectorqin 7b9c786c83 feat(web): 纯文本排版改在客户端,并让顶栏底栏一起收放
现状问题。TXT 的段落和缩进原本由服务端决定:`chapter-html:<n>` 把章节渲染成
带 `<p>` 的片段,客户端只是把它塞进 shadow root。于是三件事同时不对。一是缩进
设定只对服务端已经渲染好的那份生效,windowed 路径拿到的 `chapter:<n>` 是纯字符
没有段落,同一个读者同一本书会因为走哪条传输而长得不一样。二是抓取来的文件里
用来当缩进的段前空格被原样留下,和读者的缩进叠加,恰好在最需要这个设定的文件
上看起来像没生效。三是这些设定是每台设备各自的阅读偏好,烧进响应就等于把每次
调整变成一次往返。另一件事是顶栏和底栏可以各自隐藏,读者收起导航后底栏仍压着
每一页的最后两行。

方案依据。把「一段是什么」收敛到客户端的唯一定义(`formats/segments.ts`),
本地加载、windowed、流式三条路都走它,服务端的职责缩小为交出完整章节和在原文
上断段。`ReaderView` 在渲染前对纯文本章节再过一遍这个拆分(按块读回、拼回空行
再拆,所以服务端断得对不对不影响观感),缩进、段间距、去掉段前空格因此无需请求
即可作用于两条路径。顶栏与前底栏由 `.reader-screen` 上的一个 `data-chrome` 统一
开关,收起时两条栏一起归零高度并滑出、正文长满整屏,同时右侧浮出一列快捷按钮,
否则收起工具栏会变成「必须离开才能操作」的状态。

放弃方案。把服务端的 `chapter-html` 直接删掉:`href` 同时是阅读位置的标识,且
`format: 'html'` 字段已是既有契约,删掉会让旧客户端与旧位置一起失效,所以保留
作为传输,只去掉它承担的排版职责。让缩进靠服务端属性(`data-indent`)继续驱动:
那正是「设定是响应的属性」这一问题的根源。收起态用两个独立开关:写法上就能只藏
一半,而这恰是读者报的那个 bug。

改动点。新增 `web/src/formats/segments.ts`(分段、去段前空格、HTML/节点两种形
式、跨 chunk 的 carry 判定);`reader-view.ts` 增加 `retypePlainText` 并去掉旧
排版表里读服务端属性的规则;`render/text.ts` 按 `carry` 拼接跨块段落;服务端
`text-html.ts` 去掉 `indent` 选项、不再截断;`reader-chrome.tsx` 增加侧边快捷列
与设置面板顶部的「快捷」一行;`reader.css` 增加两条栏的收放与按钮列样式;图标
集新增 `text-indent` / `text-type` / `spacing`;`docs/ui.md` 补 §3.7 的边界说明
与 §3.8 沉浸式阅读。

验证方式。`cd web && npm test`(369 条)、`cd server && npm test`(205 条)全绿;
`npm run build` 通过;`npm run ui:review` 全绿,并新增「收起工具栏: 顶栏与底栏一
起走 / 都不|不可见 / 正文占满整屏 / 右侧仍有快捷按钮」四条尺寸断言(截图见
`docs/ui-review/03-reader-no-chrome.png`),评审夹具改用全角空格缩进,使「段前
空格被去掉」在截图上可见。

Ref: #34
2026-09-18 19:09:51 +08:00
hectorqin 49b8ffd6d0 fix(web): TXT 排版改由实际内容决定,并让面板的行跟着它走
上一提交把 TXT 的样式表与设置行都挂在 `format === 'txt'` 上,而这只在服务端
报告 `kind: 'text'` 时成立。UI 评审的替身 API 把一份纯文本按 `reflowable`
分窗——这是个合理的选择,纯文本本来就 reflowable——于是量出来:段首缩进
`0px`、样式表长度 0、设置面板里没有「正文」一节。也就是说读者能看到没有排版的
段落,而且旁边没有任何可以调整的地方。

改成两边都从**实际内容**确认:服务端自己的 `.txt-body` 包装是这条约定,
`format: 'txt'` 仍优先(声明了的书不用再自证)。视图的回答和设置面板的回答
现在来自同一个方法 `ReaderView.isPlainText()`——同一个问题被两处各算一遍,
迟早会给出两个答案,而读者看到的就是「段落没样式 + 旁边没有缩进控件」。

「正文」一节也从面板末尾移到「排版」后面:它和排版是同一类设置,找缩进的读者
不该翻过朗读控件才找到它。

`shadow.test.ts` 加了检测的四种情形(声明为 txt / 观测到 txt-body / 正文里
提到这个词 / 完全没有标记)。

评审本身也修了两处:每个场景显式声明主题(之前 TXT 场景沿用了夜间场景留下的
深色,截图不是标签说的那件事);截图不再入库(每次跑都变,二进制 diff 是评审
噪音),`report.json` 和 `README.md` 仍然提交——那才是人看的部分,也是流水线
断言的部分。
2026-09-18 12:50:05 +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
hectorqin fd743f3a3f fix(web): 修好图标与文字的对齐,并让字体度量不再说谎
界面图标显示正常但歪,实测每个字形都偏离所在盒子中心 0.496em(半个盒子),
也就是 9px 以上,且每一行歪的幅度还不一样。代价是三条互相独立的缺陷叠在
一起,而此前所有图标测试都只读 `cmap` 和字形名,一条都不覆盖这些位置。

一、`.icon` 把字体自己的行盒换成了一个方块(主因)

`line-height: 0; display: inline-block; width: 1em; height: 1em` 会得到
一个**锚在基线上、向下一整个 em** 的盒子,而字形是围绕基线画的。于是墨迹贴在
盒子上沿、盒子下方整片是空的。UI 里每个容器都在居中这个盒子,盒子确实居中了,
字形却差了半个 em——这就是「显示正常但不对齐」,也是每行幅度不同的来源:各行的
容器不同,锚法就不同。

改为用字体自己的行盒(`line-height: 1`,不再给高度),只保留 `width: 1em`
这个字体给不了的东西(可预测的前进宽度),并补 `vertical-align: middle` 给
正文里行内出现的图标。**刻意不用 `transform`**:它能在 flex/grid 里生效
(`vertical-align` 在那里是惰性的),但它改的是绘制期位置,会让「图标占的位置」
和「看起来占的位置」不再一致。

二、字体度量与字形不自洽(次因)

表格声明上升部 960 / 下降部 -64(基线靠近 em 底部),而字形是按
`(GRID/2 - y)` 围绕 em 中心画的。两者各自自洽、互相矛盾,而输的是盒子。
现在上升部与下降部各取半个 em:**对称**不是审美——UI 里每个容器都在居中盒子,
只有度量对称时「居中盒子」和「居中字形」才是一回事。

同时修掉三处字段错位(都会静默通过解析):

- `OS/2` 的 `sTypoAscender/Descender` 一直写在 panose 区域,真正的排印窗口是 0/0;
- `usWinAscent/Descent`(**无符号**)也写错了位置,读出来是 0 / 65506;
- `fsSelection` 原本声明 BOLD,`usWeightClass` 是 500——一个不粗的字体谎称自己粗,
  引擎就会合成加粗,而合成出来的图标是糊的。改为 `REGULAR | USE_TYPO_METRICS`。

`head` 的每个字形包围盒补一格下限:`more` 的三个点是用 0.06 单位描边写的,
四舍五入后两个轴各只剩一个点,包围盒为空——空包围盒是栅格化器有权跳过的图形,
于是「更多」按钮画出来是白的,而 `cmap` 依然映射它的码位,所以读表的断言全都
满意。`settings` 的路径解析出了一组跑到网格外的坐标(y 到 24.8、x 到 0.8),
重画成真正的齿轮;`chevron-right` 与 `chevron-left` 共用同一个框却差一个单位。

三、图标按钮有两条构建路径

`manager-more` 是手搭的 `<button class="icon-button">`,而 `.icon-button` 的
对齐靠的是「里面有 `.icon`」这个约定——手搭的那条路径绕过了它。现在一律走
`IconButton`,`label` 必填、字形是名字而不是任意子节点;图标加文字的新组合收进
`IconTextButton`,因为那正是最容易歪、且每次都歪得一样的组合。

验证:新增 10 条断言,覆盖字体度量、字形包围盒与 `.icon` 规则本身;把改动逐条
还原后它们会红(已验证)。真浏览器验收:37 个字形逐个测量,墨迹中心与盒子中心
最大偏差 0.88px(改前是 9px+);书架与阅读器两屏在真实产物上截图核对。客户端
`typecheck` / 317 个测试 / `build`、`check-shell.sh`、`verify-web-assets.sh` 全过。
产物 259.68KB → 259.68KB,字体 16.34KB,CSS 24.54KB。

Ref: #34
2026-09-18 11:30:36 +08:00
hectorqin 3c3e461a49 feat(web): 界面图标换成自带字体,补 UI/UX 规范并统一设计令牌
按钮里放的是 Unicode 字符——`☰` `⚙` `✕` `⟨` `⟩` `+` `🗂` `📁` `⮝`——
和 emoji。八个字形来自八个字族,光学重量各不相同;`🗂` `📁` `⏮` 在部分平台
被渲染成彩色的,而一个彩色 emoji 出现在单色工具栏里,是「没有设计过」最直接的
信号。不同平台的字体回退还不同,同一个界面在 Android 和桌面浏览器上长得不一样。
此外样式表里所有按钮都没有 `:focus-visible` 规则,键盘走一遍看不到焦点在哪。

所以这次做三件事:图标改成自带字体、把缺失的交互状态补齐、把散落的字面量收成
设计令牌,并把规范写成文档。

图标用字体而不是内联 SVG,理由是**一致性可以被强制**:字体把字形变成字符,它
继承 `color` 和 `font-size`,没有自己的几何,因此没有可以漂移的地方;而三十个
内联 SVG 组件里,颜色、尺寸、线宽每一项都是单个字形可以漂移的位置。字体是
bundle 里的一个 16KB 相对路径文件,`file://` 下能加载,与「一份产物两个宿主」
的既有约束一致。

字形由 `web/tools/icons/` 下的脚本从描边路径编译:路径 → 折线 → 圆头矩形 →
精确布尔并(Clipper)→ TTF。先做过一版「栅格化 + 边界追踪」,失败了:笔画宽度
只有一个栅格单元,而逐单元的边界规则无法表达「边界在一格宽的图形上左右交替」,
结果是一个圆被追踪成 58 段碎片。精确布尔没有这种情形。

放弃的方案:内联 SVG(前面说的漂移问题)、把图标做成 React 组件并逐个手绘
(每个都是独立的几何,光学重量必然不一致)、从 CDN 引用图标字体(本产品必须
在无外网环境下工作)。

界面改动:
- `Icon` 组件 + 37 个字形,码位表由生成器写出,测试直接解析字体 `cmap` 核对
- `IconButton` 的 `icon` 是字形名而非任意 `children`,传错名字是编译错误;
  `label` 必填,字形始终 `aria-hidden`
- 补上全部按钮的 `:focus-visible`(本次之前完全缺失)
- 间距/字号/动效收成令牌;`prefers-reduced-motion` 通过折叠时长令牌实现,
  而不是给每条动画各加一个媒体查询
- 悬停包进 `@media (hover: hover) and (pointer: fine)`,避免触屏上按钮停在悬停态

验证:`web` 336 个测试通过(新增 9 个图标契约测试);`typecheck`、`build`、
`check-shell.sh`、`verify-web-assets.sh` 全过;字体已确认被打进 Android assets。
真浏览器(Chromium + 真实服务端)实测了书架、书库管理、阅读器三个屏幕的浅色与
深色主题,以及键盘 Tab 下的焦点环。产物 259.5KB → 259.5KB(gzip 81.4KB),
新增 16KB 字体、CSS 减到 24.4KB。

Ref: #29
2026-09-18 07:08:02 +08:00
hectorqin ecb2ef4806 fix(web): 修复阅读页章节跳转失灵,补齐上一章/下一章 2026-09-17 23:52:11 +08:00
cnb.dQC5J6ohAGA dbaac99b45 feat(web): 界面加路由,首页重做
首页(书架)此前是纯命令式拼出来的,而整个客户端没有任何路由:URL 从头到尾不变,
返回键、刷新、分享、收藏一概不成立。

## 路由

`ui/router.ts` 把 fragment 解析成 route,shell 渲染 router 报上来的那个:

  #/shelf             书架
  #/book/<id>         阅读器
  #/library/<路径>    书库里的目录

用 fragment 而不是路径,是因为同一份产物要被两个宿主加载,而两个都服务不了任意路径:
Android 从 appassets.androidplatform.net 加载(那里没有服务端),H5 由 reader 服务端
托管(没有客户端路由回落)。路径式路由会在 vite dev 里正常、在两个生产宿主里 404。

返回走应用自己的屏幕轨迹,不走 history.back():应用里的「返回」是「上一个屏幕」,
而浏览器历史里可能有登录表单或别的站点。轨迹为空时(新标签页里粘贴的链接)替换成父级,
而不是压入——否则那个链接会永远压在书架后面。

走进书库子目录用 replace:书库是一个带面包屑的屏幕,走目录是同一个屏幕改自己的参数。
压栈的话,读者走过四个文件夹再按返回会逐层退出来。

带着链接进来而尚未登录时,route 存在 App.pending 里等在登录页后面,登录后直接落到
目标屏幕,而不是落到一个他从未见过的书架。

Android 外壳的返回手势不需要改动:它看的是 WebView 的 canGoBack(),而路由现在真的
往 WebView 历史里写——之前那段注释描述的行为,从这次起才成立。

## 首页

- 先看到书,再看到控件:标题和封面在最上面,搜索在后,排序与数量贴在它们作用的那一节上。
  旧版把搜索框、数量、四个排序 chip 依次排在封面之前,三行控件才轮到第一本书。
- 排序栏是唯一吸顶的东西,它承载的是读者会滚回去改的两件事。
- 空状态给一条出路(打开书库管理 / 刷新 / 清除搜索),而不是一句「书库还是空的」。
- 冷启动画骨架屏,且按当前密度画——密度不对的骨架屏会让读者看到布局动两次。
- 封面缺图时把书名画成封面,而不是一个灰色方块。

## 顺手修掉的 5 个真缺陷

1. mountUI 在 unmount 之后仍会渲染。屏幕是异步加载的,离开后那条迟到的响应会把
   整棵树画进一个刚被清空的容器;书架迟到的响应会把自己一屏封面画进书库管理器。
2. --safe-left/-right/-top/-bottom 从未在生效的样式表里定义过,只有一份没人 import 的
   死样式表定义过。未定义的自定义属性会让整个 calc() 失效并静默丢弃声明。
3. 封面在 flex 列里没有显式宽度,aspect-ratio 盒子因此收缩到内容宽度:只有书名的封面
   塌成 42px、有图的占满卡片,整排看起来参差不齐。
4. 阅读列在宽屏上被拉伸到 9 列 45px 的封面。现在内容列封顶 68rem,封面随断点变大。
5. 删掉 web/src/ui/styles.css——414 行从未被引用的旧样式,以及 .book-highlight 等
   已经没有任何代码产出的规则。

## 验证

- web 全量 284 项测试通过(新增 router 19 项 + 链接契约 4 项)。
- typecheck / build / check-shell.sh / build-web-assets.sh 均通过。
- 真浏览器实测:深链直开、刷新不丢、返回离开屏幕、登录后落回目标、书库走目录、
  空搜索、粘性工具栏、深浅色、390px 与 1440px 两档宽度。
- 体积:251KB → 252KB(gzip 79.1KB)。
2026-09-17 18:49:53 +08:00
CodeBuddy 586bec25b1 refactor(web): 界面层迁移到 Preact,排版层保持命令式
书架、登录、书库管理、以及阅读器的铬层(顶栏/页脚/目录/设置面板/朗读条)
从「构造函数建节点 + 实例字段 + 若干 render*()」改成组件树 + 单一状态。
阅读舞台——分页、注入文档、shadow root、量测列宽——仍然零框架。

为什么换:
- 同一份状态有多个副本。分段控件的选中态既在 settings 又在 aria-pressed 上,
  靠点击处理器手工同步;LoginScreen 的通知/错误/注册开关/按钮文案是四个节点
  被五个地方改,漏一处不报错。
- 重建与状态互相矛盾。朗读引擎行要在 HTTP 能力探测回来时重新生成,而整块
  面板重建会丢掉面板滚动位置,于是长出一套「只替换 [data-speech=group]」
  的定点重建;改成组件后探测只是改状态,diff 天然保住滚动位置。

边界(写在 ReaderScreen 里,不靠自觉):
- stage 由类持有并交给 ReaderView,只通过 ref 回调挂进组件树;组件决定它
  放在哪,内容一概不碰。mountUI() 是组件树与类式屏幕之间唯一的接缝,
  路由仍不认识框架,测试仍打在真实 DOM 上。

配套:
- toolkit.tsx:IconButton / Button / Segmented / SwitchRow / Scrim 这些原语
  各把 a11y 与状态细节收在一处,消掉两处手工 aria-pressed 同步。
- vendor/preact.ts:框架唯一入口,只 re-export preact 与 preact/hooks,
  无 preact/compat、无 react-dom、无额外 signals 依赖。
- tsconfig: jsx=react-jsx, jsxImportSource=preact。
- 修一处回归:书库管理的状态行改由 listing 推导,写入结果跨重载保留。

验证:web 全量 260 项测试通过(manager.test.ts 断言逻辑未改),
typecheck、build、android check-shell.sh、build-web-assets.sh 均通过。
产物 235KB → 245KB(gzip 71.2KB → 77.2KB)。
2026-09-17 17:52:18 +08:00
cnb.dQC5J6ohAGA b7ffa42c80 feat(library): 支持上传书籍与批量管理 2026-09-17 16:50:47 +08:00
cnb.dQC5J6ohAGA 282e0426c2 feat(library): 新增书库管理页面 2026-09-17 12:54:52 +08:00
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
cnb.dQC5J6ohAGA 75718e9fb7 merge: 合并 main 并解决阅读界面与分窗读取的冲突 2026-09-16 23:11:10 +08:00
cnb.dQC5J6ohAGA 058bee57a5 feat(web): 丰富阅读界面设置并支持系统 TTS 朗读 2026-09-16 22:55:58 +08:00
CodeBuddy b9b9c63605 merge: 把 main 合并进按窗口读取分支,并修正目录书页契约的两处缺陷
冲突只有两处文档(README 的测试计数与端点清单、docs/api.md 的 /manifest 说明),
两边各自都是对的,所以按并集处理:计数取本分支的实际值,端点清单保留 main 新增的
/items、/toc、/file、/library/formats。

合并后按契约把两边一起验了一遍,发现目录书这条链路上有两个会被静默吞掉的缺陷,
都是「服务端仍回 200、读者看到的是坏内容」那一类:

1. 「卷」的判据里有一句 `images === 0`。整卷系列里放一张封面扫描是常态,于是
   `第01卷.cbz` 与 `第02卷/` 并列、旁边再有张图时,整个系列会被判成「不是漫画」,
   而它也不是别的格式的书——书架上直接消失。判据改成按来源问:压缩包的页就在包里,
   一个压缩包本身就是一卷;卷子目录要往下看才知道有没有页。散图仍由 MIN_IMAGES 管,
   既不能否定同级存在的整卷,也不该在自己是唯一页源时被当成书。

2. 页的资源引用被 API 层按「列表下标」拼成了 `page:<index>`,而这个格式的引用形态是
   `page:<卷>:<页>`。「一个压缩包算一页」意味着一个卷在列表里的下标与它在卷里的位置
   无关,于是按 `page:1` 去问一卷时格式处理器解不出引用,异常回退到「当普通文件发」,
   客户端请求一页、拿到的是那个压缩包——状态码 200,内容不对。引用现在由同一次页面
   遍历给出(manifest 的 files[].ref),/file 原样回传,不再有第二处实现。

顺带修正三处文档与实际不符的地方:`.cbr`/`.rar` 并未被解析(原「不做」清单暗示只有它
不做)、嵌套深度措辞与实现(读到第 4 层,且内嵌压缩包不展开)、README 的测试计数。

server 137/137(typecheck + test + build),web 179 vitest + 29 node:test(typecheck + test + build)。
2026-09-16 22:49:45 +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
cnb.dQC5J6ohAGA 5f1e29a3e5 fix(server,docs): 目录书页面清单成为契约,修正三处会让书静默消失的缺陷 2026-09-16 18:40:37 +08:00
cnb.dQC5J6ohAGA c2afda5391 merge: 把 main 合并进 auto/init-server-scaffold-81b3
main 引入了漫画目录书与逐页取图,本分支引入了格式注册表、H5 客户端与
Android 外壳。两边都改到了扫描器、metadata、library 路由与 web 客户端,
所以冲突不是文本级的,需要按语义逐项合并。

逐项取舍:

- scanner:本分支的注册表版本胜出。main 的 `collectComicDirectories` /
  `indexComicDirectory` / `comicOf`、`SUPPORTED_EXTENSIONS` 等常量都属于
  注册表之前的设计,保留两套会让目录书被索引两次。
- metadata:`parseBookFile` / `pdfInfo` 已被格式层取代,移除;`BookFormat`
  保持开放字符串,格式集合由注册表定义。
- library:`MIME_BY_EXT` 由注册表接管,避免 manifest 与下载端点对同一个
  文件报出不同的 content-type。单文件端点保留「目录书额外认领其目录下
  路径」的修复,否则漫画目录逐页取图全部 404。
- web:测试拆成两个运行器(vitest 与 node:test 互相无法执行对方用例),
  package.json / vite.config.ts 保留本分支的拆分。

合并后 server 128 项、web 147+26 项测试全部通过。

Ref: #9
2026-09-16 18:13:39 +08:00
cnb.dQC5J6ohAGA ca68a03e5d merge: 解决 auto/init-server-scaffold-81b3 与 main 的冲突
两个分支各自演进:一侧引入格式注册表、H5 客户端与 Android 外壳,另一侧
引入漫画目录书与逐页取图。冲突集中在扫描器、metadata、library 路由和 web
客户端,逐项按「两边能力都要保留」的原则合并。

合并后修复了 4 个被冲突掩盖的真实缺陷:

- scanner 同时保留了两套目录书实现,类型检查与测试均无法通过;改为统一
  走格式注册表,目录书在文件遍历之前发现,其内容不再重复入库。
- /manifest 的 files 对漫画目录书返回的是文件夹路径,客户端无法逐页取图;
  改为由 manifest 派生真实页路径。
- 单个文件端点只认 book_files 行,漫画目录书的每一页都 404;改为目录书
  额外认领其目录下的路径,并修正路径归一化使穿越尝试仍被拒绝。
- 目录书判定只看图片数量,会把夹着一本书的目录整体吞掉;改为注册表驱动
  的判定,含书籍文件的目录始终是书架。

同时修复了 web 端两套测试运行器互相无法执行对方用例的问题(vitest 与
node:test 混跑会静默跳过),以及 web 侧 5 处类型错误。

Ref: #9
2026-09-16 18:04:11 +08:00
CodeBuddy 02453c93d3 docs: 更新 README / 架构 / API 文档
记录本轮的四项决定与三个坑:
- 排版策略:出版方优先、变量层可被书拒绝、无 !important
- 渲染分工按内存而非速度划分
- 外壳用 Shadow DOM、书用 iframe 的完整理由
- column-fill 需要确定高度的实测结论
- 分窗与目录分离的原因
- 解析器版本机制与它解决的静默陈旧问题
- 子资源 query token 的白名单边界
2026-09-16 15:49:22 +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
cnb.dQC5J6ohAGA 664141f0f1 feat: 支持 txt 与漫画等多种格式,格式层改为注册表
原先只认 .epub 和 .pdf,本地书库里占很大比重的 txt 长篇与漫画
(cbz、图片目录)会整片「消失」—— 用户能看到文件在 NAS 上,却在
书架里找不到,这是最让人困惑的一类问题。

格式列表硬编码在三处(扫描器白名单、parseBookFile 分支、content-type
映射表),加一种格式要同时改三处,漏掉任何一处就得到一个「扫到了但读不
出来」的安静 bug。这次把格式收进注册表,并补齐四种格式。

新增格式
- cbz / zip:按包内页序自然排序翻页,扫描时不解压到磁盘
- 漫画目录:一个目录一本书,子目录为卷,支持卷/页两级寻址
- txt:UTF-8 严格解码失败才回退 GB18030(UTF-16 BOM 也识别),
  按中文网文章节标题分章,正文可按章节或字节偏移读取
- 单张图片按单页读物入库
- 明确不支持 cbr/rar:需要非自由解压实现,与单容器约束冲突

格式层
- registry.ts 定义 handler 契约(parse/manifest/asset)与扩展名路由,
  扫描器与 HTTP 层只与它对话;扩展名白名单、能力声明、遍历过滤都自动派生
- zip-reader.ts 自研只读 ZIP,只用 node:zlib。JsZip 会把整包解压进内存,
  而漫画动辄几百 MB,首次扫描真实漫画库会 OOM
- natural-sort.ts 自研自然排序:page10 必须排在 page2 之后,
  否则整本漫画的阅读顺序是乱的;不用 localeCompare 是因为 ICU 行为
  在不同平台上不一致
- epub 章节返回前重写章节内的相对资源引用,否则 WebView 里图片/字体/
  样式全部 404;绝对 URL、data: URI 与文内锚点保持原样,脚注不会坏

修复
- 目录型读物认领后,其内部文件从索引中排除,否则同一部漫画会以
  「系列 + 每卷 + 每页」的形式重复出现
- 目录认领顺序改为从外往里:反过来的话一部漫画会变成 N 本独立卷
- 主键新增原地编辑规则:txt/漫画没有内嵌标识,追加一章会算出新 id
  并把读者进度留在旧记录上。现在同路径原地编辑算更新,移动/重命名
  仍走内容哈希规则,所以进度都保得住
- 损坏的 .cbz 仍然入库并记录失败原因,而不是静默从书架消失
- PDF 不再用正则猜页数(会匹配到无关的 /Count 并报出错误页数,
  让客户端分页错乱),改为诚实上报 null
- 清理已迁出的 parseBookFile / pdfInfo / SUPPORTED_EXTENSIONS

测试
- 新增 65 个用例(共 92 个):真实 zip 与真实文件,不用 mock;
  fixtures 由测试自己生成,不需要签入二进制
- 覆盖自然排序、编码探测、ZIP 边界(ZIP64/加密/超大条目)、
  目录认领、原地编辑、只读约束、各格式的读取端点
2026-09-16 14:31:15 +08:00
cnb.dQC5J6ohAGA 2233b295bb feat: 初始化自部署书库服务端
按产品设计 v0.2 落地服务端,覆盖第一版范围中的扫描、元数据、权限与同步。

核心决策:
- 书籍主键用 dc:identifier + 内容哈希,不用文件路径。
  重命名/移动不丢进度,副本自动合并,重新编码的文件保持独立。
- 库目录只读挂载,写操作全部落到 DATA_DIR。
  启动时断言 DATA_DIR 不在 BOOKS_DIR 内,路径解析统一走 resolveInside
  拦截逃逸,扫描不跟随符号链接。
- 元数据优先级:手动修改 > 内嵌 > provider > 文件名解析。
  手动修改独立成表,扫描永远碰不到,可单字段撤销。
- 中文文件名解析采取保守策略,无证据时不猜测作者方向。
- 离线合并用 LWW,笔记删除写墓碑,批次单事务。
- 无原生编译依赖:node:sqlite 与 scrypt 均为内置模块。

交付:
- Fastify + TypeScript 服务端,Docker 单容器
- 27 个自动化测试用例全部通过
- AGPL-3.0,README 做到一条命令跑起来
- CNB 流水线:typecheck / test / build
2026-09-16 14:07:25 +08:00