生成一份报表,嵌入一个 TrueType 字体,输出文件在你尝试的每个查看器里都能正常打开。字形正确,文本可选,文件有效。唯一不对的是大小。一个只用了几十个拉丁字符的文档,却带着整套 350 KB 字体;一份打印了一段中文的文档,则带着 14 MB 的 CJK 字体,而不是它原本只该需要的半兆级切片。没有抛异常,没有记录警告,文件也通过了验证。这就是收尾步骤顺序错位在外部的样子:没有任何地方明确失败,唯一的证据只是一个大得不合理的数字
制造出这个问题的 bug 曾经存在于 HotPDF 的一个发布分支里,现在已经修复。它值得被写下来,不是作为缺陷通知,而是作为一堂课,因为这种错误的形状具有普遍性。任何文档引擎都有一个 finalization 阶段,会在对象真正写出前对它们做最后变更,而这个阶段是否正确,完全取决于这些步骤相对于序列化的顺序。只要有一步落在写出之后,它就会安静地失效
字体子集化应该做什么
子集字体是 TrueType 文件中被文档实际使用的部分。ISO 32000-1 §9.9 描述了嵌入的字体程序如何存放在字体描述符引用的流中,对于 TrueType程序,该流是 /FontFile2,并带有一个给出未压缩字节数的 /Length1。子集化会重写 glyf 和 loca 表,使其仅包含文档引用的字形,重新编号字形标识符,并在 /BaseFont 名称前加上一个六字母标记(例如 ABCDEF+)以将字体标记为子集,这完全符合规范的要求。将拉丁字体子集化为十或十五 KB,是精简 PDF 与为了一个标题而传输整个字体文件之间的本质区别
这发生的时间点至关重要。子集化并不是对磁盘上已有的字节进行的转换。它编辑的是内存中的对象图:它缩小了 /FontFile2 流的内容,修正了 /Length1,并重写了 /BaseFont 字符串。当序列化器遍历对象图并输出字节时,所有这些修改都必须准备就绪。如果编辑发生在字节写入之后,它们修改的对象就再也不会被读取了
症状,以及为什么没有任何报错
报告的行为是输出中包含全量字体且没有任何诊断信息。注册了 Unicode TrueType 字体并生成普通文档的用户发现,嵌入的字体对象与源 .ttf 文件长度相同,且 /BaseFont 名称没有携带六字母的子集前缀。在仅使用十个字形的运行和使用一万个字形的运行之间,输出文件大小从未缩小
没有任何错误提示是导致此类错误排查成本高昂的原因。在错误时间运行的子集化例程仍在运行。它遍历累积的码点使用情况,构建一个完全正确的子集,并将其应用到内存中的对象图。在内部,工作已完成且调用干净地返回。唯一的问题在于,它所编辑的对象图已不再是写入的内容,因为写入器已经完成工作。从调用者的角度来看,文档已顺利生成并保存,这正是静默失败给人的印象
根本原因是 finalization 顺序
在 HotPDF 中,关闭工作发生在 EndDoc 内部。子集化步骤是一个名为 BuildAndApplyUnicodeFontSubset 的内部例程。它读取每个文档已用码点的集合,该集合保存在文本输出路径在显示字形时填充的位图中,通过缓存的码点到字形映射表将每个已用码点映射到真实的字形标识符,并围绕该闭包重写字体程序。注册 Unicode TrueType 字体后,输出路径会为它绘制的每个字符在已用码点集合中设置一个位,因此在文档关闭时,引擎确切地知道子集必须保留哪些字形
该缺陷在于,BuildAndApplyUnicodeFontSubset 是在 SaveToStream 或 SaveToFile 已经序列化文档之后才被调用的。子集化工具对 /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;如果缺少 /CIDSet,或者子集和描述符不匹配,它会把问题报告成具体失败条款,而不是留给你靠肉眼猜。驱动这些 finalization 动作的合规开关都挂在文档属性上。PDFACompliance 接受类似 '2B' 的字符串,表示 PDF/A-2 Level B;PDFUACompliance 则是一个布尔值,用来打开 tagged PDF 和 tab-order 相关要求
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 前缀,并在 PDF/A 场景下让 veraPDF 去判断缺失 /CIDSet 这样的静默缺口,把它变成一个有名字的失败
字体处理的生成器侧,也就是如何为报表输出注册和嵌入字形,已经在报表输出里的字体和图像文章中讲过。验证侧,也就是这些 finalization 步骤如何按标准接受检查,则放在PDF/A 和 PDF/UA 验证文章里。这两者都和本文描述的子集化与合规路径相互配合,而这些能力都包含在面向 Delphi 和 C++Builder 的HotPDF Component里,与本博客其他文章介绍的加载、编辑、加密和签名 API 一起提供