技术文章

在Delphi中替换PDF页面而不破坏书签

替换一份已经签署确认的合同中的第3页,本不应该让目录也跟着挪位。删掉旧页面、插入新页面,原本指向那里的每一个书签现在都会落到别的地方。PDF Library for Delphi Delphi PDF库避免了这个问题:它保留目标页面对象本身,只搬运携带视觉内容的那部分条目

为什么替换PDF页面后书签会失效?

书签失效是因为PDF的目的地是通过间接对象引用来指定页面的,而不是通过页码。ISO 32000-1 §12.3.2.2把显式目的地定义为一个数组,其第一个元素就是指向页面对象的间接引用。删掉那个对象、再追加一个替代页面,这个引用就会变成悬空引用:大多数查看器的反应是把读者停在第1页,这正是"先删除再插入"这种替换方式之后人们报告的典型症状。页面树看起来完美无缺,页数是对的,渲染效果也是对的,而整个导航层却在悄无声息地出错

命名目的地也救不了你。§12.3.2.3让一个名字通过文档目录中的/Dests名称树来路由,但这个名字最终解析到的叶子节点,依然是持有同一个页面引用的显式目的地数组。命名只是在页面引用之上加了一层间接性,而不是绕开了它。同样的道理也适用于§12.5所描述的其余交互层:一个链接批注携带的/Dest或者/A GoTo动作,其/D就是那个数组;每个批注都可能携带一个/P条目,是指向其所在页面的间接引用;表单字段控件也是一种批注,处境完全一样。一次朴素的页面替换会一口气拆散四个子系统,如果你想在真实文件上把它们逐一列出来看,大纲与批注反射检查一文遍历的正是同一张对象图

PDF Library for Delphi 对比图:以间接引用命名的书签目标在原位替换页面后依然有效,在删除后追加的换页方式下则悬空
目标位置把书签、链接和部件绑定到页面对象编号,因此原地修改该对象能让导航继续生效,而先删后插会让读者落到第 1 页

哪些页面条目携带身份,哪些携带外观

一个页面字典混合了两类条目,只有把它们区分开来,原地替换才能成功。外观这一侧是有限且可枚举的:/Contents/Resources,五个页面框/MediaBox/CropBox/BleedBox/TrimBox/ArtBox,再加上/Rotate/Group/UserUnit/BoxColorInfo。这十一个条目决定了栅格化器对这个页面输出的一切内容,文件中没有其他任何地方会按名字指向它们

身份这一侧则是文档其余部分已经绑定的东西:页面对象号和代数、指回页面树的/Parent反向链接,以及/Annots。PDF Library for Delphi把这些全部原样保留。ReplacePageRanges会从目标页面字典中清除这十一个视觉条目,再从导入的源页面重新添加进去,因此目标页面对象是被原地修改,而不是被替换掉。§7.7.3要求的页面树结构在形状上也保持字节级不变:/Kids的顺序、/Count,以及每一个幸存的/Parent在操作前后都是一样的,因为从来没有任何节点被解链过

PDF Library for Delphi如何在不重新编号对象的情况下替换页面?

这次调用接受一个源文档、一个基于1的目标起始页、一个源页面范围表达式,以及一个选项标志。两份文档必须在同一个实例中打开,且目标文档必须是当前选定的文档。因为目标页数永远不会改变,所以你请求的范围必须能容纳在从TargetStartPage开始的文档内部,而且这一点会在创建任何对象之前就被检查

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // 书签和链接必须保留下来的那份文档
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // 修订后的条款页,由生成它的任何工具渲染而成
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // 源页面 1 覆盖目标页面 3 的视觉内容。
    // 页数、页面 3 的对象号、书签和注释都会被保留。
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

在内部,源页面不能简单地跨文档边界直接读取,因为它们内部的每一个间接引用都属于源文档自己的对象编号体系。所以源范围首先会按普通方式导入,作为临时页面追加在最后一个真实页面之后,这会触发完整的对象图重映射:内容流、字体、XObject、着色和颜色空间全部被重新编号进入目标文档。只有到了这一步,那十一个视觉条目才会从每一个临时页面复制到对应的目标页面上,也只有到了这一步,临时页面才会从页面树上解链。重映射这项工作发生在成本低、又安全的地方,而具有破坏性的编辑则被压缩成了对已经存在的页面所做的一次字典级替换

那条会摧毁刚刚搬运成果的删除路径

移除那些临时页面这一步,看起来微不足道,实际上并非如此。库里普通的页面删除路径做的事情远不止解链一个节点:它会合并被删除页面的各个图层,清空第一个内容流,并回收任何其他页面都不共享的资源。这对于一次真正的删除操作来说是正确行为,但放在这里就是灾难性的,因为到临时页面被移除的那一刻,目标页面已经在引用这些完全相同的内容流和资源对象了。清空它们会让你刚刚替换好的页面变成空白,而资源清理还会把现在已经有了新主人的字体和图像一并收走

解决办法是给内部删除路径增加一个"保留被引用对象"模式。一旦设置了这个模式,删除操作会同时跳过未共享资源的清理和内容流的清空,只做把页面从页面树上分离、并修正树的记账信息这一件事。被搬运过来的对象带着新的所有者存活下来,操作之后的对象所有权关系就是你在白板上画出来的那样:一个内容流,一个拥有它的页面,一个从未变动过的对象号。创建、删除和重排页面相关的生命周期规则,在文档与页面生命周期操作一文中有单独介绍

PDF Library for Delphi:页面字典解剖图:区分文件依赖的身份条目与 ReplacePageRanges 从导入源页面换入的十一个视觉条目
ReplacePageRanges 清除十一个视觉键并从导入重新添加,而对象编号、generation、/Parent 和 /Annots 完全保持原样

顺序、重复项,以及全有或全无的失败方式

选项标志决定源范围该如何解读。0会对解析出的页码排序并去重,这是调用方传入类似'4-6,2'这种表达式时的合理默认行为,意思就是这四个页面。1会保留你写下的顺序,并允许页码重复,所以'2,1,2'真正的意思是从两个源页面中取出、总共做三次替换。校验先运行且必须完整跑完:范围语法、每个页码相对于源页数的有效性、选项值本身,以及目标容量,都会在创建任何一个对象之前全部检查完。一次被拒绝的调用会把LastErrorCode设为412,恢复之前选中的页面,让文档保持原样不变

PDF Library for Delphi:ReplacePageRanges 三阶段流程:带对象重映射的临时导入、视觉条目复制,以及保留被引用资源的解除链接
把源文档作为临时页导入可以先完成常规重映射,破坏性编辑由此缩小为复制视觉键和摘除节点,不会回收仍在使用的资源
var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1:保留源顺序并允许重复,因此
  // 目标页面 5、6、7 分别收到源页面 2、1、2
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // 成功时,当前选中页是第一个被替换的页面
  Assert(Lib.SelectedPage = 5);
end;

原子性不仅覆盖校验阶段,也延伸到搬运本身。在第一个源页面被导入之前,范围内每一个目标页面的十一个视觉条目都会被编码后快照下来。如果导入失败,或者导入的页数与请求的不一致,这些快照就会被解码回目标页面,临时页面也会被移除,所以中途失败依然会让原始视觉内容留在它们原来的对象上。这一点比听起来更重要:一份合同里出现半替换状态的页面范围,比一次干脆失败的调用还要糟糕,因为文件里没有任何标记能表明它只做了一半

// 值得在回归测试中断言的后置条件
Lib.SelectPage(3);
// 几何尺寸现在来自源页面
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// 原本就在目标页面 3 上的注释依然保留
WriteLn(Lib.AnnotationCount);
// 替换之前创建的书签仍然指向页面 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// 并且文档长度依然不变
WriteLn(Lib.PageCount);

原地替换仍然不会替你做哪些事?

源页面上的批注、源表单字段和源大纲是刻意不导入的。如果把一个控件搬过来却不带上它的/AcroForm字段条目,或者把一个带标记内容的批注搬过来却不带上它在结构树中的归属,都会产生一个没有查看器能理解的、半导入状态的交互对象,所以这个操作只搬运外观。这在实践中的结果是:如果替换页面本该带来新的表单字段或新的链接,你需要在操作之后再把它们添加到目标页面上——而那个目标页面对象仍然原地待着,等着接收它们

还有两个边界情况值得你在自己的文件上验证一下。第一,/Annots会被保留,但页面几何尺寸不会,所以用一个320毫米的页面替换一个220毫米的页面,会让批注矩形停留在旧坐标上,而/MediaBox的尺寸已经变了;如果几何尺寸发生了变化,需要重新定位你保留下来的批注。第二,十一个视觉键之外的条目按设计会留在目标页面上,这对/Trans/AA来说是对的,但对/Thumb来说就成了过时数据,所以替换之后要重新生成缩略图。带标记的文档还需要多考虑一点:结构元素依然通过/Pg正确指向页面对象,但它们的标记内容标识符描述的却是已经不存在的内容,所以在PDF/UA工作流中做页面替换,同时也是一次结构树编辑,而不只是内容编辑。如果你真正要做的其实是合成而不是替换——把美术元素叠加到你保留下来的页面上——页面拼接与模板方案是更省事的工具

这里描述的一切,包括范围表达式语法、选项取值以及周边的页面操作API,都随标准版PDF Library for Delphi Delphi PDF Library一起提供,适用于Delphi和C++Builder,其参考文档收录了页面替换调用及其错误码的完整条目