The PDFium Component for Delphi(PDFiumPas)用 TPdf 上的三个重写 API 瘦身 PDF 文件:SaveAsReachable 丢弃无人引用的对象,SaveAsDeduplicated 合并字节级相同的字体、图像和 Form XObject,SaveAsFlateRecompressed 以最高压缩级别重新编码既有 Flate 流。每个 API 都返回一份报告记录,让你看到删了什么、省了多少字节
最常见的触发是一张写着“我们删了二十页,文件反而更大了”的支持工单。有人打开一份合同,删掉附录,并进一页封面,然后保存。每一步都是增量更新,所以旧对象还在文件里;等这份文档之后被加载再用 FPDF_SaveAsCopy 写出,它们原样回来。PDFium 序列化的是内存对象表里的一切,它没有列出间接对象、删除某个对象或重写引用的公开调用。因此本文的一切都发生在 PDFiumPas 包装层里,作用在 PDFium 产出的字节之上
FPDF_SaveAsCopy 为什么把删除的页面留在文件里
FPDF_SaveAsCopy 保留孤儿对象,是因为 PDFium 把交叉引用表里每个活着的条目都装进文档,不管还有没有任何东西指向它。ISO 32000-1 §7.5.6 把增量更新描述成在文件末尾追加新对象版本和新的 xref 节。删除页面只编辑页面树;页面字典、它的内容流、以及只被该页面用到的字体或图像,仍作为在用对象列在表里。完整的非增量保存也改变不了这一点,因为在对象表看来,这些对象完全合法
同样的残留也出现在页面合并、其它工具做的表单扁平化、以及损坏文件的修复保存之后。它们在每个查看器里都不可见,除了字节数什么也不添。扫描文档上,一张孤儿页面图像就能有几 MB
用 SaveAsReachable 做标记-清除
TPdf.SaveAsReachable 用经典的标记-清除趟删除孤儿:让 PDFium 写出当前文档的非增量副本,把副本解析成对象图,从根出发标记一切可达对象,再写出一个只含被标记对象的单一经典版本。对象流里的对象沿途被物化成普通间接对象,所以输出永远用朴素的 xref 表
选根是朴素实现出错的地方。显而易见的种子是 trailer /Root 点名的 Catalog。从那里能走到页面树、页面资源、字体、图像、Form XObject、AcroForm、结构树、书签、名称树、可选内容、XMP 元数据、输出意图和嵌入式文件。但文档信息字典是被 trailer 的 /Info 条目(ISO 32000-1 §7.5.5)引用的,不是被 Catalog。只从 /Root 下种,你处理过的每个文件的 Title、Author 和 Producer 都会被悄悄删掉。PDFiumPas 以 /Root 和 /Info 双种子起走,重建的 trailer 只在该对象真正幸存时才把 /Info 带下去
遍历器是带显式栈和已访问集合的深度优先遍历,引用循环(页面树里的父链接、注释的 /P 回指)挂不住它。指向已空闲对象号的引用按原样不跟随,而不是当错误处理,这让这一趟对轻度损坏的输入保持宽容
对象号保留原值,不重编号
TPdf.SaveAsReachable 让每个幸存对象留在原对象号和 generation 上。被清除的对象在 xref 表里写成空闲条目(ISO 32000-1 §7.5.4),活号码之间的空隙用空闲条目填满,对象 0 仍是空闲链表头。因为什么都不重编号,幸存的每条引用不需要重写一个字节就仍然正确,也没有旧到新的映射可以出错。代价是几行闲置的 xref 条目,每行 20 字节,这笔交易值得做
uses
PDFium, FPdfCompress;
procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
Report: TPdfPruneReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
if not Pdf.SaveAsReachable(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
[Report.SourceByteCount, Report.OutputByteCount,
Report.KeptObjectCount, Report.PrunedObjectCount]));
finally
Pdf.Free;
end;
end;
TPdfPruneReport 携带 SourceByteCount(PDFium 产出的非增量副本的大小)、OutputByteCount、KeptObjectCount、PrunedObjectCount、Succeeded 和 ErrorMessage。文件名重载写到目标旁边的临时文件,只有重写成功才替换目标,失败的一趟绝不会留下截断的 PDF
这些 API 会拒绝哪些文件
三个重写 API 都拒绝加密文档,默认也拒绝签名文档。加密输入以 Succeeded = False 加错误消息失败而不是抛异常:单版本重写保不住加密字典依赖的对象号、字节范围和逐对象密钥,产出必然不可读。带数字签名的文档会抛 EPdfError,除非传 AllowSignedDocument = True,因为全量重写必然使签名字节范围失效。设这个标志意味着你接受签名将被报为损坏的文件;对归档副本这是正确选择,对马上要发回去的合同永远不是
SaveAsDeduplicated 如何判定两张图像相同
TPdf.SaveAsDeduplicated 只在字典、整棵可达子图和原始流字节全部一致时才把两个资源视为相同。它先跑同一趟可达性遍历,然后给每个可达的字体、图像 XObject 和 Form XObject 取指纹,把每组匹配者合并到对象号最小的那个成员上,并重写每条引用指向幸存者。被合并的重复项和孤儿一样变成空闲 xref 条目。这就是经典的“logo 嵌入 400 次”报表的解法——邮件合并工具在每一页上重新嵌入同一批资源
指纹是递归计算的 64 位 FNV-1a 哈希。对每个候选,遍历器取对象主体,把每条 N G R 引用替换成它所指对象的指纹,再对结果求哈希。这让哈希独立于对象编号:同一字体的两份拷贝,描述符恰好是对象 12 和 87,仍产出相同值。结果做了记忆化,共享子对象只哈希一次;访问集合对回边返回固定哨兵,循环图也能终止
微妙之处藏在流载荷里。为了安全地找引用,PDFiumPas 扫描的是每个对象的一份副本,其中流数据、字符串和注释都被空格抹掉;否则一段碰巧长得像 12 0 R 的二进制像素数据就会被当成引用。只哈希那份净化副本的话,两本字典完全相同(/Width、/Height、/BitsPerComponent、/Filter、/Length 一致)但像素不同的图像会塌缩成一个指纹,页面上的第二张照片被悄悄换成第一张。SaveAsDeduplicated 把 stream 与 endstream 之间的原始字节附加进被哈希的数据,像素因此成为身份的一部分。候选比较前还按类别分组,所以字体与图像之间罕见的哈希碰撞永远不可能把它们合并
引用重写反向复用同一套净化扫描技巧:引用在抹掉的副本里定位,字节却从原始主体拷贝,于是被修改的永远只有字典和数组结构,二进制载荷原样通过
procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
Report: TPdfDedupReport;
begin
if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
[Report.MergedFontCount, Report.MergedImageCount,
Report.MergedFormXObjectCount,
Report.SourceByteCount, Report.OutputByteCount]));
end;
读 TPdfDedupReport 要仔细。三个 Merged*Count 字段旁边还有 KeptObjectCount 和 PrunedObjectCount,而且在这份报告里 PrunedObjectCount 只统计被合并的重复项。不可达对象照样被丢,但不计入这个数字。日志里需要孤儿数的话,从单独一趟 SaveAsReachable 拿。另一个限制:匹配对编码后的流字节级精确,同一家 logo 被两个不同的生成器以两个不同的 Flate 级别压缩,就不会被合并
以最高级别重压缩 Flate 流
TPdf.SaveAsFlateRecompressed 把每条可达的 FlateDecode 流解压,再以最高压缩级别压回去,只有结果更小且过了你的阈值时才保留。许多生成器用快速的默认级别写 Flate,所以内容流和嵌入字体程序常常还能再缩。对象号和 generation 不动,变的只有载荷和它直接的 /Length
TPdfFlateRecompressOptions 有两个字段且没有默认构造器,两个都要设。MinSavingBytes 是每条流的最小绝对节省量,MinSavingRatio 是原长度的比例(0.05 即 5%);比例为 0 时关闭比例测试。重建的流按 stream、CRLF、新载荷、CRLF、endstream 写出,结果不依赖原文件的行尾风格
procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
Options: TPdfFlateRecompressOptions;
Report: TPdfFlateRecompressReport;
begin
Options.MinSavingBytes := 256; // 忽略小赢
Options.MinSavingRatio := 0.05; // 以及 5% 以下的一切
if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
[Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;
边界情况设计得保守。/Length 是间接引用的流被跳过,因为重写必须存直接的整数。解压失败的载荷计入 FailedCount 并原样拷贝。达不到阈值的 Flate 流计入 SkippedCount。DCT、JPX、JBIG2 和 CCITT 图像从不进候选名单,这一趟永远不碰 JPEG 质量。要有损省图像体积,在最终保存前用 PDFiumPas 的自适应图像重采样
写阈值测试时踩到一个可移植性陷阱:初稿拿 Single(Saving)/Single(OrigLen) 比较,而 Saving 声明为 Int64。Delphi 接受它,FPC 3.2.2 却停下报 Illegal type conversion: "Int64" to "Single",因为在 FPC 里这种语法是类型铸造而不是数值转换。朴素的 Saving / OrigLen 在两个编译器里都是真除法,可直接与 Single 比较,那些铸造本来就是多余的
三个 API 该按什么顺序跑
先跑 SaveAsDeduplicated,重新加载输出,再跑 SaveAsFlateRecompressed。去重本身已包含可达性清除,前面再单跑一趟 SaveAsReachable 是冗余;只想要孤儿清理和精确清除计数的工作留 SaveAsReachable。重压缩放第二位,昂贵的解压再压缩工作就对每条幸存流只跑一次,而不是对每个重复项跑一次。重压缩这一趟还会再次从 /Root 和 /Info 走一遍图,把只有被合并的重复字体还指着的字体描述符和字体文件顺手清掉
procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
TempPdf: string;
begin
TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
MergeDuplicateResources(Pdf, TempPdf); // 清除 + 合并
Pdf.Active := False;
Pdf.FileName := TempPdf;
Pdf.Active := True;
RecompressFlate(Pdf, TargetPdf); // 对幸存者重新压缩
finally
Pdf.Free;
DeleteFile(TempPdf);
end;
end;
如果还要做涂黑(redaction),请在这一切之前做,因为物理移除内容正是产生新孤儿的源头;操作符级涂黑与资源清除一文讲涂黑那一侧。管线里最后永远把成品文件重新加载一遍做完整性检查:页数与源一致是廉价的保险,在你覆盖任何客户在意的东西之前
FAQ
SaveAsReachable 会重编号对象或改动引用吗
不会。TPdf.SaveAsReachable 让每个幸存对象保持原编号和 generation,被移除的对象标记为空闲 xref 条目,任何引用都不必重写
SaveAsDeduplicated 会误合并两张不同的图像吗
光靠字典匹配不会。指纹包含原始流字节和完整引用子图,候选只在同一类别(字体、图像、Form XObject)内比较
这些 API 能保住数字签名有效吗
不能。每个 API 都执行全量重写,签名字节范围必坏,所以签名文件被拒绝,除非你传 AllowSignedDocument = True 并接受输出中失效的签名
三个 API 都随 PDFium Component for Delphi and C++Builder 提供,与本博客其他文章覆盖的涂黑和图像重采样功能同在