技术文章

在 Delphi 中用 HotPDF 把字体子集缓存到磁盘

HotPDF 可以把 TrueType 和 OpenType 字体子集保存在磁盘上,跨文档、跨进程运行复用,于是用同样三种字体渲染一万份对账单的批量任务,只需要子集那三种字体一次,而不是一万次。缓存用两个属性配置、用一条记录检查、且可以安全地一直开着:缓存失败会回退到普通的内存内子集化,绝不会让一份文档无法产出

子集化代价高昂是有原因的。构建一个子集意味着走一遍字形闭包、改写 locaglyf、重建 cmaphmtx、并输出一个 PDF 可寻址的 CID 映射。对一份文档而言,这项成本淹没在噪声里。对一台在循环里生产文档的报表服务器,它往往是整次运行中最大的一块 CPU 时间

是什么让一次缓存命中成为可能

四件事必须匹配:字体内容、已用字形集合、子集模式、缓存 schema。任何一件不匹配,HotPDF 就从零开始子集化,因为一个子集只有在本来就会逐字节相同时才可复用

字形集合是常让人意外的条件。两张只差一个客户姓名的发票用的是不同的字形集合,因此产生不同的子集、不同的缓存条目。缓存只有在文档共享同一套字形储备时才回本——固定模板的报表、变量数据是数字的表单、从一个产品数据库抽取的目录——而在每份文档都从一个大型 CJK 字形里抽取不同切片时一文不值。先度量,再假定你处于哪种情况

var
  Pdf: THotPDF;
  Info: THPDFFontSubsetCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.EnableFontSubsetting := True;
    Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
    Pdf.FontSubsetCacheMaxBytes := 64 * 1024 * 1024;   // 64 MiB, default is 256
    // ... generate the batch ...
    Info := Pdf.GetFontSubsetCacheInfo;
    LogFmt('subset cache: %d hits, %d misses, %d bytes in %d files',
      [Info.HitCount, Info.MissCount, Info.CurrentBytes, Info.FileCount]);
  finally
    Pdf.Free;
  end;
end;

怎么知道缓存到底有没有起作用

GetFontSubsetCacheInfo 返回九个计数器,前两个之间的比值直接回答了这个问题。HitCountMissCount 给出命中率。WriteCountEvictionCount 显示条目能否活得够久被复用,还是被一个过小的预算挤了出去。CurrentBytesFileCount 报告此刻磁盘上有什么

剩下的三个才是值得告警的那几个。CorruptCount 计数未通过校验并被移除的条目——一次不干净的关机之后出现几个是正常的,持续不断地出现则意味着存储不可靠。RejectedCount 计数在使用前被拒的条目。WriteFailureCount 计数根本写不进去的条目,这通常意味着文件夹的权限问题,而与字体无关。这三者都不会停下文档生成,而这恰恰是你必须去看它们的原因:一个悄悄从不写入的缓存,从外面看起来和一个能工作的缓存一模一样,差别只在 CPU 账单上

淘汰、预算,以及你缩小预算的那一刻

FontSubsetCacheMaxBytes 默认 268435456 字节,即 256 MiB,并可在运行时下调。下调会立刻触发最近最少使用(LRU)淘汰,而不是等下一次写入,所以一个对磁盘压力做出反应的服务能在它决定的那一刻释放空间,而不是在某个它无法掌控的更晚时刻

FontSubsetCacheFolder 设为空字符串会禁用磁盘层,既不清除任何已存内容,也不改变字体输出的一个字节。这正是排查时想隔离缓存时要伸手去用的属性:关掉它,跑同一批任务,比较产出的 PDF。它们应当完全一致,因为缓存存储的是一个结果,不是一项策略

条目损坏时缓存会怎么做

它移除条目并按正常流程子集化。畸形或被截断的条目在子集能进入 PDF 流之前就被拒掉,这是整个设计里最关键的部分:一个进入文档的损坏缓存条目,会产出一份字体程序破损的 PDF,而那种失败会远离其根源地暴露出来——在某个阅读器里、在客户的机器上、几周之后

写入是原子的,所以读取方永远观察不到半写入的条目,写入中途崩溃留下的缓存是一致的,而不是被污染的。压缩的子集条目保留 PDF/A 字体字典要求的 CID 重映射数据,所以缓存的子集仍然是合规的子集——归档输出不必为了保持合规而绕开缓存

// Reset the disk tier after a font upgrade or a schema change
Pdf.ClearFontSubsetCache;

// Or move it somewhere writable and let the budget apply immediately
Pdf.SetFontSubsetCacheFolder('D:\cache\fonts');

真实部署里文件夹该放在哪

三件事决定它:文件夹必须可被服务运行账户写入,它应该位于本地存储而不是网络共享上,并且不应该位于一个会被部署步骤清空的目录里。共享上的缓存把每次未命中变成一次往返,每次命中变成两次;位于某个安装器会重建的应用文件夹之下的缓存,是每次更新后都从冷启动开始的缓存

对多实例服务,除非你已确认存储按你期望的方式处理并发原子替换,否则给每个实例各自的文件夹。一个重复条目的代价是多一次子集化过程;调试一个共享缓存竞争的代价是一个下午

什么时候该用别的办法

缓存削减的是重复工作。它不能削减第一份文档的工作,也帮不上字形集合从不重复的工作负载。如果你的输出主要由一个用在不可预测文本上的超大 CJK 字形构成,更有效的杠杆是子集化的闭包本身——哪些字形被拉进来、为什么——这一点在 字体子集闭包与字形塑形的笔记里有覆盖。如果你的批量任务慢下来的原因最终与字体无关,带字体与图像的报表输出的详解展示了其他时间通常花在哪里,而 EndDoc 字体子集顺序 bug这个案例研究提醒我们:子集化的正确性和子集化的速度是两个独立的问题

HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件,子集缓存是库的一部分而不是附加服务,所以一台报表服务器只需设置一个文件夹路径就能用上它——完整的字体与性能功能清单见 HotPDF 组件页