Skip to content

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 Dick1460×141 + 1×5❌ 只能收 5 条
City of God350×22 + 1×13
Romeo & Juliet350×11 + 1×24
Romance(Conrad/Ford)470×8 + 1×5 + 2×34✅ 三层,最典型
Ashton-Kirk340×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.rsB'parse_opf 返回值改结构体 OpfParsed(多出 nav_href);新增 parse_nav_xhtml / extract_toc_nav_body / resolve_nav_href / strip_tags / decode_entitiesparse_ncx 里回填章节标题的那段抽成共用的 apply_toc_to_chapters+10 条单测nav_xhtml_tests
src/lib/epubToc.tsAcleanTocLabel()CbuildTocTree / hiddenTocRows / tocAncestors + TocNode
src/lib/epubToc.test.ts+14 条(13 → 27)
src-tauri/src/content-script/features/epub-anchor.jsD:抽出 tocAnchorList / nearestTocAnchorAbove(纯读)/ writeFragment(带"没变就不写"闸);新增 startTocScrollSpy / onSpyScrollsyncEpubTocAnchor 校正完即挂 spy
src/components/TocPanel.tsxA 渲染时 cleanTocLabelC 组头箭头 + 叶子等宽占位 + 折叠态 + 当前项祖先自动展开;D 配套的 1.5s "用户正在翻目录就别抢滚动条"让位闸
src/lib/strings/{panels,locales/{en,zh}/panels}/reader.tsC 的两条 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.tocLabeleffectiveSourceTitle() 落进 reading_pages.title,改源头会让存量行与新行标题不一致apply_toc_to_chapters 的注释里写死了这条。 同理 decode_entities 只用在 nav 这条新路上,不回头套到 parse_ncx —— nav 路此前恒产空目录, 不存在任何存量行,随便定;ncx 路有存量。

⑤ D 走 scroll 事件而非轮询,且默认不写。 一次 replaceState = 一次 reportNavigationStatetracking.js patch 过它)= 一次 IPC + 一次 React setState。两道闸把写压到"只有跨过章节分界那一下":150ms 节流 + writeFragment 里的"和当前 fragment 相同就不写"。因为它是 scroll 驱动的,页面停在顶部从不触发, 所以 B6 设计决定③("从文件头读起就别平白加锚点")依然成立。

⑥ C 默认全展开。 目录是阅读型内容,只多一层收纳能力,不替读者决定藏什么 (同 CollapsibleSectiondefaultOpen=true 立场)。不做"长目录自动折叠"——见 §1③。


3. D 的实测踩坑(这条别重踩)

一度把 onSpyScroll 写成「nearestTocAnchorAbove 算不出锚点就维持现状」,是错的。

settings.js::loadSettingsrb-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_index1 —— 正确跳过 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
18pxCover Page
28pxPART ONE.PART ONE.[12]
320pxChapter Alpha
420pxChapter Beta.Chapter Beta.[34]
58pxPART TWO.
68pxPART 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.rsmod.rs 保留 spine/OPF/ 起始章推断。目录源的优先级取舍仍在 mod.rsnav.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 校正落地前那几百毫秒、 以及校正失败时生效。刻意保留AddressBartocIdx < 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,会吃掉证据)。