技術文章

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

PDFium Component 把可重現的 PDF 輸出變成一次呼叫:TPdf.SaveAsReproducible 改寫文件,讓同樣內容存兩次得到逐位元組相同的檔案。它從文件資訊字典移除 /CreationDate 與 /ModDate、不寫 trailer /ID、保持物件順序穩定,並在 TPdfReproducibleReport 裡回報它改了什麼。加密檔案會被拒收,簽章檔案需要明確同意

需求多半出現在組建管線裡。有人把產生的 PDF 提交進儲存庫或黃金檔測試集,每次執行都產生一份 diff,儘管每一頁什麼都沒變。或者封存按 SHA-256 去重,卻悄悄存了五十份同一張發票,因為每次存檔都往檔案裡蓋了一個新時間。內嵌掛鐘的 PDF 寫出器對桌面應用沒問題,對任何把位元組當身分的東西則是敵人

同一份 PDF 存兩次,位元組為什麼會不同?

同一份未變更文件存兩次會不同,精確地說只有兩類資料的功勞:文件資訊字典裡的時間戳,與 trailer 裡的檔案識別碼。細心的寫出器輸出的其他一切——物件編號、物件順序、交叉參照版面——都可以做到確定,而 PDFium Component 的完整改寫路徑已經做到了:物件按物件編號升序寫出,xref 表遵循同樣的順序

時間戳是明擺著的元兇。ISO 32000-1 §14.3.3 在 /Info 字典裡定義 /CreationDate 與 /ModDate,PDFium 原生存檔每次序列化文件都寫一個新的 /ModDate。檔案識別碼才是微妙的那個。ISO 32000-1 §14.4 把 /ID 描述成雙元素位元組字串陣列,第一個元素標識原始檔案,第二個隨修訂而變。寫出器通常靠雜湊檔案的某些東西推導這些字串,如果那個東西包含帶時間戳的位元組,識別碼就繼承了不確定性

在 Delphi 裡使用 TPdf.SaveAsReproducible

TPdf.SaveAsReproducible 有兩個多載,一個寫 TStream、一個寫檔名,都回傳 Boolean,帶 out Report: TPdfReproducibleReport 與預設 False 的可選參數 AllowSignedDocument。回傳值與 Report.Succeeded 一致;為 False 時,Report.ErrorMessage 說明原因。檔案多載先寫到目標旁邊的暫存檔,存檔成功才替換目標,所以失敗的執行不會在您上一份組建輸出的位置留下一份寫了一半的 PDF

var
  Pdf: TPdf;
  Report: TPdfReproducibleReport;
begin
  Pdf:= TPdf.Create(nil);
  try
    Pdf.FileName:= 'invoice-2026-10.pdf';
    Pdf.Active:= True;
    if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
      Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
        '%d timestamps stripped', [Report.SourceByteCount,
        Report.OutputByteCount, Report.KeptObjectCount,
        Report.PrunedObjectCount, Report.StrippedTimestampCount]))
    else
      Writeln('Reproducible save failed: ', Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

報告欄位值得在 CI 裡記錄。StrippedTimestampCount 對 /Info 帶兩個日期的文件通常是 2,對完全沒有 /Info 字典的文件是 0。PrunedObjectCount 計的是出現在原生輸出裡、卻無法從 catalog 或 /Info 字典到達的物件;可重現改寫會把它們丟掉,所以輸出預期與普通存檔同尺寸或更小

為什麼只去掉時間戳還不夠

第一版實作把兩個時間戳都去掉了,產出的卻還是兩份不同的檔案。頁面一致、/Info 字典一致,trailer 卻在 /ID 的第二個元素上不同。根因是順序:改寫先向 PDFium 要一次原生存檔,再後處理那些位元組,trailer 建構器靠雜湊原生輸出算出 /ID。原生輸出裡還帶著新鮮的 /ModDate,所以雜湊、連同識別碼,每次執行都在變——儘管時間戳本身從來沒進過最終檔案

這是可重現組建通用規則的好例子:如果先有東西從它推導出來,光移除一個非確定值並不夠。修法是乾脆不帶 /ID。TPdf.SaveAsReproducible 寫出的 trailer 只含 /Size、/Root,來源有的話再加 /Info。trailer 裡不再有任何依賴時間的東西,同一份文件連續兩次存檔逐位元組相等——行為正是這樣驗證的

function SavesAreIdentical(Pdf: TPdf): Boolean;
var
  A, B: TMemoryStream;
  RA, RB: TPdfReproducibleReport;
begin
  A:= TMemoryStream.Create;
  B:= TMemoryStream.Create;
  try
    Result:= Pdf.SaveAsReproducible(A, RA) and
      Pdf.SaveAsReproducible(B, RB) and
      (A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
  finally
    B.Free;
    A.Free;
  end;
end;

移除字典鍵而不弄壞字串

時間戳是靠對 /Info 物件本體做一次認得 token 的掃描移除的,不是文字搜尋。對 /ModDate 做天真的位元組替換,也會命中碰巧含有這段文字的 /Subject 或 /Keywords 字串,而從字面字串裡剪掉位元組,是悄悄弄壞詮釋資料而不是大聲失敗。PDFium Component 先在本體的掃描副本裡把字面字串與註解去活性化,然後只接受真正名稱 token 開頭之處的匹配,用 PDF 物件文法找出該鍵值的結尾,整組鍵值對一起跳過

實務上的後果是:/Info 裡其他每個條目——標題、作者、產生器、自訂鍵——原樣保留。被移除的只有定義為日期的那兩個鍵,而且只從 trailer 的 /Info 條目真正指向的那個字典裡移

省略 /ID 對加密與簽章 PDF 的代價是什麼?

省略 /ID 對普通 PDF 1.x 輸出安全,卻排除了加密,也改變簽章的命運。ISO 32000-1 裡,trailer 的 /ID 是可選的,除非存在 /Encrypt 條目,因為標準安全處理器會把第一個識別字串混進加密金鑰。沒有 /ID 的檔案不可能是正確加密的檔案,所以 TPdf.SaveAsReproducible 不去嘗試:trailer 帶 /Encrypt 時,它回傳 False,Report.ErrorMessage 說文件已加密。管線允許的話先解密,否則把加密交付物排除在位元組一致性檢查之外。目標是 PDF 2.0 的話,注意 ISO 32000-2 收緊了規則、每份檔案都要求 /ID,所以可重現輸出最自然適配的是 PDF 1.x 目標;Delphi 的精確 PDF 版本合規一文講元件怎麼處理版本目標

簽章文件是另一種問題。TPdf.SaveAsReproducible 是完整、非增量的改寫,位元組偏移會移動,既有簽章的 /ByteRange 不再覆蓋被簽的那些位元組。因此,對簽章檔案做可重現輸出,是一個刻意放棄既有簽章效力、換取穩定位元組的決定——這就是 SignatureCount 大於零時該方法丟 EPdfError、除非您傳 AllowSignedDocument = True 的原因

if Pdf.SignatureCount > 0 then
  // 明確選入:改寫會移動位元組偏移,既有
  // 簽章之後應預期驗證失敗
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);

如果簽章原件是真相的紀錄,就讓它維持原樣,可重現輸出只用在衍生的副本上,例如您在 CI 裡 diff 的正規化演繹版。要在不打擾既有內容的前提下對受保護檔案做追加,見為什麼加密的 PDF 無法在 Delphi 裡被悄悄修補

依賴位元組一致性之前該知道的邊界

位元組一致性對「同一份文件、同一個元件建置」成立,而且只覆蓋這個方法碰的東西。TPdf.SaveAsReproducible 只去掉 /Info 的兩個日期;帶著自己的 xmp:CreateDate 或 xmp:ModifyDate 的 XMP 詮釋資料流,原樣帶著文件已有的位元組走。這在多次存檔之間穩定,卻意味著 XMP 日期與 /Info 日期不再描述同一件事——對要求兩者一致的 PDF/A 設定檔來說這有影響;PDF/A Info 與 XMP 詮釋資料等效性一文解釋那些規則。不同的 PDFium 二進位檔或元件版本也可能以不同方式序列化物件,任何跨機器比雜湊的管線都要把兩者釘死

在這些邊界之內,這個方法給了 CI diff、黃金檔測試與內容定址封存需要的東西:同樣輸入、同樣位元組。TPdf.SaveAsReproducible 與 TPdfReproducibleReport 隨 PDFium Component for Delphi and C++Builder 出貨,與其餘存檔、最佳化與合規 API 同在

FAQ

TPdf.SaveAsReproducible 會改變頁面內容嗎?

不會。它從 /Info 字典移除 /CreationDate 與 /ModDate 鍵、省略 trailer /ID,並丟掉無法從 catalog 或 /Info 到達的物件;頁面內容原樣寫出

能從加密 PDF 產出可重現輸出嗎?

不能。加密依賴 trailer 的 /ID,而可重現輸出省略它,所以對加密來源該方法回傳 False,並附上說明原因的 ErrorMessage

簽章的 PDF 為什麼需要 AllowSignedDocument?

可重現存檔是移動位元組偏移的完整改寫,既有簽章不再匹配它們簽署的位元組範圍。傳 AllowSignedDocument = True 表示您確認接受這筆交易