每本"翻车"书一条病例,格式固定:
书目 / 引擎 | 现象 → 根因链 → 修补点(文件:函数)→ 回归测试 → 状态
阅读指南:结构判断的修补只落在三层—— 适配器层(stage1_*:把引擎脾气翻译成契约,只动文本形态)、 契约信任层(stage2_common:凡 LLM 输出皆首票,真值来自几何与投票)、 系统层(路径/打包/分片,与内容无关)。 每条守卫的失败方向必须是"不动作",不是"乱动作"。
- 现象:EPUB 被切成 20+ 章,章名错乱重复('one 7 JANUARY 1976' 出现在错误页)。
- 根因链:目录页码被排成独立 aside_text 数字块(与条目分离)→
LLM 提取 toc_entries 配对不上,瞎编等差页码(真实 1/23/43… 编成 1/17/33…)→
_rescue_by_page偏移投票全乱 → 按错误偏移在正文合成 8 个幻影标题块。 - 修补(stage2_common.py):
_repair_toc_pages(新):条目块与数字块按 bbox y 坐标同行重配真实页码 (dy≤25 千分位贪心,支持罗马数字→置空,因其与正文偏移 regime 不同);_rescue_by_page:偏移众数 <3 票直接放弃救援(票分散 = 页码不可信); 已满足判定加"完整键精确命中即满足"(强证据不看位置);_build_anchors:剥尾页码保留完整形态('1976' 不是页码是年份); 恰好两级且高层级 ≤2 个无编号条目 → 收敛到多数层级(防 LLM 拔高前言);_calibrate_levels:目录页标题降格(toc_pages 由 repair 识别: ≥3 条目命中 + ≥3 数字块);同页同文标题去重(含锚点富化后的重复);_normalize_title:加 casefold('FOREWORD' ↔ 'Foreword: …')。
- 回归:tests/test_stage2_toc.py — repair_* / rescue_* / anchor_* / calibrate_* 共 10 例;本书重跑树 = one–eleven 平铺、零幻影。
- 状态:已修复并验证。
- 现象:
--skip-mineru重跑时 FileNotFoundError: 工作目录找不到。 - 根因:PDF 文件名 '...(David Macey) .pdf' 词干以空格结尾,Windows 建目录 时静默剥离,后续按原名 iterdir 失败。
- 修补:pipeline.py / app.py 统一
stem.rstrip(" .")。 - 回归:本书重跑通过。状态:已修复。
- 现象:首跑只剩 15 个平章,编层级丢失;二跑 53 章过度切分(目录条目 + 列表项混进树)。
- 根因链(四个独立问题):
- 目录 ~200 条,
_light_metadata_pass单次响应超 max_tokens 截断 → toc_entries 全丢 → 0 锚点; - 固定 200 页分片 → 3 片排队 48 分钟;
- PaddleOCR 输出 markdown 转义('1. 要件'、214 处 ']')→ 编号形状检测 失效 → 列表项被投成 L1;
- 简目(独立条目)+ 详目(点线页码 blob 块)并存,目录页识别单一形态 覆盖不了;且初版按行识别误杀章首页(章+本章节标题,形似小目录)。
- 目录 ~200 条,
- 修补:
- stage2_common:轻量兜底失败拆两个紧凑重试(
_LIGHT_META_PROMPT+_LIGHT_TOC_PROMPT数组格式只采样书首); - stage1_paddleocr:
_chunk_pages按平均页体积自适应(≤45MB/≤1000 页, 本书单任务 709s,提速 4 倍);_MD_ESCAPE_RE数学区外解转义; - stage2_common:
_detect_toc_pages_by_entries(新):条目行命中 ≥3 + (数字行 ≥3 或占比 ≥50%)+ 印刷页码跨度 >30(章首页跨度≈0 不误杀)+ 邻页扩展(长目录残余页)。
- stage2_common:轻量兜底失败拆两个紧凑重试(
- 回归:tests 新增 parse_toc_array / unescape / detect_toc_pages_* 6 例; 终验 21 单元(6 编 15 章)与 MinerU 基准完全一致。
- 状态:已修复并验证。残留 cosmetic:'Default Title' 空章(popo 占位)、 第六编 重复一章(页眉被 _PART_HEADER 捞回,与真章扉页相邻同名)。
- LLM 标题投票有 ±1 轮波动(同一本书两跑标题数差几个)——锚点+形状栈 吸收大部分,残余为散文小标题被投成 L1(内容无损)。
- LLM 提取的目录层级仍可能错(两级目录收敛只兜"拔高前言"一种形态)。
- 页码与条目同行但 y 错位 >25 千分位的目录版式,重配会漏。
- 单元测试:
tests/test_stage2_toc.py(23 例,每例对应真实病例场景, 运行python tests/test_stage2_toc.py) - 结构体检表:
qc_book.py <work_dir>...(内容完整=红:非空块缺失/ 目录条目未锚上/幻影合成块;结构瑕疵=黄:重复标题/空章/降级 metadata; 自动配套 content_list 与 popo_blocks 的引擎版本) - 基准书目(结构真值):
- 民法总论(561p,七层深目录):21 单元 = 6 编 15 章,874+ 页脚注 (MinerU / PaddleOCR 双引擎须一致)
- 必须保卫社会(328p,英文,页码分离目录):one–eleven 平铺、0 幻影
- teoh2010flame(21p 论文,跨页大表):5 章、11 HTML 表、上下标残留 0
- 现象:'答学友问'、'参考文献'、'人名索引' 三个后页节标题未锚上。
-
根因链(三种独立的匹配缺口):
- '答学友问' 正文块是 '答学友问1'(差 1 字),模糊匹配门槛 len≥6 把它挡在门外;
- '人名索引
$^{①}$ ' 的上标脚注标记进了归一化键; - '参考文献' 的正文标题只以 header 形态出现(PaddleOCR 把章节首页的 标题行当页眉),popo/convert.py 丢弃全部非编名 header。
-
修补:
_match_anchor模糊门槛 len≥4;_normalize_title剥$^{...}$与法式装饰前缀 '— X. —';popo/convert.py 页眉每种文本首次出现保留为 text(交锚点晋升裁决,编名仍直接捞回为标题)。 - 顺带:同页缩写标题去重('21 | 阿伦特Ⅱ' vs 完整章名,去标点+前导 编号后前缀判定,同级弃缩写)——解决章占位页的"空章"问题。
- 回归:anchor 52/55 → 55/55,黄牌及以下。
- 状态:已修复并验证。
- 现象:页码救援在 P194 合成 '— X. — La crise des flèvres' 幻影标题。
- 根因:目录条目带法式装饰前缀 '— X. —',且 OCR 把 'flèvres' 识成 'fièvres'(l/i 一字之差)——真实块 'la crise des fièvres' 距离 >2 锚不上。
- 修补:
_normalize_title剥装饰前缀后距离=1,模糊命中,幻影消失。 - 回归:anchor 13/13、synth=0。状态:已修复并验证。
- 现象:详目页(blob 形态,单页只覆盖一章小节)未被目录页识别; 4 个小节标题未锚上。
- 根因:
- 目录页识别的印刷页跨度门槛 30 太严——详目单页跨度仅 ~22; 章首页跨度≈0 才是误判源 → 门槛降至 5(章首页依旧安全);
- 轻量兜底紧凑重试的响应里末尾条目被 LLM 截断('9.2.1 Kau'), 前缀规则只覆盖"块是锚点前缀",不覆盖反向。
- 修补:跨度门槛 30→5;
_match_anchor加"锚点是块的前缀" (限锚点 ≥6 字符,防 '1.1' 误配 '1.1.2')。 - 回归:未锚 4 条全部锚上(168/172 → 172/172 待终验)。
- 已知残留(黄牌,内容无损):运行头=章名的书会有跨页重复标题与 空章(缩写去重只管同页);'CHAPITRE VIII' 多页重复章名同类。
- 状态:已修复,终验中。
- 现象:竖排全书。体检仅黄牌:metadata 完整(川端康成/偕成社/ja), 34/34 标题锚定,5 个 'image' 空章(插页被投成标题)。
- 结论:PaddleOCR-VL 对竖排日语的支持超出预期,正文可转; 'image' 空章与假名连浊误差属引擎层边界,结构层不硬修。
- 状态:记录在案,不接。
- 背景:用户指出两类"能用但不好用"——CMP 图注被误判为标题造成层级紊乱; 刘擎篇内无编号小标题('思想内在于现实')吸附到讲次同级。
- 机制修复(证据优先级:锚点 > 几何 > LLM 首票):
- 图注/表注位置过滤(
_calibrate_levels):与图块 x 重叠、垂直紧贴 (图注在图下/表注在表上 ≤3% 页高)、字号不明显大于正文的标题候选 降回正文;锚得上的绝不动。 - 无编号无锚标题下沉(
_sink_unanchored_plain):无编号标题若真是 编/章级目录里一定有它,锚不上就必须严格深于所属锚定章。
- 图注/表注位置过滤(
- 连锁暴露的四个纠缠问题:
- 斜杠页码('xxx / 060')未剥 → 目录页识别失效、目录整份混进树
(24 个空章)→
_TRAIL_PAGE_RE统一剥点线/空格/斜杠/破折号; - 系列守卫:'答学友问1..12' 全部模糊命中父锚点 '答学友问' 逃过下沉 → 块=锚点+数字/字母后缀时禁止模糊命中;
- 下沉必须在页码救援之后(系列块依赖救援先锚定首项);
- 模糊上限不对称:LLM 笔误让条目比正文长('PRÉSPACE' vs 'PRÉFACE') → 上限按两者较长者定(<8 容 1,否则容 2)。
- 斜杠页码('xxx / 060')未剥 → 目录页识别失效、目录整份混进树
(24 个空章)→
- QC 同步:page_fuzzy 位置晋升视为满足(系列块的合法锚定方式)。
- 回归:刘擎空章 24→1、55/55 锚定、答学友问系列正确嵌套; 单元测试 32/32。
- 状态:已修复并验证。25 45 23
- 目录页识别第三判据(用户提议的"页码特征"落地):点线引导行
('xxx …… 60'、'xxx ..... 153')几乎只出现在目录/索引页,≥3 行且
页码跨度 >5 即判目录页——与引擎和条目文本都无关,是最本质的判据。
罗马数字页码在
_repair_toc_pages已处理(置空不参与偏移)。 - 运行头收敛:同一标题文本在更早页面已出现、本块位于页首(y2≤8%) → 页眉重复降回正文(锚点视为已被首次出现消费)。CMP 跨页重复标题 的主要来源。
- 孤儿编号先救后罚(
_fix_orphan_series):某编号家族全书无 '1' 且系列 ≥3 起跳 → 几乎必是列表项误判('4.' 孤挂层级顶);先在附近 ±3 页找 '1' 晋升救回,找不到才把整个系列降回正文。2 起跳保守不罚。 - 运行头规则的一个教训:初版"锚定块不动"使规则完全失效(运行头 与章名同文,也能锚上)——锚点必须视为被首次出现消费。
- 回归:单元测试 35/35;全语料终验见当轮汇报。
- 状态:已修复并验证。
- 必须保卫社会:🟢 全绿;刘擎 55/55(空章 1);民法总论 165/165; 伊豆 26/26;Naissance 13/13。
- CMP 重复标题 31→0(运行头收敛生效),但体检新红 4 条未锚——排查结论 非本轮回归:'10 Crystal dynamics' 等 2 条在 content_list 中根本不存在 (PaddleOCR 漏识别章头,正文内容完好;稠密章首页按设计不合成标题); '13 Electrons in the periodic potential' 同;'A.6 Elements of statistical mechanics' 是印刷目录与正文标题的真实措辞差异(目录多 'Elements of', 编辑距离 9 超出模糊上限)。三条均属引擎层边界,结构层不接。 本轮 toc_entries 191 条(LLM 抽取波动,上轮 172 条不含这 4 条)。
- 章头被 OCR 整块漏识别且首页稠密:内容完好但标题不进目录 (稠密页不合成是有意取舍——插错位置比缺标题更糟)。
- 印刷目录与正文标题措辞差异大('Elements of statistical mechanics' vs 'Statistical mechanics'):超出模糊匹配上限,需更松的语义匹配,暂不接。
- 完整几何判别(垂直留白/对齐偏离/字号聚类定层级)尚未实现; 已落地的是图注位置过滤与页首运行头收敛两个几何点。
- 背景:完整几何判别的落地。调研 pdf-craft(AGPL)后确认其核心武器
是
split_by_cv字号聚类——它不读 PDF 文本层(全书光栅化),"字号" 就是 bbox 高度,与我们 bbox-only 输入同构。居中/留白/对齐它一律没用, 证明那些不是必需品。按算法思想重写,未搬代码。 - 实现(stage2_common.py):
_split_by_cv:变异系数阈值 + 最大间隔二分,把标题块高度(bbox 高,0..1,标题通常单行一块无需多行聚合)聚成字高档位(rank 0 最大);_height_ladder_map:用锚定标题的真实层级标定每个档位 → rank→level 映射(无锚定的档位向更浅的借一级);- 接入
_sink_unanchored_plain:无编号无锚标题在下沉硬约束 (不得浅于所属锚定章+1)之上,按字高档位只许加深、不许上浮—— 字大的小标题停在本章下一级,字小的继续沉。max_cv=0.1(宁细勿粗, 误分只会在保守方向)。
- 回归:单元测试 37/37;全语料终验见当轮汇报。
- 状态:已实现并验证。
| 特征 | 用途 | 状态 |
|---|---|---|
| bbox 位置邻接(图下/表上) | 图注/表注过滤 | ✅ 病例 008 |
| 页首位置 + 前文同名 | 运行头收敛 | ✅ 病例 009 |
| bbox 高度聚类(split_by_cv) | 无锚标题层级差异化 | ✅ 病例 010 |
| 垂直留白/对齐偏离 | 评估后放弃(pdf-craft 也不用) | ❌ 不接 |
- 全局一致性定级(
_global_level_pass,stage2_common,开关GLOBAL_LEVEL_PASS):确定性规则把候选标题列表洗干净后,把全书 标题表(ID/页/形状/锚定/文本)交给 LLM 一次定级,替代分块局部投票 的漂移。锚定锁死、无编号标题受下沉底线钳制、层级钳 1..8、失败 自动保持现有层级(安全降级)。 - A/B 验证:刘擎 0 调整(LLM 完全同意确定性路径——最强佐证)、 CMP 30 调整(锚定 168/168 不变)、民法总论 105 调整(空章 6→13 后 回落,见下)。
- 运行头阈值修正:民法总论出现全章节重复,根因是页眉 y2=0.084 恰好超过 0.08 阈值(本轮 LLM 投票波动使更多页眉被投成标题,暴露了 阈值盲区)→ 放宽到 0.10,15 章零重复恢复基准。教训:几何阈值 必须留足 OCR 抖动余量,且要在多本书上标定。
- 回归:单元测试 39/39。
- 状态:已实现并验证。
- 现象:发版回归 3 本全管线,4 条目录条目未锚上。
-
根因与修补(全部是标题键归一化缺口,
_normalize_title):- 公式节标题:目录写
$f(x)=..$型,正文是 equation 块$$ 一、f(x)=.. 型 $$→ 剥$定界符对齐; - LaTeX 排版命令:正文
\left[ \right]、目录裸括号 → 剥\left/\right(残余 1↔i 一字 OCR 误差由模糊匹配兜住); - 弯直引号:正文大写直撇号、目录弯撇号(Born a Crime ch14 长标题) → 弯引号统一为直引;
- 超短尾部:章名碎块 'RUN'(3 字母)对 'Chapter 1: Run' → 尾部匹配 len≥4 放宽到 ≥3。
- 公式节标题:目录写
- 边界(不接):Born a Crime 'Dedication' 正文无标题块(引擎漏识, 内容完好);高等数学/Born a Crime 各剩 1 条未锚属此类引擎边界。
- 回归:单元测试 40/40;高等数学 140/140、Born a Crime 21/22 ('Chapter 1: Run' 锚上)、城市与国家财富 16/16。
- 状态:已修复并验证。
- 现象:8/15 修复后用户跑回归仍报"公式不居中、标题 heading level 不对 (TOC 是对的)"。审计新 EPUB:2726 处 block 看似正常,但 257 处独占段落的 显示公式仍 inline 贴左;且章标题(h2.chapter-title)与节标题(h2)同级, 节标题渲染成居中大字与章视觉平级。
-
根因链(四条独立缺陷,层层掩盖):
-
双 display 属性(
_latex_to_mathml):latex2mathml 的 convert() 自带display="inline"(标签尾部),我们再插一份alttext+display→<math alttext display="inline" xmlns display="inline">。ebooklib 序列化去重后 inline 胜出——promote 替换第一个属性后仍被第二个抵消, 升格全部落空($$ 定界公式的 block 同病); -
正则跨段吞并(
_P_LONE_MATH_RE):math 部分.*?</math>懒惰匹配 允许跨</p>——前段 math 的 post 超长匹配失败时回溯扩展到后段的</math>,形成跨段大匹配;repl 按前段 latex 判定不合格即整体原样 返回,后段(如 '(1) f(x)=\left{...' 例题)永远失去单独匹配机会。 此 bug 单段调用永不暴露(单测全绿、探针全升、整文却漏),且反向造成 误升(大匹配判定通过时连带升格前段的行内引用公式); -
pre 过严:
_PUNCT_NUM_RE剥空才放行——"解/证/双曲正弦/例1设"等 短叙述前缀的整段显示公式 74 处贴左(附录习题答案重灾区); -
标题层级映射:popo 路径
h{min(level,6)}直用结构层级,节与章 撞 h2(章标题固定 h2)。
-
双 display 属性(
-
修补(stage3_epub.py):
-
_latex_to_mathml:替换 latex2mathml 自带 display 的值,不再另插; -
_P_LONE_MATH_RE:math 部分改 tempered token(?:(?!</p>).)*?禁跨段,replace(..., 1)改全量替换(防御双属性残留); - pre 放宽:
[^<$]{0,12}+ 剥标点数字编号后 ≤4 字放行(post 保持严格, 行文行内引用一律被 post 挡住);_PUNCT_NUM_RE补圈号 ①-⑳ ㈠-㈩; - 标题层级:章内标题按相对章深度偏移(节 → h3、小节 → h4,两条渲染 路径同规);CSS h3 改居中(贴合原书节标题居中排版)。
-
- 回归:新增 tests/test_stage3_promote.py 12 例(含双 display、跨段吞并 场景——后者复现整文/单段分歧);结构单测 40/40;高数重跑 block 2726→ 3223(误升消退后净升 497)、独段 inline 257→156(残余=短答案行不该升)、 '(1) f(x)' 段升 block、章 h2/节 h3 层级正确、QC formula_artifacts 0。
- 边界(不接):multi_math 13 处——同一公式被引擎拆成两块(block 主体 已居中,"+C" 尾巴 inline 残留),合并 MathML 过于脆弱,记引擎层边界。
- 状态:已修复并验证。
- 现象:上述修复全部落地、EPUB 文件验证无误后,用户在 SageRead 实例里 看到的公式仍然全部贴左(截图实证)。
- 排查(CDP 穿透 foliate 的 closed shadow root 实测 computed style):
block 公式的
text-align: center已生效,但视觉贴左——因为 DEFAULT_CSS 写了math[display="block"] { display: block; },把 UA 样式表的display: block math(MathML Core 的块级数学布局,内容自动 水平居中)覆盖成普通块盒,数学内容 shrink-to-fit 贴左;text-align 管 不到数学布局内部。A/B 实证:把 display 恢复block math后整页公式 即刻居中。 - 修补:
- 转换侧(stage3_epub.py DEFAULT_CSS):删除
display: block声明, 保留 text-align:center 作冗余保险——新产物不再破坏 UA 布局; - 阅读侧(SageRead
utils/style.ts注入样式):显式math[display="block"] { display: block math; text-align: center; }——存量旧产物无需重转,注入样式统一兜底(SageRead commit 同步)。
- 转换侧(stage3_epub.py DEFAULT_CSS):删除
- 教训:EPUB 文件级验证(解包看 HTML/CSS)与渲染级验证(阅读器实测) 必须都过——本案文件完全正确、渲染却是坏的;"315/315 居中"的验证当时 只抽查了 display 属性,没看最终像素。
- 回归:实例内打开未重转的 v1.3.2 版高数(旧 CSS),注入样式兜底后 习题区行列式/分式/编号公式全部居中,节标题层级正确。
- 状态:已修复并验证(exe/zip 重打,Release v1.3.2 资产已更新)。
- 现象:8/15 干净重跑后用户仍报"块级公式不居中"(对照外部 z-lib 图片版 居中)。DOM 实测 display=block 的公式全部精确居中——不居中的根本不是 它们:审计发现 840 处显示型公式(例题 y=frac…;、定义域映射、推导链) 被标 display="inline",独占段落却按行内排,吃 2em 首行缩进贴左、行内 字号。
-
根因链:MinerU 把显示公式以单
$…$ 定界输出;_mathmlify 的行内分支 照单全收转 display="inline"。(8/4 paddleocr 脏解析事件修复后,同一本书 剩余的"不居中"观感即源于此——与阅读器无关。) -
修补(stage3_epub.py):
promote_lone_display_math段落级后处理—— 整段唯一数学区 + LaTeX 含显示结构命令(frac/left/sum/…)且长于 25 字符- 前后仅标点/编号 → 升 display="block";叙述前缀("双曲正弦"/"例 1 设"/ ",即…")的行内引用不动。四个内容发射点(正文/前置/后置/ch_html)接线。
- 回归:高等数学重跑,block 2008→2726(升格 718 处),剩余 183 处 独段 inline 均带叙述前缀属正确行内;应用内实测第一章 315 个可见块级 公式 315 居中 0 偏心;升格判定单测 6 例全绿。
- 状态:已修复并验证(exe 已重打同步双实例 binaries)。
- 现象:A Modern Introduction to QFT / QFT and the Standard Model
转出的 EPUB 全书公式都是
<code class="latex">裸 LaTeX 源码(阅读器 原样显示源码);同期高等数学(外部 EPUB,前缀式 MathML)渲染正常, 排除阅读器侧嫌疑。 - 根因链:latex2mathml 的符号表
unimathsymbols.txt是包内数据文件,symbols_parser运行时 open 读取;books_converter_cli.spec的 hiddenimports 只收模块不收数据文件 → frozen exe 里符号表缺失 → 每条公式转 MathML 都 FileNotFoundError,被_latex_to_mathml的except Exception吞成 None →_mathmlify全量走<code>兜底。 无公式书不触发,此前未暴露。 - 修补(books_converter_cli.spec):
datas=collect_data_files('latex2mathml')(与 papers_converter_cli.spec 收 pypinyin 数据同款方案)。 - 回归:archive_viewer 确认新 exe 含 unimathsymbols.txt;旧 exe 从 staging 重跑复现(chapter_004: 0 MathML/126 code),新 exe 同 staging 重跑(全书 16609 MathML/1 兜底);两本坏书已从 staging 重跑 stage3 并原位替换 book.epub,应用内 foliate 实测 1108 math/1044 渲染/0 兜底。
- 状态:已修复并验证(exe 已同步 SageRead 双实例 binaries)。
-
现象:正文段落公式已渲染(病例 013 修复后),但表格单元格、表注/
图注、章节标题里的公式仍是裸
$…$源码(如 h4 "26.3$e^{+}e^{-} \rightarrow$ hadrons"、td "$\left(\frac{1}{2},0\right)$")。 -
根因链:
_mathmlify只挂在正文段落渲染路径(_render_popo_body 的 seg 处理);标题(<hN id>/chapter-title/part-title 四处发射点)、图注/ 表注(caption_map/small/strong)、表体(MinerU HTML 原样透传)、列表项 均未过公式转换。旧_render_block_to_html路径同病。 -
修补(stage3_epub.py):
- 上述全部发射点补
_mathmlify(alt 属性除外——属性值不能放 MathML); - equation 块兼容 latex/text 字段形态:纯 LaTeX 也尝试转 MathML,
失败才退
<code class="latex">; - DEFAULT_CSS 表格改
width: fit-content; max-width: 100%; margin: 1em auto(窄表按自然宽度居中,宽表占满列宽——旧版 width:100% 把所有表拉满整行)。
- 上述全部发射点补
-
补丁(同日二轮):前置/后置物质走旧路径
_render_block_to_html——首轮批量 补丁脚本因末对断言失败在写盘前退出,该路径的图注/表注/表体补丁未落盘 (高等数学回归实测附录图注裸$y=�rctan x$ );Edit 补齐后高等数学 11212 MathML、Standard Model 47/16/25 无回归。GUI spec 同步补 latex2mathml hiddenimport+数据文件(GUI 版同病)。 - 回归:两本 QFT 书从 staging 重跑(--skip-mineru --skip-deepseek), EPUB 内 td math 47/41、注 16/31、标题 25/5,裸 $ 清零;应用内 foliate 实测单元格/注/标题 MathML 渲染、表格与 block 公式均居中。
- 状态:已修复并验证(exe 已重打同步双实例 binaries)。
- 现象:born-digital 公版书无印刷目录页,轻量元数据 pass 的 LLM 不返回空 toc_entries,而是拿 prompt 附带的【全书标题列表】编造一份 假目录(page 直接抄标题块的扫描页码)。假条目经锚点以最高优先级锁死 错误层级,形状栈/字号阶梯全被跳过,文档树结构散架。
- 根因链:LLM 面对"提取目录"指令不会空手而归——采样页里没有目录页, 就拿现成的标题列表拼一份;且 page 抄扫描页(真目录应给印刷页,与扫描页 通常有非零偏移),形成确定性的伪造指纹。
- 修补:
- stage2_common.py
finish_structure伪造目录双判定硬兜底: 判定一(主)两个目录页识别器均未命中 → 判定无印刷目录,丢弃 LLM toc_entries;判定二(双保险)_forged_toc_fingerprint——≥5 条可比对 且 ≥80% 条目页码与标题块扫描页完全相等 → 丢弃。丢弃后回退 形状栈+编号先验路径; _LIGHT_PROMPT/_LIGHT_TOC_PROMPT软约束:无目录页必须输出 [], 严禁编造(软约束不足以依赖,故需硬兜底);setdefault→or防御:LLM 把字段输出成 null 时 setdefault 挡不住 (None 不是缺失键);- pipeline.py
_read_pdf_outline:fitz get_toc 读取 PDF 书签 (born-digital PDF 的免费结构真值,1 起物理页码与扫描页同 regime), 经 stage2_hybrid 透传pdf_toc,非空时取代 LLM toc_entries 且不参与 伪造判定;扫描本无书签 → 返回 [],原流程不变; - qc_book.py 连带:无目录书不再当硬错误,降为黄牌提示。
- stage2_common.py
- 回归:tests/test_stage2_toc.py 新增伪造指纹用例,全套 41/41 + stage3 12/12 绿;伪造条目回放走丢弃路径实测通过;Calculus Made Easy (Gutenberg #33283)60 页重转目录两级正确、477 MathML 居中。
- 状态:已修复并验证(源码路径;exe 重打与 SageRead 同步随下次发版进行)。