PDF 增量更新允許 Delphi 應用程式透過僅附加已變更的物件來修改文件,而讓原始位元組完全保持原樣。losLab PDF Library 透過 AppendToStream 實作這項功能,它只寫入 ISO 32000-1 §7.5.6 所定義的增量區段,因此對一個 2 GB 檔案進行單一書籤編輯的代價是數千位元組的輸出,而不是完整的重寫。相同的機制這也是為什麼可以在不使數位簽章失效的情況下更新已簽署文件之原因。
這所解決的痛苦是具體的。完整的儲存會重寫整個檔案:每個物件都會被重新序列化,每個交叉參照偏移量都會被重新計算,而且輸出與輸入在位元組層級上毫無關聯。對於 40 KB 的發票來說這沒問題。但對於 2 GB 的掃描封存檔,您只是修正了文件標題中的一個錯字,為此重寫 2 GB 來改變 20 個位元組是荒謬的——如果該檔案帶有數位簽章,重寫就等於破壞了它。
為什麼儲存 PDF 會破壞其數位簽章?
PDF 數位簽章並不簽署文件的邏輯內容;它簽署的是實體檔案的位元組範圍。簽章字典中的 /ByteRange 項目準確地記錄了密碼編譯摘要涵蓋了檔案的哪些跨度。任何重新序列化這些位元組的儲存作業——即使是產生了語意完全相同之文件的作業——都會改變摘要,並且每個驗證器都會回報簽章已被破壞。這是刻意設計的:簽章證明了簽署者所看到的位元組,而不是某個抽象的文件模型。
增量更新是 PDF 規格所提供的逃生艙口。因為增量儲存是在原始的 %%EOF 之後附加新資料,且永遠不會接觸到被簽署的位元組範圍,所以現有的簽章在根據其涵蓋的位元組進行驗證時仍然有效。驗證器接著會將附加的變更分開分類——第二個簽章、表單填寫、註解——並決定它們是否為允許的修改。每個多方簽署工作流程都依賴於此:每個簽署者都在前一個簽署者之上加上一個增量區段。如果您正在建立簽署管線,關於 在 Delphi 中進行 PAdES 簽署和驗證的伴隨文章 涵蓋了簽章位元組範圍與增量區段互動的詳細細節。
ISO 32000-1 §7.5.6 下的增量更新是如何運作的
ISO 32000-1 §7.5.6 用三個規則定義了這個模型。首先,原始的檔案內容會被完全保留——沒有一個位元組會移動。其次,變更和新建立的物件會被附加在最後一個 %%EOF 之後,每個物件都保留了它之前的同一個物件編號(變更的物件只是獲得一個較新的定義,遮蔽了舊的定義)。第三,會附加新的交叉參照區段和預告 (trailer);預告的 /Prev 項目會指回前一個交叉參照區段的位元組偏移量,形成一個鏈,讓讀取器從最新走到最舊,以解析每個物件至其最新的定義。
從這種結構衍生出兩個有用的屬性。更新的代價是與變更的內容成正比,而不是與文件大小成正比——附加的成本是修改物件的大小加上微小的交叉參照/預告負擔。而且檔案變成了它自己的版本歷史記錄:每個先前的修訂版在實體上仍然存在,因此稽核人員可以在任何較早的 %%EOF 處截斷檔案,並還原確切存在於該時間點的文件。對於必須證明每次修改前文件長什麼樣子的合規工作流程來說,這個內建的稽核追蹤往往是採用增量儲存的決定性理由。
使用 AppendToStream 寫入增量更新
losLab PDF Library 透過 AppendToStream(AppendMode: Integer; OutStream: TStream): Integer 暴露了增量輸出,成功傳回 1,失敗傳回 0。AppendMode 參數選擇什麼內容落入目標串流中。模式 0 寫入一個完整的檔案:首先將原始來源位元組複製到串流中,然後再附加增量區段。模式 1 僅寫入增量區段本身——即差異 (delta)——並完全跳過來源位元組。模式 2 首先寫入一個透過 SetAppendInputFromString 註冊的呼叫者提供的前綴,然後在它之上附加更新區段。
var
Doc: TPDFlib;
Delta: TMemoryStream;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('contract.pdf', '') <= 0 then
Exit;
// Small edit: the kind of change that should not
// trigger a rewrite of the whole file
Doc.SetInformation(3, 'Amended 2026-07-04'); // key 3 = /Subject
Delta := TMemoryStream.Create;
try
// AppendMode = 1: write only the incremental section.
// Original bytes + Delta = a complete, valid PDF.
if Doc.AppendToStream(1, Delta) = 1 then
Delta.SaveToFile('contract.delta.bin');
finally
Delta.Free;
end;
finally
Doc.Free;
end;
end;
模式 1 在系統設計中是令人感興趣的。因為差異是自包含的,您可以獨立於原始檔案來傳遞它:將各修訂版作為單獨的 blob 儲存在物件儲存空間中、僅將差異複製到遠端站點,或者透過將基礎檔案與其增量鏈串接在一起來重建任何修訂版。重建規則就是單純的位元組串接——首先是原始檔案,然後依序是每個差異——因為這正是 §7.5.6 為增量更新檔案規定的佈局。
函式庫如何在不複製原始檔案的情況下計算交叉參照偏移量?
增量區段內的交叉參照項目必須包含絕對的位元組偏移量——從完整檔案開頭開始測量的位置,而不是從差異開頭開始測量。這為模式 1 創造了一個難題:寫入器從未發出原始的位元組,但它所記錄的每一個偏移量都必須假裝它們就在那裡。losLab PDF Library 用一個內部串流配接器 TPDFAppendSectionStream 解決了這個問題,它向序列化器呈現一個虛擬的座標空間。這個配接器是以原始檔案的位元組長度作為其基礎偏移量而建立的,它將自己的位置和大小回報為該基底加上目前已經附加的所有內容,並且只將新寫入的位元組轉發到呼叫者的目標串流。
結果是模式 1 永遠不會具體化來源文件的一個副本——無論是在磁碟上還是在記憶體中。天真的實作方式(將完整的檔案寫入到一個臨時緩衝區,然後切掉尾部)會攜帶著整個原始 PDF 的短暫副本,對於十億位元組級 (gigabyte-scale) 的輸入來說,這正是增量更新為了避免而存在的代價。這種偏移量虛擬化技術與函式庫其他地方使用的位元組參照移位是近親;關於利用位元組參照移位進行快速 PDF 合併的文章展示了應用在合併文件上的相同想法,而關於透過直接檔案存取進行大型 PDF 合併和分割的指南涵蓋了針對無法輕易裝入 RAM 的檔案的周邊 I/O 架構。
使用 SaveToStream 串流完整儲存
增量輸出是串流故事的一半;另一半是在進行完整儲存時會發生什麼事。losLab PDF Library 中的 SaveToStream 會直接針對目標串流驅動文件序列化器,而不是先將整個文件渲染到一個中介的 AnsiString 中,然後在一次呼叫中將該緩衝區寫出。舊的方法有效,但這意味著每一次完整儲存都會在記憶體中短暫持有輸出的第二個完整副本——在 10 MB 時無害,在 500 MB 時痛苦,而在 32 位元處理程序上對於數十億位元組的輸出則是一道硬牆。直接序列化使得峰值記憶體是追蹤文件的物件結構,而不是它的序列化長度。
var
Doc: TPDFlib;
Output: TFileStream;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('archive.pdf', '') <= 0 then
Exit;
// ... edits that justify a full rewrite ...
Output := TFileStream.Create('archive-rewritten.pdf', fmCreate);
try
if Doc.SaveToStream(Output) = 0 then
Writeln('Save failed, error ', Doc.LastErrorCode);
finally
Output.Free;
end;
finally
Doc.Free;
end;
end;
一個關於共享模式的教訓:當 AppendToFile 傳回 0 時
這個領域的一個回歸案例值得重述,因為失敗模式可以普遍化。AppendToFile(FileName) 將增量更新直接附加到磁碟上現有的 PDF 中——這是在地稽核追蹤工作流程的自然呼叫:載入一個檔案,進行更改,附加到相同路徑。在 v3.71.2 中,完全相同的序列開始傳回 0。根本原因在於載入器,而不是寫入器:為了支援對大型文件的按需讀取,LoadFromFile 在文件物件的整個生命週期內都保持來源檔案控制代碼為開啟狀態,而且該控制代碼是以 fmShareDenyWrite 開啟的。當 AppendToFile 接著試圖為了寫入而重新開啟同一個檔案時,載入器自身的共享模式拒絕了它,於是 API 甚至連一個位元組都還沒寫入就失敗了。
修復方法是將載入器的共享模式放寬為 fmShareDenyNone,這是安全的,正因為增量附加就是這樣運作的:它嚴格地在檔案結尾之後新增位元組,並且永遠不會重寫讀取器的長壽命控制代碼正在服務的區域。對於任何包裝此函式庫或建立類似串流載入器的人來說,一般的教訓是:懶惰的、握著控制代碼的讀取器與針對同一檔案的寫入器之間存在張力,而且您在開啟時所選擇的共享模式是一種 API 契約,而不是一個實作細節。如果在您的程式碼中 AppendToFile 曾經傳回 0,請先檢查您的處理程序中是否還有其他東西仍以限制性的共享模式持著目標檔案。
誠實的成本:當增量更新是錯誤工具時
增量更新用檔案大小交換寫入效率,而這種交換並不總是划算的。每次修訂都會附加它變更的物件,而被取代的定義則留在檔案中,所以一個被編輯了數百次的文件會累積死亡物件,以及一條每個讀取器都必須走完的冗長 /Prev 鏈。更糟的是,「已刪除」的內容並未消失:在第五次修訂版中刪除的文字仍然實際存在於第四次修訂版的位元組中,任何截斷檔案的人都能還原。因此,修改 (redaction)、淨化或任何敏感內容的移除都要求進行完整的重寫——對修改進行增量儲存是多了額外步驟的資料外洩。
當目標是壓縮(擠出累積的增量和未使用的物件)時,當改變諸如加密之類的全文件屬性時——重新加密會觸及每個字串和串流,因此改變的內容已經沒有什麼「增量」可言了——或者當產生一個編輯歷史不應隨檔案傳遞的乾淨交付物時,完整的儲存也是正確的呼叫。一個合理的規則是:當文件處於活躍並發生變化時,特別是當它帶有簽章時,使用 AppendToStream 或 AppendToFile;在文件離開您的系統或其歷史記錄必須被壓平的生命週期邊界處,使用完整的 SaveToStream 重寫。
增量更新、虛擬偏移差異輸出,以及直接至串流的序列化,全都是適用於 Delphi、C# 和 VB.NET 的標準 losLab PDF Library 的一部分;產品頁面在上述的簽章和大型檔案功能的旁邊列出了完整的儲存和附加 API 介面。