把字体嵌进 PDF,让它在任何机器上都长一样

· 6 分钟 · engineering pdffontsmpdftypographyphp

需求只有一句话

一个报告生成器必须产出在任何地方都长得一样的文档:中文标题用宋体、中文正文用黑体、英文标题与封面数字用一款展示衬线体、英文正文和表格用 Inter。字重也要对——强调用 medium、标题用 semibold——而且是三语、两个 PDF 渲染引擎都要对。

同时,字体必须跟着文档走。不是「服务器装过」,也不是「读者大概有」,而是嵌进文件里,这样另一个国家的印厂看到的页面是一样的。

老管线在最后这句上栽得很隐蔽:它依赖服务器上用包管理器装过的字体,而且只嵌入了一款中文字体。第二档字重根本不存在,于是「粗体」是渲染器的合成近似;而想发现这件事,唯一的办法是打开 PDF 去看里面到底装了什么。

规则一:从成品 PDF 里把字体读回来

「已嵌入」是一个关于输出的断言,所以我量的是输出。同一份文档,改前与改后:

改前改后
zh 页数2323
en 页数2829
bm 页数3030
真正嵌进去的字体一款中文字体 + Roboto思源宋 Medium + SemiBold · 思源黑 Regular + Medium · Cormorant Garamond Medium + SemiBold · Inter Regular + SemiBold + Medium · 拉丁报告里的汉字走思源黑
渲染时间 / 峰值内存基线同一量级(±10%,属噪声)
文件体积基线约 +3%

每个字面的字形数是收据:中文报告里 15,988 个字形落在正文黑体、2,247 个落在它的 medium 档、634 个落在标题宋体的 medium、222 个落在它的 semibold。这就是「字体在服务器上」与「字体在文件里」的区别——另一个国家的读者看到的是同样四个字面,而代价是某一种语言正好多一页(拉丁字体的度量比它替换掉的那个更宽)。

规则二:只出静态实例,理由写在解析器里

现代做法看起来该是「每个字族发一个可变字体」,但在这里是错的,有两个实测理由:

  1. PDF 引擎的字体解析器只读 glyf/loca。 喂它一个 PostScript/CFF 轮廓的 OpenType 文件,它直接拒绝:Fonts with postscript outlines are not supported。问题不是「可变」,是 CFF 轮廓。
  2. 它完全不读 fvar 表,所以可变字体会静默地解析成它的默认实例——而中文字族的默认实例恰恰是最细的那一档(name 表里写得清清楚楚:Thin 100 与 ExtraLight 200)。整份报告会变成发丝体,而整条管线里不会报任何一个错。

所以构建步骤拿上游的可变字体实例化成静态切面:跑实例化器,然后把 name、OS/2.usWeightClass 和 head.macStyle 改写成它现在真实的字重(源文件的名字里还写着 Thin)。产物旁边有一份 manifest,逐文件记下 family、字重、sha256、字形数与码位数;许可证也随文件一起进仓库。

规则三:引擎只会说两种字重,那就映射到两个槽位

渲染引擎的 CSS 只认 normal 和 bold——它的源码自己就这么写(“Does not support: bolder, lighter, 100..900”)。所以数字字重对它毫无意义;而同一条管线里的无头 Chromium 又完全认它们。

可用的映射是:每个字族声明一个 R 槽和一个 B 槽(常规一档、更重一档),font-weight: 500 落到目标字族的 R 槽,600 只在 PDF 那一侧被改写成 bold,落到 B 槽。Chromium 侧照写 600。两条测试钉住它:B 槽必须比 R 槽重(读 usWeightClass,不是假设),且 font-weight: 600 绝不允许出现在交给 PDF 引擎的样式表里。

有一处细节值得单写一段:在这个管线里,medium 不是强调手段,而是一个字族成员。中文标题用宋体的 medium 当常规、用 semibold 做强调;英文标题用展示衬线体做同样的事。这里搞错,屏幕上完全看不出来,而印出来一目了然。

规则四:不要按你控制不了输入的字集做子集化

第一版把中文字体按两个国家字集的并集做了子集,产物从约 50 MB 降到约 29 MB。然后我跑了姓名探针,发现这些字被裁掉了:

犇  骉  頔  祎  廸  玧  祾  赟  堃

每一个都是真实的人名用字。报告会把客户姓名印在封面上,而姓名是自由输入——所以一套基于子集的构建,迟早会在产品最醒目的那一页上放一个豆腐块。全字形覆盖是约 31k 码位、约 50 MB 的嵌入字体;为了永远不给客户看一个空方块,这个价钱很便宜。

规则五:把「缺字」变成构建失败,而不是惊喜

闸门跑三项检查,任何一项不过就非零退出:

已知边界写在明处,不藏起来:✅ ✔ 这类符号在这几款正文字体里都没有字形,而引擎也画不了彩色 emoji,所以它们不出现在报告正文;拉丁标题那款展示衬线体只有 974 个字形,不画 ① ★ ℃,这也是为什么内容库的检查覆盖所有槽位而不只是正文;而拉丁报告里的汉字必须走专门的 CJK 槽位——两条测试防止两个渲染器在这点上产生分歧。

如果重来一次

  1. 在动样式之前先定字族与字重。 版式做完之后再去加第二档字重,意味着每一页页数都要手工复核一遍。
  2. 选字体格式之前先读渲染器的源码。 可变字体看着是审美问题,实际上是解析器问题——两个文件、十分钟就定了。
  3. 把要嵌进文档的一切当作许可证产物。 字体文件连同许可证一起进仓库,否则就得进别人的法务审查。
  4. 量「到底嵌进去了什么」。 页数、每个字面的字形数、文件体积、渲染时间:四项都动了,其中三项的方向和我猜的相反。

结果

改前:一款中文字体加一款界面字体、靠合成粗体、还取决于服务器装过什么。改后:四个字族、八个静态字面真正嵌在文件里,字重是各个字族真实的成员,页数与上一版相差不超过一页,而且有一条 lint 闸门:某个字没有字形时直接让构建失败——于是失败形态从「客户看到一个方块」变成了「CI 拒绝发版」。这跟这条管线里其它地方的取舍是同一个:让错误发不出去,而不是让错误容易被看见。

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts