技术文章

PDF 中的网页字体:Delphi 中的 WOFF2 与可变字体轴

PDF Library for Delphi 接受 Web 项目已经在使用的字体。它能导入 WOFF2 容器,把可变字体的设计轴以 JSON 形式枚举出来,并把一组选定的轴坐标实例化为一个静态字体,此后普通的内嵌、子集化和提取路径都能处理它,完全不需要了解任何字体变体相关的知识。TrueType 轮廓和 CFF2 轮廓都在支持范围内

这里要解决的情形常见又让人头疼。一个品牌的字体排印方案定义在设计系统里,设计系统提供的是可变的 WOFF2 文件,而 PDF 生成器既读不了这个容器,也读不了其中的变体数据。通常的变通做法——向字体厂商索要实际用到的每一种字重的静态 TTF 实例——需要数周时间,而且产出的文件没人会保持同步

为什么 WOFF2 不只是一个压缩字体?

把 WOFF2 当作一个 Brotli 包装体去解压,得到的字节并不是一个有效的 sfnt。这种格式还允许对表做变换和重排:glyf、loca 和 hmtx 可以按重新排列过的形式存储,必须先做逆变换才能解析出字体。一个通用的解压器做不到这一点

在 Windows 上,自 Windows 10 1703 版本起,操作系统平台本身就提供了容器解析和解包能力,因此本库使用系统自带的实现,而不是再自带一个解码器。这个决定既让部署免于携带额外的 DLL,也意味着变换的逆运算,用的正是操作系统自身文字处理栈所用的同一套代码。在其他平台上,WOFF1 仍走一条纯 Pascal 路径,而 WOFF2 会明确报告为不受支持,这是一个诚实的答案,而不是悄悄产出一个损坏的字体

导入路径在解码前后都做校验:48 字节的头部、容器长度、保留字段、声明的 sfnt 大小以及 512 MiB 上限,都会先做检查,解码后的流大小随后又会与声明值做比对。网页字体是下载得来的内容,值得拦截的两种失败模式,分别是大小不匹配和解压炸弹

流程图展示 Delphi PDF library 如何通过头部校验、系统解码、sfnt 变换逆运算和解码后大小审计来导入一个 WOFF2 网页字体
WOFF2 容器是下载得来的内容,因此导入会在解码前后分别校验头部和声明的大小
uses
  PDFlibrary;

var
  Lib: TPDFlib;
  FontID, InstanceID: Integer;
  AxesJSON: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;
    Lib.SetPageSize('A4');
    Lib.NewPage;

    // WOFF2、WOFF1、TTF、OTF 和 TTC 都通过同一个调用进入
    FontID := Lib.AddOpenTypeFontFromFile('Inter.woff2', 0);
    if FontID <= 0 then
      raise Exception.Create('font could not be imported');

    // 这个字体究竟能做什么?
    if Lib.GetFontVariationAxes(FontID, AxesJSON) = 1 then
      Log(AxesJSON);   // 每个轴的标签、最小值、默认值、最大值

    // 把一个设计位置固化成一个静态字体
    InstanceID := Lib.CreateOpenTypeFontVariation(FontID,
      'wght=620,wdth=87.5');

    Lib.DrawOpenTypeText(56, 760, InstanceID, 24, 'Quarterly results', 0);
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

实例化必须重建什么,又要移除什么

一个可变字体为每个字形存储一份默认轮廓,外加按设计空间中的位置索引的一组差量。生成一个静态实例,意味着在请求的坐标处对这些差量做插值,移动对应的点,然后重新计算一切由此派生出来的内容

对 TrueType 轮廓来说,这意味着要解析 fvar、avar、gvar、HVAR 和 MVAR,应用带共享和私有点编号的元组差量,并对那些没有显式差量的点做插值。水平幻影点与 HVAR 一起决定了前进宽度和左侧位。复合字形需要先解析它们的组件依赖关系和点映射,再应用平移、缩放、矩阵和附着偏移——同时设有深度限制和循环检测,因为一个恶意构造的组件图否则会无限递归下去

元数据同样需要迁移,而这正是大多数实现会跳过的部分。MVAR 会更新 hhea、OS/2 和 post 中的全局度量,使行距、下划线位置和斜体角度与该实例相匹配,而不是停留在默认值上。wght、wdth、ital 和 slnt 这几个轴还会额外更新字重级别、字宽级别、选择标志、macStyle 和斜体角度,因此检查这个静态字体的工具报告出来的正是你所要求的样式。一个几何形状是粗体、而元数据却写着常规体的字体,会在下游的每一处都被贴错标签

随后输出阶段会展平复合轮廓,并移除不再适用的表:变体表、hint、设备表以及任何签名。字形边界、hhea 汇总度量、maxp 的 hint 上限、各表的校验和,以及整个字体的校验和,都会被重新计算

示意图展示可变字体实例化如何通过插值 gvar 差量、展开复合字形、重新计算前进量并重写样式元数据来重建一个 TrueType 字体,最后剥离变体表
实例化会重建差量所触及的一切内容,然后剥离整套变体机制,使普通的内嵌流程看到的是一个朴素的静态字体

CFF2 走一条不同的路,通向同一个终点

CFF2 无法直接作为 PDF 字体程序内嵌:它的字符串不携带宽度,而是依赖周围的 OpenType 表来确定字形顺序和度量。因此对一个 CFF2 字体做实例化,产出的是一个以 CID 为键的 CFF OpenType 字体,而这正是现有绘制、内嵌和子集化路径本就能处理的形式

加载一个 CFF2 字体会立即为当前文档生成一个默认坐标的静态实例,而原始的 CFF2 sfnt 则保留在字体对象上,因此轴的枚举以及之后的实例化仍然能看到完整的设计空间。实例化过程中每个字形都会被单独去子程序化,这彻底消除了变体运算,也让现有的子集化器无需改动即可工作。代价是一个静态实例可能比保留了共享子程序的版本略大一些——为了不需要第二套子集化实现,这是一个合理的代价

在信任一个实例之前先校验它

实例化是那种可能以不抛出任何异常的方式悄悄出错的功能。三项检查几乎能捕获所有问题

把实例导出后在字体工具里打开。SaveFontToFile 会写出保留下来的字体程序,因此一个内存中的实例无需先生成 PDF 就能被导出查看。导出的文件应当能被干净地解析,不含任何变体表,并报告出你所要求的样式元数据

在一个非默认位置比较前进量。如果你的流水线能用另一种方式生成同一个实例,就逐字形比较前进宽度和曲线边界;此处的参考实现,与一个独立实例化的 313 字形双轴字体做比对,在每一个字形上的最大差异都是零

并从生成的 PDF 中把文本重新提取出来。一个能内嵌、却提取不出任何文本的实例,说明它丢失了 ToUnicode 映射,而这一点在视觉检查通过很久之后,会在搜索和无障碍访问方面显现出问题。更完整的提取路径见 文本、图像与字体提取

示意图展示为生成的可变字体实例所做的三项校验:导出字体文件查看、与独立实例化结果比较字形前进量,以及从 PDF 中重新提取文本
一个微妙出错的实例不会抛出任何异常,因此导出的字体文件、字形前进量和提取出的文本都要依次分别校验
// 导出一个生成的实例,供检查或复用
Lib.SaveFontToFile('Inter-620-87.ttf');

// 请求声明范围之外的坐标会被夹取到边界内,
// 所以应读取轴的 JSON,而不是假定某个字体的取值范围
// {"axes":[{"tag":"wght","min":100,"default":400,"max":900}, ...]}

品牌字体流水线的实用提示

按坐标集合缓存实例。每一组不同的坐标都是一个不同的字体程序,因此一份混用字重 400 和字重 620 的文档,会内嵌两个子集,而不是共享一个字体面。反复生成同一个实例是白费功夫;生成几十种几乎相同的字重则是白白浪费文件体积

用你实际要用来绘制的那个实例去测量文字。一个窄体实例的前进量比默认值更窄,因此任何在实例化之前就完成测量的排版,测量的其实都是错误的宽度。这是生产环境中文本溢出容器这类问题最常见的单一成因

为你的品牌字体缺失的字形保留一条回退路径。一个可变的品牌字体通常只覆盖拉丁文字符,别的很少覆盖,而一份包含西里尔字母客户姓名的报告,否则就会出现缺字;自动回退机制见 缺失字形的自动字体回退。而当目标是缩小文件体积而不是获得某个特定字重时,子集化能做的比实例化更多,见 PDF 文件体积优化与字体子集化

网页字体导入、可变字体实例化、子集化和回退,都属于 Delphi、C++Builder 和 Free Pascal 使用的同一套字体处理流水线;完整功能列表见 PDF Library for Delphi 页面