主题
EPUB 导航精度 B5 + B6 —— 交接稿(as-built)
物理仓库位置:
~/reading-browser2026-08-08 收口。范围 = backlog§🔴 EPUB 长期阅读第 5、6 条。零 migration,schema.sql未动。 前置:epub-usability-sprint1-handoff.md(B1-B4)·epub-longform-acceptance-handoff.md(验收 E5)
1. 实测归因(推翻了 backlog 里的原假设,以本节为准)
原 backlog 写 B5「未细分是高亮注入回流还是字体 settle,实施时用『关掉 CEFR 高亮再走一遍』判别」。 实际做法是往 content-script 加分相埋点(console.log → debug_log_console → /debug/logs), 一次往返就把位移拆开了,不需要切开关。
回程 h-8 → h-0#FNanchor_25_25 实测:
| 时刻 | 锚点距视口顶 | 文档总高 | 已重排版 |
|---|---|---|---|
| t0 DOMContentLoaded | 7456 | 35,243 | ✗ |
| t1 排版落地后 | 5107 | 65,223 | ✓ |
| t2 高亮注入完 | 5139 | 65,255 | ✓ |
| t3 +1.5s | 5139 | 65,255 | ✓ |
归因:排版 2,349px(98.7%)/CEFR·短语高亮 32px(1.3%)/此后无任何变化。
真凶 = content-script/features/settings.js::loadSettings 对 rb-cache:// 页做的整页重排版 (body 塞进 max-width:680px + Georgia + line-height:1.8 + fontSize + padding:48px 40px 80px), 把文档高度撑了 +85%。原生锚点滚动发生在这之前(t1 时 scrollY=7452 ≈ t0 时锚点旧偏移 7456), 排版一落地,锚点上方全部长高 → 锚点下移、视口原地不动 → 停在目标上方。
同文件锚点跳转不受影响的原因也在这里:那条路(MainApp::handleTocNavigate 的 evalInWebview(scrollIntoView) 分支)执行时页面早已排版完毕。E5 记的"去程/回程不对称"就是这么来的。
两条原稿错误(已在下游文档修正)
- 「去程 ✅」不成立——去程(正文 → 脚注页
#Footnote_25_25)同样偏,实测top=1072(≈1 屏)。 偏移量正比于锚点在文件里的深度,去程恰好浅,验收时没察觉。两个方向都坏,都由本次修复覆盖。 MainApp老注释「cross-file 导航 URL 通常不带锚点(webview 滚完就剥)」是错的。 实测url一路带着#FNanchor_25_25到navigation-state-changed。 B6 从来不是"没 hash",是"hash 不是目录锚点"。注释已删。
2. B6 的落点(为什么"退一步按 base 文件匹配"不够)
这本书 h-0.xhtml 一个文件里住着 7 条目录条目:
#pgepubid00001 THE WORKS OF AURELIUS AUGUSTINE,
#pgepubid00002 REV. MARCUS DODS, M.A.
#pgepubid00007 CONTENTS.
#pgepubid00010 EDITOR'S PREFACE.
#pgepubid00021 BOOK FIRST. ← FNanchor_25_25 上方最近的一条
#pgepubid00067 BOOK SECOND.
#pgepubid00068 ARGUMENT.只按文件回落 → 恒取第 0 条 → 「下一章」= 第 1 条 = 往回跳。 避开了"回到全书开头",但换了个小一号的错。所以做成两级:精确匹配 + 同文件兜底, 再由页内脚本把 fragment 校正成上方最近的目录锚点,让精确分支真正命中。
3. 改了什么
| 文件 | 改动 |
|---|---|
src/lib/epubToc.ts | 新文件:目录锚点推导的唯一决策处。resolveTocEntry(两级)/ sameFileTocEntries / tocAnchorsForFile / tocBase / tocAnchor,并把 computeNextTocAnchor 从 MainApp 挪进来 |
src/lib/epubToc.test.ts | 新文件:13 条单测,数据取自实测书真实 TOC 形状(含"兜底结果必须是目录里真实存在的 href"这条——它才是「‹ › 跳回开头」的真正判据) |
src-tauri/src/content-script/features/epub-anchor.js | 新文件:B5 的重定位(initEpubAnchor / restoreEpubAnchor)+ B6 的 fragment 校正(syncEpubTocAnchor) |
src-tauri/src/content-script/index.js | onPageReady 起点 initEpubAnchor();loadSettings().then 里 restoreEpubAnchor() + syncEpubTocAnchor() + 500ms 重试;full-pipeline 末尾各补一次 |
src/MainApp.tsx | 两级推导替换原 tocMatch;__RB_EPUB_META 两处注入点都加 tocAnchors;删除本地 computeNextTocAnchor |
src/components/TocPanel.tsx | 当前条目 activeRef + scrollIntoView({block:'nearest'})(ListItem 本就 forwardRef<HTMLDivElement>,无需包 div) |
三个关键设计决定
① B5 的两道让位闸(都在 restoreEpubAnchor 内部): _userMoved(wheel/touchstart/keydown 任一触发即永久让位)+ _appliedY(当前 scrollY 不等于 我们上次放的位置 → 说明别人动过 → 让位)。必需:full-pipeline 在这本书上要跑好几秒 (短语 LLM judge),那时把正在阅读的人拽回锚点,比定位不准更糟。
② B6 走 replaceState 改写 fragment,而不是新开一条 page→main 上报通道。 fragment 本来就该表示"你在哪"。改写后 MainApp 既有的精确匹配分支自然命中, 零 Rust 改动、零新事件。回路是刻意复用的:tracking.js 里 history.replaceState 已被 patch, 会捎带一次 reportNavigationState。已实测 replaceState 在 rb-cache:// 上允许 (这是设计成立与否的唯一未知数,已落实;失败也有 try/catch 退回同文件兜底)。
③ 只在"已有 fragment 且它不是目录锚点"时才改写。 无 fragment 的情况(从文件头读起)不动——那时 scrollY=0,同文件第一条本来就是对的, 不必平白给 source_ref 添一个锚点。
顺带的正面副作用
effectiveSourceUrl() 用的是 __RB_EPUB_META.tocHref。B6 之前,落在脚注锚点的页面 tocHref 是不带 # 的 spineHref → 在那儿存的词 source_ref 丢章节信息。 现在拿到精确章节锚点 → B4 的「恢复到章」在脚注往返之后也准了。
4. 实测结论(全部经 /debug 探针断言,非看屏幕)
ground truth 取自 h-0.xhtml 字节偏移:pgepubid00010@11920 / pgepubid00021@35974 / FNanchor_25_25@36751 / pgepubid00067@154154 → 正确答案 = pgepubid00021(BOOK FIRST.)。
| 条目 | 证据 |
|---|---|
| fragment 改写 | tab url = …h-0.htm.xhtml#pgepubid00021(从 FNanchor_25_25 改写而来) |
replaceState 可用性 | ✅ 允许(否则会看到同文件兜底的 pgepubid00001) |
| 目录高亮 | [class*="bg-primary/10"] → total_matches: 1,命中 BOOK FIRST.(精确,非兜底) |
| ‹ › 状态 | [aria-label="上一章"] / [aria-label="下一章"] 均 found,disabled 文案均 not-found → tocIdx=4 有效 |
| B5 落点(间接) | 校正结果按 scrollY 实算得 pgepubid00021;若仍偏 5107px,落点会在 35974 之前 → 必然算出 pgepubid00010。得到 21 ⇒ 落点已在 BOOK FIRST 区间内,目标在其后约 780 字节 |
| B5 落点(直接,用户肉眼) | 回程一跳直接落到含 [25] 的那一句。修复前同一操作偏目标上方 ≈5 屏 → 像素级到位,无需再往后挪定位时机 |
| 错误日志 | 0 条 |
pnpm test 133 passed(新增 13)· cargo check ✅ · pnpm build ✅ · /arch-check H1-H6 零命中,S17=7 / S24=7 / S25=0 / S27=3 / S19·S22·S23=0 全持平基线 (MainApp 909→895 行,content-script.js 7751→7828 行)· /ui-check U1=2(永久豁免)U2=U3=0、U4=1。 改了 content-script → 已 pnpm build:cs,并 grep -a 确认诊断埋点已从 bundle 清干净(0 命中)。
5. 留下的东西
- B6 的高亮只在导航那一拍校正一次(
_tocSynced一次性闸)。在h-0这种一文件 7 条的书里 滚动时,高亮不跟着走 → 滚动联动(scroll-spy)见epub-toc-quality-handoff.md§2 项 D(含三个已知坑)。 - 同文件兜底仍是近似(取同文件第一条)。它现在只在 fragment 校正落地前那几百毫秒、 以及校正失败时生效。刻意保留:
AddressBar的tocIdx < 0 → 下一章 = toc[0]那条兜底 服务"刚开书、锚点还没回传",不能删。 - 本轮挖到但未修:
epub.rs没有 EPUB 3nav.xhtml回落(只认application/x-dtbncx+xml)→ 纯 EPUB 3 无 ncx 的书目录整个空、‹ › 全灰 = 又一个"开书即死路"。 Gutenberg 全系带 ncx 故六轮实测未暴露。详见epub-toc-quality-handoff.md§2 项 B'(最高优先)。 - 同轮证伪:旧文档「解析器过滤掉 526 个
[Pg NNN]」结论对、归因错——那 542 条是<pageTarget>,我们只扫<navPoint>,仓里不存在任何[Pg过滤。 - 未做:B4 章内偏移(需新列 → 迁移从 v32 起)、B7-B9。