技术文章

在 Delphi 中于绘制时检测缺失的 PDF 字形

PDF 里缺一个字形不算错误。产出方请求了一个所选字体无法映射的字符,字体返回字形索引零,产出的文件结构有效、处处可开,只是在本该是姓名或金额的位置显示一个空框。生成管线里没有任何人发现,收件人发现了。HotPDF 用 TrackUnresolvedGlyphs 闭环此事:打开它之后,文本绘制路径会记录每个字形查找解析到索引零的码点,对每个不重复的发现触发一次 OnUnresolvedGlyph,附上码点、映射失败的字体、所属文字系统,以及一组能覆盖它的字体建议

检测是答案的一半。另一半是 SetFontFallbackChain,它为每个文字系统注册一份有序字体列表,常见情形自行化解,只有真正的缺口才会到达你的处理器。两者合力,把一类过去由客户报告的缺陷变成了构建期检查

缺字形为什么不引发任何异常?

因为 ISO 32000 没有规定产出方必须验证覆盖,而且字形索引零是一个合法字形。它是 .notdef,轮廓由字体设计者选择:通常是空矩形或空心矩形,有时什么都没有。绘制它的查看器行为完全正确。文本提取甚至可能返回正确的字符,因为 /ToUnicode 映射写自源文本而非轮廓,于是自动化往返检查会欣然放行一份可见文本带洞的文档

缺字形为何保持沉默的图解:字形零画出空框,ToUnicode 提取又能通过往返检查
字形零是合法的 .notdef 答案,/ToUnicode 写自源文本,管线中没有任何环节被告知缺口

实际后果是:覆盖必须在绘制那一刻检查——那时库还知道请求的是哪个码点、字体实际给出的是哪个字形。此后信息就不存在了

检测器必须盯着子集化状态,而不是设备上下文

第一版实现正是栽在这里,原因值得理解,因为它适用于任何挂在文本管线上的覆盖检查。HotPDF 有两条文本路径。一条经由注册的 Unicode TrueType 字体输出,其内存字符映射在注册时构建。另一条是传统 GDI 路径,每段文本运行都新建设备上下文与字体句柄

从 GDI 路径判断覆盖没有希望。它的映射不是最终写进输出内容流的映射,两者不同步,于是读取 GDI 结果的检测器会把整个可打印 ASCII 范围都报成未解析。权威答案在注册字体里:RegisterUnicodeTTF 解析的字符映射,经 GetUnicodeGlyphForCodepoint 查询。因此检测器以子集就绪状态为闸门条件,不依赖任何 GDI 条件;从未注册 Unicode 字体的文档它干脆不运行——这是正确的,因为那些文档本来就受限于标准编码

旁边还有第二个陷阱。字体的 GDI 家族名与注册时从字体二进制提取的 PostScript 名是不同的字符串,而且无法归一化:名为 Arial Unicode MS 的家族,PostScript 名是 ArialMT。任何写成"当前选中字体是否是我们注册的那个"、按名字比较的闸门,都是永不触发的死代码。以状态设闸,绝不以字体名设闸

HotPDF 未解析字形检测流程:子集状态闸门、GetUnicodeGlyphForCodepoint 查询与 OnUnresolvedGlyph 事件接线
覆盖从注册的 Unicode 字体映射判断而非 GDI,每个不重复码点触发一次带字体建议的事件

不要用 emoji 测试字形检测器

显而易见的测试用例是一张笑脸,它会让你确信检测器坏了。星形平面的常见 emoji 码点经由私有区合成路径直接映射到字形索引,根本到不了通用覆盖分支。检测器行为正常,是测试量错了路径

换用未分配码点。U+0378 在 Unicode 中永久未分配,任何字体都无法合法映射它,而且它恰好锻炼你想验证的那个分支。"功能坏了"与"测试挑了一个绕过功能的输入"之间的区分要花真金白银的小时数,未分配码点是避开它的最便宜方式

type
  TCoverageAudit = class
  private
    FFindings: TStringList;
  public
    procedure Handle(Sender: TObject;
      const Info: THPDFUnresolvedGlyphInfo);
    property Findings: TStringList read FFindings;
  end;

procedure TCoverageAudit.Handle(Sender: TObject;
  const Info: THPDFUnresolvedGlyphInfo);
begin
  // 每个不重复码点触发一次,而不是每次出现都触发
  FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
    [Info.CodePoint, String(Info.FontName), Ord(Info.Script),
     String(Info.SuggestedFonts)]));
end;

// 接入一个生成作业
Pdf := THotPDF.Create(nil);
try
  Pdf.TrackUnresolvedGlyphs := True;
  Pdf.OnUnresolvedGlyph := Audit.Handle;
  Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\arial.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(50, 720, 0, CustomerName);
  Pdf.EndDoc;
  if Audit.Findings.Count > 0 then
    // 让作业失败,而不是交出一页空框
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

回退链按文字系统划分,不按字体

回退按文字系统而非按源字体划定范围,是因为覆盖缺口按书写系统聚集。一个拉丁文本字体同时缺天城文、泰文、汉字和 emoji,而各自的替代是不同的字体。按文字系统声明一条链,描述的正是真实部署:正文一个拉丁字体,一个 CJK 字体,一个 emoji 字体,一个兜底

按文字系统的字体回退图:HotPDF 把 hfsCJK、hfsArabic、hfsEmoji 与 hfsOther 映射到有序替代字体链
每个文字系统有自己的有序链,缺汉字、阿拉伯文或 emoji 的拉丁正文字体逐级落到能覆盖的字体
// THPDFFontScript 涵盖 hfsCommon、hfsLatin、hfsGreek、hfsCyrillic、
// hfsHebrew、hfsArabic、hfsIndic、hfsSoutheastAsian、hfsCJK、hfsKana、
// hfsHangul、hfsEmoji 与 hfsOther
Pdf.SetFontFallbackChain(hfsCJK,
  ['Microsoft YaHei', 'SimSun', 'Yu Gothic']);
Pdf.SetFontFallbackChain(hfsArabic, ['Segoe UI', 'Arial']);
Pdf.SetFontFallbackChain(hfsEmoji, ['Segoe UI Emoji']);
Pdf.SetFontFallbackChain(hfsOther, ['Arial Unicode MS']);

回退与检测是互补而不是二选一。链处理你预见的覆盖;检测器报告你没有预见的覆盖——在处理任意客户数据的系统上,那才是有趣的一半。注意替换字体会改变度量,回退的段落可能重排;如果版式重要,替代字体的闭包与子集化行为值得在字体子集闭包一文里细读,需要重排或连写的文字系统由复杂文字系统整形所述的整形阶段处理

如何在不危及既有路径的情况下加装行为

同一版本还为字距对间距加了传统 kern 表回退,其划定方式是一个值得复制的模式。回退没有给字距调整逻辑增加新的决策点,而是挂在早退分支上——那个分支本就为没有 GPOS 表的字体存在。带 GPOS 的现代字体永远不会到达那里,所以其行为按构造保持不变,而不是靠测试保证。不注册 Unicode 字体的路径产出两个零偏移,同样保持不变

这就是成熟渲染库里低风险加装的大致形状:找到当前什么都不产出的分支,把新行为放在那里。它把"我们相信这没有造成任何回归"变成"这不可能造成任何回归"——对一个别人的发票都要流经的文本引擎,这是好得多的说法

把它做成闸门,而不是日志

覆盖发现只有在能让某些东西失败时才有用。在文档生成服务里,有效的安排是:在夜间回归作业中对真实客户姓名、地址与产品描述的语料保持追踪开启,任何发现都让作业失败。因为事件按不重复码点而非每次出现触发,即使整个文字系统缺失,输出也小到可读

在生产环境,同一个处理器更适合当遥测用:记录码点与字体,继续服务文档,让聚合数据告诉你下一步该把哪个文字系统加进部署字体集。嵌入与替代字体的渲染行为在渲染嵌入字体字形中进一步展开,包括 TrackUnresolvedGlyphs 在内的完整属性列表见 HotPDF Delphi PDF component 产品页