技術文章

Delphi 中可重現的 PDF 輸出:逐位元組相同的存檔

當 ReproducibleOutput 屬性為 True 時,HotPDF Delphi Component 產出的 PDF 在每次存檔之間逐位元組相同:它把 Info 的 /CreationDate 與 /ModDate 釘在一個固定日期、把跟著牆上時鐘走的文件識別碼換成帶種子或由內容衍生的雜湊、把 AES 加密路徑原本會抽取的每一個隨機位元組替換成常數,並對它序列化的每一個字典排序。這個旗標是為回歸測試套件與建置產物比對而存在的,不是為正式文件,而劃出這條界線的理由才是有意思的部分。推動這個功能的情境是 golden file 測試。您渲染一張發票、把 PDF commit 進去,並斷言明天的建置會產出相同的位元組。它從來不會。這個檔案在每個檢視器裡都開得好好的、文字相同、頁面樹相同,而 diff 依然在四、五個地方亮起來。任何試過把 PDF 產生器放到位元組層級回歸測試底下的人,都撞過這道牆,而解法不是「把時間戳拿掉」,而是精確盤點寫入器每一個去問文件以外東西的地方

為什麼同一份 PDF 存兩次會不一樣

同一份文件存兩次會不一樣,是因為 PDF 寫入器,包括 HotPDF,會去問四個與頁面內容毫無關係的熵來源:牆上時鐘、文件識別碼、密碼學亂數產生器,以及字典項目的記憶體順序。每一個單獨來看都合理。ISO 32000-1 也希望它們在。它們只是讓這個檔案變成「什麼時候、在哪裡寫的」的函數,而不是「裝了什麼」的函數

  • 時鐘。Info 字典帶著 /CreationDate 與 /ModDate(ISO 32000-1 §14.3.3、Table 317),形式是帶時區後綴的 D:YYYYMMDDHHmmSS 字串(§7.9.4),而 XMP 封包以 xmp:CreateDate 與 xmp:ModifyDate 重複同一個瞬間。HotPDF 兩者都從 FCreationDate 蓋章,而建構函式把它初始化為 Now,所以兩次存檔在它們被寫下的那一秒就不同
  • 識別碼。trailer 的 /ID 陣列(ISO 32000-1 §14.4)裝著一個永久識別碼與一個修改識別碼。HotPDF 的預設配方把檔名連同精確到毫秒的目前時間雜湊成第一個元素,再把它加上 GetTickCount 雜湊成第二個。兩個識別碼,每一輪都是兩個全新的值
  • 隨機位元組。標準安全性取決於識別碼與真正的隨機性。對 AES-256 而言,檔案加密金鑰、驗證與金鑰 salt,以及每一個 CBC 初始化向量都是從系統亂數源抽取的(ISO 32000-2 §7.6.4.4.7 要求 salt 必須隨機)。因為 /U、/UE、/O 與 /OE 全都由那些位元組算出來,一份加密文件即使明文沒變也會整體改變。較舊的演算法把第一個 /ID 元素捲進金鑰(ISO 32000-1 §7.6.3.3、§7.6.3.4),所以光是識別碼換新就足以把檔案重新加密金鑰
  • 順序。PDF 字典是無序的映射,而走訪自己記憶體清單的寫入器會以插入順序送出鍵。任何以不同次序建構資源字典的程式路徑,或一份從不同佈局剖析出來的已載入文件,都會產出一份合法但在文字上不同的檔案
讓同一份文件兩次 HotPDF 存檔不同的四個熵來源:由 Now 蓋章的 FCreationDate 餵給 D: 日期與 XMP 封包,trailer 的 /ID 雜湊檔名、時鐘與 GetTickCount,AES 從系統亂數源抽取金鑰材料,而字典以記憶體插入順序序列化
每個來源單獨來看都合理,ISO 32000-1 也希望它們在,但合在一起,它們就把檔案變成「什麼時候、在哪裡寫的」的函數,而不是「裝了什麼」的函數

ReproducibleOutput 釘住了什麼

在 BeginDoc 或 SaveLoadedDocument 之前設起 ReproducibleOutput := True,會把四個來源各自換成固定值,而且是在原本會去索取時鐘或亂數產生器的同一批程式路徑裡換的,所以不需要另做一趟清理。注意上面清單裡少了什麼:內容。字型、頁面串流、影像資料與交叉參照表對同一份輸入本來就是確定性的;雜訊全都住在中繼資料與安全層裡,這就是為什麼一個針對性的屬性就能把它移除。這個屬性預設為 False,程式庫裡沒有任何東西會替您打開它

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // 在 BeginDoc 之前
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

在 BeginDoc 裡,可重現分支會指定 FCreationDate := EncodeDate(2026, 1, 1),並用 MD5CalcString('HotPDF-reproducible-seed') 而不是檔名加時鐘的摘要來為文件識別碼播種。那一次指定同時涵蓋兩個 Info 日期與兩個 XMP 日期,因為四個全都是從同一個欄位渲染出來的。當檔案最後被寫出時,BuildDocumentIdentifiers 會向 ComputeCanonicalDocumentIdentifier 索取 trailer 識別碼:它以正規順序匯出整張物件圖、把它找到的任何 D: 日期字串的數字歸零,好讓時間戳無法透過雜湊漏回來,然後對結果取 MD5。/ID 的兩個元素都拿到那個值。同一套由內容衍生的識別碼也會用在「已載入文件被加密、卻從未經過 BeginDoc」的情況下,也就是對一個您用 LoadFromFile 開啟的檔案呼叫 ActivateProtection 的情形

隨機位元組是最不顯眼的替換。AES-256 的金鑰常式把它自己的亂數源包在一個本地輔助函式裡,而該函式在旗標之下,對 32 位元組的檔案加密金鑰與每個 8 位元組 salt 呼叫 FillChar(P^, Count, $5A),而 AES-128 與 AES-256 的字串與串流加密器則從 AESGenerateRandomIV 換成 AESGenerateStaticIV,後者把第 I 個槽位的初始化向量填成 14 * (1 + I)。金鑰、salt 與向量全部固定之後,/U、/UE、/O、/OE 與每一條加密串流在第二次執行時都一模一樣。最後,只要可重現旗標設起,SaveToStream 就會打開 DeterministicDictionaryOrder,序列化器接著依鍵名的原始位元組對每個字典做插入排序,較短的前綴優先,並以原始索引作為平手判定的依據。這與診斷寫入器用的是同一套排序,記在手工編輯 PDF 再修復這篇;可重現旗標只借了那套排序,沒有借用那個寫入器其餘的純文字佈局

ReproducibleOutput 在 HotPDF 裡釘住了什麼:建立日期變成 EncodeDate 2026, 1, 1,trailer 識別碼來自對正規化圖取 ComputeCanonicalDocumentIdentifier 並把 D: 的數字歸零,AES 金鑰與 salt 填 $5A 位元組、AESGenerateStaticIV 填每個槽位,而 DeterministicDictionaryOrder 對每個字典排序
這些替換跑在原本會去索取時鐘或亂數產生器的同一批程式路徑裡,所以不需要另做一趟清理,而 /ID 的兩個元素都拿到同一個由內容衍生的值

為什麼固定日期仍然漏了牆上時鐘

v2.752.2 的修法之所以存在,是因為固定建立日期原本是在建構函式裡決定的,而建構函式不可能知道呼叫端還沒設定的屬性。正常的呼叫順序是 Create、然後 ReproducibleOutput := True、然後 BeginDoc。在建構的時候 FReproducibleOutput 還是 False,所以 FCreationDate 收到 Now 並把它留著。識別碼與隨機位元組都正確地被釘住了,所以兩個檔案幾乎處處一致,只在恰好兩個日期字串與兩個 XMP 欄位上不一致。把那次指定移進 BeginDoc 的可重現分支、放在播種識別碼旁邊,就是把決定放到屬性已經有最終值的那一點上

漏掉這件事的那個回歸測試,比修法本身更值得一談。兩次存檔若都跑在同一個牆上時鐘秒數之內,就會意外寫出相同的 D: 字串,於是位元組比對對一個在任何較慢機器上都會失敗的 bug 亮了綠燈。修正後的測試在兩次存檔之間睡 1100 毫秒,好讓 PDF 時間戳保證跨越一個秒界,並對純檔案、AES-128 與 AES-256 輸出各跑一遍,兩個加密變體都帶真實密碼,再用 CompareMem 比對兩個緩衝區,失敗時回報第一個不同的位移,讓 diff 指向某個具體物件而不是一整個檔案。位元組比對證明的只有確定性,沒有別的,所以要另外留一條斷言:用使用者密碼重新載入加密輸出並讀取頁數;一個讓檔案同時變得穩定又讀不了的改動,絕不能靠一份綠色的 diff 蒙混過關

function SaveOnce(const Target: string): TBytes;
var
  Pdf: THotPDF;
  Stream: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.CryptKeyLength := aes256;
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, Stream.Size);
    if Stream.Size > 0 then
      Stream.ReadBuffer(Result[0], Stream.Size);
  finally
    Stream.Free;
  end;
end;

// 在測試主體裡
A := SaveOnce(PathA);
TThread.Sleep(1100);          // 強迫落在不同的 PDF 時間戳秒數
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
  'two saves under ReproducibleOutput must be byte-identical');

可重現的加密 PDF 還安全嗎

不安全。在 ReproducibleOutput 之下加密的文件,在任何有意義的層面上都不受保護,而任何會離開測試目錄的東西都必須把這個旗標關掉。AES-256 的檔案加密金鑰是 32 個 $5A 位元組,salt 是 8 個 $5A 位元組,而初始化向量遵循一套公開的算術模式。密碼仍然守著 /UE 與 /OE 外層,但被包住的金鑰是常數,所以任何知道那個常數的人完全不需要密碼就能解開每一條內容串流。salt 固定也移除了 ISO 32000-2 §7.6.4.4.7 所依賴的逐文件唯一性,那份唯一性原本是用來防止相同密碼在不同檔案之間產生相同的 /U 字串。想看加密屬性在亂數源完好時承諾什麼,請讀AES-256 設定這篇;在可重現旗標之下,那些承諾是暫停的

識別碼的取捨更微妙。ISO 32000-1 §14.4 的用意是第二個 /ID 元素在每次修改時都要改變,好讓工具能分辨一份更新過的檔案與它的前身,而可重現的存檔把同一個值寫進兩個槽位。因為那個值是對正規化物件圖取的雜湊,內容不同的兩份文件仍然會拿到不同的識別碼,這比用常數好。但 BeginDoc 用來推導金鑰的種子,在每台機器上的每一份文件都是同一個字串,而一個以 /ID 為鍵來分辨檔案的讀取者,例如註解快取或表單資料的 sidecar,會把每一份剛好雜湊相同的可重現檔案混為一談

這個旗標沒有涵蓋什麼

ReproducibleOutput 移除的是寫入器自己引入的熵;它無法移除從環境或它不控制的程式路徑進來的熵,而其中有三種很容易踩到

  • 時區後綴。_DateTimeToPdfDate 會附加本地 UTC 位移,所以在一個建置代理上 D:20260101000000+08'00'、在另一台上 D:20260101000000-05'00',對同一個固定日期是不同的位元組。可重現性在同一台機器上的多次執行之間成立,或在共用同一時區的機群之間成立;如果您的 golden file 會旅行到別處,就把代理的時區釘住
  • 增量更新。SaveIncrementalUpdate 從目的路徑、GetTickCount 與目前時間算出它的修改識別碼,完全沒有可重現分支,因為一個增量區段按定義就是一次新修改。要比對的是完整改寫,不是附加的差分
  • 直通捷徑。SaveLoadedDocument 通常會把未修改、未加密的來源檔逐位元組複製過去,而不是重新序列化它。可重現旗標會停用那條捷徑並強制完整改寫,好讓排序與識別碼規則生效,這代表一份已載入檔案的可重現存檔比預設慢,而且永遠不會是輸入的複本。請把它與先前的可重現存檔相比,絕不要與原始檔相比
HotPDF 可重現存檔到哪裡為止:_DateTimeToPdfDate 仍然附加本地 UTC 位移,所以 golden file 跨時區就會不同;SaveIncrementalUpdate 沒有可重現分支,因為差分就是一次新修改;而直通捷徑被停用,所以已載入檔案永遠是完整改寫
可重現性在同一台機器的多次執行之間、或在共用同一時區的機群之間成立,而一份可重現存檔應該與先前的可重現存檔相比,絕不要與原始輸入相比

同一個版本還有一個教訓,關於一個通過的檢查證明與不證明什麼。一個 PDF/X-6 測試資料呼叫了 CharProcs.DeleteValue('A'),把一個被直接持有的字符串流釋放掉,然後又把同一個指標重新插入,另外還把一個直接 ExtGState 物件同時交給一個資源字典與一個 pattern。合規驗證器在那個 use-after-free 與雙重擁有上時靈時不靈,因為它讀到的是那塊已釋放記憶體剛好還留著的東西。當一個結構檢查閃爍不定,先去看測試輸入的擁有關係,再去看驗證器。可重現輸出讓這份紀律更便宜:一旦兩次存檔逐位元組相同,閃爍的唯一剩下來源就是物件圖本身,而從 catalog 往下做的結構 diff會把它找出來

這裡描述的 ReproducibleOutput、DeterministicDictionaryOrder 與加密屬性都在標準的 HotPDF Delphi Component 裡出貨,支援 Delphi 與 C++Builder,而同一個旗標也驅動程式庫自己的回歸語料庫,所以您在測試套件裡得到的行為,就是這個元件被測試時所用的行為