技術記事

何も変えないPDF保存がInfoとXMPメタデータを壊す理由

何も変えないPDF保存でも、文書メタデータを壊す道は2つあります。/CreationDateと/ModDateが同じ文字列オブジェクトを参照している場合、そのオブジェクトに書き込む自動ModDate更新が両方を書き換えること。そして元の/Metadataストリームを読む前にXMPオブジェクトを生成すると、既定パケットが元のパケットを置き換えることです。PDF Library for Delphiは共有オブジェクトを書き換える代わりに辞書の参照を置き換え、遅延XMP初期化の前に既存パケットを退避させます

保存は、PDFライブラリが行う操作の中で最も地味なものです。ファイルを読み込み、別名で保存し、その間は何にも触れない。ページは前後でまったく同じに描画され、コンテンツストリームのハッシュも一致します。そのような検査をすべて通過しながら、レンダラーが決して見せてくれない2箇所で壊れていることがあります。この2つの罠は、実際の編集が必ず通る読み取り・変更・書き込みの経路に潜んでいます。したがって保存さえすれば発火し得て、2つのファイルの非視覚的なセマンティクスを比較する独立したパーサーが初めて暴きます

PDFを保存するとCreationDateが変わるのはなぜか

文書情報辞書は、1つの間接文字列オブジェクトを2つのキーから参照してもよいことになっています。そしてキーではなくオブジェクトを更新するライターは、両方を変えてしまいます。ISO 32000-1の§7.3.10は任意の辞書値を間接参照にすることを認め、§14.3.3の表317のどこにも、/CreationDateの値が/ModDateの値と別オブジェクトでなければならないとは書いていません。作成時に同じタイムスタンプを2回書いたプロデューサーは、両方のキーを1つの2728 0 Rに向けても完全に合法で、実世界のファイルはまさにそうなっています

引き金は自動更新日時です。UserModDateが設定されていない限り、SaveToFileは書き出しの前に現在時刻でSetInfo('ModDate', ...)を呼び、これはSetRawInfoに届きます。素朴なSetRawInfoはキー配下のオブジェクトを探し、TPDFStringならSetToを呼びます。これはキーが現在解決する先のオブジェクトへのインプレース書き込みであり、そのオブジェクトが共有されていれば、/CreationDateも保存時刻を報告するようになります。文書は以前と同じまま開け、印刷でき、ピクセル単位で同じに描画されるため、視覚系のリグレッションスイートはまばたきひとつせず通過します

PDFlibPasにおける共有Info文字列の書き換え。/CreationDateと/ModDateは1つの文字列オブジェクト2728 0 Rを合法的に参照しており、キーが解決する先へのインプレースSetToは両方の日付を保存時刻で書き換えてしまうため、SetRawInfoはhex文字列モードを保ったままキー配下に新しい文字列を追加する
辞書エントリの更新は共有オブジェクトを書き換える代わりにそのエントリの参照(entry's reference)を置き換えるため、1回の自動ModDate書き込みでCreationDateが変わることはなく、置き換えられたオブジェクトは他の参照のために保持される
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate、8 = ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

TPDFDocument.SetRawInfoの規則は小さく、背後にある原則は汎用的です。辞書エントリの更新は、そのエントリの参照を置き換えるのであって、たまたま解決した先のオブジェクトを決して書き換えません。コードは既存のTPDFStringModeを読み取り、hex文字列はhexのまま、リテラル文字列はリテラルのままにしてから、FStructure.NewString(Value, StringMode)で新しい文字列をキー配下に追加します。もう2つの細部も同じくらい重要です。ストリーム値のエントリは、古いストリームを先にクリアせずに置き換えます。そこでSetTo('')すると、同じストリームを指している他のキーすべての値が空になるからです。そして置き換えられたオブジェクトは削除しません。構造体が所有権を持ち、他の参照がまだ必要とし得るからです

// 素朴版:キーが現在解決する先のオブジェクトを書き換える
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// 正しい版:表現形式を保ち、このキーの参照だけを置き換える
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

これを試すテストは、たまたまオブジェクトを共有するファイルに頼らず、エイリアスを意図的に組み立てるべきです。両方の日付キーから参照されるhex文字列を1つ、/Titleと/Subjectで共有される直接文字列を1つ、/Authorと/Keywordsで共有されるストリームを1つ。各ペアの片方のキーを更新した後、もう片方は元の値を読み続け、更新された文字列はhexのままでなければなりません。SetInformationの公開リファレンスはこの保証を1文で述べています。Infoフィールドの更新は、他のフィールドが同じオブジェクトを参照していても、そのフィールドだけを置き換える、と

既存のXMPパケットが既定値で置き換わるのはなぜか

2行の順序のせいです。TPDFDocument.GetMetadataには高速経路があります。XMPフィールドがすでに代入済みなら、catalogから/Metadataストリームをデコードする代わりにXMP.SaveToStringを返します。XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);という遅延初期化は自然に読めますが、間違っています。GetMetadataが走る時点でXMPは代入済みなので、読み込まれる「ソース」は、1行前に生成されたオブジェクトの直列化された既定パケットなのです。dc:creator、カスタム名前空間、そして規格識別を持つ元のパケットはオブジェクトに届かないまま、保存時に上書きされます。同じ自動更新日時だけでもこれを発火させるのに十分です。SetInfoは、xmp:ModifyDateを/ModDateと歩調を合わせるために、Info辞書に触る前にXMPを初期化するからです。この罠が何の陰に隠れているかに注目してください。最初の罠で使ったInfo辞書の比較は通ってしまいます。/Infoの/Authorと/Titleは手つかずだからです。変わったのはXMPツリーだけであり、それを解析して比較する検査だけが気づきます

PDFlibPasにおける遅延XMP初期化の順序。GetMetadataを呼ぶ前にXMPオブジェクトを生成すると高速経路が既定パケットを直列化し、dc:creator、カスタム名前空間、規格識別が失われる。TPDFlibXMP.Createの前にSourceを退避すれば、catalogから元の/Metadataストリームを読み込める
SetInfoはxmp:ModifyDateを/ModDateと歩調合わせするためにXMPを初期化するため、どの保存でも入れ替わりは起こり得る。そこで文書内のすべての遅延初期化は、オブジェクト生成の前に既存パケットを退避させる1つのEnsureXMPへ集約される
// 誤り:GetMetadataは直前の行で生成されたオブジェクトを直列化してしまう
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// 正しい:先に/Metadataストリームを捕まえ、それから生成して読み込む
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

PDF Library for Delphiは、この順序を1つの場所にまとめています。TPDFDocument.EnsureXMPはTPDFlibXMP.Createの前にSource := GetMetadataを退避させ、文書内のすべての遅延初期化がこれを通ります。SetInfo、SetXMPInformation、GetXMPInformation、PDF/A、PDF/X、PDF/E、PDF/VT、PDF/VCR、PDF/UAの各モード設定、そしてメタデータ修復の経路です。SetXMPPropertyのような公開エントリもEnsureXMPを通り、GetXMPPropertyはGetDocumentMetadata経由で読むため、表面全体が1つの初期化順序を共有します。3行の手順の正しいコピーが1つあるほうが、たまたま今日一致している10個のコピー以上の価値があります

同じ経路上の小さな罠2つ

WindowsのXMPシリアライザはプラットフォームのXMLライタを使い、これはパケットに載せてはならないXML宣言を出力します。素直に取り除こうとすると、<?xpacketに到達するまで文字を削除するやり方になりますが、これは有効な入力で失敗します。ISO 16684-1の§7.3.2はxpacketラッパを任意としており、裸の<x:xmpmeta>要素を書くプロデューサーも規格の範囲内です。したがってそのようなパケットでは、このループは文書全体を、しかも有効な文書を丸ごと削除してしまいます。シリアライザは宣言の閉じ?>を特定し、そこだけを削除します。真似する価値のある保持検査は、ラッパありとなしの2回実行し、カスタム名前空間のマーカーと元の著者がSetInfo、GetMetadata、SaveToString、そして再読み込みを経ても生き残ることをアサートします。2つ目の罠はプリプロセッサシンボルです。NOVCLはFree Pascalビルドで設定されますが、XMPバックエンドの有無を決めるのはフレームワークではなくオペレーティングシステムで、PDFlibXMP.pasはOS_WINDOWSがないときにだけNO_XMPを定義します。SetInfoのInfoからXMPへの同期をNOVCLでガードすると、WindowsのLazarusビルドは動作するXMPオブジェクトを持ちながら、その更新を黙って飛ばすSetInfoを抱えることになります。ガードはNO_XMPであり、WindowsのFree PascalアプリケーションもDelphiと同じ同期を得られます

パススルー保存で元のModDateを保つにはどうするか

TPDFlibSaveOptionsのKeepModDateを設定し、SaveToFileOptionsで保存します。このオプションは呼び出しの間だけUserModDateを設定し、SaveToFileは自動タイムスタンプを飛ばします。このステップはXMPオブジェクトを遅延初期化するステップでもあります。メタデータに一切触れておらず、コンプライアンスモードも有効にしていない文書は、Info辞書も/Metadataストリームも読み込んだままの状態を保ちます。SetInformation(8, ...)を呼ぶと永続的に同じ効果が得られます。更新日時を自分で設定すると、それがユーザー管理であると印付けられるからです

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // 自動/ModDateなし、遅延XMP初期化もなし
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

このオプションで何が得られるかは正直に見極めてください。KeepModDateが正しい選択なのは、出力が入力と同じリビジョンを表すべきパススルーの工程です。実際にコンテンツを編集するものについては誤った選択です。§14.3.3は/ModDateに最新の変更を反映することを期待しているからです。また、正しい参照処理の代わりにもなりません。自動更新を誘発するあの1回の書き込みを避けるだけだからです。参照を置き換えて共有オブジェクトを書き換えないこと、そして初期化の前にパケットを退避させることこそが、普通の保存を安全にします。オプションは、意図的なノーオプ保存を正直なものにするためのものです

保存でModDate以外に何も変わっていないことをどう検証するか

ピクセルでもストリームハッシュでも捉えられません。どちらの罠も、すべてのページとすべてのコンテンツストリームをバイト単位で同一に保つからです。捉えられる検査は、テスト対象ライブラリとコードを共有しない独立したパーサーが、元ファイルと保存後のファイルから取る非視覚的なセマンティクスのスナップショットであり、続いて構造比較を行います。スナップショットは、/ModDateを除いたInfo辞書、各ブックマークをオブジェクト番号ではなくページ番号へ解決したアウトラインツリー、同じ方式で解決した名前付き宛先とリンクターゲット、フォームフィールドの値、ハッシュとしての添付バイト列、そしてテキストとしてではなくツリーとしてパースしたXMPパケットをカバーします。オブジェクト番号は意図的に対象外です。全体書き換えは番号を振り直すため、番号を鍵にした比較はノイズを報告するだけになるからです

PDFlibPas保存の非視覚的セマンティクス検証。コードを共有しない独立パーサーが、/ModDateを除いたInfo辞書、アウトラインと宛先ページ、フォーム値、添付ハッシュ、XMPツリーのスナップショットを取り、/ModDate、xmp:ModifyDate、xmp:MetadataDateを予期される変化として落としたうえで元ファイルと保存後ファイルを比較する
ピクセルとストリームハッシュはどちらの罠でもバイト単位で同一に保たれるため、比較はオブジェクト番号ではなく解決済みセマンティクスで行う。生き残ったメタデータは、スキーマValidやPDF/UA、PDF/A適合ではなく、保持されたと正直に報告する

除外設定は、含めるものと同じくらい重要です。/ModDate、xmp:ModifyDate、xmp:MetadataDateは変わることを期待される項目なので、比較の前に落とします。そもそもXMPをまったく持たない元ファイルが、パケットを得たことで罰せられることもありません。この検査が主張しないことも、同じくらい明示的です。既存パケットを保持していても、そのパケットがスキーマValidか、文書がPDF/UAやいずれかのPDF/Aパートに適合するかについては何も言っていません。それらは別の道具で答える別の質問であり、「メタデータが生き残った」と「メタデータが適合している」を混同することこそ、日付共有の書き換えが長期間隠れ続ける道です

この規則より下のレベルで作業するなら、保存がオブジェクトを書き換える仕組みはインクリメンタル更新と追記保存で扱っています。共有オブジェクトがその場にそのまま残る唯一の保存モードです。古い、または書き換えられた日付が読み手を誤導するもう1つの場所は、修正レベルとリビジョン差分です。同じInfoとXMPの対について、単に保持するのではなく2つを一致させる修復側の視点は、PDF/Aへの変換とメタデータ修復にあります

PDF Library for DelphiはDelphi、C++Builder、Lazarus向けのネイティブPascal PDFライブラリであり、ここで説明したread-modify-writeの経路は、みなさんのプロセス内のあらゆる編集が通る経路そのものです。ですから1日に1回保存しようと1000回保存しようと、上の保証は当てはまります。対応コンパイラとプラットフォームはPDF Library for Delphiの製品ページをご覧ください