Technical Article

在 Delphi 中載入來自 Word 和 Excel 的混合參照 PDF 檔案

開啟 Microsoft Word 或 Excel 產生的 PDF,翻閱頁面,沒有任何異常;將其載入 Delphi 程式,讀取頁數,數量也正確;接著在啟用加密的情況下重新儲存,工作卻因為 EListError 而失敗,或者輸出檔案開啟時出現交叉參照損壞的警告;該檔案從未損壞;它是一個混合參照檔案,而讓有著 15 年歷史的檢視器得以開啟它的結構,正是擊敗過早停止讀取的載入器的結構

這是通過所有內部測試的 PDF 管線遇到無法來回轉換之檔案的最常見情況之一;輸入檔案都是在內部產生的,因此從來不是混合型的;第一個混合檔案會在客戶轉發從試算表匯出的發票那天送達

Word 和 Excel 實際寫入的內容

ISO 32000-1 在 §7.5.8.4 中描述了混合參照版面配置;想要擁有 PDF 1.5 功能(例如物件串流),但仍要讓 PDF 1.4 讀取器開啟檔案的應用程式,會將交叉參照資訊寫入兩次;包括一個傳統的交叉參照表,即在 1.4 版本之前結束每個 PDF 的固定寬度 ASCII 列,以及一個用來索引其餘內容的交叉參照串流;傳統區段的結尾(Trailer)攜帶一個 /XRefStm 項目,其值為該串流的位元組位移量

這種分工是有意為之的;舊版讀取器必須觸及的物件(其中包括型錄和頁面樹)可以從傳統表定址;摺疊到壓縮物件串流中的物件在傳統表中被標記為空閒(帶有型態 f 項目),因此 1.4 讀取器會直接跳過它們,而不會卡在無法解析的結構上;它們的實際位置僅存在於交叉參照串流中;此類檔案的特徵在於其尾部:一個簡短的傳統區段,通常只是 xref 後接 0 0 子區段標頭,其結尾指向實際復原資料所在的 /XRefStm

為什麼正確的頁數不能證明任何事情

因為型錄和頁面樹是有意設計成可從傳統表連入的,僅讀取該表的載入器會找到 /Root,遍歷頁面樹,並回報正確的頁數;舊讀取器所需的一切都存在,因此檔案看起來是健康的;遺失的物件是打包到物件串流中的物件:AcroForm 欄位字典、標記 PDF 的結構元素,以及不需要對舊版檢視器可見的長尾小字典

在某些東西觸及 these 物件之前,您不會注意到這個差距,而完整重新儲存會觸及所有物件;遍歷檔案以重新加密或重寫,正是依序要求每個物件編號的操作,這就是症狀出現在儲存時而不是載入時(遠離其原因)的原因

陷阱是偵測器看到 xref 就停止

判斷檔案如何被索引的廉價方法是遵循 startxref 並檢查它指向的第一個位元組;關鍵字 xref 代表傳統表;串流物件代表交叉參照串流;對於採用單一配置的任何檔案,此測試都是正確的;但對於混合檔案,它是錯誤的,混合檔案的 startxref 指向傳統區段 the 唯一目的是為了滿足舊版讀取器,而該區段結尾中的 /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

合併順序不可妥協

一旦讀取了這兩個索引,它們只能朝一個方向合併;交叉參照串流必須先合併,傳統項目則在其周圍填入;原因在於格式核心的微小欺騙;混合檔案在傳統表中將其壓縮物件標記為空閒,以便舊版讀取器忽略它們;遵循「先見者獲勝」原則並先讀取傳統表的載入器會將這些物件編號記錄為空閒,然後捨棄實際定位它們的串流項目,因為位置已被佔用;反轉順序,來自串流的型態 2 項目(每個項目是一個物件串流編號加上一個索引)將贏得它們原本應該擁有的位置,而傳統項目則配置在它們周圍

相同的規範可以防止較舊的修訂版本復活已刪除的物件;增量更新透過 /Prev 向後連結,而型態 0 的空閒項目是一個哨兵(Sentinel),表示較新的區段已停用某個物件編號;鏈結中較晚、較舊的區段絕不能被允許用過時的位置覆寫該哨兵;將先見者視為空閒標記的權威,已刪除的物件就會保持刪除狀態;若不小心對待,檔案自身的歷史記錄就會重現最新修訂版所移除的內容

這在 HotPDF 中代表的意義

引擎會為您解析混合參照檔案,且在必須解析交叉參照資料的每個路徑上都會這樣做;使用 LoadFromFileLoadFromStream 載入檔案,進行變更,然後呼叫 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」管線的檔案通常是混合型的,因此您僅針對自己產生器的輸出進行測試 the 載入器在測試中可能永遠不會遇到這種檔案;在您的測試配件中植入從真實 Office 應用程式匯出的檔案,而不僅僅是您自己的程式碼所產生的檔案

檢查您懷疑的檔案

兩項檢查可以快速解決問題;在十六進位檢視中開啟檔案並讀取最後一個 startxref 之後的位元組;混合檔案會顯示一個簡短的傳統區段,其結尾字典包含 /XRefStm;或者將完整解析回報的物件計數與結尾中 /Size 宣告的最高物件編號進行比較;巨大的差距意味著物件正隱藏在載入器尚未開啟的串流中,這正是稍後轉化為儲存時失敗的相同不足

這個故事的寫入器端(最初如何產生物件串流和壓縮的交叉參照)在我們關於物件串流和增量更新的文章中有所介紹;當討論中的混合檔案也非常大時,大型 PDF 工作流程的 Direct File API 逐步說明中的載入技術可讓您在不將整個檔案讀入記憶體的情況下進行檢查;兩者都與此處描述的復原自然結合,這些工作作為 Delphi 和 C++Builder 的 HotPDF 元件的一部分隨附,並與本部落格其他地方介紹的載入、編輯、加密和簽章 API 搭配