技術記事

Delphiでブックマークを壊さずPDFページを置き換える

署名済み契約書の3ページ目を差し替えても、目次は動くべきではない。古いページを削除して新しいページを挿入すると、そこを指していたすべてのブックマークは別の場所に着地してしまう。PDFlibPas Delphiライブラリは、対象のページオブジェクト自体を保持し、視覚的コンテンツを運ぶエントリだけを転送することでこれを回避する

なぜPDFページを置き換えるとブックマークが壊れるのか

PDFの行き先(destination)はページ番号ではなく間接オブジェクト参照でページを指すため、ブックマークは壊れる。ISO 32000-1 §12.3.2.2は明示的な行き先を、最初の要素がページオブジェクトへの間接参照である配列として定義している。そのオブジェクトを削除して置き換えを追加すれば、参照は宙ぶらりんになる。ほとんどのビューアはこれに対し、読者を1ページ目に落とすことで応じ、これはまさに削除してから挿入するという置き換え方法の後によく報告される症状である。ページツリーは完璧に見え、ページ数は正しく、レンダリングも正しい。それでいてナビゲーション層全体だけが静かに間違っている

名前付き行き先もあなたを救いはしない。§12.3.2.3は文書カタログ内の/Dests名前ツリーを通じて名前を解決するが、その名前が解決される先の葉は、依然として同じページ参照を保持する明示的な行き先配列である。名前付けはページ参照の周りにではなく、その上に間接性の層を1つ加えるだけだ。同じ理屈は§12.5で説明される対話的な層の残りにも及ぶ:リンク注釈は/Dがその配列であるような/Dest/A GoToアクションを持ち、あらゆる注釈はそのページへの間接参照である/Pエントリを持つことができ、フォームフィールドウィジェットもまったく同じ立場の注釈である。素朴な1回のページ差し替えは一度に4つのサブシステムを切り離してしまう。実際のファイルでこれらが列挙されるさまを見たいなら、アウトラインと注釈のイントロスペクションが辿っているのと同じオブジェクトグラフがそれである

どのページエントリが同一性を運び、どれが見た目を運ぶのか

ページ辞書は2種類のエントリを混在させており、その場での置き換えはこの両者を切り分けたときに正確に成功する。見た目の側は有限で列挙可能である:/Contents/Resources/MediaBox/CropBox/BleedBox/TrimBox/ArtBoxという5つのページボックス、それに/Rotate/Group/UserUnit/BoxColorInfoである。これら11個のエントリがラスタライザがそのページに対して生成するすべてを決定し、ファイル内の他の何もこれらを名前で指すことはない

同一性の側は、文書の残りの部分が自らを結びつけている対象である:ページオブジェクトの番号と世代、ページツリーへ戻る/Parentリンク、そして/Annotsである。PDFlibPasはこれらのすべてに一切手を触れない。ReplacePageRangesは対象ページ辞書から11個の視覚エントリを取り除き、インポートしたソースページから再び追加する。したがって対象のページオブジェクトはその場で変異するのであって、置き換えられるのではない。§7.7.3が要求するページツリー構造も形状としてバイト単位で同一のまま保たれる:/Kidsの順序、/Count、そして生き残ったそれぞれの/Parentは前後で同じままである。ノードが一度もリンク解除されることがないからだ

PDFlibPasはどうやってオブジェクトを振り直さずにページを置き換えるのか

この呼び出しはソース文書、1始まりの対象開始ページ、ソース範囲の式、そしてオプションフラグを受け取る。両方の文書は同じインスタンス内で開かれていなければならず、対象文書は選択されているほうである。対象のページ数は決して変わらないため、要求する範囲はTargetStartPageから始まる文書の内側に収まらなければならず、これは何かが作られる前にチェックされる

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // The document whose bookmarks and links must survive
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // The revised clause page, rendered by whatever produced it
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // Source page 1 overwrites the visuals of target page 3.
    // Page count, page 3 object number, bookmarks and annotations are kept.
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

内部的には、ソースページの中身にある間接参照はすべてソース側のオブジェクト番号体系に属するため、ソースページを文書境界をまたいでそのまま読み出すことはできない。そこでソース範囲はまず通常のやり方でインポートされ、最後の実ページの後ろに追記される一時ページとなって、コンテンツストリーム、フォント、XObject、シェーディング、色空間のすべてを対象文書へ振り直す完全なオブジェクトグラフの再マッピングが実行される。その後で初めて、11個の視覚エントリが各一時ページからその対象ページへとコピーされ、その後で初めて一時ページがページツリークからリンク解除される。再マッピングという作業は安全でコストの低い場所で行われ、破壊的な編集はすでに存在するページに対する辞書レベルの入れ替えへと縮小される

今転送したばかりのものを破壊してしまう削除経路

それらの一時ページを削除するステップは、ささいに見えて実はそうではない。ライブラリの通常のページ削除経路は、ノードのリンク解除以上のことを行う:削除される各ページのレイヤーを統合し、最初のコンテンツストリームを空にし、他のどのページとも共有されていないリソースを回収する。これは本物の削除にとっては正しい挙動だが、ここでは破滅的である。一時ページが削除される時点で、対象ページはすでにまさにそれらのコンテンツストリームとリソースオブジェクトを参照しているからだ。それらを空にしてしまえば置き換えたばかりのページが白紙になり、リソースの一掃はいま生きたオーナーを持つフォントや画像を回収してしまうことになる

この修正は内部の削除経路に設けられた「参照済みオブジェクトを保護する」モードである。これが設定されると、削除は非共有リソースの一掃とコンテンツストリームのクリアの両方をスキップし、ページをページツリーから切り離してツリーの帳簿を整える以外は何も行わない。転送されたオブジェクトは新しいオーナーのもとで生き残り、操作後のオブジェクトの所有権はホワイトボードに描くとおりのものになる:1つのコンテンツストリーム、1つの所有ページ、決して動かなかった1つのオブジェクト番号である。ページの作成・削除・並べ替えに関する関連するライフサイクル規則は、文書とページのライフサイクル操作に関するノートで別途扱っている

順序、重複、そしてオールオアナッシングの失敗

オプションフラグはソース範囲がどう解釈されるかを選択する。0はパースされたページ番号をソートして重複を除去する。呼び出し側が'4-6,2'のようなものを渡し、単にそれら4ページを意味させたい場合の分別あるデフォルトである。1は書いたとおりの順序を保持し、ページの繰り返しを許可するため、'2,1,2'は2つのソースページから取られた3回の置き換えを文字どおり意味する。検証はまず実行され、完全に実行される:範囲の構文、すべてのページ番号がソースのページ数に対して妥当か、オプション値自体、そして対象の容量が、1つもオブジェクトが作られる前にすべてチェックされる。拒否された呼び出しはLastErrorCodeを412に設定し、以前選択されていたページを復元し、文書をそれ以前とまったく同じままにする

var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1: source order is preserved and repeats are allowed, so
  // target pages 5, 6 and 7 receive source pages 2, 1 and 2 respectively
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // On success the selection is the first replaced page
  Assert(Lib.SelectedPage = 5);
end;

原子性は検証を超えて転送そのものにも及ぶ。最初のソースページがインポートされる前に、範囲内の対象ページすべての11個の視覚エントリがエンコード済みの値としてスナップショットされる。インポートが失敗した場合、あるいはインポートされたページ数が要求したものと一致しない場合、スナップショットは対象ページへとデコードし戻され、一時ページは削除される。つまり途中での失敗が起きても、元の視覚要素は元のオブジェクト上にそのまま残る。これは見た目以上に重要である:契約書の中で半分だけ置き換わったページ範囲は、呼び出しが失敗するよりも悪い。ファイル内の何もそれを「半分終わった状態」だと示さないからだ

// Post-conditions worth asserting in a regression test
Lib.SelectPage(3);
// Geometry now comes from the source page
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Annotations that were already on target page 3 are still attached
WriteLn(Lib.AnnotationCount);
// The bookmark created before the replacement still resolves to page 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// And the document is still the same length
WriteLn(Lib.PageCount);

その場での置き換えがまだやってくれないこと

ソースの注釈、ソースのフォームフィールド、ソースのアウトラインは意図的にインポートされない。ウィジェットをその/AcroFormフィールドエントリなしに持ち込んだり、構造ツリーの所有関係なしにマークコンテンツを伴う注釈を持ち込んだりすれば、どのビューアも扱いに困る中途半端にインポートされた対話的オブジェクトができてしまう。そのためこの操作は見た目だけを転送する。実務上の帰結は、置き換え後のページが新しいフォームフィールドや新しいリンクを持つべきなら、それらを後から対象ページに対して追加するということであり、対象のページオブジェクトはそれらを待ちながらそこに座り続けている

自分のファイルで確認する価値のある境界がさらに2つある。第一に、/Annotsは保持されるがページのジオメトリは保持されないため、220mmのページを320mmのページに置き換えると、注釈の矩形はサイズの異なる/MediaBoxの中で古い座標のまま残る。ジオメトリが変わるなら、保持した注釈を再配置すること。第二に、11個の視覚キーの外側にあるエントリは設計上対象ページに留まり続けるが、これは/Trans/AAにとっては正しく、/Thumbにとっては古びてしまうため、置き換え後にサムネイルを再生成すること。タグ付き文書にはもう一つ考えるべきことがある:構造要素は/Pgを通じて正しいページオブジェクトを引き続き指すが、それらのマークコンテンツ識別子はもうそこには存在しないコンテンツを記述しているため、PDF/UAワークフローの中でのページ差し替えはコンテンツの編集であると同時に構造ツリーの編集でもある。もし本当にやりたいことが差し替えではなく合成——保持するページの上にアートワークを重ねること——であるなら、ページのステッチングとテンプレートによるアプローチのほうが安上がりな道具である

範囲式の構文、オプション値、そして周辺のページ操作APIを含め、ここで説明したすべては、DelphiとC++Builder向けの標準PDFlibPas Delphi PDF Libraryに同梱されており、そのリファレンスドキュメントにはページ置き換え呼び出しとそのエラーコードの完全な項目が掲載されている