技术文章

Delphi 双面扫描配页:PDF 交错合并

PDFlibPas 这个 Delphi PDF 库里的 CollateDocumentsEx 能把多个已打开的文档合并为一个交错排列的文档。它每一轮从每个源文档追加 GroupSize 页,支持为每个源指定一段页码范围列表,并把类似 3-1 这样的降序范围当作该源的倒序处理。一次调用即可把一叠正面扫描件和一叠倒序的背面扫描件变成正常阅读顺序

这个 API 背后的场景平平无奇却极为常见:单面走纸的馈纸式扫描仪把整叠纸正面朝下扫一遍,操作员再把整叠纸翻个面重扫一遍,最终得到两个 PDF——正面按顺序排列,背面则是倒序。用户想要的结果是一个文件,第 1 页正面、第 1 页背面、第 2 页正面……以此类推。本文讨论的是排序问题,以及藏在它下面的资源重复陷阱。如果你关心的是纯粹的拼接吞吐量,请看按字节级引用偏移做快速 PDF 合并;如果输入文件大到根本装不进内存,请看用直接访问方式合并与拆分吉字节级 PDF

扫描仪产出两叠文件,其中一叠是倒着的

配页不是合并。合并是拼接页码范围;配页是把它们交错排列,而交错模式本身取决于产出这些输入的物理设备。模式一旦搞错,文件不是稍有偏差,而是完全不可读:每隔一页就属于不同的一张纸。几乎所有真实场景都能用三个变量来描述:轮换中有几个源、每轮从每个源取几页、以及是否有某个源需要倒着读。CollateDocuments 用一个文档句柄数组加一个 GroupSize 整数覆盖了前两项。CollateDocumentsEx 再加上第三项,做法是接受一个用分号分隔的页码范围列表,每个源对应一段,空段表示该源的全部页面,降序范围表示倒序。两个函数都是把内容追加到当前选中文档的末尾,成功返回 1,任何被拒绝的情况返回 0

朴素的配页写法为何会让文件体积翻倍

因为把源对象号映射到目标对象号的导入映射表,是在每次复制调用时重建的,凡是被一个以上分块引用到的对象,就会每个分块导入一次。在 PDFlibPas 内部,TPDFDocument.CopyPagesFromDoc 在每次调用开头都会重置自己的 NewIndObjList。这个列表是复制器唯一记得"已经搬过什么"的地方。用一个十页的范围调用一次,十页共用的字体只会被嵌入一次;用一页一页的方式调用十次,同一个字体就会被嵌入十次。这个问题对扫描件的影响远大于对纯文本文档,因为扫描出来的一页就是一个大型图像 XObject,而共享对象往往才是真正占分量的部分:一份嵌入的 ICC 描述文件、一条共享的 /DecodeParms 链、施加在每张纸上的图章或水印 form XObject、OCR 文字层用的字体。写一个轮询式配页的直观方式是套一个按轮次的循环,而这个循环恰恰就是病态情形本身

// Do not do this. Each CopyPageRanges call rebuilds the import map,
// so anything the two sources share internally is imported once per
// round instead of once per source.
var
  RoundIndex: Integer;
begin
  for RoundIndex := 1 to 12 do
  begin
    PDF.CopyPageRanges(Fronts, IntToStr(RoundIndex));
    PDF.CopyPageRanges(Backs, IntToStr(13 - RoundIndex));
  end;
end;

十二轮,两个源,二十四张导入映射表。没有任何警告。页面顺序是对的,每一页都能正常渲染,唯一的症状是文件体积变成了输入文件总和的好几倍。在一个 300 页的批处理作业里,这个倍数不是舍入误差,而是归档文件到底能不能塞进保留空间预算的区别

只导入一次,再重排页面树

解决办法是把朴素循环里搅在一起的两件事拆开。复制决定目标里存在哪些对象;排序决定这些页面在页面树里坐落在何处。CollateDocumentsEx 对每个源只精确复制一次——用一次 CopyPagesFromDoc 调用带上该源的完整范围,这样每个源只生成一张导入映射表,共享资源只写一次。等所有源都落地之后才开始做交错排列,而这一步完全通过 TPDFPageTree.MovePage 来完成

就这里而言,页面移动的代价可以说是免费的。ISO 32000-1 §7.7.3 把页面树定义为一种节点字典构成的平衡结构,其 /Kids 数组保存间接引用,/Count 在每个节点上携带叶子节点总数。重新定位一个页面,意味着从一个 /Kids 数组里移除一条间接引用、插入到另一个数组里、调整两处的 /Count 值、再重新指向该页的 /Parent。不涉及任何内容流的改动,不复制任何资源,也不创建任何对象。页面对象保留自己的对象号,这也是为什么对象号能像保留对象号的页面替换一文里那样保持稳定。还有一个细节,朴素的页面移动会做错,而 MovePage 不会。ISO 32000-1 §7.7.3.4 允许 /Resources/MediaBox/CropBox/Rotate 从祖先节点继承,而不必写在页面本身上。一个从节点 A 继承资源的页面,如果被移动到节点 B 下面,就会悄无声息地继承别的东西,甚至什么都继承不到。因此 MovePage 会在迁移之前先把继承值解析出来并写入页面字典,让页面在移动过程中带着自己的属性

重排这一步到底做了什么

它是针对"插入到指定位置"这种语义跑了一趟选择排序。期望的块内相对顺序先被计算出来:按轮换顺序遍历各个源,每个源最多取 GroupSize 个索引,跳过已经耗尽的源,重复直到所有页面都排好位置。这样就得到了对追加块的一个排列。真正应用这个排列比较麻烦,因为 MovePage 是插入而不是交换,所以每次移动都会把旧位置和新位置之间的所有内容整体挪动一格

实现里维护一个 Current 数组,建模每个已追加页面当前所在的位置,从位置 K 向前扫描,找到该待在 K 位置的那一页,发出移动指令,然后把数组条目滑动一下,以反映这次移动对页面树造成的影响。它在数组操作上是 O(n 的平方),在对象复制上是零,这对这类工作负载来说是正确的权衡:一次 500 页的配页会带来二十五万次整数搬移,但一个字节的图像数据都不会重复。降序范围和重复页面在这一步不需要任何特殊处理,因为调用 PLParsePageRangeList 时排序被禁用、允许重复,所以请求的顺序能原封不动地穿过解析过程

倒序范围与一次调用完成双面合并

把倒序表达为一个范围之后,平板双趟扫描的场景就能压缩成一次调用。正面要按自然顺序,背面要用 12-1,分号前那个空段表示第一个源贡献它的全部页面

var
  PDF: TPDFlib;
  Target, Fronts, Backs: Integer;
begin
  PDF := TPDFlib.Create;
  try
    Target := PDF.NewDocument;
    if PDF.LoadFromFile('fronts.pdf', '') <> 1 then
      Exit;
    Fronts := PDF.SelectedDocument;
    if PDF.LoadFromFile('backs.pdf', '') <> 1 then
      Exit;
    Backs := PDF.SelectedDocument;
    PDF.SelectDocument(Target);
    // fronts 1..12 in order, backs scanned in reverse: F1 B12 F2 B11 ...
    if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 1 then
      PDF.SaveToFile('duplex.pdf');
  finally
    PDF.Free;
  end;
end;

这段代码里有两个行为值得明说。配好的页面是追加到选中文档末尾的,所以用 NewDocument 创建的文档会在这些页面前面带上它自己的初始空白页,如果不想要就得手动删掉。而且各个源的页数可以不均等:以 GroupSize 为 2、一个三页源配一个五页源为例,各轮结果是 A1 A2 B1 B2,接着 A 快用完时是 A3 B3 B4,最后单独是 B5,因为耗尽的源只是被跳过,而不会用空白页去凑

回滚、表单字段,以及哪些东西不会一起带过来

目标在被改动之前,每个参数都会先经过校验。缺失的文档句柄、选中文档把自己也列为源、小于 1 的 GroupSize、段数与源数量对不上、范围里点名了源里根本没有的页——以上情况全部返回 0,目标保持不变。复制过程中失败是更棘手的情况,处理方式是走公开的 DeletePages,而不是底层的 PageTree.DeletePages。原因很具体:复制过程启用了 MergeFormData,所以在后面某个源失败之前,前面源的表单字段可能已经被追加进了目标的 /AcroForm /Fields 数组。如果在页面树这一层直接删页,会把控件页面剥掉,却让那些字段引用悬空;走公开路径则会把字段、大纲和文章串引用连同页面一起解除关联

if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 0 then
  // Nothing was appended and the target is byte-identical to before.
  // 412 is the copy failure; 0 means the arguments were rejected
  // during validation, before any page was touched.
  Log(Format('collate rejected, LastErrorCode=%d', [PDF.LastErrorCode]));

对用户要说实话,讲清楚边界在哪。配页会带上页面、页面的注释和表单字段,并且会合并 AcroForm 字段列表、计算顺序数组和默认资源字典。它不会带上源文档的书签:扫描出来的正面文档堆栈的大纲树几乎总是空的,所以双面合并场景下不会丢什么,但如果你配页的是两份经过编排的文档,它们的大纲会留在原地,导航得自己重建。只存在于源文档目录里的命名目标也是同样的处境。在向客户承诺"无损配页"之前,先把这些考虑进去

PDFlibPas 把配页函数和其余的页面组装能力放在一起发布,因此扫描仪工作流、按范围提取以及大文件处理路径都收拢在同一个组件里,同时支持 Delphi 和 C++Builder。完整 API 参考与试用版可在 losLab Delphi PDF 库产品页获取