CollateDocumentsEx 是 Delphi PDF 元件庫 PDFlibPas 中把多個已開啟文件合併成一個交錯文件的函式,每輪從每個來源附加 GroupSize 頁,接受每個來源各自的頁範圍清單,並把像 3-1 這種遞減範圍視為該來源的反轉。一次呼叫就能把一疊正面掃描與一疊反向背面掃描變成閱讀順序
這個 API 背後的情境相當平常,也極為常見。單面進紙的送紙式掃描器整疊紙面朝下跑一遍,操作員再把整疊紙翻面重跑一遍,結果就是兩個 PDF:正面依序排列,背面卻是反向排列。使用者要的是一份檔案,第 1 頁正面、第 1 頁背面、第 2 頁正面,依此類推。本文要談的是排序問題,以及排序問題底下潛藏的資源重複陷阱;若你關心的是純粹的串接吞吐量,請參考以位元組層級參照位移實現的高速 PDF 合併;若輸入檔太大而根本無法整份放進記憶體,請參考用直接存取合併與拆分數 GB 大小 PDF
掃描器產生兩疊檔案,其中一疊是反的
拼頁(Collation)不等於合併(merge)。合併是串接頁範圍;拼頁則是交錯排列,而交錯的模式是產生輸入的實體裝置本身的特性。模式一旦弄錯,檔案不是稍微出錯,而是完全無法閱讀:每隔一頁就屬於不同的紙張。幾乎每個真實案例都能用三個變數描述:輪替中有幾個來源、每輪每個來源出幾頁、以及是否有來源需要反著讀。CollateDocuments 用一個文件控制代碼陣列加上一個 GroupSize 整數涵蓋了前兩者。CollateDocumentsEx 再加上第三者,接受以分號分隔的頁範圍清單,每個來源一個區段,空區段代表該來源的所有頁,遞減範圍則代表反轉。兩個函式都會附加到目前選取文件的末尾,成功回傳 1,任何拒絕情況回傳 0
為何樸素的拼頁做法會讓檔案大小倍增?
因為把來源物件編號映射到目標物件編號的匯入映射表(import map),在每次複製呼叫時都會重建,而任何被超過一個區塊參照到的東西,每個區塊都會被匯入一次。在 PDFlibPas 內部,TPDFDocument.CopyPagesFromDoc 在每次呼叫開頭都會重置其 NewIndObjList。那份清單是複製器記住自己已經帶過來什麼的唯一記憶。用一個十頁範圍呼叫一次,十頁共用的字型只會嵌入一次。用單頁範圍呼叫十次,同一個字型就會嵌入十次。這對掃描件的影響遠比對文字文件嚴重,因為掃描頁是單一大型影像 XObject,而共用物件才是真正吃重量的部分:內嵌的 ICC 描述檔、共用的 /DecodeParms 鏈、套用在每張紙上的浮水印或圖章表單 XObject、OCR 文字層字型。撰寫輪替拼頁最直覺的方式是一個逐輪迴圈,而這個迴圈正是病態案例本身
// Do not do this. Each CopyPageRanges call rebuilds the import map,
// so anything the two sources share internally is imported once per
// round instead of once per source.
var
RoundIndex: Integer;
begin
for RoundIndex := 1 to 12 do
begin
PDF.CopyPageRanges(Fronts, IntToStr(RoundIndex));
PDF.CopyPageRanges(Backs, IntToStr(13 - RoundIndex));
end;
end;
十二輪、兩個來源,就是二十四份匯入映射表。什麼都不會警告你。頁面順序是對的,每一頁都能正常渲染,唯一的症狀就是檔案比所有輸入加總大上好幾倍。在一份 300 頁的批次工作上,這個倍數不是四捨五入誤差,而是一份符合保存預算的封存檔與一份不符合的差別
先匯入一次,再重排頁樹
解法是把樸素迴圈揉在一起的兩個關注點拆開。複製決定目標裡存在哪些物件;排序決定頁面在頁樹中的位置。CollateDocumentsEx 對每個來源只複製一次,用一次涵蓋該來源完整範圍的 CopyPagesFromDoc 呼叫,讓每個來源只產生一份匯入映射表、共用資源只寫入一次。等每個來源都落地之後才進行交錯,而交錯完全透過 TPDFPageTree.MovePage 完成
就這裡而言,頁面搬移是免費的。ISO 32000-1 §7.7.3 把頁樹定義成節點字典組成的平衡結構,其 /Kids 陣列存放間接參照,/Count 則在每個節點記錄葉節點總數。搬移一頁,就是把一個間接參照從一個 /Kids 陣列移除,插入另一個,調整兩邊的 /Count 值,並重新指向該頁的 /Parent。沒有任何內容串流被動到,沒有任何資源被重複,也沒有任何物件被建立。頁面物件保留自己的物件編號,這也是為何物件編號能像保留物件編號的頁面替換一文所述那樣維持穩定。還有一個細節,樸素的頁面搬移會做錯,而 MovePage 不會。ISO 32000-1 §7.7.3.4 允許 /Resources、/MediaBox、/CropBox 和 /Rotate 從祖先節點繼承,而不必寫在頁面本身。一頁如果從節點 A 繼承其資源,之後被搬到節點 B 底下,就會悄悄繼承不同的內容,或什麼都繼承不到。因此 MovePage 會先解析出繼承來的值,把它寫進頁面字典,然後才進行搬移,讓頁面在搬移過程中攜帶自己的屬性
重排步驟實際上在做什麼?
它是針對插入語意執行的選擇排序。先計算出期望的區塊內相對順序:依輪替走訪各來源,每個來源取至多 GroupSize 個索引,跳過已耗盡的來源,重複直到每一頁都被安置。這會產生一個附加區塊上的排列。套用它才是麻煩的部分,因為 MovePage 是插入而非交換,所以每次搬移都會讓舊位置與新位置之間的一切位移一格
實作維護一個 Current 陣列,模擬每個附加頁目前所在位置,從位置 K 往前掃描找出屬於 K 的頁,發出搬移,然後滑動陣列項目來鏡射該搬移對頁樹造成的效果。這在陣列操作上是 O(n 平方),在物件複製上是零,對這類工作負載來說正是正確的取捨:一次 500 頁的拼頁是二十五萬次整數搬移,卻不會複製一個位元組的影像資料。這一步驟不需要為遞減範圍和重複頁做任何特殊處理,因為呼叫 PLParsePageRangeList 時停用了排序、允許重複,所以要求的順序能原封不動地通過剖析
反轉範圍與一次呼叫完成的雙面合併
把反轉表達成範圍之後,平床式雙掃描的情境就收斂成一次呼叫。正面要維持自然順序,背面要 12-1,分號前的空第一區段表示第一個來源貢獻它的所有頁
var
PDF: TPDFlib;
Target, Fronts, Backs: Integer;
begin
PDF := TPDFlib.Create;
try
Target := PDF.NewDocument;
if PDF.LoadFromFile('fronts.pdf', '') <> 1 then
Exit;
Fronts := PDF.SelectedDocument;
if PDF.LoadFromFile('backs.pdf', '') <> 1 then
Exit;
Backs := PDF.SelectedDocument;
PDF.SelectDocument(Target);
// fronts 1..12 in order, backs scanned in reverse: F1 B12 F2 B11 ...
if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 1 then
PDF.SaveToFile('duplex.pdf');
finally
PDF.Free;
end;
end;
這段程式碼裡有兩個行為值得明講。拼頁後的頁面會附加到選取文件末尾,所以用 NewDocument 建立的文件會在它們前面貢獻自己那張初始空白頁,若不想要就得自行刪除。而且來源可以不對等:以 GroupSize 2 對一個三頁與一個五頁的來源來說,輪替結果會是 A1 A2 B1 B2,接著在 A 快用完時是 A3 B3 B4,最後單獨是 B5,因為耗盡的來源只是被跳過,而不是補齊
回滾、表單欄位,以及不會一併帶過來的東西
每個引數在目標被觸碰之前都會先驗證。缺少的文件控制代碼、選取文件被列為自己的來源、小於一的 GroupSize、區段數與來源數不符、範圍指名了來源沒有的頁:以上任何一種都會回傳 0,目標維持不變。複製過程中失敗則是比較棘手的情況,處理方式是透過公開的 DeletePages,而非底層的 PageTree.DeletePages。原因很具體。複製過程啟用了 MergeFormData,所以到了較後面的來源失敗時,來源的表單欄位早已被附加進目標的 /AcroForm /Fields 陣列。若在頁樹層級刪除頁面,會把小工具頁一併剝除,留下懸空的欄位參照;公開路徑則會連同頁面一起解除欄位、大綱與文章串連的參照
if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 0 then
// Nothing was appended and the target is byte-identical to before.
// 412 is the copy failure; 0 means the arguments were rejected
// during validation, before any page was touched.
Log(Format('collate rejected, LastErrorCode=%d', [PDF.LastErrorCode]));
對使用者要誠實說明邊界。拼頁會帶著頁面、它們的註解與表單欄位一起走,並且會合併 AcroForm 欄位清單、計算順序陣列與預設資源字典。但它不會帶著來源書籤走:掃描正面疊的大綱樹幾乎總是空的,所以雙面合併的情境下不會丟失任何東西,但如果你拼合兩份已編寫好的文件,它們的大綱就會留在原地,需要自行重建導覽。只存在於來源目錄中的具名目的地也是同樣的處境。在向客戶承諾無損拼頁之前,先為此做好規劃
PDFlibPas 把拼頁函式與其餘的頁面組裝功能一起提供,所以掃描器工作流程、以範圍為基礎的擷取,以及大檔案處理路徑,全都收在 Delphi 與 C++Builder 中的同一個元件底下。完整的 API 參考與試用版本位於 losLab Delphi PDF 元件庫 產品頁面