Technical Article

DelphiでのインタラクティブPDFフォーム:アクションとJavaScript

PDFフォームフィールド自体は、値を保持する単なるボックスにすぎません。フォームを小さなアプリケーションのように動作させるのは、それに添付されたアクションです。セクションを非表示にするクリック、ファイルから保存された値をプルバックする処理、最後のページへのジャンプ、あるいは列の合計を算出するスクリプトの実行などがあります。これらはどれもフィールド内には存在しません。アクション辞書内に存在し、ISO 32000-1の§12.6でそのファミリー全体が整理されています。この記事では、Delphiプログラムで最も頻繁に使用されるアクションについて解説し、PDFlibPasがそれぞれをフィールドまたはリンクに接続する方法を示します。

覚えておくべきメンタルモデルは、フィールドとアクションは参照によって結合された別個のオブジェクトであるということです。ウィジェット注釈またはリンク注釈は、その/Aエントリにアクションを保持します。アクションは操作対象のフィールドをインデックスではなくタイトルで指定するため、フィールドに付与したタイトルは、後続のアクションがそれを見つけるためのハンドルになります。この分離が明確になれば、APIは単なる呼び出しの寄せ集めではなく、4種類の動詞に適用される1つのパターンとして見えてくるようになります。

Namedアクション:ページ番号を指定しないナビゲーション

最も単純なアクションにはパラメータが一切ありません。ISO 32000-1 §12.6.4.11、表194はNamedアクションを定義しています。ビューアは、保存された宛先に従う代わりに、実行時にシンボリック名を解釈します。4つの名前が普遍的にサポートされており、これらはまさに読者がツールバーに期待するものと同じです:NextPage、PrevPage、FirstPage、LastPageです。宛先はビューアが現在表示しているページに対して相対的であるため、この方法で構築された「次へ」ボタンは、開発者がターゲットを計算することなくすべてのページで動作します。

PDFlibPasでは、Namedアクションは現在のページのホットスポット矩形にアタッチされます。4番目と5番目の整数引数で動詞と外観を選択します。

// NamedActionType: 0 = NextPage, 1 = PrevPage, 2 = FirstPage, 3 = LastPage
// Options bit 0 (value 1) draws a border around the hotspot
Pdf.AddLinkToNamedAction(500, 560, 60, 18, 0, 1);   // Next
Pdf.AddLinkToNamedAction(40, 560, 60, 18, 1, 1);    // Previous
Pdf.AddLinkToNamedAction(110, 560, 60, 18, 3, 1);   // jump to last page

同期を保つべき宛先が存在しないことこそが、この手法の核心です。Namedアクションはページを直接指定しないため、ページの挿入や削除を行っても影響を受けません。これとは対照的に、明示的なジャンプリンク(Go-Toリンク)はターゲットのページインデックスを保持するため、ドキュメントが増加した瞬間に番号を振り直す必要があります。

Hideアクションと配列の落とし穴

Hideアクション(ISO 32000-1 §12.6.4.10、表196)は、1つ以上のフィールドの表示・非表示を切り替えます。これはスクリプトを使用せずに表示・非表示の挙動を構築する最もスマートな方法であり、「詳細表示」リンクや、一方を表示すると他方が隠れる2つの排他的なパネルを作成したい場合に適しています。このアクションは/Tエントリにターゲットを、表示・非表示の方向を決定するブール値/H(trueで非表示、falseで表示)を保持します。

注意すべき点は、そのターゲットがどのようにエンコードされるかという点にあります。この詳細は、開発環境では動作するものの顧客の環境で失敗するフォームを生み出す原因になります。アクションが単一のフィールドを指す場合、/Tは単一のテキスト文字列として書き込まれます。複数のフィールドを指す場合、/Tはテキスト文字列の配列として書き込まれます。古いビューアは、要素が1つの配列を単一の文字列と同じように扱わないため、最も幅広いリーダーで解釈されるようにするには、数に応じてエンコードを分岐させる必要があります。つまり、単一の名前は要素数1の配列ではなく文字列として出力されなければなりません。PDFlibPasはこの決定を自動的に行います。カンマ、セミコロン、または改行で区切られたフィールド名を渡すと、ライブラリは名前が1つの場合は単一の文字列を出力し、2つ以上の場合は文字列の配列を出力します。

// HideFlag non-zero hides the listed fields (/H true); zero shows them.
// One name -> /T is a text string. Two or more -> /T is an array of strings.
Pdf.AddLinkToHideField(40, 700, 90, 18, 'ShippingAddress', 1, 1);
Pdf.AddLinkToHideField(140, 700, 90, 18,
  'ShippingName,ShippingAddress,ShippingZip', 1, 1);

このアクションは外部リソースを参照しないため、PDF/Aとの互換性が保たれます。渡す名前は完全修飾されたフィールドタイトルである必要があります。そのため、グループ内の子フィールドは、単なるリーフ名ではなく、ドットで区切られた完全なパスで指定しなければなりません。

ImportData:FDFからの事前入力

Hideアクションがページ上に既に存在するオブジェクト의配置を制御するのに対し、ImportDataアクションは外部から値を持ち込みます。ISO 32000-1 §12.6.4.8、表198は、ディスク上のForms Data Format(FDF)ファイルからAcroFormにデータを投入するアクションとしてこれを定義しています。これは、「サンプルデータの再読み込み」や「デフォルトに戻す」といったコントロールの背後にあるアクションであり、PDFの隣にFDFファイルを配置して標準的なフィールド値を保持します。この呼び出しは他と同様で、ホットスポット矩形、FDFへのパス、および外観のビットマスクを受け取ります:Pdf.AddLinkToImportData(40, 660, 120, 18, 'defaults.fdf', 1)。PDFのビルド時にファイルが存在している必要はありませんが、ユーザーがクリックしたときには存在している必要があり、パス内のバックスラッシュは自動的にPDF標準のスラッシュ形式に変換されます。

頻繁に問題となる制限について説明します。ImportDataアクションは外部ファイルを指すため、PDF/Aでは許可されていません。ドキュメントがPDF/Aモードの場合、この呼び出しは検証に失敗するファイルを生成するのではなく、ゼロを返して何も追加しません。アーカイブ出力をターゲットとするパイプラインの場合、事前入力はクリック時に遅延させるのではなく、生成時にフィールド値を直接書き込むことで実行する必要があります。

JavaScript:グローバルパッケージとアクションごとのスクリプト

表示、非表示、インポートを超えるロジックについては、アクションファミリーはドキュメントレベルのJavaScriptを利用します。スクリプトが配置される場所には2つの異なる領域があり、その違いが重要になります。ドキュメントレベルのJavaScriptパッケージはファイル全体に対して1回保存され、ドキュメントが開かれたときに実行されます。そのため、関数定義や共有状態を保持するのに適しています。アクションごとのスクリプトは特定のリンクやフィールドにアタッチされ、そのオブジェクトがアクティブになったときにのみ実行されます。そのため、パッケージが定義済みの関数を呼び出すための1行を記述するのに適しています。

PDFlibPasは両方を提供します。AddGlobalJavaScriptはドキュメントレベルで名前付きパッケージを保存します。同じ名前を再利用すると、保存されていた内容が置き換わります。AddLinkToJavaScriptはスクリプトをホットスポットにアタッチし、クリック時にそれを実行します。

// Document-level package: define a reusable function once.
Pdf.AddGlobalJavaScript('Totals',
  'function recalcTotal() {' +
  '  var net = this.getField("Net").value;' +
  '  var tax = this.getField("Tax").value;' +
  '  this.getField("Gross").value = Number(net) + Number(tax);' +
  '}');

// Per-action script on a link: just call the shared function.
Pdf.AddLinkToJavaScript(40, 620, 100, 18, 'recalcTotal();', 1);

関数をグローバルパッケージに置き、呼び出しをリンクに置くことは、単なるスタイルの好みではありません。これにより、必要とするすべてのコントロールで同じ本体を重複して定義するのを避けることができ、スクリプトが無効化されているビューアでインラインの破損したブロックによるエラーを引き起こすことなく、クリック時に単に何もしないようにできます。また、後で検査する際にもファイルが読みやすくなります。

フィールド、子フィールド、および結果の確定(フラット化)

アクションには操作対象のフィールドが必要であるため、フィールドがどのように生成されるかを確認することが重要です。NewFormFieldは現在のページにフィールドを作成し、そのインデックスを返します。整数の種類でフィールドの種類を選択します。1はText、2はPushbutton、3はCheckbox、4はRadiobutton、5 is Choice、6はSignature、そして7は子を所有するものの自身は何も描画しないParentです。ピリオドはアクションが子を指定するために使用する完全修飾名でのセパレータであるため、渡すタイトルにピリオドを含めることはできません。

ラジオグループや階層型フォームは、親フィールドに子を付与することで構築されます。NewChildFormFieldは指定した親の下に子を追加し、ラジオボタンや選択肢(Choice)の場合は、AddFormFieldSubが個々のオプションを追加して、それぞれを配置するために使用する一時的なインデックスを返します。インタラクティブなフェーズが終了し、現在の外観を永続的なページコンテンツとして固定したい場合は、FlattenFormFieldがフィールドをページ上に描画し、フォームから削除します。フラット化を実行すると、後続のフィールドのインデックスが1つずつ繰り下がるため、ループ内で複数のフィールドをフラット化する場合はこの点に注意する必要があります。

var
  Pdf: TPDFlib;
  FldShip: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.SetOrigin(1);          // top-left origin
    Pdf.SetPageSize('A4');
    Pdf.NewPage;

    // A text field the Hide action will target by its title.
    FldShip := Pdf.NewFormField('ShippingAddress', 1);
    Pdf.SetFormFieldBounds(FldShip, 40, 120, 240, 20);
    Pdf.SetFormFieldValue(FldShip, '');

    // Wire a Hide link and a navigation link to this page.
    Pdf.DrawText(40, 110, 'Toggle shipping block:');
    Pdf.AddLinkToHideField(220, 100, 70, 16, 'ShippingAddress', 1, 1);
    Pdf.AddLinkToNamedAction(500, 800, 60, 18, 3, 1);  // Last page

    // A document-level script available to every event in the file.
    Pdf.AddGlobalJavaScript('OnOpen',
      'app.alert("Form ready", 3);');

    // Freeze the field if the output should no longer be editable.
    // Pdf.FlattenFormField(FldShip);

    if Pdf.SaveToFile('form_actions.pdf') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

ここではフラット化(Flatten)の呼び出しを意図的にコメントアウトしています。これを除外すると、ドキュメントはリーダーでアクションが実行されるアクティブなフォームとして送信されます。有効にすると、フィールドは静的な描画データに変換されます。これは、フォームが入力完了し、結果を固定されたレコードとして送信したい場合に適しています。同じフィールド、同じコードでありながら、確定処理を行うかどうかでまったく異なるドキュメントになります。

適切なアクションの選択

4つのアクションは、操作対象によって明確に分かれています。Namedアクションはビューポートを移動させ、フィールドを必要としません。Hideアクションは表示状態を変更し、フィールドタイトルを必要としますが、文字列と配列のエンコーディングは自動的に処理されます。ImportDataアクションはディスク上のファイルにアクセスするため、PDF/Aでは禁止されています。JavaScriptアクションは任意のロジックを実行し、関数のグローバルパッケージと小さなアクションごとの呼び出しに分割するのが最適です。目的を達成できる最も単純なアクションを使用してください。Hideアクションは非表示フラグを設定するスクリプトよりもポータブルであり、Namedアクションは数値の維持が不要なため、保存されたページ宛先よりも耐久性があります。

ここから、関連する2つのトピックが全体像を完成させます。フォームがアクセシブルなドキュメントの一部である場合、スクリーンリーダーが巡回する構造ツリーについては、タグ付きPDFとアクセシビリティ構造に関する記事でカバーしています。入力完了したフォームをロックして署名する必要がある場合のワークフローは、コンプライアンスおよび署名ワークベンチのチュートリアルで説明しています。これら3つすべてが同じエンジン上に構築されており、このブログの他の場所で扱っている作成、フォーム、および署名APIと並んで、Delphi向けPDFライブラリとして提供されています。