技術文章

在 Delphi 中處理大型 PDF:HotPDF Direct File API

數一份 1.4 GB 掃描封存有幾頁,應該很便宜。對那個檔案呼叫 LoadFromFile,它就不再便宜了:HotPDF 會剖析交叉參照資料,並為這份文件數十萬個間接物件中的每一個建出一個記憶體內物件,而一個 32 位元的工作者會在那次剖析進行到一半時撞上 2 GB 的位址空間天花板。您真正想要的那個操作,也就是頁數,一個那些物件都不需要。它需要的是頁面樹,別無其他。工作所要求的東西與完整載入所交付的東西之間的這道落差,就是 Direct File API 存在的全部理由

Direct File API 給 Delphi 與 C++Builder 檔案層級的 PDF 存取:頁數、複製、解密、增量附加,全都從磁碟讀它們真正需要的東西,而不是在 RAM 裡重建整套文件模型。訣竅在於把每一項工作配到答得出它的最輕層級。配對配得好,一項服務就能在任何輸入尺寸下維持平坦的記憶體用量。配錯了,第一個超大檔案就把工作者放倒

Delphi 中 HotPDF Direct File API 的工作路由圖:唯讀控制代碼探測、整檔複製與加密、增量附加,以及依記憶體輪廓排序的完整記憶體載入
把每項操作配到答得出它的最輕層級,讓常駐記憶體不論輸入多大都維持平坦

完整載入讓您付出什麼

LoadFromFile 不是敵人。它掙得起它吃掉的記憶體:樹一進 RAM,您就對每一頁與每一個物件擁有隨機存取,而那正是 InsertPagesFromDocumentMovePage 與經由 SaveLoadedDocument 的重新序列化所需要的。真正的重構沒有捷徑;您得握住整份文件才能重新排列它

麻煩起於輸入尺寸不由您掌控的時候。客戶上傳、掃描器輸出,以及十年前的封存,都不理會您的測試語料當初假設了什麼。無條件地載入每一份輸入,您的記憶體上限就由任何人有朝一日會送來的那個最大檔案決定。剖析時間跟著物件數量走,而把物件結構與解碼後的串流都算進去之後,常駐記憶體會落在檔案大小的好幾倍,所以磁碟上的一 GB 可能意味著常駐好幾 GB

重新編譯成 64 位元會抬高位址空間的天花板,但帳單原封不動。工作者仍然要燒掉好幾秒 CPU 與檔案大小的好幾倍 RAM,去回答一個檔案自身結構原本能在幾毫秒內回答的問題。在並行之下,這筆帳變得更惡劣:四次大型載入同時進行,共用一份記憶體預算,而吞吐量正好在佇列最深、您最付不起的時候崩塌

透過控制代碼讀取檔案

唯讀層級把檔案開成一個控制代碼,回答關於它的結構問題,然後關掉。沒有物件樹,沒有頁面算繪,也沒有隨輸入成長的記憶體

var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly('archive-2026-06.pdf', '');
    if Handle > 0 then
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      RouteByPageCount('archive-2026-06.pdf', PageCount);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

有三個習慣讓這一層保持誠實。第一,檢查傳回值。非正數的控制代碼代表開啟失敗,而對一個已死的控制代碼發射 DAGetPageCount,是那種一直藏著、直到某天客戶送來一個格式壞掉的檔案才現形的臭蟲。第二,把每一次成功的開啟,都跟一個放在 finally 區塊裡的 DACloseFile 配成對;一個洩漏控制代碼的服務不會當掉,它只是慢慢腐爛,而那更糟。第三,尊重那個密碼參數實際上做了什麼。DAOpenFileReadOnly 收一個密碼,但對加密的輸入,它會悄悄退回完整剖析去讀頁數,於是那份平坦記憶體的保證就蒸發了。先把受保護的檔案送進 DecryptFile,管線其餘部分就仍然便宜

同一道探測也兼作分流關卡。檔案會以標錯、上傳到一半,或從別種格式直接改名的樣子出現,而一次 DAOpenFileReadOnly 檢查能在幾毫秒內於大門口把它們全部退回,錯誤還釘在那個惹事的檔案上。另一種選擇,是讓一個垃圾檔一路搭進佇列工作者深處,在那裡爆炸,而理清是哪一個輸入造成的可能要花掉一個下午

整檔的複製、解密與加密

第二層在完全不揭露檔案內部的情況下搬動與轉換完整檔案。進料管線最倚賴的就是這些呼叫

// 結構性複製:驗證並搬移,不剖析物件樹
Status := Pdf.DACopyFile('incoming\statement.pdf', 'verified\statement.pdf');
LogDirectFileStatus('copy', Status);

// 邊複製邊解密:進入受保護輸入的 Direct File 路線
Status := Pdf.DecryptFile('incoming\protected.pdf',
  'verified\plain.pdf', 'batch-password');
LogDirectFileStatus('decrypt-copy', Status);

// 邊複製邊加密:不必完整載入就保護輸出
Status := Pdf.EncryptFile('verified\statement.pdf',
  'outbound\statement.pdf', 'owner-secret', '', aes256, [prPrint]);
LogDirectFileStatus('encrypt-copy', Status);

每個呼叫都掙得了自己的位置。DACopyFile 是從隔離目錄複製進受管儲存的那次已驗證複製:它邊走邊開啟並索引 PDF 結構,所以一個被截斷或根本不是 PDF 的輸入就在這裡失敗,而不是在下游三個階段之後。DecryptFile 沿著一條只要輸入允許就跳過物件樹的直接 AES-256 改寫路徑寫出解密後的副本,它是 AES-256 加密那篇文章所涵蓋的載入再重存解密流程的大型檔案對應版。EncryptFile 反過來跑同一套動作,在一次檔案層級的複製過程中套用密碼保護,用的是記憶體內路徑早就在用的金鑰型別與權限參數

附加變更,而不是重寫

ISO 32000-1 §7.5.6 所定義的增量更新是第三層。原本的位元組留在磁碟上原地不動,任何新增或修改過的物件被附加在它們之後,後面再跟一段回指原檔的新交叉參照區段。對一份需要加一頁的 900 MB 封存來說,寫入成本是那個增量,不是整個檔案

HotPDF 產出的 PDF 增量更新剖析圖:原本的位元組原封不動,而一份新物件的增量與一段串接的交叉參照區段附加其後,先前的修訂版在一次完整 SaveLoadedDocument 重寫把它們丟掉之前都還救得回來
增量儲存只附加它的增量並保留先前的修訂版,所以壓實是另一次刻意的重寫
// 為一份大型封存附加一頁稽核頁,不必重寫它
Pdf.BeginIncrementalUpdate('archive-2026-06.pdf');
Pdf.AddPage;
Pdf.CurrentPage.SetFont('Arial', [], 10);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Processed by intake service 2026-06-11');
Pdf.SaveIncrementalUpdate('archive-2026-06-stamped.pdf');  // 原本的位元組 + 增量

這裡有兩點紀律很要緊。BeginIncrementalUpdate 必須指向原本的檔案,因為附加上去的交叉參照資料是回指它內部的位元組位移。而這個模型依設計是唯附加的:每一次增量儲存都讓檔案變大,從不變小。一份每晚蓋章的文件會無止境地膨脹,直到一次定期的重新序列化,也就是把它載入再經由 SaveLoadedDocument 寫回去,把它壓實下來為止。同樣這種唯附加的本性,也正是增量更新之所以成為碰一份已數位簽署文件的唯一安全方式,這項限制在 數位簽章與 PAdES 那篇文章裡有所檢視。底下那套交叉參照機制則在 物件串流與增量更新那篇文章裡另有專門處理

唯附加儲存裡有個陷阱,溜得過多數審查。原本的位元組留在檔案裡,任何願意去看的人都讀得到。一次「取代」某一頁的增量更新並不會刪掉舊的那一頁;它只是在目前這個修訂版裡把它蓋過去,而先前的修訂版就躺在那裡,完全救得回來。所以增量更新是剝除敏感內容的錯誤工具。要真正丟掉一個收件人永遠不該看到的歷史,您需要一次完整的重新序列化:LoadFromFile 後面接 SaveLoadedDocument,它只寫出目前的狀態,把那些被埋起來的修訂版留在身後

把層級配上操作

選擇邏輯短到腦子裡放得下,而且值得把它編碼成管線最上頭一個明確的路由決定,而不是讓每項工作各自即興發揮自己的路徑。您需要的操作決定了層級:

  • 計數、檢視或分類開一個控制代碼:DAOpenFileReadOnlyDAGetPageCountDACloseFile
  • 搬移、解密或加密整個檔案停在檔案層級,用 DACopyFileDecryptFileEncryptFile
  • 重構頁面或合併文件需要完整載入:LoadFromFile,接著 InsertPagesFromDocumentMovePage,然後 SaveLoadedDocument
  • 為一份巨大或已簽署的檔案加上一個小增量就呼叫 BeginIncrementalUpdate 再儲存

混合式管線最好在完整載入路徑前面擺一道尺寸門檻。把超過幾百 MB 的東西一律送進 Direct File 各層級,並把完整載入保留給一台有真實記憶體預算的 64 位元工作者上的真正重構。這道門檻把一次記憶體不足的當機,換成一個您看得見也調得動的路由決定

不論是哪一層處理某項工作,都請把它的輸出寫成一個暫時名稱,等結果通過驗證之後才改名就位。一個寫到一半、卻頂著最終名稱的檔案,在管線下一階段眼裡跟一個好檔案長得一模一樣,而 Direct File 的呼叫讓這道檢查很便宜:確認一份輸出就是一行控制代碼探測

Direct File API 隨給 Delphi 與 C++Builder 的 HotPDF Delphi Component 一同出貨。產品頁連結了完整的函式參考,含這裡展示的增量更新呼叫