技术文章

在 Delphi 中减小 PDF 文件大小:字体、图像与 LZW

为了在 Delphi 中减小 PDF 文件大小,losLab PDF Library 提供了三个针对三大臃肿来源的 API:SubsetEmbeddedFonts 将每个嵌入的 TrueType 字体程序重写为文档实际渲染的字形,DownsampleImages 对超过目标 DPI 的光栅图像进行重新采样,而 NormalizeLZWStreams 用 FlateDecode 替换了遗留的 LZWDecode 压缩。每个 API 都会返回它修改的对象数量,因此返回零表示该步骤没有执行任何操作,而不是发生了静默失败

为什么我合并后的 PDF 比其源文件还要大?

合并或以编程方式生成的 PDF 通常会因为以下三个原因之一而体积过大:完全嵌入的字体、采样分辨率远超显示分辨率的图像,以及仍然使用遗留 LZW 过滤器压缩的流。ISO 32000-1 §9.9 允许生成器嵌入完整的字体程序,而大多数生成器确实会这样做,因为这是最安全的选择。一个完整的 Arial FontFile2 大小可达数百千字节;如果将其嵌入到十几个源文件中然后进行合并,你将携带十几份没人输入过字符的字形轮廓副本。合并本身并不会产生浪费,它只是将浪费集中到了一个文件中,使得总量最终变得显而易见

图像则是第二大元凶。一个放置在四分之一页框架内的 4800 像素宽的扫描件,其携带的像素数据大约是 300 DPI 打印管道所能使用的 40 倍。第三个原因更隐蔽:通过 LZWDecode 过滤的流。ISO 32000-1 §7.4.4 同时规定了 LZWDecode 和 FlateDecode,并指出 Flate 的压缩效果通常至少同样好;在实践中,对于相同的数据,Flate 输出始终更小,而 LZW 主要存在于在其历史中某个时间点通过了 1990 年代工具处理的文件中。本文的其余部分将逐步介绍修复每个问题的三个 losLab PDF Library 步骤,然后将它们组合成一个管道

使用 SubsetEmbeddedFonts 进行字体子集化

SubsetEmbeddedFonts 可以将已加载文档中每个嵌入的 TrueType 字体缩小为文档实际使用的字符,它不需要任何参数,因为它会从内容流本身导出保留列表。在内部,该步骤使用 GetTextRuns 遍历每个页面的内容流,收集在每个字体资源下引用的字符代码,构建保留列表,并将原始字体程序交给 Windows FontSub 引擎(CreateFontPackage)以生成子集。重写后的程序会就地替换 FontFile2 流,并且 BaseFont 名称会获得一个 LOSABC+ 标签,即 ISO 32000-1 §9.6.4 为子集字体定义的“六个大写字母加加号”的约定。该前缀也是使此调用具有幂等性的原因:运行该步骤两次,已子集化的字体会被识别并跳过,因此将其接入可能重新访问文件的批处理作业是安全的

var
  Lib: TPDFlib;
  Fonts: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('merged-report.pdf', '') = 1 then
    begin
      Fonts := Lib.SubsetEmbeddedFonts;
      // Fonts = 重写的 FontFile2 程序数量;
      // 0 表示没有嵌入内容,或者所有内容都已子集化
      Lib.SaveToFile('merged-report-subset.pdf');
    end;
  finally
    Lib.Free;
  end;
end;

有两个实现细节值得了解,因为它们解释了该 API 的限制。首先,该步骤针对 FontFile2,因此它覆盖了嵌入的 TrueType 程序;嵌入为 Type 1 或纯 CFF 的字体则保持不变以避免风险。其次,它依赖于 FontSub,这使得 SubsetEmbeddedFonts 仅适用于 Windows。该实现中还有一个微妙之处:一个字体是否符合条件是通过实际解析 FontDescriptorFontFile2 引用链决定的,而不是靠信任嵌入标志启发式算法,因为已加载文档中的字体从未经过设置此类标志的创建端簿记。如果解析后的流存在,则该字体是候选字体;如果不存在,则在不报错的情况下跳过它

切合实际的权衡:子集字体仅包含子集化时存在的字形。如果下游工具或您自己的代码随后使用同一种字体添加文本,子集之外的任何字符都将没有轮廓并会被渲染为缺失字形。应当将子集化作为更改内容的最后一步,而绝不要放在编辑阶段之前。如果您计划稍后再次将字体提取出来复用,也需要同样的谨慎;关于使用 PDFlibPas 提取文本、图像和字体的文章介绍了提取出来的子集程序能给你和不能给你带来什么

DownsampleImages 如何决定缩减哪些图像?

DownsampleImages(MaxDPI, Quality, Filter) 仅对那些可以确信为过采样的图像进行重新采样,并使用刻意保守的 DPI 估算。PDF 图像 XObject 存储像素维度,但没有可信的物理分辨率,而且源图像的任何 DPI 标签在加载-编辑-保存周期中都很难幸存下来。因此,该步骤估算 SrcDPI = PixelWidth / 8.5,实际上相当于在问:如果此图像跨越 Letter 页面的完整宽度,它的分辨率会是多少?只有估算值超过 MaxDPI 的图像才会被处理。这种偏向是有意为之:在页面上放置得很小的图像,其真实 DPI 会高于估算值,因此该步骤会宁可触发不足,也不想降级一个它无法衡量的打印质量资产

Quality 从 1 到 100 选择 JPEG 重新编码的质量,而 0 则将输出保持为无损的类 PNG 的 Flate;Filter 选择重新采样内核, 0 代表框平均,1 代表双线性。对于扫描的办公文书,DownsampleImages(150, 75, 1) 是一个合理的起点;对于可能重新打印的任何内容,请将 MaxDPI 提高到 300 或完全跳过该步骤。下采样是这三个步骤中唯一的有损步骤,因此应当将其放在用户可以关闭的设置中

使用 NormalizeLZWStreams 转换遗留的 LZW 流

NormalizeLZWStreams 是一项稳赚不赔的功能:它无损地解压每个 LZWDecode 流,并使用 FlateDecode 重新就地压缩,然后返回转换的流的数量。它既可以处理单一的 /Filter /LZWDecode 条目,也能处理出现在过滤器链数组中的 LZW,对于后者,仅替换 LZW 环节而保留链的其余部分。预测器参数(PredictorColumnsColorsBitsPerComponent)会从流的 DecodeParms 中读取并传递给解压器,从而使预测器编码的图像数据能够正确回转。因为这两种过滤器都是位精确(bit-exact)的编解码器,所以解码后的字节在前后是完全相同的;改变的仅仅是容器的压缩方式,这就是为什么这个步骤可以安全地在每个文件上无条件运行的原因

在没有 LZW 流的文档上,该调用仅返回 0 且不触及任何内容,该库的回归测试套件明确练习了这一点:一个新创建的仅限 Flate 的文件必须报告零转换。当该步骤置于处理数千个异构文件(一些来自 2024 年,一些来自 1998 年)的管道中时,这种无操作保证就显得尤为重要

Delphi 中完整的尺寸优化管道

这三个步骤组合成一个单一的“加载-优化-保存”函数,而顺序的影响比您预期的要小,因为它们操作在互不相干的对象类型上:字体、图像 XObject 和流过滤器。首先运行子集化仍然是整洁的选择,因为该步骤具有编辑顺序的约束

function OptimizePDF(const Src, Dst: string): Boolean;
var
  Lib: TPDFlib;
  Fonts, Images, Streams: Integer;
begin
  Result := False;
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Src, '') <> 1 then
      Exit;
    Fonts   := Lib.SubsetEmbeddedFonts;        // TrueType FontFile2 -> 子集
    Images  := Lib.DownsampleImages(150, 75, 1); // >150 DPI -> JPEG q75,双线性
    Streams := Lib.NormalizeLZWStreams;        // LZWDecode -> FlateDecode
    Result := Lib.SaveToFile(Dst) = 1;
    // 记录 Fonts/Images/Streams:三个零表示文件已经很精简了
  finally
    Lib.Free;
  end;
end;

像该库验证自身那样验证管道:双向回转(round-trip)。v3.130 回归测试会创建一个文档,保存它,重新加载它,运行优化,再次保存,然后断言三件事:输出变小了、返回的计数符合预期,并且重新加载优化后的文件仍能成功解析和渲染。在您自己的生产文件样本上重现该“创建-优化-重新加载”循环,并比较前后的提取文本,是一项一小时的投资,它能在客户打开损坏的发票之前很久就捕获集成错误

// 双向回转检查:优化后的文件仍必须干净地加载
Lib := TPDFlib.Create;
try
  Assert(Lib.LoadFromFile('merged-report-opt.pdf', '') = 1);
  Assert(Lib.GetPageCount > 0);
finally
  Lib.Free;
end;

管道适合合并工作流中的哪个环节?在合并之后,而不是在此期间。先合并再优化单一结果意味着每个嵌入的字体仅针对所有已使用字符的并集进行一次子集化,而不是针对每个源文件。如果合并吞吐量是瓶颈,PDFlibPas 提供了避开完整对象解析的字节级快速路径(详见通过字节引用转移快速合并 PDF 的文章);而对于大到无法完全放入内存的输入,针对大型 PDF 的直接访问合并与拆分指南则介绍了适合不便于放入内存的文件的相关 I/O 架构。两者都能自然地与合并输出上的最终优化步骤配对

这三个步骤不会做的事情

losLab PDF Library 的优化三件套特意排除了任何会改变文档语义的操作。SubsetEmbeddedFonts 不会把跨合并源的重复字体合并为一个程序,而是独立缩小每个程序;去重是一种不同的、更具风险的转换。DownsampleImages 会漏掉保守 DPI 估算值保持在阈值以下的图像,即使人类能够看出它对其框架而言是过大的。并且没有一个步骤会触及文档结构,因此由数千个孤立对象引起的臃肿文件需要进行重写式的保存,而不是这些流级别的步骤。在这些限制内,字体子集化、图像下采样和 LZW 到 Flate 规范化的组合通过各一次可预测的 API 调用消除了 PDF 臃肿的三个经典来源。这三个函数作为适用于 Delphi、C# 和 VB.NET 的 losLab PDF Library 的一部分发售,同时也包含上面讨论的合并、提取和渲染 API