打開一份 Microsoft Word 或 Excel 產生的 PDF,翻閱它,沒有任何異常。將它載入 Delphi 程式中,讀回頁數,數字也是對的。然後在開啟加密的情況下重新儲存它,作業卻以 EListError 失敗,或者輸出的檔案打開時出現損壞的交互參照 (cross-reference) 警告。檔案從未損壞。它是一個混合參考 (hybrid-reference) 檔案,而正是那個讓擁有十五年歷史的檢視器能夠打開它的結構,打敗了一個過早停止讀取的載入器
這是通過了所有內部測試的 PDF 處理流程遇到無法來回讀寫 (round-trip) 的檔案時,最常見的方式之一。輸入都是內部產生的,所以它們從來都不是混合的。第一個混合檔案到來的日子,就是客戶轉寄一份從試算表匯出的發票的那一天
Word 和 Excel 實際寫入的內容
ISO 32000-1 在 §7.5.8.4 中描述了混合參考的佈局。一個想要使用 PDF 1.5 功能(例如物件串流),同時又想讓 PDF 1.4 閱讀器打開檔案的應用程式,會將交互參照資訊寫入兩次。有一個傳統的交互參照表,也就是在 PDF 1.4 之前結束每個 PDF 的固定寬度 ASCII 列,還有一個交互參照串流,用於索引其餘部分。傳統區段的預告 (trailer) 帶有 /XRefStm 條目,其值是該串流的位元組位移 (byte offset)
這種分工是刻意的。舊閱讀器必須存取的物件(包括目錄和頁面樹狀結構),可以從傳統表尋址。被摺疊進壓縮物件串流的物件在傳統表中標記為空閒 (free),帶有類型 f 的條目,所以 1.4 的閱讀器會直接跳過它們,永遠不會在它無法解析的結構上絆倒。它們的真實位置只存在於交互參照串流中。這類檔案的特徵在於它的尾部:一個簡短的傳統區段,通常只不過是 xref 後接一個 0 0 的子區段標頭,其預告指向實際復原資料所在的 /XRefStm
為何正確的頁數無法證明任何事
因為目錄和頁面樹狀結構是刻意要從傳統表中存取,一個只讀取該表的載入器會找到 /Root,走訪頁面樹狀結構,並報告正確的頁數。舊閱讀器所需的一切都存在,所以檔案看起來很健康。遺失的物件是那些被打包進物件串流的物件:AcroForm 欄位字典、標記 PDF (tagged-PDF) 結構元素,以及從未需要對舊版檢視器可見的小型字典的長尾
直到某些東西碰觸到這些物件時,您才會注意到這個缺口,而一次完整的重新儲存會碰觸所有的物件。走訪檔案以重新加密或重寫它,這項操作正是需要依序要求每個物件編號的操作,這就是為什麼症狀會在儲存時浮現,而不是載入時,距離其原因非常遙遠
陷阱在於一個看到 xref 就停止的偵測器
決定檔案如何索引的廉價方法是跟隨 startxref 並檢查它指向的第一個位元組。關鍵字 xref 意味著傳統表;一個串流物件意味著一個交互參照串流。這個測試對於任何致力於單一方案的檔案來說是正確的。對於一個混合檔案來說則是錯誤的,它的 startxref 瞄準一個傳統區段,其唯一目的是滿足舊閱讀器,而該區段預告中的 /XRefStm 才是檔案大部分實際被索引的地方。一個在遇到第一個 xref 就回傳 "classic" (傳統) 的偵測器,永遠不會讀取 /XRefStm,而每一個只存在於串流中的物件都會變成隱形的
var
Pdf: THotPDF;
PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf'); // count is correct
// inspect or edit the loaded document here
Pdf.SaveLoadedDocument('Invoice_secured.pdf'); // walks every object
finally
Pdf.Free;
end;
end;
有了提早離開的偵測器,載入看起來很好,而重新儲存則是那些缺席物件宣布自己存在的地方。修復方法不是在開頭讀取更多位元組;而是要辨識出混合的預告,並在決定檔案處理完畢之前跟隨 /XRefStm
合併順序是不可妥協的
一旦兩個索引都被讀取,它們只能往單一方向合併。交互參照串流必須先被合併,傳統條目則填補在其周圍。原因是格式核心的一個小欺騙。混合檔案在傳統表中將其壓縮物件標記為空閒,以便舊閱讀器忽略它們。一個遵循先見為贏 (first-seen-wins) 策略並先讀取傳統表的載入器,會將那些物件編號記錄為空閒,然後丟棄那些實際定位它們的串流條目,因為插槽已經被佔用了。反轉這個順序,來自串流的類型 2 條目(每一個都是物件串流編號加上一個索引),就會贏得它們原本應該擁有的插槽,而傳統條目則會安置在它們周圍
同樣的紀律也防範了較舊的版本復活已刪除的物件。漸進式更新透過 /Prev 向後鏈接,類型 0 的空閒條目是一個哨兵 (sentinel),表示較新的區段已經停用了該物件編號。在鏈條中較晚(較舊)的區段,絕不能被允許用過時的位置覆寫該哨兵。將首見視為空閒標記的權威,已刪除的物件就會保持刪除狀態;如果不加注意地處理,檔案自身的歷史就會復活最新版本已經移除的內容
這在 HotPDF 中意味著什麼
引擎會為您解析混合參考檔案,且在每一個必須解析交互參照資料的路徑上都會這麼做。使用 LoadFromFile 或 LoadFromStream 載入檔案,進行更改,並呼叫 SaveLoadedDocument;或者執行像是 EncryptFile 這樣的一次性操作,讀取輸入並寫出輸出。無論哪種方式,復原作業都會讀取 /XRefStm,將串流區段合併在傳統條目之前,並在寫入作業列舉它們之前解析那些存在於串流中的物件。AES-256 加密路徑是這個問題首次顯現的地方,因為加密一份檔案會重寫每一個物件,因此要求每一個物件都已經被定位
// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
'owner-secret', '', aes256, [prPrint, prFillAnnotations]);
在 API 上游值得帶走的細節是:來自 Word、Excel、PowerPoint,以及一長串「另存為 PDF」處理流程的檔案,通常都是混合的,所以一個您只針對自己產生器的輸出進行練習的載入器,可能永遠不會在測試中遇到混合檔案。用從真正的 Office 應用程式匯出的檔案作為您的測試輔助資料 (fixtures) 種子,而不僅僅是您自己的程式碼產生的檔案
檢查您懷疑的檔案
兩項檢查可以迅速解決這個問題。在十六進位檢視器中打開檔案,讀取最後一個 startxref 之後的位元組;混合檔案會顯示一個簡短的傳統區段,其預告字典包含 /XRefStm。或者比較完整解析報告的物件計數與 /Size 在預告中宣告的最高物件編號。巨大的缺口意味著物件隱藏在載入器尚未打開的串流中,這與稍後變成儲存時失敗的短缺是相同的
這個故事寫入端的部分(一開始是如何產生物件串流和壓縮交互參照的),在我們關於物件串流和漸進式更新的文章中有討論。當有疑問的混合檔案也非常龐大時,針對大型 PDF 工作流程的直接檔案 API 演練中的載入技術能讓您檢查它,而不需要將整個東西讀入記憶體。兩者自然地與這裡描述的復原技術配對,這些技術作為 Delphi 和 C++Builder 的 HotPDF 元件 的一部分,與本部落格其他地方涵蓋的載入、編輯、加密和簽章 API 搭配提供