技術記事

FDFとXFDF往復でのPDFマルチセレクトフィールド(Delphi)

Deep、改行、Blueで構成されるリストボックスのエクスポート値は完全に合法なPDFですし、Redという2つ目のオプションも合法です。両方を選び、選択を交換ファイル用にLF区切りの1つの文字列へ平坦化すると、相手側が受け取るのはDeep\nBlue\nRedです。2つの選択なのか3つなのか、バイト列のどこにも答えはありません。マルチセレクトの値がFDFやXFDFから無傷で戻ってくるのは、PDFから出ていく瞬間から書き戻される瞬間まで、ずっと配列のまま保たれた場合だけです

改行での結合がマルチセレクトの値を決して運べないのはなぜか

結合された文字列は値の間の境界を失います。そして値は区切り文字を含んでもよいことになっているため、境界を復元できるインポーターは存在しません。ISO 32000-1 §12.7.4.4は、choiceフィールドの/Vエントリに単一のテキスト文字列かテキスト文字列の配列を許しています。MultiSelectフラグ(/Ffのビット22)の立ったリストボックスは、2つ以上のオプションが選ばれた瞬間に配列形式を使います。同じセクションは/I、つまり昇順の0始まりオプションインデックスの配列も定義しており、ビューアはこれで同じエクスポート値を共有する2つのオプションを区別します。どちらの構造も文字列型のAPIでは通せません。HotPDFのGetFormFieldValueはAnsiStringを返し、/Vの文字列形式を読むため、これの上に構築されたエクスポートは、フィールドごとに1つのスカラーしか書けません

インポート側には鏡像の問題があります。XFDFは各選択をそれぞれの<value>要素として書くため、それらをスカラーのセッターに渡すインポーターは、まずそれらを接着しなければなりません。たいていLFで。上のDeep/Blueオプションでは、その接着剤はデータと区別がつきません。そのためHotPDFはスカラーを往復の途中に入れません。ExportLoadedFormToFDF、ExportLoadedInterchangeToFDF、ExportLoadedFormToXFDFはフィールドから配列を直接読み、対応するインポートメソッドは結合済み文字列を経由せずに新しい配列を組み立てます

素朴なマルチセレクトの往復。2つの選ばれたリストボックスオプションのうち1つは埋め込み改行を含み、スカラーのGetFormFieldValue経路によってDeep、改行、Blue、改行、Redという単一の文字列へ平坦化されるため、下流のリーダーはこれを2つの選択とも3つの選択とも解釈できてしまう
マルチセレクトの値を1つの文字列へ結合すると値の境界が失われ、それ自体が改行を含むエクスポート値は、平坦化された形を曖昧にします

エクスポートされるFDFとXFDFのファイルの中身

ディスク上では境界は見えたままです。FDFではマルチセレクトのフィールドは型付き配列になり、各項目はソースPDFでの綴りを保ちます。16進文字列はhexとして出ていき、リテラル文字列はCRとLFを\rと\nにエスケープしたリテラルとして出ていきます。ExportLoadedFormToFDFが書くファイルでは、フィールドのリッチテキスト/RVやゼロでないローカル/Ffも同じフィールド辞書に同乗します。XFDFではルート要素がxml:space="preserve"を宣言します。ISO 19444-1が要求しており、テキスト要素内のすべての空白文字をデータに変えるものです。これは整形出力にとっての罠です。HotPDFは各<value>の開始タグ、エスケープ済みテキスト、終了タグを1行に書き、インデントは要素の外に保ち、CR、LF、TABを文字参照としてエンコードします。行末正規化を適用するXMLパーサーが、CR LFのペアを単独のLFへ書き換えられないようにするためです

マルチセレクトリストボックスに対してHotPDFが書くエクスポートの形。FDFはフィールドごとに1つの型付き配列を運び、/V [(Deep 改行 Blue) (Red)] とhexのregion値を持ちます。一方XFDFは、xml:space preserveのもとで選択ごとに1つのvalue要素を運び、空白文字がデータとして数えられます
境界はディスク上で見えたままです。FDFは各選択を独自の配列項目として保ち、XFDFはそれぞれを別々のvalue要素に書くため、インポーターが推測する必要はありません
<!-- FDF:フィールドごとに1つの型付き配列 -->
<< /T (options) /V [(Deep\nBlue) (Red)] >>
<< /T (region) /V [<45553132>] >>

<!-- XFDF:選択ごとに1つの <value> -->
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
  <fields>
    <field name="options">
      <value>Deep&#xA;Blue</value>
      <value>Red</value>
    </field>
  </fields>
</xfdf>

呼び出しコードを書くときに関係するエクスポートのエッジケースが2つあります。ExportLoadedFormToFDFは完全なFDFボディをメモリで組み立ててからターゲットファイルを作るため、エクスポートできない値(文字列以外を含む配列など)は、ディスク上の既存の.fdfがまだ無傷のうちに例外を出します。2つ目のケースはXFDFそのものの本物の隙間です。空文字列のエクスポート値も提供しているリストボックスでの空の選択です。<value/>は「何も選択していない」にも「空のオプションを選択している」にも読め、フォーマットには3つ目の綴りがありません。この状況では、ExportLoadedFormToXFDFはターゲットファイルを開く前に例外を出します。FDFにはこの問題はありません。/V []と/V [()]は異なるオブジェクトだからです。3つのエクスポーターはいずれも、/T名を持たないウィジェット専用の末端をスキップします。そのようなエントリをフィールドへ対応づけられるインポーターは存在しないためです

var
  Pdf: THotPDF;
  Written: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    begin
      // マルチセレクトのリストボックスは /V [(...) (...)] として書かれる
      Written := Pdf.ExportLoadedFormToFDF('order-form.fdf');
      try
        Pdf.ExportLoadedFormToXFDF('order-form.xfdf');
      except
        on E: Exception do
          // 空の選択と空文字列のエクスポートオプション:XFDFには区別が
          // つかず、既存の.xfdfファイルはそのまま残る
          ShowMessage('XFDF export refused: ' + E.Message);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

インポートされた選択は/Optに対してどう検査されるのか

HotPDFが入力配列を受け入れるのは、ターゲットがMultiSelectフラグの立ったchoiceフィールドで、すべての値がフィールドの/Opt配列内のエクスポート値(2要素のオプションペアなら最初の要素)と一致するときだけです。各オプションスロットは1回しか使えません。そのため、どちらもbをエクスポートする2つのオプションを持つリストは、[<62> <62>]を2つの別個の選択として受け入れ、3つ目のbは拒否します。使われていないスロットがもうないからです。組み直された/Iは、値が到着した順序にかかわらず/Opt順に従い、§12.7.4.4が要求するとおりインデックスを昇順に保ちます。新しい/Vと/Iは切り離されたオブジェクトとして組み立てられ、すべての値が通った後にだけ割り当てられるため、拒否された値がフィールドに半分の配列や古いインデックスを残すことはありません

割り当ての後、フィールドは新しい外観ストリームを得ます。このステップは手作りのインポーターで忘れやすく、症状は紛らわしいものになります。ビューアは/Vではなく保存された/APを描画するため、データが変わっても古い選択が画面と印刷に残るのです。リストボックスは変更フィールドの集合にも加わるため、引数なしのRecalculateLoadedFormFieldsIncremental呼び出しが、それを読む計算だけを正確に再実行します。FDFから届いたhexの綴りは、保存までhexのままです。スカラー値を1つ設定するだけなら、読み込み済みPDFに1つのフォームフィールド値を設定するがその経路を扱っています。こちらは設計上、複数選択を扱いません

マルチセレクト値に対するHotPDFのインポート検証。ターゲットは/FfにMultiSelectの立ったchoiceフィールドでなければならず、入ってくる値はどれも/Optのエクスポート値と、各スロット1回の使用で一致しなければならず、/Iは/Opt順の昇順で組み直され、切り離された/Vと/Iはすべての値が通った後にだけ割り当てられます
何かが書き込まれる前に、入ってくる値ごとにフィールドオプションとの照合が行われるため、拒否された値が半分の配列や古い選択インデックスを残すことはありません

特別な扱いに値する入力パターンが1つあります。PDFはプレーンASCIIのエクスポート値を、バイト順マークなしのhex文字列として保存できます。Abrに対する<416272>です。PDF文字列の本体をそのままコピーするエクスポーターは、デコード済みテキストではなく生の数字416272を要素テキストとするXFDFを作ります。hexエンコードされたエクスポート値を生の数字のまま運ぶXFDFは、綴りでは/Optとマッチできません。厳格な比較は416272という名前のオプションを見つけられず、インポートは失敗します。HotPDFのXFDFインポーターはそのようなファイルを受け取ると、一致しなかった値を1回だけ、hexペイロードとしてデコードして再試行します。再試行は意図的に狭く設計されています。まったく正確に一致しない値に対してだけ実行され、正確に一致した値はすべて先に予約されるため、救済された綴りが、後から来る正確な綴りの値が必要とするオプションスロットを奪うことはありません。そして保存される/V項目はオプション自身のPDF文字列なので、/Vと/Iは同じ選択を記述します。これが適切な寛容さです。インポートが例外を出していたはずの場所でのみ発火し、すでにマッチした値が再解釈されることはありません。マッチング自体は、PDFDocEncoding、どちらのバイト順マークのUTF-16、UTF-8を理解する1つのUnicodeデコーダーを通るため、エンコーディングが混在するドキュメントでも、同じ論理テキストは同じオプションにマッチします

FDF解析の2つの罠:hex終端子と他ファイルのオブジェクト番号

1つ目の罠は字句の問題です。<< /T (region) /V <416273>>>では、最初の>がhex文字列を閉じ、次の2つが辞書を閉じます。>>のペアだけを探すスキャナーは1文字早く辞書を閉じ、フィールドを失います。HotPDFのファイルレベルFDFインポーターは、/Fields配列を切り分ける間、hex文字列やリテラルの内側にいるかどうかを追跡します。そしてImportLoadedInterchangeFromFDFの背後にあるストリームベースのリーダーは、ファイル全体をPDF構文としてトークン化するため、素朴な走査が2つに見えるところを、どちらも3つの閉じ括弧として見ます

2つ目の罠は参照に関するものです。FDFファイルはそれ自身のオブジェクト番号付け(ISO 32000-1 §12.7.7)を持つ小さなPDF構文のドキュメントであるため、/V [11 0 R]はFDFファイルのオブジェクト11を意味し、記入対象のPDFのオブジェクト11では決してありません。ターゲットドキュメントに対して解決すると、たまたまそこでその番号を持つオブジェクトが何であれ、静かに取り込まれてしまいます。Delphi 2009以降のビルドでは、インターチェンジリーダーはFDFを完全なオブジェクトグラフとしてデコードし、そのグラフ内で参照をたどり、結果の文字列を他のインライン配列と同じように検証し、FDFにオブジェクト11がなければ例外を出します。ファイルレベルのImportLoadedFormFromFDFは、FDFのオブジェクトテーブルなしで各フィールド辞書を単独に読みます。参照の配列は文字列専用の検査に落ち、そのフィールドはスキップされます

インポート経路の選択:スキップ、例外、ロールバック

3つのインポート経路はすべて、マルチセレクトの値を同じ方法で検証します。違うのは、値が失敗したときに何が起こるかです。ImportLoadedFormFromFDFは失敗したフィールドをすべてスキップし、実際に適用できたフィールド数を返します。送ったエントリ数より少ないカウントだけが問題の兆候です。ImportLoadedFormFromXFDFとImportLoadedInterchangeFromFDFは最初に拒否されたフィールドで例外を出し、各呼び出しはロード済みドキュメント上で1つのトランザクションとして動きます。例外がメソッドの外へ出るとき、呼び出しがすでに変更していたすべてのフィールド(インターチェンジ経路ではすべての注釈も)がロールバックされます。Falseを返したインターチェンジのインポートもロールバックされ、Statusとinfoレコードはクリアされます

人がレビューして送り返してきたデータには、例外を出す経路のどれかを使います。半分だけ適用された発注書は、拒否されたものよりひどいのです。保存されたPDFのどこにも、どのリストボックスがレビュー済みファイルから来て、どれが古い値を保ったかを示すものがないためです。ImportLoadedFormFromFDFのスキップして数える振る舞いは、別の仕事に合っています。フォームの少し違うリビジョン向けに生成されたかもしれないFDFからの、ベストエフォートの事前記入です

var
  Pdf: THotPDF;
  Source: TMemoryStream;
  Status: AnsiString;
  Info: THPDFFDFInterchangeInfo;
begin
  Pdf := THotPDF.Create(nil);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile('order-form-reviewed.fdf');
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    try
      // フィールドのみ。/Optの外の値や、単一セレクトのリストに向けた
      // 複数の値は例外を出し、インポート全体をロールバックする
      if Pdf.ImportLoadedInterchangeFromFDF(Source, True, False, Status, Info) then
      begin
        // インポートしたフィールドを読む計算だけを再実行する
        Pdf.RecalculateLoadedFormFieldsIncremental;
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
      end;
    except
      on E: Exception do
        ShowMessage('Import rejected, document unchanged: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

自前のデータストアをXFDF配列コールバックに結びつける

XFDFと、コールバックで記述できるあらゆるフィールドストアの間を変換するHPDFXFDFユニットは、配列対応を別のレコードTHPDFXFDFArrayAccessと、HPDFXFDFExportFieldsとHPDFXFDFImportFieldsの追加オーバーロードに載せています。既存のTHPDFXFDFAccessレコードに新しいスロットを足す方が短かったでしょうが、それは間違いでした。呼び出し側はしばしばそのレコードをローカル変数として宣言し、知っているスロットだけを埋めます。末尾に新しい関数ポインタを足すと、そこにはスタックのゴミが入り、ライブラリはそれをコールバックと誤認します。別レコードなら、既存のコードはレイアウトとオーバーロードを保ったままです。それらのオーバーロードは内部で全nilの配列レコードを渡します

2つのインポートオーバーロードは、繰り返される<value>要素で異なる振る舞いをします。元のスカラーオーバーロードは今もLFで結合してSetFormFieldValueを呼びます。既存の呼び出し側がその規約に依存しているためです。配列対応のオーバーロードは決して結合しません。リストをSetFormFieldValueArrayに渡し、コールバックが辞退した単一の値に対してだけスカラーセッターへフォールバックし、2つ以上のリストを辞退された場合は例外を出します。エクスポートではDefault(THPDFXFDFArrayAccess)から始めて、割り当てないスロット(オプションのSetFormFieldRVを含む)がnilのままになるようにします。何も選択されていないものを含む、リスト値のフィールドすべてについてGetFormFieldValueArrayはTrueを返し、スカラーコールバックに任せるフィールドではFalseを返します。空の配列は単一の空の<value/>として出ていくため、空文字列のオプションを提供するストアは、ExportLoadedFormToXFDFと同じように、そのケースをコールバック内で拒否すべきです

uses HPDFXFDF;

// プレーンな関数ポインタで"of object"ではない:Contextが自分のストアを運ぶ
// TFormStoreとそのメソッドは自分のコードであり、HotPDFの一部ではない
function StoreGetSelections(Context: Pointer; FieldIndex: Integer;
  out Values: THPDFXFDFValueArray): Boolean;
begin
  Result := TFormStore(Context).IsListField(FieldIndex);
  if Result then
    Values := TFormStore(Context).Selections(FieldIndex);
end;

procedure ExportStore(Store: TFormStore; out Bytes: TBytes);
var
  Access: THPDFXFDFAccess;
  ArrayAccess: THPDFXFDFArrayAccess;
begin
  Access := MakeStoreAccess(Store);             // 既存のスカラー束縛
  ArrayAccess := Default(THPDFXFDFArrayAccess); // 使わないスロットはすべてnil
  ArrayAccess.GetFormFieldValueArray := StoreGetSelections;
  HPDFXFDFExportFields(Access, ArrayAccess, Bytes);
end;

ここまでの話は、すでに存在し、/FfにMultiSelectビットの立ったリストボックスを前提にしています。choiceフィールドの作成とフラグビットの設定は読み込み済みPDFにListBoxと他のAcroFormフィールドを追加するで、XFDFの<annots>部分を流れる注釈マークアップはHotPDFでのXFDF注釈のインポートとエクスポートで扱っています。ここで示したすべての呼び出しのメソッドリファレンスと、自分のフォームで試せる評価版は、HotPDF Delphi PDF componentページにあります