技術記事

DelphiとPDFiumでXFAフォームを検出し抽出する

はい、PDFiumはXFAフォームに対応しています。ただし重要な二分があります。PDFium Componentは標準のDLLだけでXFAフォームを検出し、そのtemplate、datasets、configの各パケットを生のXMLとして抽出でき、ランタイム依存は一切ありません。動的なXFAフォームを実行すること、つまりそのXML記述を生きた対話的ページとしてレイアウトすることは別問題で、V8を有効にしたビルドが追加で必要になります。コミュニティでは今も「PDFiumはXFAをやるのか」が単一のイエスかノーの機能であるかのように問われ続けていますが、正直に答えるなら、XFAデータを読むこととXFAフォームを描画することは、依存関係の重さがまるで違う2つの別物です

本稿では、Delphi / C++Builder向けにPDFiumを包むネイティブVCLラッパーであるPDFium Componentで、その両面をたどります。まずは検出と静的抽出の道筋、すなわちFormType、XFAプロパティ、そしてGetXfaPacket*系の読み取りメソッド、次にV8の制約を伴う動的ランタイムの道筋、そして最後に、配布したDLLがエンジンを動かせないときに行儀よく失敗するための機能プローブです

XFAパケットを抱えた1つのPDFが、DelphiにおけるPDFiumの2つの機能、すなわち最近のDLLならどれでも動く静的なパケット抽出と、V8を有効にしたビルドを追加で必要とする動的な描画へ供給される図
静的抽出はエンジンなしでtemplate、datasets、configの各パケットをそのままのXMLとして読み取り、動的なXFA描画にはV8を有効にしたPDFiumビルドが追加で必要です

PDFiumはXFAフォームに対応していますか?

1つではなく2つの機能として読んでください。静的なXFA抽出は常に利用できます。GetXfaPacketCount、GetXfaPacketName、GetXfaPacketContentを裏で支える低水準のエクスポートFPDF_GetXFAPacket*は、XFAを無効にしてビルドしたものであっても、そこそこ新しいpdfium.dllならすべてに組み込まれています。だからこそ、フォームから生のtemplate、datasets、configのXMLを取り出す作業にはランタイム依存がまったくありません。純粋に構造を読むだけで、エンジンは介在しないのです。動的なXFAはもう一方の機能で、フォームを実際に走らせ、その計算を評価し、XFAレイアウトエンジンにページ分割させることを指します。この道筋は、V8 JavaScriptエンジンの上にXFAサポートを載せてコンパイルしたPDFiumビルドにしか存在せず、「PDFiumはXFAに対応しているのか」という議論の大半が本当に争っているのはこの部分です

規格はこの二分をきれいに枠づけています。XFAはPDFの中核文法の外側、Adobe XFA 3.3(2012年)で定義されており、PDF 1.7 §8.6.7がXFAフォームをPDFの内部にどう載せるかを述べています。フォームのパケットはAcroForm辞書の/XFAエントリの下に置かれ、意味を持つ前にXFAプロセッサでレイアウトされなければならない文書は、カタログに/NeedsRendering trueを設定します。この2つの目印こそPDFium Componentが探しているものであり、見たこともないファイルについて推論するときに頼れるものです

XFAはAcroFormと何が違うのですか?

AcroFormは古典的なPDFフォームです。テキストフィールド、チェックボックス、ラジオグループといったウィジェット注釈が、通常のPDFページ上の固定座標に固定されています。目に見えるページはファイルの中のページそのもので、フィールドはその上に乗っています。XFAは正反対の道を採ります。対話的なフォームはすべてXMLで記述され、完全な(動的な)XFA文書ではPDFページは実質的な置き場所にすぎません。本当の内容は、XMLテンプレートを読んでレイアウトするXFAエンジンが開いた時点で生成し、データに合わせて伸び縮みします。/NeedsRendering trueが告げているのはまさにこれです。XFAプロセッサがなければ、静的なページは空白か、「対応ビューアーで開いてください」という但し書きしか載せていないかもしれません

XFAのペイロードそのものは名前付きパケットの集まりで、そのうち3つがたいていの統合に必要なものをすべて抱えています。templateパケットはフォームの定義、つまりフィールド、レイアウト、計算、スクリプトです。datasetsパケットは実際のインスタンスデータ、すなわち入力済みのフィールド値をXMLツリーとして保持します。configパケットはXFAエンジン向けの処理指示を運びます。ほかにもlocaleSet、connectionSet、xdpラッパーなどがありますが、フォームを監査したりそこからデータを移行したりするなら、templateとdatasetsで仕事はほぼ片付きます。そのデータはストリームの中に収まっているただのXMLなので、読むのに何かを実行する必要は決してなく、それが静的抽出に依存関係がない根本の理由です

ページの固定座標に固定されたAcroFormウィジェットと、template、datasets、configの各パケットを/NeedsRendering trueのもとでXFAエンジンがレイアウトするPDFium上のXFAフォームの対比
AcroFormのフィールドは完成済みのページに載りますが、XFAフォームは定義をAcroFormの/XFAエントリ配下の名前付きXMLパケットとして運び、/NeedsRendering trueを設定することがあります

DelphiでXFAフォームを検出する

検出は、PDFium Componentのどの作業とも同じ始まり方をします。FileNameを設定し、Active := Trueに切り替え、本当に開いたかを確かめます。Active := Trueは例外を投げません。ファイルがない、パスワードが違う、DLLが見つからないといった場合もActiveがFalseのままになるだけなので、この番人は必須です。文書が開いたら、FormTypeがどのフォームモデルを使っているかを教えてくれます。TPdfFormTypeの値のうち2つがXFAです。ftXfaFullは完全な動的XFAフォーム、つまり描画を必要とする種類で、ftXfaForegroundはXFAF、すなわちXFAの内容が通常どおりのAcroFormページの上に描かれ、静的なページも意味を保つ前景サブセットです。XFAブール値は「これはどちらかの流儀のXFA文書だ」という近道になります。XFAFやAcroForm文書でウィジェット水準のフィールドも扱うなら、その仕組みはPDFiumのフォームフィールド操作の手引きで扱っています

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'application-form.pdf';
    Pdf.Active := True;
    if not Pdf.Active then
      Exit;                        // 破損、暗号化、あるいはDLLの欠落
    case Pdf.FormType of
      ftXfaFull:
        Log('Full (dynamic) XFA form');
      ftXfaForeground:
        Log('XFAF: XFA layered over static AcroForm pages');
      ftAcroForm:
        Log('Classic AcroForm');
    else
      Log('No interactive form');
    end;
    if Pdf.XFA then
      Log('Document carries an XFA packet payload');
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

XFAパケットを生のXMLとして抽出する

抽出は、価値が高く依存関係のない側の半分です。XfaStaticReadableで番をして、読み込まれたDLLの中でパケット読み取りのエクスポートが解決したことを確かめてから、添字でパケットをたどります。GetXfaPacketCountはフォームが運ぶパケットの数を返し、GetXfaPacketNameはそれぞれの名前を、GetXfaPacketContentは生のバイト列を返します。よく知られたパケットでは、その中身はUTF-8のXMLです。どのパケットが欲しいかが既に分かっているなら、名前付きのヘルパーGetXfaTemplate、GetXfaDatasets、GetXfaConfigが名前を解決し、TBytesを直接返してくれます。データ移行の仕事で効いてくるのはGetXfaDatasetsで、送信されたフィールド値を、解析して自前のスキーマへ対応づけられるXML文書として渡してくれます。これはPDFからのテキスト抽出のあとに続く後段の作業と同種のものです

procedure ExtractXfaData(Pdf: TPdf; const Dir: string);
var
  I, Count: Integer;
  PacketName: string;
  Content, Datasets: TBytes;
begin
  if not Pdf.XfaStaticReadable then
    Exit;                          // パケットリーダーのエクスポートが存在しない
  Count := Pdf.GetXfaPacketCount;
  for I := 0 to Count - 1 do
  begin
    PacketName := Pdf.GetXfaPacketName(I);
    Content := Pdf.GetXfaPacketContent(I);   // 生のUTF-8 XML
    TFile.WriteAllBytes(
      TPath.Combine(Dir, PacketName + '.xml'), Content);
  end;

  // あるいは名前を指定して入力済みのフィールド値へ直接向かう:
  Datasets := Pdf.GetXfaDatasets;
  if Length(Datasets) > 0 then
    ParseAndMap(Datasets);
end;

動的なXFAの描画にV8エンジンが要るのはなぜですか?

結論から言えば、動的なXFAフォームは静的な図ではなく小さなプログラムだからです。templateパケットはFormCalcとJavaScriptの計算、検証、イベントスクリプトを抱えており、XFAレイアウトエンジンはそれらを実行しなければページがどう見えるかすら決められません。PDFiumがそのエンジンを実装しているのは、GoogleのJavaScriptエンジンであるV8の上にXFAサポートを載せてコンパイルしたビルドだけです。V8を有効にしたDLL(pdfium32v8.dllまたはpdfium64v8.dll)が標準のビルドより目に見えて大きいのも同じ理由です。V8がなければスクリプトを走らせる者がいないので、描画されたページも存在せず、静的に読めばよい生のXMLだけが残ります

そのビルドの選択には、設計で回避できない厳しい制約があります。プロセスごとに一度きりの決定だという点です。1つのプロセスが標準のpdfium.dllとV8を有効にしたビルドの両方を読み込むことはできません。同じシンボルをエクスポートし、V8はグローバルなisolateの状態を保つからです。したがってPDFium Componentは、ライブラリが初めて読み込まれる前にV8ビルドを選んでおかなければなりません。それを自動化するために、LoadDocumentはファイルを事前走査して/XFAと/NeedsRendering trueの目印を探し(同じ検査は単体のPdfFileLooksXfa関数としても公開されています)、見つかれば読み込みの前にEnableV8Engine := Trueを設定します。素のpdfium.dllが既に常駐してしまうと、この切り替えはもう効かず、その場合がランタイム欠落の経路として報告されます

begin
  // PDFiumが初めて読み込まれる前にXFAをのぞき見しておけば、V8を
  // 有効にしたビルドをまだ選べる。PdfFileLooksXfaはファイル全体を
  // 解析することなく/XFAと/NeedsRendering trueを走査する
  if PdfFileLooksXfa('dynamic-form.pdf') then
    EnableV8Engine := True;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'dynamic-form.pdf';
    Pdf.Active := True;
    if Pdf.Active and Pdf.XFA then
    begin
      if Pdf.XfaRuntimeAvailable then
        RenderXfaPages(Pdf)          // エンジンが生きている: 動的レイアウト
      else
        ExtractXfaData(Pdf, 'C:\out'); // 静的なパケットへ退避する
    end;
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

XFAランタイムが無いときに行儀よく振る舞う

3つのプローブが、XFAの機能をどこまで持っているかを正確に教えてくれます。正直なコードは、思い込まずにそれらを確かめます。XfaFeaturesAvailableは、読み込まれたDLLにXFA / V8のヘルパーエクスポートが存在するかを見る全体的な検査です。XfaStaticReadableは文書ごとに、パケットリーダーが解決したことを確かめます。抽出の番人です。XfaRuntimeAvailableは強いほうで、この特定の文書が実際にXFAエンジンを起動できたとき、すなわちXFAであり、V8ビルドが読み込まれ、FPDF_LoadXFAが成功したときにだけ真になります。配布したDLLにXFAが無い場合や、標準ビルドが先に読み込まれていた場合には、文書がXFA = Trueを報告しながらXfaRuntimeAvailable = Falseということが起こり得ます

XFA文書が開いてもエンジンを動かせないとき、PDFium Componentは賭けに出ません。果たせない動的ランタイムを要求する代わりにフォームを安全な静的モードへ固定し、OnXfaRuntimeMissingを一度だけ発火させてホスト側が反応できるようにします。反応とはたいてい、V8を有効にしたビルドで起動し直すようユーザーに伝えつつ、その間も静的に読めるデータは提供する、というものです。このハンドラを配線しておくかどうかが、きれいな退避と、黙って何も表示しないフォームとの分かれ目になります

PDFiumで動的なXFA文書を扱うDelphiの判断フロー。XfaRuntimeAvailableを調べてV8で描画するか静的モードへ固定し、OnXfaRuntimeMissingを一度発火させたうえでパケットは抽出できる
XfaRuntimeAvailableが真ならV8エンジンが生きたページを描画し、そうでなければフォームは静的モードへ固定され、OnXfaRuntimeMissingが一度発火し、パケットは読める状態のまま残ります
procedure TForm1.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // XFA文書が開いてもエンジンを動かせないときに一度だけ発火する:
  // 素のpdfium.dllが既に読み込まれたか、ビルドにXFA/V8が無い
  ShowMessage('This is a dynamic XFA form. Restart with the ' +
    'V8-enabled build to render it; its packet data is still readable.');
end;

自社の製品でも、この境界については正直でいてください。V8を有効にしたDLLを決して同梱しないなら、動的なXFAフォームがユーザーの手元で描画されることはありません。それでも検出はでき、あらゆるパケットを抽出でき、送信されたデータを取り出せます。旧来の行政や銀行のフォームを対話的に見せることではなく、そこからデータを取り出すことが本題である多くの仕事には、それで十分です。生きた入力可能なフォームが本当に必要な場面のためにこそ重いV8ビルドを取っておき、機能プローブに文書ごとの通れる道を振り分けさせましょう。抽出の処理系が埋め込みファイルも扱うなら、同じ読み取り専用の思想がDelphiでのPDF添付ファイルにも当てはまります

ここで示したFormType、XFA、GetXfaPacketCount、GetXfaPacketName、GetXfaPacketContent、GetXfaTemplate、GetXfaDatasets、GetXfaConfigおよび機能プローブの各APIは、Delphi / C++Builder向けのPDFium Componentの一部です