Skip to content

EPUB 章内滚动偏移(B4 剩余部分)—— 实施计划

物理位置~/reading-browser/docs/plans/epub-intra-chapter-position-plan.md(RB 仓,Tauri/Rust/TS) 上游:backlog §🔴 EPUB 长期阅读 第 4 条(B4)。「恢复到章」2026-08-07 已闭环 (epub-usability-sprint1-handoff.md §6),本轮只做章内创建:2026-08-13 性质:纯 RB。零 Supabase、零同步矩阵、零 RVH。这一串 EPUB 工作里唯一碰 schema 的一轮(v32)。


1. 为什么值得做

2026-08-12 压测:最长章 h-6 = 232KB / 可滚 65,977 物理像素 ≈ 47 屏,滚动流畅无掉帧。 读者确实会在一章里读很远,而现在重开只回到章首,每次都得自己滚回去找。


2. 核心设计问题:存什么?—— 先量后拍板

计划里给了两条路(A 存像素 / B 存最近可见锚点 id),倾向 B。实测数据把 B 也否掉了。

2.1 实测(两本测试书,脚本见 §7 附录)

最长章章内 id平均 ch/锚点<p>平均 ch/段
City of God(Gutenberg)h-6,205K 字符3326181501,368
Romance(Standard Ebooks)chapter-4-10,54K 字符153,936165327

两本书的锚点密度与段落密度正好相反。

  • Gutenberg 书锚点稠密(pgepubid# 577 / Page_# 560 / pt-# 542),B 在这类书上很好;
  • Standard Ebooks 每章只有一个 id<section id="chapter-4-10"> = 章首本身)。 B 在这类书上退化成「恢复到章」= 本轮等于没做。而这正是现代 EPUB 3 的主流形态 (6.5/B' 那轮已确认「本地 5 本书全部无 nav.xhtml」是样本偏差,不是市场事实)。

2.2 定案:C = 结构锚(块序号 + 块内比例)

block_idx(章内第 N 个文本块)+ block_frac(该块内已滚过的比例 0..1)。

抗重排版?City of GodRomance
A 像素❌ 实测排版把文档撑 +85%(B5 真凶)
B 锚点 id618 ch ✅53,936 ch ❌
C 块序号+比例✅ 块序号是 DOM 结构量,与锚点同源1,368 ch ≈ 0.6 屏,加 frac ≈ 精确327 ch ✅

C 与 B 抗重排版的理由完全相同:settings.jsrb-cache:// 页的整页重排版是纯 CSS (往 <head> 塞一个 <style id="__rb_epub_styles"> + 改 body.style.fontSize,已核实不改 body 结构), 只动像素不动 DOM。换字号 / 换栏宽 / 换窗宽后块序号不变,块内比例近似成立。

块枚举(save / restore 共用同一个函数,唯一决策处):

p, h1-h6, li, blockquote, pre, td, figcaption, dd, dt
  • 刻意不含 div / section:① 我们自己注入的 UI 全是 div;② div 在 Gutenberg 里是包装层 (h-0 有 78 个),计进去等于把「结构量」重新绑到排版实现上。
  • 再加一道 closest('[class*="rb-"]') 过滤:popup 内部有 <p> / <li>popup 开着时存位置 会让整章块序号错位。这道闸不是洁癖,是真实可达路径(双击查词后滚动)。

3. 存哪?—— 新表 epub_positions,不是 reading_pages 加列

「顺手往 reading_pages 加两列」是最省事的写法,但它做不成这件事

reading_pages 的行只在存词 / 划线时才产生ensure_source_for_url), 且 EPUB 章节导航刻意 logResourceOpen(backlog 第 4 条的「一个刻意的判断」)。

而 B4 的场景恰恰是「只读不存词,读了 47 屏」——挂在 reading_pages 上 = 「只有存过词的章才记得住位置」,等于没做。所以新表,一本书一行

sql
CREATE TABLE IF NOT EXISTS epub_positions (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id     TEXT NOT NULL,
    file_path   TEXT NOT NULL,        -- 剥掉 fragment 的纯 .epub 路径
    source_ref  TEXT NOT NULL,        -- 章:`filePath#锚点` / `filePath#ch{N}`(= reading_pages.source_ref 同形)
    block_idx   INTEGER NOT NULL,
    block_frac  REAL NOT NULL DEFAULT 0,
    updated_at  TEXT NOT NULL,
    UNIQUE(user_id, file_path)
);

三个决定:

  1. 章的身份复用 source_ref 形态epubSourceRef(em) 原样产出)→ 直接喂给现成的 epubStart.ts::hrefForAnchor,两种 fragment 形态(#锚点 / #ch{N}消费端零新逻辑。 这就是「在既有链末端加一环,不另起一套」的落点。
  2. per-user 走 user_id 列 + 读查询过滤,沿 v27 reading_log / v31 word_encounters 先例: 零数据丢失、不进 auth.rs 换用户清理循环,换用户靠过滤天然隔离。 user_id NOT NULL —— 未登录时命令静默 no-op(v31 同款:避开「UNIQUE 里的 NULL 互不相等」)。
  3. local-only,不进 Supabase 同步矩阵:RVH 无 EPUB 阅读概念,且 file_path 本就是设备本地路径。

不 touch last_opened_at —— 新表与「继续阅读」排序零交集,backlog 里那条刻意决定继续成立。


4. 回落链(epubStart.ts,末端加一环)

① preferredRef(调用方给的锚点)      → 章,**不带偏移**
② epub_positions 行                   → 章 + 章内偏移      ← 本轮新增
③ getEpubLastReadRef(reading_pages) → 章(存量数据回落)
④ start_index(跳过封面)
⑤ chapters[0]

刻意不带偏移:复习卡「打开原书」要的是那条词所在的位置,不是「你上次读到哪」—— 把不相干的偏移贴上去等于把用户扔到别处。

返回值 string{ href, pos? }resolveEpubStartHref 更名 resolveEpubStart)。


5. 偏移怎么送进页内 —— 必须一次性

__RB_EPUB_META 由 MainApp 在 navigation-state-changed 里注入,而这个事件同一页会反复触发 (6.6 的 D 滚动联动每跨一个目录锚点就 replaceStatereportNavigationState)。 把 startPos 无条件塞进 meta = 用户每滚过一个章节分界就被拽回原处

所以两道一次性闸:

  • 主 webview 侧tab.epubStartPos 只在「文档 base 与它记录的章一致」时随 meta 注入, 注入后立刻 updateTab(tab.id, { epubStartPos: undefined }) 消费掉。
  • 页内侧_startPosApplied 一次性标志(同一次注入被两个 restore 时点各调一次)。

restore 挂在restoreEpubAnchor 完全相同的两个时点(排版落地后 / 高亮管线跑完后), 共用既有的 _userMoved 让位闸 —— 用户已经在读了就不再抢滚动条。

startPos优先于 hash 锚点(锚点只精确到章首,偏移更细)。


6. 保存侧 —— 什么时候才允许写

页内 scroll spy 加一路(独立节流 1.5s trailing),但必须先「武装」

页面加载 → 原生锚点滚动 / 排版落地都会触发 scroll 事件。若此时就写库, 会在 restore 落地之前把 DB 覆盖成「章首」——下次开书位置就丢了。

武装信号取两个之一(谁先到算谁):

  1. 用户表现出移动意图(复用既有的 markUserMoved)—— 此时 restore 本就已让位, 保存用户的真实位置正是对的;
  2. 高亮管线跑完那一拍的 restore(= 恢复流程收尾)。

外加 pagehide 时无视节流 flush 一次(换章 / 关 tab)。


7. 改动清单

文件改动
src-tauri/src/db/migrations.rsv32 epub_positions(append-only,不动 schema.sql)+ 链测
src-tauri/src/commands/epub/position.rs新文件save_epub_position / get_epub_position + 单测(mod.rs 已 715 行,不再往里加)
src-tauri/src/commands/epub/mod.rspub mod position;
src-tauri/src/lib.rs注册 2 个命令
src/lib/commands.tsEpubPosition 类型 + getEpubPosition
src/lib/epubStart.ts回落链插 ②;返回 { href, pos }
src/stores/useTabsStore.tsOpenPayload.epub.startPos + Tab.epubStartPos + patchFor
src/hooks/useNavigation.ts · src/components/AddressBarReviewSegment.tsx适配新返回值
src/MainApp.tsxmeta 注入带 startPos + 注入后消费
src-tauri/src/content-script/features/epub-anchor.js块枚举 / restore / 保存 spy(+ 导出纯函数供单测)
src/lib/epubPosition.test.ts:块枚举(happy-dom)+ 挑块/还原(纯函数)单测

7.5 As-built(2026-08-13 收口)

按 §2-§6 落地,无偏离。三处实施时才浮现的细节,后来者按这里为准:

① 保存的「武装」条件比计划里更严:要「动过」「真滚了」

§6 原写「用户表现出移动意图(复用 markUserMoved)」。实现时发现这会把按了键但没滚 (⌘F、⌘+)也算进来 —— 那时视口还停在加载态的落点,一写就是用「章首」盖掉真实位置。 落地版把武装挪进 onSpyScrollif (_userMoved) _posArmed = true。 两个条件缺一不可 —— 只判 _userMoved 漏上面那类;只判滚动会把加载期的原生锚点滚动 算进来,同样是自我覆盖。

② 滚动监听的挂载点从 syncEpubTocAnchor 上移到 initEpubAnchor

原先 scroll 监听由 syncEpubTocAnchor 挂(startTocScrollSpy),而那个函数要 meta.tocAnchors 非空才走到底。目录条目不带锚点的书(Standard Ebooks 一族)那张表恒为空 —— 挂在那里等于「最需要章内位置的那类书恰好一次都不记」(正是 §2.1 里锚点密度垫底的那一类)。 改名 startEpubScrollSpy,无条件挂在 initEpubAnchor,两件事各自 gate 自己的前提。 连带把 markUserMoved 的监听也从 if (!_hash) return 后面挪到前面(不带 fragment 的书同样需要让位闸)。

③ 🔴 实机第一轮整个功能静默失效closest() 一路走到 <body> 就命中

第一轮实测 epub_positions 一行都没写。根因不在保存链路,在块枚举:

js
// settings.js:75 —— 每张 rb-cache:// 页都执行
document.body.classList.add('rb-reading-paper');

而枚举用 el.closest('[class^="rb-"]') 排除自注入 UI —— 它一路往上走到 body 命中, 于是整篇正文的每个块都被判成「我们的 UI」,枚举恒空 → 存不了也恢复不了,全程零报错calm.jsrb-calmstyles.jsrb-images-off 同类)。

修法:closest 命中 <body> / <html>放行 —— rb- 前缀在这两个节点上是 整页皮肤标记而非 UI 容器。.rb-reader-content 同样放行(它是 reader 档装正文的容器, 不是注入的 chrome)。popup 内部的 <p> 仍命中更近的 .rb-popup-root,照旧排除。

为什么 17 条单测全绿却没抓到:用例只造了 popup 这种子层容器,从没造 「祖先根节点rb- class」—— 而后者是每张 EPUB 页的常态,不是边角情况。 已补回归用例,并反向验证过(退回旧判据 → 3 条红)。 配套加了一次性 console.warn:枚举为空时喊一声 —— 此前「这章真没文本块」(正常)与 「枚举口径坏了」(bug)都表现为「什么都不发生」,这一行让两者一眼可辨。

③b 块枚举的 UI 过滤用 token 形式,不能用 [class*="rb-"]

*="rb-" 是子串匹配 —— 正文里 class="verb-list"rb-,会被整块当成我们的 UI 吞掉 (正文丢块,比多算几块严重得多)。落地用 [class^="rb-"],[class*=" rb-"],单测钉住。

④ 顺带修:⌘+ 改字号不再把读者甩回章节前面(用户实测提出,已确认后加做)

__rb_setFontSize 原先只改字号:正文整篇重排、文档变高,而 scrollY 原地不动 → 读者相对内容往回退(实测本来在章节中段,按几下 ⌘+ 就跑到章节前面)。

与 B4 是同一个结构锚,只是往返之间隔的不是「关掉重开」而是「一次重排」: 新增 captureReadingAnchor(),改字号前记「在第几块、块内多少」,改完按新块高折算回去 (正是「存比例不存像素」在这里成立的地方)。

范围:只接了 __rb_setFontSize(用户报的那条路径),栏宽 __rb_setContentWidth 是同形重排、 暂未接(照抄三行即可)。这条超出 B4 本身,是用户在验收时提出后单独确认要做的。

⑤ 🔴「继续阅读」把整条链短路在第 ① 环 —— 用户实测「每次重开都停在同一章」

实测现象:City of God 每次重开都落在 ARGUMENT 章首,epub_positions 里的位置从来没被用上

根因是回落链漏了一个调用方,且那个调用方的语义与 ① 相反HomePage 的「继续阅读」行点击传的是 entry.source_ref…epub#pgepubid00450最后存词那一章),进 openFileByPath 后就成了 preferredRef → 命中第 ① 环直接返回, 而 ① 是刻意不带章内偏移的。

两类调用方语义相反,我却当成了一个:

调用方意图该传什么
复习卡「打开原书」(AddressBarReviewSegment硬目标:那条词在哪传锚点(走 ①)
HomePage「继续阅读」回到我读到的地方纯路径(走 ②)

修法:「继续阅读」传 stripFragment(sourceRef)不丢信息 —— 查不到位置时第 ③ 环的 get_epub_last_read_reflast_opened_at 取同一本书最新那行,结果与原来那个锚点等价。 epubStart.ts 的链注释里加了 🔒,写明「① 只给硬目标调用方,别人不许往里传锚点」。

遗留(本轮未做,属 B9 粒度):「继续阅读」那一行的副标题仍显示 reading_pages 的章名 ("- ARGUMENT."),而落点现在走位置 —— 两者可能不是同一章,副标题与落点会对不上。 要对齐得让 get_continue_reading 认识 epub_positions,那是另一件事。

⑥ 🔴 位置恢复不能照搬锚点那道「让位闸」

⑤ 修完后落点对了,但偏浅约 9 个块:存的是 block 44/0.809,恢复落在 y=21701, 而那个 y 读回来是 block 35/0.039。

根因:我给 restoreEpubStartPos 抄了 restoreEpubAnchorMath.abs(scrollY - _posAppliedY) > 4 → 让位。第一拍在排版完全落地前算出偏浅的 y, 之后文档长高、WebKit 的 scroll anchoring 又自己微调了 scrollY —— 一超过 4px 就被 这道闸判成「别人动过」,第二拍的纠正被自己吃掉了

位置与锚点在这一点上不是一回事

  • 锚点那道闸存在,是因为它没法区分「排版长高了」和「用户自己滚了」;
  • 位置是结构量,每一拍都能从当前排版重新算出正确的 y,根本不需要记住上次放哪

修法:删掉 _posAppliedY,唯一的闸只剩 _userMoved;每一拍重算目标,已经在目标上就不动。 并加了 [RB-POS] 日志(每次真移动记一行,沿 [RB-NUDGE]/[RB-EVAL] 先例)—— 这类偏差在界面上只是「停在稍微靠前的地方」,肉眼判不出来,没有日志就查不动

⑦ 顺带修:目录标签离开面板时用上级限定(用户实测提出,已确认后加做)

⑤ 修完后用户追问「继续阅读里一直显示 ARGUMENT.,这算不算问题」。取证后确认是真缺陷, 且不是本轮引入的:

  • The City of God 的 35 条目录里 12 条就叫 ARGUMENT.(34% 塌进同一标签,唯一标签只剩 24 个);
  • 库里那行的真身是 #pgepubid00244 = BOOK SIXTH 的 ARGUMENT,而 reading_pages.name 存的是 The City of God, Volume I - ARGUMENT. —— 名字里毫无线索;
  • Romance 更甚:每个 Part 下的章都叫 I/II/III

机制:effectiveSourceTitle() = 书名 + tocLabel,而 tocLabel 直取目录条目自己的文字。 目录面板里没事(层级摆在眼前),一旦单独拿出来就废了。第二处受害更重: 笔记的「来源页」列表里同一本书会出现好几行一模一样的 ARGUMENT.

修法:新增 epubToc.ts::tocStandaloneLabel —— 重名才用最近的上级限定BOOK SIXTH. › ARGUMENT.),唯一标签原样返回。判据取「重名」而非「level > 0」: 顶层标签本来就唯一、加前缀是噪声,而 Romance 的 II 虽是 level 2 却非限定不可。 resolveTocLocationtocLabel 改走它 → 一处改,__RB_EPUB_META / reading_pages.name / reader 快照标题全部受益;目录面板不受影响(它自己渲染 toc[i].label,不经这里)。

存量行不回填(用户选定的 A 方案)ensure_source_for_url 命中已有行时本就不改 name, 所以老的 ARGUMENT. 会留着。B 方案(按锚点重算 name)要引入「为了改名去解析 EPUB」这条 新依赖,对 2 条存量行不值当。副作用epubToc.ts 里原有两处「只在展示层剥/解, 不碰任何写入路径」的注释就此失效,已改写说明「2026-08-13 起展示层含写入路径」。

与 ⑤ 的遗留叠加:限定标签落地后,「继续阅读」副标题(走 reading_pages)与落点 (走 epub_positions)不同章时会更扎眼(以前都叫 ARGUMENT 看不出来)。仍属 B9 粒度,未做。

⑦ 「继续阅读」显示的是最后存词那一章,不是读到的地方(用户实测,已修)

⑤ 修好了落点,但那一行显示的章名与时间仍全部来自 reading_pages —— 实测 Romance 位置是 #ch41(刚关 tab 那下),行上写的却是 "Romance - II" / 21 小时前。

修法(migration v33):epub_positionspage_name,由页内存位置时一并带下来 (effectiveSourceTitle() = 书名 - 章名)。为什么要存而不是现算:由锚点反查章名 需要那本书的目录,get_continue_reading 那条列表查询里做不到。

顺带改了成员资格(与我原先说的「只改显示」不同,实施时判断改掉更对): 「只有阅读位置、没有 reading_pages 行」的书现在也会出现 —— 「继续阅读」的语义是 「我读到哪」,读了半本却一个词没存也该跟着走。副标题的「已存 N 词」仍是真实计数, 「读过」与「读过且存过词」依旧分得开,不必再靠成员资格表达。

⑧ 当前章贴在目录最底下(用户实测,已修)

TocPanel 的自动滚动原用 block:'nearest' —— 按定义只滚最小距离,于是当前项恰好停在 容器边缘。重开一本恢复到中后段的书时,高亮那行贴在目录最下面一行,看不到前后文 (实测 Romance 的 Part IV › VIII)。改成「离两端有余量就不动,否则居中」: 前者保住了 nearest 当初想要的好处(读者滚过章节分界时目录不跟着抖),后者让 「位置突变」一次到位。直接改容器 scrollTop,不用 scrollIntoView({block:'center'}) (后者会连所有可滚祖先一起滚)。

改了什么

文件改动
src-tauri/src/db/migrations.rsv32 epub_positions + 2 条链测(建表/不进冻结基线、一本书一行 + user_id NOT NULL
src-tauri/src/commands/epub/position.rs新文件 248 行:save_epub_position / get_epub_position + 5 条单测
src-tauri/src/commands/epub/mod.rs · src-tauri/src/lib.rspub mod position; + 注册 2 命令
src-tauri/src/content-script/features/epub-anchor.js185 → 380 行:块枚举 / 挑块 / 还原 / 恢复 / 保存 spy
src-tauri/src/content-script/index.js两个恢复时点各加 restoreEpubStartPos();管线尾 armEpubPositionSave()
src/lib/commands.tsEpubPosition + getEpubPosition
src/lib/epubStart.tsresolveEpubStartHrefresolveEpubStart(返回 {href, pos}),回落链插②
src/stores/useTabsStore.tsOpenPayload.epub.startPos + Tab.epubStartPos + patchFor
src/hooks/useNavigation.ts · src/components/AddressBarReviewSegment.tsx适配新返回值(后者刻意不接 pos
src/MainApp.tsxmeta 注入捎带 startPos + 注入后立刻消费
src-tauri/src/content-script/features/settings.js__rb_setFontSize 前后夹一次 captureReadingAnchor()(④)
src/pages/HomePage.tsx「继续阅读」传 stripFragment(sourceRef)(⑤)
src/lib/epubToc.ts新增 tocStandaloneLabelresolveTocLocationtocLabel 改走它(⑦)
src/lib/epubToc.test.ts+6 例(限定 / 唯一不加噪 / 剥页码 / 顶层重名 / 不在目录 / 经 resolveTocLocation
src/lib/epubPosition.test.ts:19 例(块枚举 happy-dom 7 —— 含 ③ 的两条回归 —— + 挑块 7 + 还原 5)

验证

cargo test --lib 190 passed(含 v32_creates_epub_positions / epub_positions_is_one_row_per_book / position::tests 5 条 / flattened_baseline_matches_terminal 仍绿)· pnpm test 254 passed(新增 17)· cargo check + pnpm build 通过 · node scripts/check-schema-frozen.mjs ✓(schema.sql 未动)· pnpm build:cs 已跑 · /arch-check H1-H6 零新增命中、S24=7 S25=0 S27=3 全持平基线 · /ui-check U1=2(永久豁免)U2=U3=0 (本轮 .tsx 变更零 JSX 行,纯逻辑 + 注释)。

实机验证(2026-08-13,四轮,全部经 /debug 探针断言,非看屏幕)

轮次结果
1epub_positions 零行 → 挖出 ③(closest() 走到 body)
2两本书各写出恰好一行:City of God #pgepubid00450 44/0.59 · Romance #ch21 22/0.392(#ch{N} 回落形态,正是「目录不带锚点」那类书)→ 保存侧通
3用户实测「每次重开都停在同一章」→ 挖出 ⑤(「继续阅读」短路在第 ① 环);修完落点对了但偏浅 9 个块 → 挖出 ⑥(照搬锚点让位闸)
4冷启动往返精确闭合:关闭前存 block 62/0.1158(y=35761)→ 重启 app → 点「继续阅读」→ [RB-POS] target=62/0.1158 y=35761 landed=35761rb_scroll 复核 at.y=35761一像素不差;两拍 blocks=165 一致

cargo test --lib 190 passed(含 v32_creates_epub_positions / epub_positions_is_one_row_per_book / position::tests 5 条 / flattened_baseline_matches_terminal 仍绿)· pnpm test 262 passed(新增 25)· cargo check + pnpm build 通过 · check-schema-frozen ✓ · pnpm build:cs 已跑 · /arch-check H1(2 处为 SettingsPanel 既有注释)· S19=S22=0 · S24=7 · /ui-check U2=U3=0。

未单独复验:Romance 的恢复(保存已验,走的是同一条代码路径)· ④ 的 ⌘+ 对位(用户侧肉眼)。


7.6 实机验收清单

  1. City of God(锚点稠密):开书 → 目录选 h-6 → 往下滚十几屏 → 关 tab → 重新开书 → 应落回刚才那一处(而非章首)。
  2. Romance(目录条目不带锚点#ch{N} 形态):同上。这本压的是 §7.5 ② 那条 —— 挂载点若还在 syncEpubTocAnchor 上,这本书一次都不会记。
  3. 换字号(⌘+ 几下)后重开:落点应仍在同一段话,而非差出几屏(= 存结构量而非像素)。
  4. 复习卡「打开原书」:应落到那条词所在的章,不带章内偏移(回落链第 ① 环)。
  5. DB 断言:SELECT * FROM epub_positions 每本书恒 1 行。

8. 附录:密度实测脚本

python
# 统计每章 id / <a name> / 块元素数与平均字符间距
import zipfile, re
z = zipfile.ZipFile(path)
raw  = z.read(name).decode('utf-8','replace')
body = re.sub(r'(?is)<(script|style)[^>]*>.*?</\1>', ' ', raw)
text = re.sub(r'\s+',' ', re.sub(r'(?s)<[^>]+>',' ', body)).strip()
ids  = set(re.findall(r'\sid\s*=\s*["\']([^"\']+)["\']', raw))
ps   = len(re.findall(r'(?i)<p\b', raw))