技術記事

ぶら下がり参照を残さずDelphiでPDFページを削除する

HotPDF Delphi Componentは、読み込み済みのPDFからTHotPDF.DeletePageでページを削除します。バージョン2.751.0以降、この呼び出しはそのページをまだ指している文書レベルの参照もすべて整理します。/Namesの/Destsツリー内の名前付きデスティネーション、旧式のcatalogの/Dests辞書、ブックマークの/GoToアクション、/StructTreeRoot配下の構造要素、ParentTree、注釈のOBJRエントリ、そして生き残ったページ上のリンク注釈です。ページツリーの再構築は最後で、他に何も削除対象オブジェクトへ到達できなくなってから行います

これが防ぐ障害は、再現は簡単で診断は難しいものです。タグ付きレポートの表紙ページを削除して保存し、その結果を開いてみてください。Acrobatは正しいページ数を表示しますが、「Contents」ブックマークはどこにも着地しなくなり、アクセシビリティチェッカーはページを持たない構造要素を報告し、厳格なバリデータは解放済みオブジェクトへの参照を列挙します。ページツリーには何も間違いがありません。問題は、PDFのページが/Pagesの葉であるだけでなく、catalogの半分が指している的でもあることです。葉を取り除けば、それらのポインタはすべてぶら下がったままになります

/Kidsからページを取り除くだけでは足りない理由

ISO 32000-1は、少なくとも7つの独立した構造がページオブジェクトへの参照を保持することを許しており、そのうちページツリーは1つだけだからです。/Kidsからページを外し、/Countを減らせば§7.7.3は満たしますが、それ以外の参照はすべて、xrefで解放されるか、書き直したファイルに単に存在しないオブジェクトへのポインタになります。それらのポインタをたどったビューアはnullを受け取り、そのnullをどう扱うかはビューア次第です

  • /Namesの/Dests配下の名前ツリー(§7.7.4、§12.3.2.3)は、名前を、最初の要素がページであるデスティネーション配列に対応付けます
  • catalog直下の1.2より前の/Dests辞書は、同じ種類の配列を名前で引ける形で保持します
  • しおり項目(§12.3.3)は、インラインの/Destを通すか、/S /GoToと/D配列を持つ/Aアクションを通してページに到達します
  • 構造要素(§14.7.2)は、そのマーク付きコンテンツが載るページを名指す/Pgキーを持ち、その/Kの子は、そのページに結び付いたマーク付きコンテンツ参照とオブジェクト参照(§14.7.4.3)であることがあります
  • ParentTree(§14.7.4.4)は、ページと注釈の/StructParents番号を構造要素へ逆引きし、ルートからの/Kチェーンには一切現れないままそこに住む要素もありえます
  • 他のページ上のリンク注釈(§12.5.6.5)は、そのページを対象とする/Destまたは/GoToアクションを持ち、catalogの/OpenActionも同じことをしている場合があります
HotPDFのページを/Kidsから取り除くだけでは足りない理由。ISO 32000-1では、/Namesの/Dests名前ツリー、旧式のcatalogの/Dests辞書、しおり項目、/Pgを持つ構造要素、ParentTree、リンク注釈、/OpenActionがどれも同じページオブジェクトへの参照を保持でき、再構築されるのはページツリーだけです
PDFのページはcatalogの半分が指している的です。葉を外せばページツリーは満たされますが、それ以外のポインタはすべてnullに解決されるので、トリミングしたレポートはContentsブックマークを失い、アクセシビリティ検査に落ちます

THotPDF.DeletePageはページツリーに触る前に何を片付けるのか

読み込み済みの文書に対するTHotPDF.DeletePage(PageIndex)は、まず参照の一掃をすべて実行し、次にDeleteObjでページオブジェクトを削除済みにし、ウィジェット注釈をAcroFormのフィールドツリーから切り離し、内部のページ配列をずらし、最後にRebuildLoadedPageTreeを呼んで/Kids、/Count、そして生き残った各ページの/Parentを書き直します。一掃はcatalogを決まった順に訪れます。/Namesの/Dests名前ツリー、旧式の/Dests辞書、/OpenAction、しおりツリー、ParentTreeを伴う/StructTreeRoot、そして最後に残るすべてのページの/Annots配列です。各段階は、その構造がページなしで何をしてよいかを規格が許しているかに従って、参照を削除するか、向け直すか、そのままにするかを決めます。そのすべてが走る前に2つのガードが働きます。DeletePageは範囲外のインデックスに対してInvalid page numberを送出し、最後の1ページの削除を拒否します。子が0個の/Pagesノードは妥当なPDFではないからです。一方DeletePagesは、他の読み込み済み文書のページ操作と同じ1ベースの"1,3-5,7-"記法を受け取り、選択された最も大きいインデックスから順に処理するので、処理中も書いたインデックスが有効なまま保たれます

THotPDF.DeletePageがページツリーに触る前に走らせる決まった順序の参照一掃。ガードが範囲外インデックスや最後の1ページを拒否し、次に/Namesの/Destsと旧式の/Destsが整理され、/OpenActionが外され、しおりがNearestRetainedPageへ向け直され、StructTreeRootとParentTreeが整理され、残るページのリンクが削除され、最後にRebuildLoadedPageTreeが走ります
各構造は規格が許す扱いを受けます。名前は消え、ブックマークは最も近い残存ページに着地し、構造要素は/Pgを失うか消えるかし、/Kidsの書き直しは他に何も削除対象オブジェクトへ到達できなくなってから行われます
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // 0ベース:表紙ページを削除する。名前付きデスティネーション、
      // ブックマーク、構造ツリー、ParentTree、そしてそこを
      // 指していたリンク注釈は、/Pagesツリーを再構築する
      // 前に整理される。
      Pdf.DeletePage(0);
      // バッチ用の1ベース範囲記法。内部では最も大きい
      // インデックスから処理し、手前のインデックスを有効に保つ。
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

名前付きデスティネーションとブックマークはどう違う扱いになるのか

名前付きデスティネーションは削除し、ブックマークは向け直します。存在しなくなった名前は許容できる結果ですが、行き先のないブックマークは目に見える欠陥だからです。/Namesの/Destsツリーでは、HotPDFがすべてのノードをたどり、裸の配列形式と/Dキーを持つ辞書形式の両方について各デスティネーションを削除対象ページと突き合わせ、配列の最初の要素がそのページであるときに名前と値の組を削除します。/Namesと/Kidsがどちらも空になったノードは削除済みとして印を付けられ、親から切り離されるので、ツリーに空洞の葉が残ることはありません。同じ検査が旧式のcatalogの/Dests辞書に対しても走り、catalogの/OpenActionは、削除対象ページで開いていたなら単純に外されます。ここに境界が1つあります。名前ツリーのノードがエントリを失ったとき、HotPDFは新しい最小キーと最大キーを計算し直すのではなく、そのノードの/Limitsの組を削除します。ビューアはそれがなくても名前を問題なく解決しますが、ISO 32000-1 §7.9.6を読む厳格な適合性チェッカーは、/Limitsを持たない非ルートノードを指摘するかもしれません

しおり項目は逆の扱いになります。RetargetOutlineDestinationsはしおりのルートから/Firstと/Nextをたどります。訪問済みリストと128の深さ制限を備えているので、壊れた循環ツリーが呼び出しを固まらせることはありません。そしてそのページを狙った/Dest配列や/GoToアクションの/D配列それぞれについて、最初の要素をNearestRetainedPageに置き換えます。削除されたページの次に来ていたページ、削除対象が最後のページだった場合はその前のページです。ページ参照より後ろのビューパラメータはそのままにします。したがって、削除された章扉を指していたブックマークは、サイドバーから消えるのではなく、残った部分の最初のページに着地します。これはレビュー担当者がトリミング済み文書に期待する振る舞いです。ただしデスティネーションの検査が一致させるのは明示的な配列だけです。/Destが、以前は削除対象ページに解決されていた名前文字列であるしおり項目は向け直しません。名前ツリーのエントリが消えており、その参照は解放済みオブジェクトではなく何も指さない状態になるので、ビューアはそれを死んだブックマークとして扱います。しおりツリーそのものの仕組み、つまり/First、/Next、そして直感に反する/Countのセマンティクスは、読み込み済みPDFにブックマークと名前付きデスティネーションを追加する手引きで扱っています

// 一掃を信じるのではなく検証する。
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// 表紙を狙っていたブックマークは、その次に来ていた
// ページ(削除後は0ベースのインデックス0)に解決される。
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

構造ツリーとParentTreeはどうなるのか

削除対象ページのためだけに存在する構造要素は削除され、複数ページにまたがる要素は/Pgキーを失いますが子は保ちます。PruneStructureElementは/StructTreeRootから/Kチェーンを深さ128まで下り、§14.7.2が許している/Kの配列形式と単一辞書形式の両方を扱います。各要素について、まず子を整理し、それから要素自身を評価します。整理の結果/Kが空になったなら、その要素は削除済みとして印を付けられ、親がそれを外します。要素自身の/Pgが削除対象ページを名指し、かつその要素にまだ子と/Pの親があるなら、削除するのは/Pgだけです。要素上の/Pgはマーク付きコンテンツの子にとっての既定ページであり、それらの子は別のページを明示的に参照しているかもしれないからです。/Pgが削除対象ページで、その下に何も残っていない要素だけが、丸ごと削除されます

ParentTreeも同じ扱いを受けます。その理由は開発中に噛みつかれたものでした。構造要素はParentTreeからだけ到達でき、他からはどこからも到達できないことがあるのです。この番号ツリーは/StructParentsの整数を、単一の要素か要素の配列に対応付けます。PruneParentTreeNodeは見つけたすべての値に対してPruneStructureElementを走らせ、整理されて消えた値を取り除き、値の配列が空になった/Numsの組を削除し、/Numsと/Kidsがどちらもなくなったノードを切り離します。/Kの子孫だけを整理していたら、そうした孤立した要素が、/Pgを通して解放済みページを指し、/MCRの子を通して解放済みのマーク付きコンテンツ参照を指したまま残っていたでしょう。構造順にテキストを抽出するなら、これは直結する話です。構造順のテキスト抽出はまさにこれらのツリーをたどりますし、/Pgがnullの要素は、読み順から黙って抜け落ちる段落です

生き残ったページ上のどのリンク注釈が削除されるのか

生き残ったページ上のリンク注釈のうち、/Dest配列や/GoToアクションが削除対象ページを指しているものは、その構造ツリー上の所有関係ごと削除されます。RemoveRetainedPageDestinationAnnotationsは、対象以外のすべてのページの/Annots配列をたどり、しおりで使ったのと同じデスティネーション検査を適用し、一致した注釈を削除済みとして印を付け、配列から外し、それからPruneAnnotationReferencesInStructureTreeを呼びます。これにより、/Objがその注釈を名指していたOBJR辞書が構造要素から取り除かれ、そのOBJRが唯一の子だった場合は要素自体も取り除かれます。OBJRをそのまま残すと、/Objが既存のオブジェクトを参照することを求める§14.7.4.3に違反し、PDF/UAのチェックでは、背後に注釈を持たないタグ付きリンクとして現れます。ブックマークとの非対称性に注意してください。リンクは向け直すのではなく削除します。本文中の「page 3を見よ」という相互参照は、page 3が消えれば間違いになります。それをpage 4に向けるのは、ブックマークが最寄りの章に着地するのとは違って、嘘になります。ですからワークフローがそれらのリンクの保持を必要とするなら、DeletePageを呼ぶ前に自分で向け直してください

削除された/MCRや/OBJRを解放リストに登録してはいけない理由

マーク付きコンテンツ参照とオブジェクト参照は通常、親要素の/K配列の中にある直接辞書であり、インクリメンタル変更レジストリは直接オブジェクトを、それを含む最も近い間接オブジェクトに解決するからです。RemoveArrayItemが/K配列から子を外すとき、メモリ上のオブジェクトを解放するのは、それがTHPDFLinkか非間接の値であった場合だけです。またMarkRemovedObjectがオブジェクトを解放リストに登録するのは、そのオブジェクト番号が0より大きいときだけです。この一掃の最初のバージョンはその区別をしておらず、インクリメンタル保存での影響は、レジストリがまさに設計どおりに働いた結果でした。RegisterIncrementalChangeが直接の/MCRからグラフのトランザクションルートまで、つまりそれを所有していた生き残り側の構造要素までさかのぼり、その要素をnullとして書き出してしまったのです。1ページを失っただけの文書が、他のページのタグ付きコンテンツを黙ってタグなしにして戻ってきました。直接の子に対して唯一正しいのは、TouchContainerでそのコンテナをダーティにしてコンテナを書き直させることであり、解放リストには手を付けないことです

HotPDFで削除された/MCRやOBJRの子を解放リストに登録してはいけない理由。インクリメンタル変更レジストリは直接辞書を最も近い間接コンテナに解決するため、最初のバージョンは生き残り側の構造要素をnullとして書き出し、残るページを黙ってタグなしにしました。今はTouchContainerがコンテナを書き直し、解放リストには手を付けません
メモリ上の子の解放はTHPDFLinkか非間接の値、そして0より大きいオブジェクト番号に限られるので、インクリメンタル保存は触れたコンテナと解放されたページオブジェクトだけを追記します
// インクリメンタル更新:追記されるセクションに入るのは
// 触れたコンテナと解放されたページオブジェクトだけ。
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // /Kが直接の/MCRを失った生き残り側の構造要素は
  // その場で書き直され、nullとして書かれることはない。
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

同じ慎重さが、DeletePageが読み込み済み文書上で意図的に解放しないものも形作っています。削除対象ページのコンテンツストリーム、XObject、そしてウィジェット以外の注釈はオブジェクトとして残されます。読み込んだファイルでは、それらのどれでも残るページと共有している可能性があり、削除の時点でそうでないことを証明する安い方法がないからです。正しさのためにはページツリーの参照を取り除けば十分です。それらのオブジェクトがまだ占めているバイト数は別の問いであり、トリミング済み文書が何をまだ抱えているかを測る道具がオブジェクト依存グラフと保持バイトの分析です

DeletePageとDeleteLoadedPage、どちらを呼ぶべきか

ユーザーに見える形でページを削除するときはDeletePageを呼び、文書全体を組み直していて文書レベルの参照を残す価値がない場合にだけDeleteLoadedPageを取っておきます。バージョン2.508.0で追加されたTHotPDF.DeleteLoadedPage(PageIndex)は軽量な変種です。内部のページ配列をずらし、RebuildLoadedKidsArrayを呼んで/Kidsと/Countを書き直し、レンダリング済みページキャッシュを無効化し、OnLoadedDocumentModifiedを発火させます。名前ツリー、しおり、構造ツリー、他のページの注釈はたどらず、ページオブジェクトを削除済みにすることもありません。N-up面付けの中ではこれが正しい道具です。そこではHotPDFが新しく合成したシートを追加し、その後にDeleteLoadedPage(0)で元のページをすべて落とします。元のページは丸ごと置き換えられ、シートのコンテンツはページオブジェクトではなくそのリソースを参照しているからです。よくある「この契約書から7ページ目を削除する」という仕事では、SaveLoadedDocumentによる完全な書き直しでも、SaveIncrementalUpdateによるインクリメンタル更新でも、タグ付きでブックマークと相互リンクのある文書をバリデータが通る程度に一貫させてくれる呼び出しはDeletePageだけです。どちらのメソッドもDelphiとC++Builder向けのHotPDF Delphi Componentに含まれており、外部のビューアランタイムや依存は一切不要です