技术文章

Delphi 跨文档 PDF 对象复制:循环崩溃

手工合并两个 PDF,把单个页面对象移进目标文档,复制会直接撞上访问违例。PDFlibPas 在 CopyForeignObject 中修复了这个问题:它深拷贝一个间接对象及其整个引用闭包,并把 /Parent 这类循环反向引用解析为 null 而不再递归

为什么跨文档复制一个页面会崩溃?

因为 PDF 页面树只有当你向下读时才是一棵树。按递归复制器的方式走,跟着每个字典里的每个值,页面字典会把 /Parent 交给你,它指回你出发的 /Pages 节点,而那个节点又把 /Kids 交给你,指回这个页面。ISO 32000-1 §7.7.3 要求除根节点外的每个页面树节点都必须有 /Parent,所以这不是一个可以拒绝的畸形文件,而是你经手的每一份文档的正常形态

问题的另一半是编号。间接对象由仅在一个文件内有效的对象号标识(ISO 32000-1 §7.3.10),所以从文档 A 拖进文档 B 的对象必须重新编号,被复制闭包内部指向它的每个引用也必须以同样方式重新编号,否则两个原本指向同一个共享字体的引用就会变成指向两个毫不相干的东西。这种重新编号和快速合并在字节层面做的是同一件事,两者值得对照着读:快速 PDF 合并的字节级引用平移靠整体翻译文件来解决,而对象级复制必须一条边一条边地解决

为什么 Delphi 中的跨文档 PDF 复制需要谨慎:页面字典与其 /Pages 节点通过 /Parent 和 /Kids 闭合成环,字体闭包向下走并终止,PDFlibPas 重映射每个文件内对象号
页面树通过 /Parent 和 /Kids 闭合成环,而内容闭包会终止,每个被复制的对象号都必须在途中重映射

PDFlibPas CopyForeignObject 实际复制什么

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) 克隆一个间接对象及从中可达的一切,包括嵌套字典、数组、字符串、名称、数字,以及字典原样保留的流,写入当前选中的文档,并返回指向新间接引用的非零句柄。源对象号经由本次调用期间持有的活映射重映射,于是闭包中被到达两次的对象只克隆一次、共享两次。当源文档 ID 未知、源就是选中文档本身、或 ObjectNumber 小于 1 时,它返回零而不抛异常

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile 成功时返回 1
    SourceDoc := Lib.SelectedDocument;   // 加载会选中它所加载的文档
    Lib.SelectDocument(TargetDoc);       // 复制目标是当前选中的文档
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

第一次跑就有两个细节会咬人。LoadFromFile 回答的是 1 或 0,不是文档 ID,所以你需要的句柄来自加载后立刻读取的 SelectedDocument;而复制永远写入 SelectDocument 最后设为当前的那个文档,绝不写入你加载来源的那个。内部递归还带 64 层的硬深度上限,那是对抗病态嵌套的兜底,不是处理循环的机制,循环处理是另一套,而且是刻意为之

为什么预留 Nil 映射并不能打断循环?

因为映射表里的 Nil 同时意味着两件不同的事,代码无法区分它们。对循环的显然防御是在递归进入对象之前先加映射项,这样任何绕回来的边都能找到该项并停下。但该项还装不了真正的目标,因为目标要等它下面的闭包写完才存在,所以它装的是 Nil,而本应抓住回边的查找读到 Nil 后断定该对象从未被映射过

// 错误写法:预留的 Nil 目标与“尚未映射”无法区分
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // 预留,仍是 Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // 只在回程时回填
end;

跟着走一遍页面循环。页面的克隆到达 /Parent,递归进入 /Pages 节点,它到达 /Kids,又递归回页面,而页面的预留项仍读作 Nil,于是它被克隆第二次、第三次,每一层都压入一个新栈帧和一个新的半成品对象。你观察到的甚至不是干净的栈溢出:外层栈帧坐落在目标从未被赋值的引用上,所以第一次经由某个这种槽位的写入是一次访问违例,现场看上去跟引发它的页面复制毫无相似之处

为什么在 PDFlibPas 跨文档复制中预留 Nil 映射目标挡不住循环:查找无法区分预留项与未映射项,于是复制器沿着越来越深的半成品栈帧下坠,直到一次写入崩溃
因为一个 Nil 目标同时回答两个不同的问题,回边永远认不出来,页面每走一圈就被再克隆一次

修复:显式的进行中状态

修复办法是停止让 Nil 超载,直接把问题问出来。目标尚未赋值的映射项意味着这个对象正在被克隆,而 InProgress 谓词在普通查找运行之前检测的正是这一点。它为真时,这条边就是回到当前克隆的某个祖先的循环回边,PDFlibPas 为它发出 null 对象而不再跟进

// 目标为 Nil 的映射项标记一个进行中的克隆
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ……位于 CloneObject 内部,处理间接引用时:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // 循环回边,不再递归
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // 悬空的源引用
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // 先预留,再递归
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // 回填
end;
Exit(NewRef);

之所以能安全地推广,靠的是 PDF 的一个结构性事实:对象图中的循环出现在回指边上,而不是内容边上。页面树里的 /Parent 和大纲链里的 /Prev 向上或向后指向某个已访问过的东西;字体、图像 XObject 或表单 XObject 的闭包向下走并终止。所以字体描述符、颜色空间或渐变字典的副本不受 null 替换影响,那些闭包里没有任何东西会碰到 InProgress。把代价也直说了:循环边不会在复制后存活。以这种方式克隆的页面字典带着 null 对象的 /Parent 到达,按 ISO 32000-1 §7.3.9 这等同于条目缺失,于是这个复制的页面是个有效对象,但在你亲手把它链接进目标 /Pages 节点并修好 /Count 之前不属于任何页面树。被复制的大纲项同样丢失 /Prev,需要重建兄弟链。这是诚实的交换:CopyForeignObject 给你一个正确的闭包,把结构性的重挂父节点留给调用方,这与保留对象号替换页面所遵守的边界是同一个

Delphi 版 PDFlibPas CopyForeignObject 的修复:显式 InProgress 检测先于映射查找运行,循环回边变成 null 对象,调用方随后把复制的页面重新链接进目标页面树
显式的进行中状态取代了超载的 Nil,于是回边解析为 null,留给调用方的只剩一处结构性修复

为什么映射项必须先于 NewObjRef 预留

一个显然的替代方案能绕开整套进行中的舞步:先分配一个空壳对象,把它的真实编号登记进映射,等子对象克隆完再填充壳。这在这里行不通,因为 TPDFIndObj.Obj 是只读的,构造后内容不可替换,根本没有可填充的壳。编号和内容由 NewObjRef 一并决定,这意味着映射项必须在递归调用之前创建、之后完成,而这两个时刻之间的间隔正是 InProgress 必须覆盖的区间。对输出做 diff 前值得知道的一个后果:因为 NewObjRef 在子闭包写完后才运行,目标里的编号自底向上产生,对象号不会镜像源顺序。文件格式对此毫不在意,但和手工构造的期望做字节比对会在意。如果某次运行留下了你决定不链接进任何东西的对象,它们是未被引用而不是已损坏,对不可达 PDF 对象的标记-清扫回收就是保存前清走它们的工具

覆盖这项修复的回归测试有一个细节会让给 TPDFlib 写测试的人意外:构造函数已经持有一个默认文档,所以 DocumentCount 从 1 起步,双文档夹具必须断言 >= 2 而不是 = 2。除了成功复制之外,测试还钉住三种拒绝,即未知源 ID、选中文档以自身为源、对象号为零,全部返回 0 而不抛异常,因为合并循环可不是发现守卫子句会抛异常的好地方

它在合并管线中的位置

当整文件合并太粗时,对象级复制就是你伸手要的原语:从模板里单独取出一个字体程序、把单个表单 XObject 拉进盖章文档、或把一条注释连同它的外观流跨文件搬走而不拖动页面其余部分。PDFlibPas 把它暴露为对已加载文档的单次调用,你可以在 PDFlibPas Delphi PDF Library 参考文档中看到它与低级对象 API 其余部分的配合