扫描版 PDF 章节定位与提取
适用场景
PDF 是图片扫描件(extract_text() 返回空),但需要其中某一章/某一表的数据。
典型:方言调查报告(谢建猷《广西汉语方言研究》、语保《中国语言资源集》)、古籍影印本、老技术手册。
不适用:有文本层的 PDF(直接用 pypdf 抽取即可,见文末「有文本层时」)。
前置检查(30 秒内完成)
# 1) 有无文本层 + 页数 + 元数据(Producer 能看出扫描来源)
python -c "
from pypdf import PdfReader
r=PdfReader(PDF); print('页数',len(r.pages)); print(r.metadata)
t=(r.pages[10].extract_text() or '').strip(); print('第11页文本长度',len(t))"
- 文本长度全为 0 → 扫描件,走本流程
- 元数据里
Producer含"PDF打印机/扫描全能王/CamScanner" → 确认是二次打印转制的扫描
环境:隔离 venv 里需有 pymupdf(fitz)+ pillow + pypdf。
路径:C:/Users/<user>/.workbuddy/binaries/python/envs/default/Scripts/python.exe。
> ⚠️ 系统 python 通常没有 fitz(ModuleNotFoundError: No module named 'fitz' 是本节最常见的第一道坎)。一律用上面这个 venv 的 Scripts/python.exe,不要用系统 python 或托管版本的裸 python。
> ⚠️ 不要用超长 heredoc 写脚本。把几百行数据一次性塞进 python <<EOF 会触发 Error: spawn ENAMETOOLONG(且报错发生在脚本创建前,容易误判为逻辑错误)。拆法:① 用 Write 工具把纯数据写成独立模块(如 rows.py,只含列表常量);② 再用一个几行的短脚本 from rows import ROWS 执行写入。既绕开长度限制,也让数据与逻辑分离、便于重跑。
核心流程
第 1 步:建立「PDF 页 ↔ 印刷页码」映射(最关键)
扫描件的印刷页码与 PDF 序号通常差一个固定偏移。不要猜,用闭环验证:
- 渲染 2~3 个分散位置的页面(如 index 10、index 200、index 500),读出页脚/页眉上的印刷页码
- 线性拟合:
印刷页 = PDF序号 + 偏移(注意 PDF 序号 1-based 与 index 0-based 差 1) - 用第三个采样点验证;三点全吻合才启用
实例:某书 index 218 显示印刷页 209,index 221 显示 211 → 偏移 = 10(PDF页 = 印刷页 + 10),全书线性成立。
> ⚠️ 若书中夹有未编页的插页(图版、扉页),映射会在该处跳变。跨插页定位时重新采样。
第 2 步:读目录定位目标章节
渲染前 10~20 页找目录(通常在内容简介之后、正文之前)。目录里直接给出章节的印刷页码,代入第 1 步公式即可。
目录本身是扫描图,拼图渲染(2×2)足够识读。
第 3 步:确认文档覆盖范围(大文档必做)
多卷本扫描件常常只有上册。判断方法:
- 渲染最后一页看页码与封底文字("(上)"、"定价 xxx/上、下册")
- 对照目录里各部分页码范围,看末尾章节是否落在 PDF 页数之内
实例:1076 页的 PDF,末页印刷页仅到 1066 且为上册封底 → 确认缺下册(词汇对照表部分)。
第 4 步:渲染与识读(参数经验)
import fitz
from PIL import Image
doc = fitz.open(PDF)
def render(i, width=1150, gray=True):
pg = doc[i]
zoom = width / pg.rect.width # 按目标宽度算缩放,比固定 dpi 更可控
cs = fitz.csGRAY if gray else fitz.csRGB
pix = pg.get_pixmap(matrix=fitz.Matrix(zoom, zoom), colorspace=cs)
return Image.frombytes("L" if gray else "RGB", (pix.width, pix.height), pix.samples)
| 目的 | 宽度 | 布局 |
|---|---|---|
| 找目录、看整体版式结构 | 1000–1200 | 2×2 或 2×4 拼图 |
| 读正文、读表格数据 | 2200–2600 | 单页一张 |
| 多页同一行批量索引(见第 7 步) | 1400–1700 | 细条纵向拼接,20–22 条/图 |
| 辨认 IPA / 小字 / 上下标 / 声调符号 | 3200–6600 + 局部裁剪 | 整行输出(保列位,见第 10 步);已知列位后才分半 |
| 单字定案(s/ʃ/ts/tʃ/ɬ/θ 等易混符号) | 10000–13000 | 只裁 2–3 个单元格,缩到 1500 宽输出 |
| 建立字-音对应(列位映射) | 3600–4200 取源、1500–1600 输出 | 整行含网格线,每图 4 个字组(例字行+数据行成对) |
| 一次拿「例字 + 中古音 + 数据」三行(第 13 步) | 3600–4200 取源、1500–1600 输出 | 窗口上移一档(如 y = 0.386–0.420),保留网格线 |
★ 分辨率梯度(血泪教训)
擦音/塞擦音符号在低分辨率下无法可靠区分。同一次任务中,1700px 读数给出「精组=tʃ、心母=ʃ」,13000px 单字复核后改为「精组=ts、心母=s、生书母=ʃ、知章母=tʃ」——结论完全翻转。
| 层级 | 宽度 | 能可靠读出的 |
|---|---|---|
| 索引层 | 1400–1700 | 汉字、编号、行标签、调值数字 |
| 数据层 | 3200–6600 | 声母大类(p/t/k/m/n/l/ŋ/h 等)、韵母、调号 |
| 定案层 | 10000–13000 | s vs ʃ vs ɬ vs θ、ts vs tʃ vs tɕ、ʔ vs k vs t 等易混对 |
定案层的输出方式:10 000–13 000px 取源后,整行分左右两半、每半缩到 1500–1600px 宽(约 300px/字)——比单格裁剪省读图次数,又能可靠分辨 ʔ/k/t 这类单字符差异。整行缩到 1500px(约 150px/字)不够,此轮仍读错若干 ʔ/k。
规则:凡结论要写进项目文件的符号,必须走"定案层"复核;拿不准就在数据文件里标可靠度(A/B/C)。
拼图实现:按 cols 与固定 width 直接 paste,不要缩放各页到不同尺寸。
第 5 步:双栏/多列表格的读取方式
学术书表格常一页两栏、一页 4 组"声母+例字"列。做法:
- 先整页读一遍拿到行列结构(哪一列是什么)
- 再按象限裁剪(左上/右上/左下/右下)分块精读
- 表头斜线格("方言点 / 编号 / 汉字 / 中古音")单独裁剪确认语义
第 6 步:可脚本化时先探路
若目标数据量很大(如 700 页对照表),不要一次性全渲染。先用 1 页确认:
- 表格列顺序与目标行(哪一列/哪一行是你要的数据)
- 是否值得做 OCR(扫描件字迹清晰时可试 paddleocr / tesseract,但 IPA 与生僻字 OCR 准确率低,往往人工视觉提取更可靠)
第 7 步:★ 多页「同一行」细条拼接(大表格的效率核心)
问题:700 页对照表,逐页整页渲染会有几百次读图,成本不可接受。
解法:把每一页的同一行裁成细横条,纵向拼成一张图 —— 一次读图拿到 N 页的数据。
def strip(doc, page, y0, y1, width=1700):
pg = doc[page]; z = width / pg.rect.width
pix = pg.get_pixmap(matrix=fitz.Matrix(z, z), colorspace=fitz.csGRAY)
im = Image.frombytes("L", (pix.width, pix.height), pix.samples)
H = im.height
return im.crop((0, int(y0 * H), im.width, int(y1 * H)))
# 索引层:例字行 → 22 页/图(每图 220 个汉字)
# 数据层:目标行 → 4–6 页/图(纵向 ×1.4 放大提高可读性)
两种典型用法:
- 例字行扫描 → 建立「页码 ↔ 内容」全表索引(配合步长采样:每 12 页取 1 页,一张图看 22 页)
- 目标数据行裁剪 → 直接读数值(一张图看 4–6 页)
配对输出:把「表头行(例字+中古音)+ 数据行」拼成一组,既知道字是什么又知道读音,避免二次定位。
第 8 步:★ 复杂表格的「分块页」结构
学术书的大表格常因点数太多而拆成两组页轮流印。识别与利用方法:
- 先怀疑、再验证:若按"每页 N 条编号"推算的页码与实测不符,先别怀疑自己算错,去看相邻两页的行标签是否不同 —— 很可能是 A/B 双组页
- 确认规律:本例中「每 10 个字占相邻两页,偶数页 = A 组(含目标点)、奇数页 = B 组」,于是目标点的页索引 =
base + 2b(b 为字组序号),编号区间 =[10b-9, 10b] - 实测锚点:抽 3~4 页确认公式,再看锚点是否吻合;不吻合就加采样点重拟合
第 9 步:★ 行定位(视觉估计 vs 横线自动检测)
不要靠目测估行位的比例——容易整体错一行(本次踩过,导致整批数据抽错行)。做法:
import numpy as np
a = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width)
dark = (a < 140).astype(np.float32)
prof = dark[:, int(0.03*a.shape[1]):int(0.97*a.shape[1])].mean(axis=1) # 每行暗度
i = [k for k in range(2, len(prof)-2) if prof[k] > 0.30 and prof[k] >= prof[k-1] and prof[k] >= prof[k+1]]
# 合并邻近峰 → 表格横线位置;相邻线差值取众数 = 行高
- 得到线位置后,第 n 数据行 = 第 n 与 n+1 条线之间 → 直接算
y0, y1 - 务必用一页目视验证(裁出该行读标签,确认是目标行)
- 部分扫描页偏淡会导致检测失败 → 回退到"邻页线位置 + 行高"推算,或直接目视
> 本次错误链:目测估行位 → 整体错一行(抽到隔壁点)→ 改用横线检测 + 目视双验证才修好。任何批量抽行任务,先做单页双验证再放量。
第 10 步:★★ 列位对应(分半裁剪 vs 整行输出)——最容易前功尽弃的一步
问题:为了看清 IPA,常把一行切成左右两半分别放大。但半切图的列边界与行标签列错位,读出来「第几列=第几个字」无法确定 —— 数据看着清晰,映射全错。
规则:
| 阶段 | 正确输出方式 | 理由 |
|---|---|---|
| 建立字-音对应时 | 整行输出(含网格线),小字缩略即可 | 网格线是列位的唯一锚点;缩略只影响清晰度,不影响「第几列」 |
| 已知目标在第几列后复核字形/音标 | 才用高倍 + 分半裁剪(或单格裁剪) | 此时不存在列位歧义 |
# 正确:整行输出,例字行 + 数据行成对,保留全部网格线
h = strip(doc, page, w, Y_HDR[0], Y_HDR[1]) # 例字行,不裁左右
r = strip(doc, page, w, Y_BOB[0], Y_BOB[1], x0=0.105) # 数据行,只跳过行标签列
# 每图 4 个字组(8 条)→ 约 1000–1800px 高,可直接读
判定信号:如果出现的读音与中古音地位系统性不合(如溪母字读出零声母、影母字读出以母的韵类),先怀疑列位错位,再怀疑文献。反过来,先用一个中古音地位可预测的字(如禅母上声应读阳上)校准列位,再放量。
第 11 步:★ 用「例字」做定位捷径(省一个数量级)
学术书里的韵母表、声母表、调值表几乎每一格都配例字。与其全表扫描找字,不如:
- 高倍(3200–6000px)把韵母表/声母表的例字抄下来 —— 这是作者自己给的「该韵/该母的代表字」
- 用第 7 步的细条索引扫描,把这批例字映射到字组号
- 只抽这些字组的行
实例:本项目中,先读到「声母表 Ǿ 例字=科火野余」「韵母表 aʔ 例字=笔密栗七」,再定位字组 —— 12 个字组就覆盖了原本要扫几百页才能撞上的缺口。
粗细两级索引:粗扫(每 12 页取 1 页,6 字组/张)先确定「摄」所在区间 → 区间内逐字组扫(步长 2)精确定位。不要一上来就逐页扫全书。
第 12 步:★★ 不要相信书里印的「序号 / 编号」列
学术对照表常在行首印一串字序号(如 1…3800),看起来可以拿来换算页码。不要用它:
- 本项目中「编号 ↔ 页码」的换算公式被反复误读(先记
241+10b,后实测为编号首 = 10b − 9),三次返工都是因为它 - 更糟的是:同一本书里可能存在两套互不相干的序号(一部分一套),或者序号在插页处跳变,越算越乱
正确做法:
- 以内容定位,不以序号定位 —— 用「例字」作为唯一主键(第 11 步);数据文件里也以例字列为主,序号列可留可删
- 只信「同页配对输出」 —— 例字行与目标数据行必须取自同一页(同一次
doc[page]渲染),这样字-音对应天然成立,与任何页码公式无关 - 页码公式(
page = base + k*b)只用来取页,不用来编号;且每轮开工先抽 1 页目视验证公式仍然成立
# 唯一可信的配对模式:同一个 page 同时取「例字行」和「目标数据行」
def pair(doc, page, srcw, y_hdr, y_bob, x_skip):
h = strip(doc, page, srcw, *y_hdr) # 例字行
r = strip(doc, page, srcw, *y_bob, x0=x_skip) # 数据行(只跳过行标签列)
return h, r # 二者列位天然对齐
> 本轮错误链:按扫描图的「编号」推块号 → 三处区块错配(把宕摄当臻摄、把梗摄当通摄)→ 全部作废重取。改用「页内配对 + 例字定位」后一次通过。
>
> 第五轮新错误链(索引图的限度):粗扫细条索引图上目视读「编号行」会把相邻行看串(读出 411–420,实为 391–400)→ 一度误判页码公式失效。索引图只用于「找摄/韵区间」,编号核对必须整页渲染或页内配对精扫;页码公式疑似失效时先怀疑读图串行,再用整页渲染复核(本轮整页复核证明公式完全正确)。
第 13 步:★★★ 行位上移一档,一次拿到三行(证据链闭合的关键杠杆)
问题:第 12 步的「页内配对」只保证字-音对应不错位,但「这个字属于什么摄/韵/等/开合」仍需外部字表反查 —— 而外部字表本身可能版本不一,等于把不确定性从书里搬到了书外。
解法:学术对照表的页内行序通常是 段落头 → 例字行 → 中古音标注行 → 各点读音行。裁窗时不要只框「例字 + 目标点读音」两行,把窗口上移一档、跨出三段,就能同时读到:
- 例字行(字是什么)
- 中古音标注行(摄/韵/等/开合 —— 作者自己标的,权威且与本书音系同源)
- 目标点读音行
实例:某书第三轮用 y = 0.404–0.454(例字 + 博白两行),第四轮改为 y = 0.386–0.420,一次输出三行 —— 从此「摄/韵」不再依赖外部字表,证据链在页内闭合。同一次改动还把每轮抽字数从 139 提到 642(因为不再需要为每个字单独查字表)。
做法与要点:
# 裁窗要覆盖「例字行 + 中古音行 + 目标点行」,成一个连通的细条
Y_TRIPLE = (0.386, 0.420) # 上移到含中古音标注行
# 保留网格线(列位锚点),x 只跳过最左的行标签列
s = strip(doc, page, srcw, *Y_TRIPLE, x0=0.105)
- 先单页目视确认行序(有的书把中古音印在例字右侧而非下方 → 此时上移无效,改成正则式「横向配对」)
- 若中古音行字号特别小,需要比数据层更高的宽度才可读,但只用于确认韵摄,不用于定案 IPA —— 两者分层,别混用分辨率
- 收益:省掉外部反查、避免字表版本不一致、每轮可抽字数提升数倍
第 14 步:★ 多字符韵基 / 复合韵尾的陷阱(写数据前必查)
抽到「韵尾 + 调值」这类需要程序解析的字段时,不要假设「韵基永远单字符、韵尾永远单字符」。方言材料里常出现:
| 形态 | 例 | 陷阱 |
|---|---|---|
| 复合韵基 | ɐu + ŋ → ɐuŋ;ɐu + k → ɐuk;uɐ + n → uɐn |
正则「先韵基后韵尾」会把 ɐuŋ 拆成 ɐ + uŋ(uŋ 不是合法韵尾) |
| 多字符 IPA 韵尾 | ʔ 与 -p/-t/-k 并列 |
派生规则只列 [ptk] 时,喉塞尾字静默落入错误分支,不报错 |
| 声化韵 / 成音节辅音 | ŋ̍(鱼 ŋ²³²) |
无元音,韵基匹配为空 |
对策(写解析器前逐条自检):
- 韵尾清单要显式枚举,不要用「最后一个辅音字母」这类隐式规则;喉塞尾必须单列一个分支
- 韵基用「贪心匹配到最后一个合法韵尾」,而不是「第一个韵基 + 剩余韵尾」
- 加一条「解析失败即报错」的断言 —— 隐式规则最危险的失败模式是静默给出错误结果(本例中喉塞尾字被当舒声处理,调号分支判错却不报错)
- 抽样自检:抽 5–10 个已知特殊字(喉塞尾、声化韵、复合韵基)跑一遍解析器,人工核对输出
实例:某项目 derive 规则写作 ^([a-z]+)([ptk])([5-8])$,喉塞尾字完全不匹配该式却也不报错,导致全部 ʔ 尾字调号判定错误。修法 = 增加 ʔ 分支 + 处理 ɐu/uɐ 多字符韵基 + 对不匹配项抛异常。
常见陷阱
| 陷阱 | 后果 | 对策 |
|---|---|---|
| 直接用 PDF 序号当印刷页码 | 定位偏移 10 页以上 | 第 1 步闭环验证 |
| 拼图缩到 1000px 就读小字 | IPA 符号读错(tʃ→tɬ) | 精读一律 2400px + 局部放大 |
| 只读目录不验证 | 目录页码与实际不符(插页/分册) | 渲染目标页确认页眉页脚 |
| 误判只有上册 | 大段内容缺失 | 第 3 步查末页 |
| 一次性渲染整本 | 磁盘/时间浪费 | 先探路,再按需渲染 |
| 目测估行位 | 整体错一行,整批数据报废 | 第 9 步:横线检测 + 单页目视双验证 |
| 低分辨率读擦音/塞擦音 | ts/tʃ、s/ʃ、ɬ/θ 全读错,结论翻转 | 第 4 步分辨率梯度,关键符号走 13000px |
| 按"每页 N 条编号"直线推算页码 | 遇到分块页结构时整体错位 | 第 8 步:先验证是否 A/B 双组页 |
| 渲染脚本间 0-based / 1-based 索引混用 | 抽到隔壁页 | 统一约定并在函数注释里写明 doc[N] = PDF 第 N+1 页 |
| 分半裁剪后读数据行 | 读音与字错位,整批映射全错(图还很清楚) | 第 10 步:建映射用整行输出;分半只用于已知列位的复核 |
| 只用「十六摄/声母组」分层抽样 | 项目音系里没有槽位的类别永远撞不上(如零声母、喉塞尾) | 第 11 步:先抄韵母表/声母表的例字,反向定位 |
| 把同韵内的分化当"读错" | 漏掉文白异读/词汇扩散 | 同一字组内逐字核;同音韵地位而不同读音是常态(实例:笔/毕=pɐʔ⁵⁵ 但 必=pit⁵⁵) |
| 拿书里印的「序号/编号」换算页码 | 块号错配,整批数据张冠李戴(本轮三次返工) | 第 12 步:以「例字」为唯一主键;例字行与数据行必须同页配对输出 |
| 整行缩到 1500px 就下结论(ʔ/k/t、s/ʃ 类) | 单字符差异读错,结论翻转 | 定案层用 10000px 取源 → 分左右半、各缩 1540px(≈300px/字) |
| 只框「例字 + 数据」两行 | 摄/韵仍需外部字表反查,不确定性外溢 | 第 13 步:窗口上移一档,一次拿「例字 + 中古音 + 数据」三行 |
| 假设韵基/韵尾都是单字符 | 多字符韵基(ɐuŋ/uɐn)与喉塞尾静默解析错误 |
第 14 步:韵尾显式枚举 + 贪心匹配 + 不匹配即报错 |
| 解析器只覆盖常见分支、无失败断言 | 静默给出错误结果(比报错更危险) | 第 14 步:加断言 + 拿已知特殊字抽样自检 |
| 一条长 heredoc 写几百行数据 | spawn ENAMETOOLONG,脚本根本没写成 |
拆两步:先用 Write 工具落「纯数据模块」,再用一个短脚本 import 它并执行 |
| 音系孤例未复核就入库 | 低可靠度的「孤例」常是读图误差(实例:蟹开四 tɐi 系全部实为 ai、章组尤韵 tʃɐu 实为 tʃau——字表中 a 的小字形被误读为 ɐ) | 新「孤例」先做同音类对照:同韵同调字全不合群 → 先怀疑读图误差;真孤例必须 10000px 高倍定案后才改旧数据 |
| 会话失去读图能力仍继续跑转写流程 | Read 图片一律 "does not support images",若硬编必然臆造 IPA 污染数据 | 立即停止并报告阻塞(无视觉 = 无法产出读音数据);恢复条件:换多模态模型重开会话或装 OCR(OCR 只能解例字行建索引,IPA 定案仍需视觉);开工前先用一张已知内容的小图自检视觉 |
版权与来源
时间检查一次来源合法性:文件名带 z-library / 1lib / libgen 等标记的属于未授权副本。
处理原则:
- 可以用于个人研究分析(读取、对照、事实性数据摘录)
- 不要把原书正文、整表、词汇表大段复制进项目文件或知识库
- 不要再分发、上传公开平台
- 在校订/引用文档开头写"来源与使用声明"段,说明来源与限定
- 主动提示用户补购正版(可用孔夫子旧书网查在售价)
交付物写法
产出一份 docs/<作者>-<版本>-校订.md:
0. 来源与使用声明(含版权提示)
1. 本书结构与页码映射(表:部分 / 印刷页 / PDF页 / 内容)
2. 目标章节的数据摘要(只记事实:清单、数值、符号体系)
3. 与项目现状的差异对照(表格)
4. 推论与修订建议(明确标注「待决策,未执行」)
5. 用法说明(后续可提取范围的路径)
6. 待办
7. 交叉引用
关键:只改自己的文档,不擅自改动项目真源文件(如配置/音系定义)。真源修订单独列出建议,等用户决策。
有文本层时
有文本层的 PDF 直接抽,成本低得多:
from pypdf import PdfReader
r = PdfReader(PDF)
t = "".join((p.extract_text() or "") for p in r.pages)
i = t.find("关键词") # 定位
print(t[i-500:i+2500]) # 上下文
若 pypdf 抽取乱序或丢字(常见于中文竖排/多栏),改用 pymupdf:
doc[i].get_text("text") 或 get_text("blocks")(按区块,保留版式)。