技術文章

在 Delphi 中載入 Word 與 Excel 的混合參照 PDF

打開一份 Microsoft Word 或 Excel 產出的 PDF,一頁一頁翻過去,沒有什麼看起來不對勁。把它載入一個 Delphi 程式、把頁數讀回來,數字也是對的。然後開著加密把它重新存檔,這項工作就以一個 EListError 失敗,或是輸出打開後跳出交叉參照損壞的警告。那個檔案從來就沒壞。它是一份混合參照檔案,而那個讓十五年前的檢視器打得開它的結構,正是那個讓一個讀太早就停下來的載入器潰敗的結構

這是一條通過了所有內部測試的 PDF 管線,遇上一個它無法完整往返之檔案的最常見途徑之一。輸入全是自家產生的,所以從來都不是混合式。第一份混合式檔案抵達的那天,是某個客戶轉寄了一張從試算表匯出的發票

Word 與 Excel 實際寫出了什麼

ISO 32000-1 在 §7.5.8.4 描述了混合參照的版面。一個既想要物件串流之類的 PDF 1.5 功能、又想讓 PDF 1.4 閱讀器打得開檔案的應用程式,會把交叉參照資訊寫兩份。有一張傳統的交叉參照表,也就是直到 1.4 版為止每份 PDF 結尾都有的那些固定寬度 ASCII 列,另外還有一道索引其餘部分的交叉參照串流。傳統區段的 trailer 帶著一個 /XRefStm 項目,其值就是那道串流的位元組位移

這樣的分工是刻意的。舊閱讀器必須搆得到的物件,其中包括目錄與頁面樹,都能從傳統表定址得到。那些被折進壓縮物件串流的物件,在傳統表裡以型別 f 的項目標記為 free,於是 1.4 閱讀器會直接跳過它們,永遠不會絆到一個它剖析不了的結構。它們真正的位置只住在交叉參照串流裡。這種檔案的特徵在它的尾巴:一段很短的傳統區段,常常只不過是 xref 後面跟著一個 0 0 子區段標頭,而它的 trailer 指向 /XRefStm,真正的復原資料就坐在那裡

HotPDF 圖解一份 Word 或 Excel 混合參照 PDF 的尾巴,傳統 xref trailer 帶著 /XRefStm 87325,回指那道索引了表單欄位與標籤化結構的交叉參照串流,而停在傳統表的載入器看不到它們
混合式尾巴留著一張象徵性的傳統表,它唯一的酬載就是 /XRefStm 位移,而串流那一側才握著真正的物件索引

為什麼頁數正確什麼都證明不了

因為目錄與頁面樹是刻意設計成從傳統表搆得到的,所以一個只讀那張表的載入器會找到 /Root、走完頁面樹,並回報正確的頁數。舊閱讀器需要的一切都在,所以檔案看起來很健康。不見了的那些物件,是被打包進物件串流的那些:AcroForm 欄位字典、標籤化 PDF 的結構元素,以及那一長串從來不必讓舊式檢視器看見的小字典

在有東西碰到那些物件之前,您不會注意到這道缺口,而一次完整的重新存檔會碰到它們全部。走訪整份文件去重新加密或重寫它,正是那個依序索取每一個物件編號的操作,這也就是為什麼症狀出現在存檔時而非載入時,離它的成因很遠

陷阱是一個看到 xref 就停下來的偵測器

要判斷一個檔案是怎麼建索引的,最便宜的做法是跟著 startxref 走,並檢視它所指向的頭幾個位元組。關鍵字 xref 代表傳統表;一個串流物件代表交叉參照串流。這項測試對任何只採用一種方案的檔案都正確。對混合式檔案則是錯的,它的 startxref 瞄準一段傳統區段,唯一的目的就是滿足舊閱讀器,而那段區段的 trailer 裡的 /XRefStm,才是文件大半真正建索引的地方。一個在遇到第一個 xref 就回報「傳統」的偵測器,永遠不會去讀 /XRefStm,於是每一個只住在串流裡的物件都變成隱形

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // 頁數是正確的
    // 在這裡檢視或編輯已載入的文件
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // 會走訪每一個物件
  finally
    Pdf.Free;
  end;
end;

裝著那個提早退出的偵測器,載入看起來沒事,而重新存檔才是那些缺席物件出面自報的地方。修法不是在開頭多讀幾個位元組;而是要認出混合式的 trailer,並在斷定這個檔案讀完之前先跟著 /XRefStm

合併順序沒得商量

兩份索引都讀進來之後,它們只能朝一個方向合併。交叉參照串流必須先合併,再把傳統項目填在它周圍。理由是這個格式核心處那個小小的欺瞞。混合式檔案把它的壓縮物件在傳統表裡標成 free,好讓舊閱讀器忽略它們。一個奉行先到先贏政策、又先讀傳統表的載入器,會把那些物件編號記成 free,然後丟掉那些真正定位它們的串流項目,因為位置已經被佔走了。把順序倒過來,來自串流的型別 2 項目,每一個都是一個物件串流編號加一個索引,就贏得它們本該擁有的位置,而傳統項目就在它們周圍落定

同樣這套紀律也防止一個較舊的修訂版把一個已刪除的物件復活。增量更新透過 /Prev 往回串接,而一個型別 0 的 free 項目是一個哨兵,表示某個較新的區段已經把某個物件編號退役了。鏈中一個較晚出現、較舊的區段,絕不能被允許用一個過時的位置去覆寫那個哨兵。把先到者視為 free 標記的權威,被刪除的物件就維持刪除;草率對待它,一個檔案自身的歷史就會讓最新修訂版移除掉的內容起死回生

HotPDF 圖解對比混合式 xref 資料的兩種合併順序:先讀傳統表會把物件 12 標成 free 並丟掉它稍後的型別 2 項目,於是重新存檔以 EListError 失敗;而先合併交叉參照串流則讓每個項目取得自己的位置,傳統表在它周圍落定
先讀串流,型別 2 項目才拿得到自己的位置,於是傳統表在它們周圍落定,而不是把它們抹掉

這在 HotPDF 裡意味著什麼

引擎替您解析混合參照檔案,而且在每一條必須剖析交叉參照資料的路徑上都這麼做。用 LoadFromFileLoadFromStream 載入一份文件、做您的變更,再呼叫 SaveLoadedDocument;或是跑一次像 EncryptFile 這種讀入一個輸入、寫出一個輸出的一次性操作。不論哪一種,復原程序都會讀 /XRefStm、把串流區段排在傳統項目之前合併,並在寫入把它們列舉出來之前先解析出住在串流裡的那些物件。AES-256 加密路徑是這個問題最早現身的地方,因為加密一份文件會改寫每一個物件,因而要求每一個物件都必須已經被定位過

// 一次性:讀入混合式輸入,寫出一份 AES-256 加密的副本
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

值得帶走的那個細節坐在 API 的上游。來自 Word、Excel、PowerPoint,以及一長串「另存為 PDF」管線的檔案,照例都是混合式的,所以一個您只拿自家產生器輸出去操練的載入器,在測試裡可能永遠遇不到一份。請拿真正 Office 應用程式匯出的文件充實您的測試資料,不要只用自家程式碼產出的檔案

檢查一個您起疑的檔案

兩項檢視能很快把問題了結。用十六進位檢視器打開檔案,讀最後一個 startxref 之後的位元組;一份混合式檔案會顯示一段很短的傳統區段,其 trailer 字典裡含有 /XRefStm。或者,把一次完整剖析所回報的物件數,跟 trailer 裡 /Size 所宣告的最高物件編號相比。差距很大就表示有物件躲在載入器沒打開的串流裡,而那正是稍後會變成存檔時失敗的同一份短缺

一份典型 Excel 匯出檔的尾巴,讓第一項檢查變得具體。最後那個 xref 關鍵字之後的一切都是純 ASCII,所以這個特徵直接從十六進位檢視器裡就讀得出來(位移為示意,註解為另外加上)

xref
0 0                          % 空的傳統子區段:一列都沒有
trailer
<< /Size 216                 % 比使用中最高物件編號大一
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % 交叉參照串流的位元組位移
>>
startxref
88710                        % 指向上面那段傳統區段
%%EOF

0 0 這個子區段就是關鍵徵兆:一張零個項目的傳統表存在的唯一目的是承載那個 trailer,而那個 trailer 存在的主要目的是說出 /XRefStm 87325。一個在 xref 關鍵字就停下來的偵測器,走到這裡看到的是一份空無一物的索引。當您寧可用指令碼做這項檢查而不是用眼睛看時,這個標記永遠坐在檔案最後那幾 KB 之內,所以一次有界的反向讀取就夠了

// 從檔案尾巴傳回 /XRefStm 位移,若標記不存在則傳回 -1
// (該檔案不是混合式,或根本不是 PDF)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // trailer 住在尾巴裡
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // 有界的反向讀取:最多 2 KB
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // 尾巴裡沒有混合式標記
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // 跳過鍵之後的空白
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// 用法:非負的結果指出串流起始的那個位元組
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

請把這道探測當成分流,不是當成剖析器:它告訴您一個批次裡哪些檔案在重新存檔工作跑起來之前值得留意,如此而已。載入器接下來拿它找到的那個位移該做什麼,也就是跟著區段鏈走、把串流項目排在傳統項目之前合併、尊重 free 項目哨兵,在 我們處理 Office 應用程式混合參照 PDF 的姊妹篇裡有逐步的走訪

這個故事的寫入端,也就是物件串流與壓縮交叉參照最初是怎麼產出來的,涵蓋於 我們談物件串流與增量更新的文章。當談到的那份混合式檔案同時也非常大時,大型 PDF 工作流程的 Direct File API 逐步解說裡的載入技巧,能讓您在不把整份東西讀進記憶體的情況下檢視它。兩者都跟這裡所描述的復原機制搭得很自然,而那份復原機制隨給 Delphi 與 C++Builder 的 HotPDF Delphi Component 一同出貨,與本部落格其他地方談到的載入、編輯、加密與簽署 API 並列