技术文章

PDFlibPas:CJK 与 Emoji 文本的自动字体回退

对于所选字体画不出来的字符,PDFlibPas 会按簇逐一在一条由已安装字体组成的回退链中搜索,同时保留文本整形和双向文本的书写顺序。通过 SetAutomaticFontFallback 启用这项功能,用 AddFontFallback 扩展回退链,最终只有实际用于输出的回退字体才会被嵌入文件

它解决的是每个文档生成器迟早都会遇到的问题:某个客户名字用的文字,恰好是模板字体从没预料到的书写系统。这种失败很安静,正因为安静才代价高昂

为什么不支持的文本会直接消失,而不是抛出错误?

因为 PDF 里根本没有"这个字体画不出某个字符"这种概念。简单字体通过编码把字节码映射到字形名称;复合字体通过 CMap 把代码映射到字形索引。向字体请求一个它不包含的字形,得到的是字形索引零,也就是 .notdef,大多数字体会把它画成什么都没有,或者一个空方框。文件在结构上是合法的,文本操作符格式良好,页面也能正常渲染,只是本该出现名字的地方是空白的

ISO 32000-1 里没有任何规定要求生成端察觉这一点。一个不检查字形覆盖情况就写文本的生成器,产出的是一份技术上合规、却悄悄丢失了内容的 PDF,而这种丢失往往要等到几周后才会出现在客户的屏幕上。这正是字体回退功能要和缺字形报告一起提供的原因:解决能解决的问题只是一半工作,报告解决不了的问题是另一半

回退按簇进行,而不是按码点进行

粒度是区分一个真正能用的实现和一个看起来能用的实现的关键细节。文本不是一串互相独立的字符。一个天城文音节、一个带肤色修饰符的 emoji、一个带组合符号的基础字母:每一个都是一个必须由同一种字体渲染的簇,因为簇内部的整形决策依赖于该字体自身的表数据

PDFlibPas 是按簇来解析的,因此某个回退字体覆盖的簇,会整体由这个字体来绘制。如果在簇中间拆分,一半用主字体画、一半用回退字体画,得到的结果虽然技术上"存在",视觉上却是破损的,某种意义上比一开始的空白还要糟糕。书写顺序同样得到保留,所以从右到左的文本行内出现回退时,不会打乱周围文本的顺序;同一套机制也支撑着日文和中文的竖排一文中描述的排版

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetAutomaticFontFallback(1);

    // 搜索顺序:先匹配者优先,所以覆盖面最广的字体要放在最后
    Lib.AddFontFallback('Microsoft YaHei');   // 简体中文
    Lib.AddFontFallback('Meiryo');            // 日文
    Lib.AddFontFallback('Segoe UI Symbol');
    Lib.AddFontFallback('Segoe UI Emoji');

    Lib.SetMissingGlyphPolicy(PDF_MISSING_GLYPH_REPORT);

    Lib.AddTrueTypeFont('Arial', 1);          // 1 = 嵌入该字体
    Lib.SetTextSize(11);
    Lib.DrawText(72, 720, 'Invoice for 北京示例科技有限公司');
    Lib.DrawText(72, 700, 'Delivery status: on time');

    Lib.SaveToFile('invoice.pdf');
  finally
    Lib.Free;
  end;
end;

应该有意识地安排回退链的顺序。解析过程会选取第一个覆盖该簇的字体,如果把一个覆盖面很广的泛 Unicode 字体放在最前面,它几乎会赢下所有情况,你精心挑选的各语言专用字体就永远不会被用到。应该把专用字体放在前面,兜底字体放在最后

报告还是中止:你想要哪种失败方式?

SetMissingGlyphPolicy 可以设为兼容性更好的默认值 PDF_MISSING_GLYPH_REPORT,也可以设为 PDF_MISSING_GLYPH_ABORT。在"报告"策略下,文本操作会照常进行,无法解析的码点仍会像以前一样被丢弃,但每一个都会被记录下来。在"中止"策略下,文本操作会在写入任何内容之前就被拒绝,LastErrorCode 被设为 521

应该根据文档的用途来选择。一批内部报表应该继续渲染并记录缺口,因为今天一份略有缺失的报表,也好过完全没有报表。一份具有法律约束力的合同、一张发票,或者任何带有姓名的文件,都应该选择中止,因为当事人姓名中被悄悄丢掉的字符,是你希望在自己的流程里发现的缺陷,而不是在纠纷里才发现。中止策略在写入之前就会失败,因此不会留下半成品的内容流

var
  Lib: TPDFlib;
  Report: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetMissingGlyphPolicy(PDF_MISSING_GLYPH_ABORT);
    // ……构建文档……

    if Lib.DrawText(72, 660, CustomerName) <> 1 then
      if Lib.LastErrorCode = PDFLIB_ERROR_MISSING_GLYPH then
      begin
        Report := Lib.GetMissingGlyphReportJSON;
        // {"valid":false,"policy":1,"eventCount":1,"events":[
        //   {"sequence":1,"documentIndex":0,"page":1,"utf16Index":12,
        //    "codePoint":21271,"unicode":"U+5317","fontName":"Arial",
        //    "fontType":"TrueType","operation":"DrawText"}]}
        EscalateToOperator(Report);
      end;
  finally
    Lib.Free;
  end;
end;

这份报告刻意设计成机器可读且有边界。每个事件都携带页码、字符串内的 UTF-16 索引、数字形式和 U+XXXX 形式的码点、被选中的字体、字体类型以及触发问题的操作,因此一张支持工单可以准确指出具体是哪个字符出了问题,而不是只能描述一个症状。跟踪器只保留最近 256 个事件,这个数量足够诊断一份文档,又足够小,不会让一次异常运行把诊断信息变成内存问题

度量和绘制必须保持一致

宽度度量使用的是和绘制完全相同的、感知簇的回退决策。这听起来理所当然,却是大多数自研回退层最容易搞错的地方:它们只在绘制路径上打补丁,度量仍然停留在主字体上,结果每一个文本框、右对齐和表格列,最终都是按和实际渲染结果不一致的宽度计算出来的

因为度量和绘制这两条路径共享同一套解析结果,一段在绘制前先被度量过的字符串,占用的正是度量得到的宽度,包括其中的回退片段在内。这也是为什么字体回退可以放心地全局启用,而不是只能在你手工审核过的地方使用

只有实际用到的才会被嵌入

回退字体采用惰性嵌入:回退链中从未解析出任何簇的字体,对输出内容没有任何贡献。一份包含一个汉字和 5,000 个拉丁字符的文档,不会携带一整套完整的 CJK 字体;它携带的只是子集化处理为那一个字形生成的内容,这正是文件体积优化与字体子集化一文中描述的行为

正是这种惰性,让配置一条宽泛的回退链的成本变得很低。可以把你的文档集在所服务的每个 locale 下可能需要的字体全部注册进去,每一份具体的 PDF 只会为它实际用到的部分付出代价。对于不是你自己生成的文档,缺失的字体已经在一份既有文件内部,修复路径则不同,在向既有 PDF 嵌入缺失字体一文中有说明

有一条部署注意事项值得直说:回退是针对运行代码的那台机器上安装的字体来解析的。一台没有安装 CJK 字体的服务器根本没有可以回退的对象,而这一点会在第一份文档上就被报告出来,而不是等到第一次客户投诉才发现。应该随应用一起分发你依赖的字体,并确认它们的授权允许被嵌入

PDFlibPas 是一个面向 Delphi、C++Builder 和 Lazarus 的 PDF 库,配套提供 DLL 和 ActiveX 接口,因此字体回退和缺字形 API 同样可以从非 Pascal 调用方使用。完整文档见 PDFlibPas Delphi PDF 库页面