技术文章

PDF 中的可变字体:在 Delphi 中生成静态实例

PDF 没有可变字体的概念。嵌入 PDF 文件的字体是一组固定轮廓加固定度量,因此可变字体在放入文档之前必须先归约为一个静态实例。HotPDF 在内部完成这项实例化工作:你检视可变字体的各条轴,选定诸如字重 620 或字宽 87.5 这样的坐标,库就会把这些数值烘焙进一个完整、自包含的字体程序,任何符合规范的 PDF 阅读器都能渲染它

这件事之所以重要,原因是实际而非理论上的。字体厂商越来越倾向于发布一个可变文件而不是十几个静态字重,设计团队挑选的数值往往是任何命名实例都没有提供的。没有实例化能力,报表生成器要么退回到默认实例,从而丢弃了设计上的决定,要么嵌入整个可变字体并寄望于查看器能识别它无从得知的轴坐标,而这一点没有哪个阅读器是被要求做到的

实例化到底要重建些什么?

OpenType 可变字体为每个字形存储一套默认轮廓,外加一组按设计空间中的位置索引的增量。应用一个轴坐标并不是往表头写一个数字那么简单;它意味着要遍历 gvar 表,为所请求的位置插值增量,移动控制点,然后重新计算所有由这些点派生出来的内容。HotPDF 会重建字形轮廓、长版 loca 表、完整的水平和垂直度量、全局字体边界框以及 sfnt 校验和调整值

同样重要的是哪些内容会被移除。静态实例不得保留 fvaravargvarHVARVVARMVARSTATcvar,过时的 DSIG 同样要去掉,因为已签名的字节已不复存在。留下其中任何一项,都会产生一个自称是可变字体、实际却携带已被移动过的轮廓的字体,而那些确实会应用变化的阅读器将再应用一次

幻影点,以及重复应用的陷阱

整个流程里最微妙的规则涉及度量。在 gvar 中,一个字形的点数涵盖轮廓点,或者对复合字形而言是组件点,再加上四个编码左侧位移、前进宽度及其垂直对应值的幻影点。这些幻影点本身同样会受增量影响

因此,当某个字体带有 gvar 表时,HotPDF 会从插值后的幻影点推导水平和垂直度量,而不会再额外应用 HVARVVAR。两者都加上正是经典的错误:同一份变化被应用了两次,每个前进宽度都会略微偏宽,表现为文本在两端对齐的行中逐渐向右漂移。只有当字体没有 gvar 时,库才会把度量变化存储直接烘焙进 hmtxvmtx

还有两处细节让几何计算保持准确。幻影点从不参与轮廓插值,因此对于简单字形中未被显式列出的点,会按每条轮廓分别用 IUP 推断,幻影点被排除在外。复合字形的增量会应用到使用 XY 参数的组件偏移上,之后子边界会被递归重新计算。这种递归在深度上有限制且会做循环检测,因为一个恶意或仅仅是损坏的组件图否则可能无限递归下去

在选择之前检视设计空间

任何实例化工作流的第一步调用都是 InspectVariableFont,它会报告字体厂商定义的各条轴以及命名实例。轴记录携带四字节标签、最小值、默认值和最大值、标志位以及一个名称 ID;命名实例携带子族名称 ID、标志位、可选的 PostScript 名称 ID,以及每条轴各一个坐标值:

var
  Pdf: THotPDF;
  Axes: THPDFVariableFontAxisArray;
  Instances: THPDFVariableFontNamedInstanceArray;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.InspectVariableFont('C:\Fonts\Inter.ttf', Axes, Instances) then
    begin
      for I := 0 to High(Axes) do
        Writeln(Format('%s  min=%.1f default=%.1f max=%.1f',
          [string(Axes[I].Tag), Axes[I].MinimumValue,
           Axes[I].DefaultValue, Axes[I].MaximumValue]));
      Writeln(Format('%d named instance(s) defined', [Length(Instances)]));
    end
    else
      Writeln('not a variable font - embed it as an ordinary TrueType face');
  finally
    Pdf.Free;
  end;
end;

报告轴的取值范围之所以重要,是因为轴值会被限制在字体声明的范围内,而不是你界面提供的范围内。如果某个滑块允许用户在 wght 轴上限为 900 的字体上请求字重 1000,应当在界面层修正这个问题,而不是让字体层悄悄纠正,否则打印输出会与预览效果不一致

选定坐标并生成文档

轴选择是有状态的,并会应用于之后注册的字体。SetVariableFontAxis 接受一个四字节可打印 ASCII 标签和一个有限数值,遇到其他任何输入都会以抛出异常的方式拒绝,而不是悄悄忽略。ClearVariableFontAxes 会重置选择,GetVariableFontAxisSelections 会报告当前挂起的选择,这在多条代码路径可能都触碰过同一个文档对象的报表引擎中很值得记录。字族本身通过 SetFont 按名称选定,和选择任何其他嵌入 TrueType 字体完全一样:

begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginDoc;

    Pdf.SetVariableFontAxis('wght', 620);   // 半粗体,不是命名实例
    Pdf.SetVariableFontAxis('wdth', 87.5);  // 略微收窄
    Pdf.CurrentPage.SetFont('Inter', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Quarterly results');

    Pdf.ClearVariableFontAxes;              // 回到默认实例
    Pdf.CurrentPage.SetFont('Inter', [], 10);
    Pdf.CurrentPage.TextOut(72, 700, 0, 'Prepared by the finance team');

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

OnVariableFontInstance 事件会在每生成一个实例时触发,并报告所用的轴值,这是在日志中证明某份 PDF 实际包含什么内容的最低成本方式。由于每一组不同的坐标都会生成一个不同的字体程序,应把轴选择当作字体缓存键的一部分;缓存机制在 持久化字体子集缓存 中有描述

实例化如何与子集化、排版塑形相互作用?

实例化在子集化之前运行,这个顺序是正确的。实例化后的字体是一个普通的静态 TrueType 字体,因此普通的子集化器会像对待任何其他字体那样处理它:计算字形闭包,保留文档实际用到的字形,丢弃其余部分。需要留意的交互点在于,同一字族的两次不同轴选择是两个不同的字体程序,因此同时混用字重 400 和字重 620 的文档会嵌入两个子集,而不是一个带两个实例的共享字体

排版塑形原理上不受影响,但实践中值得核实。布局特性存放在 GSUBGPOS 中,实例化会保留它们,因此连字和风格替代字形会继续按 OpenType GSUB 风格替代字形 中描述的方式工作。发生变化的是定位:收窄实例的前进量比默认实例更窄,因此任何在实例化之前测量文本的布局都测量出了错误的宽度。用你渲染时所用的同一套轴选择去测量,这个偏差就消失了

实现中最后一条防御性提示,对任何想扩展这条路径的人都有用。没有垂直度量的字体在 Delphi 调用点仍会计算动态数组实参,因此解析阶段的数组总会被分配,而不是依赖 HasVerticalMetrics 检查来提前跳过一个空索引。这正是那种把表面上看似受保护的分支变成访问违规的语言层面细节,而且恰好只会出现在你没有测试过的那些字体上

可变字体支持融入了与嵌入、子集化和字形闭包相同的字体流水线,详情见 字体子集闭包与塑形后的字形。适用于 Delphi 和 C++Builder 的完整排版特性集合列在 HotPDF Delphi PDF 组件页面