技术文章

在 Delphi 里子集化 PDF 中已嵌入的字体

PDFium Component 经 FPdfDocumentFontSubset 单元的 SubsetPdfDocumentFonts 子集化已嵌入既有 PDF 的字体。只有当字体资源自身能证明文档可引用的每一个字形时,函数才会把嵌入的 TrueType /FontFile2 程序换成更小的;字形域证明不了的字体原样保留

想用它的常见理由是一批不是你产出的档案。老生成器出的发票、对账单和报表,常常每个文件都带完整的 Arial 或整副 CJK 字体,两页文档背上几百 KB 字体,摊到几百万份就是大数目。从源头重新生成几乎不可行。你手里只有这份 PDF,唯一安全的问题是:每个嵌入字体能裁掉多少,而一页都不丢字符

为什么子集化既有 PDF 有丢字形的风险?

子集化既有 PDF 有丢字形的风险,因为后处理器不知道原作者用过哪些字形。嵌入字体的生产者了解自己的文本;事后跑的工具得重建那份了解,而漏掉的任何一个字形都会变成空框或 .notdef 形状——留在没人看的页面上,直到某个客户看到

想当然的做法是解析每条内容流、每个 Form XObject、每个注解外观和每个字段默认外观,收集每种字体显示过的码,保留那些字形。它一路工作,直到撞上跨页面与 form 共享的字体资源、外观由阅读器从 /DA 重新生成的表单字段、或扫描器没建模的操作符序列。SubsetPdfDocumentFonts 刻意不扫内容流。它从 PDF 无论如何都要为每种字体携带的元数据推导字形域:/CIDToGIDMap、/W 与 /W2 宽度数组、/CIDSet 位图、/ToUnicode CMap、简单字体的 /Widths 数组和 /Encoding。元数据框不住域时,字体原样不动

PDFium Component 从 Delphi 子集化嵌入 TrueType 字体时如何证明字形域:/CIDToGIDMap、/W 与 /W2 宽度、/CIDSet、/ToUnicode、/Widths 和 /Encoding 不扫内容流就框住了域,证明不了的字体原样保留
SubsetPdfDocumentFonts 在动任何东西之前先把字体元数据变成证明过的字形域;资源框不住域的字体被记入报告、原样保留

保留原始字形 ID,内容流一字不动

SubsetPdfDocumentFonts 背后的子集后端 TPdfTrueTypeSubsetBackend 保留每个原始字形 ID 槽位,所以任何内容流、CID 或 /CIDToGIDMap 都不需要重写。它重建 glyf 与 loca 表,给每个未用字形一个零长 loca 区间,maxp.numGlyphs 保持不变。子集化之后字形 4711 还是字形 4711;只是文档够得着它时才有轮廓

这个选择消除了子集化成品文档最危险的部分。重排字形号意味着重写使用该字体的每条内容流里的每个字符串、映射流、以及一切缓存字形 ID 的地方。保留 ID 略费尺寸——loca 为每个原始字形留一个条目——但 TrueType 程序的大头是轮廓数据,去掉的正是它。复合字形追到其组件,所以带变音符的字母保住基础字母和构成它的变音符;循环或过深的复合引用让整个字体失败,而不是产出破闭环

用 PDFium Component 在 Delphi 里子集化嵌入 TrueType 字体后保留的字形 ID:字形 4711 保住编号与轮廓,未用字形拿到零长 loca 区间而 maxp.numGlyphs 不变,内容流、CID 与 /CIDToGIDMap 从不需要重写
字形 ID 在子集中存活,内容流、CID 与 /CIDToGIDMap 就从不需要重写——省下的尺寸来自丢弃的轮廓,不是重编号

改名后的字体也遵循 ISO 32000-1 §9.6.4——它要求子集字体名以六个大写字母加加号开头。组件从新字体程序的 SHA-256 摘要导出那六个字母,先剥掉已有的 ABCDEF+ 前缀,解决同一文档内字体间的撞名,把新名字写进描述符的 /FontName 和字体字典的 /BaseFont——包括 CIDFont 的 Type0 父级

uses
  System.SysUtils, System.Classes, FPdfDocumentFontSubset;

procedure SubsetEmbeddedFonts(const SourceName, TargetName: string);
var
  Source, Target: TFileStream;
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Source := TFileStream.Create(SourceName, fmOpenRead or fmShareDenyWrite);
  try
    if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
      raise Exception.Create('Font subsetting failed: ' + Report.ErrorMessage);
  finally
    Source.Free;
  end;
  // 输出永远是完整 PDF:Report.Applied 为 True 时是重建的文件,
  // 否则是源文件的逐字节副本
  Target := TFileStream.Create(TargetName, fmCreate);
  try
    if Length(Output) > 0 then
      Target.WriteBuffer(Output[0], Length(Output));
  finally
    Target.Free;
  end;
end;

SubsetPdfDocumentFonts 怎么证明 CIDFont 的字形域?

SubsetPdfDocumentFonts 从 CID 到字形的映射证明 CIDFontType2 字体的字形域,而映射决定还需要多少额外证据。显式 /CIDToGIDMap 流(ISO 32000-1 §9.7.4.2)本身就是该字体可寻址每个字形的清单,流里的每个 GID 都入域。映射流超过 65536 项、或字节数为奇数,超出有界 profile,字体保留

/CIDToGIDMap /Identity 是难啃的情形,也是现代文件里最常见的。Identity 说 CID 等于 GID,对文档用了哪些 CID 什么也没说。因此组件要求一手证据:显式 /W 或 /W2 宽度数组(§9.7.4.3)——其 CID 条目标记生产者为哪些字形声明过度量——或字体描述符里的 /CIDSet 流(§9.8.3),每个 CID 一位、高位在前的位图。两者皆无的裸 Identity 字体被保留,理由是 Identity CIDToGIDMap requires an explicit W array or CIDSet stream

拿 /W 当证据的陷阱在 /DW。规范允许生产者省略宽度等于默认宽度的每个 CID,优化得好的写入器对最常见的字形宽度正是这么干的。域若只来自 /W,那些字形会悄悄从子集里消失。为堵住这个缺口,组件遍历每台 /DescendantFonts 指向该 CIDFont 的 Type0 字体,要求其 /Encoding 为 /Identity-H 或 /Identity-V——保证 2 字节码即 CID——并把其 /ToUnicode CMap(§9.10.3)里每条 beginbfchar 与 beginbfrange 条目的源码并入域。其他任何编码、或预算内解不开的 /ToUnicode 流,都让字体保留

PDFium Component 在 Delphi 里字体子集化对 Identity CIDToGIDMap 的证据判定:显式映射流本身已列出每个字形;Identity 要求 /W 或 /W2 宽度数组或 /CIDSet 位图;/ToUnicode 源码补上 /DW 默认宽度的盲区
Identity CIDToGIDMap 自己对用量只字未提,所以子集化器索要 /W 或 /CIDSet 证据,并并入 /ToUnicode 的码,盖住 /W 在 /DW 处省掉的字形

简单 TrueType 字体:从 Widths 到字形 ID

单字节 /Subtype /TrueType 字体经码域与编码证明,再经字体程序自己的 cmap 解析到字形 ID。组件要求 0 <= FirstChar <= LastChar <= 255,以及长度恰为 LastChar - FirstChar + 1 的 /Widths 数组;过短、过长或含非数字的数组意味着码域未被证明,字体保持原样

该范围内的每个码,外加 /Differences 点名或 /ToUnicode 覆盖的码,随后按 ISO 32000-1 §9.6.6 描述的方式映射。/WinAnsiEncoding、/MacRomanEncoding 或 /StandardEncoding 下的码经对应表转 Unicode。/Differences 字形名经 Adobe Glyph List、uniXXXX 与 uXXXX 命名、258 个标准 Macintosh 字形名以及字体自己的 post 格式 2.0 名表解析。得到的值在格式 0、4、6 和 12 的 cmap 子表里查找,包括 Unicode 的 (3,1)、(3,10) 和 (0,*),Mac Roman 的 (1,0),以及 Windows Symbol 的 (3,0) 于 F000 加码

字体完全没有基础编码时,组件标记所有合理解释的并集,而不是挑一个。这会多留几个大概用不上的字形——往安全方向错。反过来的情形则严格:/Differences 里映射不到任何已证字形 ID 的名字会拒绝整个字体,因为一个解析不了的名字就意味着域不完整

procedure ReportFontSubset(Source: TStream; Log: TStrings);
var
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
  I: Integer;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Options.SubsetOptions.MinimumSavingPercent := 10;  // 每字体
  Options.MinimumDocumentSavingPercent := 5;         // 整个文件
  if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
  begin
    Log.Add('Rejected: ' + Report.ErrorMessage);
    Exit;
  end;
  Log.Add(Format('Fonts %d, subset %d, preserved %d, glyphs %d',
    [Report.FontCount, Report.SubsetFontCount, Report.PreservedFontCount,
     Report.GlyphCount]));
  Log.Add(Format('Font bytes %d -> %d, file bytes %d -> %d',
    [Report.OriginalFontBytes, Report.SubsetFontBytes,
     Report.SourceByteCount, Report.OutputByteCount]));
  for I := 0 to High(Report.Issues) do
    Log.Add(Format('  object %d kept: %s',
      [Report.Issues[I].ObjectNumber, Report.Issues[I].Reason]));
end;

SubsetPdfDocumentFonts 对哪些东西不动手?

SubsetPdfDocumentFonts 不碰它证明不了的每种字体,并把每一台连同对象号与理由记进 Report.Issues,而不是让整个文档失败。只有没有任何字体被保留时 Report.Complete 才是 True。在承诺缩减尺寸之前,值得知道这些保留情形:

  • Type 1 的 /FontFile 与 CFF 或 OpenType 的 /FontFile3 程序,报为 Only TrueType FontFile2 programs are supported
  • 没有 /W、/W2 或 /CIDSet 的 Identity CIDFont,以及既无映射流也非 /Identity 的 CIDFont
  • 字体程序被字体描述符之外的地方引用,以及描述符被既非 CIDFontType2 也非简单 TrueType 的消费方共享
  • 子集达不到 SubsetOptions.MinimumSavingPercent 的字体;重建文件达不到 MinimumDocumentSavingPercent 时则所有字体——此时 Report.Applied 为 False,输出是原始字节

有两种输入是直接失败而不是保留。已签名文档与带签字段的文档被拒,因为替换字体对象会让每个签名的字节区间失效;加密文件需要先解密。成功的运行以稀疏更新写出替换对象,然后经 孤儿对象修剪与资源去重指南描述的同一趟可达性遍历重建文件,于是旧字体程序不会以不可达对象的形态存活,结果是一个干净的单修订版

保存时子集化新文本:saSubsetNewFonts

PDFium Component 在当前会话里嵌入的字体是另一回事,由保存选项 saSubsetNewFonts 而不是 SubsetPdfDocumentFonts 处理。saSubsetNewFonts 对应 PDFium 的 FPDF_SUBSET_NEW_FONTS 标志,它让原生 HarfBuzz 子集化器处理经 TPdf.AddText 之类原生调用从系统加载的字体,包括 OpenType CFF 字体。上游 PDFium 仍把这个标志标为实验性。它不碰文件里本来已有的字体

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(0, 595, 842);
    Pdf.AddText('Quarterly statement', 'Arial', 14, 72, 770, clBlack, $FF, 0.0);
    if not Pdf.SaveAs('statement.pdf', saSubsetNewFonts) then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

在组件的探针里,带 Arial 文本的文档在 saSubsetNewFonts 下从 590 KB 降到 22 KB,保存文件里的文本提取仍然完全一致。一个 COLR v0 彩色字体——7.0 MB 的 Segoe UI Emoji——走过同一条路降到 10.5 KB,按其基础轮廓渲染,因为 PDF 没有 COLR 层的概念。可变字体是例外,拦在子集化器之前:GDI 的 GetFontData 为 1.85 MB 的 Segoe UI Variable 文件返回了 18.3 MB 的替代流,文本对象创建在任何子集化开始之前就失败了。从 Delphi 嵌入 CID Type 2 字体的走读讲了那些字体先要经过的嵌入路径

证明的尽头

SubsetPdfDocumentFonts 内的证明,强度恰好等于生产者写下的字体元数据。证据只有 /W 的 Identity CIDFont 仍有一个盲点:宽度等于 /DW、没有 /ToUnicode 条目、描述符的 /CIDSet 里也没列出的字形。这样的字形从资源出发不可证明,而组件不做它刻意回避的内容流扫描就无从知道它存在。同时省掉 /ToUnicode 与 /CIDSet 又压缩 /W 的生产者很少,但存在——所以对来历不明的生成器产出的档案,前后各渲染一份样本,比对像素与提取文本

跑批次之前,弄清对手是谁也有帮助。用 PDFium Component 读取 PDF 字体属性的做法告诉你哪些字体嵌入了;干跑一遍的 Report.Issues 告诉你其中哪些会被保留、为什么。默认选项也给工作设了界:1024 台字体、总计约一百万字形、128 MB 源和固定的工作预算,长批次任务可配可选的 CancellationToken

FAQ

SubsetPdfDocumentFonts 会改变 PDF 的文本或布局吗?

不会。原始字形 ID、CID、宽度和内容流全部保留,文本提取与渲染用的还是那些字形;只有够不着的轮廓被移除,字体名拿到新的子集前缀

为什么某个嵌入字体没被子集化?

看 Report.Issues。每台被保留的字体都带着理由——Identity CIDFont 上缺 /W 或 /CIDSet、非 TrueType 程序、畸形的 /Widths 数组,或节省低于阈值

saSubsetNewFonts 与 SubsetPdfDocumentFonts 是一回事吗?

不是。saSubsetNewFonts 在保存时子集化当前会话嵌入的字体;SubsetPdfDocumentFonts 处理一份既有 PDF 流,替换的是别的生产者早已嵌入的字体

文档字体子集化、保存时字体子集化及其背后的孤儿修剪遍历,都随 PDFium Component for Delphi and C++Builder 发布,它为 Delphi、C++Builder 与 Lazarus 项目封装 PDFium