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 上限,都会先做检查,解码后的流大小随后又会与声明值做比对。网页字体是下载得来的内容,值得拦截的两种失败模式,分别是大小不匹配和解压炸弹
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 上限、各表的校验和,以及整个字体的校验和,都会被重新计算
CFF2 走一条不同的路,通向同一个终点
CFF2 无法直接作为 PDF 字体程序内嵌:它的字符串不携带宽度,而是依赖周围的 OpenType 表来确定字形顺序和度量。因此对一个 CFF2 字体做实例化,产出的是一个以 CID 为键的 CFF OpenType 字体,而这正是现有绘制、内嵌和子集化路径本就能处理的形式
加载一个 CFF2 字体会立即为当前文档生成一个默认坐标的静态实例,而原始的 CFF2 sfnt 则保留在字体对象上,因此轴的枚举以及之后的实例化仍然能看到完整的设计空间。实例化过程中每个字形都会被单独去子程序化,这彻底消除了变体运算,也让现有的子集化器无需改动即可工作。代价是一个静态实例可能比保留了共享子程序的版本略大一些——为了不需要第二套子集化实现,这是一个合理的代价
在信任一个实例之前先校验它
实例化是那种可能以不抛出任何异常的方式悄悄出错的功能。三项检查几乎能捕获所有问题
把实例导出后在字体工具里打开。SaveFontToFile 会写出保留下来的字体程序,因此一个内存中的实例无需先生成 PDF 就能被导出查看。导出的文件应当能被干净地解析,不含任何变体表,并报告出你所要求的样式元数据
在一个非默认位置比较前进量。如果你的流水线能用另一种方式生成同一个实例,就逐字形比较前进宽度和曲线边界;此处的参考实现,与一个独立实例化的 313 字形双轴字体做比对,在每一个字形上的最大差异都是零
并从生成的 PDF 中把文本重新提取出来。一个能内嵌、却提取不出任何文本的实例,说明它丢失了 ToUnicode 映射,而这一点在视觉检查通过很久之后,会在搜索和无障碍访问方面显现出问题。更完整的提取路径见 文本、图像与字体提取
// 导出一个生成的实例,供检查或复用
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 页面