技術記事

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

表紙ページを切り取ったタグ付きレポートは、ページ数が正しいまま開けても3か所で壊れていることがあります。クリックしても「Contents」しおりは何も反応せず、アクセシビリティチェッカーはどこにも属するページのない構造要素を報告し、厳格なバリデータはフリーオブジェクトへの参照を1つ指摘します。ページツリー自体は無傷です。ISO 32000-1の§7.7.3が要求するのは、ページを/Kidsから外して/Countを1減らすことだけで、その条項を満たしたまま、catalogの半分が消えたページを指し続けるファイルも作れます

1ページを参照できる構造は7つある

そのうちページツリーは1つだけです。残りはcatalog、しおり、論理構造、そして他のページの注釈に散らばっており、ページオブジェクトがファイルから消えた瞬間に、どれもnullに解決されます。このnullをビューアがどう扱うかはビューアの自由なので、同じトリミング済みファイルでも、2つのリーダーで違う不調を見せることがあります

  • /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が/Kidsに触れる前に行うこと

読み込んだドキュメントに対するTHotPDF.DeletePage(PageIndex)は、まずそれらの構造をすべて一掃してから、ページツリーの書き直しを最後に行います。インデックスはゼロベースです。何かが変わる前に2つのガードが走ります。範囲外のインデックスはInvalid page number例外になり、最後に残った1ページを削除する要求も同じく例外です。/Pagesノードの子が0個では、有効なドキュメントを記述できないからです

一掃の順序は固定です。/Namesの/Destsツリー、旧式のcatalogの/Dests辞書、/OpenAction、しおり、/StructTreeRootとそのParentTree、そして最後に残る全ページの/Annots配列、の順で進みます。その後で初めて、ページオブジェクトが削除マークされ、そのウィジェット注釈がAcroFormフィールドツリーから切り離され、/Kidsと/Count、各残存ページの/Parentが書き直されます。フォーム側では、ウィジェットを失って子が1つもなくなった親フィールドも削除され、その上が/Fieldsまで順にたどられるので、トリミング後のフォームに画面に何もないフィールドエントリは残りません

THotPDF.DeletePageがページツリーに触れる前に走らせる固定の参照一掃。ガードが範囲外インデックスや最後の1ページを拒否し、次に/Namesの/Destsと旧式の/Destsが整理され、/OpenActionが外され、しおりのデスティネーションは最も近い残存ページへ移動され、StructTreeRootとParentTreeが整理され、残存ページのリンクが削除され、最後に/Kids、/Count、/Parentが書き直されます
各構造は規格が許す扱いを受けます。名前は消え、ブックマークは最も近い残存ページに着地し、構造要素は/Pgを失うか消えるかし、/Kidsの書き直しは他に何も削除対象オブジェクトへ到達できなくなってから行われます

DeletePagesは、他の読み込み済みドキュメント用ページ操作と同じ記法である'1,3-5,7-'のような1ベースの範囲文字列を受け取り、選択された最も大きいページから下へ向かって削除します。上から下へ進むので、ページが消えても書いた番号は有効なままです。全ページを覆う範囲は、DeletePageが最後の1ページを拒否するのと同じ理由で例外になり、この関数は削除したページ数を返します

var
  Pdf: THotPDF;
  Removed: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // LoadFromFile は読み込んだページ数を返します
    if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
    begin
      // ゼロベース。ページ0が表紙です
      Pdf.DeletePage(0);
      // 1ベースの範囲指定。現在のドキュメントに対する番号です
      Removed := Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

名前は消え、しおりは移動する

削除ページを指す名前付きデスティネーションは削除され、それを指すしおりは向け直されます。/Namesの/Destsツリーでは全ノードを訪れ、各デスティネーションを、規格が許す2つの形、素の配列と/Dキーを持つ辞書の両方で検査します。配列の最初の要素が削除ページなら、名前と値のペアを取り除きます。/Namesも/Kidsも持たなくなった中間ノードは親から切り離されるので、ツリーに中空の葉は残りません。旧式のcatalogの/Dests辞書にも同じ検査をキーごとに施し、削除ページを開く形だったcatalogの/OpenActionは取り除かれます。開き方はビューアのデフォルトに任されます

エントリを失ったノードは/Limits配列も失います。新たな最小キーと最大キーの再計算はせず、ビューアは/Limitsなしで名前を解決します。ただし§7.9.6は中間ノードと葉ノードに/Limitsを要求しているので、パイプラインのプリフライトがこの規則を厳格に強制するなら、トリミング済みファイルを同じジョブの中でその検査に通してください

しおりは/Firstと/Nextをたどって走査します。訪問済みリストと深さ128の制限を備えるので、自己ループする壊れた/Nextチェーンが呼び出しをハングさせることはありません。削除ページを指すすべての/Dest配列と/GoToの/D配列について、最初の要素は「その次にあったページ」で置き換えられ、削除ページが最後の1枚だった場合は「その前のページ」で置き換えられます。ページ参照より後ろのビューパラメータはそのままなので、/XYZジャンプはleft、top、zoomの値を保ったまま、それを新しいページに適用します

この非対称は意図的です。何も解決しない名前は合法で無害であり、存在しないデスティネーションを名指すGoToは単に何もしません。一方、デスティネーションがnullになったしおりは厄介です。サイドバーに残り、クリックできるように見えるのに動作しない。最も近い生存ページに着地させるのは、2つの不完全な選択肢のうちましな方です。向け直しの対象は明示的なデスティネーション配列だけです。/Destが名前文字列であるしおり項目は名前ツリーを経由して解決され、その名前が一掃で削除された後はどこも指さないので、名前付きしおりを多用するドキュメントは削除後に確認が必要です。下の確認コードはResolveLoadedNamedDestinationで名前を読みます。これはルートノードの/Names配列だけを見るので、フラットな名前ツリーには意味のある答えを返しますが、/Kidsに分かれたツリーでは削除の有無にかかわらず全名前が-1を返します。アウトライン自体の構造、とりわけ直感に反する/Countのセマンティクスについては、読み込んだPDFにしおりと名前付きデスティネーションを追加するガイドで扱っています

// 結果を決め打ちせず、必ず確認します
// Memo1 は自分のフォーム上のTMemoです
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination はルートの /Names 配列だけを読むので、
// この確認はフラットな名前ツリーなら意味を持ちます。
// /Kids を使うツリーでは、削除の有無にかかわらず全名前が -1 になります
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// 表紙を指していたしおりは、削除後の後続ページ、
// つまりゼロベースのインデックス0に解決されます
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
  [Pdf.GetLoadedBookmarkPageIndex('Contents')]));

構造ツリーとParentTreeの刈り込み

削除ページのためだけに存在していた構造要素は削除され、複数ページにまたがる要素は/Pgキーを失うものの子は保持します。走査は/StructTreeRootから始まり、§14.7.2が許す配列形式と単一辞書形式の両方の/Kを、深さ128まで降りて処理します。各要素の子を先に刈り込み、その後で要素自身を判定します。刈り込みで/Kが空になった要素は削除され、親から取り除かれます。自分の/Pgが削除ページを名指していても、子と/Pの親がまだある要素は/Pgだけを失います。要素の/Pgは、自分ではページを名指さないマーク付きコンテンツの子のためのデフォルトページにすぎず、それらの子は別のページに住んでいることもあるからです。/Pgが削除ページで、その下に何もない要素は丸ごと削除されます。最後のこの規則は、/Pgがそのページを名指すマーク付きコンテンツ参照辞書にも適用されます

ParentTreeも同じ扱いを受けます。見落としやすい理由があります。構造要素はParentTreeからしか到達できないことがあるのです。この数値ツリーは/StructParentsの整数を、単一の要素または要素の配列に対応付けます。すべての値に上述の規則を適用して刈り込み、削除された値は配列から抜け、配列が空になった/Numsペアは削除され、/Numsも/Kidsもないノードは切り離されます。この工程を飛ばすと、孤立要素が/Pgを通じて解放済みページを指し続けます。タグ順にテキストを抽出する人にとってこれは直接的な問題です。構造順テキスト抽出はまさにこのツリーをたどるので、/Pgがnullの要素は読み順から抜け落ちる段落になります

なぜ残存ページのリンクは向け直されず、削除されるのか

残存ページ上のリンク注釈のうち、/Dest配列や/GoToアクションが削除ページを対象とするものは、そのページの/Annotsから取り除かれ、タグも一緒に失われます。注釈を/Objで名指していたOBJR辞書は、親の構造要素から取り出されます。そのOBJRが要素の唯一の子だった場合は要素も削除され、ParentTreeが再度刈り込まれます。OBJRを残しておくと§14.7.4.3に反します。この条項は/Objが実在するオブジェクトを参照することを期待しており、PDF/UAチェッカーはオブジェクト参照が行き場を失ったLink要素を目にすることになります

しおりは移動し、リンクは移動しません。本文中の「3ページを参照」という相互参照は、3ページが消えた時点で誤りになります。それを黙って4ページに向け直すと、壊れたリンクが誤解を招くリンクに変わるだけです。次の章に着地するしおりはナビゲーションですが、本文リンクは内容についての主張です。削除のほうが安全なデフォルトです。ワークフロー上それらのリンクを残したいなら、DeletePageを呼ぶ前にデスティネーションを書き直してください。呼び出しの後には、もう/Annots配列から出てしまっています

インクリメンタル保存と直接子の罠

BeginIncrementalUpdateとSaveIncrementalUpdateの間でも、DeletePageの振る舞いは同じです。追記されるセクションには、一掃で変更されたオブジェクトに加えて、ページや削除されたリンク注釈のように固有のオブジェクト番号を持っていた削除オブジェクトへのfreeエントリが入ります。元の%%EOFより上は、§7.5.6が要求するとおりバイト単位でそのままです

Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // 直接の /MCR 子を失った構造要素は、
  // 短い /K 配列の形に書き直されて追記されます
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

手書きの刈り込み処理が足を踏み外すのがまさにここです。マーク付きコンテンツ参照(/Type /MCR)とオブジェクト参照(/Type /OBJR)は、典型的には親要素の/K配列の中に座る直接辞書です。直接オブジェクトにはオブジェクト番号もxrefエントリもないので、それ自体を解放することはできません。安直な発想は「削除された子をとにかくfreeにする」というもので、直接オブジェクトを、それを保持する間接オブジェクトに対応させて解放するwriterは、保持側である残存構造要素をfreeにすることになります。その要素はnullとして書き出され、一切触れていないページのタグ付きコンテンツからタグが消えます。HotPDFは、削除したオブジェクトを解放リストに送るのは固有のオブジェクト番号を持つ場合だけです。直接の子については、包含する要素を変更済みとしてマークするので、要素は子なしの形で書き直され、解放リストは汚れません

インクリメンタル更新で削除した/MCRやOBJRの子を解放リストに登録してはいけない理由。その子は要素の/K配列の中に座る直接辞書なので、解放するには包含する構造要素ごと解放することになり、それはnullとして書き出されて残存ページのタグを外します。正しい扱いは、コンテナを書き直して解放リストには手を付けないことです
解放リストに行くのは固有のオブジェクト番号を持つオブジェクトだけなので、インクリメンタル保存は書き直されたコンテナと、ページやその他の削除された間接オブジェクトへのfreeエントリを追記します

読み込み済みドキュメントでDeletePageが何に手を付けないかも、同じ用心が決めます。削除ページのコンテンツストリーム、XObject、ウィジェット以外の注釈は、オブジェクトとしてそのまま残ります。読み込んだファイルでは、それらのどれもが残るページと共有されている可能性があり、削除時点でそれを安価に証明する方法はないからです。正しいファイルにするには、ページツリーからの参照を外すだけで十分です。残骸がまだ何バイトを食っているかは別の計測であって、オブジェクト依存グラフと保持バイト数の分析はそのための道具です

DeletePageかDeleteLoadedPageか

ユーザーが要求したページ削除にはDeletePageを使います。THotPDF.DeleteLoadedPage(PageIndex)は軽量版です。同じ2つのガードを適用し、読み込み済みページリストをずらし、/Kidsと/Countを書き直し、描画済みページキャッシュを無効化し、OnLoadedDocumentModifiedを発火させます。ただし名前ツリー、しおり、構造ツリー、他ページの注釈には触れず、ページオブジェクトに削除マークも付けません。ComposeLoadedPagesNUpはその軽量版が適任の仕事です。ソースページをフォームXObjectとして描く新規シートを追記し、それから各オリジナルをDeleteLoadedPage(0)で落としていきます。シートが参照しているのはページの中身であって、ページオブジェクトではないからです。タグ付きで、しおり付きで、相互リンクされたファイルに対する「この契約書の7ページを消して」には、参照をすべて一貫したまま残すDeletePageが正解です。SaveLoadedDocumentで保存するかSaveIncrementalUpdateで追記するかは問いません

両メソッド、範囲指定のDeletePages、そして上で使ったインクリメンタル更新の経路はすべて、HotPDF Delphi Componentの一部です。マシンにビューアランタイムを置かずに、DelphiとC++Builderのコードから読み込み済みPDFを編集できます