技術文章

無 Pages 字典的 PDF:解析的影響

PDF Catalog(型錄)字典正好有一個必要的導覽鍵:/Pages;該鍵必須指向 /Pages 類型的間接物件,該物件進而持有 /Kids 陣列和頁面總數 /Count;拿走該指標,則沒有一個符合標準的檢視器能在檔案中定位單個頁面;ISO 32000-1 §7.7.2 在此點上是非常明確的:Catalog 應具有 /Pages 項目,且被參考的物件應具有 /Pages 類型;違反此需求的檔案不僅僅是不合規,它們在結構上已經損壞,導致大多數解析器難以處理

規範實際上是怎麼說的

一個最小的合規 PDF 至少有三個物件;物件 1 是 Catalog,物件 2 是 Pages 根節點,物件 3 及以後是個別的 Page 字典;Catalog 指向 Pages 根節點,Pages 根節點在 /Kids 中列出其子節點,每個 Page 都帶有指向其 /Parent 的反向參考;整個鏈結在設計上是雙向的,因此對於平衡樹,解析器可以從任一端開始並在 O(log n) 時間內遍歷到任何頁面

% Minimal conforming structure (ISO 32000-1 §7.7.2)
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>
endobj

3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 5 0 R /Resources << >> >>
endobj

4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj

Pages 樹可以嵌套;包含數千頁的文件通常會將頁面分組到同樣帶有 /Pages 類型的中間節點物件中,每個物件都有其自己的 /Kids 和反映其下方子樹的 /Count;根節點的 /Count 始終等於總頁數;該計數是檢視器在解析單個頁面之前在頁碼欄位中顯示的內容,因為從物件 2 讀取一個整數比遍歷整個樹要便宜得多

無 Pages 樹的檔案看起來像什麼

缺少 Pages 字典的檔案通常源自直接寫入頁面物件而未將其組裝成樹狀結構的 PDF 產生器,或者源自移除根節點但保留葉節點 Page 物件完好無損的損壞;此類檔案中的 Catalog 要麼完全缺少 /Pages 鍵,要麼持有對交叉引用表中已不存在的物件的引用

% Non-conforming: Catalog with no /Pages reference
1 0 obj
<< /Type /Catalog >>
endobj

% Page objects exist but are unreachable from the Catalog
5 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj

15 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 16 0 R /Resources << >> >>
endobj

25 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 26 0 R /Resources << >> >>
endobj

遵循規範的解析器將讀取 Catalog、嘗試解析 /Pages、找不到任何內容(或失效的參考),然後要麼引發錯誤要麼報告零頁;它絕不能做的是像檔案只有零頁那樣繼續執行並默許成功,這會產生一個空白輸出,對於自動化工具看起來是正確的,但對每個打開它的使用者來說卻是錯的

解析器為何當機

大多數 PDF 解析器在載入時會根據 Pages 根節點的 /Count 值來配置其內部頁面表;當該根節點不存在時,解析器要麼讀取到零、不配置任何內容,然後在任何程式碼第一次請求第 1 頁時對空指標進行取值,要麼讀取到垃圾資料並配置一個極其錯誤的緩衝區;這兩種結果都不體面;處理此類檔案時在當機記錄中顯示的 0x008E5D78 存取違規正是如此:頁面存取路徑內部的空指標取值,這是在解析器假設始終存在的結構缺失時觸發的

底層的設計假設是合理的;現存的絕大多數 PDF 都有 Pages 字典;為了節省幾條指令而跳過存在性檢查的解析器並非魯莽,它們是在針對常見情況進行最佳化;懲罰該最佳化的檔案非常罕見,以至於生產程式碼可能永遠不會遇到,直到遇到了,如果工程師沒有閱讀過 §7.7.2,此時的當機既是可重現的也是令人困惑的

在沒有 Pages 樹的情況下進行恢復

如果解析器必須處理這些檔案而不是拒絕它們,恢復將遵循一條可預測的路徑:掃描交叉引用表中的每個間接物件、收集具有 /Type /Page 的物件,並按物件編號對其進行排序;規範中並不保證物件編號順序與閱讀順序相符,但在實務中,省略了Pages 樹的產生器往往會按順序輸出頁面,因此物件編號順序多數情況下是正確的

檢查本身很便宜;在遍歷 Catalog 的 /Pages 指標之前,確認該指標存在、它解析為真實物件,且解析後物件的 /Type 等於 /Pages;如果這三個條件中的任何一個失敗,則降級到線性掃描;對於大型文件,掃描比樹遍歷慢,因為它讀取每個物件的標頭而不是遵循平衡路徑,但它可行,且對於已經損壞的檔案,正確性重於速度

線性掃描無法自動解決的一個邊緣情況是:頁面排序;在沒有 /Kids 陣列來定義序列的情況下,規範並未定義「正確的」順序;物件編號順序是實用的預設設定,如果該檔案足夠重要以至於需要仔細處理,檢查 Page 物件是否帶有明確的 /StructParents 或暗示閱讀順序的註解參考是值得付出額外工作的

對 PDF 產生器的啟示

對於任何撰寫 PDF 產生器而非解析器的人來說,教訓是深刻的:在關閉檔案之前,始終輸出 Pages 根節點;在規範的任何修訂版中,沒有 /Pages 項目的 Catalog 都不是有效的 PDF;即時建置頁面物件並在完成時組裝樹的產生器(大多數序列流寫入器採用的方法),只要完成步驟實際執行就沒有問題;常見的失敗模式是異常或提前傳回,在尾標完成之前中止了寫入,留下了一個可以在某些檢視器中開啟(這些檢視器具有恢復啟發式方法),但在其他檢視器中失敗(這些檢視器沒有)的檔案

PDF/A 和 PDF/UA 對頁面樹提出了超出基本規範要求的額外約束,但兩者都未放寬 /Pages 需求;在達到設定檔特定規則之前,檢查是否符合 ISO 19005 或 ISO 14289 的驗證器就會將缺少的 Pages 字典擷取為基本規範違規