Technical Article

在 Delphi 中進行快速 PDF 合併:位元組級的參照移位

將多個 PDF 串接起來聽起來應該是很低成本的。頁面內容已經配置好,字型已經嵌入,影像也已經壓縮。原則上,合併只是一種記帳工作:對物件重新編號以使兩個檔案的編號空間不再衝突,將頁面樹狀結構拼接在一起,修正交叉參照表,然後寫入。實際上,大多數合併程式碼都丟棄了這種低成本優勢。對於每個輸入檔案中的每個物件,它會執行一次完整的剖析以產生標記化的物件樹,改變幾個間接參照,然後將該樹狀結構重新序列化回位元組。剖析和重新序列化是昂貴的兩個半部,而且對於絕大多數的物件來說,它們產生出的位元組序列幾乎與輸入的內容完全相同。

PDFlibPas 是適用於 Delphi 和 C++Builder 的原生 Object Pascal PDF 引擎,其快速合併路徑的存在就是為了在任何可證明安全的情況下跳過這個來回過程。這個想法雖然狹窄,但卻在整個文件集中獲得了回報:對於未經修改的非串流物件,原封不動地採用原始來源位元組,並對其所包含的間接參照進行單次的位元組級重寫,將每個 N G R 變成 (N+Offset) G R。沒有標記器,沒有物件樹,也沒有序列化器。本文將逐步探討在哪些情況下這個捷徑是合法的、在不損壞任何東西的情況下執行位元組重寫的剖析器狀態機、為什麼書籤合併需要完全不同的機制,以及普通的合併路徑是如何同時從二次方 (quadratic) 被重建為線性 (linear) 的。

為什麼物件重新編號是合併的真正成本

每個 PDF 都帶有自己的物件編號空間。檔案 A 有物件 1、物件 2,依此類推;檔案 B 有自己的物件 1、物件 2,依此類推。您不能將 B 的物件原封不動地放入 A 的檔案中,因為數字會發生衝突,而且 B 內部的每個間接參照現在都會解析為錯誤的物件。解決方案是一個偏移量 (offset):如果 A 在物件計數 Offset 處結束,那麼 B 的物件 N 就會變成輸出中的物件 N+Offset,而任何出現在 B 的物件內部的參照 N G R 都必須被移位為 (N+Offset) G R 以進行匹配。

這個移位是合併主體的全部語意工作。頁面樹狀結構的修復和 AcroForm 的合併都是對少數幾個物件進行的小型且有限的編輯。大量的工作是重寫跨越數千個物件的參照,而執行此操作的天真方法是剖析每個物件,這樣您就能從結構上找到參照。PDFlibPas 的 MergeFileListFast 採取了相反的觀點:參照在原始位元組中也是找得到的,只要您小心那些數字-空格-數字-空格-R 序列不是參照的情境。跳過剖析,就地進行移位,每個物件的成本就會崩潰為對您本來就要複製的位元組進行一次線性掃描。

何時重複使用來源位元組是可證明安全的

只有當從後續文件中複製出來的物件同時滿足三個條件時,才會採用位元組路徑。只要其中任何一個失敗,該物件就會被送回完整的解碼並重新序列化路徑,因此正確性始終勝過速度:

  • Doc2.IsChangedObject(X) 為 False。如果合併引擎已經在記憶體中改變了該物件(例如,一個其 /Parent 被重新指向的頁面物件),則記憶體中的樹狀結構就是事實來源,而原始位元組已經過時。只有未觸碰過的物件才符合資格。
  • 來源位元組不包含 stream 關鍵字。串流物件的主體是由 stream/endstream 包圍的不透明二進位資料,對壓縮或加密的串流資料進行天真的參照掃描將會愉快地「找到」並破壞看起來像參照的位元組模式。串流物件保持原始的支援串流路徑。
  • 來源位元組既不包含 /StructTreeRoot 也不包含 /StructElem。在快速設定檔中,標籤化 PDF 的結構樹狀結構會被丟棄而不是被合併,因此這些物件必須經過解碼路徑,引擎才能刻意地將它們清空。

這個決定存在於逐物件的複製迴圈中。當所有三項檢查都通過時,該物件的位元組會直接進入 ShiftIndRefsInSource,然後傳送給寫入器;否則會捨棄這些位元組,並使用 GetObject 重建該物件,用 ShiftIndRef 進行移位,然後再序列化。這個分支的結構值得一看,因為檢查的順序正是保持其安全的原因:

ObjectData := '';
if not Doc2.IsChangedObject(X) then
begin
  ObjectData := FastMergeObjectSource(Reader2, X);
  if (PLPos('stream', ObjectData) > 0) or
     ((not PreserveStructTree) and (PLPos('/StructTreeRoot', ObjectData) > 0)) or
     ((not PreserveStructTree) and (PLPos('/StructElem', ObjectData) > 0)) then
    ObjectData := ''                                  // fall back to decode
  else
    ObjectData := ShiftIndRefsInSource(ObjectData, Offset);
end;

if ObjectData <> '' then
  Writer.AddObject(X + Offset, Doc2.GetGenNum(X), ObjectData)
else
begin
  Obj := Doc2.GetObject(X, TempStruct);              // full parse path
  // ... null out struct-tree objects, ShiftIndRef, Obj.Output ...
end;

空白的 ObjectData 是表示位元組路徑拒絕該物件的信號。這個單一的標記可防止快速路徑和慢速路徑產生分歧:完全只有一個地方做決定,也完全只有一個後備方案。

參照移位狀態機及其邊緣情況

對間接參照進行位元組重寫很容易出錯而具有欺騙性,因為 R 和一連串的數字在不是參照的上下文中出現在整個 PDF 物件的各處。ShiftIndRefsInSource 是一個小型的手寫掃描器,它只遍歷位元組一次,並且只有在一個數字後面跟著(標記之間有 PDF 空白)另一個數字,然後是一個 R 分隔符號時,才會重寫該數字。最便宜的出口排在第一位:如果偏移量為零或來源為空,位元組將會被原封不動地傳回,根本不會進入掃描器。

掃描器的正確性建立在辨識出必須不更動狀似參照之序列的情境上。這些是最容易被忽略的邊界,而每一項都有被明確處理:

  • () 所分隔的字面字串會被逐字複製,同時追蹤巢狀深度並遵守反斜線跳脫字元,如此一來被跳脫的括號就不會擾亂深度的計數。像 (see object 3 0 R for details) 這樣的字串包含了一個教科書式的參照模式,但那其實只是散文,它必須一個位元組一個位元組地保留下來。
  • <> 所分隔的十六進位字串會未經解釋地傳遞過去。十六進位字串內部的 52 位元組是 R 的 ASCII 碼,而將十六進位有效負載視為文字的掃描器可能會製造出一個幽靈參照。字典開頭的 << 會先被偵測到,這樣字典才不會被誤認為十六進位字串。
  • / 開頭的名稱物件會從斜線開始一直到下一個空白或分隔符號被完整消耗。如果沒有這個,像 /R 這樣的名稱(常見的資源鍵)可能會被解讀為參照的 R
  • % 所引入的註解會執行到行尾,並被當作不透明文字跳過。
  • 數字後接 R 的測試是嚴格的。 一個參照只有在 R 由空白、分隔符號或輸入結尾終止時,才會被辨識為 N 空白 G 空白 R。如果缺少產生編號 (generation number),或者 R 後面跟著一個字母,數字將會原封不動地被發出。正是這一點保護了 /Length 1234 中的整數和 MediaBox 的四個數字,使其不會被靜默地遞增。

這個嚴格測試的核心部分讀起來幾乎就像規格說明句子所描述的那樣:

if (P <= N) and (Source[P] = 'R') and
   ((P = N) or PLIsPdfWhite(Source[P + 1]) or PLIsPdfDelimiter(Source[P + 1])) then
  Obj1 := PLStrToIntDef(PLCopy(Source, I, E1 - I), -1);

if Obj1 >= 0 then
begin
  AppendStr(PLIntToStr(Obj1 + Offset));   // shifted object number
  AppendBytes(E1, P - E1);                 // original whitespace + generation
  AppendBytes(P, 1);                       // the 'R'
end;

只有物件編號會被重寫;產生編號和標記之間精確的原始空白會被直接複製過去,因此輸出與輸入除了那一個必須改變的整數之外,在位元組層級是完全相同的。這種精確度就是整個重點——這使得重複使用來源位元組等同於完整的重新序列化,而不僅僅是接近它。這種行為被一組專注的單元測試所涵蓋,這些測試運行了裸參照、陣列內部的參照、非參照數字、字面字串、十六進位字串,以及套用偏移量且不為零的產生編號。

為什麼書籤不能重複使用 AppendOutline

將多個文件的書籤合併到一個大綱樹狀結構中,看起來像是現有 AppendOutline 輔助程式的工作,它已經知道如何將一個文件的頂層書籤嫁接到另一個文件上。這裡這是錯誤的工具,原因在於微妙的分層不匹配。AppendOutline 透過讓讀取器走訪原始檔案位元組來尋找目前最後一個頂層書籤。但是快速合併會透過 ChangeObject 將其編輯階段放在一個新物件緩衝區中;讀取器永遠看不到這些編輯。將三個或更多文件串連起來,每次附加都會將第一個文件的原始最後一個書籤重新指向最新的文件,因此所有中間文件的書籤都會從串連中掉出來——只有累積的 /Count 保持正確,這使得這個錯誤在有人打開書籤面板之前很容易被忽略。

快速路徑使用一個不會重新走訪讀取器的兩階段、元資料驅動之注入方式來解決這個問題。第一次遍歷所有輸入時,會針對每個文件收集大綱根物件和產生編號、第一個和最後一個頂層書籤編號,以及根目錄的 /Count。根據這個摘要,程式碼可以計算出它需要偽造的每個連結的整體物件編號——每個文件的頂層 /Parent 指向共用的根目錄,第一個書籤的 /Prev 指向先前的文件的最後一個書籤,最後一個書籤的 /Next 指向下一份文件的第一個書籤——使用純粹的物件編號算術。這背後有一個寫入順序的限制:第一個文件的物件會在後續的任何文件被打開之前寫出,因此第一個文件的所有大綱編輯(根 /Count/Last,以及舊的最後一個書籤的 /Next)都必須能夠表達為算術,且不需要手頭上有後續的文件。後續每個文件的編輯都是在它被打開之後、寫入之前就地套用,因此它們會經過相同的更改物件路徑輸出。

將其綁在一起的偏移量對齊不變量

參照移位和書籤注入都取決於一個算術不變量,這是整個設計中最脆弱的假設。注入到後續文件中的參照被寫為目標整體物件編號減去該文件的偏移量 (Offset),如此一來當該物件稍後被 ShiftIndRef(Offset) 移位時,該值就會落在預期的整體編號上。第一個文件的 Offset = 0 並且直接使用整體編號。為了使該減法正確,在注入期間所使用的運行偏移序列必須與最終寫出物件時所使用的偏移序列相符。

它確實如此,這是因為頁面和表單合併運作方式的一項屬性:AddPagesAddFieldsAddFieldFonts 僅修改第一個文件的現有物件——它們從不新增物件。因此,第一個文件的物件計數在整個頁面合併階段保持不變,而每個後續文件的偏移量(所有先前文件的物件計數總和)從注入到寫出都保持穩定。如果打破這一點——引入一個在合併中途建立新物件的階段——下游的每一個頁面和書籤參照都會出現偏差,偏差的數量正是您所新增的物件數量。這個不變量是靜默的,但它承擔著重任。

三個進入點共用一個引擎

快速路徑並不是合併程式碼的硬分叉。在同一工作流程中,位元組級引擎被重構為單一的內部常式 MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode),而公共 API 則變成了選擇兩個旗標的薄型包裝器:

  • MergeFileListFast 呼叫引擎時關閉結構樹保留——這是最精簡的路徑,丟棄標籤化 PDF 的樹狀結構,因此位元組路由適用於最多的物件。
  • MergeFileList 呼叫它時開啟保留,因此結構樹得以保留,且結果仍然是一個可用的標籤化 PDF。這個一般路徑也繼承了多文件書籤和表單的合併。
  • MergeFileListStrict 開啟嚴格模式:第一次中繼資料傳遞會在第一個未報告乾淨合併的輸入處停止,因此只有在不良檔案之前收集到的文件才會被包含在內,而不是跳過不良檔案並繼續。

將這些路徑折疊在一起,也讓普通的合併能夠被重建,從成對的 O(N²) 迴圈——合併檔案一和二,將結果與三合併,依此類推,每個步驟都重新剖析不斷成長的累加器——變成一個單一的線性傳遞,每個輸入只打開一次。兩個長久存在的雙檔案和雙串流進入點,MergeFilesMergeStreams,都沒有被觸動,仍然可供真正想要成對合併的呼叫者使用。

關於結構樹行為,要說一句實在話,因為它曾讓測試套件吃盡苦頭。快速路徑的「丟棄」並非完全捨棄:它移除了第一個文件的型錄 (catalog) 對 /StructTreeRoot 的參照,但結構樹物件本身仍會被寫出為孤立物件。因此,快速輸出的位元組中仍然包含 /StructTreeRoot 字串,您無法透過搜尋該字串來區分快速輸出與普通輸出——真正的差異在於型錄是否仍能抵達結構樹,這決定了該檔案是否仍是可導覽的標籤化 PDF。

何時應該使用哪條路徑

位元組路徑是一種吞吐量最佳化,適用於當您不需要保留標籤化 PDF 結構樹狀結構,而要組裝大量文件時——例如報告綑綁、對帳單批次執行、批次串接等。在對中大型輸入集進行重複合併的測量中,重複使用位元組縮減了大約 4% 到 13% 的掛鐘時間 (wall-clock time)(取決於物件組合),且對小型或格式錯誤的輸入沒有產生新的失敗,因為任何掃描器無法證明安全的物件都會退回到完整的剖析。如果您確實為了無障礙功能而需要保持結構樹完整,請使用保留了它的普通 標籤化 PDF 合併路徑;如果您正在處理的是非常大的單一檔案,而不是許多個輸入,在關於 透過直接檔案存取進行大型 PDF 合併和分割 的伴隨文章中所描述的位元組複製技術,在檔案層級上應用了相同的「複製位元組,避免完整的物件樹」哲學。

這些合併常式及其快速和嚴格的變體是 PDFlibPas Delphi PDF 函式庫 的一部分,其文件載有這裡所描述的檔案清單 API 和合併選項的完整參考資料。