技术文章

在 Delphi 中用系统字体渲染未内嵌的 PDF 字体

PDF 没有内嵌某个字体时,HotPDF 组件用一个已安装的 Windows 字体来渲染那段文字,字体由 HPDFMapBaseFontToSystem 挑:它解码 /BaseFont 名字,剥掉样式部分,尝试多种拼写直到 GDI 确认该字族已安装,从度量兼容字体量出缺失的标准 14 字体宽度,绘制前把单字节码转成 Unicode。这些步骤每一个都存在,因为天真的版本在真实文件上失败过。RenderLoadedPageToBitmap 页面渲染器对付内嵌字体程序没问题;这里讲的是那些根本不在文件里的字体的故事

为什么 GDI 会对未内嵌字体无声地画出另一种字型?

GDI 从不报告字体缺失:把一个它不认识的字型名递给 CreateFontIndirect,它会悄悄选个替代品,常常是一个没有粗体字重的另一种衬线。早期渲染器几乎原样传 PDF 名字,于是 TimesNewRoman,Bold、TimesNewRomanPS-BoldMT 和 SegoeUI-Semibold 全都匹配不到任何东西,落进 GDI 随手挑的那个。名字还可能更糟。ISO 32000-1 §7.3.5 允许名字把任意字节写成 #xx,而 CJK 生成器惯于把字体名写成转义的 UTF-8 或旧代码页字节;v2.766.69 之前,转义序列本身成了字型名。HPDFMapBaseFontToSystem 现在先解码转义,把合法的 UTF-8 字节序列当作字符返回,其余高位字节按系统代码页读

切样式是启发式咬人的地方。逗号总是终结字族(Arial,Bold 给出 Arial),连字符只在后面那个词是样式时才算:Bold、Italic、Oblique、Regular、Roman、Medium、Light、Black、Heavy、Semi、Demi、Thin、Extra、Ultra 或 Condensed。这条规则保住完整的 MS-Mincho,同时把 Calibri-Light 变成 Calibri。HotPDF 随后尝试字族的各拼写,接着试去掉 PSMT、MT 或 PS 后缀的拼写。从 v2.768.18 起,这些拼写覆盖单词可能起头的每个位置上的空格取舍:小写字母后面的大写之前(MyriadPro 变 Myriad Pro)、一串大写里被小写跟住的最后一个大写处(UIGothic)、以及前导 MS 之后(MSPGothic),从全空格形式到原名照抄;这样的位置超过四个时只试全空格与全连写两种。空格不能瞎加,因为 Windows 保留了一些连写:SimSun 安装时就是这个拼写,而 MicrosoftYaHei、MicrosoftJhengHei 和 MSPGothic 属于 Microsoft YaHei、Microsoft JhengHei 和 MS PGothic。v2.768.18 之前,映射器在每个内部大写前加空格,MicrosoftYaHei 被当作 Microsoft Ya Hei 查找,永远找不到。候选算「已安装」的条件是:CreateFontIndirect 之后 GetTextFace 返回被请求的名字;从 v2.768.18 起,或者所选字体的 name 表在任意语言下把它列为字族、完整字族或排版字族名。答案按名字缓存,所以含大量未安装字体的文档不再在每页对每个名字去探测 Windows

Delphi 中用系统字体渲染未内嵌 PDF 字体的 HotPDF 流水线:HPDFMapBaseFontToSystem 解码 /BaseFont 名里的 #xx 转义字节,剥掉 Bold、Italic、Light 样式后缀而保住完整的 MS-Mincho,构造 Myriad Pro、Microsoft YaHei 这样的候选拼写,只有 GetTextFace 或字体 name 表确认了已安装名字才接受
GDI 从不报告字体缺失,它无声替代——映射按顺序尝试候选,只信任 GDI 交还的名字或所选字体 name 表里列出的名字

映射函数在 HPDFRenderFontMetrics 单元里是公开的,所以预检报告可以展示每个未内嵌字体将以哪个已安装字族渲染,用的字体枚举 THotPDF 对已加载文档早就暴露了:

uses
  HPDFDoc, HPDFRenderFontMetrics;

procedure ListSystemFontMappings(Pdf: THotPDF; Log: TStrings);
var
  Page, I: Integer;
  Info: THPDFLoadedFontInfo;
begin
  for Page := 0 to Pdf.LoadedPageCount - 1 do
    for I := 0 to Pdf.GetLoadedFontCount(Page) - 1 do
      if Pdf.GetLoadedFontInfo(Page, I, Info) and not Info.IsEmbedded then
        Log.Add(Format('page %d  /%s  %s -> %s',
          [Page + 1, string(Info.ResourceName), string(Info.FontName),
           HPDFMapBaseFontToSystem(Info.FontName)]));
end;

没有 /Widths 的标准 14 字体怎么量宽度?

HotPDF 用同样度量的已安装字体量缺失的推进量,因为 ISO 32000-1 §9.6.2.2 允许标准 14 字体省略 /Widths,而库不带 AFM 表。Arial 带 Helvetica 度量、Times New Roman 带 Times、Courier New 带 Courier,所以 HPDFMeasureBaseFontWidths 以 lfHeight = -1000 创建对应字型并调 GetCharWidth32W;在那个高度下结果已经是 PDF 宽度用的 1/1000 em 单位。渲染器先经 /Encoding、/BaseEncoding 和 /Differences 把每个码转成 Unicode,缺省 StandardEncoding。SVG 导出和文本提取还撞过一个坑:一个完全没有 /Encoding 的标准 Type 1 字体产出了一个没有编码信息的解码器,SVG 导出从不注册它,量出的宽度全部没用上。补上隐含的 StandardEncoding 修好了它,前提是把它标成预定义编码;把它导进 CMap 名字路径会把每个码都解成 0,宽度跟着全错

字体描述符标志里的粗体、斜体与一个差一错误

字体描述符的 /Flags 条目从 1 起数位,不是 0,所以按 ISO 32000-1 Table 123,ForceBold 是位 19($40000),Italic 是位 7($40)。旧代码测的是 $20000,那是位 18,SmallCap。这个错误从 v2.345.0 活到 v2.766.53,因为 /FontDescriptor 几乎总是间接引用而字体构建器只读直接对象,整个标志分支从没跑过,同一种失明也忽略了 /Widths 12 0 R,按 500 单位的兜底推进量排文本。v2.766.53 一旦开始经渲染器解析间接引用,这个位就得在同一轮修正,否则每个 small-caps 字型会突然渲染成粗体:

const
  // ISO 32000-1 Table 123 从 1 起数位
  FD_ITALIC     = $00040;  // 位 7
  FD_SMALLCAP   = $20000;  // 位 18,不是字重
  FD_FORCEBOLD  = $40000;  // 位 19

procedure ApplyDescriptorFlags(Flags: Integer; var LF: TLogFont);
begin
  if (Flags and FD_FORCEBOLD) <> 0 then
    LF.lfWeight := FW_BOLD;
  if (Flags and FD_ITALIC) <> 0 then
    LF.lfItalic := 1;
end;
PDF 字体描述符 /Flags 条目的位编号:ISO 32000-1 Table 123 从位 1 起数,Italic 是位 7 的 $40,SmallCap 是位 18 的 $20000,ForceBold 是位 19 的 $40000,于是 HotPDF 测 $20000 的描述符测试瞄着 SmallCap,只在间接 /FontDescriptor 引用从未被解析时才保持无害
标志分支当了四十个版本的死代码,因为描述符是间接的——引用一旦被解析,差一位的位就变成了看得见的 small-caps 文本

带 UCS2 CMap 的 CJK 字体为什么量错宽度?

带预定义 UCS2 CMap 的 CJK 文本画出的字形对、间距错,当渲染器把码当 CID 用的时候,因为 /W 按 CID 索引、不按字符码。用 STSong-Light 和 UniGB-UCS2-H 时码恰好等于 Unicode 值,GDI 画出正确的字符,bug 藏在推进量里:小写字母的码从 97 起步,落在 [1 95 500] 这样的 /W 条目之外,全都拿到 /DW 的默认值 1000。从 v2.766.56 起,HotPDF 渲染器经 CMap codespace 区间(ISO 32000-1 §9.7.6.2)读码、先把码映射到 CID 再查宽度。只使用内建的 UCS2 和 UTF16 表以及内嵌 CMap 流;对 GBK-EUC-H 这类东西做恒等近似只会显得支持、实则输出错误,所以渲染器不装

带 UCS2 CMap 的 CJK 文本为什么字形对而推进量错:/W 按 CID 索引而码是 Unicode 值,用 STSong-Light 与 UniGB-UCS2-H 时 97 起步的小写码错过 /W 条目 [1 95 500]、拿 /DW 默认值,HotPDF 经 CMap codespace 区间把码映射到 CID 修复了它
bug 藏得住是因为这里码等于 Unicode——字形看着对而每个推进量无声走默认,判断 CJK 渲染看间距、别看形状

中文 Windows 上带变音符号的字符为什么变成问号?

单字节码绝不能流向 ANSI(「A」)系 GDI 函数,因为 GetGlyphOutlineA 和 GetGlyphIndicesA 按系统代码页解字节,而 TextOutA 用所选字体的字符集。在中文系统上,Arial 的字节 $A9(Windows-1252 里的版权符)成了 GBK 前导字节,渲染成「?」,这是 v2.766.83 加上的无 hint 轮廓路径一头撞上的陷阱。v2.767.3 用 GetTextCharset 向已实现字体要字符集,经 TranslateCharsetInfo 换算成代码页,把字节过一遍 MultiByteToWideChar 再调 W 系函数;符号字体改用 U+F000 加码值。与 Windows-1252 不一致的编码——/Differences、StandardEncoding、MacRomanEncoding——在任何系统字体见到它们之前先映射到 Unicode

用系统字体绘制有什么极限?

系统字体渲染是一种近似,HotPDF 组件对自己在哪里止步很诚实。v2.768.18 之前,安装检查只比对 GetTextFace 返回的名字,而在本地化的 Windows 上这个函数按系统语言报字族名,所以中文 Windows 上的 Microsoft YaHei 或日文 Windows 上的 Yu Mincho 被判缺失、画成 GDI 替代品;从 v2.768.18 起,以别名回来的字型也会到字体 name 表里查,这类字体找得到。度量兼容只为 Helvetica、Times 和 Courier 三族有保证;Symbol 映到 Symbol、ZapfDingbats 映到 Wingdings,那是权宜而非匹配。上面的预检也只看每页 /Resources 字典里的字体,不管从 Form XObject 内部引用的。某个码还是画不出来时,绘制期的未解析字形跟踪会报告它,比眯眼盯缩略图强

经久的修复在创作侧。HotPDF 自己写出时 FontEmbedding 默认为 True,即使代码用 Helvetica 调 SetFont 也以内嵌的 Arial 替代,内嵌文本走内嵌字体字形渲染器,不经过上面任何猜测。对进来的文件,一个便宜的防护是渲染前发现映射字族不在屏幕字体清单里就警告:

// VCL:Screen.Fonts 列出已安装字族名(Forms 单元)
function MissingSystemFamily(const BaseFont: AnsiString): Boolean;
begin
  Result := Screen.Fonts.IndexOf(HPDFMapBaseFontToSystem(BaseFont)) < 0;
end;

完整组件(含页面渲染、文本提取和写出侧的字体子集化)见 HotPDF Delphi PDF component 产品页