技术文章

HotPDF 中的 PDF 页面顺序 Bug:物理结构与逻辑结构的较量

这个病征最初是在一个基于 HotPDF 组件 搭起来的页面复制工具里冒头的:明明去抓一个只有三页的文档的第一页,结果它死皮赖脸地次次都给你甩回第二页。把那些处理索引的逻辑翻了个底朝天,愣是没找出半点毛病。调用明明老老实实地用了从 0 开始的逻辑索引,加减法算得清清楚楚,边界检查也毫无破绽。可它就是次次都能精准地给你塞错页面

这锅压根就轮不到那段复制代码来背。真正的雷,埋在 HotPDF 加载文件时在肚子里搭那个内部页面数组的逻辑里头

PDF 页面顺序的概念图:物理顺序和逻辑顺序的区别
PDF 页面顺序:页面树里头的 /Kids 数组才是掌握着逻辑顺序生杀大权的主,它压根不管文件里那些对象是怎么编号、或者按啥顺序挤在字节流里的

两套顺序,同一口黑锅

PDF 文件说白了就是一堆间接对象(indirect objects)的集贸市场,每个摊位(对象)头上都顶着个对象号。可这文件的物理结构压根没打算让这些号码去凑什么阅读顺序的热闹。对象 1 完全可以大摇大摆地装着第 2 页的内容;对象 20 里头也能坦然地躺着第 1 页。真正拍板定下阅读顺序的,是那棵页面树(page tree):那是一个由 /Pages 字典搭起来的层级结构,里头的 /Kids 数组白纸黑字地排好了页面引用的座次,阅读器就是得照着这个单子按顺序翻页(ISO 32000-1 规范第 7.7.3 节)

那个点燃了这颗雷的文档,它的页面树长这副德行:

{ Pages tree root, object 16 }
16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R   { logical page 1 }
         1 0 R    { logical page 2 }
         4 0 R]   { logical page 3 }
>>
endobj

好巧不巧,这文件在字节流里,硬是把对象 1 和对象 4 排在了对象 20 的前头。随便换哪个解析器来,要是它只知道蒙着头顺着文件里的物理顺序挨个去翻这些间接对象,只要摸到一个页面类型的字典就往它的 PageArr 数组里盖个戳,那最后数组的坑位绝对是这么排的:对象 1 占着索引 0,对象 4 霸着索引 1,对象 20 被挤到了索引 2。逻辑上的第 1 页,就这么被塞进了 PageArr[2] 里。这时候你跑去要索引 0 的页面,它当然只能把逻辑上的第 2 页给你扔回来

这正好就是 HotPDF 那两条内部解析路线当年犯下的蠢事。不管是那条对付 PDF 1.3/1.4 老古董的传统路线,还是那条对付对象流文档(PDF 1.5+)的现代路线,它俩在搭 PageArr 的时候,全都是在顺着物理文件的顺序去挨个翻牌子,而不是老老实实地去顺着 /Kids 给的链子往下爬

铁证如山:验证猜想

在动手改代码之前,咱们得先拿铁证把这个顺序错位给钉死在耻辱柱上,不能光靠猜。qpdf 命令行工具在这事上简直就是个照妖镜:

{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }

qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }

再把每一页挨个抽出来,拿文件大小去一对账,这乱点鸳鸯谱的映射关系就被彻底坐实了:PageArr[0] 吐出来的货色,实打实是逻辑第 2 页的内容,而 PageArr[2] 里头装的才是逻辑第 1 页。这波完美的首尾错位,就是最硬的作案工具。这也顺带解释了为啥这毛病能在好几个不同的源文档里阴魂不散:只要随便来个 PDF,碰巧让某个页面对象的号码比排在它前头的逻辑页面还小,这雷绝对一踩一个准

其实 PDF 会被搞成这副鬼样子,原因粗暴得很。增量保存(Incremental saves)那帮家伙,就是喜欢把更新的对象随便安个新号码然后一把甩到文件尾巴上,把交叉引用表里头那些占着茅坑不拉屎的旧坑位就那么晾着。那些往文件里塞封面的编辑器,才不管这页面在 Kids 数组里是排老几,直接甩给它一个天大的对象号。有些个生成器干脆就是按着怎么往内容流里倒垃圾顺手,就按什么顺序写页面,压根不管逻辑顺序死活。而 PDF 规范对这些流氓行径,连个“不”字都没说

拔除病根:顺着 Kids 数组往上爬

走正道的法子是:得从目录(catalog)的根节点出发,顺着 /Kids 的链子一步步往下爬来搭这个 PageArr,而不是去翻那些间接对象的垃圾堆。等两条解析路线都跑完了它们的第一遍扫描后,再加个后处理的活儿,把逻辑顺序给掰正了:

procedure THotPDF.ReorderPageArrByPagesTree;
var
  PagesObj  : THPDFDictionaryObject;
  KidsArray : THPDFArrayObject;
  NewPageArr: array of THPDFDictArrItem;
  I, J, PageIndex, KidsIndex: Integer;
  RefObj    : THPDFLink;
  PageObjNum: Integer;
  Found     : Boolean;
begin
  { Locate root /Pages dictionary via FRootIndex }
  PagesObj := FindPagesRootFromCatalog;
  if PagesObj = nil then Exit;

  KidsIndex := PagesObj.FindValue('Kids');
  if KidsIndex < 0 then Exit;
  KidsArray := THPDFArrayObject(PagesObj.GetIndexedItem(KidsIndex));

  SetLength(NewPageArr, KidsArray.Items.Count);
  PageIndex := 0;

  for I := 0 to KidsArray.Items.Count - 1 do
  begin
    RefObj     := THPDFLink(KidsArray.GetIndexedItem(I));
    PageObjNum := RefObj.Value.ObjectNumber;

    Found := False;
    for J := 0 to Length(PageArr) - 1 do
    begin
      if PageArr[J].PageLink.ObjectNumber = PageObjNum then
      begin
        NewPageArr[PageIndex] := PageArr[J];
        Inc(PageIndex);
        Found := True;
        Break;
      end;
    end;
    { Non-page Kids (intermediate /Pages nodes) produce no match; skip }
  end;

  if PageIndex > 0 then
  begin
    SetLength(PageArr, PageIndex);
    for I := 0 to PageIndex - 1 do
      PageArr[I] := NewPageArr[I];
  end;
end;

这个补救的调用得死死卡在每条解析路线收工的那一刻,也就是在所有对象都被登记造册了、但任何页面操作还没来得及处理的那个节骨眼上:

{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;

{ Modern path (object streams) }
if TryParseModernPDF then
begin
  Result := ModernPageCount;
  ReorderPageArrByPagesTree;
  Exit;
end;

这个掰正顺序的步骤,时间复杂度是 O(n * m),n 是 Kids 的个数,m 是当前 PageArr 的长度。但对于随便哪个扁平得只有一层的页面树(所有叶子节点的深度全在 1,这几乎囊括了市面上绝大多数活着的 PDF)来说,这俩值是一模一样的,那点性能开销连塞牙缝都不够。要是碰上那种嵌套得跟迷宫一样的页面树,那就得拿递归去爬了,光靠这单层的套路肯定是扛不住的;不过在生产环境的实现里,那都是另外单独对付的

修好之后:拿 CopyPageFromDocument 来干活

只要把 ReorderPageArrByPagesTree 往那一放,逻辑页面的索引终于算是干了人事。那些更高一层的家伙,比如 CopyPageFromDocument,只要你给它塞个从 0 开始的逻辑索引,它就能乖乖地把对的那页给你搬进目标文档里去:

var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    Source.LoadFromFile('source.pdf');

    Dest.FileName := 'extracted.pdf';
    Dest.BeginDoc;

    { Copy logical page 0 (first page the user sees) }
    Dest.CopyPageFromDocument(Source, 0, 0);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

CopyPageFromDocument 在肚子里早就学精了,它直接去查页面树理好的顺序,再也不去信 PageArr 里那个野路子索引了,所以就算撞见那种物理顺序和逻辑顺序分家、打死不相往来的文档,它也能稳稳当当地把活干对。要是碰上一批批的活儿,直接拿 InsertPagesFromDocument,往里扔个装着逻辑索引的数组,它一把就能全给你搬过去

这破事到底给 PDF 解析上了什么课

PDF 规范里白纸黑字写得明明白白:逻辑页面的顺序,那是页面树里的 /Kids 数组说了算的,根本轮不到对象号或者字节偏移量来指手画脚(ISO 32000-1 规范第 7.7.3.2 节)。随便哪个解析器,只要它敢抄近道用别的野路子来排顺序,在它遇到的大部分文档上可能都碰巧能蒙混过关,那是因为大部分生成器还是会老老实实地按自然顺序写页面、发连续的对象号的。但这雷就在地下埋着,直到哪天哪个倒霉蛋加载了一个被增量编辑过、被别的破工具重组过,或者是被某个非要用野路子排版的软件硬生生搞出来的 PDF,这 Bug 绝对会当场炸锅

光拿自己生成的 PDF 去跑测试,这种毛病你一辈子也别想发现。想把页面顺序倒退(regression)的雷全排干净,你手里必须得攥着一个从五湖四海搜刮来的文档测试库:得有增量保存搞出来的奇葩,得有被硬塞进封面的扫描件,还得有那些被各种工具按照千奇百怪的姿势线性化或是优化过对象图的 PDF。那个最初踩出这个 Bug 的破文档,必须给它在回归测试套件(regression suite)里头供起来,永远别想跑

HotPDF 组件 页面上扒全了所有用于页面操作的 API,这其中就包括了 CopyPageFromDocumentInsertPagesFromDocument,还有 MovePage