主题
EPUB 目录质量 B' + A + D + C —— 交接稿(as-built)
物理仓库位置:
~/reading-browser(ReadBrowser 桌面端,Tauri + React + Rust) 2026-08-08 收口。范围 = backlog§🔴 EPUB 长期阅读第 6.5(B')+ 6.6(A·C·D)条。 零 migration,schema.sql未动。 前置:epub-usability-sprint1-handoff.md(B1-B4)·epub-anchor-precision-handoff.md(B5/B6)
1. 三条被实测推翻的说法(旧稿里有,别照抄)
① 「解析器过滤掉了 526 个 [Pg NNN] 翻页锚点」—— 结论对,归因错。(B5/B6 轮已证伪,此处存档)
2026-08-08 实测 toc.ncx(The City of God):<navPoint> 35 / <pageTarget> 542 / 含 [Pg 的 <text> 542(全部来自 <pageTarget>)。parse_nav_points 只扫 <navPoint>, 那 542 条从来不在解析路径上。仓里不存在任何 [Pg 启发式过滤。
② 「TOC 条目数 ≈ 章节文件数」不成立。 本书 35 条 TOC 分布在 11 个 xhtml 里,h-0.xhtml 一个文件装 7 条。EPUB 规范本来就允许 file.xhtml#anchor,一文件多条目、一条目跨文件都合法。
③ 本轮新证伪:「Moby Dick 146 条是 C(折叠)的用例」—— 不成立。 实测本地 5 本书的 ncx(脚本见 §6):
| 书 | 条数 | level 分布 | 折叠是否有用 |
|---|---|---|---|
| Moby Dick | 146 | 0×141 + 1×5 | ❌ 只能收 5 条 |
| City of God | 35 | 0×22 + 1×13 | ✅ |
| Romeo & Juliet | 35 | 0×11 + 1×24 | ✅ |
| Romance(Conrad/Ford) | 47 | 0×8 + 1×5 + 2×34 | ✅ 三层,最典型 |
| Ashton-Kirk | 34 | 0×8 + 1×26 | ✅ |
Moby Dick 那面墙是扁平长列表,不是嵌套问题,按 level 折叠解决不了它。 真正受益的是「一个 Part 挂几十章」的书。扁平长目录要搜索/过滤或虚拟滚动 —— 未立项。
④ 同轮确认:本地 5 本书 全部无 nav.xhtml(全是带 ncx 的 EPUB 2 式)。 这印证了「B1-B6 六轮实测没暴露 B'」不是运气,是本地样本里根本没有纯 EPUB 3 书; 也意味着修复前的空目录状态无法在本机自然复现,fixture 必须自造(§6)。
2. 改了什么
| 文件 | 改动 |
|---|---|
src-tauri/src/commands/epub.rs | B':parse_opf 返回值改结构体 OpfParsed(多出 nav_href);新增 parse_nav_xhtml / extract_toc_nav_body / resolve_nav_href / strip_tags / decode_entities;parse_ncx 里回填章节标题的那段抽成共用的 apply_toc_to_chapters。+10 条单测(nav_xhtml_tests) |
src/lib/epubToc.ts | A:cleanTocLabel()。C:buildTocTree / hiddenTocRows / tocAncestors + TocNode |
src/lib/epubToc.test.ts | +14 条(13 → 27) |
src-tauri/src/content-script/features/epub-anchor.js | D:抽出 tocAnchorList / nearestTocAnchorAbove(纯读)/ writeFragment(带"没变就不写"闸);新增 startTocScrollSpy / onSpyScroll;syncEpubTocAnchor 校正完即挂 spy |
src/components/TocPanel.tsx | A 渲染时 cleanTocLabel;C 组头箭头 + 叶子等宽占位 + 折叠态 + 当前项祖先自动展开;D 配套的 1.5s "用户正在翻目录就别抢滚动条"让位闸 |
src/lib/strings/{panels,locales/{en,zh}/panels}/reader.ts | C 的两条 aria-label(collapseTocGroupAria / expandTocGroupAria) |
pnpm build:cs 已跑(content-script 是 include_str! 进 Rust 的打包产物)。
关键设计决定
① B' 的目录源优先级 = ncx 优先、nav 回落(EPUB 3 规范本身以 nav 为准,这里刻意反过来)。 理由是风险不对称:带 ncx 的书(Gutenberg 全系 + 一切 EPUB 2)走的仍是原路径 → 零行为变更、 EpubChapter.title 不动、reading_pages.title 的存量一致性不受影响;且 ncx 解析器经了六轮实测, nav 解析器是新的。混合书两份目录几乎总是同源生成、内容一致,取谁差别极小。 但 ncx 存在却解析不出条目时仍回落 nav —— 那正是"目录空 + ‹ › 全灰"的场景,有备份就该用。
② nav 的 properties 按空格分隔 token 比对,不用 contains("nav")。 规范里 properties 是 token 列表(properties="nav scripted" 常见);contains 会把 properties="navigation-aid" 误当导航文档。有反向单测。
③ level 由 <ol> 嵌套深度给,不逐个匹配 <li>。find_closing_tag 的前缀比对会把 <link> 当成 <li> 的开标签;而 level 语义本来就只由 <ol> 深度决定。所以顺着文档序扫标签、用 <ol>/</ol> 维护深度,遇到 <a href> 就落一条。
④ A 只在展示层剥,Rust 侧 TocEntry.label 一个字不动。 那个 label 经 EpubChapter.title → __RB_EPUB_META.tocLabel → effectiveSourceTitle() 落进 reading_pages.title,改源头会让存量行与新行标题不一致。 apply_toc_to_chapters 的注释里写死了这条。 同理 decode_entities 只用在 nav 这条新路上,不回头套到 parse_ncx —— nav 路此前恒产空目录, 不存在任何存量行,随便定;ncx 路有存量。
⑤ D 走 scroll 事件而非轮询,且默认不写。 一次 replaceState = 一次 reportNavigationState(tracking.js patch 过它)= 一次 IPC + 一次 React setState。两道闸把写压到"只有跨过章节分界那一下":150ms 节流 + writeFragment 里的"和当前 fragment 相同就不写"。因为它是 scroll 驱动的,页面停在顶部从不触发, 所以 B6 设计决定③("从文件头读起就别平白加锚点")依然成立。
⑥ C 默认全展开。 目录是阅读型内容,只多一层收纳能力,不替读者决定藏什么 (同 CollapsibleSection 的 defaultOpen=true 立场)。不做"长目录自动折叠"——见 §1③。
3. D 的实测踩坑(这条别重踩)
一度把 onSpyScroll 写成「nearestTocAnchorAbove 算不出锚点就维持现状」,是错的。
settings.js::loadSettings 给 rb-cache:// 页的 body 加了 padding: 48px 40px 80px。 于是滚回最顶时首个目录锚点的 top = 48 > scrollY(0) + 2,一个锚点都不在视口顶上方 → 恒走那一支 → 上一章的高亮永久留在那儿。
实测记录(Moby Dick h-0,修之前):
scrollTo 2000 → #sec2 ✅
scrollTo 3600 → #sec3 ✅
scrollTo 0 → #sec3 ❌ 卡住正解 = 回落 list[0],与导航那一拍(syncEpubTocAnchor 里的 || list[0])以及 resolveTocEntry 的同文件兜底同一口径。修后见 §4。
4. 实测结论(全部经 /debug 探针断言,非看屏幕)
B' —— fixture ~/Downloads/EPUB3 Nav Only Testbook.epub(自造,§6)
| 条目 | 证据 |
|---|---|
| 目录非空 | epubInfo.toc.length = 6(修复前恒 0) |
| level | [0,0,1,1,0,0] —— 嵌套 <ol> 正确压成 level |
| fragment 保留 | part1.xhtml#sec1 / #sec2 / #sec3 三条同文件条目 |
| 章节标题回填 | chapters = ["Cover Page","PART ONE.[12]","PART TWO.","PART THREE.[199]"](带原始页码,证明 Rust 侧 label 未被清洗) |
start_index | 1 —— 正确跳过 200 字符以下的封面页 |
| ‹ › | [aria-label="上一章"] / [aria-label="下一章"] 各 1 命中,两个 disabled 文案均 0 命中 |
| 回归:ncx 路径 | Moby Dick 仍 146 条、level 分布不变 |
| 错误日志 | 0 条 |
⚠️ 探针注意:
/debug/state对大数组做摘要({__summary,length,preview}),.toc \| length数的是 key 数 = 3,不是条目数。看.toc.length字段。
A —— TOC 面板实际渲染的 6 行
| 行 | padding-left | 渲染标签 | 源 label |
|---|---|---|---|
| 1 | 8px | Cover Page | 同 |
| 2 | 8px | PART ONE. | PART ONE.[12] |
| 3 | 20px | Chapter Alpha | 同 |
| 4 | 20px | Chapter Beta. | Chapter Beta.[34] |
| 5 | 8px | PART TWO. | 同 |
| 6 | 8px | PART THREE. | PART THREE.[199] |
D —— Moby Dick h-0(一文件多锚点),修复后
| 动作 | url fragment | 目录高亮 |
|---|---|---|
| 开书未滚 | 无 | MOBY-DICK;…(同文件兜底) |
| →1500 | #pgepubid00000 | 同上 |
| →4000 | #pgepubid00000 | 同上 —— 未重复写,no-op 闸生效 |
| →9000 | #pgepubid00003 | (Supplied by a Late Consumptive Usher…) 跟着走 |
| →0 | #pgepubid00000 | 回到首条 —— §3 那个 bug 的修复点 |
C —— Moby Dick(5 个组头)+ 测试书(C×D 耦合)
| 条目 | 证据 |
|---|---|
| 组头箭头 | 5 个 [aria-label="折叠本节"],全 aria-expanded="true";展开按钮 0 |
| 叶子对齐 | 叶子渲染 <span class="w-3.5" aria-hidden="true">,与箭头同宽 |
| 折叠 | 点箭头 → 行数 146 → 145,出现 1 个「展开本节」 |
| 展开 | 再点 → 146 |
| 不误跳章 | 点箭头后 tab url 未变、高亮仍在首条 → stopPropagation 生效 |
| C×D 耦合坑 | 折叠 PART ONE(6→4 行)→ 滚进 Chapter Alpha → 行数回 6(祖先自动展开)+ 高亮落在 Chapter Alpha |
收口检查
cargo check ✅ · cargo test --lib 137 passed(+10)· pnpm build ✅ · pnpm test 147 passed(+14)· /arch-check H1-H6 零命中;S10 加 arch-r1: 注释后回基线 7;S12-S17 在改动文件上 0; S19/S22/S23 = 0;S24=7 / S25=0 / S26=3/3 / S27=3 全持平 · /ui-check U1=2(永久豁免)· U2=U3=0 · 改了 content-script → 已 pnpm build:cs。
⚠️ 唯一新增软警告:epub.rs 645 → 1008 行,跨过 S1 强警告阈值 1000 (其中 ~330 行是新增单测,nav 解析本体约 200 行)。 刻意没拆——拆模块不在本轮锁定的四项内。天然切分缝很清楚:nav 解析那一组函数 + 其单测 可整块挪去 commands/epub/nav.rs,拆完 epub.rs 回到约 680 行 (注意它改动前就已经 645 > 500 的软警告线,拆完只是回到原本那档)。
5. 留下的东西
同轮追加已做(2026-08-08 第二次提交)
epub.rs拆成commands/epub/{mod,nav}.rs(704 + 319 行,S1 强警告消除)。 EPUB 3 nav 解析那组函数 +nav_xhtml_tests整块搬到nav.rs;mod.rs保留 spine/OPF/ 起始章推断。目录源的优先级取舍仍在mod.rs,nav.rs只管解析。 ⚠️ 切分时差点漏掉start_index_tests的#[cfg(test)](会把测试代码编进 release 二进制), 是cargo check的 unused-import warning 暴露的 —— 拆完务必看 warning,别只看 test 绿。- backlog 6.7 已修(切 EPUB tab 后目录不高亮)。归因更正:不是残留旧值,是被清空 (effect 里写死
setCurrentTocHref(''))。推导抽成epubToc.ts::resolveTocLocation, listener 与 effect 共用一份。抽取时要保住matched——chapterIdx没匹配到时只是回落值, 据它回写tab.epubCurrentIdx会把"不知道"写成"在第 0 章"。
仍未做
- B7 全书搜索 + 扁平长目录过滤 —— 新会话做(2026-08-08 定)。 现场勘察结果(find.js 架构 / 四个 window 全局 / 全书搜索不必建索引)已写进 backlog §7, 新会话直接用,别重查。
- 同文件兜底仍是近似(取同文件第一条)。B6 起它只在 fragment 校正落地前那几百毫秒、 以及校正失败时生效。刻意保留:
AddressBar的tocIdx < 0 → 下一章 = toc[0]那条兜底 服务"刚开书、锚点还没回传",不能删。 - 未做:B4 章内偏移(需新列 → 迁移从 v32 起,独立立项)、B7-B9。
6. 测试书
- 主(EPUB 2 / 带 ncx):
~/Downloads/The City of God, Volume I by Saint of Hippo Augustine (4505).epub(Gutenberg 45304,35 条 TOC,h-0一个文件 7 条,632 处脚注) - 长目录 / 扁平:
~/Downloads/Moby Dick; Or, The Whale by Herman Melville.epub(146 条,141 条 level 0) - 三层嵌套:
~/Downloads/joseph-conrad-ford-madox-ford_romance.epub(47 条,8/5/34) - B' fixture(自造,纯 EPUB 3 无 ncx):
~/Downloads/EPUB3 Nav Only Testbook.epub—— 本地 5 本真书全带 ncx,这种书必须自己造。生成脚本见下(一并做进了 A 的尾随页码 与 D 的一文件 3 锚点,一本书覆盖三项):
生成脚本(存这儿而不是 scripts/:它是一次性 fixture 工具,不进构建链。 epub.rs::nav_xhtml_tests::NAV 常量就是下面这份 nav 的形状):
python
#!/usr/bin/env python3
"""生成一本纯 EPUB 3 / 无 toc.ncx 的测试书(B' 复现用)。
刻意做进去的四件事:
· manifest 零 application/x-dtbncx+xml、spine 无 toc 属性 → 修复前必然 toc=[]
· nav.xhtml 用嵌套 <ol> → 验 level
· landmarks nav 排在 toc nav 之前 → 认错 nav 就只剩一条「Start」
· part1.xhtml 一文件 3 锚点(验 D)+ 三条 label 带尾随 [NNN](验 A)
· cover.xhtml 可见字符 < 200 → 验 start_index 跳封面
"""
import os, zipfile
OUT = os.path.expanduser("~/Downloads/EPUB3 Nav Only Testbook.epub")
def para(n, seed):
return "\n".join(
f"<p>Paragraph {i} of {seed}. The quick brown fox jumps over the lazy dog "
f"while contemplating the nature of navigation documents and their many "
f"discontents in the modern electronic publishing landscape.</p>"
for i in range(1, n + 1))
def page(title, body):
return (f'<?xml version="1.0" encoding="utf-8"?>\n'
f'<html xmlns="http://www.w3.org/1999/xhtml" '
f'xmlns:epub="http://www.idpf.org/2007/ops">\n'
f'<head><title>{title}</title></head>\n<body>\n{body}\n</body>\n</html>\n')
files = {
"META-INF/container.xml": '<?xml version="1.0"?>\n'
'<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n'
' <rootfiles><rootfile full-path="OEBPS/content.opf" '
'media-type="application/oebps-package+xml"/></rootfiles>\n</container>\n',
"OEBPS/content.opf": '<?xml version="1.0" encoding="utf-8"?>\n'
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">\n'
' <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">\n'
' <dc:identifier id="bookid">urn:uuid:rb-epub3-navonly-0001</dc:identifier>\n'
' <dc:title>EPUB3 Nav Only Testbook</dc:title>\n'
' <dc:creator>RB Test Fixture</dc:creator>\n <dc:language>en</dc:language>\n'
' <meta property="dcterms:modified">2026-08-08T00:00:00Z</meta>\n </metadata>\n'
' <manifest>\n'
' <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>\n'
' <item id="cover" href="text/cover.xhtml" media-type="application/xhtml+xml"/>\n'
' <item id="part1" href="text/part1.xhtml" media-type="application/xhtml+xml"/>\n'
' <item id="ch2" href="text/ch2.xhtml" media-type="application/xhtml+xml"/>\n'
' <item id="ch3" href="text/ch3.xhtml" media-type="application/xhtml+xml"/>\n'
' </manifest>\n'
' <spine>\n <itemref idref="cover"/>\n <itemref idref="part1"/>\n'
' <itemref idref="ch2"/>\n <itemref idref="ch3"/>\n </spine>\n</package>\n',
"OEBPS/nav.xhtml": '<?xml version="1.0" encoding="utf-8"?>\n'
'<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">\n'
'<head><title>Contents</title></head>\n<body>\n'
' <nav epub:type="landmarks" hidden="hidden">\n'
' <ol><li><a epub:type="bodymatter" href="text/part1.xhtml">Start</a></li></ol>\n'
' </nav>\n'
' <nav epub:type="toc" id="toc">\n <h1>Contents</h1>\n <ol>\n'
' <li><a href="text/cover.xhtml">Cover Page</a></li>\n'
' <li><a href="text/part1.xhtml#sec1">PART ONE.[12]</a>\n <ol>\n'
' <li><a href="text/part1.xhtml#sec2">Chapter Alpha</a></li>\n'
' <li><a href="text/part1.xhtml#sec3">Chapter Beta.[34]</a></li>\n'
' </ol>\n </li>\n'
' <li><a href="text/ch2.xhtml">PART TWO.</a></li>\n'
' <li><a href="text/ch3.xhtml">PART THREE.[199]</a></li>\n'
' </ol>\n </nav>\n</body>\n</html>\n',
"OEBPS/text/cover.xhtml": page("Cover", "<h1>Nav Only</h1>"), # 故意 < 200 可见字符
"OEBPS/text/part1.xhtml": page("Part One",
f'<h1 id="sec1">PART ONE.</h1>\n{para(12, "part one opening")}\n'
f'<h2 id="sec2">Chapter Alpha</h2>\n{para(12, "chapter alpha")}\n'
f'<h2 id="sec3">Chapter Beta</h2>\n{para(12, "chapter beta")}'),
"OEBPS/text/ch2.xhtml": page("Part Two", f'<h1>PART TWO.</h1>\n{para(14, "part two")}'),
"OEBPS/text/ch3.xhtml": page("Part Three", f'<h1>PART THREE.</h1>\n{para(14, "part three")}'),
}
if os.path.exists(OUT):
os.remove(OUT)
with zipfile.ZipFile(OUT, "w") as z:
# mimetype 必须第一个且 STORED
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", zipfile.ZIP_STORED)
for name, content in files.items():
z.writestr(name, content, zipfile.ZIP_DEFLATED)
print("wrote", OUT)7. 工作方式(已验证有效,沿用)
- 用户负责"到达现场"(开书 / 点脚注 / 按键),只做窄探针检查。给编号步骤,等回「好了」再批量探测。
- 禁止
osascript合成按键——本机对 dev app 不抵达,硬试会导致误诊。 - 探针(注意是
$TMPDIR不是/tmp):bash路由:P=$(cat "$TMPDIR/rb-debug-port") curl -s --get "http://127.0.0.1:$P/debug/dom" --data-urlencode 'selector=...' \ --data-urlencode 'webview=content-N' | jq -c '.result'GET /debug/{snapshot,snapshot/tab,logs,state,dom}、POST /debug/{click,type,scroll,db/query}。/debug/dom只返回 outerHTML,跑不了任意 JS。 - 本轮新增的可复用探针:bash
# 目录行(tree ListItem 靠 inline padding-left 认) --data-urlencode 'selector=[role="button"][style*="padding-left"]' # 当前高亮 --data-urlencode 'selector=[class*="bg-primary/10"]' # 折叠态 --data-urlencode 'selector=[aria-label="折叠本节"]' # / "展开本节" # 滚动:参数是 top / dy,**不是** y(写错会静默 no-op,报 scrolled:true 但 at.y 不动) curl -X POST ".../debug/scroll" -d '{"webview":"content-2","top":9000}' # 切 tab(省掉一轮人工往返) curl -X POST ".../debug/click" -d '{"webview":"main","selector":"[title*=\"书名片段\"]"}' - ⚠️ 断言 tab 时别用「出现新 id」:
openContent见到空白 tab 会就地复用,tab id 不变。按内容断言。 - ⚠️ 改 Rust / content-script 会让
tauri dev重编重启,tab 全清、debug port 变。 先把代码全部改完再请用户开书,否则每次都要重来。 - 实测期间不要切账号(token 刷新触发
clear_learning_data_if_user_changed,会吃掉证据)。