技術記事

DelphiのHotPDFで既存のPDFにブックマークを追加する方法

HotPDF は、Delphiで AddLoadedOutline を介して既存のPDFにブックマークを追加することができます。これにより、読み込まれたドキュメントの任意のネストレベルにアウトラインエントリを作成できます。また、ResolveLoadedNamedDestination および AddLoadedNamedDestination を介して、名前付きデスティネーション(Named Destination)の読み書きに対応しています。ファイルをロードし、ツリー構造を構築して SaveLoadedDocument を呼び出すだけで、これまで空白だったリーダーのサイドバーに機能する目次が表示されるようになります。

この機能が必要とされるシナリオは、非常によくあるものです。十数個の契約書を1つのレビューパッケージにまとめたり、3つの製品マニュアルを1つの配布用ファイルに結合したりして、出力ファイルの構造(全ページの存在、フォントの整合性など)は問題ないとします。しかし、Acrobatなどで開いてみると、ブックマーク(しおり)パネルが空になってしまっています。ナビゲーション機能のない400ページのドキュメントは、技術的には完全であっても、実用上は使い物になりません。バージョン 2.347.0 までの HotPDF は、読み込まれたドキュメント内のブックマークの読み取り、変更、削除には対応していたものの、新規作成はサポートしていませんでした。現在はこのギャップが解消されています。この記事では、新しいAPIインターフェースと、単純なエントリカウンタとして扱うとバグの元になるPDFの難解な仕様である /Count キーについて解説します。

アウトラインツリーの実態について

PDFのアウトラインは、平坦なリストではなく、辞書の二重リンクツリー構造をしています。この構造を理解すれば、APIの各パラメータの意味が明確になります。ISO 32000-1 §12.3.3 での定義によると、ドキュメントカタログは /Outlines ルートを指し、ルートは /First および /Last を介して最初と最後の子要素を指し、兄弟要素は /Next および /Prev を介してチェーン接続され、各エントリは /Parent を介して親要素を指し示します。各エントリは /Title 文字列を保持し、通常はクリックした際にユーザーを移動させる場所を示す /Dest を保持します。

デスティネーション(移動先)には2つの形式があり、ISO 32000-1 §12.3.2 ではこれらを明確に区別しています。「明示的デスティネーション」は、[page /XYZ x y zoom] のようなインライン配列であり、ページオブジェクトへの直接参照と表示仕様で構成されます。一方、「名前付きデスティネーション」は移動先の名前文字列を保持し、実際のターゲットはドキュメントカタログ内の /Names /Dests 名前ツリーに格納されます。このワンレベルの間接参照により、複数のリンクで1つのターゲットを共有でき、リンクに触れることなくターゲットを移動させることができます。HotPDFはブックマークの作成時に明示的な /XYZ デスティネーションを書き込み、名前ツリーの読み取りと拡張のために個別のAPIを提供しています。

Delphiで既存のPDFにブックマークを追加する方法

LoadFromFile を呼び出した後に AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) を呼び出します。最上位のエントリを作成する場合は ParentIndex = -1 を渡し、既存の最上位エントリの下にネストさせる場合はその 0 から始まるインデックスを渡します。この関数は、ドキュメントにアウトラインが存在しない場合は /Outlines ルートを自動作成し、エントリ辞書を構築し、0から始まる DestPageIndex を指す /Dest [page /XYZ x y zoom] 配列を書き込んで、新しいエントリを兄弟チェーンにリンクします。成功時は 0 を返し、DestPageIndex が範囲外などの理由で失敗した場合は -1 を返します。

リンク処理における順序に関する1つの重要な仕様として、新しいエントリは親要素の子供チェーンの「先頭」(新しい /First)に挿入されます。そのため、最上位エントリとして「Chapter 1」を追加した後に「Chapter 2」を追加すると、サイドバーでは「Chapter 2」が「Chapter 1」の上に表示され、「Chapter 1」の最上位インデックスは 1 にシフトします。実践的なパターンとしては、エントリを読書順とは逆の順序で追加するか、または各親エントリを追加した直後に(その親がまだインデックス 0 に存在している間に)その子要素を追加していく方法が挙げられます。

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Add chapters in reverse reading order: each new
      // top-level entry becomes /First (index 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 is now top-level index 0; nest sections
      // under it (children also prepend, so reverse order).
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

XY、および Zoom パラメータは /XYZ デスティネーションに直接マッピングされ、すべてデフォルトは 0 です。PDFは座標原点を左下隅とするため、Y = 792 は US Letter ページの最上部を指します。また、ISO 32000-1 に従い、/XYZ スロットに 0 を指定するとビューアの現在のズーム設定が維持されるため、ほぼすべてのケースでズームには 0 を指定するのが適しています。これらの操作はすべてメモリ上のオブジェクトグラフに対して実行され、SaveLoadedDocument が実行されるまでディスクには書き込まれません。これは、既存PDFのタイトル、作成者、およびXMPメタデータの書き換えなど、既存ドキュメント操作APIで採用されている「編集してから保存する」モデルと一致しています。

ブックマークの再配置:ページジャンプとURIアクションの違い

既存のブックマークは、再作成することなくリンク先を変更できます。HotPDFは、背後にあるPDFの構造的な違いを考慮して、2つのケースに対して個別のAPIを提供しています。SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) は、Index で指定された最上位エントリの /Dest を新しい /XYZ 配列で置き換えます。これは、マージ処理の後にすべてのブックマークが元のマージ前ファイルのページ番号を指してしまっている問題の修正に適しています。一方、SetLoadedOutlineURI(Index, URI) は構造的に異なる処理を行います。これは /S /URI アクションを含む /A アクション辞书を添付し、ブックマークをドキュメント内のジャンプではなくウェブリンクに変換します。

// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

ツリーを設計する際は、これら2つの仕組みの違いを意識してください。/Dest は純粋にドキュメント内部のナビゲーションであり、URI アクションはドキュメントの外部へ移動します。ISO 32000-1 では、アウトラインエントリはどちらか一方の仕組みでナビゲートすることを想定しており、両方を混在させるべきではありません。そのため、SetLoadedOutlineURI はすでにページ移動先が設定されているエントリではなく、ツリーの末尾にある「製品のホームページ」や「不具合の報告」といった、ページデスティネーションを持たないリーフエントリに対して使用してください。ここで書き込まれる URI アクションは、ページコンテンツ内のリンクアノテーションで使用される /S /URI 構造と同じであり、詳細についてはHotPDFを使用したPDFドキュメント内へのハイパーリンク作成ガイドを参照してください。

読み込まれたPDFでの名前付きデスティネーションの動作

名前付きデスティネーションは、ISO 32000-1 §12.3.2.3 で定義されている「固定アンカー」の仕組みです。すべてのリンクに直接ページ参照を埋め込む代わりに、ドキュメントカタログの /Names /Dests 名前ツリーで「名前からデスティネーションへのマップ」を保持し、リンクは名前を介してそれを参照します。ResolveLoadedNamedDestination(Name) はそのツリーから名前を検索し、対応する 0 から始まるページインデックスを返します(名前が存在しない場合は -1 を返します)。このリゾルバーは、仕様がマッピング値に対して許可している [page /XYZ x y zoom] のような生のデスティネーション配列と、それを /D キーの下にラップした辞書形式の両方のデータ構造に対応しています。

逆に、AddLoadedNamedDestination(Name, DestPageIndex, X, Y)/Names /Dests ツリーに新しいアンカーを登録し(ツリーが存在しない場合は自動作成し)、成功時に True を返します。これらを組み合わせることで、マージされたドキュメントにおいて上流のツールが作成したアンカーを尊重しつつ、下流のリンクがターゲットとするための新しいアンカーを公開するワークフローを実装できます。

var
  PageIdx: Integer;
begin
  // Honor an anchor another tool wrote into /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publish a new anchor for other links to target.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

実質的な制限として、HotPDFがブックマークや名前付きアンカーに書き出すのは /XYZ デスティネーションのみです。仕様が定義している /Fit/FitH/FitB などの他の明示的な形式は出力されませんが、ズームを 0 に設定した /XYZ は「その場所に移動し、現在のズーム率を維持する」という最も一般的なユースケースをカバーできます。また、ブックマークのタイトルから宛先ページを逆引きする便利な関数 GetLoadedBookmarkPageIndex(Title) は、アウトラインツリーの最上位レベルのみをスキャンするため、深い階層のリーフではなく、作成した章レベルのエントリの検証用に使用してください。

/Countキーの罠:エントリ数ではなく子孫の数を表す仕様

アウトラインルートに存在する /Count キーの意味は、多くの開発者が想定しているものとは異なり、この誤解はデータ破損のように見えるテストの失敗を引き起こします。ISO 32000-1 §12.3.3 の定義によると、/Count は最上位のエントリ数ではなく、「表示されているすべての子孫エントリの総数」を表します。さらに、個々のエントリにおいてもこの値の符号に意味があります。正の値はそのエントリが開かれており、その数の子孫エントリがサイドバーに表示されていることを意味し、負の値はそのエントリが折りたたまれており、|N| 個の子孫エントリが非表示になっていることを表します。

これは、HotPDFのブックマークAPIの開発段階での回帰テストでも浮き彫りになりました。テスト用ドキュメントには3つの最上位ブックマークがあり、そのうちの1つに子要素が1つ含まれていました。最上位エントリを1つ削除した後、ルートの /Count を読み取る GetLoadedOutlineCount は、想定していた 2 ではなく 1 を報告しました。データが破損したわけではありません。初期値の 3 は「3つの最上位エントリ」を意味していたわけではなく、正しい再計算は、最上位チェーンを走査して各ノードの値にその正の /Count を累積し、折りたたまれているノードの子孫はスキップして合計を算出する必要があるためです。したがって、ご自身のコードでの教訓として、構造編集の後に /Count の差分に基づいて検証を行わないでください。表示されている子孫エントリの数によって値が非線形に変化するためです。代わりに、コンテンツ(タイトル)の検索によって構造を検証してください。

// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

既存ドキュメント操作ツールセットにおける位置づけ

ブックマークと名前付きデスティネーションの作成機能の追加により、これまでに提供されてきたメタデータの書き換え、リンクアノテーションの追加、およびXFDFインポート/エクスポートを介したアノテーションの往復処理といった、既存ドキュメント操作APIの機能が補完されました。すべて「ロード、メモリ上での *Loaded* 関数の実行、および SaveLoadedDocument による保存」という一貫した開発サイクルに統合されているため、PDFのマージやスタンプ処理のパイプラインの最後のステップとして、目次作成の処理を簡単に追加できます。

ここで紹介した AddLoadedOutlineSetLoadedOutlineDestinationSetLoadedOutlineURIResolveLoadedNamedDestinationAddLoadedNamedDestination、および GetLoadedBookmarkPageIndex は、外部依存関係や個別のビューア実行環境を必要とせず、DelphiおよびC++Builder向けの標準のHotPDF Componentに含まれています。