技术文章

在 Delphi 中用 HotPDF 渲染 PDF 内嵌字体

HotPDF 在 Delphi 中渲染 PDF 内嵌字体,无需在机器上安装任何东西:HPDFGlyphRender.pas 里的内嵌字形渲染流水线直接解析存放在 PDF 内部的字体程序 —— 来自 FontFile2 的 TrueType glyf 轮廓、来自 FontFile3 的 CFF Type 2 charstring,以及 Type 3 字形内容流 —— 并把它们重放成填充的 GDI 矢量路径。本文是用 HotPDF 把 PDF 页面渲染到 TBitmap一文背后的字体保真度深潜:那一篇讲整体渲染器,这一篇讲那些页面上的文字如何获得精确的字形

为什么 PDF 渲染出来是方块而不是文字?

渲染后的 PDF 输出里出现方块、空白或细微走样的字符,几乎总意味着渲染器向操作系统要了一份字体,而没有用文件里内嵌的那一份。抱怨的到来方式每次都一样:文档在生成它的那台机器上看着完美无缺,然后客户在一台干净的服务器或一台锁死的桌面上打开,日文发票显示成豆腐块,或者一款替换上来的相似字体把每一处换行都挪了位。那些字体从来就不在那台机器上 —— 只在 PDF 里面 —— 而止步于系统字体替换的渲染器根本看不见它们。子集字体让情况更糟:一个子集可能只带四十个字形,挂在专为这一个文件私下分配的字符码之下,而这套分配没有任何已安装字体会认同

ISO 32000-1 §9.9 为字体描述符中的内嵌字体程序定义了三种载体:FontFile 存放原始 Type 1 程序,FontFile2 存放 TrueType 程序,FontFile3 存放裸 CFF(Type1C 或 CIDFontType0C)或一个 OpenType 包装。第四种形态是 ISO 32000-1 §9.6.5 的 Type 3 字体,它根本不内嵌任何二进制 —— 每个字形都是一小段就地执行的 PDF 内容流。这三种载体在轮廓数学上各不相同(二次 B 样条、三次 charstring、任意页面操作符),因此忠实的渲染器需要为每一种配一个独立解释器,外加一层编码机制,在触碰任何轮廓之前先把字符码转成正确的字形索引

HotPDF:内嵌 PDF 字体载体示意图,字体描述符持有 FontFile、FontFile2 或 FontFile3 程序以及 Type 3 内容流,每种载体走各自的渲染路径
每种内嵌载体都有自己的解释器,从 TrueType glyf 轮廓到 Type 3 内容流

HotPDF 如何把 TrueType glyf 轮廓转成 GDI 路径?

HPDFGlyphRender.pas 中的 THPDFEmbeddedTTF 读取 loca 表定位每条字形记录,逐点遍历 glyf 轮廓,并生成一条 GDI 路径。有两条 TrueType 约定需要显式处理。第一,连续的离线点意味着在它们的中点存在一个在线点,而一条所有点都离线的轮廓,起点是其末点与首点的中点 —— 漏掉任一条规则,圆润的字形就会长出扁平棱面或者整个塌掉。第二,TrueType 曲线是二次贝塞尔,而 GDI 的 PolyBezierTo 接受三次曲线,因此每一段二次曲线都是被精确升阶,而不是拍平成折线段

Delphi 中的 TrueType glyf 渲染流水线:loca 表定位字形记录,重建隐含的在线中点,把二次曲线精确升阶为三次,最后由 GDI 填充路径
隐含中点与精确升阶让重放出的轮廓与作者创作的 glyf 曲线完全一致
// 精确升阶:二次 (P0, Q, P2) -> 三次 (P0, C1, C2, P2)
// C1 = P0 + 2/3 (Q - P0),  C2 = P2 + 2/3 (Q - P2)
C1.X := P0.X + 2 * (Q.X - P0.X) / 3;
C1.Y := P0.Y + 2 * (Q.Y - P0.Y) / 3;
C2.X := P2.X + 2 * (Q.X - P2.X) / 3;
C2.Y := P2.Y + 2 * (Q.Y - P2.Y) / 3;
// 然后用 C1、C2、P2 调用 PolyBezierTo — 几何上完全相同的曲线

升阶是无损的:三次曲线描出的是同一条曲线,因此渲染出的轮廓在任何缩放级别上都与合规查看器从同一张表画出的一致。剩下的工作是定位。每个字形都以字体单位创作(通常是每 em 1000 或 2048 单位的网格),渲染器会在填充路径之前,把缩放矩阵、文本矩阵和当前变换矩阵合成为一个字形到设备的变换。这里的顺序比看上去更要紧:把同样三个矩阵反过来合成,每个字形都会向原点塌缩 —— 一页看着莫名其妙的输出,真正的 bug 只是一行矩阵代数

Type 2 charstring 解释器如何处理 CFF 字体

THPDFEmbeddedCFF 为 FontFile3 程序提供了一个货真价实的 Type 2 charstring 解释器:它解析 CFF 的 INDEX 结构、Top DICT 和 Private DICT,然后执行每条 charstring,把路径段直接送给 GDI。OpenType 包装(OTTO 容器)会先被剥掉以取到裸 CFF 表;赤裸的 CIDFontType0C 和 Type1C 流则被直接消费。charstring 是一种紧凑的栈式语言,其中三条约定决定了解释器能否与字节流保持同步。可选的宽度前缀意味着第一个清栈操作符可能多带一个前导操作数。当栈上仍有操作数时,hintmask 操作符隐含一次 vstemhm,而要跳过的掩码字节数取决于累计的 stem 计数 —— 这个计数错一次,之后每一个操作码都会被读错。而子程序调用在查表之前要给索引加上一个偏置(依子程序数量为 107、1131 或 32768),因此未加偏置的调用会落到完全不相干的子程序上

CID 键控的 CFF 多出一层间接,会绊倒天真的实现:字符码选出的是一个 CID,但 charstring 索引用的是 GID,而字体的 charset 把 GID 映射到 CID —— 所以渲染器会在绘制前构建 CID 到 GID 的逆映射,并对携带多个 Private DICT 的字体通过 FDSelect 选出每个字形对应的那一份。名称键控的 Type1C 程序是简单 Type 1 字体的常见载体,它则通过 CFF 程序内建的编码,或者通过下文要讲的 PDF 层编码机制来解析单字节码。有一处必须如实说明的限制:解释器读取提示操作符只是为了保持字节流同步,并不执行 hinting,这条边界在文末讨论

什么是 Type 3 字体,它是怎么画出来的?

Type 3 字形根本不是轮廓 —— ISO 32000-1 §9.6.5 把它定义为一段内容流,因此 HotPDF 的做法是压入图形状态,把字体矩阵、字号和文本矩阵合成到 CTM 上,再用绘制页面的同一个操作符解释器执行该字形过程,并让字体自己的 /Resources 处于作用域内。有两处规范细节关乎正确性。Type 3 的 /Widths 以字形空间表达,而不是其他所有字体类型使用的千分之一文本空间,所以步进量必须经过 /FontMatrix —— 否则矩阵为 0.01 的条码字体步进量会差上一个数量级。另外,以 d1 操作符开头的字形过程做出了两项由渲染器强制执行的承诺:绘制被裁剪到声明的边界框内,并且按 ISO 32000-1 §9.6.5.2,该字形忽略自身的颜色操作符,改用调用方当前的填充色,因此过程内部的 rg、g、k 及其描边孪生操作符在该字形期间被抑制。漏掉颜色规则,一款被页面盖成蓝色的 d1 条码字体就会出成黑色;漏掉裁剪,一个畸形字形就会画到自己的格子外面

字符码如何变成字形 ID

轮廓解释器只完成了一半工作,因为 PDF 文本字符串里的字节是字符码而不是字形索引,ISO 32000-1 为这层映射专门写了两个子条款。对简单字体,§9.6.6 规定了严格的优先级:/Differences 数组覆盖基础编码(WinAnsiEncoding、MacRomanEncoding 或 StandardEncoding),而基础编码又覆盖字体程序自带的映射。HotPDF 把这条链解析成一张 256 项的码到 GID 表,并通过三条途径把字形名翻译成字形索引:CFF 程序内 charset 的精确匹配、把数字式 gNN/glyphNN 名当作字面索引,以及对 TrueType 程序先做 Adobe Glyph List 的名称到 Unicode 转换再查 cmap。对复合字体,§9.7 让 CIDToGIDMap 说了算:常见情形是 /Identity,但该条目也可能是一段按 CID 索引的大端字对流 —— 而 HotPDF 自己的 Unicode 输出对紧凑子集用的正是这种流形式,所以流这条路径并非什么冷僻角落

HotPDF:PDF 字符码到字形 ID 的映射示意图,简单字体经 Differences、基础编码和字体程序映射解析,复合字体经 CIDToGIDMap,随后是 TrueType cmap 回退链和单字形 GDI 兜底
两大字体家族最终都落到一张码到 GID 的表上,任何无法映射的码只降级单个字形,而不是整段文本
// /CIDToGIDMap 作为流:按 CID 索引的大端 Word 对
if 2 * CID + 1 <= High(MapBytes) then
  GID := (MapBytes[2 * CID] shl 8) or MapBytes[2 * CID + 1]
else
  GID := 0;  // 越界则映射到 .notdef

当需要查 TrueType 的 cmap 时,HotPDF 会走一条回退链,而不是只信一张子表:先是 Windows Unicode 子表(格式 4,补充平面则用格式 12),然后是 (3,0) 符号子表及其把 F000 私用区约定映射回低字节的做法 —— 这正是 Wingdings 这类符号字体能响应普通 ASCII 码的原因 —— 最后才是遗留的格式 6 和格式 0。格式 2 子表是被有意不解释的:它们映射的是 Shift-JIS、Big5 这类遗留多字节代码页而非 Unicode,而现代 CJK 字体无论如何都会带上格式 4 或格式 12 子表。任何一条路都活不下来的码,会针对那一个字形回退到 GDI 绘制,因此一个无法映射的字符只降级一个字形,而不是整段文本

内嵌路径不做哪些事

这些边界值得直说。hinting 不被执行 —— 轮廓按创作原样填充,在 150 DPI 及以上与经过 hinting 的输出无从分辨,但在极小字号下可能与 hinting 光栅器差一个像素。FontFile 中的原始 Type 1 程序(eexec 加密的 charstring)不被解释,OpenType 可变字体的轴也不会被应用;这两种情况,连同损坏的字体程序或者一张没有任何可用 cmap 的 glyf 表,都会回退到系统字体绘制,而不是让整页失败。这种保真优先的思路在渲染器的其他部分同样贯彻 —— 轴向与径向着色图案享受渐变应得的同等待遇 —— 而生成一侧也有自己的字体微妙之处,见EndDoc 如何为字体子集排序

使用这条流水线完全不需要任何针对字体的代码 —— 上面每一种机制都在页面渲染调用内部自动启用

var
  Pdf: THotPDF;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoice-embedded-fonts.pdf') > 0 then
    begin
      // 内嵌的 TrueType、CFF 和 Type 3 字体直接从文件本身
      // 渲染 —— 这台机器上不需要安装任何东西
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);
      if Bmp <> nil then
      try
        Bmp.SaveToFile('page1.bmp');
      finally
        Bmp.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

实际后果正是你的支持邮箱最在意的那一条:一份自带字体的 PDF 就用那些字体渲染,无论是在构建服务器上、在 Windows 容器里,还是在一台从没见过这款字体的客户桌面上。内嵌字形渲染流水线作为面向 Delphi 与 C++Builder 的 HotPDF Delphi Component 的一部分发布 —— 这是一个覆盖 PDF 创建、编辑、文本提取和页面渲染的原生 VCL 库,不依赖任何外部 DLL