技術文章

在 Delphi 中合併與分割 GB 級 PDF

用最直觀的方式去合併或分割一份兩 GB 的 PDF,會同時付出兩種代價:實際等待時間與位址空間。所謂最直觀的方式,就是載入每個輸入檔、做完工作、寫出輸出。壞就壞在載入這一步。一座掃描封存從 300 DPI 換成 600 DPI,線性解析度加倍,落到磁碟上大約變成四倍,於是整年都在處理 400 MB 檔案的同一個組版工作,會在某個輸入檔跨過一 GB 的那一刻開始劇烈換頁,而它當時往往只是在數頁數而已。工作本身從來沒有變難。開啟、計數、挑選範圍、串接,全部就這些。只是在那個尺寸下,把整棵樹載進記憶體早已不再是個合理的預設做法。PDF Library for Delphi 是 losLab 為 Delphi 與 C++Builder 打造的 PDF 函式庫,它用 Direct Access 層來回應這件事:一組以 DA 為前綴的函式,背後是一個就地走訪交叉參照表的串流讀取器,而不是把整份文件建進記憶體

完整載入時記憶體都花到哪裡去了

「正常地」載入一份 PDF,意味著剖析 xref、把每一個間接物件解析成記憶體中的樹狀結構、解碼物件串流,並把頁面樹、字型與註釋接成您可以操作的物件。對編輯類的工作流程來說,這筆交易划得來。對合併、分割與檢視類的工作而言,它多半是浪費。一座三萬頁的掃描封存可能藏著數百萬個間接物件,而一次分割工作只需要讀其中幾百個:所要求範圍內的頁面節點,加上這些節點所參照到的東西

Direct Access 層把這個模型反了過來。DAOpenFileDAOpenFileReadOnly 只剖析檔案尾端那幾 KB 的 trailer 與 xref,然後傳回一個檔案控制代碼。物件則在某次呼叫需要時才被惰性取出。實務上的後果是,開啟一份數 GB 的檔案,所花的時間大致跟開啟一份小檔案一樣,而記憶體用量追隨的是您碰過的東西,不是檔案裡有的東西

PDF Library for Delphi 比較圖:把一份 GB 級 PDF 完整載入記憶體物件樹,對比以直接存取開啟,後者剖析只做到 trailer 與 xref,再由控制代碼服務逐物件的惰性讀取
完整載入必須先解碼每一個間接物件,合併才能開始,於是 RAM 與開啟時間隨封存規模成長。直接存取路徑讀了幾 KB 就傳回可用的控制代碼,讓每次呼叫只取自己需要的物件

不載入就探測一份巨大的檔案

下面這個模式取自函式庫自己的大型檔案效能基準:以唯讀方式開啟、提問、關閉。從頭到尾都不存在文件樹

var
  Lib: TPDFlib;
  Handle, Pages: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Handle := Lib.DAOpenFileReadOnly('archive-2025.pdf', '');
    if Handle = 0 then
      raise Exception.Create('Direct access open failed');
    Pages := Lib.DAGetPageCount(Handle);
    Writeln('pages : ', Pages);
    Writeln('title : ', Lib.DAGetInformation(Handle, 'Title'));
    Lib.DACloseFile(Handle);
  finally
    Lib.Free;
  end;
end;

只要辦得到,唯讀模式都值得優先採用:它讓進料階段能在其他行程仍握著檔案時執行,而且它把意圖寫進了程式碼。一個不小心呼叫到會改動檔案的函式的探測階段,會立刻失敗,而不是把封存弄壞

PageRef 是物件控制代碼,不是頁碼

使用 DA API 時最常見的錯誤,就是在函式期待 PageRef 的地方傳了一個頁碼進去。幾乎每一個逐頁的 DA 呼叫,收的都是指向頁面物件的參照控制代碼,而不是頁碼:DAExtractPageTextDARenderPageToFileDARotatePageDACapturePage 全都要一個 ref。您要透過 DAFindPage 把那個給人看的數字翻譯過來,才能拿到它:

PDF Library for Delphi:頁碼轉 PageRef 的流程圖,DAFindPage 餵給各個逐頁直接存取呼叫,對比直接傳入整數 ref 落到任意物件而悄悄取出錯頁文字
每一個逐頁的直接存取呼叫吃的都是 DAFindPage 產出的 PageRef,絕不是給人看的那個數字。略過這道翻譯,整數就會冒充成物件 id,錯頁的文字有可能無聲無息地出貨
PageRef := Lib.DAFindPage(Handle, 250);          // 頁碼 -> 物件控制代碼
if PageRef <> 0 then
begin
  Text := Lib.DAExtractPageText(Handle, PageRef, 0);
  Lib.DARenderPageToFile(Handle, PageRef, 5, 150, 'page250.png');
end;

改傳原始數字 250 進去並不會引發錯誤。它會定址到剛好坐在那個控制代碼值背後的任何物件,運氣好的日子明明白白失敗,運氣差的日子則從錯的那一頁擷取文字,塞進一份要交給客戶的文件。如果您把 DA 層包進自己的服務程式碼裡,就讓這道翻譯變成不可能被略過的一步:在邊界處接受頁碼,立刻呼叫 DAFindPage,內部只傳 ref

用具名清單合併數百個檔案

兩個檔案時,MergeFiles(First, Second, Output) 就夠了。批次組版則靠檔案清單擴充得更好:把輸入檔登記在某個清單名稱底下,然後一次合併整份清單

PDF Library for Delphi:具名檔案清單工作流程,一月、二月與三月的對帳單登記在同一個清單名稱下並在單次通過中合併,Fast、預設與嚴格三種變體在保留結構樹與速度之間權衡
數百個登記過的輸入檔收斂成單次 MergeFileList 通過,其結果再用一次唯讀探測就能在毫秒內驗證。變體是逐條管線的決策,因為 Fast 會丟掉 Tagged PDF 的結構樹
Lib.AddToFileList('Statements', 'jan.pdf');
Lib.AddToFileList('Statements', 'feb.pdf');
Lib.AddToFileList('Statements', 'mar.pdf');
Lib.MergeFileList('Statements', 'q1-statements.pdf');

// 用便宜的方式驗證結果:再做一次直接存取
Handle := Lib.DAOpenFileReadOnly('q1-statements.pdf', '');
Writeln('merged pages: ', Lib.DAGetPageCount(Handle));
Lib.DACloseFile(Handle);

合併家族有三種變體,而差別不只在速度。MergeFileListFast 略過結構樹的保留;MergeFileListStrict 強制嚴格模式;沒有後綴的那個版本則是取得平衡的預設值。由此推出的維運規則是:只要有任何一個輸入檔是結構必須存活下來的 Tagged PDF,為 PDF/UA 而產出的東西就是最明顯的例子,那就伸手去拿預設或 Strict 變體,因為 Fast 會無聲地丟掉結構樹。對沒有標籤的純掃描封存來說,Fast 是白撿的效能。請逐條管線決定,而不是逐個開發者的心情決定,並把用到的變體記進工作日誌

不載入就分割:範圍擷取

分割遵循同一套不載入的哲學。ExtractFilePages(InputFileName, Password, OutputFileName, RangeList) 直接把一段頁碼範圍從檔案拉到檔案,範圍清單寫成 '1-500''501-1000' 或以逗號分隔的選取,而來源檔從頭到尾都不會變成文件樹。當某份文件因為別的理由已經載入時,ExtractPageRanges 會從目前這份產生一份新的記憶體內文件,而 CopyPageRanges 則依 ID 從另一份已載入的文件把範圍拉過來。要把整合過的列印串流逐份對帳單切開時,檔案對檔案的形式,正是能讓一個 4 GB 的輸入檔永遠不必膨脹進 RAM 的那一個

謊報自身幾何結構的檔案

大型檔案的管線遇到損壞檔案的頻率,是小型檔案管線從來看不到的,理由很單純:這些輸入檔經過了更多套系統。有兩種失敗形狀值得明確處理

第一種是位移過的標頭。郵件閘道與列印多工緩衝處理器有時會在 PDF 前面塞入位元組,於是 %PDF 標記不再坐在位移 0,而檔案裡每一個 xref 位移都錯了同樣的量。串流讀取器會偵測到這件事並把它公開出來(平面層是 DAShiftedHeaderTSmartPDFReader 上則是 ShiftedHeader),然後在讀取期間予以補償。自己土法煉鋼的位移算術通常不會這麼做,這也就是為什麼「我們自己產的每份檔案都沒事,客戶 X 來的檔案就掛掉」是那個經典症狀

第二種是壞掉的交叉參照表。DACopyFile(InputFileName, OutputFileName, PageCount) 會把整份檔案串流成一份新的副本,同時重建 xref,並把頁數當作副產品回傳。把它擺在挑剔的下游消費端前面當作正規化階段,就能把一整類間歇性的剖析失敗,換成一個可預期的修復步驟。而當您自己的編輯需要存檔時,DAAppendFile 會把它們寫成一次增量更新,附加一個新修訂版而不是重寫好幾 GB,這讓儲存成本正比於改動量,而不是正比於檔案大小

交付細節:線性化與構圖

有兩項相鄰的能力,讓大型檔案管線得以完整。當組好的輸出要透過 HTTP 供瀏覽器內檢視時,LinearizeFile 會為位元組範圍串流重新編排它,讓第一頁在 500 MB 封包的其餘部分下載完成之前就先顯示出來。請把它當作最終階段來跑,在所有合併之後,因為之後任何一次修改都會讓檔案再度失去線性。而當封包需要的是構圖而非單純串接時,比方說在每份對帳單背後蓋一張封面,或把兩張來源頁拼版到一張輸出紙上,DACapturePage 會把任何一頁變成可重複使用的範本,再由 DADrawCapturedPage 把它擺到目的頁上的任意矩形內,而且對那份數 GB 的來源檔依然不必完整載入文件

極限,以及哪些仍然是唯讀的

格式本身會比 Direct Access 更早用光空間。位移量在整個 DA 層一路都是 Int64,所以真正的天花板是可用磁碟,以及傳統(非串流)交叉參照表那個十位數的 xref 位移欄位。數 GB 的掃描封存在實務上平淡無奇,而不論檔案多大記憶體都保持有界,因為物件只有在某次呼叫要它時才會被讀取

有兩個問題常被問到,值得直接回答。走預設路徑合併會把文件結構帶過去,所以書籤與連結都活得下來;拿結構樹去換速度的是 Fast 變體,這也正是要把它保留給未加標籤輸入檔的全部理由。安全的習慣是開啟合併後的輸出、走一遍它的大綱,並在出貨前抽查幾個內部連結。至於編輯:在唯讀探測與完整載入之間,有一塊有用的中間地帶。頁面層級的操作直接作用在控制代碼上,DARotatePageDAMovePageDAHidePage 都在其中,表單欄位讀取也是,而 DAAppendFile 會把這些編輯保存成一次增量修訂。內容層級的編輯,也就是任何會改寫頁面內標記運算子的動作,仍然屬於完整文件層

相關文章

如果您合併出來的輸出必須維持無障礙,結構樹的背景知識涵蓋於 Tagged PDF 無障礙一文,它精確說明了 Fast 合併變體會丟掉什麼。要從分割出來的範圍裡取出內容,請見 文字、影像與字型擷取指南

完整的 Direct Access 函式清單隨函式庫一同提供;各版本與試用下載請見 PDF Library for Delphi 產品頁