PDF 内部是绘制指令序列,文件中根本没有「Invoice」这个词,只有 Inv、kerning 参数、oice 的拼接——解析器必须靠阈值判断是单词内间距还是单词间空格,而这个阈值必然有失败案例。
PDF 解析:为何它依然困难
PDF 提取之所以困难,不是因为解析器做得差,而是因为你想要的东西——按顺序排列的文本、成段的段落——根本不是文件里实际存放的内容,而且在很多文档中,这些信息根本无法精确还原。
PDF 页面内容流是一系列绘图操作符,定义在 ISO 32000-1 中。涉及文本的操作符数量很少:BT 开启一个文本对象,Tf 选择字体和字号,Td 和 Tm 定位文本矩阵,Tj 和 TJ 输出字符串,ET 结束对象。一行文档的输出大概长这样:
BT
/F1 11 Tf
72 720 Td
[(Inv) 20 (oice) -250 (T) 80 (otal:)] TJ
180 0 Td
(1,240.00) Tj
ET
仔细看这段代码,因为 PDF 提取的一切问题都从中而来。文件里并没有"Invoice"这个单词——只有 Inv,然后是一个 20 单位字距调整,再是 oice。TJ 数组里的数字让笔尖在 em 的千分比上向后移动,以收紧字母对。解析器必须对每一个调整做出判断:它是单词内部的字距调整,还是单词之间的空格,但文件本身并没有说明这一点。这个判断依赖一个阈值,而阈值在两个方向上都存在失败案例:阈值太低会得到"Inv oice",太高则得到"InvoiceTotal"。
"Total:"和"1,240.00"之间也没有空格字符,只有一次 Td 将笔尖向右移动了 180 单位。这个间隙究竟是单个空格、制表位,还是两个表格列之间的边界,完全是从几何位置推断出来的。
Tj 字符串中的字节不是 Unicode,而是字符码,需要到所选字体的编码表中查找。对于嵌入的字体子集来说,这个编码表是生成文档的应用程序自行决定的——通常是只包含文档所用字形的密集编号,所以码值 1 可能对应的是任意一个碰巧最先出现的字母。
PDF 可能携带一个 /ToUnicode CMap,将这些码值映射回 Unicode。但这是可选的。当它缺失时——在使用老版本 TeX 工具链、某些 CAD 和报表工具,以及经过重写器处理过的文件中很常见——提取过程没有任何方式可以还原文本。pdfminer.six 会明确标注这一点,对无法映射的码值输出 (cid:34) 风格的标记;其他库可能直接返回原始码点,解码后看起来像合理的乱码,没有任何"这是文本吗"的断言能将其捕获,除非你检查字母分布。
连字是同样问题的微缩版本。格式正确的 /ToUnicode 会将 fi 连字字形映射到 U+FB01,这是一个真实存在的字符,你的分词器、搜索引擎和去重哈希都会将其视为与"fi"不同的内容。用 unicodedata.normalize("NFKC", text) 规范化可以将它分解回两个 ASCII 字母,这就是为什么规范形式应该在提取后立即应用,而不是在查询时才处理。
为此问题值得建立一个诊断方法,因为症状太容易被忽略。取一页提取出的文本,统计其中有多少字符落在你实际使用语言字符集之外,然后与空格字符的比例做比较。字体映射损坏的页面通常空格比例看起来正常——定位是准确的,只是码值到字符的映射出了问题——但字母分布异常,出现真实单词中永远不会出现的字母序列。这个组合比任何基于长度的检查都更能检测出问题,而且它是唯一能捕获"缺失的映射产生了字母而非 cid 标记"这种情况的检测方式。
内容流按照生成器绘制它们的顺序输出。对于单栏报告来说,这通常是自上而下。但对于双栏论文、杂志排版,或者任何包含提要引用和侧栏的文档,顺序往往并非如此,简单的提取会交错地逐行输出各栏内容——产生局部可读但全局无意义的文本。这是最坏的结果,因为根本没有任何检测机制会发现它。
Tagged PDF(PDF/UA,以及 ISO 32000 第 14.7 节中的结构树)确实携带了逻辑顺序,而且当文档拥有它时,这个顺序是权威的。但大多数文档没有。其他一切都是几何重建:通过位置将文本片段聚合成块,对块排序,对块内的行排序。PyMuPDF 正是通过 page.get_text("dict") 暴露了这些信息,它返回块 → 行 → 片段,每个层级都有边界框,sort=True 标志让纯文本调用按位置排序而非按绘制顺序。
import fitz # PyMuPDF
def two_column_aware(page, gutter_ratio=0.45):
"""Split blocks by which half of the page they start in, then read
the left column top-to-bottom before the right one."""
width = page.rect.width
blocks = page.get_text("blocks") # (x0,y0,x1,y1,text,bno,btype)
left = [b for b in blocks if b[0] < width * gutter_ratio]
right = [b for b in blocks if b[0] >= width * gutter_ratio]
order = sorted(left, key=lambda b: b[1]) + sorted(right, key=lambda b: b[1])
return "\n".join(b[4] for b in order if b[6] == 0) # 0 = text block
这个启发式方法很粗糙,但对于大量学术 PDF 来说已经足够了。关键在于,无论你是否意识到,你都在写布局启发式规则;把它写出来意味着当它出错时你可以调优它。
这些策略组合使用比相互竞争效果更好。在混合语料上存活的模式是级联:先尝试文本层,运行摄入检查中的提取断言,只对那些未能通过的文档才降级到 OCR 或视觉模型。这样一来,对于数字原生的主流文档,每页成本接近于零。
其他地方发布的解析器排名都是关于别人文档的结果。构建一个由二三十页到五十页困难页面组成的金标准集——每种你实际见过的失败模式各一页——手动转录一次,然后用它来评分候选解析器:
import jiwer, json, pathlib
def score(parsers: dict, gold_dir="gold"):
# gold/<name>.pdf next to gold/<name>.txt, transcribed by a human
rows = []
for txt in pathlib.Path(gold_dir).glob("*.txt"):
pdf = txt.with_suffix(".pdf")
want = jiwer.Compose([jiwer.ToLowerCase(),
jiwer.RemoveMultipleSpaces(),
jiwer.Strip()])(txt.read_text())
for name, fn in parsers.items():
got = want.__class__ and jiwer.Compose([
jiwer.ToLowerCase(), jiwer.RemoveMultipleSpaces(), jiwer.Strip()
])(fn(pdf))
rows.append({"doc": txt.stem, "parser": name,
"cer": jiwer.cer(want, got),
"wer": jiwer.wer(want, got)})
print(json.dumps(rows, indent=1))
字符错误率是首要指标,因为词错误率会双重惩罚第一节中的空格插入决策——一次作为删除,一次作为插入——而一个每个字母都对但部分空格有误的解析器比这个数字所暗示的要有用得多。五十页手动转录的页面是一天的工作量,但它取代了所有关于解析器的争论,以一张表格呈现。
如果级联的最后一级是视觉模型读取页面图像,每页成本高度依赖于模型如何计收图像输入——有些按瓦片计费,有些按分辨率推导的 token 数量计费,这使得同一页在不同模型间费用差异很大。模型目录中各模型的输入定价是这项计算的依据。