技術記事

Delphiの動的XFAフォームランタイム:HotPDFのトランザクション

HotPDFはTXFAWidgetRuntimeというホスト非依存のウィジェット層を通じて、Delphiで動的XFAフォームを入力します。すべてのフィールド編集を一つのトランザクションとして扱い、スナップショット、検証、計算、再レイアウトの順に進めてから、全体を公開するか全体をロールバックします。あなた自身のVCLまたはFMXホストの中でシングルスレッドで動作し、Acrobatのインストールは不要で、何かを割り当てる前にすべての上限値を検査します

政府系や保険系の仕事にドキュメントソフトを出荷したことがある人なら、見覚えのあるシナリオです。請求フォームや税務申告書が、ページコンテンツが「しばらくお待ちください…」という通知だけのPDFとして届き、本物のフィールドはAdobe Acrobatしか描画できないXFAパケットの中に眠っています。ユーザーは自社アプリの中でそれを入力したいはずです。ラスター化で逃げることもできません。データの入力に合わせてフォームは行を増やしていき、3行目以降のレイアウトはファイルに最初から入っていたものとは別物になるからです

動的XFAが今も解く価値のある問題である理由

動的XFAが生き残っているのは、現場に展開済みのフォームが形式そのものより長寿だからです。ISO 32000-1 §12.7.8はXFAをAcroForm辞書上の/XFAエントリとして記述し、XDPパケットストリームを保持します。ISO 32000-2はこの仕組み全体を非推奨にしましたが、ロードマップから外れただけで現場から消えたわけではなく、XFA 3.3仕様に基づいて作られたフォームは今も発行され、今も法的拘束力を持っています。静的XFAなら通常のウィジェット注釈に還元でき、ApplyXFAAsAcroFormを呼び出せばHotPDFがそれを行います。トレードオフはXFAフォームのAcroFormフィールドへのフラット化の記事で扱っています。動的XFAは別物です。occurレンジ、伸長可能なテキスト、calculateスクリプトの存在によりフィールド集合がデータの関数になるため、ユーザーが入力を終えるまでフラット化先の固定注釈リストが存在しません。TXFAWidgetRuntimeが埋めるのはこのギャップです。XFA DOMを生きたまま保持し、受理された編集のたびにレイアウトを再計算し、描画とヒットテストのための位置付きウィジェットのフラットな配列をホストに渡します

ランタイムはホストアプリケーションに何を渡すのか

渡されるのはジオメトリと状態だけで、UIツールキットを前提とするものは一切ありません。TXFAWidgetRuntimeWidgetCountWidgets[I]TXFAWidgetStateレコードとして公開し、IDNameKindPageIndex、PDFポイント単位のBoundsValueEditValue、そしてFocusedEditingReadOnlyValidの各フラグを運びます。描画、キャレット描画、キーボードルーティングはあなたのコード側に留まります。ウィジェットの識別は安定した序数ベースです。各ウィジェットにはname[n]形式のIDが付与され、nはレイアウト順でそれより前に出現した同名フィールドの個数を数えるため、繰り返しサブフォームの2行目はamount[1]になります。この識別子こそがリビルドをまたいで生き残るものであり、FocusWidgetBeginEditDispatchEventHitTestが共通で話す言葉でもあります。THotPDFインスタンスで既に開かれている文書に対しては、CreateLoadedXFAWidgetRuntimeがXDPパケットを抽出し、最初のページボックスをレイアウトページサイズとして採用し、XFAをまったく含まないファイルにはnilを返します

var
  Pdf: THotPDF;
  Runtime: TXFAWidgetRuntime;
  WidgetID: AnsiString;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('claim-dynamic.pdf');
    Runtime := Pdf.CreateLoadedXFAWidgetRuntime;   // /XFAが無い場合はnil
    if Runtime = nil then
      Exit;
    try
      for I := 0 to Runtime.WidgetCount - 1 do
        Memo1.Lines.Add(Format('%s p%d [%.1f %.1f %.1f %.1f] = %s',
          [string(Runtime.Widgets[I].ID), Runtime.Widgets[I].PageIndex,
           Runtime.Widgets[I].Bounds.Left, Runtime.Widgets[I].Bounds.Top,
           Runtime.Widgets[I].Bounds.Right, Runtime.Widgets[I].Bounds.Bottom,
           string(Runtime.Widgets[I].Value)]));
      // ページ空間のヒットテスト、最前面のウィジェットが優先される
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

フィールド確定時に何をアトミックにすべきか

編集が触り得るすべてです。フィールド値よりはるかに広い範囲に及びます。CommitEditは何かを書き込む前にCaptureSnapshotを呼び出し、そのスナップショットは四つの対象をカバーします。TXFADocument.SaveToBytesによる直列化済みXFA DOM、TXFAWidgetStateインタラクションレコードの全配列、LastCalculationPassesLastReflowPassesの各カウンター、そして現在のWarnings.Countです。ノード値だけを保存するのは誘惑的な近道ですが、間違いです。calculateスクリプトや未解決のバインディングがEnsureValueNodeを呼び出し、編集開始時点には存在しなかったデータノードを実体化させ得るからです。値だけの復元にはそれらを除去する手段がなく、拒否された編集がdatasetsパケットに恒久的な構造的残留物を残すことになります。確定シーケンス自体は厳格です。候補値を書き込み、編集対象フィールドにvalidateを実行し、calculateを不動点まで回し、レイアウトが安定するまで再レイアウトします。どの段階で失敗してもFailAndRestoreへ流れます。スナップショットバイト列を新しいTXFADocumentへ読み込み直し、ウィジェットリストを再構築し、記録済みインタラクション状態を再適用し、カウンターをリセットし、Warningsをスナップショット時点の長さへ切り詰めます。失敗時の理由はLastDiagnosticに入り、復元自体が例外を投げるという病的なケースではXFA transaction rollback failedという文字が入ります

HotPDFはXFAフィールドの確定を一つのトランザクションとして扱い、検証・計算・再レイアウトの前に直列化済みDOM、全ウィジェット状態、パスカウンター、警告数をスナップショットし、まとめて公開または復元する
CommitEditは何かを書き込む前に四種類の状態をスナップショットするため、validate、calculate、再レイアウトのいずれが失敗しても構造的な残留物を残さない
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // 読み取り専用、または該当ウィジェット無し
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // 範囲不正、またはサロゲートの分断
    Exit;
  end;
  Result := Runtime.CommitEdit;             // 全か無か
  if not Result then
    // 文書、ウィジェット、カウンター、警告はすでに編集前の
    // 状態へ戻っている。フォーカス中のウィジェットに無効印が付くだけ
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelectionは単独で言及する価値があります。不正な入力を最も安価に拒否できる場所だからです。UTF-16サロゲートペアを分断する選択範囲、対にならない上位または下位サロゲートを含む置換テキスト、MaxValueCharsを超える長さの結果をいずれも拒否します。キーストローク層でそれを捉えれば、トランザクション機構が書きかけの面外文字を巻き戻す必要は一切生じません

プライベートリストで再構築し、一つのスワップで公開する

ウィジェットの再構築は途中経過が見えてはいけません。そこでRebuildWidgetsは完全に別個の所有TObjectListを構築し、最後に単一の代入で差し替えます。理由は美学ではありません。TXFALayoutEngine.ComputeLayoutは再構築の進行中に走り、あなたが供給したMeasureText関数を通じてホストコードへコールバックし、ウィジェット上限に達するとEXFAWidgetRuntimeErrorを送出し得ます。ランタイムが生きているリストをその場で書き換えていたら、どちらの経路でもホストの手元には、旧レイアウトと新レイアウトが混在し、ロールバックされようとしている文書を指すDataNodeポインタを持つリストが残ります。再レイアウトの収束判定はLayoutSignatureで行います。ウィジェット数に全ID、ページインデックス、小数4桁に丸めたバウンディングボックスを連ねた文字列で、CommitEditは再構築とシグネチャ比較を繰り返し、連続する2つのシグネチャが一致するかパス予算を使い切るまで続けます。シグネチャが一度も変わらなければLastReflowPassesは0のまま残り、これが値のみの編集と実際にフォームを成長させた編集を見分ける方法になります。インタラクション状態は各再構築でウィジェットID単位で引き継がれるため、フォーカスと編集中の内容は行挿入をまたいで生き残ります

HotPDFのXFAランタイムはレイアウト実行中にホストの測定コードへコールバックするため、ウィジェットリストを別個の所有リストへ再構築し、完成したリストを単一の代入で公開する。ホストに半完成状態は見えない
再構築がプライベートリストで行われるのはComputeLayoutが途中で例外を送出し得るためで、LayoutSignatureが連続する2回の再レイアウトの収束を判定する

バインド済みフィールドが誤ったレコードを読む理由

スクリプトがデータコンテキストなしで走ったからです。明示的な<bind match="dataRef" ref="$record.actual"/>を持つフィールドと、同じデータノードにちなんだ名前を持つフィールドは、一つの値を指す別々のウィジェットです。さらに<occur max="2"/>を持つ繰り返しサブフォームは名前を共有し、属するデータ行だけが異なる複数のウィジェットを生み出します。文書ルートに対して検証と計算を評価すると、そのすべてがthisをdatasetsパケット全体で最初に一致したノードへ解決するため、2行目が黙って1行目を検証することになります。HotPDFはこれを避けるため、レイアウトがウィジェットを生成する時点で解決済みのDataNodeを各ウィジェットエントリに格納し、そのノードをHPDFXFAEvaluateFieldScriptの両呼び出し、xfskValidatexfskCalculateも同じように通します。同じコンテキストが、まだ存在しないバインディングを計算が対象にするときEnsureValueNodeがどのノードに対して作成するかも決めます。バインディングを解決できない場合は誤った行へ書き込む代わりに、XFA calculation target is not boundできれいに確定が失敗します。これらのスクリプトの背後にあるFormCalcセマンティクスは、AcroFormの書式とcalculateスクリプトの記事で扱ったアクションがAcroForm文書に与えるものと響き合いますが、ここでの解決規則はフィールド名スコープではなくXFAスコープです

上限値は副作用の後にではなく、前に検査される

ランタイムのあらゆる上限値は前提条件です。割り当てが済んだ後に強制される上限は、上限とは呼べないからです。TXFAWidgetRuntimeOptions.DefaultMaxWidgetsを10000、MaxValueCharsを1048576、MaxCalculationPassesを16、MaxReflowPassesを4として出荷され、既定のTXFAFormScriptOptionsMaxOperationsを100000、MaxElapsedMillisecondsを500として運びます。その下ではXFA DOMが独自のTXFADOMLimitsを適用します。非圧縮入出力に128 MBの上限、接合可能なパケットは最大1024、ノードは1000000、ネスト深さは256です。数字そのものより重要な細部が二つあります。第一に、スクリプト予算はスクリプト単位ではなくトランザクション全体で共有されます。CommitEditは残り操作カウンターと単調なデッドラインを一つずつ用意し、すべてのvalidateとcalculateの呼び出しが同じカウンターを減らし、残りミリ秒だけを受け取ります。計算フィールドを200個持つフォームが500 msを200回ぶん消費することはありません。第二に、デッドラインは注入可能なMonotonicMilliseconds関数から得られます。これにより経過時間の挙動が、忙しいビルドエージェント上のコイン投げではなく、テストスイートの中で再現できるものになります

HotPDF XFAランタイムの予算レイヤー。ウィジェットと値の上限からスクリプトの操作数と時間制限、さらに下層のXFA DOM上限まで。一つの操作カウンターと一つのデッドラインがトランザクション内の全呼び出しで共有される
スクリプト予算はスクリプト単位ではなくトランザクション単位のため、200個の計算フィールドがそれぞれ新たな500 msを主張することはない
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // デフォルトは10000
  Options.MaxCalculationPasses := 8;                       // デフォルトは16
  Options.MaxReflowPasses := 2;                            // デフォルトは4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // トランザクション全体
  Options.ScriptOptions.Limits.MaxElapsedMilliseconds := 200;
  Options.MeasureText :=
    function(const AText: UnicodeString; const AFont: TXFAFontSpec;
      AMaxWidth: Double): TXFATextExtent
    begin
      Result := MeasureWithHostCanvas(AText, AFont, AMaxWidth);
    end;
  Runtime := TXFAWidgetRuntime.Create(XDPBytes, 612, 792, Options);
  try
    Runtime.OnLayoutChanged :=
      procedure
      begin
        RepaintAllPages;   // 再レイアウトが実際にウィジェットを動かしたときのみ発火
      end;
    // ... フォームを操作する ...
  finally
    Runtime.Free;
  end;
end;

ランタイムが手を引く場所、そしてそれを明言する理由

このランタイムは意図的に汎用XFAスクリプティングエンジンではありません。DispatchEvententerexitのアクティビティをフォーカス移動としてネイティブに処理し、スクリプトを伴うその他のアクティビティには見て見ぬふりをせず、具体的で安定した診断メッセージ付きで拒否します。addInstanceremoveInstanceinstanceManagerに言及するスクリプトはXFA runtime does not support event-driven instance mutationを返し、.presenceに触れるスクリプトは対応するpresenceメッセージを返し、それ以外はXFA runtime does not support this event scriptを返します。サンプルファイルでは動くのに顧客環境では挙動が逸れる部分エミュレーションより、分岐できる予測可能な拒否の方が有用です

スレッドモデルも同じく率直です。一つのランタイムインスタンスは一つのスレッドに属し、内部ロックを持ちません。レイアウトエンジンはホストの測定コールバックへ逆に届くため、そこをロックすると再描画待ちのデッドロックになるからです。フィールド内のリッチコンテンツはライブラリの他の場所と同じ保守路線に従い、exDataペイロードはXFA exDataリッチテキストとハイパーリンクの記事で説明された形で処理されます。署名とボタンのウィジェットはReadOnlyとして返され、未対応のUI種別は黙ってデータを失う編集可能テキストボックスではなく、xwkUnsupportedとして表面化します

まとめれば、これはDelphiにおける動的XFAへの実用的な答えです。DOMを生きたまま保ち、各編集を完全に着地するか何も残さないかのトランザクションにし、すべてのパスに上限を課し、スコープ外のものを明示する。請求、税務、給付のワークフロー向けに評価しているなら、XFAランタイムはHotPDF Delphi PDF componentの一部として出荷されており、これらの案件が最終的に揃って必要とするAcroForm、フラット化、描画の各経路と並んでいます