PDFium Component は、再現可能な PDF 出力を 1 回の呼び出しにします。TPdf.SaveAsReproducible が文書を書き直し、同じコンテンツを 2 回保存すればバイト単位で同一のファイルが得られるようにします。文書情報辞書から /CreationDate と /ModDate を取り除き、トレーラーの /ID を書かず、オブジェクト順序を安定に保ち、何を変えたかを TPdfReproducibleReport で報告します。暗号化ファイルは拒否され、署名付きファイルは明示的な同意を要します
この需要が現れるのは、たいていビルドパイプラインです。誰かが生成された PDF をリポジトリやゴールデンファイルのテストスイートに入れ、どのページも変わっていないのに、実行のたびに diff が出ます。あるいはアーカイブが SHA-256 で重複排除し、同じ請求書の 50 部を黙って格納します。保存のたびに新しい時刻がファイルに刻まれるためです。壁時計を埋め込む PDF ライターは、デスクトップアプリには良く、バイトを同一性として扱うものすべてには敵対的です
同じ PDF を 2 回保存するとバイトが違うのはなぜか
無変更の文書の 2 回の保存が違うのは、ちょうど 2 種類のデータのためです。文書情報辞書の中のタイムスタンプと、トレーラーの中のファイル識別子です。注意深いライターが出力するその他すべて、オブジェクト番号、オブジェクト順序、クロスリファレンスレイアウトは、決定論的にできます。そして PDFium Component のフルリライト経路は既にそれをやっています。オブジェクトは昇順のオブジェクト番号で書かれ、xref テーブルは同じ順序に従います
タイムスタンプは明白な犯人です。ISO 32000-1 §14.3.3 が /Info 辞書の中の /CreationDate と /ModDate を定義し、ネイティブの PDFium 保存は文書をシリアライズするたびに新しい /ModDate を書きます。ファイル識別子は微妙なやつです。ISO 32000-1 §14.4 は /ID を、バイト文字列の 2 要素配列と記述します。第 1 要素はオリジナルのファイルを識別し、第 2 要素はファイルが改訂されるたびに変わります。ライターはたいてい、ファイルについての何かをハッシュしてこれらの文字列を導出します。その何かに時刻付きのバイトが含まれていれば、識別子は非決定性を相続します
Delphi で TPdf.SaveAsReproducible を使う
TPdf.SaveAsReproducible には 2 つのオーバーロードがあり、1 つは TStream へ、もう 1 つはファイル名へ書き、どちらも out Report: TPdfReproducibleReport と、既定が False のオプションの AllowSignedDocument パラメータを伴う Boolean を返します。戻り値は 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 辞書からも到達できなかったオブジェクトを数えます。再現可能なリライトはこれらを落とすため、出力は普通の保存と同じサイズか、より小さくなると見込んでください
タイムスタンプの除去だけでは足りなかった理由
最初の実装は両方のタイムスタンプを取り除きながら、それでも違う 2 つのファイルを生みました。ページは一致し、/Info 辞書は一致し、トレーラーは /ID の第 2 要素で食い違っていました。根因は順序でした。リライトはまず PDFium にネイティブ保存を求め、それからそのバイト列を後処理します。そしてトレーラーのビルダーは、ネイティブ出力をハッシュして /ID を計算していました。そのネイティブ出力はまだ新しい /ModDate を含んでいたため、ハッシュひいては識別子は、タイムスタンプ自体が最終ファイルに届かないにもかかわらず、実行のたびに変わりました
これは、再現可能なビルドのための一般規則の見本のような例です。何かがそれから先に導出されていたなら、非決定的な値を取り除くだけでは足りません。修正は、/ID を運ぶのを完全に止めることでした。TPdf.SaveAsReproducible は、/Size と /Root、そしてソースが持っていれば /Info だけを含むトレーラーを書きます。トレーラーに時刻依存のものが何も残らなければ、同じ文書の 2 回の連続保存はバイト単位で等しくなります。挙動の検証もまさにこのやり方で行われます
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 オブジェクト本体のトークンを認識する走査で取り除かれます。/ModDate への素朴なバイト置換は、たまたまそのテキストを含む /Subject や /Keywords の文字列にも当たります。そしてリテラル文字列からバイトを切り取ると、大声で失敗する代わりに、メタデータが黙って壊れます。PDFium Component はまず、本体の走査コピーの中のリテラル文字列とコメントを無害化し、その後、本物の名前トークンが始まる場所でのみマッチを受け入れ、PDF オブジェクト文法でそのキーの値の終わりを見つけ、キーと値のペア全体をスキップします
実務上の帰結として、/Info のその他のエントリ、タイトル、作成者、プロデューサ、カスタムキーは、手つかずで生き延びます。取り除かれるのは日付と定義された 2 つのキーだけで、しかもトレーラーの /Info エントリが実際に指す辞書からだけです
暗号化と署名付き PDF に対して /ID の省略は何を代償にするのか
/ID の省略は、素の PDF 1.x 出力には安全ですが、暗号化を不可能にし、署名に何が起こるかを変えます。ISO 32000-1 では、/Encrypt エントリが存在しない限りトレーラーの /ID はオプションです。標準セキュリティハンドラは、最初の識別子文字列を暗号化鍵に混ぜ込むからです。/ID のないファイルは、正しく暗号化されたファイルにはなり得ないため、TPdf.SaveAsReproducible は試みません。トレーラーが /Encrypt を運ぶとき、False を返し、Report.ErrorMessage が文書が暗号化されていると告げます。パイプラインが許すなら先に復号するか、暗号化された成果物をバイト一致の検証の外に置いてください。PDF 2.0 をターゲットにするなら注意してください。ISO 32000-2 は規則を引き締め、すべてのファイルに /ID を要求します。再現可能な出力が最も自然に収まるのは PDF 1.x のターゲットです。Delphi における正確な PDF バージョン適合の記事が、コンポーネントがバージョンターゲットをどう扱うかを扱います
署名付き文書は別の問題です。TPdf.SaveAsReproducible は完全な非インクリメンタルのリライトであるため、バイトオフセットが動き、既存の署名の /ByteRange は、署名されていたバイトをもうカバーしません。したがって署名付きファイルの再現可能出力は、安定なバイトと引き換えに既存の署名の有効性を諦めるという、意図的な決断です。AllowSignedDocument = True を渡さない限り、SignatureCount がゼロより大きいときにこのメソッドが EPdfError を上げるのは、このためです
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 が剥ぎ取るのは 2 つの /Info の日付だけです。独自の xmp:CreateDate や xmp:ModifyDate を持つ XMP メタデータストリームは、文書が既に保持しているバイト列のまま運ばれます。保存をまたいで安定ではありますが、XMP と /Info の日付はもう同じものを記述しないということです。両者の一致を要求する PDF/A プロファイルにとってはこれが効いてきます。PDF/A の Info と XMP メタデータの等価性の記事がその規則を説明します。別の PDFium バイナリやコンポーネントバージョンは、オブジェクトを違う形でシリアライズし得るため、マシンをまたいでハッシュを比較するパイプラインでは両方を固定してください
この限界の中で、このメソッドは、CI の diff、ゴールデンファイルテスト、コンテンツアドレス型アーカイブが求めるものを与えます。同じ入力、同じバイトです。TPdf.SaveAsReproducible と TPdfReproducibleReport は、Delphi と C++Builder 向け PDFium Component に、保存、最適化、適合の API の残りとともに同梱されています
FAQ
TPdf.SaveAsReproducible はページコンテンツを変えるのか
いいえ。/Info 辞書から /CreationDate と /ModDate のキーを取り除き、トレーラーの /ID を省き、Catalog や /Info から到達できないオブジェクトを落とします。ページコンテンツは無変更のまま書かれます
暗号化 PDF から再現可能な出力を作れるのか
いいえ。暗号化はトレーラーの /ID に依存し、再現可能出力はそれを省くため、暗号化ソースに対してこのメソッドは説明付きの ErrorMessage とともに False を返します
署名付き PDF に AllowSignedDocument が要るのはなぜか
再現可能な保存はバイトオフセットを動かすフルリライトであるため、既存の署名は署名されたバイト範囲と一致しなくなります。AllowSignedDocument = True を渡すことは、そのトレードオフを受け入れることの確認です