技術記事

Delphi で PDF のブックマークと注釈アクションを読み取る

上流工程から流れてきた PDF のフォルダを引き継ぎ、依頼は一見単純です。どのブックマークが外部 URL へ飛び、どれが JavaScript を実行し、内部移動のものは実際にどこへ着地するのかを教えてほしい。ところが API リファレンスを開くと、ライブラリはそれらのアクションをすべて作成できるのに、読み戻す手段は何も用意していないと分かります。この非対称性は PDF ツール全般にあります。開くブックマークを書き込むのは https://example.comhttps://example.com/A/S/S/URI/Dest/D

TPdfActionIDTPDFObject だけを返し、あとは自分で潜るしかありませんでした。v3.77.0 では、小さな型付きイントロスペクション API 群によってその一部が埋まり、アクション種別、アクションのペイロード、到達先のジオメトリを素のレコードとして返せるようになりました。この記事では、それらの呼び出しが ISO 32000-1 のアクションおよび destination モデルへどう対応しているか、そして手書き実装が静かに間違えやすい 3 つの具体的な落とし穴を扱います

アクションを読むことが書くことより難しい理由

PDF におけるアクションは、サブタイプを示す /S/SGoToGoToGoToRURIURILaunchLaunchJavaScriptNamedGoToRJavaScriptNamedURIURI/URI/URIGoToRLaunchLaunchGoToR/F/FJavaScriptJavaScript/JS/JSGoToGoTo/D/D

読む側では、まず /S/SGetOutlineActionInfoGetOutlineActionInfoGetAnnotActionInfoGetAnnotationActionInfoTPDFlibActionInfoTPdfActionInfo

type
  TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
                       akLaunch, akNamed, akJavaScript);

  TPDFlibActionInfo = record
    Kind: TPDFlibActionKind;
    URI: AnsiString;          // populated for akURI
    JavaScript: WideString;   // populated for akJavaScript
    FileName: AnsiString;     // populated for akGoToR / akLaunch
    OpenInNewWindow: Boolean; // akGoToR / akLaunch
  end;

レコードを返します。KindKindKindKindakURIpakURIURIUriakGoTopakGoToakNone のときは、どのペイロードフィールドも意味を持たず、さらに下で扱う destination 側へ進みます

ブックマークを見つけるためにアウトラインツリーをたどる

ブックマークを解析するには、まずそのハンドルが必要です。PDFlibPas はアウトラインノードを整数 ID で識別し、FindOutlineByTitleFindOutlineByText

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

が表示テキストで検索します。どこまで探索するかは明示的に制御できます。DepthSearchModeosdSiblingsOnlyosmSiblingsosdChildrenOnlyosmChildrenosdFullSubTreeosmRecursiveGetFirstOutline0

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Search the whole tree from the root for a nested bookmark.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID is now a handle you can pass to the action and
        // destination getters below.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

UnicodeStringWideString として比較されるため、大文字小文字を区別し、保存されている Unicode テキストをそのまま尊重します。生成元がばらばらの PDF を相手にするなら、検索語も文書に保存されたのと同じ形に正規化しておかないと、存在しない取りこぼしを追いかけることになります

ブックマークのアクションとターゲットを解決する

ハンドルを得たら、GetOutlineActionInfoGetOutlineActionInfoKind を呼んで型付きビューを得ます。パターンは単純で、

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetOutlineActionInfo(FoundID);
  case Info.Kind of
    akURI:
      Writeln('Opens URL: ', Info.URI);
    akGoToR, akLaunch:
      Writeln('Opens file: ', Info.FileName,
        ' (new window: ', Info.OpenInNewWindow, ')');
    akJavaScript:
      Writeln('Runs script: ', string(Info.JavaScript));
    akGoTo:
      Writeln('Jumps within this document');  // see destination below
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

KindGetActionURLGetActionFileNameURIURIGetActionURLGetActionFileName/F/FGoToRLaunchLaunchGoToRURIURIURIURI/URI/URIURIURI/URI/URIakURIURIakGoToRLaunchakLaunchGoToR

destination の fit 種別と、その背後にあるジオメトリ

GoToakGoTo というアクションは「この文書内で移動する」を意味しますが、どこへ なのか、どのように 表示するのかまでは語りません。それを担うのが destination であり、人が思うより多くのニュアンスを持っています。PDF の destination は単なるページ番号ではなく、ページに加えて、そのページをビューアがどうフレーミングすべきかを表す fit 指定です(ISO 32000-1 §12.3.2.2)。GetOutlineDestinationInfoGetOutlineDestinationInfo

type
  TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
    dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);

  TPDFlibDestinationInfo = record
    Kind: TPDFlibDestinationKind;
    Page: Integer;   // 1-based; 0 when unresolved
    Left, Top, Right, Bottom, Zoom: Double;
  end;

8 種類の fit は、それぞれ違うフレーミングの問いに答えます。dkXYZpdfdXYZLeftLeftTopTopZoomZoomdkFitpdfdFitdkFitHpdfdFitHdkFitVpdfdFitVdkFitRpdfdFitRdkFitB*pdfdFitB

PDF reader bookmark navigation panel showing a nested outline tree
このナビゲーションパネル内の各ブックマークはアクションへ解決され、内部ジャンプの場合は独自の fit 種別と座標を持つ destination へ解決されます。

実装の内部では、信頼性の理由を説明してくれる意図的な整列が使われています。内部関数 GetDestTypeGetDestinationFitTypeTPDFlibDestinationKind は 8 種類の fit に対して XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV の順でちょうど 1..8 の整数を返します。dkXYZpdfdkXYZdkFitBVpdfdkFitBVdkNonepdfdkUnknown

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // destination did not resolve
  case Dest.Kind of
    dkXYZ:
      Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
    dkFitR:
      Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
    dkFit, dkFitB:
      Writeln(Format('Page %d, fit whole page', [Dest.Page]));
  else
    Writeln(Format('Page %d, fit kind %d',
      [Dest.Page, Ord(Dest.Kind)]));
  end;
end;

PageIndexPage が 0 のときは destination が解決できなかった合図であり、通常はアクションが destination を持たないか、名前付き destination が見つからなかったことを意味します。座標を信用する前にここを確認してください。また GetOutlineDestinationInfoGetOutlineDestinationInfo/Dest/DestGoToGoTo/D/D

注釈アクションと SelectPage の落とし穴

リンク注釈はブックマークと同じようにアクションを持ち、GetAnnotActionInfoGetAnnotationActionInfoTPDFlibActionInfoTPdfActionInfo

を返します。種別を見てからペイロードを読むという流れも同じです。ただし、アウトラインにはない状態依存の落とし穴が 1 つあり、これが 3 つ目の罠です。GetAnnotActionInfoGetAnnotationActionInfoSelectPage(N)SelectPageakNonepakNone

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // mandatory before touching annotations
    // GetAnnotActionID(1) <> 0 is the reliable "has an action"
    // test. CheckPageAnnots returns a boolean-style flag, not a
    // count, so it is the weaker signal here.
    if Lib.GetAnnotActionID(1) <> 0 then
    begin
      Info := Lib.GetAnnotActionInfo(1);
      if Info.Kind = akURI then
        Writeln(Format('Page %d link -> %s', [P, Info.URI]));
    end;
  end;
end;

このループ内で意図的にしていることは 2 つあります。1 つ目は、SelectPage(P)SelectPageGetAnnotActionID(1) <> 0GetAnnotationActionID(0) <> 0CheckPageAnnotsHasAnnotationsJavaScriptJavaScript/JS/JS

読み取り側イントロスペクションの位置づけ

これらの getter は意図的に狭い責務に留められています。既存の整数ハンドルベースのアクション層と destination 層の上に積み上がる純粋な読み取り API であり、書き込み経路には触れず、同時に編集中の文書へ追加のリスクも持ち込みません。報告するのはファイル内にある内容だけで、ポリシー検証や書き換えは行いません。逆に、こうしたアクションを持つブックマークやリンク注釈を最初から構築したいなら、それは書き込み側の仕事です。関連する記事 Delphi でインタラクティブフォームのアクションと JavaScript を扱う では作成方法を順に説明しています。ナビゲーショングラフではなく、PDF から可視コンテンツや構造コンテンツを引き出したいなら、PDFlibPas でテキスト、画像、フォントを抽出する を参照してください

覚えておくべき正直な境界はこれです。イントロスペクションが見えるのは、生成側が実際に書いたものだけです。ジェネレータが不正なまま残したブックマークアクションや、定義されていない名前付きターゲットを指す destination は、例外ではなく akNonepakNonePDFlibPas のネイティブ PDF ライブラリの一部です