HotPDF 在不安装机器任何字体的情况下在 Delphi 中渲染嵌入的 PDF 字体: HPDFGlyphRender.pas 中的嵌入式字形渲染流水线解析存储在 PDF 内部的字体程序 —— 来自 FontFile2 的 TrueType glyf 轮廓、来自 FontFile3 的 CFF Type 2 字符字串,以及 Type 3 字形内容流 —— 并把它们重放为填充的 GDI 矢量路径;本文是使用 HotPDF 将 PDF 页面渲染为 TBitmap 背后的字体保真度深度剖析:那篇文章整体介绍了渲染器,本文介绍这些页面上的文本如何获取其确切的形状
为什么 PDF 渲染出来的是方框而不是文本?
渲染出的 PDF 输出中出现方框、空白或微小的字符错误,几乎总是意味着渲染器向操作系统请求了字体,而不是使用文件中嵌入的字体;投诉每次都以相同的方式到达:文档在产生它的机器上看起来很完美,然后客户在干净的服务器或锁定的桌面上打开它,日本发票显示出“豆腐块”(Tofu squares),或者替换后的相似字体移动了每个换行符;这些字体从未存在于该机器上 —— 仅存在于 PDF 内部 —— 停留在系统字体替换的渲染器是无法看到它们的;子集字体让情况变得更糟:一个子集可能在私下分配给该文件的字符代码下携带 40 个字形,没有任何已安装的字体能共享这种分配
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-样条 vs 三次字符字串 vs 任意的页面操作符),因此忠实的渲染器需要为每个载体提供单独的解释器,外加一个在触及任何轮廓之前将字符代码转为正确字形索引的编码层
HotPDF 如何将 TrueType glyf 轮廓转换为 GDI 路径?
HPDFGlyphRender.pas 中的 THPDFEmbeddedTTF 读取 loca 表来定位每个字形记录,逐点遍历 glyf 轮廓,并发出 GDI 路径;有两种 TrueType 约定需要显式处理;首先,连续的曲线外(Off-curve)点意味着在它们的中点有一个曲线内(On-curve)点,而其点全部在曲线外的轮廓则从其最后一点和第一点的中点开始 —— 跳过这其中任何一条规则,圆角的字形就会长出扁平的刻面或坍塌;其次,TrueType 曲线是二次贝塞尔曲线,而 GDI 的 PolyBezierTo 接受三次曲线,因此每个二次段都是精确的升阶,而不是扁平化为线段:
// Exact degree elevation: quadratic (P0, Q, P2) -> cubic (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;
// then PolyBezierTo with C1, C2, P2 — geometrically identical curve
升阶(Degree elevation)是无损的:三次曲线跟踪相同的曲线,因此在任何缩放比例下,渲染出的轮廓都与一致的查看器从同一张表绘制的内容相匹配;剩下的工作是放置;每个字形都以字体单位(通常为 1000 或 2048 units-per-em 网格)设计,渲染器在填充路径前,将缩放矩阵、文本矩阵和当前变换矩阵合成为一个字形到设备的变换;这里的顺序比看起来更重要:如果逆向合成相同的三个矩阵,每个字形都会向原点塌陷 —— 导致页面看起来错误,而实际的 bug 仅为一行矩阵代数
Type 2 字符字串解释器如何处理 CFF 字体
THPDFEmbeddedCFF 为 FontFile3 程序提供了一个正宗的 Type 2 字符字串解释器:它解析 CFF INDEX 结构、Top DICT 和 Private DICT,然后执行每个字符字串并将路径段直接发送给 GDI;首先剥离 OpenType 包装器( OTTO 容器)以触及裸 CFF 表;直接消耗裸 CIDFontType0C 和 Type1C 流;字符字串(Charstring)是一种紧凑的栈语言,它的三个约定决定了解释器是否能与字节流保持同步;可选的宽度前缀意味着第一个栈清空操作符可以携带一个额外的导前操作数;当操作数仍在栈上时,hintmask 操作符暗示了 vstemhm,要跳过的掩码字节数取决于累积的 Stem 计数 —— 一旦搞错计数,随后的每个操作码都会被误读;而且子例程调用在查找前会给其索引添加一个偏置(根据子例程计数为 107、1131 或 32768),因此未加偏置的调用会完全落在错误的子例程上
CID 键控的 CFF 增加了一级间接寻址,这会让天真的实现栽跟头:字符代码选择一个 CID,但字符字串索引是 GID,并且字体的字符集将 GID 映射到 CID —— 因此渲染器在绘制前构建反向的 CID-to-GID 映射,并通过 FDSelect 为携带多个 Private DICT 的字体选择每个字形的 Private DICT;名称键控的 Type1C 程序(简单 Type 1 字体通常的载体)则通过 CFF 程序内置的编码或通过接下来描述 of PDF 级别编码机制来解析单字节代码;一个诚实的警告:解释器读取 hint 操作符以保持流的同步,但不执行微调(Hinting),这是在最后讨论的边界
什么是 Type 3 字体,它是如何绘制的?
Type 3 字形根本不是轮廓 —— ISO 32000-1 §9.6.5 将其定义为内容流,因此 HotPDF 通过推入图形状态,将字体矩阵、字体大小和文本矩阵合成到 CTM 上,并在字体自身的 /Resources 作用域内通过绘制页面的相同操作符解释器执行字形过程来渲染它;规范的两个细节对于正确性至关重要;Type 3 的 /Widths 是以字形空间表示的,而不是其他字体类型使用的 1/1000 文本空间,因此行进量必须通过 /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 条目的 code-to-GID 表,通过三条路径将字形名称转换为字形索引:CFF 程序内的精确字符集匹配、被视为字面索引的数字 gNN/glyphNN 名称,以及 Adobe Glyph List 名称到 Unicode 的转换,后跟 TrueType 程序的 cmap 查找;对于复合字体,§9.7 让 CIDToGIDMap 掌管:常见情况是 /Identity,但条目可能是由 CID 索引的大端对流 —— HotPDF 自身的 Unicode 输出正是为紧凑子集使用了这种流形式,因此流路径并不是个奇异角落
// /CIDToGIDMap as a stream: big-endian Word pairs indexed by CID
if 2 * CID + 1 <= High(MapBytes) then
GID := (MapBytes[2 * CID] shl 8) or MapBytes[2 * CID + 1]
else
GID := 0; // out of range maps to .notdef
当需要 TrueType cmap 查找时,HotPDF 遍历一个回退链,而不是信任单个子表:首先是 Windows Unicode 子表(格式 4,然后是用于辅助平面的格式 12),然后是具有映射到低字节的 F000 私有使用区约定的 (3,0) 符号子表 —— 类似 Wingdings 的符号字体对应普通 ASCII 代码的原因 —— 接着是旧版的格式 6 和 0;格式 2 子表故意未作解释:它们映射的是旧的多字节代码页(如 Shift-JIS 和 Big5),而不是 Unicode,并且现代 CJK 字体无论如何都带有格式 4 或格式 12 子表;无法幸存通过这些路线的任何代码都会回退到针对该单个字形的 GDI 绘制,因此单个无法映射的字符只会降级单个字形,而不是整个文本运行
嵌入式渲染路径不做的事
这些边界值得直白说明;Hinting 并不执行 —— 轮廓按设计作者原始填充,这在 150 DPI 及以上与 Hint 过的输出是没有区别的,但在非常小的尺寸下,与带有 Hint 的光栅化器相比,可能会相差一个像素;FontFile 中原始的 Type 1 程序(eexec 加密的字符字串)不做解释,OpenType 可变字体的轴(Axes)也不应用;在这两种情况下,如果字体程序损坏或 glyf 表没有任何可用的 cmap,都会退回到系统字体绘制,而不是使页面失败;这种保真度优先的做法也延伸到了渲染器的其他地方 —— 轴向和径向渐变填充模式得到了渐变色应得的相同对待 —— 并且生成端在其如何通过 EndDoc 订购字体子集中有着自有的字体细微差别故事
var
Pdf: THotPDF;
Bmp: TBitmap;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('invoice-embedded-fonts.pdf') > 0 then
begin
// Embedded TrueType, CFF, and Type 3 fonts render from the
// file itself — nothing needs to be installed on this machine
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 Component 的一部分提供 —— 这是一个原生的 VCL 库,涵盖了 PDF 的创建、编辑、文本提取和页面渲染,没有外部 DLL 依赖