技術記事

PDFium ComponentのDynamic XFA:ページ数はデルタ値

Delphiビューアー内の動的XFAフォームがページを足したり減らしたりするとき、PDFium Componentはv3.126.1から、新しい合計をTPdf.PageCountとTPdf.OnXfaPageCountChangedで報告します。ネイティブのページイベントが、合計ではなく追加/削除のデルタを運ぶからです。v3.126.1のWindows V8ライブラリは移動したフィールドに合わせて入力ヒット領域も動かし、v3.126.2はレイアウトコールバックが戻った後に古いページハンドルを再ロードします。発端のバグ報告は経費申請フォームでした。Add Rowを2回クリックするとフォームが2ページに伸び、ページインジケーターは誇らしげに1 of 1を表示する。2ページへ移ったフィールドに入力すると、キーストロークは見えないどこかへ着地する。どれも、誰もがまずテストする固定長のサンプルフォームでは出てきません。フォームビューアーを組み込むなら、その理由を知る価値があります

動的XFAフォームが再ページングすると何が起きるのか

動的XFAフォームには固定のページリストがありません。だからページ数はレイアウトの出力であり、ユーザーがデータを編集するたびに変われます。XFA 3.3はフォームをサブフォームの木として記述します。繰り返すサブフォームはinstanceManagerが制御し、_Row.addInstance()のようなスクリプトが行をもう1つクローンします。レイアウトプロセッサーはその後、コンテンツをページ領域へ再び流し込みます。ページが増えることも、減ることも、既存のフィールドが別のページへ押し出されることもあります。ISO 32000-1 §12.7.8が定義するのは、XFAパケットがPDFの中でどう乗るかだけです。その後のすべてはXFAエンジンの管轄で、PDFium Componentではホストプロセス内で走るPDFium独自のXFAレイアウトがそれです。だからDelphiビューアーが扱うのは、ページ数、ページサイズ、ウィジェット位置がすべて生きた状態であるドキュメントです。ホストが違う想定をすると、3つのことが壊れます:

  • ナビゲーション、スクロール範囲、ページスピナーのためにホストがキャッシュするページ数が古びるか、さらに悪いことに間違った数で更新されます
  • 移動したフィールドは新しい位置に枠を表示するのに、エディターとマウスのヒット領域は古い座標に留まります
  • ビューアーがレイアウトに置き換えられたページハンドルを保持し続けるので、クリックも描画も、そのフォームにはもう存在しないページへ行きます

行の編集を保存と再オープンをまたいで永続化するのは、独自のルールを持つ別の問題です。この記事は、ランタイムでビューアー内に起きることに留まります

動的XFAはどのPDFiumランタイムを必要とするのか

PDFium Componentの動的XFAは、ネイティブライブラリのV8/XFAビルドを必要とします。最初のドキュメントがロードされるより前に、PDFiumユニットのグローバル変数EnableV8Engineで選択します。プロセスは、どのTPdfかが最初にライブラリをロードした時点で1つのDLLへ確定し、素のPDFiumビルドはXFAエンジンをまったく走らせられません。ドキュメントを開くとき、TPdfはファイルを覗いてXFAマーカーを確認し、V8ビルドへ自動的に切り替えます。ただしそのプロセスでまだ素のライブラリがロードされていない場合だけです。確定がすでに間違った方向へ行ってしまったときは、TPdf.OnXfaRuntimeMissingが1度発火し、ホストはユーザーに再起動を促せます。起動時に明示的にフラグを設定すれば、推測は消えます。XFAイベントを運ぶFPDF_FORMFILLINFOコールバック構造体もDLLと一致せねばなりません。背景はFPDF_FORMFILLINFOバージョン2とXFAコールバックABIにあり、フォームタイプの見分けはXFAフォームの検出とパケットの読み取りがビューアーを開く前に扱います

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // 最初のTPdfがネイティブライブラリをロードする前に決める:
  // プロセスは後からpdfium.dllからpdfium.v8.dllへ切り替えられない
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

2ページのフォームなのに、PageCountはなぜ1を報告したのか

v3.126.1より前、PDFium Componentはネイティブページイベントのpage_count引数をドキュメントの合計として保存していました。ところがこの引数は、実際には新旧ページ数の絶対差です。PDFiumはFFI_PageEventを、レイアウトパスの完了後にページ追加かページ削除のイベントタイプで上げます。内部ではまず保存済みページ数を更新し、それからabs(new - old)を渡します。初期レイアウトでは旧数がゼロなので、デルタは合計に等しく、3ページの静的サンプルは期待どおり3ページを報告します。まさにこのため、固定長のテストフォームはバグを一度も晒しませんでした。動的フォームが初めて1ページから2ページへ伸びるとき、デルタは1で、ラッパーはTPdf.PageCountとOnXfaPageCountChangedのNewCountパラメータの両方を1にしたのです。3ページのフォームから行を削除すると、逆方向に同じ種類のナンセンスが起きました

デルタを前の値に積み上げるのも安全な修理ではありません。初期化とレイアウトコールバックの順序のせいで、ラッパーは自分の以前の数をベースラインとして常に信頼できるわけではなく、累積和はずれ得ます。v3.126.1から、コールバックは引数をカウントとして無視し、ドキュメントにFPDF_GetPageCountを呼びます。これは今ちょうど完了したレイアウトから合計を読みます。それからキャッシュ済みのページシーンをクリアし、その合計をTPdf.PageCountの裏にあるXFAページ数オーバーライドとして保存し、その後に初めてOnXfaPageCountChangedを上げます。ハンドラーが走る頃には、NewCountとFPdf.PageCountは一致しています

PDFium Componentの動的XFAの図。行を追加すると1ページのフォームが2ページへ再ページングされ、FFI_PageEventはabs(new - old)をデルタとして渡します。旧ラッパーはTPdf.PageCountを1と報告しましたが、v3.126.1はFPDF_GetPageCountを読んで正しい合計を報告します
ネイティブのページイベントが報告するのは追加/削除のデルタであって合計ではありません。だからv3.126.1は引数を無視し、OnXfaPageCountChangedを上げる前に完成済みレイアウトを読みます
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1以降:NewCountは完成済みレイアウトの合計で、決してdeltaではない。
  // PDFiumのレイアウトコールバック内で走る:ホストUIの状態だけを更新し、
  // ここからドキュメントを閉じたりページを再ロードしたりしない
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // 遅延XFAリフレッシュを含むすべてのページ再ロードの後に発火する
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

このイベントが発火するのは、ランタイムでレイアウトが変わるFull XFAフォームだけです。Static XFAとAcroFormのドキュメントは決して上げないので、両方を扱うビューアーは同じハンドラーを割り当てたままにできます。割り当てないままでも安全です。TPdf.PageCountの裏のオーバーライドはどのみち適用され、イベントがあるのは、ホストがキャッシュしたものを更新できるようにするためです

フィールドが移動したとき、入力ボックスが古いページに留まるのはなぜか

枠は動いたのにエディターが動かなかったのは、ネイティブのXFA通知器が矩形を自分自身と比較していたからです。レイアウトがすでにロード済みのウィジェットのジオメトリを変えるとき、PDFiumは新しい矩形に気づき、ウィジェットにPerformLayoutを呼ぶはずでした。これがテキストエディターとそのヒット領域を再配置します。チェックはGetWidgetRect()をRecacheWidgetRect()と比較していました。両関数は同じメンバーへのconst参照を返し、recacheはそのメンバーをその場で上書きします。だから比較は常に2つの同一の値を見て、ロード済みウィジェットは再レイアウトをスキップしていたのです

症状が表面化したのは、テストがサブフォームの高さを変えて、既存のフィールドが次のページへ跨るようにしたときです。両V8アーキテクチャーで、フィールドの枠は新しい位置に描かれるのに、打ち込んだテキストとマウスのヒット領域は前のY座標に留まりました。明示的な再レイアウトでも直らず、ページの再ロードでも直りませんでした。ウィジェットはまだ自分のジオメトリが最新だと信じていたからです。v3.126.1とともに出荷されるWindows V8ライブラリは、recacheの前に古い矩形を値でコピーし、そのコピーと比較します。だから移動したウィジェットは再レイアウトし、編集した値は枠のまさにその位置に現れます。これはネイティブの修正です。DLLと一緒に旅するので、Pascalユニットを更新しても古いpdfium.v8.dllを保ち続けると、ずれたヒット領域はそのまま残ります。きっかけになった回帰チェックは、生き残る行をまず非デフォルトの値へ編集し、それからフィールドの新しい位置でその値を要求します。デフォルト値で再構築された行では、合格に見えてしまうからです

PDFium Componentのウィジェット再レイアウトの図。GetWidgetRectとRecacheWidgetRectが1つの共有メンバーを返していた旧の自己比較では、移動したウィジェットがPerformLayoutをスキップしました。v3.126.1のWindows V8の値渡しチェックは、エディターとマウスのヒット領域を再描画された枠の上へ再配置します
矩形を自分自身と比較しても決して失敗しません。だから枠は動くのに、打ち込んだテキストとクリックは、チェックが先に値のコピーを保存するまで置き去りのままでした

TPdfViewはどうPDFiumの足元からハンドルを奪わずにページを再ロードするのか

v3.126.2から、TPdfViewはXFAレイアウト変更に続くページ再ロードを、ネイティブのコールスタックが展開されるまで延期します。ページイベントはたいてい、PDFiumがまだ入力を処理している間に発火します。ユーザーがAdd Rowボタンをクリックし、クリックがスクリプトを実行し、スクリプトがインスタンス数を変え、レイアウトがその同じネイティブ呼び出しの中で完了するのです。その瞬間にページハンドルを閉じて開き直すと、呼び出し側がまだ使っているオブジェクトを解放することになります。v3.126.2より前は、ビューアーは自分を無効化するだけで、表示中のページハンドルはレイアウト前の状態を指し続け得ました。そして消えたのが最終ページだった場合、選択されたページ番号は範囲外になります

遅延リフレッシュはいくつかの小さなステップで働き、ホストから見える挙動を説明します:

  1. ページイベントのコールバックは、ビューに保留中のXFAレイアウトリフレッシュがあると印を付け、プライベートなウィンドウメッセージをポストします。メッセージが届く前に繰り返されたイベントは、1つのリフレッシュへ統合されます
  2. まだウィンドウハンドルを持たないビューは保留フラグを保ち、CreateWndからメッセージをポストします。ドキュメントの変更、ビューの非活性化、破棄はフラグをクリアします
  3. メッセージが届くと、ビューはテキスト選択、検索ハイライト、フォーカス中フィールドのインデックスをクリアします。3つとも古いレイアウトを参照していたからです
  4. 選択されたページは新しいPageCountへクランプされます。ページ番号が変わっていれば通常のページ切替を通し、そうでなければ現在のページを再ロードし、フィットモードを再適用します
  5. レイアウトがページをまったく残さないとき、ビューは、もう存在しないページを描く代わりに古いページハンドルをアンロードします
PDFium ComponentのTPdfView遅延XFAリフレッシュの図。ネイティブのレイアウトコールスタック内のページイベントは、保留中のリフレッシュに印を付けてウィンドウメッセージをポストするだけです。メッセージは後になって古い選択状態をクリアし、ページを新しいPageCountへクランプし、ページハンドルを再ロードまたはアンロードします
再ロードはネイティブのコールスタックが展開されるまで待ちます。ポストされたメッセージが繰り返しイベントを統合し、その後ビューがページをクランプし、再ロードし、OnPageChangeを上げます

同じ制約が自分のコードにも当てはまります。OnXfaPageCountChangedはそのネイティブのレイアウトコールバックの中で走るので、通知として扱ってください。ラベル、スピナー範囲、ツールバーの状態はそこで更新し、ドキュメントを閉じる、別のドキュメントを開くといった重い処理は、ポストされたメッセージでキューに入れて、コールバックが戻った後に走らせます。ビューが実際にページを再ロードしたタイミングは、続くTPdfView.OnPageChangeが教えます。その時点でPdfView1.PageNumberを読めば、クランプ済みの値が得られます。Tabキーのトラバーサルと、フォームビューアーがオープン時に実行するFormTypeチェックは、PDFium ComponentによるPDFフォームフィールドのナビゲーションで扱います

Full XFAフィールドのクリックで"Cannot open text page"が上がるのはなぜか

Full XFAページにはPDFのテキストページがなく、v3.126.2より前のビューアーは、デフォルトのテキスト選択とリンク検出がそれでもロードしようとしていました。TPdfView.AllowUserTextSelectionがデフォルトのTrueのままでは、ホバーがマウスの下の文字をテキストレイヤーに尋ね、マウスアップのクリックがページテキストに自動のURLプローブを走らせます。Full XFAページではテキストページを開けないので、フィールドへの普通のクリックがCannot open text page例外で終わることがありました。v3.126.2から、TPdf.FormTypeがftXfaFullでXFAランタイムが利用可能なとき、両内部経路は結果なしで返ります。デフォルト設定のままで動作し、フィールド入力も使い続けられます

Full XFAドキュメントでAllowUserTextSelectionをオフにするのは、今も理にかなったUIの選択です。選ぶべきページテキストがなく、ドラッグのジェスチャーが選択モードを始めるべきでないからです。ただしアップグレードの代用ではありません。より古いバージョンでは、クリック時のURLプローブはこのプロパティに依存しなかったので、選択を無効にしていても同じ例外に当たることはありました

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormTypeは開いたドキュメントを読む。FPdf.Active := Trueの後に呼ぶこと
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // Full XFAページにPDFテキストレイヤーは存在しない。フィールドは編集可能なまま
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

入力はv3.126.2で独自の修理を必要としました。ネイティブのXFAテキストエディターは、文字を受け取っても選択を置き換えません。FORM_OnCharはキャレット位置に挿入し、Backspaceは1文字だけ削除します。だから値を選んでから打ち込むと、古いテキストと新しいテキストが並んでいました。PDFium Componentは今、クリックがXFAテキストフィールドに着地したことを覚え、選択が存在し、ドキュメントがフォーム記入か変更の権限を認めている限り、打ち込まれた文字とBackspaceとDeleteをFORM_ReplaceSelectionへ回します。読み取り専用のXFAフィールドが変えられるかは、依然としてネイティブエディターが決めます。だからフォームで読み取り専用と印を付けられたフィールドは、他が記入を許すドキュメントでも値を保ちます。TPdfView.AllowFormEventsをFalseにしても、このキーボード回しは止まります。読み取り専用ビューアーを読み取り専用のまま保つわけです

クイックリファレンス:Delphiビューアーにおける動的XFA

症状原因修正版
フォームが2ページに伸びた後もページ数が1のままネイティブのページイベントが合計ではなく追加/削除のデルタを渡すv3.126.1(ラッパー)
フィールドの枠は動き、打ち込んだテキストとヒット領域は置き去りロード済みウィジェットが自己比較の後に再レイアウトをスキップv3.126.1(Windows V8ライブラリ)
ビューアーがレイアウト前のページ状態へ描画または入力を振り向ける再ページング後にページハンドルが再ロードされないv3.126.2(遅延リフレッシュ)
フィールドへのクリックでCannot open text pageが上がるテキストレイヤーのないページでのテキスト選択とURLプローブv3.126.2
選択値へ打ち込むと置換でなく追記になるネイティブのXFAエディターがキャレット位置に挿入するv3.126.2
  • ドキュメントがロードされるより前にEnableV8EngineをTrueに設定し、素のライブラリが先にロードされたケースのためにOnXfaRuntimeMissingを処理する
  • 合計はTPdf.PageCountかOnXfaPageCountChangedのNewCountパラメータから読む。ページ数を自分で足したり引いたりしない
  • OnXfaPageCountChangedのハンドラーは軽く保つ。ネイティブのレイアウトコールバックの中で走るからです
  • 現在ページのインジケーターはTPdfView.OnPageChangeで同期する。遅延再ロードがページ番号をクランプした後に発火します
  • v3.126.1以降のWindows V8 DLLをユニットと一緒に配備する。ウィジェット再レイアウトの修正はネイティブコードにあります
  • 実際にページ数が変わり、編集済みフィールドがページ境界を跨いで動くフォームでテストする。固定長のサンプルは、このリストのすべてのバグを隠します

動的XFAはページ数とフィールドのジオメトリを生きた値に変えます。ビューアーが正しくあり続けるのは、完成済みレイアウトからそれらを受け取り、安全な瞬間にページを再ロードするときだけです。PDFium Componentはその両方をTPdfとTPdfViewの中で処理するので、ホストは聞いているだけで済みます。詳細とダウンロードは、PDFium Component for Delphi product pageにあります