技術記事

DelphiにおけるXFAリッチテキストハイパーリンクのPDFリンクへの変換

XFA(XMLフォームアーキテクチャ)は廃止されました。ISO 32000-1の§12.7には、PDF 2.0から削除された旨の注記があり、現代のビューアは次々とXFAエンジンを廃止しています。しかし、それによってアーカイブが空になったわけではありません。政府の申請フォーム、保険申込書、銀行取引明細書は20年近くXFAで作成されており、それらのファイルは今も受信トレイやドキュメントパイプラインに届いています。以前それらをレンダリングしていたビューアがそれを止めると、フォームは「別のリーダーで開いてください」というプレースホルダーが表示された空白ページになります。永続的な修正は、どのリーダーでも表示できる静的なPDFコンテンツにXFAを平坦化することです

その平坦化の難しい部分はフィールドではありません。テキストボックスとチェックボックスはAcroFormウィジェットにクリーンにマッピングされます。難しいのは、XFAがdraw要素の内部に<exData contentType="text/html">ブロックとして格納するリッチテキストです。そのブロックはインラインスタイルとアンカーを持つHTMLのサブセットです。それをページに表示するには、スタイル付きテキストとライブハイパーリンクの両方を再現する必要があります。そしてハイパーリンクこそが、ほとんどの実装が密かにあきらめるところです

XFAリッチテキストの実際の外観

exDataの本文は小さなXHTMLのスライスです。段落は<p>、スタイル付きの文字の範囲は太さ、傾き、色、サイズの独自インラインCSSを持つ<span>、ハイパーリンクは表示テキストをラップする<a href="...">です。1行に異なるスタイルの複数のスパンが並び、その1つがアンカーの場合があります。スタイルは省略できる装飾ではありません。法的警告であるために太字の赤でレンダリングされた条項は、平坦化後も太字の赤のままでなければなりません。そうでなければ平坦化されたドキュメントが元のドキュメントを誤って表現します

そのため、平坦化エンジンはブロックを1つの文字列として扱うことができません。インライン構造をたどり、スパンのインラインCSSをdraw要素のベースフォントに重ねることで各実行の有効なスタイルを解決し、行全体に実行を1つずつ配置する必要があります。HotPDFはこれらの配置されたフラグメントをそれぞれ内部のTXFARichRunレコードとしてモデル化します。このレコードには実行のテキスト、解決されたスタイル、計測されたボックス、アンカーの場合はそれが指すHrefが含まれます

実行を左から右に配置する

位置付けは、リッチテキストがパース問題から組版問題になるところです。実行は行を共有するため、各実行は前の実行が終わった場所から始まります。それらの位置を記録するマークアップはなく、計測する必要があります。エンジンの内部LayoutRichTextルーティンは後でそれを描画するのと同じフォントメトリクスですべての実行を計測し、実行の水平オフセットをそれ以前のすべての実行幅の累積和に設定します。実行1はdrawボックスの原点から始まり、実行2は実行1の幅から始まり、実行3は最初の2つの組み合わせた幅から始まり、行全体にわたって続きます

これが計測フォントの位置合わせがこれほど重要な理由です。レイアウトパスはアドバンスを計測し、別のレンダリングパスがグリフを描画します。それら2つのパスがフォントについて同意しない場合、レイアウトが計算したボックスはレンダラーが描画するグリフの下に位置しません。HotPDFはそれらを同期させるために、各実行の解決されたスタイルをフォント仕様にマッピングします。内部のRunStyleToFontSpecヘルパーを通じて、レンダラー自身のデフォルト(Arial 10ポイント)と一致します。計測されたアドバンスと描画されたテキストが一致し、実行の計算されたボックスが読者が見る文字を真に覆います

// Conceptual shape of one laid-out run. The engine builds an array of these
// internally; you never construct them yourself, but the fields explain how a
// link's hit box is derived from measured geometry rather than from text.
type
  TRichRunInfo = record
    Dx, Dy : Double;       // top-left, relative to the draw-box origin
    W, H   : Double;       // measured run box (width from the layout pass)
    Text   : AnsiString;   // the run's visible characters
    Href   : AnsiString;   // URI target for an <a> run, '' otherwise
  end;

アンカー実行からPDFリンク注釈へ

完成したPDFのハイパーリンクはページコンテンツの一部ではありません。それはISO 32000-1の§12.5.6.5に記述されている別個のオブジェクトであるリンク注釈です。注釈にはページ上のクリック可能な矩形を定義する/Rectと、矩形がクリックされたときに発動するアクションがあります。外部リンクの場合、アクションはURIアクション(/S /URIとターゲットアドレスが/URI文字列)です。下の表示テキストは通常のページコンテンツです。注釈はその上に重ねられた不可視のホットゾーンです

平坦化のパスはまさにこのモデルに従います。実行がHrefを持つ場合、HotPDFはまずスタイル付きテキストを描画し、次に実行のボックスの上にリンク注釈を構築します。その注釈の公開エントリポイントはページメソッドAddURILinkで、/URIアクションを持つ/Type /Annot /Subtype /Linkオブジェクトを作成し、注釈ディクショナリを返します。その矩形は実行の計測されたボックスで、drawエレメントのローカル座標からページ座標に変換されます。結果はアンカーテキストに正確に、それ以外のどこにも重ならないリンクです

// The same public API the flatten path uses for each anchor run. It produces
// an ISO 32000-1 12.5.6.5 Link annotation: /Subtype /Link with a /URI action
// over the given rectangle. The optional description fills /Contents so a
// screen reader can announce the target.
var
  LinkRect: TRect;
  Annot: THPDFDictionaryObject;
begin
  LinkRect := Rect(72, 690, 268, 706);  // page-space hit box for the run
  Annot := Pdf.CurrentPage.AddURILink(LinkRect,
    'https://www.example.gov/appeal', 'File an appeal online');
end;

ヒットボックスが計測幅から導出されなければならない理由

表示テキストを検索してその周囲に矩形を描くことでリンクを配置することを想像するかもしれません。しかしそれは機能せず、その理由は平坦化されたテキストが格納される方法の根本に関係しています。スタイル付き実行は埋め込みサブセットフォントで描画されます。サブセットフォントは保持するグリフを再番号付けするため、ページコンテンツストリームは16進数のCIDコードを保持しており、元の文字コードではありません。ページ上のバイトは人間が読む文字ではなく、テキストとして検索可能ではありません。アンカーのキャプションの検索は何も見つけません。なぜならそのキャプションはストリームのどこにもリテラルテキストとして存在しないからです

矩形の唯一の信頼できる基点は、レイアウトパスがすでに生成したジオメトリです。各実行のオフセットと計測幅は行を流し込む際に計算されており、グリフが番号付けし直される前に、テキストが物理的にどこに現れるかを記述しています。HotPDFはそのためリンクの矩形をテキスト検索からではなく実行の配置されたボックスから直接取得します。計測にレンダリングフォントを使用したため、ボックスはサブセット化に関係なく正確です。ジオメトリはエンコーディングを乗り越えます。テキストはそうではありません。これが計測幅による位置付けの全論拠であり、テキスト検索でリンクを後付けしようとする平坦化ツールがずれたり消えたりするヒットゾーンを生成する理由です

コードから平坦化を実行する

XFAパケットをすでに含むPDFの場合、エントリポイントはFlattenLoadedXFAです。ドキュメントをロードし、メソッドを呼び出し、結果を保存します。Editableパラメータはフォームフィールドへの対応を決めます。Trueを渡すと記入可能なAcroFormウィジェットとして保持され、Falseを渡すとすべてのウィジェットが読み取り専用にマークされて出力が固定レコードになります。スタイル付き実行とリンク注釈を持つリッチテキストのdrawブロックはどちらの場合も生成されます。関数は出力したウィジェットの数を返します

var
  Pdf: THotPDF;
  Emitted, i: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('xfa_appeal_form.pdf');
    // True keeps fields fillable; False freezes them read-only.
    Emitted := Pdf.FlattenLoadedXFA(True);

    // Anything the engine could not map is reported, not raised.
    for i := 0 to Pdf.XFAFlattenWarnings.Count - 1 do
      Writeln('XFA warning: ', Pdf.XFAFlattenWarnings[i]);

    Pdf.SaveLoadedDocument('appeal_form_flat.pdf');
    Writeln('Widgets emitted: ', Emitted);
  finally
    Pdf.Free;
  end;
end;

呼び出し後は必ずXFAFlattenWarningsを読んでください。リストは各平坦化の開始時にクリアされ、エンジンがレンダリングを拒否したすべての要素(サポートされていないフィールドの種類、デコードできないdraw画像、使用可能なスパンがないexDataブロック)の1行が蓄積されます。それらはいずれも例外を発生させないため、空の警告リストはすべてがマッピングされた証拠であり、空でない警告リストはどの元のドキュメントを調べるべきかを正確に示します。XFAをXDPバイトとしてロードされたPDFではなく直接保持している場合、兄弟メソッドApplyXFAAsAcroFormがそれらのバイトを直接受け取り、同じコードパスと同じ警告動作を共有します。補完的なAddXFAPacketメソッドは逆方向で、構築中のドキュメントにXFAパケットを埋め込みます

リーダーで結果を確認する

AcrobatまたはGerminaの現行ビューアで平坦化されたファイルを開いて、2つのことを確認してください。まず、リッチテキストがスタイルを保ったままレンダリングされているか。太字の実行が太字で、色付きの実行がその色を持ち、スパンがボックスをはみ出したり重なり合ったりせずに行に正しい順序で並んでいるか。次に、ハイパーリンクが有効か。アンカーにホバーするとステータスバーにターゲットアドレスが表示され、クリックするとURIアクションが起動するはずです。ビューアの注釈インスペクターを使用して、各リンクが本物の/Link注釈であり、その/Rectがアンカーテキストに密着していて、現在はXFAでレンダリングされたフォームではなく通常の描画グリフの上に配置されていることを確認してください。スタイル付き静的テキストとリンクが適切な矩形上に真のLink注釈として配置されている組み合わせが、平坦化されたドキュメントがもはや必要としないXFAエンジンを超えて存在し続ける理由です

このリッチテキストを取り囲むフィールド自体の平坦化、つまりテキストボックス、チェックボックス、選択リストについては、XFAフォームをAcroFormウィジェットに平坦化するウォークスルーで扱っています。平坦化パスが生成するもの以外のリンク注釈を手動で構築および配置する広い話題については、HotPDFでのPDF注釈の操作を参照してください。どちらもDelphiとC++Builder向けのHotPDF Componentに同梱されている同じ注釈とフォームモデルの上に構築されています