技术文章

Delphi中的PDF垃圾回收:标记-清除算法

从PDF中删除一页并不会删除它的字体、图像或内容流。losLab PDF Library用一个标记-清除收集器来回收这些对象:它从trailer根节点出发正向遍历对象图,并移除所有无法到达的间接对象。这个过程只在完整保存(full save)时运行,默认关闭,并会返回它丢弃的对象数量

为什么删除PDF页面不会让文件变小?

因为删除页面只是一次引用编辑,而不是存储层面的操作。DeletePages(StartPage, PageCount)会把页面对象从页面树中解链,并修复曾经指向它们的大纲条目。但它做不到的是判断这些页面用过的字体程序、内容流和图像XObject现在是否已经死亡,因为在删除的那一刻,文件里没有任何记录能说明还有谁可能仍然指向它们。这些对象仍然留在文档对象列表中,而完整保存会把它们原封不动地全部写回去。结果就是大多数支持工单开头的那种抱怨:客户删掉了百分之九十的页面,保存后文件只缩小了百分之二。更糟的是,这种泄漏会不断累积:加载、删除、保存,再加载、再删除、再保存,页数在下降,文件体积却在单调增长。这和字体子集化与图像降采样解决的问题不一样——后者是让存活对象变小,而这里的对象并不是太大,它们只是已经不再属于这份文档

根集合来自trailer,而不是页面树

PDF对象图没有反向引用字段。该格式既未定义引用计数,也没有反向指针列表,而确实存在的/Parent键只属于页面树这类特定结构,并不覆盖整个对象图。间接对象自身不会告诉你谁在指向它,所以"对象47是否还有人在用"这个问题只有一种回答方式:从已知的根节点出发正向遍历,看能不能到达它。这就是为什么losLab PDF Library中的收集器是一个标记-清除收集器,而不是引用计数方案

根节点来自文件trailer(ISO 32000-1 §7.5.5)。有三个键携带它们:/Root,即§7.7.2定义的文档目录,页面树、名称树、大纲、AcroForm和元数据都挂在它下面;/Info,文档信息字典;以及/Encrypt,加密字典。trailer中剩下的两个键是烟雾弹。/ID是一个由两个字节串组成的数组,/Prev是指向上一个交叉引用小节的整数字节偏移量。两者都不是间接引用,因此都不贡献根节点。losLab PDF Library会把整个trailer字典入队,而不是只处理这三个具名键,这样做不额外花什么代价,还能让任何私有的trailer扩展保持存活

遍历本身是迭代式的,而不是递归的。遍历过程每遇到一个间接引用,只记录对象号和代数(generation),标记对应槽位,并把它压入一个FIFO队列,而不是立即解引用——这样深层的页面树和长长的大纲链就不会占用调用栈,也避免同一个对象被解码两次。直接字典、数组和流字典进入第二个队列,由一个已访问集合守护,因为真实文档中确实存在环:页面的/Parent会回指它所在的页面树节点,大纲条目也通过/Prev/Next在两个方向上互相链接。代数号是匹配条件的一部分,不是装饰性的:只有对象号和代数都吻合时,引用才会被解析;指向某个存在但代数不同的对象号的引用,会被当作规范要求的null对象处理,绝不会当成一条存活边

如何在保存时启用垃圾回收?

垃圾回收是可选启用的,属于保存选项记录的一部分。它默认是False,因为收集器是对对象图的一次破坏性遍历,任何库都不应该在调用者从未要求检查的情况下悄悄删除对象

var
  Pdf: TPDFlib;
  Opt: TPDFlibSaveOptions;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
      Exit;
    Pdf.DeletePages(11, 490);          // keep the first ten pages

    FillChar(Opt, SizeOf(Opt), 0);
    Opt.CompressContent := True;
    Opt.CompressFonts := True;
    Opt.OptimizeContentStreams := True;
    Opt.PackObjectStreams := True;
    Opt.GarbageCollect := True;        // drop everything the pages left behind
    Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
  finally
    Pdf.Free;
  end;
end;

另外还有两个入口能到达同一个收集器。SetGarbageCollect(1)会在选定的文档上设置该标志位,让普通的SaveToFile遵循它;而GarbageCollectObjects会立即执行这一遍处理,并返回被移除的孤立间接对象数量。当你想要一个可以记录或断言的数字时,应该用后面这种立即执行的形式,而且这个返回值值得检查,因为负数并不代表计数

var
  Removed: Integer;
begin
  Pdf.DeletePages(11, 490);
  Removed := Pdf.GarbageCollectObjects;
  if Removed < 0 then
    // The graph could not be fully decoded. Nothing was swept and the
    // document is unchanged; save it without GC or reject the input.
    LogWarning('object graph incomplete, GC skipped')
  else
    LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;

这条失败路径比它看起来更重要。对象是惰性解码的,一个从未被解码过的对象不会暴露出任何引用。如果收集器把一个无法解码的对象当成空节点处理,就会把只能通过它才能到达的一切都清扫掉。所以遍历过程会在触碰每个对象时强制解码,一旦出现解码错误就会中止整个流程并返回负数,文档保持字节级不变。清扫一张自己只理解一部分的对象图,正是收集器把一份损坏文件变成一份被摧毁文件的方式

什么会拖垮一个朴素实现的PDF收集器?

有两个细节,而且它们都是悄无声息地失败,而不是大声报错。第一个是对象流。自PDF 1.5起,非流对象可以压缩存放在/ObjStm容器内(§7.5.7),它的交叉引用条目是一个type 2条目,标明容器加上容器内的索引。因此,一个压缩对象只能通过它的容器才能到达。如果只标记了成员本身、却清扫掉了容器(因为没有任何东西把容器当作文档对象来引用),你就会写出一个xref指向已经不存在对象的文件。容器属于结构性存储,不是文档数据,所以在你遍历的对象图中它从来不会作为一条边出现。losLab PDF Library的处理方式是:在容器消失之前,先把每一个存活下来的压缩成员从其源容器中分离出来,之后保存过程会把这些幸存者重新打包进新的对象流。第二个细节是流对象实际引用了什么。流的字节内容不属于对象图。一段用/F1 12 Tf绘制文字的内容流是通过资源名来指定字体的,而这个名字要通过页面的/Resources字典来解析,所以可达性的边是页面→/Resources/Font→字体对象这样一条路径,绝不会经过流的载荷本身。一个流所贡献的引用只来自它的字典,其中/Length/Filter/DecodeParms都允许是间接引用。一个去解析流字节以寻找引用的收集器是在做无谓的昂贵工作;而一个跳过流字典的收集器则会丢失length对象,把文件弄坏

被释放的对象号会怎样

它们会变成空闲条目,而且在同一次保存中不会被复用。清扫过程按降序遍历对象列表,让删除操作保持索引稳定,只在最后统一重建一次查找索引,而不是每删一个就重建一次;每移除一个对象,就把它的编号连同代数加一后记录进空闲列表——正如§7.5.4对"以后可能被复用"的条目所规定的那样。已经处于65535的代数会保持不变,标记该编号被永久退役。对象号是刻意不做压缩整理的。回收之后文件里会留有空洞:对象12可能是空闲的,而13和14仍在使用中,trailer中的/Size依旧报告最大编号加一,而不是幸存对象的实际数量。这是合法且正常的。重新编号只能在交叉引用表中省下寥寥几个字节,却需要重写文档中的每一处引用,而这类改动恰恰会悄悄让文档外部任何持有对象号的东西失效。你最终得到的体积缩减来自对象本体,而不是来自xref表

什么时候绝不能运行收集器

绝不能在增量更新时运行。收集器被限定只在完整保存时启用,当文档处于追加状态时,这个标志位根本不会被读取——这个限制不是一个需要绕过的缺陷。增量更新(§7.5.6)不会触碰原有字节,而是追加一个新的交叉引用小节,通过/Prev链接回上一个。每一个更早的版本仍然指向它一直指向的那些对象,所以一个在当前版本中不可达的对象,在某个更旧的版本里很可能仍然可达。删掉它会破坏除最后一个版本外的所有版本,具体原因在增量更新与追加模式保存一文中有详细说明。同样的道理也排除了在已签名文档上运行垃圾回收的可能,因为让回收得以进行的那次完整重写,恰恰正是会让签名失效的操作

同样值得说清楚的是,回收不是什么。它不是一个清洁工具。收集器移除的是没有任何东西引用的对象;它对这些对象内容是否敏感没有任何判断,一个仍被引用的对象无论内容如何都会原样保留。如果目标是让信息变得不可恢复,而不是让文件变小,那么对象图就不是正确的处理层,指令级涂黑与文档净化才是。这两者按这个顺序搭配效果很好:先涂黑并净化,再回收,这样涂黑操作分离出来的对象才会真正离开文件。资源清理API中也有同样的组合,传入垃圾回收选项会让清理操作在之后执行一次回收,并在OrphanObjectsRemoved中报告它移除的孤立对象

最后再养成一个习惯。在你做页面删除的批处理任务里记录GarbageCollectObjects的返回值,观察几周内真实文档上的表现。如果一份你刚砍掉一半内容的文件返回结果是零,说明上游有什么东西仍然持有一个你没预料到的引用,通常是一个名称树条目、一个大纲目的地或者一个AcroForm字段,它所依附的页面已经没了,它自己却还留着。收集器是你能拥有的最廉价的可达性调试工具,因为它回答的正是PDF格式本身拒绝回答的问题

这里介绍的垃圾收集器、保存选项记录和资源清理API,都是面向Delphi与C++Builder的losLab PDF Library的一部分,其产品页提供了完整的保存流程参考,涵盖回收、对象流打包与线性化之间的交互关系