技術記事

DelphiのPDFにおけるGoToR、GoToE、Launchアクション

PDFlibPasは、DelphiとC++Builderの開発者に、現在のページを離れるナビゲーション用の3種類のアクションを提供する:GoToR(Go To Remote)は別のPDFファイル内の特定のページを開き、GoToE(Go To Embedded)は現在の文書内に埋め込まれたPDFファイルを開き、Launchはオペレーティングシステムを通じて外部プログラムを実行するかファイルを開く。この3つはすべてISO 32000-1 §12.6.4、すなわち日常的なGoToアクションも定義しているAction Typesセクションに存在し、それぞれが不注意な人にとっての独自の罠を運ぶ:どの呼び出しがそれを構築するかによって意味が異なるページ番号、ファイルパスではなく名前であるターゲット、そして同一に見えるが2つの異なるビューアに仕える一対の文字列パラメータである

これはどれも仮想の話ではない。技術リファレンスパッケージ——主要マニュアル、販売代理店が独自のスケジュールで更新する仕様PDF、その両方と一緒にインストールされる較正ユーティリティ——は、まさにこの種の文書間配線に頼っている:仕様ファイルの5ページ目に着地しなければならない相互参照、その隣ではなくマニュアルの内部に出荷する価値のあるデータシート、較正ツールへ直接引き渡すリンクである。本稿は既存のPDFからブックマークと注釈のアクションを読み戻すの鏡像である:その記事は他の何らかの生成器がすでにファイルに書き込んだGoToR、Launch、GoToEアクションを消費することを扱う;本稿は、PDFlibPasが1バイトもコミットする前に強制するフィールドレベルのルールを含めて、その同じ3種類のアクションをゼロから構築することを扱う

PDFアクションが現在のページを離れる3つの方法

PDFlibPasは、アクションの/Sキーにおいてローカルナビゲーションと他のすべてを分離しており、GoToR、GoToE、Launchは、そのターゲットが現在のページの外にある3つのサブタイプである:GoToRはISO 32000-1 §12.6.4.3の下、GoToEは§12.6.4.4の下、Launchは§12.6.4.5の下にあり、これらはすべて、日常的なGoToアクションも定義しているより広い§12.6.4 Action Typesセクションの内部にある。単純なGoToアクションの目的地は文書内にすでに存在するページオブジェクトを名指しするため、PDFlibPasはそれを即座に検証できる;GoToRとGoToEは同じようにそれができない、なぜなら外部ファイルはこのマシン上にそもそも存在しないかもしれず、埋め込みファイルのページ数はホスト文書が追跡するものではないからだ。そのため両方とも、固いリンクの代わりに未解決の参照を運ぶ——GoToRはファイル指定と目的地、GoToEは埋め込みファイル名とターゲットページ——一方Launchは目的地の概念を完全に落とし、オペレーティングシステムが実行または開くべき何かを単に名指しするだけである。この分裂は書き込み側で2つの呼び出しファミリーとして現れる:AddLinkToFileAddLinkToFileExAddLinkToEmbeddedPDFAddLinkToLocalFileのような高レベルのワンショットビルダーは、ページのホットスポットリンク注釈とそのアクションを一緒に作成し、ほとんどの実際のレイアウト——読者がクリックする一行のテキストやアイコン——をカバーする一方、SetActionRemoteDestinationExSetActionLaunchOptions、そしてそれらのAddActionNext*の対応物のような低レベルのセッターは、すでにハンドルを持っている何か——既存のブックマーク、フォームフィールドトリガー、あるいは文書やページレベルのライフサイクルイベント——にアクションを取り付けるか置き換える。両方のファミリーは最終的に同じ辞書の形を書き込むことになる;違いは呼び出すときにどこに立っているかであり、そして次のセクションが扱うように、そのときページ番号が何を意味するかである

別のPDFファイル内のページを開くGoToRリンクをどう構築するか

GoToRアクションは2つのもの——ファイル指定とそのファイル内の目的地——を必要とし、PDFlibPasは2つ目の部分を供給するために2つの異なる呼び出しを公開しており、それぞれが独自のページ番号付けの慣習を持っている。高レベルのページホットスポットビルダーであるAddLinkToFileAddLinkToFileExは、そのPageまたはDestPage引数がゼロより大きいことを検証する、これはSelectPageを含む他のあらゆる場所でPDFlibPasが使う同じ1始まりの番号付けである。すでにハンドルを持っている何かにGoToRアクションを取り付けるか置き換えるために使われる低レベルのセッターであるSetActionRemoteDestinationExは、代わりにDestPageがゼロ以上であることを検証し、それを調整なしにアクションの明示的な目的地配列にそのまま書き込む:それはターゲット文書の生の、ゼロ始まりのページインデックスを求めており、これはISO 32000-1がリモートの明示的目的地に対して指定する番号付けである。低レベルのセッターを高レベルのビルダーに渡すのと同じ数値で呼ぶと、そのリンクは1ページ早く開いてしまう

var
  Lib: TPDFlib;
  ActionID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('manual.pdf', '') = 1 then
    begin
      Lib.SelectPage(12);
      // Page is 1-based here, same as SelectPage above: this opens
      // the fifth page of specs.pdf.
      Lib.AddLinkToFile(72, 700, 200, 16, 'specs.pdf', 5, 0, 0, 0);

      // A later maintenance pass repoints the same link at a
      // reorganized file. SetActionRemoteDestinationEx edits the
      // action directly, and DestPage here is the zero-based index
      // PDF itself uses for a remote explicit destination -- "the
      // fifth page" is now 4, not 5.
      ActionID := Lib.GetAnnotActionID(1);
      Lib.SetActionRemoteDestinationEx(ActionID, 'specs-2026.pdf',
        4, Ord(dkFit), 0, 0, 0, 0, 0, 0, -1);
    end;
  finally
    Lib.Free;
  end;
end;

SetActionRemoteDestinationExの残りの引数も同じくらい文字通りである。ValueMaskはビットセットである——左に1、上に2、右に4、下に8、ズームに16——そしてPDFlibPasは何かを書き込む前にそれをDestTypeに対してチェックする:dkFitR目的地は正確に15(4辺すべて、ズームなし)を供給しなければならず、dkFitdkFitB0を供給しなければならず、dkFitH/dkFitVはその関連する1つの座標だけを受け付ける。それ以外は有効なマスク内で設定しないままにしたビットは、配列から省略されるのではない;それらは明示的なPDF nullとして書き込まれ、ISO 32000-1はそれをその座標について「ビューアがすでに持っているどんな値でも保持する」と扱う——これは見落としではなく、「このページにジャンプするが、ズームはそのままにする」と言う正当な方法である。ズーム自体は渡した値の分数として保存される、そのため150パーセントを求める呼び出しは配列に1.5という保存値を渡し、有効な入力範囲は0から6400である

自分自身の文書内に埋め込まれたPDFへどうリンクするか

AddLinkToEmbeddedPDFはGoToEアクションを構築し、そのターゲット引数EmbeddedFileNameはパスではなく名前である:それは、その添付ファイルが作られたときにすでにEmbedFileに渡されたTitle文字列と一致しなければならない。なぜならそのタイトルこそが、PDFlibPasが文書の/EmbeddedFiles名前ツリーに保存する文字通りのキーであり、GoToEはファイルシステムに再び触れるのではなくその名前を検索することで解決するからだ。この関数はEmbeddedFileNameが空でないこととTargetPageが少なくとも1であることだけをチェックする——実際には一度も埋め込まれたことのない名前を渡しても、呼び出しはそれでも成功を返し、アクションはそれでも書き込まれ、そのリンクはそれをクリックするすべての読者に対して単純に解決に失敗する

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;
    Lib.NewPage;
    // The Title argument becomes the key PDFlibPas stores in the
    // document's EmbeddedFiles name tree -- that string, not
    // "datasheet.pdf", is the target GoToE resolves against.
    if Lib.EmbedFile('Datasheet', 'datasheet.pdf', 'application/pdf') = 1 then
      Lib.AddLinkToEmbeddedPDF(72, 700, 200, 16, 'Datasheet', 3, 0, 0);
    Lib.SaveToFile('manual.pdf');
  finally
    Lib.Free;
  end;
end;

ここでは1つではなく2つのバージョンの下限が積み重なる。EmbedFile/EmbeddedFiles名前ツリーのためにPDF 1.4を必要とし、AddLinkToEmbeddedPDFは別途、GoToEアクションタイプ自体のために下限をPDF 1.6に引き上げる。そのためこの機能を使うあらゆる文書の実効最低値は1.4ではなく1.6である。ここでのTargetPageが1始まりであること、すなわち通常のPDFlibPasの慣習であることにも注目してほしい——これは前のセクションがちょうど扱ったゼロ始まりのDestPageとの意図的な対比であり、どちらのページ番号方式が適用されるかはアクション種別と特定の呼び出しに依存し、一律のルールには依存しないという念押しである。アクションのターゲット辞書はまた、埋め込みファイルへの2ホップの連鎖、あるいはそのコンテナへ戻る連鎖をサポートする「子」を意味するCか「親」を意味するP/Rエントリも運べるが、AddLinkToEmbeddedPDFは決して子の方向しか構築しない、なぜならそれが、埋め込まれる側ではなく埋め込む側の文書にとって意味をなす方向だからだ

Launchアクション:1つのFileName、交換可能ではない2つの文字列ターゲット

SetActionLaunchOptionsは、Launchアクションのファイルターゲットを、単一のFileName引数から2つの異なるキーへ書き込み、その2つのキーは2種類の異なる文字列を保持する。トップレベルの/Fキーは、GoToRに使われるのと同じパス変換を通じて構築されるファイル指定辞書を得る、これはISO 32000-1 §7.11.3がファイル指定辞書用に定義するポータブルな形式である。/Winサブ辞書は、PDFlibPasがそれを書く場合、渡されたままの生のFileName値に一切変換なしで設定された独自の/Fキーを得る、なぜなら/Win /FはISO 32000-1 §12.6.4.5でWindowsビューアだけが読むことを意図した単純なWindowsパス文字列として文書化されているからだ。両方のキーが同一になることを期待してポータブルなすでに変換済みのパスを渡すと、/Winのコピーは、あなたが関数に渡したものをそのまま手つかずで運ぶことになる

var
  Lib: TPDFlib;
  ActionID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('manual.pdf', '') = 1 then
    begin
      Lib.SelectPage(1);
      Lib.AddLinkToLocalFile(72, 660, 220, 16, 'calibrate.exe', 0);
      ActionID := Lib.GetAnnotActionID(1);
      // Operation 0 leaves this as a normal open -- pass 1 to ask a
      // Windows viewer to print instead. Parameters and
      // DefaultDirectory only ever reach /Win /P and /Win /D, never
      // the top-level /F.
      Lib.SetActionLaunchOptions(ActionID, 'calibrate.exe',
        '/silent /profile:default', 'C:\Tools\Calibration', 0, -1);
    end;
  finally
    Lib.Free;
  end;
end;

Launchをこの3つの中で最も摩擦の高いアクションとして扱ってほしい、なぜならその存在意義全体が、PDFのサンドボックスの外でプログラムを実行するかファイルを開くことであり、あらゆる主要なビューアがそれに応じて扱うからだ。Adobe AcrobatのEnhanced Securityは、ターゲットが明示的に信頼された場所に座っていない限り、既定でLaunchアクションをブロックするかプロンプトを出す。そしてほとんどのエンタープライズAcrobatの導入はその保護を有効なままにしている。そのため、公衆に渡される文書内のLaunchアクションは信頼できるトリガーではない:それがファイルを開くどのビューアによってもブロックされるか、プロンプトを出されるか、静かに無視されると計画し、それを、閉じた環境——社内キオスク、統制された企業展開、あなたが管理するマシンから決して離れない文書——のために取っておくこと。そこではビューアの信頼設定もあなたが制御している

PDF/Aのゲート:なぜGoToRとLaunchの呼び出しはゼロを返せるのか

SetActionRemoteDestinationExSetActionLaunchOptionsは、ターゲット文書が何らかのPDF/A適合性モードにある場合、完全に拒否する:両方とも、その最初の条件として文書のPDF/Aモードをチェックし、アクションに触れる前に例外を発生させずに結果0で終了する。これは意図的なものだ。PDF/Aの対話的アクションに対する制限は具体的にLaunchを排除する、なぜなら、アーカイブファイルに任意のプログラムを実行する能力を与えることは、まさに長期アーカイブフォーマットが防ぐために存在する環境依存の挙動だからだ。そしてPDFlibPasは同じ保守的なゲートを、同じコードパスでリモートgo-toセッターにも適用する。実際上の帰結は開発中に見逃しやすい:通常のPDFで機能する同一の呼び出しは、PDF/A適合性レベルが設定された文書上ではコンパイルされ、実行され、静かに何もしない。そのため成功を仮定するのではなく戻り値をチェックすること——ここでの0は不正な入力のエラーではなく、ライブラリが文書自身の適合性の主張と矛盾する要求を拒否しているのである

GoToR、GoToE、Launchがより大きなPDFlibPasワークフローにどう収まるか

本稿の3種類のアクションはすべてが同じ場所に到達するわけではない。文書・ページのライフサイクルアクショントリガーの関連記事SetDocumentActionSetPageActionを扱っており、これらは共有されたPDF_ACTION_BUILDER_REMOTE_DESTINATIONPDF_ACTION_BUILDER_LAUNCH定数を通じてWillCloseのようなトリガーにGoToRやLaunchアクションを取り付けることができる——これは単純なURIやJavaScriptトリガーもカバーする同じビルダーである。GoToEにはそのような定数はなく、その汎用ビルダーへの経路が全くない;AddLinkToEmbeddedPDFだけがPDFlibPasがそれを構築する方法であり、それはそれを厳密にページホットスポットアクションにし、決して文書やページレベルのトリガーにはしない。GoToRとLaunchがその汎用ビルダーに到達する場合、トレードオフは制御である:それは、名前付きリモート目的地だけを指すGoToRと、単なるファイル名とパラメータだけを持つLaunchアクションを構築する。一方、本稿で扱った明示的なページ・フィットタイプのアドレス指定とWindows固有のlaunchオプションは、SetActionRemoteDestinationExSetActionLaunchOptionsを直接通じてのみ到達する

これらのセッターの周りにメンテナンスツールを構築する前に知っておく価値のある安全性の性質が一つある。SetActionRemoteDestinationExSetActionLaunchOptionsは、まず完全な置き換えアクションをスクラッチ辞書に構築し、そのスクラッチコピーが検証されて初めて、/F/D/Win/NewWindowのキーをライブなアクションに削除・コピーする——そのため、範囲外のValueMaskや空のFileNameのいずれかによる検証失敗の呼び出しは、元のアクションとそこにすでにぶら下がっているどんな/Nextチェーンも、半端に上書きされるのではなく完全に手つかずのままにする。これが重要なのは、GoToRとLaunchのアクションはどちらも、AddActionNextRemoteDestinationExAddActionNextLaunchEx、あるいはより一般的なAddActionNextExで構築された/Nextチェーンの中に座ることができ、単一のトリガーがJavaScriptのログエントリを発生させてから連続してリモートジャンプを発火させることを可能にするからだ。ここで説明したGoToR、GoToE、Launchの構築は、DelphiおよびC++Builder向けネイティブPDFライブラリであるPDFlibPasの一部である