Technical Article

静默禁用字体子集化的 EndDoc 错误

生成报表并嵌入 TrueType 字体,输出的 PDF 可以在你尝试的每个查看器中正确打开。字形正确,文本可选,文件有效。唯一的问题是大小。一个仅使用几十个拉丁字符的文档却携带了整个 350 KB 的字体。而打印了一段中文的文档则携带了 14 MB 的 CJK 字体,而不是其应该需要的半兆字节切片。没有抛出异常,没有记录警告,文件也通过了验证。这就是 finalization(收尾)步骤顺序错误在外部的表现:没有发生失败,唯一的证据是一个过大的数字。

导致该问题的错误曾在 HotPDF 的一个发布版本线中存在,此后已被修复。写下这篇文章不是为了发布缺陷通知,而是作为一个教训,因为这种错误的表现形式具有通用性。任何文档引擎都包含一个 finalization 阶段,该阶段在写入对象之前对其进行修改,而该阶段的正确性完全取决于其各个步骤相对于序列化(serialization)的顺序。一旦某个步骤被放在了写入操作的错误一侧,它就会静默地失效,什么也不做。

字体子集化应该做什么

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

这发生的时间点至关重要。子集化并不是对磁盘上已有的字节进行的转换。它编辑的是内存中的对象图:它缩小了 /FontFile2 流的内容,修正了 /Length1,并重写了 /BaseFont 字符串。当序列化器遍历对象图并输出字节时,所有这些修改都必须准备就绪。如果编辑发生在字节写入之后,它们修改的对象就再也不会被读取了。

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

报告的行为是输出中包含全量字体且没有任何诊断信息。注册了 Unicode TrueType 字体并生成普通文档的用户发现,嵌入的字体对象与源 .ttf 文件长度相同,且 /BaseFont 名称没有携带六字母的子集前缀。在仅使用十个字形的运行和使用一万个字形的运行之间,输出文件大小从未缩小。

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

根本原因是 finalization 顺序

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

该缺陷在于,BuildAndApplyUnicodeFontSubset 是在 SaveToStreamSaveToFile 已经序列化文档之后才被调用的。子集化工具对 /FontFile2 的修改、修正后的 /Length1 以及六字母的 /BaseFont 前缀,全都是针对一个已经转换成字节的对象图进行计算的。修复方法是调整一行的顺序:将子集化调用移到序列化之前,这样写入器输出的就是子集化后的字体,而不是原始字体。修正后的顺序会先运行子集化工具,然后进行序列化。

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;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

在纠正顺序后,调用代码不需要做任何改变。一旦注册了 Unicode TrueType 字体,子集化默认处于启用状态。你注册字体、开始文档、绘制并结束文档,子集就会在字节离开内存之前根据你使用的字形构建完成。

为什么一个放错的步骤会影响一整类功能

这件事值得作为一个教训而不是注解的原因是,EndDoc 会输出一系列的关闭步骤,而且其中每一步都对其相对于写入操作的位置非常敏感。字体子集化就是其中之一。PDF/A 输出需要一个 /CIDSet 流来精确列出子集中存在的字形标识符,这是 ISO 19005 施加的约束,以便验证器确认嵌入的程序与字体描述符所声明的内容相匹配;该流在同一个 finalization 窗口中输出,并且依赖于已首先构建的子集。PDF/UA-1 根据 ISO 14289-1 §7.18.3 的要求,每个带有注释的页面都必须声明值为 /S/Tabs,并且一个名为 EnsurePDFUATabsOnAnnotatedPages 的内部例程会在同一阶段写入该键。输出意图(Output-intent)检查也在该阶段运行。

导致子集化失效的同个顺序错误,也会在带注释的页面上丢掉 PDF/UA 的制表符顺序(tab-order)键,因为该步骤位于写入操作的相同错误一侧。veraPDF 和 PAC 将缺失的 /Tabs /S 报告为违反马特洪峰协议(Matterhorn protocol)检查点 21-001。因此,一个放错位置的调用不仅会增加文件大小,还会同时静默破坏可访问性合规要求,且同样没有任何报错。这就是 finalization 阶段的危害:其步骤共享一个前提条件,单个顺序错误就可能同时导致其中几个步骤失效,而每个调用却依然返回成功。

静默输出失败实际上是如何被捕获的

不引发异常的错误是无法通过运行程序来捕获的。它是通过检查输出并将其与输入应该产生的结果进行对比来捕获的。对于字体子集化,检查是具体的。将输出文件大小与大致预期进行对比:一个仅使用了几个字形的文档不应该是一个完整字体的大小。打开嵌入的字体对象并读取其字节长度;拉丁字体的子集化 /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;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

对于 PDF/A 输出,检查更加明确,因为验证器会为你做这项工作。设置合规级别并将结果通过 veraPDF run:缺失的 /CIDSet,或者与描述符不匹配的子集,会被报告为失败条款,而不是留给你用肉眼去发现。驱动此 finalization 工作的合规开关是文档上的属性。PDFACompliance 接受诸如 '2B' 的字符串,代表 PDF/A-2 Level B,而 PDFUACompliance 是一个布尔值,用于开启标记 PDF(tagged-PDF)和制表符顺序要求。

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  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;

工程教训

这里可以总结出两条规则。第一条是,任何修改对象的 finalization 步骤都必须在这些对象被序列化之前运行,并且文档引擎的关闭阶段应该被理解为一个有序的流水线,其中序列化是最后一个动作,而不是多个动作中的普通一员。第二条是这里耗费最多时间的一点:对于输出步骤,没有错误并不代表成功。一个构建了正确子集并将其应用到错误的、已写入的图形上的例程不会报告任何错误,因为从它自己的角度来看一切正常。验证必须查看生成的产物,而不是返回代码。检查输出大小,读取嵌入字体的字节长度及其 /BaseFont 前缀,并在缺少 /CIDSet 会将静默不足转变为具名失败的情况下,让 veraPDF 判定 PDF/A 输出。

字体处理的生成器端,即如何注册和嵌入字体以进行报表输出,已在我们关于报表输出中字体和图像的文章中介绍。验证端,即这些 finalization 步骤如何针对标准进行检查,已在PDF/A 和 PDF/UA 验证指南中介绍。这两者都与此处描述的子集化和合规性工作配合使用,该工作作为 Delphi 和 C++Builder 的 HotPDF Component 的一部分提供,同时还包括本博客其他地方介绍的加载、编辑、加密和签名 API。