PDF 的测试
摘要
作者描述了为其简历 PDF 构建测试套件,以确保它能被申请人追踪系统解析,并通过实验检查禁用连字是否影响 pdftotext 的输出。
<p><a href="https://lobste.rs/s/98zvnf/tests_for_pdf">评论</a></p>
查看缓存全文
缓存时间: 2026/08/04 11:45
# 为 PDF 编写测试 · Ata Kuyumcu 的博客
我的简历有一个测试套件。这要么是理性的,要么是病态的。
借口是,我不是第一个读者。申请人跟踪系统(ATS)才是,它需要文本。文档中的每一个视觉决策(字体、页边距、日期摆放的位置)对它来说都是不可见的。它唯一能看到的就是 `pdftotext` 从页面上抓取到的任何内容。
所以这份简历(https://codeberg.org/lvmbdv/resume)是一个 Typst(https://typst.app/)文档,`make` 把它编译成 `dist/`,而 `make test` 会对找到的每个 PDF 运行一个 Python 脚本(https://codeberg.org/lvmbdv/resume/src/branch/main/scripts/test-pdfs.py)。有四项检查:
1. 文件以字节 `%PDF-` 开头。
2. `pdfinfo` 报告非空的 Author 和 Title。
3. 它至少有一页。
4. `pdftotext` 能从其中提取至少 200 个字符。
前三个是廉价的偏执。第四个才是我真正想要的。PDF 可以是文档的一张图片。如果文本层损坏或缺失,文件对我来说看起来完美无缺,但对我和人类之间的每个解析器来说都是空白的——而对简历来说,这占了受众的大部分。200 是一个任意数字,用来区分“这里面有文本”和“这里面没有文本”。
```
$ make test
Checking 1 PDF(s)...
dist/resume/Ata Kuyumcu - CV.pdf
Author: Ata Kuyumcu
Title: Ata Kuyumcu
Pages: 2
Text: 5054 chars
All 1 PDF(s) OK
```
5054 个字符。这里面有文本。
## 为解析器而写作
测试检查输出。模板则试图让输出从一开始就易于阅读:单栏、无表格、半英寸页边距、技术栈用逗号分隔的纯文本而不是图标。双栏布局是把你交错着日期的职位头衔交给解析器的经典方式。
还有模板顶部附近的这段:
```
set text(
font: font,
size: font-size,
lang: lang,
// Disable ligatures so ATS systems do not get confused when parsing fonts.
ligatures: false,
)
```
那句注释是你在简历建议网站上会读到的那种东西。连字将 “ffi” 变成一个字形,理论上是这样,然后解析器交给招聘人员的是 “oce” 而不是 “office”。我深信不疑,以至于把它写了下来。我从未验证过。
所以我验证了。两个文件,除了一个布尔值之外完全相同:
```
#set text(font: "Charis SIL", ligatures: true)
Office workflow efficiency. Certified affiliate. Final draft.
```
那一行有 61 个字符。连字开启时,Typst 在内容流中写入 53 个字形。关闭时,写入 61 个。连字是真实存在的,而且它们的行为完全符合民间传说的描述:将八个字符折叠成四个字形。
然后我提取了这两份文件:
```
$ pdftotext lig-on.pdf -
Office workflow efficiency. Certified affiliate. Final draft.
$ pdftotext lig-off.pdf -
Office workflow efficiency. Certified affiliate. Final draft.
```
完全一致。因为两个 PDF 都带有 ToUnicode CMap,这是一个表,说明“字形 0x0002 表示字母 f、f、i”。Typst 无论哪种方式都会写入它。`pdffonts` 一直在用我从不阅读的一列告诉我这件事:
```
name type encoding emb sub uni
-------------------------- ------------- ----------- --- --- ---
PYRJVB+CharisSIL CID TrueType Identity-H yes yes yes
```
`uni yes`。字形映射回 Unicode。
我还是保留了这个设置,我想诚实地承认这一点,而不是粉饰它。它能防止忽略 ToUnicode 的提取器,或者不写 ToUnicode 的生成器。这两者都存在。我只是说不出究竟是哪个 ATS 在这方面出错,而那些给我建议的页面也说不出。这就是这类问题的全部问题所在:失败在原则上是真实的,在实践中是不可观察的,而缓解措施是免费的,所以每个人都这么做,却没有人去测量它。我现在是那些测量过它却仍然照做的人之一。
它并非完全免费。禁用连字也会扼杀 `---`,所以文档中的每个日期范围都要绕远路:
```
// Cannot just use normal --- ligature because ligatures are disabled for good reasons
start-date + " " + sym.dash.em + " " + end-date
```
一个针对我从未见过的解析器的防御性设置,代价是我不能正常输入破折号。
## 从未运行的检查
当我在那里时,我发现了这个:
```
EXPECTED = {
"dist/resume/main.pdf": {
"author": "Ata Kuyumcu",
"title_contains": None, # just check non-empty
},
}
```
```
relative = str(pdf_path)
if relative in EXPECTED:
exp = EXPECTED[relative]
if exp.get("author") and exp["author"] != author:
fail(pdf_path, f"expected author '{exp['author']}', got '{author}'")
```
构建产生的是 `dist/resume/Ata Kuyumcu - CV.pdf`。字典的键是 `dist/resume/main.pdf`,那是在我把输出重命名之前、为了让招聘人员在下载文件夹里得到比 `main.pdf` 更好的东西之前的文件名。从那以后,`relative in EXPECTED` 每次运行都是 `False`。
我用一种笨办法确认了这一点:把预期的作者改成别人,然后运行测试套件:
```
$ sed 's/"author": "Ata Kuyumcu"/"author": "Somebody Else Entirely"/' ...
All 1 PDF(s) OK
```
绿灯。这个套件不会告诉我,我的简历是不是由“别的什么人”写的。
那是文件中唯一一个断言了*哪个*文档的检查。其他四项描述的是一个文件。有效的文件头、一些元数据、一些页面、一些文本:你的简历的 PDF 能通过我全部四项检查。唯一一项知道应该署谁名字的断言在重命名中悄然死去,而测试继续在那一行上面打印 `Author: Ata Kuyumcu`,但下面的检查并没有把它与任何东西进行比较。
## 关于字体,简短地说
同样的 bug 形态在 CI 中坑了我。模板要求使用 Charis SIL(https://software.sil.org/charis/),运行器从未安装过它,而 Typst 将缺少字体族视为警告并以 0 退出。于是 CI 发布了一个完全有效的 PDF,但用的是后备衬线字体,持续了三周,所有四项检查都对它很满意。修复方法是安装字体加上一行:
```
pdffonts "dist/resume/Ata Kuyumcu - CV.pdf" | grep -q CharisSIL
```
版本被固定在 6.101,因为 Charis 7 将字体族重命名为单纯的 “Charis”,而 Typst 按字体族名解析字体。正确字体更新更好的版本会产生同样的无声后备。
## 这个套件实际上是用来干什么的
四项描述文件的检查、一项描述文档却不运行的检查、一个字体 grep,以及一个充满针对我从未观察到的读者的防御性设置的模板。这么说来,并不是一个漂亮的成绩单。
但 200 字符的检查仍然是我如果只能保留一项时会保留的那项,它仍然是最有可能触发的那项,因为“文本层坏了”是确实会发生在 PDF 上的事,而且在屏幕上是不可见的。其余的是我在猜测解析器想要什么,偶尔,就像连字那样,能够证明我猜错了却仍然照做。
我确实修复了作者检查,尽管不是通过修正那个键。修正键会让 bug 的形态留在原地:一个匹配不到任何东西的查找会静默地什么都不做,而下一次重命名会重新开始计时。现在它是一个常量,与 `dist/` 下每个 PDF 进行比较,没有会漏掉的查找。
```
if author and author != EXPECTED_AUTHOR:
fail(pdf_path, f"expected author '{EXPECTED_AUTHOR}', got '{author}'")
ok = False
```
我知道它有效的方式是,我污染了这个常量,然后套件变红了——这正是我本该对原始版本运行的检查。
相似文章
我用不同类型,比较了更多解析器在14项PDF解析能力上的表现
一项基准测试比较了8款PDF解析器在14项能力上的表现,发现Chandra最准确,同时也指出了速度等方面的权衡;LightOnOCR-1B以小体积令人印象深刻,但在无法辨认的文本上会产生幻觉。
@jerryjliu0: 使用VLM解析PDF的一个缺点是难以保证输出文本的*正确性*和正确的阅读顺序……
Jerry Liu讨论了使用视觉语言模型进行PDF解析所面临的挑战,特别是关于确保文本正确性和保持正确阅读顺序的同时避免出现幻觉问题。
如果你的PDF提取器返回的是空字符串,那文件可能并没有损坏。
一位开发者描述了构建PDF分类工具的过程,该工具通过检查PDF结构来判断其包含文本层还是由扫描图像组成,从而避免静默的空提取失败,并分享了关于虚假文本检测和幻影表格的陷阱。
@jerryjliu0:LiteParse,我们的开源文档解析器,在将复杂 PDF 布局、文本和表格解析为清晰的空间网格方面表现出色……
LiteParse 是一款基于启发式规则的开源 PDF 解析器,无需依赖 ML 模型即可快速将复杂布局、文本和表格转换为整洁的空间网格。
我用6种文档类型对比了MinerU、Granite-Docling和PaddleOCR-VL的12项PDF解析能力
一位开发者对三种PDF解析模型(MinerU、Granite-Docling、PaddleOCR-VL)在六种文档类型和十二项能力上进行了基准测试,发现MinerU会丢失页脚,除非重建其markdown,而Granite-Docling输出更干净的原生markdown表格。