技術文章

PDF 頁面順序陷阱:物理結構與邏輯結構

經典的症狀看起來像個差一錯誤:頁面複製程序對一份三頁文件要求第 1 頁,卻始終拿到第 2 頁。索引邏輯檢查過了:呼叫用的是 0 起算的邏輯索引,算術正確,邊界條件也沒問題。但每次輸出的都是錯的頁面

原因根本不在複製程式碼裡,而在載入器怎麼建出它的頁面陣列——這是 PDF 解析裡最容易踩的陷阱之一

PDF 頁面順序概念:物理順序與邏輯順序的差異
PDF 頁面順序:Pages 樹中的 /Kids 陣列定義了邏輯順序,與物件在檔案中的編號或儲存方式無關

兩種排序,一個混淆的來源

PDF 檔案是間接物件的集合,每個物件都由一個物件編號來識別;檔案結構並未強制要求這些編號必須反映閱讀順序;物件 1 可以容納第 2 頁,物件 20 可以容納第 1 頁;實際定義閱讀順序的是頁面樹:這是一個 /Pages 字典的階層結構,其 /Kids 陣列列出了檢視器應顯示的頁面參考順序 (ISO 32000-1 §7.7.3)

下面這棵頁面樹正好踩中這個陷阱:

{ 頁面樹根節點,物件 16 }
16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R   { 邏輯第 1 頁 }
         1 0 R    { 邏輯第 2 頁 }
         4 0 R]   { 邏輯第 3 頁 }
>>
endobj

該檔案在位元組串流中恰好將物件 1 和物件 4 列在物件 20 之前;任何以檔案順序反覆運算間接物件並在發現頁面類型字典時將其標記到 PageArr 中的解析器,最終會得到索引 0 為物件 1、索引 1 為物件 4,以及索引 2 為物件 20;邏輯上的第 1 頁位於 PageArr[2],請求頁面索引 0 反而會擷取邏輯上的第 2 頁

圖解對比:Delphi PDF 程式庫建立頁面陣列時,物件掃描順序與 Kids 陣列邏輯的差異
物件掃描載入按檔案位置蓋章 PageArr,而 PDF 頁序只由 /Kids 定義,每個索引請求因此錯位一格

證實假設

在動任何程式碼之前,先把不符證明出來,而不是憑假設行事。qpdf 命令列工具讓這件事變得很直接:

{ shell }
qpdf --show-pages input.pdf
{ 輸出揭示 Kids 順序:20 0 R,接著 1 0 R,再接著 4 0 R }

qpdf --show-object="16 0 R" input.pdf
{ 顯示依閱讀順序排列、含 /Kids 的 Pages 字典 }

單獨擷取每一頁並比較檔案大小,證實了這份對應關係:物件掃描的 PageArr[0] 產生的內容屬於邏輯第 2 頁,而 PageArr[2] 持有的是邏輯第 1 頁。循環偏移就是決定性證據。這也解釋了為什麼這個症狀會出現在各種不同的來源文件上:只要哪份 PDF 的頁面物件碰巧拿到比某個更早的邏輯頁面更低的物件編號,它就會中招

診斷時序圖:qpdf 揭露頁面複製程序背後 /Kids 頁面順序與實體物件順序分歧的過程
驗證把懷疑變成證據:這份文件的頁面樹順序與實體物件順序出現分歧

PDF 會落到這個狀態,原因很直接。增量更新會保留它所改物件的物件編號,但它新增的任何東西——比方說插在最前面的封面頁——都會拿到一個比檔案裡既有一切更高的新編號,而它的參照卻排進 Kids 陣列的開頭。合併或重組文件的工具按自己方便的順序重新編號物件。有些產生器乾脆按方便內容串流的順序寫頁面,而不是按邏輯頁面順序。PDF 格式並不要求它們非得照規矩來

正確做法:遵循 Kids 陣列

建立頁面清單的正確方式,是從型錄根沿著 /Kids 鏈結走訪,而不是掃描間接物件。走訪必須遞迴,因為大型文件通常把頁面拆散在中間的 /Pages 節點下;它還該防著格式錯亂、回指到自己祖先的樹。下面的示意程式裡,TPdfDict 與 TPdfArray 代表您解析器所用的字典與陣列型別:

type
  TPageList = array of TPdfDict;

procedure CollectPages(Node: TPdfDict; var Pages: TPageList;
  Depth: Integer = 0);
var
  Kids: TPdfArray;
  Child: TPdfDict;
  I: Integer;
begin
  if Depth > 64 then  // 迴圈或病態深的樹
    raise EPdfError.Create('Page tree too deep or cyclic');
  Kids := Node.GetArray('Kids');
  if Kids = nil then
    Exit;
  for I := 0 to Kids.Count - 1 do
  begin
    Child := Kids.ResolveDict(I);  // 跟著間接參照走
    if Child = nil then
      Continue;
    if Child.GetName('Type') = 'Pages' then
      CollectPages(Child, Pages, Depth + 1)  // 中間節點
    else
    begin
      SetLength(Pages, Length(Pages) + 1);   // 葉子:顯示順序裡的下一頁
      Pages[High(Pages)] := Child;
    end;
  end;
end;

// 用法:從型錄的 /Pages 條目開始
SetLength(Pages, 0);
CollectPages(Catalog.ResolveDict('Pages'), Pages);
if Length(Pages) <> Catalog.ResolveDict('Pages').GetInteger('Count') then
  LogWarning('Page tree /Count disagrees with the pages actually found');

有兩個細節是刻意的。缺少 /Type 條目的葉子仍會被當成頁面,因為有些寫入器就是會省略它,而檢視器照樣開得了那些檔案。最後那個 /Count 比對幾乎零成本,卻能在呼叫者拿索引跳進樹裡之前,攔下被截斷或手工改過的樹

如果您是用 HotPDF 載入文件,這份排序在文件載入時就已經解析好了:頁面索引遵循頁面樹的顯示順序,包含巢狀的 /Pages 節點,所以索引 0 永遠是檢視器顯示的第一頁。上面的走訪程式,是給直接解析 PDF 結構的您自己的程式碼用的

以邏輯索引複製:CopyPageFromDocument

因為 HotPDF 在載入時就解析好頁面順序,邏輯頁面索引的意義與呼叫者的預期一致。高階的 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;

    { 複製邏輯頁面 0(使用者看到的第一頁) }
    Dest.CopyPageFromDocument(Source, 0, 0);

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

因為索引就是顯示順序索引,即使面對物理順序與邏輯順序分歧的文件,CopyPageFromDocument 的行為也正確。批次操作方面,InsertPagesFromDocument 接受邏輯索引陣列,一次跑完就把它們全部複製

這揭示了關於 PDF 解析的什麼內容

PDF 規範非常明確:邏輯頁面順序由頁面樹的 /Kids 陣列定義,而不是由物件編號或位元組偏移量定義 (ISO 32000-1 §7.7.3.2);任何使用不同排序作為捷徑的解析器,都會在其看到的大多數文件上產生正確的結果,因為大多數產生器以自然順序寫入頁面並分配順序物件編號;該錯誤會一直隱藏,直到有人載入了被增量編輯、被另一個工具重新組織或由選擇了不同版面配置的軟體所產生的 PDF

只拿自己產生的 PDF 來測,會完全漏掉這一類問題。頁面順序的處理需要一份來源多元的文件測試語料庫:增量儲存的、插入了封面頁的掃描文件,以及由以不同方式線性化或最佳化物件圖的工具產生的 PDF。任何物理順序與邏輯順序分歧的文件,都值得在語料庫裡有個永久的位置

HotPDF Delphi Component 的產品頁涵蓋頁面操作的完整 API,包括 CopyPageFromDocument、InsertPagesFromDocument 與 MovePage