用同一份範本與資料產生同一張發票兩次,然後比對兩份 PDF。有兩處不同:trailer /ID 陣列裡的十六進位字串,與文件資訊字典裡的 ModDate 字串,兩者都取自時鐘。在 losLab PDF Library 裡,SetDeterministicDocumentID(1) 加上固定的 ModDate,就能讓沒有逐次執行 XMP UUID 或附件的文件,兩份輸出位元組相同
最直覺的第一步是把日期釘住再重跑。CreationDate 與 ModDate 這下相符了,雜湊仍然不同——現在只差在檔案最末端、/ID [<…> <…>] 裡的兩串 32 位十六進位字串。那個識別碼是獨立於 metadata 計算的,只要它還依賴存檔時間,以檔案雜湊為鍵的建置快取就永遠不命中,內容定址儲存庫也變成一次執行一個 blob,而不是一份文件一個
trailer ID 陣列是做什麼用的
trailer 的 /ID 識別的是檔案,不是內容校驗和。ISO 32000-1 §14.4 把它定義為兩個字節字串的陣列。第一個是永久識別碼,文件建立時指派,目的是撐過之後的每一次編輯;第二個是變動識別碼,寫入器每次修改檔案就刷新。文件管理系統比較這一對,判斷兩份檔案是同一份文件的修訂版,還是兩份不相干的文件。§7.5.5 的表 15 規定 trailer 帶 /Encrypt 時此條目必填,其他情況選填;§14.4 也提醒,省略它會妨礙依賴檔案身分的工作流程
規範從未規定值怎麼算。它建議對當前時間、檔案路徑、檔案大小與資訊字典的值之類的東西做 MD5 摘要,而時鐘正是讓結果唯一的那味材料。唯一性恰恰是你對身分標記的期待,也恰恰是摧毀可重現性的東西。losLab PDF Library 預設以最簡單的形式遵循那個建議——對當前時間戳做 MD5——所以相隔一秒建立的兩份文件,即使其他每一個位元組都相同,永久識別碼也不同
開啟可重現的識別碼
確定性模式是逐文件的開關,預設關閉,所以在你開口之前,既有輸出不會改變。SetDeterministicDocumentID 接受 0 或 1,值被接受時回傳 1,其他回傳 0;GetDeterministicDocumentID 回報當前設定。SetDocumentIDSeed 提供明確的種子字串,種子就位後,識別碼就是該字串的 MD5,寫進陣列的兩個元素。GetDocumentFileID 在存檔後讀回 /ID[0],方便記錄日誌或寫測試斷言
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1); // 緊跟在 Create 之後
Lib.SetDocumentIDSeed('invoice-4471-rev3');
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Invoice 4471');
Lib.SaveToFile('invoice.pdf');
FileID := Lib.GetDocumentFileID; // 每次執行都是同一個值
finally
Lib.Free;
end;
end;
識別碼在存檔時才計算。在文件建置後期才翻開開關也行,改過的種子在下一次完整存檔時進到檔案:用種子 A 存一次、再用種子 B 存一次,兩份輸出帶不同識別碼;換回種子 A,原值又回來了
不過,開關要在建立物件之後立刻打開。字型、影像、圖形狀態與其他頁面資源會在頁面的資源字典裡取得名稱,確定性模式開著時,這些名稱來自計數器。開關之前加進來的東西保留隨機產生的名稱——識別碼依然穩定,但內容串流裡的幾個位元組會每次執行都不同
沒設種子時,識別碼為什麼還是會變?
因為資訊字典也在雜湊裡。種子留空,或傳空字串給 SetDocumentIDSeed 清掉它,losLab PDF Library 就從檔案本身推導識別碼:寫出時把序列化的本體跑一遍 SHA-256,取摘要的前 16 位元組填進 /ID 的兩個元素。頁面內容任何變動都會改變識別碼,這正是重點。麻煩在於 CreationDate 與 ModDate 也住在那個本體裡,而除非呼叫端設過,存檔常式會給 ModDate 蓋上當前時間
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(''); // 從存出的位元組衍生 /ID
// 本體雜湊涵蓋 Info 字典,所以日期必須是
// 建置輸入。鍵 8 也阻止存檔給 ModDate 蓋章。
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 在這裡一石二鳥,也是人們最常漏掉的那一行。透過 SetInformation 設 ModDate,會把日期標記為呼叫端供應,從此 SaveToFile 與 SaveToString 不再碰它。少了這一行,每次存檔都寫入新時間戳、雜湊的本體改變,內容衍生的識別碼跟著變。日期請取自來源記錄或固定紀元。Now 在可重現建置裡沒有立足之地
有幾樣東西刻意排除在雜湊之外。隨機產生的資源名稱與 XMP xmpMM:InstanceID 條目裡的 UUID,會在雜湊前先正規化,所以只在這些權杖上不同的兩次執行拿到相同的 /ID。檔案位元組在那幾處仍然不同,所以每次執行帶新 instance UUID 的 PDF/A 風格文件,給你的是可重現的識別碼,而不是位元組相同的檔案。內嵌附件是另一個漂移來源:AddAssociatedFileFromString 會給附件自己的 /ModDate 蓋當前時間,而那個日期跟其他本體位元組一樣進雜湊
明確種子,還是內容雜湊
對建置產物,我們會讓種子留空。內容衍生的識別碼恰在位元組改變時改變,以它為鍵的快取絕不可能吐出過期檔案,你也不用再維護一把要跟資料同步的鍵。明確種子適合另一種工作:下游封存把 /ID[0] 當文件身分,期待同一張發票的每次重新產生都帶同一識別碼,即使範本已經改過。這更貼近 §14.4 的本意,代價是兩份肉眼可辨的不同檔案可能共用一個識別碼
內容路徑有一個後果值得刻意決策。載入既有 PDF、開著確定性模式完整存出時,進來的 /ID 被丟棄,新的識別碼只取決於寫出了什麼。trailer 識別碼不同的兩份來源檔案,出來帶相同的 /ID。正規化建置步驟要的正是這個。如果你在編輯客戶的文件、而且它的身分必須延續,那份工作就別開確定性模式,或者用增量方式存
為什麼改寫 ID 會弄壞加密 PDF?
因為在加密文件裡,/ID[0] 是金鑰材料。ISO 32000-1 §7.6.3.3 演算法 2 會把檔案識別碼的第一個元素,連同補位後的密碼、/O 值與權限位元,一起餵進修訂 2 到 4 標準安全處理器的加密金鑰計算。那把金鑰接著產生讀取器開檔時核對的 /U 字串。金鑰在你呼叫 Encrypt 或載入加密檔案時就已推導,早於任何存檔,所以存檔中途換掉識別碼,會產生一份格式完好、密碼檢查卻對誰都不過——包括你自己——的檔案
因此 losLab PDF Library 只在未加密的完整存檔時刷新決定性識別碼。加密文件保留它已有的 /ID,開關對那條路徑沒有作用。用使用者密碼重新開啟這種檔案一切如常。加密輸出也別放進位元組比對:§7.6.2 之下的 AES 加密,每個加密字串與串流都以隨機的 16 位元組初始向量開頭,同一份文件加密兩次永不逐位元組相符。需要回歸檢查的話,把兩份都解密再比對收回的內容。修訂處理與權限位元的深入說明,見PDF 加密與權限稽核一文的完整走讀
增量存檔保留原始識別碼
增量更新把檔案先前的每一個位元組原樣留下,在其後附加新修訂。/ID[0] 的永久性,正是讓接收方判斷「附加的修訂屬於同一份文件」的依據。改寫它會跟檔案裡已有的修訂矛盾,也會動搖簽章工作流程,因為簽章覆蓋的是某一特定修訂的位元組範圍。確定性模式下,附加模式絕不碰識別碼,PDF 增量更新與附加到串流一文描述的行為因此完好如初
讓輸出長期保持可重現
產生器之後的每一處改動,都是把時鐘值塞回檔案的機會:印出產生時間的水印、附加檔案的程式路徑、另一個存檔選項。存檔選項與內容同樣要緊:把物件打包進壓縮物件串流,產生的檔案佈局與傳統交叉參考表截然不同,物件串流與交叉參考串流的筆記解釋了其中細節,所以每條管線把這些設定釘死。最便宜的防線,是一個在記憶體裡把同一份文件建兩次、比較位元組的測試
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;
// 兩次獨立建置必須得到同一個位元組序列
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
同一套 harness 可以比較同一來源的序列與並行 RecompressStreams 執行:把來源載入兩個 TPDFlib 實例,兩邊都開確定性 ID、釘住 ModDate,一邊設 SetOptimizationWorkers(1)、另一邊設更高的 worker 數,然後比對兩份 SaveToString 結果。時鐘已從兩份檔案裡拿掉,剩下的差異就只能來自重新壓縮本身
可重現的 trailer 識別碼、metadata 控制與增量存檔,都是 losLab PDF Library for Delphi and C++Builder 的一部分,其參考文件也列出這裡提到的加密選項