技术文章

HotPDF EndDoc 中的字体子集化与收尾顺序

生成一份报表,嵌入一个 TrueType 字体,输出文件在你试过的每个查看器里都能正常打开。字形正确,文本可选,文件有效。唯一不对的是大小。一个只用了几十个拉丁字符的文档,却带着整套 350 KB 字体;一份打印了一段中文的文档,带着 14 MB 的 CJK 字体,而不是它本该只需要的那半兆级切片。没有抛异常,没有记录警告,文件也通过了验证。这就是收尾步骤顺序错位在外部的样子:没有任何东西失败,唯一的证据只是一个大得不合理的数字

它值得被当成一堂课写下来,因为这个错误的形状具有普遍性。任何文档引擎都有一个收尾阶段,会在写出对象之前对它们做最后变更,而这个阶段的正确与否,完全取决于各步骤相对于序列化的顺序。只要有一步落在写出的错误一侧,它就什么也不做,而且悄无声息

字体子集化应该做什么

子集字体是 TrueType 文件中被文档实际使用的部分。ISO 32000-1 §9.9 描述了嵌入的字体程序如何存放在字体描述符引用的流中,对 TrueType 程序而言该流是 /FontFile2,并带有一个给出未压缩字节数的 /Length1。子集化会重写 glyf 和 loca 表,使其仅包含文档引用的字形,重新编号字形标识符,并在 /BaseFont 名称前加上 ABCDEF+ 这样的六字母标记,把字体标明为子集,与规范的要求完全一致。一个被子集化到十或十五 KB 的拉丁字体,就是精简的 PDF 与为了一个标题而携带整个字体之间的差别

这发生的时间点至关重要。子集化并不是对磁盘上已有字节施加的变换。它编辑的是内存中的对象图:缩小 /FontFile2 流的内容,修正 /Length1,重写 /BaseFont 字符串。序列化器遍历对象图并输出字节时,这些修改必须全部就位。如果编辑发生在字节写出之后,它们更新的对象就再也不会被任何人读到

HotPDF:字体子集化让完整 TrueType 字面经过用到的字形闭包,重写 glyf 和 loca,再更新内存中的 FontFile2、Length1 和 BaseFont 对象
子集化围绕文档实际使用的字形重建 glyf 和 loca,随后在内存中更新 /FontFile2、/Length1 和 /BaseFont 前缀。这一切都必须在任何字节写出之前就位

症状,以及为什么没有任何报错

子集化落在写出的错误一侧时,症状就是输出带着全量字体,且没有任何诊断。注册一个 Unicode TrueType 字体,生成一份普通文档,嵌入的字体对象出来的长度和源 .ttf 文件一样,/BaseFont 名称上也没有六字母子集前缀。用十个字形的运行和用一万个字形的运行之间,输出从不缩小

没有任何报错,正是这类错误代价高昂的原因。在错误时间运行的子集化例程照样运行。它遍历累积的码点使用情况,构建一个完全正确的子集,并把它应用到内存中的对象图。在内部,工作已完成,调用干净地返回。唯一的问题在于,它编辑的对象图已不再是即将写出的内容,因为写入器已经完工。从调用方的角度看,文档顺利生成并保存——这正是静默失败给人的印象

子集化在收尾顺序中的位置

在 HotPDF 中,关闭工作发生在 EndDoc 内部。子集化步骤是一个名为 BuildAndApplyUnicodeFontSubset 的内部例程。它读取每个文档已用码点的集合——该集合保存在文本输出路径在显示字形时填充的位图中——把每个已用码点经缓存的码点-字形映射表映射到真实的字形标识符,并围绕这个闭包重写字体程序。注册 Unicode TrueType 字体后,输出路径会为它绘制的每个字符在已用码点集合中置一个位,因此在文档关闭时,引擎确切地知道子集必须保留哪些字形

陷阱在于那个调用的位置。如果 BuildAndApplyUnicodeFontSubset 在 SaveToStream 或 SaveToFile 已经序列化文档之后才被调用,子集化对 /FontFile2 的编辑、修正后的 /Length1 和六字母 /BaseFont 前缀,就全是针对一个已经变成字节的对象图计算的。正确的顺序是先跑子集化、后序列化,这样写入器输出的才是子集化后的字体,而不是原始字体。做对它只关乎一个调用的位置,别无其他

HotPDF 时序图:对比 BuildAndApplyUnicodeFontSubset 在 SaveToStream 之后运行的 EndDoc 顺序,与子集化先于序列化的正确顺序
把一个调用挪到写入之前,调用代码毫无变化,保存的字节里是完整字体还是真正的子集就此注定。站在序列化错误一侧的调用干净返回且什么也不做
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // 子集化在这里执行,早于写出
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

这一切在调用代码里都看不见。一旦注册了 Unicode TrueType 字体,子集化默认就是开启的。你注册字体、开始文档、绘制内容并结束文档,子集就会在字节离开内存之前,根据你用到的字形构建出来

为什么一个放错的步骤是一整类问题

这件事值得作为一个教训而不是脚注,原因在于 EndDoc 会发出一串关闭步骤,而其中每一步都对其相对于写入的位置敏感。字体子集化是其中之一。PDF/A 输出需要一个 /CIDSet 流来精确列出子集中存在的字形标识符,这是 ISO 19005 施加的约束,好让验证器确认嵌入的程序与字体描述符声明的内容一致;该流在同一个收尾窗口中输出,并且依赖先建好的子集。PDF/UA-1 按 ISO 14289-1 §7.18.3 的要求,每个带批注的页面都必须声明值为 /S 的 /Tabs,一个名为 EnsurePDFUATabsOnAnnotatedPages 的内部例程在同一阶段盖印这个键。输出意图检查也在那里运行

让子集化失效的同一个顺序错误,也会把带批注页面上的 PDF/UA 制表顺序键一并弄丢,因为那一步就在同一个窗口里。veraPDF 和 PAC 会把缺失的 /Tabs /S 报告为违反 Matterhorn 协议检查点 21-001。所以一个放错位置的调用不只是撑大文件体积;它同时静默破坏一项可访问性合规要求,同样不带任何报错。这就是收尾阶段的危险:各步骤共享同一个前提条件,一个顺序失误可以同时打掉其中好几个,而每个调用依然返回成功

HotPDF 在 EndDoc 内排队的四个收尾步骤,包括字体子集化、CIDSet 输出和 PDF/UA Tabs 盖印,全部在任何一个字节产生之前供给序列化器
子集化、/CIDSet 输出和 /Tabs /S 盖印共享序列化之前的同一个收尾槽位。一次错位的调用可以同时丢掉字体子集和一项可访问性要求,而所有例程仍然报告成功

静默的输出失败实际上如何被抓住

不抛异常的失败无法靠运行程序来抓住。它靠检查输出、并把它与输入本应产出的结果做对比来抓住。对字体子集化而言,检查是具体的。把输出文件大小和一个粗略预期做对比:只碰了少数字形的文档,不应该有整套字体的个头。打开嵌入的字体对象,读它的字节长度;拉丁字体的子集化 /FontFile2 只是源文件的一小部分。读 /BaseFont 名称,确认六字母前缀存在,因为它的缺失就是未应用子集的直接信号

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // 约 700 KB 字面里的少数字形,绝不该产出几百 KB 的流
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

对 PDF/A 输出,检查还可以更锋利,因为验证器会替你干活。设置合规级别,再把结果送进 veraPDF:缺失的 /CIDSet,或与描述符不匹配的子集,会被报告成一条失败条款,而不是留给你靠肉眼发现。驱动这些收尾动作的合规开关都是文档上的属性。PDFACompliance 接受 '2B' 这样的字符串,表示 PDF/A-2 Level B;PDFUACompliance 是一个布尔值,用来打开 tagged PDF 和制表顺序要求

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B,驱动 /CIDSet 输出
  Pdf.PDFUACompliance := True;    // 在带批注的页面上盖印 /Tabs /S
  Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

工程教训

从这里能提炼出两条规则。第一,任何会修改对象的收尾步骤都必须在这些对象被序列化之前运行,文档引擎的关闭阶段应该当成一条有序流水线来读,序列化是最后一个动作,而不是若干并列动作之一。第二条最容易被忘:对一个输出步骤来说,没有报错不是成功的证据。一个构建出正确子集、却把它应用到已经写完的对象图上的例程,不会报告任何异常,因为从它自己的视角看确实什么都没发生。验证必须看产物,而不是返回码。检查输出大小,读嵌入字体的字节长度和 /BaseFont 前缀,PDF/A 输出交给 veraPDF 判断——缺失的 /CIDSet 会把一个静默缺口变成一个有名字的失败

字体处理的生产侧——字面如何为报表输出注册和嵌入——见报表输出中的字体与图像一文。验证侧——这些收尾步骤如何对照标准接受检查——见PDF/A 与 PDF/UA 验证演练一文。两者都与本文描述的子集化和合规能力搭配,作为面向 Delphi 和 C++Builder 的 HotPDF Delphi Component 的一部分,随本博客其他文章介绍的加载、编辑、加密和签名 API 一同提供