技術文章

PDF Page Tree Shape: Fan-Out, Flattening, and /Count Integrity

我們在 PDF 頁面排序 的配套說明中介紹了基本規則:顯示順序來自 /Pages 樹中 /Kids 陣列的深度優先、由左至右的走訪,而絕非來自物件編號;本文從另一個角度 —— 樹的形狀來剖析它;當單一扁平陣列完全合法時,為什麼成熟的 PDF 寫入器仍然會輸出中間節點的階層結構?當工具扁平化或重建樹時,實際上改變了什麼?而當使整個結構快速運作的 /Count 記錄不再說真話時,又會發生什麼事

分支係數是一項效能決策

沒有與任何規定強迫編寫器必須進行巢狀設計;一個擁有單一根 /Pages 節點和單個 /Kids 陣列中 10,000 個葉引用的 10,000 頁文件完全符合規格;然而,PDF 參考指南建議對大型文件使用平衡樹,且主流產生器遵循該建議並採用溫和的分支係數,每個中間節點通常有幾十個子節點

其原因在於檢視器在顯示任何內容之前必須讀取什麼;想像一下直接跳轉到該 10,000 頁檔案的第 8,214 頁;在扁平樹中,檢視器首先必須解析根節點,而該根節點是一個巨大的陣列:按每個間接引用大約八個位元組計算,這是一個 80 KB 的物件,在解析第 8,213 個項目之前,必須對其進行端到端的語彙分析;在分支係數為 32 的平衡樹中,相同的跳轉會讀取根節點,比較執行中的 /Count 總數以選擇正確的子節點並向下搜尋 —— 總共只需讀取三或四個小字典,每個字典只有幾百位元組;這正是該樹旨在提供的 O(log n) 隨機存取,也是中間節點上存在 /Count 的全部原因:它允許閱讀器跳過整個子樹,而不用開啟其中的任何一個物件

樹的形狀也決定了編輯的成本;插入一個頁面的增量更新必須重寫 /Kids/Count 發生變更的每個節點,這意味著從新葉節點的父節點一直到根節點的路徑;在平衡樹中,該路徑是附加到檔案中的少數幾個小字典;而在扁平樹中,「路徑」是單個巨大的根陣列,在每次修訂時都需要完整複製;一個經歷了三十次檢閱和註記循環的合約,最終可能會在它的位元組串流中攜帶同一個 80 KB 陣列的三十個被取代的複本

內部節點攜帶繼承的屬性

內部節點不僅僅用於路由;四個可繼承的頁面屬性(/Resources/MediaBox/CropBox/Rotate)可以提升到任何 /Pages 節點上,並適用於其下的每個葉節點,除非後代節點覆寫了它們;編寫一個帶有橫向附錄報告的編寫器,可以在樹本身中表達該版面配置:

5 0 obj   % document root
<< /Type /Pages /Count 6 /Kids [6 0 R  7 0 R] >>
endobj

6 0 obj   % report body: portrait A4, body font
<< /Type /Pages /Parent 5 0 R /Count 3
   /Kids [30 0 R  31 0 R  32 0 R]
   /MediaBox [0 0 595 842]
   /Resources << /Font << /F1 8 0 R >> >> >>
endobj

7 0 obj   % appendix: landscape A4, rotated, its own font
<< /Type /Pages /Parent 5 0 R /Count 3
   /Kids [40 0 R  41 0 R  42 0 R]
   /MediaBox [0 0 842 595] /Rotate 90
   /Resources << /Font << /F2 9 0 R >> >> >>
endobj

40 0 obj  % appendix page: inherits size, rotation, fonts
<< /Type /Page /Parent 7 0 R /Contents 43 0 R >>
endobj

物件 40 到 42 幾乎是空的;它們的頁面大小、旋轉和字型資源都透過繼承自節點 7 獲得,這使得檔案保持緊湊且易於自行維護:在附錄節點下新增第四頁,它會自動呈現為橫向

相同的機制也帶來了經典的頁面移動風險;假設某個工具透過編輯兩個 /Kids 陣列並將 /Parent 重新指向節點 6,將物件 40 移動到報告主體中;該移動在結構上是有效的,但物件 40 現在繼承了直向的 /MediaBox、無旋轉和字型 /F1 —— 而它的內容串流仍然選擇 /F2,此時 /F2 已經無法解析;頁面在單次編輯中縮小、取消旋轉並失去了文字;因此,強健的重新排序程式碼在重新指定父節點之前,會將所有四個可繼承屬性的解析值具體化寫入到頁面字典中;如果您曾經在編輯器中拖曳頁面並看著它改變大小或方向,這就是您所目睹的機制

扁平化:合法、常見,但偶爾成本高昂

許多工具會反其道而行;簡單的編寫器會輸出單層的樹,編因為這很簡單,且許多合併與分割公用程式會將它們讀取的任何樹重建為一個扁平的 /Kids 陣列,因為產生平衡的結構需要額外的工作,而扁平輸出總是合規的;正確的重建必須同時解析繼承:分頁繼承的每個屬性都必須複製到分頁上,或者在整個文件中一致時提升到新的根節點 —— 否則輸出會改變幾何形狀,其情況與頁面移動案例完全相同

對於典型文件,扁平化是無害的;它在規模擴大時會帶來傷害,正如已經描述的兩種方式:根陣列變成一個巨大的物件,每次開啟和每次跳頁都必須完整解析它,且每次結構編輯都會重寫整個物件;扁平化所沒有破壞的是透過間接引用進行的共享 —— 所有 10,000 頁都指向同一個 /Resources 字典物件的扁平樹仍然完成了去重;所失去的僅僅是將該項目留空並讓祖先節點提供它的選項

當 /Count 說謊時

/Count 是純粹的記錄簿:它必須等於節點子樹中的葉頁面數量,而檔案格式中沒有任何機制可以強制執行這一點;在實際中看到的大多數說謊的計數,都可以歸結為兩種損壞模式

第一種是增量更新留下的過期計數;編輯器插入一個頁面,使用新的 /Kids 和更新後的 /Count 重寫直接父節點,並將兩者附加到檔案中 —— 但從不碰觸祖先節點:

% Original revision
12 0 obj
<< /Type /Pages /Count 9 /Kids [13 0 R  14 0 R  15 0 R] >>
endobj

14 0 obj
<< /Type /Pages /Parent 12 0 R /Count 3
   /Kids [50 0 R  51 0 R  52 0 R] >>
endobj

% Appended revision: one page inserted into the middle branch.
% Object 14 is superseded; object 12 is never rewritten
14 0 obj
<< /Type /Pages /Parent 12 0 R /Count 4
   /Kids [50 0 R  51 0 R  90 0 R  52 0 R] >>
endobj

該樹現在包含十個葉節點,但根節點仍然顯示九個;信任根節點的檢視器會在它的頁面計數器中回報九頁;使用內部計數來二元搜尋跳頁的檢視器,會為插入點之後的每一頁計算出錯誤的索引;而完整的走訪會找到十個頁面;一個檔案,給出了三種不同的答案

第二種模式是根本不可能正確的計數:負數、已填充節點上的零,或者荒謬的巨大數值;這些來自模糊測試、傳輸損壞,有時也來自編輯器中的算術 Bug;它們對於信任 /Count 來進行記憶體分配的程式碼特別危險 —— 根據 -3 的 /Count 來調整陣列大小最多會引發範圍錯誤,而根據二十億的 /Count 來執行此操作則會導致拒絕服務的記憶體分配;該值是不可信的輸入,就像檔案中的其他數字一樣

剖析器對此分成了兩個陣營;嚴格的取用端(發行前檢查工具、PDF/A 驗證器、封存管線)會將 /Count 與走訪結果進行對比,並拒絕或標記檔案;互動式檢視器幾乎無一例外地寬容:它們進行走訪,得出真實的計數,並默默忽略儲存的計數,這正是為什麼一個計數過期的檔案可以流通多年而無人抱怨,直到它在某些自動化工作流程中遇到更嚴格的剖析器為止;對於函式庫程式碼,防禦性的折衷方案是將 /Count 視為提示 —— 用於預分配記憶體,並在驗證後用於跳過子樹 —— 同時讓走訪仍然是真實來源

關於走訪演算法本身、繼承查閱規則以及從目錄到分頁的步驟,請先閱讀 頁面排序說明;要了解當真實的客戶文件到達生產環境程式碼時這些失敗模式看起來是什麼樣子,請閱讀 頁面順序偵錯案例研究,它追蹤了分頁混亂事件從症狀到根本原因的過程

適用於 Delphi 和 C++Builder 的 HotPDF 元件 在內部處理了所有這些問題:它走訪任何深度的巢狀樹、在複製或移動頁面時解析繼承的屬性,並將 /Count 與實際的葉頁面計數進行驗證,而不是信任它,因此其 API 中的頁面索引始終意味著邏輯頁面