技术文章

Delphi 中通过 fontsub.dll 进行 TrueType 字体子集化

HotXLS 是 Delphi 和 C++Builder 的 Excel 组件,可通过 TrueType 字体子集化缩小嵌入 PDF 的字体体积:导出 PDF 时,它调用 Windows 系统库 fontsub.dll 的 CreateFontPackage 函数,根据工作表实际使用的 Unicode 码点重建嵌入的 TrueType 字体,而不是携带完整字体文件。一份包含两百行中文产品名称的报表可能只需要几百个不同的汉字,但 Windows 随附的 CJK 字体通常每个就有 5 到 20 MB。完整嵌入一个字体后,单是字体大小就可能超过 PDF 中其他所有对象的总和

大多数 Delphi 开发者从未听说过 fontsub.dll,这并非偶然:Microsoft 将它作为体积小、文档稀少的工具 DLL 随系统提供,而不是广为人知的 Win32 API。HotXLS 将它视为可选能力而非硬依赖,因此导出器如何加载和调用它,以及缺少它时如何回退,既体现了防御式 Windows 编程,也体现了对字体格式的处理,这两方面都值得展开说明

Unicode 文本为什么会让 HotXLS 导出的 PDF 迅速膨胀

只有当工作表文本超出 WinAnsi 范围时,HotXLS 的 PDF 导出器才会使用嵌入的 TrueType 字体,其余情况会继续使用内置的 Helvetica 字体系列,这正是工作表导出为 PDF 的操作说明详细介绍的默认路径。WinAnsi 对西欧文本的覆盖已经足够好,因此许多工作簿根本不会触发字体嵌入:PDF 只需按名称引用 Helvetica,由阅读器在本地提供字体,文件自然保持较小。一旦单元格包含 WinAnsi 无法表示的内容,例如中文产品名称、韩文备注或注释中的特殊符号,导出器就必须嵌入实际字体程序,因为 PDF 阅读器无法为标准 14 种字体之外的字符提供备用字形

HotXLS 会自动定位该字体,扫描 Windows Fonts 文件夹中的一小组已安装候选字体,其中包括 Windows 为中文和韩文显示提供的 CJK 字体,除非导出器的 UnicodeFontFile 属性已经指向特定文件。无论找到哪种字体,都会先完整嵌入,再执行子集化。这项嵌入要求是 PDF 特有的:HotXLS 的RTF 和 HTML 导出路径通过将码点转义到字节流中来保持 Unicode 文本,而不是携带字体程序,因此本文讨论的体积问题在这两种格式中没有对应情况

uses
  lxHandle, lxPDF;

var
  Book: TXLSWorkbook;
  Exporter: TXLSPDFExport;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('catalog-cn.xlsx');
    Exporter := TXLSPDFExport.Create;
    try
      // Optional: pin a specific CJK-capable font instead of the
      // exporter's automatic Windows\Fonts scan.
      Exporter.UnicodeFontFile := 'C:\Windows\Fonts\simhei.ttf';
      Exporter.SaveAsPDF(Book.ActiveSheet, 'catalog-cn.pdf');
    finally
      Exporter.Free;
    end;
  finally
    Book.Free;
  end;
end;

fontsub.dll 是什么,为什么不从头编写子集化器

fontsub.dll 是一个随 Windows XP 起提供的 Windows 系统库,在这里相关的函数只有一个:CreateFontPackage。将源 TrueType 字体的字节和要保留的 Unicode 码点列表交给它,它就会返回一个仍满足所有字体格式约束的最小字体:重新编号字形索引,只围绕保留的轮廓重建 glyfloca,并相应重写 hmtxcmap。HotXLS 直接依据这一约定声明函数指针类型

const
  TTFCFP_FLAGS_SUBSET = 1;
  TTFMFP_SUBSET = 0;
  TTFCFP_MS_PLATFORMID = 3;
  TTFCFP_UNICODE_CHAR_SET = 1;

type
  TCreateFontPackage = function(puchSrcBuffer: Pointer; ulSrcBufferSize: Cardinal;
    var puchFontPackageBuffer: PAnsiChar; var pulFontPackageBufferSize: Cardinal;
    var pulBytesWritten: Cardinal; usFlags, usTTCIndex, usSubsetFormat,
    usSubsetLanguage, usSubsetPlatform, usSubsetEncoding: Word;
    pusSubsetKeepList: PWordArray; usSubsetKeepListCount: Word;
    lpfnAllocate, lpfnReAllocate, lpfnFree, reserved: Pointer): Cardinal; cdecl;

如果不调用 CreateFontPackage 而手工实现它的工作,就必须编写一个正确的 TrueType 子集化器:遍历复合字形以补入保留字形引用的所有组件字形,在丢弃轮廓后重建 loca 偏移,遵守字体 OS/2 表中的嵌入许可位,并让这些逻辑在客户机器安装的各种特殊字体上都正确运行。Microsoft 已经解决了这个问题,并将解决方案作为 Windows 的一部分随系统提供,因此调用由 Microsoft 维护、会针对其字体渲染栈测试并免费分发到每台机器的系统 DLL,只需让 HotXLS 负责动态加载和函数指针;重新实现同样的逻辑,则意味着要为一个拥有数十年边界情况的二进制格式维护解析器,而这个功能只在字体碰巧很大时才有价值

根据实际渲染的字形构建保留列表

HotXLS 从一个原本就因其他用途而维护的映射中构建子集化保留列表,因此无需额外的统计成本。每当页面渲染代码绘制需要嵌入 Unicode 字体的字符时,它都会查找该字符的字形索引,并在 FUnicodeGlyphMap 中记录这组对应关系。这个字形到码点的表还会驱动 PDF 的 ToUnicode CMap,使成品文档中的复制粘贴返回原始文本,而不是原始字形 ID。页面内容流完成时,该映射已经准确列出文档使用过的 Unicode 码点集合,不多不少

var
  keepList: array of Word;
  keepCount, i: Integer;
  codePoint: LongWord;
begin
  SetLength(keepList, FUnicodeGlyphMap.Count);
  keepCount := 0;
  for i := 0 to FUnicodeGlyphMap.Count - 1 do
  begin
    codePoint := LongWord(StrToIntDef('$' + FUnicodeGlyphMap.ValueFromIndex[i], 0));
    if codePoint > 0 then
    begin
      keepList[keepCount] := Word(codePoint);
      Inc(keepCount);
    end;
  end;
end;

在最终处理阶段,HotXLS 会再次遍历同一映射,构建 CreateFontPackage 所需的保留列表,也就是以该 API 的保留列表参数要求的 16 位形式保存要保留 Unicode 码点的普通数组。由于该参数是 16 位字数组,它可以直接处理基本多文种平面,其中包括普通 CJK、西里尔文、希腊文和阿拉伯文;如果工作表大量使用补充平面字符、某些表情符号或罕见历史文字,就超出了单个保留列表项可以直接命名的范围。了解这一边界很重要,但它并不是缺陷,因为绝大多数 Unicode 使用量较高的业务电子表格根本不会涉及该平面

缺少 fontsub.dll 时会发生什么

HotXLS 从不假设 fontsub.dll 一定存在,PDF 导出也不会因为缺少它而失败。只有在确实需要子集化时,库才会使用 SafeLoadLibraryGetProcAddress 动态加载它,而不是静态导入,原因正是 fontsub.dll 不像 kernel32.dll 那样属于有文档且保证存在的公共 API:它是随系统提供的字体嵌入工具,Microsoft 的约定并未承诺它会在每个 SKU、每个服务分支或尝试模拟 Windows 的每个兼容层中持续存在

var
  hFontSub: HMODULE;
  CreateFontPackage: TCreateFontPackage;
begin
  hFontSub := SafeLoadLibrary('FontSub.dll');
  if hFontSub = 0 then
    Exit; // no subsetting available - keep the full embedded font
  try
    @CreateFontPackage := GetProcAddress(hFontSub, 'CreateFontPackage');
    if not Assigned(CreateFontPackage) then
      Exit;
    // ... call CreateFontPackage, check its return code ...
  finally
    FreeLibrary(hFontSub);
  end;
end;

所有失败路径都会回到同一个结果。DLL 缺失、导出函数缺失、返回码非零,或者字体的 OS/2 表通过嵌入许可位禁止子集化时,HotXLS 都会保留已经嵌入的完整字体并继续执行。不会抛出异常,不会中止导出,调用代码也不需要为字体优化额外编写异常处理;无论哪种情况,导出的 PDF 都有效,唯一的区别只是文件最终较小还是稍大

PDF 实际能缩小多少

HotXLS 的 TrueType 字体子集化通常可以将 Unicode 使用量较高的工作表导出的 PDF 缩小到未子集化大小的二十分之一至八分之一,相当于缩小 8 到 20 倍,具体幅度取决于文档实际使用了完整字体的多少内容:一份只包含几百个不同中文字符的采购订单,只需从 CJK 字体提供的数万个字形中保留这几百个,而覆盖更广字符范围的工作表则会按比例保留更多字形。HotXLS 还会在将子集字体字节写入 PDF 的 /FontFile2 流之前再执行一次 Flate 压缩,文档其他内容流也使用同样的压缩方式,而且这一切都不要求调用代码做额外处理:从未超出 WinAnsi 的工作表不会进入这条路径,继续使用普通 Helvetica 导出;触发 Unicode 字体路径的工作表会自动完成子集化,不需要设置属性,也不需要单独调用,而涉及的唯一属性 UnicodeFontFile 只决定嵌入并进行子集化的字体,不决定是否进行子集化

字体子集化只是HotXLS Delphi Excel 组件完整 PDF 导出能力中的一个细节,该能力还包括分页、工作表打印元数据,以及随产品提供的 CSV、HTML 和 RTF 导出路径