技術記事

PDFのライフサイクルアクション:DelphiにおけるCatalog /AA対Page /AA

ネイティブDelphi・C++Builder PDFライブラリであるPDFlibPasは、PDF文書に自動的な挙動を掛ける2つの別個の場所を与える:WillClose、WillSave、DidSave、WillPrint、DidPrintのような文書レベルのライフサイクルアクションは、カタログの/AA辞書に保存され、ページレベルのライフサイクルアクション——OpenとClose——は代わりに各Pageオブジェクト自身の/AA辞書に保存される。この2つのコンテナを混同することが、ライフサイクルアクションが静かに何もしなくなる最も一般的な単一の原因である

この動機となるケースは平凡なものである。経理チームは、印刷が実際に始まった瞬間に印刷タイムスタンプを刻印し誰がそれを印刷したかをログに残す明細書テンプレートを望む、単にファイルが開かれた瞬間ではなく。フォームを多用するワークフローは、リーダーのPDFクライアントがウィンドウを閉じることを許される前に、フィールド値を自動的にサーバーへプッシュする必要がある。それによって閉じられたタブが決して失われた編集を意味しないようにするためだ。複数ページのレポートは、そのページが画面上にある間だけ現れるページ固有のバナーを望む。PDFは実際に、この種の挙動のために文書とページの下に3番目の層を提供している——個々のフォームフィールドやリンク自身の/Aエントリに取り付けられたアクションであり、これは対話的フォームアクションとJavaScriptの関連記事の主題である——しかし本稿はその上の2つの層、すなわち文書全体と単一ページに留まる

文書カタログの/AAにはどんなトリガーが存在するのか

カタログ/AA辞書には5つのトリガーが存在し、そのすべてが単一ページではなく文書全体に影響するイベントに対して発火する。ISO 32000-1 §12.6.3(トリガーイベント)は、文書レベルのキーをWCWSDSWPDP——/AA辞書に書き込まれる文字通りの2文字の名前——として、それぞれWillClose、WillSave、DidSave、WillPrint、DidPrintに対応してリストしており、PDFlibPasはその集合をTPDFlibDocumentActionTrigger列挙型で正確に反映している:datWillClosedatWillSavedatDidSavedatWillPrintdatDidPrintである。SetDocumentActionは、この5つのいずれかを取り付ける単一のエントリポイントであり、それが取るActionKindパラメータは、単純なURIからスクリプト、目的地ジャンプまで、ライブラリ内のすべてのアクションビルダー呼び出しで共有される10個のPDF_ACTION_BUILDER_*定数のうちの1つである。GoTo、リモートファイル、埋め込みファイル、Launchアクションがトリガーされたときに実際に何をするかは、それがどこに取り付けられるかとは別の問題であり、それはGoTo・リモート・埋め込み・launchアクションの関連記事の主題である——本稿は、アクションの種類の問題ではなく、コンテナの問題(カタログかページか)に留まる

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.AddStandardFont(4);
    Lib.DrawText(40, 700, 'Quarterly statement');
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save', '', 0, 0);
    Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_SUBMIT,
      'https://example.com/forms/submit', 'CustomerName;OrderTotal', 0, 0);
    Lib.SaveToFile('statement.pdf');
  finally
    Lib.Free;
  end;
end;

ページレベルのトリガーは文書レベルのものとどう違うのか

ページレベルのトリガーは、それが取り付けられている単一のPageオブジェクトに対してだけ発火し、PDFlibPasはそれをカタログのものではなく、そのページ自身の/AA辞書に保存する。ページトリガーはOpenとCloseの2つだけであり、ISO 32000-1がページの追加アクション辞書に対して定義するOキーとCキーに対応する。PDFlibPasはそれらをSetPageActionを通じてpatOpenpatCloseとして公開しており、これはSelectPageを通じて現在選択されているどのページにも取り付けられる——これは、1回の呼び出しがすべての場所に適用されると期待して文書全体をループする最初のときに重要になる詳細である、なぜならそれは決してそうならないからだ。どちらの種類のトリガーを取り付けることも、ファイルの最低PDFバージョンを引き上げる。そして2つのコンテナは異なる下限を求める:PDFlibPasは、最初にカタログ/AAエントリを書き込む際に文書を少なくともPDF 1.4に引き上げ、最初にページ/AAエントリを書き込む際に少なくともPDF 1.5に引き上げる、その内部にどのアクション種別が座っていようともだ。これはコンテナレベルの要件であり、アクション自体が単独で必要とするものの上に重ねられる。そのため、単独で立てば PDF 1.1 しか必要としないはずの裸のURIアクションでも、それがページオープン時トリガーに包まれると、ファイル全体をPDF 1.5に引き上げてしまう

Lib.SelectPage(3);
Lib.SetPageAction(patOpen, PDF_ACTION_BUILDER_JAVASCRIPT,
  'app.alert("Section 3: internal review only");', '', 0, 0);
Lib.SetPageAction(patClose, PDF_ACTION_BUILDER_WEB,
  'https://example.com/analytics/page-3-closed', '', 0, 0);

ライフサイクルアクションを読み取り、削除する

GetDocumentActionInfoGetPageActionInfoはどちらもTPDFlibActionInfoレコードを返し、Kindフィールドは、そのトリガーに何も取り付けられていない場合akNoneとして戻ってくる。そのためレコード上の他のフィールドを信頼する前にKindをチェックすること——URIJavaScriptFileNameなどは、ビルダーが生成できるあらゆるアクションタイプにわたって同じレコード形が再利用されるため、Kindが実際に報告するその一つのアクション種別にとってしか意味をなさない。RemoveDocumentActionRemovePageActionはそれぞれ単一のトリガーをクリアし、削除すべき何かを見つけた場合は1を、そのトリガーがすでに空だった場合は0を報告する;削除されたエントリが/AA辞書に残っていた最後のものだった場合、PDFlibPasは、カタログやページ上にぶら下がった意味のないコンテナを残すのではなく、今や空になった/AA自体を削除する

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetDocumentActionInfo(datWillSave);
  if Info.Kind = akURI then
    WriteLn('WillSave calls out to: ', string(Info.URI));

  if Lib.RemoveDocumentAction(datWillSave) = 1 then
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save-v2', '', 0, 0);
end;

PDF/Aはそもそもライフサイクルアクションを許可するのか

いいえ。PDF/A適合性は、危険そうに聞こえるアクション種別だけでなく、追加アクションのコンテナ全体を拒否する。なぜならISO 19005は、アーカイブファイルが数十年後も同じように描画されなければならないという前提のもとで、その時点では存在しないかもしれないスクリプトエンジンやネットワーク接続に依存することなく、PDFの対話的アクションモデルを制限しているからだ。SetDocumentActionSetPageActionの両方の背後にある共有ビルダーであるSetLifecycleActionは、ActionKindを見る前にPDFAModeをチェックする。そのため会社のウェブページを開くだけのURIアクションや、次のページに行くだけを意味するNamedアクションも、危険なものと同じ網に引っかかる——セキュリティレビュアーが通常フラグを立てないようなものでも、それでもブロックされる、なぜならこの制限は場合ごとではなく構造的なものだからだ。実際上の危険は、その拒否が静かであることだ:SetDocumentActionSetPageActionはどちらも例外を発生させることなく0を返す。そのため戻り値を一度もチェックしない呼び出しサイトは、それが運ぶはずだったトリガーを静かに欠いた文書を出荷してしまう

Lib.SetPDFAMode(2); // PDF/A-1b
if Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_NAMED,
     '', '', 0, 0) = 0 then
  // rejected: PDF/A-1b forbids Catalog /AA, even a plain Named action
  WriteLn('lifecycle action not attached');

覚えておく価値のある一つの非対称性がある。RemoveDocumentActionRemovePageActionは決してPDFAModeをチェックしない。そのため、すでに非準拠のライフサイクルアクションを運んでいるファイルを読み込み、PDF/A準拠の保存に向けてそれらを取り除くことは期待通りに機能する——ゲートされているのは書き込み経路、すなわち新しいトリガーを取り付けることだけである

WillOpenトリガーなしで印刷オンオープンはどこに収まるのか

カタログ/AA辞書には設計上WillOpenエントリが全く存在しない——ISO 32000-1の文書レベル/AAはちょうど5つのキー、WillClose、WillSave、DidSave、WillPrint、DidPrintを定義し、そのリストの何も、単にファイルが開かれたという理由だけでは発火しない。オープン時のフックは別個のカタログエントリ、/OpenActionに存在し、PDFlibPasはこれを、SetOpenActionJavaScriptSetOpenActionDestinationSetOpenActionNamedDestinationを含むその独自の呼び出しファミリーを通じて公開する。そのどれも/AA辞書やTPDFlibDocumentActionTrigger列挙型に一切触れない。しかしこの2つの仕組みは組み合わさる。そしてそれこそが通常、印刷オンオープンのテンプレートが実際に必要とするものだ:テンプレートを、その/OpenActionが印刷ジョブを開始するように構築する(典型的にはビューア自身の印刷コマンドを呼ぶJavaScriptアクション)。そして印刷自体が、WillPrintとDidPrintに対して実行すべきものを与える——ページがスプールされる前に刻印されるタイムスタンプ、それらが終わったら書き込まれる監査エントリである

これらのトリガーはPDFビューアにわたってどれほど信頼できるのか

PDF/Aの外でも、すべてのビューアがそれらを実行するわけではない。そのためライフサイクルアクションは保証ではなく要求として扱ってほしい。Acrobatとほとんどの完全なデスクトップリーダーはその集合全体を忠実に実行するが、実世界のPDF消費の大部分はそもそも追加アクション辞書に一切触れない:ブラウザ組み込みのビューア、ほとんどのモバイルリーダー、そしてほぼすべてのサーバーサイドのレンダリングやテキスト抽出パイプラインは、/AAを完全に無視するか、その狭い一部だけを尊重する。WillPrintとDidPrintは通常最も分が悪い、なぜならヘッドレスの変換にはそれらがフックできる印刷操作がないからだ。WillCloseのフォーム送信アクションがフォームデータを捕捉する唯一の経路であるなら、それは信頼できる経路ではない——明示的な送信ボタンとペアにし、自動トリガーは、たまたまそれをサポートするリーダーにとっての便利さとして扱うこと

文書、ページ、フィールドのトリガーは、同じ基礎となるアクション辞書の仕組みの3つの層であり、コンテナが明確になれば、残りは正しいActionKind定数を選び戻りコードをチェックすることだけである。これらのライフサイクルトリガーは、本稿が触れているより広いアクションビルダーAPIとともに、DelphiおよびC++Builder向け標準PDFlibPas Delphi PDFライブラリの一部として出荷される。トリガーとアクション種別の完全なリファレンスは製品ドキュメントに掲載されている