扫描 PDF → EPUB 电子书
把「没有文字层的扫描书」变成「手机可读、段落正确、注释可点、错字已校」的 EPUB。
核心难点不在「识别文字」,而在「还原版面」:扫描 OCR 默认把整页拍平成一坨文字,
会丢掉段落边界和注释结构。本 skill 用「每行坐标」重建原书段落,把圈号注释
转成 EPUB3 脚注,并校正系统性 OCR 错字。
为什么是这套做法(先建立直觉)
- 必须带坐标 OCR:扫描书靠「首行缩进 2 字符」区分段落,不是靠行距。纯文本 OCR
丢掉每行在页面上的 x/y 位置,就无法区分「同一段的续行」和「下一段的首行」,
最终要么每句一段、要么几段被并成一段。一开始就用带 bbox 的 OCR,别走弯路。 - 错字是识别精度问题,不是排版问题:OCR 会把「进入」识成「进人」、把「汗流浃背」
识成「汗流背」。这类是固定的字形混淆,单独做一轮精确词对校正即可,不要混进排版逻辑。 - 注释要转脚注:原书用圈号①②③标注释,OCR 后这些圈号混在正文里。把它们抽成
章末脚注区 + 正文回插引用标记,阅读体验最接近原书。
前置依赖
pip install -q pymupdf numpy rapidocr_onnxruntime ebooklib
> ⚠️ 沙盒/云端环境经常被重置,rapidocr_onnxruntime、ebooklib 等可能要重装。
> 每次开新会话、脚本报 ModuleNotFoundError 时先重装。
工作流
Phase 0 — 判定 PDF 类型
用 PyMuPDF 打开,检查某页 get_text() 长度是否为 0。
= 0→ 纯扫描件、无文字层 → 走本 skill 全流程(带坐标 OCR)。> 0→ 已有文字层 → 不需要 OCR,直接用fetch读取或普通转换即可。
Phase 1 — 带坐标 OCR(断点续跑)
运行 scripts/ocr_box.py:把每页识别成 page_XXXX.json,每行含
{t, x1, y1, x2, y2, s},页内按 (y1, x1) 排序。
python3 scripts/ocr_box.py --start 0 --end 100 # 先跑前 100 页探路
python3 scripts/ocr_box.py # 续跑剩余(已存在则跳过)
要点:
- 必须支持断点续跑:300+ 页前台一次跑完易超时。脚本按「json 是否已存在」跳过,
配合 --start/--end/--limit 分批。中断后重跑自动续上。
- 跑完清理残留 worker 进程,避免占用显存。
Phase 2 — 定阈值(只看 2~3 页)
运行 scripts/diag_gap.py,观察输出的 x1 与 gap:
- 续行
x1≈ 页内左边界中位数;段首行x1≈ 左边界 + 约 100px(2 字符宽)。
→INDENT取「相对左边界的缩进阈值」,如续行 153、段首 256,左边界中位 153,则INDENT=40(段首 x1 > 153+40 即判缩进)。 - 相邻行
gap(y1−上一行 y2):同段内小、段间大 →GAP_THR取中间值(如 70)。 - 跨页只用缩进判段,别用 gap(换页 y 从顶部重算,gap 天然很大,会误判成段界)。
把这两个值填进 scripts/build_epub.py 顶部的 INDENT / GAP_THR。
Phase 3 — 改 CONFIG 并生成 EPUB
编辑 scripts/build_epub.py 顶部 CONFIG 块:
| 参数 | 含义 |
|---|---|
OCR_DIR / OUT_EPUB / TOTAL_PAGES |
输入目录、输出路径、页数 |
BOOK_TITLE / AUTHOR / COVER_* |
书名、作者、封面文字 |
CHAPTER_TITLES |
按阅读顺序列出章标题。OCR 误识形要保留原文(如 进人深水区),因为要拿它匹配 OCR 文本;显示/导航时再由 correct() 修正。章号型标题(第X章…)定位时只用「第X章」章号,所以副标题里的错字/破折号/拆行都不影响匹配(见下) |
PART_OF |
章 → 篇/部标题的映射(用于 TOC 分组);无则空 {} |
CORRECTIONS |
OCR 错字精确词对字典(见 Phase 5) |
FOOTER_TEXTS |
每页固定页眉/页脚文本(书名、英文书名等)。扫描书里这些每页独立成一个 OCR 框、会混进正文;列在此处即在段落重建前剥离。兼容与页码粘连(272书名/书名 272)和英文页脚带 OCR 垃圾尾巴(The wheel of life 00l)。无则空 [] |
TAIL_CUT_MARKERS |
书末版权页/CIP 标记(如 图书在版编目 / 版本馆CIP)。正文在最早出现处截断,其后丢弃。无则空 [] |
GALLERY_SKIP |
可选:跳过的照片插页/彩插页号集合(0-based),彩插 OCR 混进正文时才填;无则空 set() |
章号 token 定位(find_start):对 第X章… 这类标题,脚本只用「第X章」章号做前缀匹配,副标题不参与——这样即便副标题被 OCR 识错字、破折号识别成不同符号(临沂—广州 vs 临沂-广州)、或标题被拆成「第X章」+「副标题」两段,都能稳定定位。前言/引子/后记等非章号标题回退到「整名 + CORRECTIONS 变体」的前缀匹配。副标题若被拆成独立段,会在正文抽取时自动剥离。
然后运行:
python3 scripts/build_epub.py
脚本会自动打印每章定位([k] 标题 -> idx=...)和每段统计。重点看:
- 有没有 WARN chapter not found(标题没匹配上)。
- 各章段数是否合理(不该出现「1 句 1 段」或「全并成 1 段」)。
- 抽查正文有没有页眉/页脚(书名)残留、书末有没有版权页/CIP 混入 → 若有,把对应文本填进 FOOTER_TEXTS / TAIL_CUT_MARKERS 重跑。
> 页眉页脚为什么必须在重建前剥离:扫描书每页页脚(书名/英文书名)会被 OCR 成一个独立文本框,
> 若不剥离会当成正文段落插进每页边界,全书混入几百处书名噪声,还会污染 rebuild 的左边界中位数。
> is_footer() 只匹配「独立成框」的页脚,不会误伤正文里恰好含书名的句子。
Phase 4 — 注释转脚注(已内置于脚本)
脚本的 make_chapter() 自动处理:以圈号开头的段判定为注释,抽离到章末
「注释」区(EPUB3 <aside epub:type="footnote">),章首放「本章注释(N)」跳转,
正文残留圈号按顺序链接到对应脚注。
正文回插引用标记(insert_refs)只回插高置信关键词:英文缩写
(VP/GM/PM/PV/UV/OKR)、英文术语(leader,含弯引号容错)。书名《》类和笼统
解释类不回插——正文里是观点而非书名,极易误插到普通词(如把《自我》插进「自我空间」)。
匹配不到就跳过,宁可不插也不插错。
Phase 5 — OCR 错字校正
- 先确认系统性错字类型:把全书纯文本提取出来扫一遍。
- 常见的是「人/入」混淆:进入→进人、陷入→陷人、加入→加人、投入→投人、收入→收人、融入→融人、深入→深人、装入→装人、介入→介人、嵌入→嵌人、输入→输人,以及汗流浃背→汗流背。
- 用正则扫「动词+人」模式统计命中数,确认是系统性而非个别。 - 用精确词对字典,切勿通配:
CORRECTIONS = {'进人':'进入', ...}。
- 坑:若用「动词+人→动词+入」通配,会误伤合法词——引人(吸引人)、招人(招聘人)、纳人(吸纳人才子串)、导人(引导人们子串) 都是正确用法。精确双字词替换可避免。 - 常见错词库扫描:再扫一遍
末来/己经/震憾/缘份/事非等约 70 个候选。若全书命中为 0 或仅为合法词子串巧合(如「经过」含「以经」),说明本书无其他系统性错字,不做盲替。 - 校正接入点(关键):把
correct()放进esc(),让所有文本(章名、注释、导航标题)经esc即自动校正。
-CH_TITLES/CHAPTER_TITLES必须保留 OCR 误识形(如进人深水区)以便匹配;只在显示/导航时correct()。
- 导航残留根因:part 章标题经EpubHtml对象的.title显示,若.title用原始字符串就不走correct(),导致nav/toc.ncx里残留「进人深水区」。修复:EpubHtml(title=correct(title))且 TOC 的index(correct(title))。
验证清单(交付前必查)
- 抽查各章开头:多句成段、句末标点在段尾(无半句断段)。
- 无
***噪声残留、无标题碎片残留(目标 = 0 残留)。 - 页眉页脚残留 = 0(书名/英文书名)、书末版权页/CIP 残留 = 0(结尾应为正文真实末句)。
- 全文 12 类(或本书实际)错字残留 = 0;
nav/toc.ncx里错误形(如「进人」)= 0,正确形(「进入」)出现。 - 每章注释区存在、章首跳转可点、正文圈号链接正常。
- 全部章节都定位成功(无
WARN chapter not found)、章数与目录一致。 - 用阅读器打开 EPUB,翻几页确认手机端排版(段首缩进、注释点击)。
示例
示例 A:标准扫描书(有 part 分篇)
1. Phase 0 判定无文字层。
2. ocr_box.py 分两批跑完 370 页 → page_0000.json … page_0369.json。
3. diag_gap.py 看 3 页,定 INDENT=40, GAP_THR=70。
4. 在 build_epub.py 填 CHAPTER_TITLES(含 进人深水区)、PART_OF、SECTION_TITLES。
5. 填 CORRECTIONS(人/入 11 对 + 汗流背)。运行,核对定位无 WARN、段数合理。
6. 校验 nav 无「进人」残留 → 交付。
示例 B:只有章、没有分篇的简单书
- PART_OF = {}、SECTION_TITLES = set()。其余相同。find_start 的回退逻辑对空映射安全。
通用注意点(避坑)
- 环境重置:
pip install -q重装依赖是常态。 - 数据漂移:切勿拿旧的纯文本
merged.txt行号去切基于新 JSON 的数据(数量/顺序会漂),始终基于新数据全局重建。 - 别走弯路:不要先「纯文本→按句修补」再「按句切→每句一段」,三轮皆错;一开始就带坐标 OCR + 缩进法重建。
- 详细根因与几处「疏忽复盘」见
references/lessons.md。
附属脚本
scripts/ocr_box.py— 带坐标 OCR,支持断点续跑/分批。scripts/build_epub.py— 段落重建 + 页脚/版权页去噪 + 章号 token 章节切分 + 注释脚注 + 错字校正 + EPUB 生成(CONFIG 在顶部)。scripts/diag_gap.py— 诊断 2~3 页定INDENT/GAP_THR。