技術文章

PDF 頁面順序:頁面樹如何控制頁面順序

物件編號 1 並不是第 1 頁;與該格式的其他任何方面相比,這一個事實讓更多的 PDF 處理程式碼出錯,而要了解原因,需要跳過檢視器向您展示的內容,深入研究檢視器實際讀取的物件圖

PDF 檔案是已編號的間接物件的集合;每個物件都帶有一個物件編號和世代號,其他物件使用寫作 N G R 的引用指向它:3 0 R 表示物件 3 的目前版本;頁面是這些物件之一,但它們的顯示順序與它們在檔案中的位置或它們所帶的編號無關;顯示順序完全由 /Pages 樹決定,這是一個以文件目錄為根的連結結構;如果您忽略樹並按數值掃描物件,那麼在很大一部分實際檔案中,您將以錯誤的順序組合頁面

頁面樹:實際設定順序的部分

每個 PDF 都以文件目錄開頭(ISO 32000-2 §7.7.2);目錄包含一個指向頁面樹根節點的 /Pages 項目;該根節點是一個具有 /Type /Pages、由間接引用組成的 /Kids 陣列,以及給出其下葉頁面總數的 /Count 的字典;顯示順序就是該樹的深度優先、由左至右的走訪,就這麼簡單

一個最小的三頁檔案可以使這更加具體:

%PDF-1.7

1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

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

% Object 4 is stored third in the file but is page 2 in display order
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% Object 9 is stored fourth but is page 3
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 10 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% Object 20 is stored last but is page 1; Kids[0] decides, not object number
20 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 21 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

/Kids 陣列讀作 [20 0 R 4 0 R 9 0 R],因此物件 20 是第 1 頁,物件 4 是第 2 頁,物件 9 是第 3 頁;物件編號表格不重要;在此檔案上,任何按數值順序反覆運算物件並收集帶有 /Type /Page 的物件的程式碼都將產生錯誤的順序

為什麼產生器會產生非循序的配置?有幾個原因;在寫入內容之前為所有頁面預先分配物件編號的函式庫,會按建立順序為它們編號,然後以適合序列化器的任何順序寫入實際的位元組;將文件縫合在一起的合併工具會為每個來源文件的物件重新編號以避免衝突,重新編號的頁面物件最終會分散在合併後的物件表中,而新的根 /Kids 陣列則保存正確的顯示順序;增量更新會在檔案末尾附加帶有新編號的新物件,因此即使它在顯示順序中屬於位置 1,作為修訂版本新增的頁面也會位於位元組串流的末尾附近

扁平樹與巢狀子樹

規格書允許頁面樹有兩種形狀;簡單的產生器會產生扁平的結構:一個根 /Pages 節點,其 /Kids 陣列只包含 /Page 葉物件;這很容易走訪:一級深度,一次通過

大型文件通常會使用平衡樹;根 /Pages 節點的 /Kids 陣列包含中間 /Pages 節點,每個節點又各自包含自己的 /Kids 陣列;每個中間節點上的 /Count 報告了其子樹中的葉頁面總數,因此檢視器在按索引跳轉到某一頁時可以跳過整個子樹,而不用解析每個物件;一個結構為平衡樹、每個葉節點有 10 頁的 1,000 頁文件,可以透過三或四次字典查閱來進行二元搜尋以定位第 750 頁,而不是掃描 750 個 /Kids 項目

處理程式碼的後果是:您不能假設 /Kids 的第一層包含 /Page 物件;必須檢查每個子項目;如果它的 /Type/Pages,則遞迴進入其中;如果它的 /Type/Page,它就是一片葉子;在產生器選擇巢狀的任何文件上,停在第一層會靜默地丟棄整個子樹;關於為什麼編寫器最初選擇深層樹、扁平化工具放棄了什麼,以及在實踐中 /Count 損壞是如何發生的,在我們的配套文章 頁面樹形狀、展開與 /Count 完整性 中有所介紹

繼承的頁面屬性

頁面樹還攜帶了資源共享機制;某些頁面屬性(/MediaBox/CropBox/Resources/Rotate)是可繼承的(ISO 32000-2 §7.7.3.4);如果 /Page 字典省略了其中之一,閱讀器會沿著 /Parent 鏈向上尋找,直到找到該屬性或到達根節點為止;將共享字型字典放在根 /Pages 節點中,而不是將其複製到每個葉頁面中,對於全程使用相同字型的文件來說,可以顯著減小檔案大小

繼承規則為讀取頁面屬性的程式碼帶來了微妙之處;直接從 /Page 物件讀取 /MediaBox 並將遺失的鍵視為錯誤是錯誤的,因為該鍵可能只是被繼承了;正確解析頁面幾何形狀的程式碼必須遵循父鏈;它還需要一個循環保護:損壞的檔案可能有一個指向已造訪節點的 /Parent 引用,如果沒有已造訪物件的檢查,這將會無限循環

xref 表與交叉引用串流

間接物件的查閱是透過交叉引用表(或其後繼者,即 PDF 1.5 中引入的交叉引用串流)進行的;xref 將每個物件編號對應到檔案中的位元組偏移量;合規的閱讀器使用 xref 直接跳轉到任何物件,它不會循序掃描檔案;這種隨機存取設計使得快速跳頁成為可能:檢視器讀取目錄、透過 xref 解析 /Pages 引用、讀取根 /Pages 節點、解析 /Kids 項目,依此類推,只觸及它需要的物件

增量更新在檔案末尾新增一個新的 xref 區段,其 trailer 鏈接回前一個區段;在修訂版本中更新的物件在附加的 xref 區段中獲得一個新項目,原始位元組保持不變但被取代;這就是即使在新增註記或表單填寫修訂版本後,已數位簽署的 PDF 仍可驗證的原因:已簽署的位元組範圍永遠不會被觸動,而新內容存在於附加的區段中;頁面樹也可以更新,因此修訂版本中的頁面新增或刪除會產生帶有修訂後 /Kids 陣列的新 /Pages 根,而舊的根物件仍然佔用其在檔案中的原始位置;線性化(網頁最佳化)檔案增加了一個位元組配置的變化:第 1 頁的物件被實體移到檔案的前端,以便檢視器可以在其餘部分仍在下載時顯示第一頁,但頁面樹仍然是順序的唯一權威 —— 只有 xref 中記錄的偏移量會發生變化

不用樹走訪會出什麼錯

物件掃描方式的失敗模式是悄悄發生的;輸出的文件看起來合理:它有正確的頁數,且每頁都包含可識別的內容;順序只是錯了,而這種錯誤方式取決於產生器、修訂次數以及是否有任何頁面是從外部來源合併而來;由單一工具產生的檔案測試語料庫可能完全通過,而來自不同工具或合併工作流程的檔案將會失敗;這種不一致性正是啟發式修復永遠不成立的原因;要詳細了解真實客戶文件中發生的此失敗(症狀、誤診和走訪修復),請參閱 我們的頁面順序偵錯案例研究

增量更新檔案特別容易發生這種情況,因為在後續修訂中新增或重新排列的頁面帶有較高的物件編號,而顯示順序是由更新後的 /Kids 陣列控制的;按數值順序處理物件的掃描會將這些編號較晚的頁面置於末尾,而不管樹指出它們屬於何處

修復方法並不複雜;從目錄開始,解析 /Pages 引用,遞迴走訪 /Kids 陣列,並按您遇到的順序輸出葉子;這就是定義上的顯示順序,與物件編號、位元組偏移量或檔案結構無關;大多數成熟的 PDF 函式庫都公開了頁面計數和已索引的頁面存取器,這些存取器已經正確地執行了此操作,風險在於繞過函式庫的頁面模型並直接觸及物件層的程式碼中

一個值得明確處理的結構異常:中間 /Pages 節點上的 /Count 值在格式損壞的檔案中可能是錯誤的;當計數被低估時,信任 /Count 來進行邊界檢查,進而在完成完整走訪前停止,會靜默地省略頁面;僅將 /Count 用於容量預分配或二元搜尋的效能提示,並從走訪中得出實際計數,是對於重要文件更安全的模式

 下一篇文章