losLab PDF Library 只要呼叫 SetDeterministicDocumentID(1),就能對相同輸入產生逐位元組相同的 PDF 輸出。預設情況下 trailer 的 /ID 陣列是掛鐘時間的 MD5 摘要,所以同一個產生器跑兩次,至少那幾個位元組會不同。決定性模式改從穩定的種子推導 /ID,藉此恢復可重現建置
這個症狀通常在 CI 裡先冒出來,等你去找才發現。範本沒改過,輸入紀錄沒改過,字型沒改過,產生出來的 PDF 卻在每次管線執行時雜湊值都不同。建置快取永遠打不中。內容定址儲存體每夜建置都會多出一份全新的 blob。沒人動過的檔案卻在位元組層級回歸比對中亮起紅燈。把差異一路追到實際位元組,幾乎每次都是同一小撮落在檔案 trailer 裡的十六進位數字
Trailer ID 陣列的用途是什麼
Trailer 的 /ID 是檔案身分標記,不是內容的校驗碼。ISO 32000-1 §14.4 把它定義為兩個位元組字串組成的陣列:第一個元素是文件建立時指派的永久識別碼,理應在往後每次編輯後都存活下來;第二個元素是變更識別碼,寫入者每次修改檔案時都會刷新。兩者合起來,讓系統能判斷兩個檔案是同一份文件的不同版本,還是兩份互不相干的文件。§7.5.5 在實務上讓這個項目幾近強制,因為 trailer 只要同時帶有 /Encrypt,就必須帶有 /ID
規範裡並未說明該如何計算這個值。建議做法是對諸如目前時間、檔案路徑、檔案大小和文件資訊字典之類的東西取摘要,而掛鐘時間正是讓結果獨一無二的成分。這恰好是身分識別想要的特性,也恰好是破壞可重現性的特性,所以這件事需要是一個明確的開關,而不是一個悄悄改變的行為
為何同一份建置每次產生的 PDF 都不一樣?
因為預設的識別碼是從產生的那一刻推導出來的。過去 losLab PDF Library 用目前時間戳記的 MD5 建構 /ID 字串,所以就算檔案裡其他每個位元組完全相同,相隔一秒鐘產生兩次的文件也會帶有兩個不同的永久識別碼。下游代價是實實在在的:以雜湊值為鍵管理成品的建置系統永遠無法重用某個 PDF 步驟,去重物件儲存體每次建置都保留一份副本,而不是每份文件保留一份副本,看著二進位差異的審查者,得先證明唯一的變化是雜訊,才能信任其餘的差異結果。決定性 /ID 產生存在的目的就是消除這類雜訊,其精神與物件串流與交叉參照串流筆記裡描述的版面穩定性工作相通
切換到可重現的識別碼
決定性模式是選擇性啟用、以每份文件為單位、預設關閉,所以在你要求之前,既有輸出不會有任何變化。SetDeterministicDocumentID 接受 0 或 1,值被接受時回傳 1,超出範圍則回傳 0;GetDeterministicDocumentID 回報目前狀態。SetDocumentIDSeed 提供明確的種子字串,其優先權高於一切,傳入空種子則會回復成推導出來的種子。GetDocumentFileID 在儲存後讀回 /ID[0],方便你記錄或斷言檢查
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed('invoice-4471-rev3');
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Invoice 4471');
Lib.SaveToFile('invoice.pdf');
FileID := Lib.GetDocumentFileID; // identical on every run
finally
Lib.Free;
end;
end;
刷新動作發生在儲存時,而非切換旗標的當下,所以在建置文件過程中較晚才啟用決定性模式仍然生效。這也表示變更過的種子要到下一次完整儲存才會反映到檔案上:設定種子 A、儲存、設定種子 B、儲存,兩個檔案會帶有不同的識別碼,而恢復種子 A 就會恢復原本的值。當文件有天然的穩定鍵值,例如發票編號、紀錄修訂版本或 git commit 識別碼時,明確種子就是正確選擇,因為它讓識別碼與偶發性中繼資料脫鉤
沒有提供種子時,種子從何而來?
沒有明確種子的情況下,losLab PDF Library 會從理應在相同文件重新產生時保持不變的文件狀態推導出一個種子:PDF 版本標頭、頁數,以及文件資訊字典裡的每一個項目。字串與名稱值會逐字取用,其他物件型別則貢獻其序列化形式,整體再被雜湊進 /ID 字串。重要的結果是 CreationDate 與 ModDate 屬於資訊字典的一部分,因此依設計也是種子的一部分。只有兩次執行真正產生出相同的文件中繼資料時,才會得到相同的識別碼
Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
Lib.SetInformation(2, 'Quarterly Report'); // Title
Lib.SetInformation(5, 'reporting-service 4.2'); // Creator
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SaveToFile('report.pdf');
用鍵值 8 釘住 ModDate 有雙重作用,而這正是容易讓人踩坑的地方。單靠決定性 /ID 並不會讓檔案逐位元組相同,因為除非呼叫端已明確設定,否則儲存路徑會用目前時間戳記 ModDate。設定鍵值 8 會把該值標記為呼叫端提供,並抑制那次戳記。如果你要的是可重現的檔案而不只是可重現的識別碼,就該把中繼資料時間戳記當成建置輸入來對待:從來源紀錄或固定的紀元推導它們,而不是取自 Now
為何重寫 ID 會破壞加密 PDF?
因為 /ID[0] 在加密文件裡不只是中繼資料,它是金鑰材料。ISO 32000-1 §7.6.3.3 演算法 2,在標準安全處理常式修訂版 2 到 4 中,把檔案識別碼的第一個元素連同已填補的密碼、/O 值與權限位元,一起餵進加密金鑰的計算過程。推導出的金鑰接著會產生 /U 驗證字串,讀取程式在開啟檔案時會檢查它,而檔案金鑰的推導與快取發生在你呼叫 Encrypt 或載入加密文件時,兩者都在儲存之前發生。若在儲存過程中重寫識別碼,就會產生一份結構上有效、但重新開啟時 /U 檢查會失敗的檔案:這不是細微的損壞,而是一份沒有人能開啟的文件,包括你自己。這就是為何決定性刷新只限於不帶有加密狀態的文件,而已加密的文件無論是否啟用決定性模式,都會保留原本的 /ID,這項設定在該路徑上完全不起作用。相關的修訂版處理與權限語意,在PDF 加密與權限稽核的說明中有涵蓋。另請注意,加密還原路徑只會刷新 /ID[1],也就是變更識別碼,這正是 §14.4 的本意
為何增量儲存會保留原本的識別碼
第二個邊界是附加模式。增量更新不會動到檔案中任何較早的位元組,而是在其後寫入一個新的修訂版,而 §14.4 中 /ID[0] 的永久性正是讓後續使用者得知新修訂版屬於同一份文件、而非另一份文件的依據。重寫它會切斷這個關聯,與檔案中已存在的修訂版相矛盾,並干擾簽章語意,因為簽章涵蓋的是特定文件某個特定修訂版的位元組範圍。因此 losLab PDF Library 只在完整儲存時刷新決定性識別碼,附加模式下絕不刷新,這樣就能維持PDF 增量更新與附加至串流一文所述的保證不變
識別碼產生的單一收斂點
losLab PDF Library 裡所有的 /ID 產生現在都匯聚到單一內部常式 NewFileIDString,這正是讓決定性開關值得信賴、而非只是修補某一條程式路徑的關鍵。空白文件建立、按需延遲建立缺少的 /ID 陣列,以及加密指紋還原路徑,全都呼叫它,因此掛鐘時間唯一可能滲回的入口就只有這一處。這也表示未來的變體,例如以內容推導的識別碼,只需要改動一個函式,而不必稽核整個序列化器
function BuildQuote(const Seed: WideString): AnsiString;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(Seed);
Lib.SetInformation(7, 'D:20260101000000Z');
Lib.SetInformation(8, 'D:20260101000000Z');
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Quote 8812');
Result := Lib.SaveToString;
finally
Lib.Free;
end;
end;
// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
在依賴任何地方的可重現輸出之前,先把這個比對接進你的測試套件,因為一旦有新功能重新引入時間戳記,它就會立刻大聲失敗。可重現性是一種若不維護就會悄悄流失的特性,而對兩次記憶體內儲存做一次斷言,每次建置的成本幾乎可以忽略不計
這裡展示的決定性識別碼 API,隨 Delphi 與 C++Builder 版的 losLab PDF Library 一併出貨,連同完整的文件資訊、加密與增量儲存參考文件