技術文章

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

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

這是一條通過了所有內部測試的 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 閱讀器會直接跳過它們,永遠不會絆到一個它剖析不了的結構。它們真正的位置只住在交叉參照串流裡。這種檔案的特徵在它的尾巴:一段只涵蓋舊閱讀器所需物件的短傳統區段,它的 trailer 指向 /XRefStm,真正的復原資料就坐在那裡

HotPDF diagram of a Word or Excel hybrid-reference PDF tail where the classic xref trailer carries /XRefStm 87325, pointing back at the cross-reference stream that indexes form fields and tagged structure invisible to a loader that stops at the classic table
混合式尾巴留著一張服務舊閱讀器所需物件的傳統表,而它 trailer 裡的 /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 項目才拿得到自己的位置,於是傳統表在它們周圍落定,而不是把它們抹掉

兩份索引怎麼漸行漸遠

以上一切都假定兩份索引彼此一致。剛從 Word 出來時確實如此:每個檢視都在自己宣稱的範圍內描述同一份文件。麻煩從檔案路過一個只懂其中一份的工具開始。蓋章工具是頭號嫌疑犯。有的會追加一段傳統風格的增量更新,附帶幾個新物件、一段新的 xref 區段、一條指回前一區段的 /Prev 連結,和一個全新的 trailer。那個 trailer 若丟掉 /XRefStm,串流檢視就成了孤兒;若把舊值照抄向前,串流檢視描述的還是蓋章之前的那份文件。無論哪種,兩份索引就此分歧

PDF:混合參照 PDF 的尾巴——舊閱讀器信的是傳統 xref 表,現代閱讀器沿 /XRefStm 走到壓縮串流檢視
一個 trailer 鍵決定檢視:傳統表服務舊閱讀器,/XRefStm 則把定位其餘一切的串流交給現代閱讀器

知道之後,這個特徵一眼就能認出。透過一份檢視看得到的物件,在另一份裡缺席或過期。經串流解析的閱讀器,找到的是某個已更新物件的編輯前版本,或者對新增物件根本沒有條目。只信傳統表的閱讀器看得到編輯,卻失去所有只有串流才定位得到的壓縮物件。使用者的描述是:表單欄位在一個檢視器裡出現、在另一個裡消失;蓋章那一趟似乎刪掉的註解;或者查找落到完全錯誤的物件上

讓這些檔案追起來格外昂貴的是 Adobe Acrobat。宣告的索引與位元組對不上時,Acrobat 通常會靠掃描物件標頭悄悄重建交叉參照資料,於是產出壞檔案的人打開它、看不出任何問題、繼續往下送。失效之後才在別處現身——預檢驗證器、簽章服務、封存匯入工作——任何信任宣告結構、並回報缺失物件或交叉參照不符的消費端。幾乎每一張混合式失步的工單,開頭都是「Acrobat 開起來好好的」

這在 HotPDF 裡意味著什麼

引擎替您解析混合參照檔案,而且在每一條必須剖析交叉參照資料的路徑上都這麼做。用 LoadFromFile 或 LoadFromStream 載入一份文件、做您的變更,再呼叫 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 16                         % 物件 0-15:舊閱讀器所需
0000000000 65535 f
0000000017 00000 n
...                          % 再 14 列;其餘住在物件串流裡
trailer
<< /Size 216                 % 比使用中最高物件編號大一
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % 交叉參照串流的位元組位移
>>
startxref
88710                        % 指向上面那段傳統區段
%%EOF

trailer 裡的 /XRefStm 鍵就是鐵證。上面的傳統區段只列了檔案所用 216 個物件編號中的 16 個;打包進物件串流的一切,要嘛不在其中,要嘛被標為 free。停在 xref 關鍵字的偵測器,走到這裡只見過那份殘缺的索引。當您寧可用指令碼做這項檢查而不是用眼睛看時,標記就坐在檔案最後那幾 KB 之內,一次有界的反向讀取就夠了。若檔案後來又被增量更新過,尾巴裡會有一個以上的 trailer,算數的是最後一個

// 從檔案尾巴最新的 trailer 傳回 /XRefStm 位移,
// 標記不存在則傳回 -1(該檔案不是混合式,或根本不是 PDF)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P, Q: 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 := 0;                               // 保留最後一個標記:增量更新
  Q := Pos(AnsiString('/XRefStm'), Tail); // 會追加較新的 trailer
  while Q > 0 do
  begin
    P := Q;
    Q := Pos(AnsiString('/XRefStm'), Tail, P + 1);
  end;
  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');

請把這道探測當成分流,不是當成剖析器:它告訴您一個批次裡哪些檔案在重新存檔工作跑起來之前值得留意,如此而已

把它指向一個裝滿真實輸入的資料夾,檔案會分成三組。純傳統檔案的尾巴有 trailer 關鍵字但沒有 /XRefStm,探測回傳 -1。完全採用交叉參照串流的檔案同樣正確地回傳 -1:它根本沒有 trailer 關鍵字,因為它的 trailer 鍵住在串流字典裡。只有雙索引版面才會交出位移。如果分流報告需要區分前兩組,在丟掉尾巴之前先在同一截裡搜一下 trailer;多呼叫一次 Pos 就夠

探測要無人值守地跑之前,有兩處要先加固。位移只是一個宣稱。跳到那個位置,確認那裡真的坐著一個字典寫著 /Type /XRef 的物件,因為損壞或被截斷的檔案仍可能帶著鍵、而它點名的串流已經不在。那種檔案該跟健康的混合式分開放。這個檢查在原有的檔案處理之上只多幾行

// Offset 是 FindXRefStm 傳回的值;FS 是以同樣方式打開的 TFileStream,
// Head 是 AnsiString(Min 來自 System.Math)
if Offset >= FS.Size then
  Exit(False);                          // 鍵活了下來,串流沒有
FS.Position := Offset;
SetLength(Head, Integer(Min(Int64(512), FS.Size - Offset)));
FS.ReadBuffer(Head[1], Length(Head));
Result := (Pos(AnsiString('/Type /XRef'), Head) > 0) or
  (Pos(AnsiString('/Type/XRef'), Head) > 0);  // PDF 語法兩種拼法都允許

然後把 2 KB 的視窗做成參數。兩 KB 蓋得住一般的 Office 輸出,但特別大的 trailer 字典可能把標記推出範圍;把視窗加寬,好過誤把檔案判成傳統

把混合式檔案分流進管線

偵測的價值在於它改變檔案的下一站。下一站在哪,取決於管線接下來要拿它做什麼

Delphi 決策流程:掃描檔案尾巴找最後的 trailer 與 /XRefStm,然後把混合式 PDF 分流到驗證、正規化或僅追加處理
有界的尾巴搜尋給出三種判定,只有雙索引的那一種以真正的混合式身分繼續往下走

只被讀取、渲染或驗證的檔案,該送往能解析兩份檢視的載入器。PDFium Component 在載入時就解析 /XRefStm 鏈,您的程式碼看到的物件表已經是合併後的那份,我們驗證物件與交叉參照串流的文章裡的檢查原樣適用。失步的混合式檔案若損壞到拒絕載入,引擎會透過載入錯誤碼回報,結構性損壞產生的正是 FPDF_ERR_FORMAT。乾淨的載入是較弱的證據。PDFium 天生寬容,多半會不動聲色重建不一致的檔案,所以成功只證明檔案救得回來,對兩份檢視是否一致隻字未提。那個問題,還是回到「完整物件走訪找到的數量」對「trailer 的 /Size」的比較上

管線要修改的檔案是另一回事。我們在這裡的偏好很直白:讓它們根本不再是混合式。用 HotPDF 載入文件、完整存檔,輸出就帶著單一、自洽的交叉參照,只有一種形式。沒有 /XRefStm,沒有留下來漂移的第二份檢視,每個物件恰好歸一個索引條目所有。封存匯入之前、嚴格的下游 RIP 或簽章服務之前、以及對混合式輸入做過任何編輯之後,您要的都是這種正規化。它完全倚賴前面描述的串流優先合併,因為完整存檔只能寫出載入時成功定位的物件

已簽署的文件是例外。完整改寫會搬動每一個位元組,使任何以原始位元組範圍算出的簽章失效。對已簽署混合式的修改,必須以正規的增量更新進入、並讓兩份檢視保持同步;只需要讀取的已簽署檔案,則應原樣通過管線

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