技術記事

Delphiでの再現可能なPDF出力:バイト単位で同一の保存

HotPDF Delphi Componentは、ReproducibleOutputプロパティがTrueのとき、保存をまたいでバイト単位に同一のPDF出力を生成します。Infoの/CreationDateと/ModDateを固定の日時にピン留めし、実時計に基づく文書識別子をシード付きまたは内容由来のハッシュに置き換え、AES暗号化の各経路がそうでなければ引いてしまう乱数バイトのすべてを定数に置き換え、そして直列化するすべての辞書をソートします。このフラグはリグレッションスイートとビルド成果物の比較のために存在し、本番の文書のためではありません。そしてその境界を引く理由こそが面白いところです。この機能を必要とするのはゴールデンファイルのテストです。請求書を描画し、そのPDFをコミットし、明日のビルドが同じバイト列を生成するとアサートします。決してそうなりません。ファイルはどのビューアでも問題なく開き、テキストも同じ、ページツリーも同じ、それなのに差分は4、5か所で光ります。PDF生成器をバイトレベルのリグレッションテストに載せようとした人は誰でもこの壁にぶつかります。そして修正は「タイムスタンプを削る」ことではなく、ライターが文書自身以外の何かを参照する場所を1つ残らず数え上げることです

同じPDFを2回保存すると違うものになる理由

同じ文書を2回保存すると違うものになるのは、HotPDFを含むPDFライターが、ページ内容とは何の関係もない4つのエントロピー源を参照するからです。実時計、文書識別子、暗号論的乱数生成器、そして辞書エントリのメモリ上の順序です。どれも単体では正当なものです。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で初期化するので、2回の保存は書かれた秒の分だけ違ってきます
  • 識別子。trailerの/ID配列(ISO 32000-1 §14.4)は、永続識別子と変更識別子を持ちます。HotPDFの既定の作り方は、最初の要素についてファイル名と現在時刻をミリ秒まで含めてハッシュし、2番目についてはそれにGetTickCountを加えてハッシュします。識別子が2つ、毎回2つの新しい値です
  • 乱数バイト。標準セキュリティは識別子と本物の乱数に依存します。AES-256では、ファイル暗号化キー、検証用とキー用のソルト、そしてすべてのCBC初期化ベクトルがシステムの乱数源から引かれます(ISO 32000-2 §7.6.4.4.7はランダムなソルトを要求しています)。/U、/UE、/O、/OEはすべてそれらのバイトから計算されるので、暗号化された文書は、平文が変わらなくても全体が変わります。古いアルゴリズムは最初の/ID要素をキーに畳み込むので(ISO 32000-1 §7.6.3.3、§7.6.3.4)、識別子が新しくなるだけでファイルの鍵が変わります
  • 順序。PDFの辞書は順序を持たないマッピングであり、メモリ上のリストをたどるライターはキーを挿入順に出力します。リソース辞書を違う順序で組み立てるコード経路や、違うレイアウトから解析された読み込み済み文書は、合法でありながらテキストとして異なるファイルを生みます
1つの文書に対する2回のHotPDF保存を違える4つのエントロピー源。Nowから刻印されるFCreationDateがD:の日時とXMPパケットを養い、trailerの/IDがファイル名、時計、GetTickCountをハッシュし、AESがシステムの乱数源から鍵素材を引き、辞書はメモリ上の挿入順で直列化されます
どの源も単体では正当で、ISO 32000-1もそれを求めています。それでも合わさると、ファイルは中身の関数ではなく、いつどこで書かれたかの関数になってしまいます

ReproducibleOutputは何を固定するのか

BeginDocの前、またはSaveLoadedDocumentの前にReproducibleOutput := Trueを設定すると、4つの源それぞれが固定値に置き換わります。しかもそれは、そうでなければ時計や乱数生成器に手を伸ばすのと同じコード経路の中で行われるので、別途の後処理パスは不要です。上のリストに何が欠けているかに注目してください。内容です。フォント、ページストリーム、画像データ、クロスリファレンステーブルは、同じ入力に対してすでに決定論的です。ノイズはメタデータとセキュリティ層にしかなく、だからこそ1つの的を絞ったプロパティでそれを取り除けます。このプロパティの既定値は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')を使います。その1つの代入が、Infoの2つの日時とXMPの2つの日時の両方をカバーします。4つとも同じフィールドから描画されるからです。ファイルが最終的に書き出されるとき、BuildDocumentIdentifiersはtrailerの識別子をComputeCanonicalDocumentIdentifierに求めます。これはオブジェクトグラフ全体を正準な順序で書き出し、見つけたD:の日時文字列の数字をゼロにしてタイムスタンプがハッシュ経由で戻ってこないようにし、その結果のMD5を取ります。/IDの両方の要素がその値を受け取ります。BeginDocを一度も通さずに読み込み済み文書を暗号化する場合にも、同じ内容由来の識別子が使われます。LoadFromFileで開いたファイルに対してActivateProtectionを使う場合がそれです

乱数バイトは最も気づきにくい置き換えです。AES-256の鍵ルーチンは乱数源をローカルのヘルパーで包み、このフラグが立っているときは32バイトのファイル暗号化キーと各8バイトのソルトに対してFillChar(P^, Count, $5A)を呼びます。またAES-128とAES-256の文字列およびストリームの暗号化器は、AESGenerateRandomIVからAESGenerateStaticIVに切り替わり、これはスロットIに対して初期化ベクトルを14 * (1 + I)で埋めます。鍵もソルトもベクトルもすべて固定されれば、/U、/UE、/O、/OE、そしてすべての暗号化ストリームが、2回目の実行でも同一のものとして出てきます。最後に、SaveToStreamは再現可能フラグが立っていると必ずDeterministicDictionaryOrderをオンにし、シリアライザは各辞書をキー名の生バイトで挿入ソートします。短いプレフィックスが先で、同点の場合は元のインデックスで決めます。これは診断用ライターが使っているのと同じ順序で、PDFを手で編集して後から修復する記事で説明しています。再現可能フラグが借りるのは順序だけで、そのライターの平文レイアウトの残りは借りません

HotPDFのReproducibleOutputが固定するもの。作成日時はEncodeDate 2026, 1, 1になり、trailerの識別子はD:の数字をゼロにした正準グラフに対するComputeCanonicalDocumentIdentifierから来て、AESの鍵とソルトは$5Aバイトで埋まりAESGenerateStaticIVが各スロットを埋め、DeterministicDictionaryOrderがすべての辞書をソートします
置き換えは、そうでなければ時計や乱数生成器に手を伸ばすのと同じコード経路で走るので、別途の後処理パスは不要であり、/IDの両方の要素が同じ内容由来の値を受け取ります

固定したはずの日時がなぜ実時計を漏らしたのか

v2.752.2の修正があるのは、固定の作成日時がもともとコンストラクタで決められており、コンストラクタは呼び出し側がまだ設定していないプロパティを知りえないからです。普通の呼び出し順序はCreate、次にReproducibleOutput := True、そしてBeginDocです。構築の時点でFReproducibleOutputはまだFalseなので、FCreationDateはNowを受け取り、そのまま保持していました。識別子と乱数バイトは正しく固定されていたので、2つのファイルはほぼどこでも一致し、ちょうど2つの日時文字列と2つのXMPフィールドでだけ食い違っていました。代入をBeginDocの再現可能の分岐へ、シード付き識別子の隣に移したことで、判断はプロパティが最終値になっている地点で行われるようになりました

これを見逃したリグレッションテストは、修正そのものより価値があります。どちらも同じ実時計の秒の中で走る2回の保存は、偶然同じD:文字列を書き、より遅いマシンなら失敗するバグに対してバイト比較が通ってしまいます。修正後のテストは2回の保存の間に1100 ms眠り、PDFのタイムスタンプが必ず秒の境界をまたぐようにします。平文、AES-128、AES-256の出力についてケースを走らせ、2つの暗号化ケースには本物のパスワードを使い、2つのバッファをCompareMemで比較し、失敗時には最初に異なるオフセットを報告するので、差分はファイル全体ではなく特定のオブジェクトを指します。バイト比較が証明するのは決定性だけで、それ以外ではありません。ですから暗号化された出力をユーザーパスワードで再読み込みしてページ数を読むアサーションを別に用意してください。ファイルを安定かつ読めないものにする変更が、緑の差分を頼りに通り抜けてはいけません

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のファイル暗号化キーは$5Aが32バイト、ソルトは$5Aが8バイト、そして初期化ベクトルは公開された算術パターンに従います。パスワードは依然として/UEと/OEのラッパーを守っていますが、包まれている鍵が定数なので、その定数を知っている人はパスワードなしでコンテンツストリームをすべて復号できます。ソルトが固定されることは、同じパスワードがファイルをまたいで同じ/U文字列になるのを防ぐためにISO 32000-2 §7.6.4.4.7が頼っている、文書ごとの一意性も失わせます。乱数源が無傷なときに暗号化プロパティが何を約束するかは、AES-256の設定の記事を読んでください。再現可能フラグの下では、それらの約束は停止しています

識別子のトレードオフはもっと微妙です。ISO 32000-1 §14.4は、ツールが更新されたファイルをその先祖と区別できるように、2番目の/ID要素が変更のたびに変わることを意図しています。そして再現可能な保存は両方のスロットに同じ値を書きます。その値は正準なオブジェクトグラフのハッシュなので、内容の異なる2つの文書は依然として別の識別子を受け取ります。定数よりはましです。しかしBeginDocが鍵導出に使う種は、どのマシンでもどの文書でも同じ文字列です。ファイルを区別するために/IDをキーにする読み手――たとえば注釈キャッシュやフォームデータのsidecar――は、たまたま同じハッシュになった再現可能ファイルをすべて同一視してしまいます

このフラグがカバーしないものは何か

ReproducibleOutputが取り除くのは、ライター自身が持ち込むエントロピーです。環境を通して、あるいは自分が制御しないコード経路を通して入ってくるエントロピーは取り除けません。そのうち3つはつまずきやすいものです

  • タイムゾーンの接尾辞。_DateTimeToPdfDateはローカルのUTCオフセットを付け加えるので、あるビルドエージェントではD:20260101000000+08'00'、別のエージェントではD:20260101000000-05'00'となり、同じ固定日時でもバイト列が違います。再現性が成り立つのは1台のマシンでの実行間、あるいは同じタイムゾーンを共有するマシン間です。ゴールデンファイルが旅をするならエージェントのゾーンを固定してください
  • インクリメンタル更新。SaveIncrementalUpdateは、対象パス、GetTickCount、現在時刻から変更識別子を計算し、再現可能の分岐を持ちません。インクリメンタルセクションは定義上、新しい変更だからです。比較するのは完全な書き直し同士であり、追記されたデルタではありません
  • パススルーの近道。SaveLoadedDocumentは通常、未変更で暗号化されていない元ファイルを、直列化し直すのではなくバイト単位でコピーします。再現可能フラグはこの近道を無効にし、順序と識別子のルールを適用するために完全な書き直しを強制します。つまり読み込み済みファイルの再現可能な保存は既定より遅く、入力のコピーには決してなりません。差分を取る相手は前回の再現可能な保存であり、元ファイルではありません
HotPDFの再現可能な保存が止まる場所。_DateTimeToPdfDateは今もローカルのUTCオフセットを付け加えるのでゴールデンファイルはタイムゾーンをまたぐと違ってきます。SaveIncrementalUpdateには再現可能の分岐がありません。デルタは新しい変更だからです。そしてパススルーの近道が無効になり、読み込み済みファイルは必ず完全に書き直されます
再現性が成り立つのは1台のマシンでの実行間、あるいはゾーンを共有するマシン間です。そして再現可能な保存は、元の入力ではなく前回の再現可能な保存と差分を取るべきものです

同じリリースからもう1つ、通った検査が何を証明し何を証明しないかについての教訓があります。PDF/X-6のテストフィクスチャがCharProcs.DeleteValue('A')を呼び、これが直接保持していたグリフストリームを解放し、その後で同じポインタを再挿入していました。さらに別に、1つの直接のExtGStateオブジェクトをリソース辞書とパターンの両方に渡していました。適合性バリデータは、その解放後使用と二重所有に対して断続的に通っていました。解放されたメモリがたまたま保持していたものを読んでいたからです。構造の検査がちらつくときは、バリデータを見る前にテスト入力の所有関係を見てください。再現可能な出力はその規律を安くしてくれます。2回の保存がバイト単位で同一になれば、ちらつきの残る源はオブジェクトグラフ自身だけであり、catalogから下へたどる構造差分が見つけてくれます

ここで説明したReproducibleOutput、DeterministicDictionaryOrder、そして暗号化のプロパティは、DelphiとC++Builder向けの標準のHotPDF Delphi Componentに含まれています。同じフラグがライブラリ自身のリグレッションコーパスも駆動しているので、テストスイートで得られる振る舞いは、このコンポーネントがテストされている振る舞いそのものです