技術記事

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

PDF Library for Delphiのv3.539.18とv3.539.20は、何も変えていないPDF保存がそれでも文書メタデータを壊しうる2つの経路を修正しました。/CreationDateと/ModDateが同じ文字列オブジェクトを参照している場合、自動のModDate更新が両方を書き換えてしまいます。もう1つは、元の/Metadataストリームを読む前にXMPオブジェクトが生成されると、既定のパケットが元のものを置き換えてしまいます。修正では、共有オブジェクトを書き換えるのではなく辞書の参照を差し替え、XMPの遅延初期化より前に既存のパケットを捕まえるようにしました

この設定は、PDFライブラリが行う操作のなかで最も退屈なものです。ファイルを読み込み、別名で保存し、その間は何も触りません。ページは前後で同じように描画され、コンテンツストリームのハッシュも一致しました。ファイルは当時のあらゆる検査を通っていて、それでも、どんなレンダラも見せてくれない2か所で間違っていました。どちらの不具合も、あらゆる実際の編集が通るread-modify-writeの経路に居座っていたので、保存するだけで発動しました。そして見つかったのは、2つ目の独立したパーサが2つのファイルの非視覚的なセマンティクスを比較したときだけでした

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

文書情報辞書は2つのキーから1つの間接文字列オブジェクトを参照することが許されており、ライブラリはキーではなくオブジェクトのほうを更新していたからです。ISO 32000-1 §7.3.10は辞書の値を間接参照にしてよいと定めており、§14.3.3 Table 317のどこにも、/CreationDateの値が/ModDateの値と別オブジェクトでなければならないとは書かれていません。作成時に同じタイムスタンプを2回書いた生成側は、まったく合法に、両方のキーを単一の2728 0 Rに向けることができます。手元のコーパスにあったCJKのデザイン文書がまさにそうでした

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

PDFlibPasにおける共有Info文字列の書き換え。/CreationDateと/ModDateは合法に単一の文字列オブジェクト2728 0 Rを参照しており、古いSetRawInfoはキーの解決先に対してSetToを呼び、両方の日時を保存時刻で書き換えていました。新しいSetRawInfoはhex文字列モードを保ったまま、そのキーの下に新しい文字列を追加します
辞書エントリの更新は、共有オブジェクトを書き換えるのではなくそのエントリの参照を差し替えるようになったので、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));

Tests\SharedInfoSemantics.incのリグレッションは、コーパスファイルに頼るのではなくエイリアスを意図的に組み立てます。両方の日時キーから参照される1つのhex文字列、/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を初期化するからです。この不具合が何の陰に隠れているかに注目してください。1つ目のバグで使ったInfo辞書の比較は通ってしまいます。/Infoの/Authorと/Titleは手つかずだからです。変わったのはXMPツリーだけであり、それを解析して比較する検査だけが気づきます

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

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

修正は2つのことをします。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>要素を書く生成側も規格の範囲内です。したがってそのようなパケットでは、ループが文書全体を削除してしまっていました。シリアライザは宣言の終わりの?>を探し、それだけを削除するようになりました。Tests\XMPRetentionSemantics.incは保持の検査を2回、ラッパありとなしで実行し、カスタム名前空間のマーカーと元の著者がSetInfo、GetMetadata、SaveToString、そして再読み込みを経ても生き残ることをアサートします。2つ目の罠はプリプロセッサシンボルでした。SetInfoのInfoからXMPへの同期はNOVCLでガードされていましたが、これはFree Pascalビルドで設定されます。しかしXMPバックエンドの有無を決めるのはフレームワークではなくオペレーティングシステムで、PDFlibXMP.pasはOS_WINDOWSがないときにだけNO_XMPを定義します。つまり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回の書き込みを避けるだけです。上の2つの修正が普通の保存を安全にし、このオプションが意図的なno-opを正直にするのです

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

ピクセルでもストリームハッシュでもありません。どちらの不具合も、すべてのページとすべてのコンテンツストリームをバイト単位で同一のまま残すからです。それらを捕まえた検査は、独立したパーサ――供試ライブラリとコードを一切共有しないもの――が、元ファイルと保存後ファイルから取る非視覚的なセマンティックスナップショットと、それに続く構造比較です。スナップショットが対象とするのは、/ModDateを除いたInfo辞書、各ブックマークをオブジェクト番号ではなくページ番号に解決したしおりツリー、同じように解決した名前付きデスティネーションとリンク先、フォームフィールドの値、ハッシュ化した添付バイト、そしてテキストとして比較するのではなくツリーとして解析したXMPパケットです。オブジェクト番号は意図的に含めていません。完全な書き直しではすべてが振り直され、それらをキーにした比較はノイズを報告するだけだからです

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

除外項目は、対象項目と同じくらい重要です。/ModDate、xmp:ModifyDate、xmp:MetadataDateは変わるのが想定内なので、比較の前に落とします。元ファイルがXMPをまったく持っていなかった場合も、パケットが付いたことを不問にします。この検査が何を主張しないかも、同じくらい明示しています。既存のパケットが保持されていることは、そのパケットがスキーマとして妥当かどうか、あるいは文書がPDF/UAやいずれかのPDF/A規格を満たすかどうかを何も語りません。それらは別々の道具で答える別々の問いです。「メタデータが生き残った」と「メタデータが適合している」を混同することが、最初のバグがこれほど長く隠れていた理由です。ライブラリ側では、この2つのリグレッションがDelphi Win32/Win64とFree Pascal Win32/Win64のすべての対象パスで走り、セマンティクス比較は実文書コーパスのベンチマークの合格条件になっています

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

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