PDFium Componentが編集済みXFAフォームの値を正確に保存し、保存と再オープンを通して保つのは、v3.125.2以降で出荷されたWindows V8ランタイムpdfium.v8.dllのときです。より古いランタイムは、フィールド値に改行を足し、絵文字を無関係なBMP文字まで切り詰め、単一ストリームのXFA保存を黙ってスキップし、失敗した最終書き込みを飲み込めました。再オープンの症状の1つは、ライブラリの欠陥ではまったくありません。restoreState="auto"を欠くルートサブフォームを持つ動的フォームは、テンプレートからレイアウトを組み立て直します
この件のバグ報告は、どれも似た顔をしていました。客がDelphiビューアーでXFAの請求フォームを記入し、保存し、再オープンすると、何かがわずかにずれている。空だったコメント欄に空行が入り、2回目の保存後には2行になる。絵文字入りの名前が、私用領域のグリフとして戻ってくる。誰もエラーを受け取りません。だからこそこの種のバグは高くつきます。ずれは数週間後、別の誰かのエクスポートで顔を出すのです
XFAフォームを保存して再オープンすると、どこで狂うのか
ネイティブXFA保存経路の4つの独立した欠陥が値のずれを生み、それぞれが成功したように見える保存の後ろに隠れていました。2つは直列化から、1つは単一ストリームの保存レイアウトから、1つはPDFライター自身から来ました。次の表は、各症状を原因と、PDFium Componentが修正したリリースへ対応付けます
| 再オープン後の症状 | 原因 | 修正版 |
|---|---|---|
| 空フィールドが改行を保持する。保存ごとに値へ改行が増える | 両XFAライターが開始タグの後にレイアウト用改行を挿入していた | v3.125.2、pdfium.v8.dll |
| U+1F642がU+F642として戻る。あるいは絵文字がフォームパケットから消える | デコードでの16ビットwchar_t切り詰め。フォーム直列化でのサロゲート除去 | v3.125.2、pdfium.v8.dll |
| 単一ストリームXFAドキュメントの編集が消えている | ネイティブ保存がストリームレイアウトを拒否していたが、戻り値が無視されていた | v3.125.2。コメントと処理命令はv3.126.0から保持 |
| 保存は成功と報告したのにファイルが切り詰められている | ライターがすでに成功を返した後の、最終バッファード書き込みの失敗 | v3.125.2でV8ランタイム。v3.125.3で通常のpdfium.dll |
| 3ページの動的フォームが2ページとして再オープンする | ルートサブフォームがrestoreState="auto"を要求していない | フォームの作り込みの問題。ライブラリの欠陥ではない |
初期のまとめは、XFAフィールド編集はPDFiumでは永続化できないと結論していました。当時のランタイムについては正確でした。新しいV8ランタイムはXFAの値をネイティブに保存するので、生きているフォームでの編集は、こちらでパケットを手術せずとも、保存されたdatasetsパケットへ届きます
どのPDFiumランタイムがXFAの値を保存できるのか
XFA保存の忠実度は、DelphiラッパーではなくネイティブDLLに依存します。だから最初のチェックは、プロセスが実際にロードしたランタイムがどれかです。PDFium Componentはアーキテクチャーごとに2つのWindowsビルドを出荷します。V8とXFAなしでビルドされた通常のpdfium.dllと、JavaScriptエンジンとXFAフォームランタイムを運ぶpdfium.v8.dllです。XFAフォームを実行できるのはpdfium.v8.dllだけなので、ここで述べるすべてのXFA修正はそちらに住んでいます。v3.125.2で再ビルドされたWin32とWin64のV8ライブラリからです
最終書き込みの修正は汎用のPDFライターコードなので、通常のドキュメントにも効きます。v3.125.3は、同じ修理を運ぶ通常のpdfium.dllライブラリを再ビルドしました。ソースの共有は、挙動の共有の証明にはなりません。バイナリが再ビルドされるまで、古いDLLは古いバグを保ちます
2つ目の罠はローダーに座っていました。v3.125.2より前は、EnableV8EngineをTrueにすると、バインディングはデフォルトのpdfium.v8.dll名を選び、LibraryNameの完全パスを無視しました。新しく配備したランタイムを指すアプリケーションが、別フォルダの古いコピーをロードし続けることがあり得たのです。v3.125.2から、ディレクトリを含むLibraryNameは、どちらのエンジンモードでも正確にそのファイルを選び、見つからないパスは、別のバンドルライブラリへフォールバックする代わりに失敗します
uses
System.SysUtils, PDFium;
procedure SelectXfaRuntime;
begin
// LibraryName内のディレクトリはこのファイルに固定する(v3.125.2以降)。
// ファイルがなければ、フォールバックの代わりにロードが例外を上げる
{$IFDEF WIN64}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
PDFium.EnableV8Engine := True;
PDFium.LoadLibrary; // 最初の保存ではなく、起動時に失敗させる
end;
ドキュメントを開いた後、TPdf.XFAがファイルがXFAを含むことを告げ、TPdf.XfaRuntimeAvailableがロードされたDLLが実際に実行できることを告げます。静的フォームと動的フォームの見分けも必要なら、TPdf.FormTypeがftXfaFullかftXfaForegroundを返します。この調べ方は、DelphiでのXFAフォームの検出とXFAパケットの抽出の記事が詳しく扱います
保存されたXFAフィールドに余計な改行が増えるのはなぜか
保存されたXFAフィールドに改行が増えたのは、両方のネイティブXFAライター、汎用のXML要素ライターとフォームパケットの直列化器が、開始タグの後に改行を入れて出力を整形していたからです。ほとんどのXMLではその空白は化粧です。しかしXFAデータでは違います。datasetsパケットが再パースされると、<Comments>と</Comments>の間のテキストがフィールド値であり、改行も含めてです。だから空のフィールドは単一のLFを保持して再オープンされ、保存と再オープンのサイクルを重ねるごとに、もう1つ足し得ました
分かりやすい修理であるロード時のトリムは、間違いです。ユーザーはXFAフィールドに、先頭のスペース、末尾のスペース、意図的な複数行テキストを打ち込みます。住所ブロックや固定幅のコードは、バイト単位で生き延びねばなりません。だからv3.125.2の修正は、直列化器自身がタグの周りに合成した空白だけを除去します。ユーザーの値、既存のテキストノード、CDATAセクションはそのまま通るので、" indented"はインデントされたままで、意図的に空のフィールドは空のままです
絵文字が別の文字として戻ってくるのはなぜか
絵文字が間違って戻ったのは、Windowsのwchar_tが16ビット幅で、2つのデコード経路が完全なUnicodeスカラー値を単一のwchar_tへ保存していたからです。UTF-8ストリームデコーダーと、🙂のような数値文字参照のパーサーが、両方そうしました。U+1F642、ほほえみの顔は16ビットに収まらないので、上位ビットが落ちてU+F642が現れました。ほとんどのフォントが箱か何もなしで描く、私用領域のコードポイントです
フォームの直列化器には正反対の問題がありました。1回に1つのwchar_tで文字をろ過し、単独では不正な2つのサロゲートコードユニットを見て、両方を落としました。だから絵文字はフォームパケットから丸ごと消えたのです。v3.125.2では、デコーダーは各スカラー値を完全に消費し、正しいサロゲートペアを出力します。出力スロットが1つしか残らないとき、下位サロゲートを保留に保ち、そのユニットがまだバッファーにある間はストリーム終了を報告しません。読み込みブロックに跨って分割されたUTF-8シーケンスは、捨てられる代わりに次の読み込みへ持ち越されます。フォームエクスポーターは今、有効なサロゲートペアを一緒に保ち、数値文字参照も正しいペアを作ります
Latin-1のテストデータは、このどれも見せません。だからすべてのXFA往復テストには、少なくとも1つの追加面の文字が要ります
単一ストリームXFAと、誰にも見えなかった保存失敗
単一ストリームのXFAドキュメントが編集を失ったのは、ネイティブの保存ヘルパーがその保存レイアウトを拒否し、その呼び出し側が失敗を無視していたからです。ISO 32000-1 §12.7.8は、対話フォーム辞書の/XFAエントリーを、パケット名とストリームの配列としても、XDPドキュメント全体を保持する単一ストリームとしても許します。パケット配列がよくあるケースですが、単一ストリームも完全に合法です。フォームデータは古い値のまま、PDF保存は何事もなかったかのように完了しました
v3.125.2から、V8ランタイムは対応済みの単一ストリームサブセットを処理します。まず生きている両パケット、datasetsとformをステージング領域へエクスポートして検証し、それから元のXDP内の一致するパケットを置き換えます。その他のパケットとルートの名前空間宣言は保たれます。ステージングが失敗すれば、永続的なXFAストリームは決して触れられず、ドキュメントは変更印を保ちます
XMLコメントと処理命令には追加の配慮が要りました。内部のXML DOMがこれらを落とすからです。v3.125.2では、これらの存在は、コンテンツを静かに失う代わりに保存を丸ごと失敗させていました。v3.126.0はこれらを保持します。パースの前に、各コメントや処理命令は、元のテキストのどこにも現れないプレフィックスから組み立てたマーカーへ取り替えられます。生きているパケットが置き換えられた後、元のトークンを復元してストリームを書き出す前に、すべてのマーカーが正確に1回現れたことを確認します。だから置き換えられなかったパケット内のトークンは、テキストも順序も保ちます。プロローグ、テンプレート、その他のパケット内のトークンを含めて
一部の入力は今も意図的に拒否されます。そして各拒否は明示的な保存失敗です:
- 生きている
datasetsかformパケットの内側のコメントや処理命令。元の位置を新しくエクスポートしたコンテンツへ写像できないからです - DTD宣言とXMLDSig署名。XDPの書き直しは、XML署名を有効なまま保てないからです
- 不正なUTF-8やUTF-16のエンコーディング、不完全なタグ、不正な文字参照、未知の実体、奇形の処理命令は、黙って修理する代わりに拒否されます
単一ストリームの出力はUTF-8で、XMLのコンテンツモデルを保ちます。元のバイトレイアウトやエンコーディング宣言ではありません
最後の欠陥はXFAの下に座っていました。ネイティブのファイルライターは出力を32 KBブロックでバッファリングし、最後の端数ブロックは、ドキュメントライターがすでに成功を報告した後の、デストラクターの中でしかフラッシュしていませんでした。その最後のブロックでのディスク満杯やI/Oエラーは、呼び出し側には見えませんでした。V8ランタイムではv3.125.2から、通常のランタイムではv3.125.3から、その最終フラッシュは保存結果の一部になり、XFAの変更印は本物の成功の後にだけクリアされます。Delphi側では、TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Booleanはターゲットの隣の一時ファイルへ書き出し、保存がTrueを返したときにだけそれを所定の位置へ移します。だから失敗した保存は前のファイルを無傷のままにします
動的XFAフォームがより少ないページ数で再オープンするのはなぜか
動的XFAフォームがより少ないページ数で再オープンするのは、ルートサブフォームがrestoreState="auto"を宣言していないときで、これはフォーム作りの判断であって、PDFium Componentの欠陥ではありません。XFA 3.3では、ルートサブフォームのrestoreStateはmanualがデフォルトです。manualの下では、XFAプロセッサーは保存されたフォームパケットから限られた状態だけを復元し、残りを作者のスクリプトに任せます。保存されたフィールド値と繰り返しサブフォームのインスタンス数は戻りますが、実行時に設定された幾何プロパティは戻りません
これを晒したのは、スクリプトがサブフォームをh="450pt"へ伸ばす3ページのフォームでした。保存されたフォームパケットは新しい高さと、値と、インスタンス数を保持していました。ところが再オープンでは、レイアウトはテンプレートの高さから組み立て直され、フォームは2ページへ再流し込みされました。ランタイムは正しかったのです。テンプレートは自動復元を頼んだことがなかった。ルートサブフォームで宣言すれば、再オープンは直ります:
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form1" layout="tb" restoreState="auto">
<pageSet>
<pageArea name="Page1">
<contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
<medium stock="letter"/>
</pageArea>
</pageSet>
<subform name="Details" layout="tb" w="7.5in">
<!-- フィールド。スクリプトが実行時にhを変えたりインスタンスを足したりする -->
</subform>
</subform>
</template>
テンプレートを所有していないなら、ビューアー側でその場しのぎのパッチを当てないでください。manualモードに頼るフォームは、状態を組み立て直すのは自分のスクリプトだと期待しています。ユーザーが打っている間のライブ再ページングは別の話題で、PDFium Componentが動的XFAのページ数と移動したフィールドを追跡する仕組みで扱います
DelphiでXFAの保存を検証するには
信頼できる唯一のXFA保存チェックは、保存済みファイルを新しいTPdfインスタンスで再オープンし、保存されたデータを読み戻すことです。TPdf.GetXfaDatasetsは、生きているXFAデータモデルではなく、ドキュメントに保存された通りdatasetsパケットを返します。だから保存前に呼ぶと古い値が見えます。再オープン後なら、書かれたものが正確に見えます。単一ストリームのドキュメントには、個別に名前の付いたパケットがありません。PDFiumはXDP全体を空の名前の1つのパケットとして報告するので、GetXfaPacketByName('datasets')とGetXfaDatasetsは何も返さず、フォールバックはGetXfaFormPackets経由で完全なストリームを読みます
uses
System.SysUtils, PDFium, FPdfXfa;
function ReadSavedXfaData(const FileName: string): string;
var
Pdf: TPdf;
Packets: TXfaPacketList;
Bytes: TBytes;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
Bytes := Pdf.GetXfaDatasets; // パケット配列レイアウト
if Length(Bytes) = 0 then
begin
Packets := Pdf.GetXfaFormPackets; // 単一ストリーム:名前なしパケット1つ
if Length(Packets) = 1 then
begin
SetLength(Bytes, Length(Packets[0].Content));
if Length(Bytes) > 0 then
Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
end;
end;
Result := TEncoding.UTF8.GetString(Bytes); // 保存されたXDP出力はUTF-8
finally
Pdf.Free;
end;
end;
保存ルーチンはその後、保留中の編集をコミットし、SaveAsの結果をチェックし、再オープンした値と比較します。TPdf.ClearFormFieldFocusはフォームフォーカスを殺します。これがPDFiumがフォーカス中フィールドの編集バッファーをコミットする瞬間です。TPdf.SetFocusedFormFieldText(const Value: WString): Booleanはプログラムからフォーカス中フィールドを埋めますが、これはウィジェット注釈を辿るFocusFormField経由でラッパーが追跡するフォーカスに依存します。動的XFAページには普通それがないので、そこではテキストはたいていTPdfViewのキーボード入力経由で届き、追跡中フィールドにフォーカスがなければ関数はFalseを返します
function XmlText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
end;
procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
Expected: string);
var
Saved: string;
begin
// 任意のスクリプト記入。Falseは追跡中フィールドにフォーカスがないことを意味する
if (Pdf.FocusedFormFieldIndex >= 0) and
not Pdf.SetFocusedFormFieldText(Expected) then
raise EPdfError.Create('Could not write the focused field');
Pdf.ClearFormFieldFocus; // 編集バッファーをコミットする
if not Pdf.SaveAs(FileName) then // 最終フラッシュを含む(v3.125.2以降)
raise EPdfError.CreateFmt('Saving %s failed', [FileName]);
Saved := ReadSavedXfaData(FileName);
if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
Saved) = 0 then
raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;
部分文字列テストはスモークテストとして扱ってください。空の要素は<Tag/>として直列化され得ますし、属性はデータ要素に現れ得ますし、&と<を超えたエスケープは直列化器の選択です。本番のチェックでは、再オープンしたXMLを本物のXMLパーサーでロードし、結び付けられたデータ要素のテキストノードを比較してください。そしてチェックは2回続けて走らせます。改行の欠陥が完全な姿を見せたのは、第2世代だったからです
クイックリファレンス:XFA保存の忠実度チェックリスト
- XFAフォームにはv3.125.2以降の
pdfium.v8.dllを、通常のpdfium.dllにはv3.125.3以降を配備する。最終書き込みの修正を両方に入れる LibraryNameは完全パスへ向け、EnableV8EngineはTrueにする。見つからないパスは、別のコピーをロードする代わりに失敗します- ドキュメントを開いた後、
TPdf.XFAとTPdf.XfaRuntimeAvailableを確認する - フォーカス中フィールドをコミットさせるため、
SaveAsの前にClearFormFieldFocusを呼ぶ SaveAsのブール結果を決して無視しない。Falseは前のファイルをその場に残します- 検証は新しい
TPdfで再オープンしてGetXfaDatasetsを読み、単一ストリームXFAではGetXfaFormPacketsへフォールバックして行う - 空の値、先頭スペース、複数行テキスト、
&、追加面の文字で、2世代の保存を跨いでテストする - 単一ストリームXFAの生きているパケット内のDTD、XMLDSig、コメントは、明示的な保存失敗を期待する
- 動的フォームが再オープンで実行時の幾何を失うなら、ライブラリを疑う前にルートサブフォームの
restoreState="auto"を確認する
XFAランタイムがホストアプリケーションに期待するコールバック構造体は、DelphiにおけるFPDF_FORMFILLINFOバージョン2とXFA ABIをご覧ください。V8ランタイム、DelphiとC++Builderのラッパー、ビューアーコントロールはすべて、PDFium Component for Delphi and C++Builderの一部で、Win32とWin64の両Windowsランタイムを含みます