起点是一份只有图的 PDF
手上一本学术书的电子版,282 页,正文七万到八万页之间,通篇是图片,没有文字层。用任何工具复制出来都是空的。
这种书想变成能读、能检索、能标注的 EPUB,第一反应是「OCR 一下不就行了」。真做下来才知道,OCR 只占整件事的两成。剩下的八成是:把 OCR 吐出的几千行碎片,重新拼回一本书该有的样子——哪里是页脚、哪里是脚注、哪两行其实是同一段、哪个引号是原书就有的、哪个是印刷墨点。
最终成品:10 章 37 节、672 个正文段(其中 108 个引文块)、51 个脚注块、131,103 个汉字、267 个原书页码锚点,EPUB 211 KB。全程一条 Python 流水线跑完,结构化的中间产物是 blocks.json,不是文本。
这篇记录四个阶段和其中真正卡住我的地方。
流水线全景
扫描 PDF
→ PDF 拆页 PNG(282 张)
→ 坐标 OCR(每行带归一化坐标,TSV)
→ 版面还原(页脚 / 脚注 / 标题 / 段落)
→ 纠错 + 繁转简 + 标点规范化
→ 质量门禁(13 项)
→ EPUB
关键设计是第二步:OCR 必须输出坐标。只有拿到每一行的 x/y,后面才有还原版面的可能。
一、坐标 OCR:让每一行都带上位置
macOS 上最省事的方案是系统自带的 Vision 框架,识别中文的准确率比多数开源 OCR 高,而且是本机跑、不上传。用 Swift 写个几十行的命令行工具即可,核心是 VNRecognizeTextRequest:
let req = VNRecognizeTextRequest()
req.recognitionLevel = .accurate
req.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"]
req.usesLanguageCorrection = false // 学术书不启用语言纠正,避免改动原文
usesLanguageCorrection = false 这一行很重要。开启后系统会「帮你改得通顺」,人名、术语、文言引文经常被悄悄替换掉。校对类工作必须关掉。
输出格式定为 TSV,每行五个字段,坐标统一归一化到 0~1,并把 Vision 原生的「原点在左下角」翻转成「距顶部」:
xLeft \t xRight \t yTop \t yBottom \t text
排序也要自己做:按 yTop 升序,同一行内(|Δy| < 0.006)按 xLeft 升序。Vision 返回的 observation 顺序不保证是阅读顺序。
一张页约 3~5 秒,282 页十来分钟跑完,脚本带断点续跑(已存在非空 TSV 就跳过)。
二、版面还原:把行拼回段落
这是最难的一段,也是 v1 失败的地方。v1 只做「读文本 → 正则清洗」,结果整页被揉成一段,段落结构全丢。v2 改成坐标驱动,五个子问题。
页脚:靠 y 坐标,不靠正则
页脚是「页码 + 书名 + 破折号」,看起来好匹配,但每页的破折号长度、空格位置都在变,正则很快就写不下去。坐标时代一句话解决:
Y_FOOTER = 0.895 # yT > 0.895 的一律丢弃
顺带一个坑:页脚剔除必须在标题定位之前做。页脚去掉尾部页码后,形态和某些标题完全同形,会被精确匹配抢走标题的位置。
页边界:用边缘簇的中位数,不能用众数
要判断「这一行有没有缩进」,先得知道本页的左右边界在哪。最直觉的做法是对所有行的 xL 取众数。实测下来,282 页里有 27 页(9.6%)算错。
原因是 OCR 抖动把同一边界的行分散到相邻的几个箱里,而整块缩进的引文行反而聚成一个更大的单一箱——众数会把引文块的左边界当成整页左边界。
改成「边缘簇中位数」:
def _cluster_edge(xs, lo):
"""取边缘簇的中位数(lo=True 取最左簇=左边界)"""
s = sorted(xs)
seed = s[len(s) // 10] if lo else s[min(len(s) - 1, len(s) * 9 // 10)]
cl = [x for x in s if abs(x - seed) <= 0.012]
return statistics.median(cl) if cl else seed
取第 10 百分位做种子,收 ±0.012 内的元素取中位数。不需要分箱,抗抖动,也不怕引文块。
分段判据:缩进 AND 上一行不满行
有了边界,「这一行是不是新段落」看起来只要看缩进。但只凭缩进会把整块缩进的引文逐行切成一段,实测产出大量三五个字的碎片。
真实版式里有个规律:段落末行必然留白,引文块的中间行几乎都是满行。于是判据变成两个条件的合取:
新段 ⟺ 本行缩进 且 上一行不满行
阈值是实测出来的。全书统计行尾留白 右边界 - xR 呈双峰:满行簇 <0.01(784 行),段落末行 >0.05(874 行),中间 0.01~0.02 只有 13 行模糊带。所以取 0.02:
FULL_TOL = 0.02 # 取 0.04 会把近满行的段末误判为满行,导致过度合并
跨页续段另有一条:本页首行未缩进 且 上一段不是以句末标点结尾 → 合并回上一段。
脚注:三个条件一起用
脚注和正文的区分靠三个信号同时成立:与上文有异常大的间距、起始位置靠下、行距骤缩。
Y_NOTE_MIN = 0.70 # 脚注区最低起始位置
GAP_NOTE = 0.028 # 与正文之间的异常段间距(正文行间距仅约 0.010)
PITCH_NOTE = 0.027 # 脚注行距上限
行距那条最有用:正文行距约 0.030,脚注约 0.021,差异稳定。再加两个兜底信号——行首是注符(*、†、‡、数字编号)或者内容命中引文特征词(出版社、轉引、University Press、pp. 之类)。
标题:全书搜索 + 模糊匹配
目录从权威来源(出版社官网、豆瓣)单独整理成 JSON,不靠 OCR 的目录页。然后在全书范围内搜索每个标题的实际位置,允许编辑距离 ≤2 以容纳 OCR 形近误字(思根→思維、尼朵→尼采、第七草→第七章)。
if c == h["norm"]: # 精确命中
sc = 1000 - abs(p - h["hint"])
else:
d = edit_distance(c, h["norm"])
if d <= 2 and abs(len(c) - len(h["norm"])) <= 2:
sc = 500 - 20 * d - abs(p - h["hint"]) # 模糊命中,按距离扣分
hint 是目录给出的预期页码,同分时用它做 tie-break。另外标题合法性只排除以 。,、;: 结尾的行——疑问式和感叹式标题(倫理奠基於身體之上是否可能?)是合法的,别误杀。
三、繁转简,以及它的反查
OpenCC 的 t2s 开箱即用。难点是怎么确认没有残留。
第一版我用「固定繁体字集扫描」,报了 5553 处,全是简繁同形字造成的误报。正确做法是利用 OpenCC 自身的幂等性——它只改繁体、不动简体:
_t2s = OpenCC('t2s')
for b in blocks:
t = b['text']
if _t2s.convert(t) != t: # 转换前后不同 ⇒ 有繁体残留
for a, c in zip(t, _t2s.convert(t)):
if a != c:
trad_hit[(a, c)] += 1 # 逐字比对定位差异
一个误报都没有,因为判断标准就是「OpenCC 想改它」。
四、精校:绝不臆造
精校的原则只有一条:确定性错误直接改,存疑一律留痕,绝不猜。
原书引号体例普查
这是整个项目里最有效的一招,也是我推翻自己旧结论的地方。之前我在技能笔记里写过「弯引号全局缺口属已知限制、无法机械解决」——其实有解,只是要做普查。
先统计原始 OCR 里各类引号的数量:
| 符号 | 数量 | 开闭是否相等 |
|---|---|---|
「」 | 2131 对 | 是 |
『』 | 74 对 | 是 |
“ | 9 | 否 |
” | 8 | 否 |
‘ | 2 | 否 |
’ | 5 | 否 |
结论一目了然:这本书只用直角引号。那二十几个弯引号,没有一类开闭数是相等的,说明它们不是配对的引用,而是印刷墨点、污渍、扫描噪点被 OCR 读成了引号。逐条回原页核对后全部删除。
其中两处例外,确认是 OCR 吞了原文真实的引号,按原位补回:
- 第 29 页《素女经》引文:段末
以此为节。→以此为节。” - 第 240 页王鲁彦《黄金》引文:
谁喜欢我呢?→谁喜欢我呢?”
配对假象陷阱(最隐蔽的一个坑)
删掉杂散开引号之后,有几段反而冒出「多余的闭引号」。查下去才发现:两个相隔数百字的杂散符号会一开一闭互相抵消,栈扫描完全查不出来。只有删掉其中一个,另一个才会裸露出来。
所以流程必须是迭代的:删除 → 重新扫描 → 处理新暴露的 → 再扫描。一轮不够。
跨块括号配平:就近配对
有些括号在 OCR 切块时被拆到了相邻两段。跨块配平要用就近配对——倒序找最近且未超出间隔上限(我取 8 个块)的未闭合项。
我一开始用的是 FIFO 队列,只处理了 2 处;改成就近配对后处理了 7 处。FIFO 的问题在于队列头部会卡着一个远端的陈旧未闭合项,把后面所有真实配对全堵死。
改什么,不改什么
确定性误字,全书频率对比后直接改:
这种“赤字观”→这种“赤子观”(全书「赤子」14 见,「赤字」仅此 1 见)做为→作为(台湾用词习惯,2 处)
看着像错、其实是对的一律不动:
眸子瞭焉—— 《孟子》原文如此声名狼藉—— 正确写法失了精采—— 鲁迅《在酒楼上》原文
拿不准的一律进「存疑待核」表:某条法文脚注在扫描时被截断、脚注上标编号缺失(因此没做弹出式脚注重排)、少量间隔号噪声、部分脚注标记被误识(原书实际用的是 § 和 *)。
五、EPUB:ebooklib 的三个静默坑
ebooklib 是 Python 里最好用的 EPUB 库,但有三个坑不报错、只让你拿到一个坏文件。
坑 1:<?xml?> 声明让 body 变空
手写 XHTML 时习惯加一行 <?xml version="1.0" encoding="utf-8"?>。在 ebooklib 里这会触发 lxml 的 Unicode strings with encoding declaration are not supported,异常被库内部的 try/except 静默吞掉,body 变成空字符串,最后在很远的地方抛出一个:
lxml.etree.ParserError: Document is empty
报错栈指向 utils.py 的 get_pages(),离真正的病因有十万八千里。解决办法是不写 XML 声明——EPUB3 里 UTF-8 就是默认编码。
坑 2:自己写的 <head> 会被丢掉
EpubHtml.get_content() 只抽取 <body>,然后按 self.links / self.metas 重建 head。你在模板里写的 <link rel="stylesheet"> 一个都活不下来。
CSS 必须用 API 注册:
def attach_css(item):
item.add_link(href='style/main.css', rel='stylesheet', type='text/css')
item.add_meta(charset='utf-8')
return item
book.add_item(attach_css(EpubHtml(...)))
坑 3:别解析 TXT 去拆章
第一版我让生成器去解析精校后的 TXT、按标题切章。多此一举且脆弱——流水线里本来就有 blocks.json 这个结构化中间产物,直接读它,章节层级、页码、脚注归属全都在。
学术书的 EPUB 体例
- 保留原书页码:每个页面切换处插入锚点
<span class="pg" id="bk{n}" title="原书第 n 页">〔n〕</span>,写论文引用时能对准纸质版。 - 嵌套目录:章 → 节两级,37 个节锚点全部可跳转。
- 校勘记放附录:确定性订正和存疑待核各一张表,明写改了什么、为什么改。
六、十三道门禁
精校最怕「改完不知道有没有改坏」。所以每条规则都要有一个对应的自动核验。跑出来的结果:
| 项 | 结果 |
|---|---|
| 繁体残留(OpenCC 反查) | 0 |
| 页脚残留(两种形态) | 0 |
| ASCII 直双引号 | 0 |
“” 配对 | 2154 : 2154 |
‘’ 配对 | 79 : 79 |
《》 配对 | 311 : 311 |
〈〉 配对 | 98 : 98 |
() 配对 | 370 : 370 |
| 段内括号失配(栈扫描) | 0 段 |
| 确定性误字残留(反向核验) | 0 |
| 杂散引号(体例普查对照) | 0 |
| U+2022 噪声 | 0 |
表中 “” 的计数是规范化之后的终稿统计——流水线把原书的直角引号统一转成了弯引号,因此与上文普查阶段的 「」 计数不直接对应。
这里有个反直觉的设计:核验是反向的。不是「扫描还有没有错」,而是「列出订正后的形态,检查它还在不在」。比如杂散引号这一项,第一版我用正则扫「句读后的孤立开引号」,报了 227 处——全是误报,,''以身为天下贵''、''推己及人'' 这种完全合法的连续引用被判违规。改成「列出 27 条订正后的字符串,看有没有残留」才对。
七、几条能带走的原则
- OCR 必须带坐标。纯文本 OCR 出来的东西,版面信息已经永久丢失,后面再怎么洗都洗不回来。
- 阈值要实测,不要拍脑袋。行尾留白的双峰分布、正文与脚注的行距差、边缘簇的容差——这些都是翻全书坐标统计出来的,不是猜的。
- 统计先于规则。做体例普查之前,我在靠肉眼一条条判断引号对不对;普查之后,二十五处杂散符号的定位一夜之间全部明确。先摸清全书的统计特征,再写订正规则。
- 核验要反向做。检查「错误还在不在」容易误报,检查「订正有没有生效」才稳。
- 存疑留痕,绝不臆造。校对工作的信用全在「我没瞎猜」这四个字上。改了什么、为什么改、哪里不确定,全部写进校勘记交给读者判断。
- 中间产物用结构化数据。JSON,不是 TXT。后面所有环节都会感谢你。
最后
整套流水线跑下来大概两小时,其中八成时间花在版面还原的阈值调校和引号普查上,OCR 本身只占十几分钟。
做完之后最大的感受是:这类活儿的难点从来不是「识别文字」,而是判断哪些东西不该出现在文本里。OCR 会把墨点读成引号、把页脚读成正文、把目录读成章节——它是一个诚实的识别器,不负责理解版面。理解版面的活儿,得靠统计和规则一层层筛下去。