技术文章

用 PDFium 在 Delphi 中控制 PDF 字体替代

PDFium Component 让 Delphi 应用自行决定,当一份 PDF 引用了它没有嵌入的字体时,应当使用哪些字体字节。ConfigureSystemFontProvider 安装一个 IPdfSystemFontProvider 实现,接收 PDFium 发出的每一次字体映射请求,包含字体名、字重、斜体标志、字符集和间距族信息,并以要使用的 TrueType、TrueType Collection 或 OpenType 字节作答

这项功能存在的原因在于,未嵌入字体本质上是一场渲染抽奖。一份命名了 Arial、却什么都没嵌入的 PDF,在工作站上会以 Arial 渲染,在 Linux 服务器上会以某个度量兼容的替代字体渲染,在一个锁定的容器镜像上则会用宿主映射器能找到的任何字体渲染。同一份发票在每处看起来都不一样,换行位置发生偏移,客户收到的文档与归档副本对不上

为什么不直接把字体装到服务器上?

有时候那就是正确答案,如果是这样,请直接采用。但它在三种常见情形下会失效。许可条款可能禁止把某款字体装到服务器上用于自动化渲染。容器镜像会被频繁重建,手工安装的字体会随下一次部署一起消失。而受监管的工作流需要渲染栈能从纳入版本控制的产物中可复现地重建,而机器级别的字体安装做不到这一点

一个提供方把决策权移交回你的应用,从而同时解决了这三个问题。字体作为你能掌控的资源发布,映射策略是你能审查的代码,同一份二进制文件在任何地方都能渲染出一致的结果,因为没有任何环节依赖机器上碰巧装了什么字体

安装提供方

配置必须在库被加载之前完成。PDFium 在初始化时接受一个系统字体信息结构,并保留它此后发放出去的句柄,因此在有文档打开的情况下更换提供方会让 PDFium 仍持有的字体句柄失效;组件会直接拒绝这种做法,而不是让它悄悄破坏一次渲染:

uses
  PDFium;

type
  TAppFontProvider = class(TInterfacedObject, IPdfSystemFontProvider)
  public
    function ResolveFont(const Request: TPdfSystemFontRequest;
      out Font: TPdfSystemFontData): Boolean;
  end;

function TAppFontProvider.ResolveFont(const Request: TPdfSystemFontRequest;
  out Font: TPdfSystemFontData): Boolean;
var
  Path: string;
begin
  // 确定性映射:字体名加字重和斜体标志决定了
  // 这次请求应发放我们随应用打包的哪个文件
  Path := MapFaceToBundledFile(Request.FaceName, Request.Weight,
    Request.Italic, Request.Charset);
  Result := Path <> '';
  if not Result then
    Exit;
  Font.FaceName := Request.FaceName;
  Font.FontData := LoadFileBytes(Path);   // 完整的 sfnt 或 TTC 字节
  Font.Charset := Request.Charset;
  Font.TTCIndex := 0;                     // 集合内的索引
end;

var
  Policy: TPdfSystemFontPolicy;
begin
  Policy := TPdfSystemFontPolicy.Default;
  Policy.AllowDefaultFallback := False;   // 一切交由宿主决定
  Policy.AllowFaceSubstitution := False;  // 拒绝不同的字体名
  Policy.MaxFontBytes := 32 * 1024 * 1024;
  Policy.MaxCacheEntries := 64;

  ConfigureSystemFontProvider(TAppFontProvider.Create, Policy);
  // 到此为止才能加载库并打开文档
end;

拆除按相反顺序进行:先把提供方从 PDFium 分离,再卸载库。跳过分离这一步,会让原生字体句柄继续指向即将被释放的 Pascal 对象,这正是把引用计数接口和一个 C 库混用时经典的关闭期访问违规

策略标志到底决定了什么?

AllowDefaultFallback 是两种运行模式之间的开关。关闭时,一次被提供方拒绝的请求会直接失败,这正是你在证明某个语料库中的每一款字体都已被妥善处理时想要的行为:任何缺口会立刻显现出来,而不是被悄悄掩盖。开启时,未解析的请求会委托给 FPDF_GetDefaultSystemFontInfo 返回的映射器,而外部看到的仍是一个统一的句柄包装,字体名、字符集、表数据和字体删除例程都会按来源被正确路由

AllowFaceSubstitution 决定提供方是否可以用与请求不同的字体名作答。关闭它能让替代变成一个显式决策,而不是意外发生的事,这在某份文档命名的字体度量差异大到足以改变分页时尤为重要

组件会在每一次提供方响应到达 PDFium 之前对其进行校验:空数据会被拒绝,超出 MaxFontBytes 的超大字体会被拒绝,TTC 索引会被检查,当 PDFium 请求某个表而不是整份文件时,单独的 sfnt 表会从字体目录中被提取出来发放。这最后一项能力意味着提供方可以直接交出一份完整字体文件,让组件来应答表级别的查询,而不必把原始 Pascal 对象暴露到 C ABI 边界之外

缓存,同时不留下悬空的字体数据

字体映射请求在渲染过程中会不断重复出现,因此响应会按一个涵盖所有字体选择参数的键进行缓存,并按有界的最近最少使用顺序淘汰。微妙之处在于生命周期:PDFium 有可能仍在读取一个刚被淘汰出缓存的字体条目的字节

缓存存放的是引用计数的动态数组,每个原生句柄都持有自己的一份快照,因此淘汰只是丢弃一个引用,而不是释放正在使用中的内存。删除回调会释放句柄并维护一个活动计数。实际效果是,MaxCacheEntries 可以为了内存而随意调优,而不会有从一次进行中的渲染下面抽走数据的风险

提供方是否会在我的线程上被调用?

不一定。PDFium 可能会从它自己的工作线程调用映射器,因此实现必须是线程安全的。共享计数器、缓存和配置观察各自都在组件内部由自己的临界区保护,但 ResolveFont 内部的代码需要你自己负责保证安全

最安全的形态是一个不触碰任何可变共享状态的提供方:从启动时构建好的表中读取,从文件或资源加载字节,然后返回。如果某次查找需要用到你自己的共享缓存,请给它加锁。并且要把异常留在你自己的实现内部,因为一个 Pascal 异常绝不能穿过 PDFium 的调用栈往外传播;组件会在 C ABI 边界处捕获并转换为失败或可选的默认回退,但把这当作常规控制流来依赖,既有性能代价也会掩盖真正的缺陷。组件其余部分的线程规则遵循与 渲染锁纪律 中相同的原则

在生产环境中证明映射结果

统计信息把字体替代从靠猜变成了可以断言的事实。GetSystemFontProviderStatistics 报告是否配置并安装了提供方、发出了多少次映射请求,以及这些请求是如何被满足的,细分为缓存命中、提供方命中和默认回退命中,还有被拒绝的响应、失败的请求、存活的句柄和已缓存的字体数量:

var
  Stats: TPdfSystemFontStatistics;
begin
  Stats := GetSystemFontProviderStatistics;
  Writeln(Format('requests=%d cache=%d provider=%d fallback=%d',
    [Stats.MapRequests, Stats.CacheHits, Stats.ProviderHits,
     Stats.DefaultFallbackHits]));
  Writeln(Format('rejected=%d failed=%d handles=%d cached=%d',
    [Stats.RejectedProviderResponses, Stats.FailedRequests,
     Stats.ActiveHandles, Stats.CachedFonts]));

  // 在禁用回退的合规性检查中,任何一次回退命中或失败请求
  // 都意味着文档引用了我们没有随包提供的字体
  if (Stats.DefaultFallbackHits > 0) or (Stats.FailedRequests > 0) then
    raise Exception.Create('unmapped font encountered - update the font set');
end;

RejectedProviderResponses 计数持续上升,是提供方正在用策略拒绝的数据作答的信号,通常是文件过大或替代了字体名,这一点值得设置告警,因为这类请求会悄悄退化为回退或失败。要在构建映射表之前先诊断某份文档究竟需要哪些字体,分析 PDF 字体属性 中的检视方法会按文档列出已嵌入和未嵌入的字体

字体供给、渲染和文本提取在 Delphi、C++Builder 和 Lazarus 中共享同一个库实例;部署细节见 PDFium Component for Delphi 页面