技術文章

用 HotPDF 在 Delphi 中做物件串流與增量更新

PDF 1.5 引進了兩種先前檔案格式無從表達的儲存結構:物件串流與交叉參照串流。物件串流是一個以 Flate 壓縮、標記為 /Type /ObjStm 的容器,它把許多小型間接物件首尾相接地打包在一起,而不是把它們撒在檔案主體各處。交叉參照串流則是把檔案的查找表改寫成帶可變寬度欄位的壓縮二進位資料,取代那張直到 1.4 版為止結束每份 PDF 的固定寬度 ASCII 表。它們是一起出動的。一旦物件被折進串流,舊的文字表就再也定址不到它們,所以二進位 xref 必須跟著來

把它跟傳統版面對照,它消掉的成本就一目了然。在一份 PDF 1.4 檔案裡,每個間接物件都未經壓縮地坐在自己的 obj 標頭後面,而尾端那張表每個項目正好花掉 20 位元組的 ASCII,禁止壓縮。一份有 200,000 個物件的文件,在畫下第一個字形之前就已經背著大約 4 MB 的交叉參照資料,上面還疊著所有未壓縮的字典本體。PDF 1.5 同時攻擊這兩個數字:字典折進 Flate 容器,而那 4 MB 的表縮成幾百 KB 的二進位資料。ISO 32000-1 在 §7.5.7 與 §7.5.8 定義了這兩種結構

HotPDF 並排的檔案版面,比較未壓縮的 PDF 1.4 物件與 ASCII xref 表,和壓縮過的 PDF 1.5 物件串流與二進位 xref 串流
折疊起來的字典與二進位 xref 串流讓好幾 MB 的結構性負擔塌縮,而頁面內容與影像資料維持它們原有的壓縮 — 結構繁重的檔案獲益最大

省下來的量究竟落在哪裡

物件串流只碰非串流物件,所以它壓縮的是結構,不是像素。頁面內容在 1.5 之前就已經是 Flate 壓縮的了,而影像資料自帶編解碼器,這也就是為什麼一份影像繁重的宣傳冊幾乎紋風不動。會塌縮的是那些結構繁重的檔案:帶著數千個欄位字典的 AcroForm、很深的大綱樹、標籤化 PDF 的結構元素。那些物件很小、數量很多,而且彼此幾乎一模一樣,而這種重複性正是 Flate 在它們坐進同一個緩衝區、而不是散在主體各處還夾著標頭時所利用的東西

人們很容易低估一份舊檔案裡有多少是負擔。一份吸收了多年編輯的表單封存,可能有遠超過一半的位元組花在字典標頭、xref 填充,以及沒有任何閱讀器會去看的修訂版上。這裡的兩項功能取回了前兩項。第三項,累積的修訂版,只有在檔案不再需要記住自己的歷史時,才會向壓實低頭

在 HotPDF 裡,您透過一對屬性把兩者打開,而它們如何互相依賴,比您把它們寫成什麼順序更要緊:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog-2026.pdf';
    Pdf.UseXRefStream := True;      // 二進位 xref,ObjStm 的先決條件
    Pdf.UseObjectStreams := True;   // 把物件打包進 /Type /ObjStm
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Compressed structure demo');
    Pdf.EndDoc;                     // 發出 XRefStm + ObjStm 容器
  finally
    Pdf.Free;
  end;
end;

UseObjectStreams 需要 UseXRefStream 設成 True。一個壓縮物件是透過型別 2 的 xref 項目搆到的,那個項目記的是一個物件串流編號加上一個索引,而傳統那 20 位元組的文字列沒有地方存這一對值。所以 UseObjectStreams 自己一個什麼看得見的事都不會做;兩個旗標都設、而且設在 BeginDoc 之前,才是可行的設定。設在 BeginDoc 之後,HotPDF 早就已經對舊版面下了承諾

為什麼兩者都預設為關閉

HotPDF 出廠時把這兩個屬性都留在 False,而理由會在與老舊下游程式碼整合時現身。一個只懂 PDF 1.4 的閱讀器,不會宣告自己處理不了壓縮物件。它遇上一道 xref 串流,找不到它預期的任何一個 trailer 關鍵字,於是回報交叉參照表損壞,或乾脆拒絕開啟檔案。如果您的輸出流進一台老舊的傳真閘道、一台跑著內嵌直譯器的硬體印表機,或是十年前某人照著 1.4 規格寫的剖析器,那就為那條通道把兩個旗標都關掉,並接受較大的檔案。至於封存儲存與網頁交付,那裡每一款主流檢視器都已經讀了二十年的 PDF 1.5,把它們打開就是幾乎白撿的壓縮

還有一個二階效應值得告訴您的支援團隊。一旦字典被打包進物件串流,逐位元組比較兩份產生出來的檔案就不再有任何意義,因為改一個欄位就可能讓整個容器重新 Flate,並把它之後的一切都挪位。請以物件內容去比對這類檔案的差異,不要用二進位比較

增量更新,以及它們所保護的位元組位移

一枚數位簽章涵蓋一個明確的 /ByteRange:實體檔案的兩段區間,以絕對位元組位移給出,也就是 CMS 摘要所取用的範圍。重寫這個檔案,即使重寫成一個在螢幕上看起來一模一樣的東西,那些位移全都會挪動。摘要不再吻合,簽章就讀成壞掉。那正是 ISO 32000-1 §7.5.6 用增量更新解決的問題。新增與變更過的物件被附加在既有的 %%EOF 之後,接著寫出一段新的交叉參照區段,它的 /Prev 項目回指前一段。原本的位元組從不被擾動,所以一個已簽署的修訂版仍然可驗證,而 Acrobat 能在簽章面板裡個別呈現每一個已簽署的修訂版

HotPDF 透過它自己的入口公開這件事:

HotPDF 三個唯附加的修訂版以 Prev xref 項目串接,而原本的 ByteRange 摘要仍然驗證得過
附加上去的修訂版透過 Prev 項目往回串接,從不碰簽章摘要過的位元組,所以每一個先前已簽署的修訂版都持續驗證得過,而檔案只往右邊長
Pdf.BeginIncrementalUpdate('contract-signed.pdf');
Pdf.AddPage;
Pdf.CurrentPage.SetFont('Arial', [], 10);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Addendum recorded 2026-06-11');
Pdf.SaveIncrementalUpdate('contract-updated.pdf');  // 只附加那個增量

有兩件事會絆倒人。BeginIncrementalUpdate 必須收到原始檔名,因為附加上去的 xref 區段所記的位移,只有對著那份確切的原始位元組才有意義;把它指向一份改過名或重新存過的副本,那些位移描述的就是一個不再存在的檔案。而這次儲存在構造上是唯附加的,所以輸出永遠比輸入大。那份成長不是可以調校掉的浪費。它跟讓先前已簽署修訂版完好無損的,是同一項性質

修改已載入的檔案要走 LoadFromFile

最初透過產生 API 認識 HotPDF 的開發者,往往會撞上一堵特定的牆。BeginDoc 開啟的是一份全新的文件,而當您想改的是一份已經存在的文件時,它就是錯的工具。編輯既有檔案改走已載入文件的那組呼叫:

PageCount := Pdf.LoadFromFile('base.pdf');
Pdf.InsertPagesFromDocument(OtherDoc, '1-3', 5);  // 第 1-3 頁放到第 5 頁之後
Pdf.MovePage(2, 5);
Pdf.SaveLoadedDocument('modified.pdf');

把兩者混用,症狀就是一個輸出檔裡有您的新內容、卻沒有原檔任何東西,因為 BeginDoc 開開心心地在您以為自己正在編輯的那份文件旁邊,另外建了一份新文件。請把 LoadFromFileSaveLoadedDocument 讀成一套詞彙,把 BeginDocEndDoc 讀成另一套。一段對同一個檔案同時伸手去拿這兩套的常式,幾乎一定是錯的

HotPDF 兩套儲存詞彙,BeginDoc 建立新檔案,而 LoadFromFile 搭配 SaveLoadedDocument 編輯既有檔案
產生那一對建的是一份全新文件,而已載入文件那一對編輯的是磁碟上既有的東西 — 把兩者混用,正是編輯偶爾會在缺了原檔所有頁面的情況下出貨的原因

什麼時候該壓實一份被附加過的檔案

唯附加的儲存帶著一筆緩慢累積的成本。一項每晚在同一份 PDF 上蓋一行狀態的工作,一年下來會產出 365 個修訂版,而每個修訂版身後都拖著一段新的 xref 區段。當那份歷史已經沒有用處,而且檔案裡沒有任何簽章需要存活時,您可以走已載入文件的路徑把整個東西重新序列化,藉此把它壓平:

Pdf.LoadFromFile('stamped.pdf');
Pdf.SaveLoadedDocument('compacted.pdf');

這次重新存檔是一次完整重寫。它刻意丟掉先前的修訂版,並毀掉檔案裡任何仍在的簽章,所以請把它擺在您對任何其他破壞性步驟所套用的同一道政策關卡之後。有一條站得住腳的生產規則:當修訂版數量超過某個門檻時壓實,或當附加的負擔長到超過基底檔案的某個比例時壓實,而且永遠不要壓實一份簽章面板裡有東西的文件

在出貨前檢查輸出

驗證這一對功能爽快地具體。在 Adobe Acrobat 裡開啟結果,並確認三點:物件串流開啟之後,文件內容回報 PDF 1.5 或更新;一次增量更新之後,簽章面板仍然對每一個先前已簽署的修訂版驗證通過;以及頁數與書籤毫髮無傷地通過一輪載入、修改、儲存的循環。對於封存輸出,也請把檔案推過 veraPDF,因為壓縮過的 xref 正是那種嚴格驗證器會比任何寬容檢視器更仔細端詳的結構。如果您的工作還牽涉到非常大的輸入,我們針對大型 PDF 工作流程的 Direct File API 逐步解說裡的檢視方法跟增量儲存搭得很自然,而上文那些位元組範圍背後的簽章機制,則在 HotPDF 數位簽章與 PAdES 那篇文章裡有深入處理

這兩項功能都隨給 Delphi 與 C++Builder 的 HotPDF Delphi Component 一同出貨,與本部落格其他地方談到的產生、表單、加密與簽署 API 並列。如果您想把上面那些呼叫跟自家的文件管線對照著看,產品頁連結了完整的 API 參考