技術記事

再現可能ビルドのためのDelphi決定論的PDF ID

同じテンプレートとデータから同じ請求書を2回生成し、2つのPDFをdiffします。2箇所が違います。トレーラーの/ID配列の16進文字列と、文書情報辞書のModDate文字列で、どちらも時計から取られています。losLab PDF Libraryでは、SetDeterministicDocumentID(1)プラス固定のModDateが、実行ごとのXMP UUIDや添付のないドキュメントについて、ファイルをバイト単位で同一にします

見え見えの最初の試みは日付を固定して再実行することです。CreationDateとModDateはもう一致し、それでもハッシュは違います。今度はファイルのごく末尾近く、/ID [<…> <…>]の中の2つの32桁の16進文字列だけが違うのです。その識別子はメタデータとは別に計算されており、保存の時刻への依存が止まらない限り、ファイルハッシュをキーにしたビルドキャッシュは決してヒットせず、コンテンツアドレス型ストアはドキュメントごとに1つではなく実行ごとに1つのblobを保持し続けます

トレーラーID配列は何のためのものか

トレーラーの/IDはファイルを識別します。内容のチェックサムではありません。ISO 32000-1の§14.4はそれを2つのバイト文字列の配列として定義します。最初は永続識別子で、ドキュメントが作られたときに割り当てられ、その後のすべての編集を生き延びることを意図しています。2番目は変化する識別子で、ライタはファイルを変更するたびに更新します。ドキュメント管理システムはこのペアを比較し、2つのファイルが1つのドキュメントのリビジョンなのか、無関係な2つのドキュメントなのかを判断します。§7.5.5のTable 15は、トレーラーが/Encryptを運ぶときこのエントリを必須とし、それ以外では任意とします。そして§14.4は、省くことがファイルの同一性に頼るワークフローの邪魔になり得ると注記しています

仕様は値の計算方法を決して語りません。現在時刻、ファイルパス、ファイルサイズ、情報辞書の値のようなものに対するMD5ダイジェストを示唆しており、時計こそが結果を一意にする材料です。一意性は、識別マーカーに望むものであり、まさに再現性を壊すものです。デフォルトではlosLab PDF Libraryはその示唆を最も素朴な形で、つまり現在のタイムスタンプのMD5で追うため、1秒差で作られた2つのドキュメントは、他のバイトがすべて一致していても、違う永続識別子を得ます

同一のDelphiビルド実行を比較する図。デフォルトの壁時計ベースのPDFトレーラーIDが実行ごとに異なるのに対し、決定論的な出力はバイト単位で同一
デフォルトのトレーラーIDは壁時計をファイルの同一性へ折り込む一方、決定論的モードは安定したシードから導出する

再現可能な識別子を有効にする

決定論的モードはドキュメントごとのスイッチで、デフォルトではオフです。そのため既存の出力は、求めるまで変わりません。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で再保存すると、2つの出力は違う識別子を運び、シードAへ戻せば元の値がまた手に入ります

ただしスイッチはオブジェクトを作った直後に有効にしてください。フォント、画像、グラフィックス状態、その他のページリソースはページのリソース辞書で名前を得ますが、決定論的モードが有効な間はその名前がカウンタから来ます。スイッチの前に追加されたものはランダムに生成された名前を保持し続け、識別子は安定したままでも、コンテンツストリームの数バイトが実行ごとに変わります

シードを設定しないのに識別子が変わるのはなぜか

情報辞書がハッシュの一部だからです。シードを空のままにするか、SetDocumentIDSeedに空の文字列を渡して消すと、losLab PDF Libraryは識別子をファイル自身から導出します。書きながら直列化された本体をSHA-256にかけ、ダイジェストの最初の16バイトを/IDの両要素に使います。ページ内容への変更は識別子を変えます。それがこの設計の要点です。落とし穴は、CreationDateとModDateもその本体の中に住んでいることで、保存ルーチンは、呼び出し側が設定していない限りModDateに現在時刻を捺印します

PDF Library for Delphiの判断フロー。SetDocumentIDSeedが、決定論的PDF IDのために使われる導出済みドキュメント状態シードより優先されることを示す
明示的なシードは他のすべてに優先する一方、導出経路はCreationDateとModDateを含むInfo辞書のエントリをハッシュする
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は、ハッシュの前に正規化されるため、それらのトークンだけで違う2つの実行は同じ/IDを得ます。ただしファイルのバイトはその場所で依然として違うため、実行ごとに新しいインスタンスUUIDを持つPDF/A風のドキュメントは、再現可能な識別子を得るものの、バイト一致のファイルは得られません。埋め込み添付はドリフトのもう1つの源です。AddAssociatedFileFromStringは添付自身の/ModDateに現在時刻を捺印し、その日付は他の本体バイトと同じようにハッシュされます

明示的なシードか、内容ハッシュか

ビルド成果物にはシードを空のままにすることをお勧めします。内容から導出された識別子はバイトが変わるちょうどその時に変わるため、それをキーにしたキャッシュが古いファイルを出すことは決してなく、データと同期を保つべき2つ目のキーも生まれません。明示的なシードは別の仕事に適します。下流のアーカイブが/ID[0]をドキュメントの同一性として扱い、テンプレートが変わった後でさえ、同じ請求書の再生成の間で同じ識別子を期待するような仕事です。そのほうが§14.4の意図により合っています。代償は、見た目で違う2つのファイルが同一の識別子を共有し得ることです

内容経路の1つの帰結には、意図的な決定が値します。既存のPDFを読み込み、決定論的モードを有効のままフル保存すると、入ってきた/IDは捨てられ、新しいものは書かれたものだけに依存します。トレーラー識別子だけで違う2つのソースファイルは、同じ/IDで出てきます。それが正規化ビルドステップの望むものです。顧客のドキュメントを編集していて、その同一性が生き延びなければならないなら、その仕事では決定論的モードを切っておくか、インクリメンタルに保存してください

IDの書き換えが暗号化PDFを壊すのはなぜか

暗号化されたドキュメントでは、/ID[0]が鍵素材だからです。ISO 32000-1の§7.6.3.3 Algorithm 2は、リビジョン2から4の標準セキュリティハンドラの暗号化キー計算に、ファイル識別子の最初の要素を、パディングされたパスワード、/O値、権限ビットとともに流し込みます。そのキーが、読み手が開くときに検査する/U文字列を作ります。キーは、Encryptを呼ぶときや暗号化ファイルを読み込むとき、保存の前に導出されるため、保存の間に識別子を置き換えると、形は整っているのにパスワードチェックが誰に対しても、あなた自身に対してさえ失敗するファイルが出来上がります

そのためlosLab PDF Libraryは、決定論的識別子を暗号化されていないフル保存のときだけ更新します。暗号化されたドキュメントは既に持っていた/IDを保持し、スイッチはその経路に効果を持ちません。そのようなファイルをユーザーパスワードで開き直すのはいつも通り動きます。暗号化された出力をバイト比較に入れないでください。§7.6.2のAES暗号化はすべての暗号化文字列とストリームをランダムな16バイトの初期化ベクトルで始めるため、同じドキュメントの2回の暗号化はバイト単位で決して一致しません。そこで回帰チェックが要るなら、両方を復号して戻ってきたものを比較してください。リビジョン処理と権限ビットは、PDF暗号化と権限監査の解説でより深く扱っています

PDF Library for Delphi:決定論的PDF IDのガードレールマップ。フル保存、暗号化ドキュメント、インクリメンタル更新、暗号化復元経路をカバーする
IDを更新できるのは暗号化されていないフル保存だけ。暗号化ファイルは/ID[0]を鍵素材として扱い、追記リビジョンはリンクされ続けなければならない

インクリメンタル保存は元の識別子を保持する

インクリメンタル更新はファイルの既存のバイトをすべてその場に残し、その後に新しいリビジョンを追加します。/ID[0]の永続性こそが、追加されたリビジョンが前のものと同じドキュメントに属すると消費者に語るものです。書き直すことは、ファイルに既にあるリビジョンと矛盾し、署名ワークフローの足元を掘ります。署名は1つの特定のリビジョンのバイト範囲を覆うからです。追加モードは決定論的モードでも識別子に触れないため、PDFインクリメンタル更新とストリームへの追記の記事が述べる挙動がそのまま保たれます

出力を時間を越えて再現可能に保つ

ジェネレータへの後の変更はすべて、時計の値をファイルへ戻す機会です。生成時刻を印字する透かし、ファイルを添付するコードパス、違う保存オプション。保存オプションは内容と同じだけ重要です。オブジェクトを圧縮オブジェクトストリームに詰めることは、古典的なクロスリファレンステーブルとはまったく違うファイルレイアウトを生みます。オブジェクトストリームとクロスリファレンスストリームのノートで説明している通りです。それらの設定はパイプラインごとに固定してください。最も安い防御は、同じドキュメントをメモリ内で2回ビルドし、バイトを比較するテストです

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;

// 2つの独立したビルドは1つのバイト列を与えなければならない
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

同じテストハーネスは、同じソースに対する直列と並列のRecompressStreams実行も比較できます。2つのTPDFlibインスタンスに読み込み、両方で決定論的IDを有効にしModDateを固定し、片方にSetOptimizationWorkers(1)を、もう片方に大きなワーカー数を設定し、2つのSaveToString結果をdiffします。時計が両方のファイルから追い出されていれば、残った差は再圧縮そのものから来ます

再現可能なトレーラー識別子、メタデータ制御、インクリメンタル保存は、いずれもDelphiとC++Builder向けlosLab PDF Libraryの一部であり、リファレンスはここで触れた暗号化オプションも一覧にしています