Tests for a PDF

Tests for a PDF

My CV has a test suite. This is either reasonable or a symptom. 我的简历有一套测试程序。这要么是合理的做法,要么是一种病态的表现。

The excuse is that I’m not the first reader. An applicant tracking system is, and it wants text. Every visual decision in the document (the font, the margins, where the dates sit) is invisible to it. The only thing it can see is whatever pdftotext scrapes off the page. 我的借口是,我并不是简历的第一个读者。申请人跟踪系统(ATS)才是,而它需要的是文本。文档中的每一个视觉决策(字体、页边距、日期位置)对它来说都是不可见的。它唯一能看到的就是 pdftotext 从页面上抓取下来的内容。

So the resume is a Typst document, make compiles it into dist/, and make test runs a Python script over every PDF it finds. Four checks: 因此,这份简历是一个 Typst 文档,通过 make 将其编译到 dist/ 目录中,并由 make test 对找到的每个 PDF 运行一个 Python 脚本。测试包含四项检查:

  1. The file starts with the bytes %PDF-.
  2. pdfinfo reports a non-empty Author and Title.
  3. It has at least one page.
  4. pdftotext gets at least 200 characters out of it.
  5. 文件以 %PDF- 字节开头。
  6. pdfinfo 报告作者和标题不为空。
  7. 至少有一页。
  8. pdftotext 能从中提取出至少 200 个字符。

The first three are cheap paranoia. The fourth is the one I actually wanted. A PDF can be a picture of a document. If the text layer is broken or absent the file looks perfect to me and is blank to every parser between me and a human, which for a CV is most of the audience. 200 is an arbitrary number that separates “there is text in here” from “there is not”. 前三项是廉价的偏执。第四项才是我真正想要的。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 characters. There is text in there. 5054 个字符。里面确实有文本。

Writing for the parser

为解析器而写

The tests check the output. The template tries to make the output easy to read in the first place: single column, no tables, half-inch margins, tech stacks as comma-separated plain text rather than icons. A two-column layout is the classic way to hand a parser your job titles interleaved with your dates. 测试检查的是输出结果。模板本身就试图让输出易于阅读:单栏、无表格、半英寸页边距、技术栈使用逗号分隔的纯文本而非图标。双栏布局是让解析器将你的职位名称与日期混淆的经典方式。

And this, near the top of the template: 在模板顶部附近还有这段代码:

#set text(
  font: font,
  size: font-size,
  lang: lang,
  // Disable ligatures so ATS systems do not get confused when parsing fonts.
  ligatures: false,
)

That comment is the kind of thing you read on resume advice sites. Ligatures turn “ffi” into one glyph, the theory goes, and the parser hands the recruiter “oce” instead of “office”. I believed it enough to write it down. I never checked it. 那条注释是你常在简历建议网站上看到的内容。理论认为,连字(Ligatures)会将“ffi”变成一个字形,导致解析器交给招聘人员的是“oce”而不是“office”。我对此深信不疑,甚至把它写了下来。但我从未验证过。

So I checked it. Two files, identical except for one boolean: 所以我验证了一下。两个文件,除了一个布尔值外完全相同:

#set text(font: "Charis SIL", ligatures: true)
Office workflow efficiency. Certified affiliate. Final draft.

That line is 61 characters. With ligatures on, Typst writes 53 glyphs into the content stream. With them off, 61. The ligatures are real and they are doing exactly what the folklore says they do, collapsing eight characters into four glyphs. 那一行有 61 个字符。开启连字时,Typst 在内容流中写入了 53 个字形。关闭时则是 61 个。连字是真实存在的,它们确实在执行传说中的操作,将八个字符压缩成了四个字形。

Then I extracted both: 然后我提取了两者:

$ pdftotext lig-on.pdf -
Office workflow efficiency. Certified affiliate. Final draft.
$ pdftotext lig-off.pdf -
Office workflow efficiency. Certified affiliate. Final draft.

Identical. Because both PDFs carry a ToUnicode CMap, which is the table that says “glyph 0x0002 means the letters f, f, i”. Typst writes one either way. pdffonts has been telling me this the whole time in a column I never read: 完全相同。因为两个 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

The glyphs map back to Unicode. 字形可以映射回 Unicode。

I kept the setting anyway, which I want to be honest about rather than dress up. It protects against an extractor that ignores ToUnicode, or a producer that doesn’t write one. Both exist. I just can’t name the specific ATS that gets this wrong, and neither can any of the pages that gave me the advice. That’s the whole problem with this genre: the failure is real in principle, unobservable in practice, and the mitigation is free, so everyone does it and nobody measures it. I’m now one of the people who measured it and did it anyway. 我还是保留了这个设置,我想诚实地说明这一点,而不是粉饰它。它能防止提取器忽略 ToUnicode,或者防止生成器不写入该表。这两种情况都存在。我只是无法说出具体是哪个 ATS 会出错,那些给我建议的网页也说不出来。这就是这类建议的通病:失败在原则上是真实的,但在实践中无法观察到,而缓解措施又是免费的,所以每个人都照做,却没人去验证。现在,我成了那些验证过它但依然选择照做的人之一。

It isn’t quite free. Disabling ligatures also kills ---, so every date range in the document goes the long way around: 它并不完全免费。禁用连字也会导致 --- 失效,所以文档中的每个日期范围都必须绕远路:

// Cannot just use normal --- ligature because ligatures are disabled for good reasons
start-date + " " + sym.dash.em + " " + end-date

A defensive setting against a parser I’ve never met, and the cost is that I can’t type an em dash normally. 这是一个针对我从未见过的解析器的防御性设置,代价是我无法正常输入长破折号(em dash)。

The check that never ran

从未运行的检查

While I was in there I found this: 在检查时,我发现了这个:

EXPECTED = {
    "dist/resume/main.pdf": {
        "author": "Ata Kuyumcu",
        "title_contains": None, # just check non-empty
    },
}
# ...
relative = str(pdf_path)
if relative in EXPECTED:
    # ...

The build produces dist/resume/Ata Kuyumcu - CV.pdf. The dictionary is keyed on dist/resume/main.pdf, which was the filename before I renamed the output so that recruiters would get something better than main.pdf in their downloads folder. relative in EXPECTED has been False on every run since. 构建产物是 dist/resume/Ata Kuyumcu - CV.pdf。而字典的键是 dist/resume/main.pdf,这是我重命名输出文件之前的文件名,目的是让招聘人员在下载文件夹中看到比 main.pdf 更好的文件名。自那以后,每次运行 relative in EXPECTED 的结果都是 False

I confirmed it the dumb way, by changing the expected author to somebody else and running the suite: 我用笨办法确认了这一点,通过将预期的作者改为其他人并运行测试套件:

$ sed 's/"author": "Ata Kuyumcu"/"author": "Somebody Else Entirely"/' ...
All 1 PDF(s) OK

Green. The suite will not tell me if my CV is by Somebody Else Entirely. 测试通过。如果我的简历变成了“其他人”,测试套件根本不会告诉我。

That was the only check in the file that asserted anything about which document this is. The other four describe a file. Valid header, some metadata, some pages, some text: a PDF of your CV passes all four of mine. The one assertion that knew whose name was supposed to be on it died silently to a rename, and the tests kept printing Author: Ata Kuyumcu on the line right above the check that wasn’t comparing it to anything. 这是文件中唯一一个断言“这是哪份文档”的检查。其他四项只是在描述一个文件。有效的头部、一些元数据、一些页面、一些文本:任何一份简历的 PDF 都能通过我这四项检查。唯一一个知道上面应该写谁名字的断言,因为重命名而悄无声息地失效了,而测试程序依然在检查项上方打印着 Author: Ata Kuyumcu,尽管它并没有将该值与任何东西进行比较。

The font, briefly

关于字体,简而言之

The same shape of bug got me in CI. The templates ask for Charis SIL, the runner never installed it, and Typst treats a missing font family as a warning and exits 0. So CI published a perfectly valid PDF in the fallback serif for three weeks, and all four checks were delighted with it. The fix is a font install plus one line: 同样的错误也出现在了 CI 中。模板要求使用 Charis SIL,但运行环境从未安装它,而 Typst 将缺失字体视为警告并以状态码 0 退出。因此,CI 在三周内发布了一个使用回退衬线字体的 PDF,而所有四项检查都对此感到满意。修复方法是安装字体并增加一行:

pdffonts "dist/resume/Ata Kuyumcu - CV.pdf" | grep -q CharisSIL

The version is pinned to 6.101 because Charis 7 renames the family to plain “Charis”, and Typst resolves fonts by family name. A newer, better version of the correct font would produce an identical silent fallback. 版本被锁定在 6.101,因为 Charis 7 将字体族重命名为简单的“Charis”,而 Typst 是通过字体族名称来解析字体的。一个更新、更好的正确字体版本反而会导致同样静默的回退。

What this suite is actually for

这套测试套件的真正用途

Four checks that describe a file, on 四项描述文件的检查,关于