Skip to content

fix(footnotes): stop ancestor blocks from poisoning note definitions - #133

Merged
finlater merged 2 commits into
finlater:mainfrom
baily-zhang:fix/footnote-definition-poisoning
Sep 3, 2026
Merged

fix(footnotes): stop ancestor blocks from poisoning note definitions#133
finlater merged 2 commits into
finlater:mainfrom
baily-zhang:fix/footnote-definition-poisoning

Conversation

@baily-zhang

@baily-zhang baily-zhang commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

变更说明

修复繁简双语书籍中脚注定义被祖先块污染的缺陷。collect_definitions() 会把整章正文误存为单条脚注定义,导致在 KOReader 默认的页内脚注流中,每个脚注标记都渲染出一大段拍平的章节正文,读者看到大量重复文字。

相关:#137(调试同一本书时发现的 font-size: 0 根字号问题,已另开 PR)。

类型

  • Bugfix
  • Feature
  • Refactor
  • Documentation
  • Other

Bugfix 要求

无关联 issue(上游此前无人报告过此问题),提供复现步骤。

复现步骤(修复前):

  1. 用插件下载一本繁简合排的双语 EPUB。实测书目:《天才的責任:維根斯坦傳》繁简版
  2. 在 KOReader 中打开该书,保持默认的页内脚注(in-page footnote)显示方式
  3. 翻到任意带脚注标记的正文页并触发脚注
  4. 现象:脚注展开的不是注释本身,而是整章正文(繁简两个版本被 clean_note_text 拼接在一起),页面上出现大段重复文字

实测环境:KOReader v2026.07.1,Kindle。

根因:

collect_definitions() 遍历 BLOCK_TAGS 时,对每个匹配到的外层块,会用外层块的内部文本去记录其下所有带 id 的后代标签。在繁简双语章节中,章节根级 wrapper 几乎覆盖整章,同时包含正文锚点与章末注释,于是每个脚注锚点都被索引成了整章拍平文本。先到先得的守卫(definitions[anchor])让问题进一步恶化:一旦被污染的祖先捕获先于真正的定义块被记录,正确的短文本就再也无法替换它。

实测数据(受影响书籍):生成 606 条 aside,其中 60 条为被污染的巨型条目,最长约 53500 字符,而正常注释的中位长度约 130 字符。

修复:

remember_definition() 现在会:

  • 直接拒绝超过 MAX_NOTE_TEXT_BYTES(6000 字节,约 2000 个中日韩字符)的候选,并记录一条警告日志。真实注释远低于此阈值,整章捕获远高于此阈值
  • 每个锚点保留最短候选而非先到先得。真正的注释是描述该锚点的最小区域,祖先捕获必然是它的严格超集
  • 拒绝纯符号候选,使共用同一 id 的回链字形(如 )无法顶替真实注释文本

Feature 要求

不适用,本 PR 为 bugfix,未新增特性。

测试

  • 已在 KOReader 中手动测试
  • 已运行 bash scripts/run_lua_specs.sh
  • 已运行 bash scripts/check_lua_namespace.sh
  • 已运行 luacheck main.lua _meta.lua weread spec
  • 如涉及插件加载/KOReader 兼容性,已运行或触发 KOReader integration
  • 不适用,仅文档或注释变更

测试说明:

新增回归 spec(spec/footnotes_spec.lua,+91)覆盖四种情形:
1. 章节根级 wrapper 污染
2. 阈值之下最短候选优先
3. 长但合法的注释被保留(约 1300 个中日韩字符)
4. 纯箭头符号候选被拒绝

已验证这些新 spec 在修复前的代码上失败、修复后通过。
全套 45 个 Lua spec 通过;check_lua_namespace.sh 无告警;
luacheck main.lua _meta.lua weread spec 无告警。

未触发 KOReader integration 工作流:本 PR 只改动 footnotes.lua 内部的
定义收集逻辑,不涉及插件加载路径或 KOReader 兼容性。

设备侧已手动验证:修复后该书正文 4117 段、470866 个可见字符零丢失,
重复文字现象消失。

非公开 WeRead API

  • 不涉及非公开 WeRead API
  • 已新增或更新可复现的 Python 验证脚本

脚本路径:

不适用

复现命令与脱敏结果:

不适用。本 PR 只修改本地 HTML 解析阶段的脚注定义收集逻辑,
未新增或修改任何 WeRead Web API 调用。

模块结构

未新增或移动任何模块。仅修改既有的 weread/lib/footnotes.lua,并新增 spec/footnotes_spec.lua。命名空间保持不变,未引入根级 lib/ui/ 模块,也未引入裸 lib.*ui.* 模块键。

截图

不适用。本 PR 是渲染缺陷修复,未新增或修改 UI、菜单、弹窗与交互。

Checklist

  • 我已经说明这个 PR 解决的问题或新增的特性。
  • 如果是 bugfix,我已经提供复现步骤或关联 issue。
  • 如果是新增 UI/交互特性,我已经提供截图或录屏。(不适用:未新增 UI 或交互特性)
  • 我没有提交 KOReader settings/weread.lua、API key、cookie、token、x-wrpa-* 或私人书籍内容。
  • 新增或移动的项目 Lua 模块遵循 weread/lib/weread/ui/ 命名空间规范。(不适用:未新增或移动模块)
  • 新增或修复的逻辑包含对应回归测试;若无法自动化,已在测试说明中解释原因。
  • 如果修改了用户可见文本,我已经更新 weread/lib/i18n.lua。(不适用:未修改用户可见文本)
  • 如果修改了菜单结构,我已经同步更新 README 菜单结构。(不适用:未修改菜单结构)
  • 如果涉及非公开 WeRead Web API,我已经在 scripts/ 中提交可独立运行、可复现的 Python 验证脚本,并填写了复现命令和脱敏结果。(不适用:不涉及)

collect_definitions() remembers every id-bearing descendant of each
matched block using the outer block's inner text as note content. In
dual-language WeRead chapters a chapter-root wrapper spans nearly the
whole chapter, so footnote anchors were indexed with entire flattened
chapter text (both language variants concatenated) as the note body.
The generated wr-book-footnote asides then pasted those blobs into
KOReader's in-page footnote flow at every marker; one real book
produced 60 giant asides of up to ~53k chars alongside 546 legit ones.

remember_definition() now:
- rejects candidates longer than MAX_NOTE_TEXT_BYTES outright, with a
  warning log
- keeps the shortest candidate per anchor instead of first-win: the
  true note is the smallest region describing the anchor, and ancestor
  captures are strict supersets of it
- refuses pure-symbol candidates, so backlink arrows can no longer
  displace real note text via a shared id

Adds regression specs for wrapper poisoning, shortest-wins ordering,
long-but-legit notes, and arrow-only candidates.
@finlater

Copy link
Copy Markdown
Owner

请模板提交 cr

@baily-zhang

Copy link
Copy Markdown
Contributor Author

已按 .github/PULL_REQUEST_TEMPLATE.md 重新整理了 PR 描述,补齐了变更说明、类型、Bugfix 复现步骤、测试、非公开 API、模块结构与 Checklist 各节,感谢提醒。

有一项想说明:测试小节里的「KOReader integration」我留空了。本 PR 只改动 weread/lib/footnotes.lua 内部的脚注定义收集逻辑,不涉及插件加载路径或 KOReader 兼容性,理由写在测试说明里了。如果你希望无论如何都跑一次,我可以补上。

截图一节也标了不适用:这是渲染缺陷修复,没有新增或改动 UI、菜单与交互。设备侧的验证结果(正文 4117 段、470866 个可见字符零丢失)写在测试说明里。

@finlater

finlater commented Sep 2, 2026

Copy link
Copy Markdown
Owner

这里 has_word_or_cjk_char() 只判断了 %w 和 0xE4~0xE9,好像会漏掉日文假名、韩文以及其他非 ASCII 字符。

如果脚注只有这些文字,可能会被误认为纯符号直接丢掉。这里是不是直接针对已知的回链符号过滤会更安全一些?

… script whitelist

Review feedback: has_word_or_cjk_char() only knew %w and the CJK UTF-8
lead bytes 0xE4-0xE9, so notes written only in kana, hangul, Cyrillic or
any other non-ASCII script were mistaken for pure backlink glyphs and
dropped.

Invert the test: a candidate is rejected only when every codepoint it
holds is a symbol or punctuation (arrows/dingbats/enclosed numbers
U+2000-U+2FFF, CJK punctuation U+3000-U+303F, Latin-1 symbols
U+0080-U+00BF, variation selectors, BOM). Any other codepoint -- Latin
letters, kana, hangul, CJK, emoji -- counts as note text, so the backlink
glyph guard keeps working without whitelisting scripts.
@baily-zhang

Copy link
Copy Markdown
Contributor Author

这里是不是直接针对已知的回链符号过滤会更安全一些?

已改成反向过滤(f65cf22):新增 is_symbol_only(),只有当候选文本每个码点都是符号/标点时才丢弃,其他任何码点都算正文直接保留:

  • 符号区间:回链箭头、注标符号、圈数字(U+2000–U+2FFF);CJK 标点(U+3000–U+303F);拉丁补充符号(U+0080–U+00BF);变体选择符与 BOM
  • 文本区间:假名、韩文、西里尔文、希腊文、CJK 扩展区、emoji……全部保留

没有采用手列回链符号清单,主要是清单列不全:↩ ← ↑ ⤴ 之外,——«» 这类纯符号候选一旦漏掉,会以 3 字节的长度在 shortest-wins 里挤掉真正的注释。按码点区间判定"是否整体纯符号"可以一次挡住所有这类候选,同时不误伤任何文字脚注;带变体选择符的 ↩️(U+21A9 + U+FE0F)也覆盖了。

spec 补了两组用例:假名/韩文/西里尔文/CJK 扩展区 B/注标混合文本的脚注必须保留(旧实现下会失败),以及 ↩ ← ⤴ ① 。 «» …… 等纯符号候选仍被拒绝。

@finlater
finlater merged commit 0be5f4e into finlater:main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants