技術記事

DelphiでPDF注釈をXFDFとして入出力する

HotPDFは、現在読み込んでいる文書に作用する2つの関数ExportLoadedAnnotationsToXFDFとImportLoadedAnnotationsFromXFDFを通じて、PDFの注釈をXFDFとして書き出し、また読み込みます。XFDFはISO 19444-1として標準化された注釈交換用のXML形式で、この2つがあればDelphiやC++Builderのプログラムは自分のコメントをAcrobatや他社のレビューツールへ渡し、書き込まれた結果を受け取れます。しかも注釈が乗っているページの内容を書き直す必要はありません

これが解決する2つの方向を思い浮かべてください。レビュー担当者がAcrobatであなたの生成したレポートを開き、位置のずれた図に赤い矢印を落とし、間違った合計に丸を付け、余白に注記を打ち込み、そしてコメントを小さなXFDFファイルへ書き出します。あるいはその逆で、あなたのプログラム自身がマークアップを作り、HotPDFを使っていない相手へそれを届ける必要がある場合です。どちらの向きでも注釈は双方が理解できるXMLとして行き来し、PDFのページは1バイトも変わらないままです

XFDFの往復を示すHotPDFの図。読み込んだPDFが注釈をXFDFファイルへ書き出し、ページはそのままで再び読み込む流れ
1つのXFDFファイルが両方向へコメントを運び、その間PDFのページはバイト単位で同一のままです

FDFとXFDFの違いは何か

FDFとXFDFは同じ中身を2つの異なる構文で運ぶもので、この区別は他のツールへどちらのファイルを渡すかを決める瞬間に効いてきます。FDFはPDF仕様書自身の中で定義された古い方のForms Data Formatです。PDFのオブジェクト構文を使うので、FDFファイルは切り詰めたPDFのように見え、読むにはPDFを理解するパーサーが要ります。XFDFは同じデータをXMLで表したもので、ISO 19444-1として独立に標準化されています。つまりどのプラットフォームのどのXMLライブラリでも、開くことも差分を取ることも生成することもできます。どちらの形式もISO 19444-1の6.3節が定める<fields>のツリーでフォームフィールドの値を、<annots>のツリーで注釈を運べます。HotPDFはこの役割を分け、フォームのデータはExportLoadedFormToXFDFへ回し、ExportLoadedAnnotationsToXFDFは<annots>側のために取ってあります。Webサービス、Javaのレビューサーバー、あるいはスクリプトとコメントをやり取りするとき、相手にPDFパーサーの組み込みを強いない形式がXFDFです

FDFとXFDFを比べるHotPDFの図。同じ注釈の中身をPDFのオブジェクト構文で書くか、ISO 19444-1のXMLで書くかの違い
FDFはPDFのオブジェクト構文を話し、XFDFはXMLを話すので、同じ中身がはるかに多くの読み手へ届きます

DelphiでPDF注釈をXFDFとして書き出すには

HotPDFは読み込んだ文書のすべてのページを歩き、対応している注釈ごとにXFDFの要素を1つ出力し、書き出した注釈の数を返します。まずPDFを読み込み、それから出力先のパスを渡してExportLoadedAnnotationsToXFDFを呼びます。整数の戻り値は直列化された注釈の数です。0以下という結果は何も書き出されずファイルも作られなかったことを意味し、その文書が対応サブタイプの注釈を1つも持っていなかったという合図になります

var
  Pdf: THotPDF;
  Written: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('report-reviewed.pdf', '') > 0 then
    begin
      // 全ページの対応注釈ごとにXFDF要素を1つ書き出す
      Written := Pdf.ExportLoadedAnnotationsToXFDF('comments.xfdf');
      if Written <= 0 then
        ShowMessage('No supported annotations were found');
    end;
  finally
    Pdf.Free;
  end;
end;

出てくるXFDFは、飾りのない読めるXMLです。HotPDFはISO 19444-1の名前空間で<xfdf>のルート、<annots>の容器、そして注釈1つにつき子要素を1つ書き、0起点のページ番号、色、ジオメトリを属性または子要素として添えます。黄色の塗りと開いた矢先を持つ線が、塗りつぶされた多角形の隣にあると、次のように直列化されます

<?xml version="1.0" encoding="UTF-8"?>
<xfdf xmlns="http://ns.adobe.com/xfdf/">
  <annots>
    <line page="0" start="72,700" end="220,700"
          color="#FF0000" interior-color="#FFFF00"
          head="OpenArrow" tail="None">
      <contents-richtext>Baseline looks off</contents-richtext>
    </line>
    <polygon page="0" color="#0000FF" interior-color="#CCE5FF">
      <vertices>72,120;180,120;180,200;72,200</vertices>
    </polygon>
  </annots>
</xfdf>

注釈のサブタイプがXFDFの要素へどう対応するか

注釈のサブタイプはそれぞれ固有のジオメトリの流儀を持つISO 19444-1の特定の要素へ対応し、HotPDFは独自の方式を編み出すのではなくその構造に従います。線の注釈は2つの端点の座標の組を保持するstartとendの属性を持ち、これは注釈のL配列からそのまま取られます。LEの線端スタイルはheadとtailの属性になります。多角形と折れ線の注釈は、点の並びを属性ではなく<vertices>の子要素へ、セミコロン区切りのx,yの組として移します。子要素を期待する読み手は、それ以外の場所に隠された点を黙って落としてしまうからです。いくつもの独立したストロークを持てるインクの注釈は、<inklist>要素の中にストロークごとの<gesture>子要素を入れ子にします。おかげで複数ストロークの署名は1つに混ざった塊ではなく、別々のジェスチャーとして旅を終えます

リッチテキスト、色、境界の装飾もジオメトリと一緒に生き残ります。注記のリッチテキスト本体は<contents-richtext>の子要素として書かれます。PDFがIC配列に格納する内部の塗り、つまり円、四角、多角形、線の矢先の内側の塗りと墨消しの枠の塗りは、#RRGGBB形式のinterior-color属性として渡ります。境界の幅、破線のパターン、雲形の境界効果はwidth、dashes、style、intensityの属性へ対応するので、雲形の輪郭を持つ吹き出しは受け取り側でも雲形のまま読まれます。HotPDFはマークアップ注釈に付いたポップアップウィンドウも保ち、ポップアップの子のジオメトリと開閉の状態を注釈のPopup辞書へ取り込みます。さらにテキスト注釈の開閉状態とレビュー状態も運ぶので、レビュー済みの文書は図形だけでなく、レビュー担当者が頼る作業上のメタデータも保ちます

PDFの線、多角形、インク、リッチテキストの注釈の属性を、対応するXFDFの属性と子要素へ対応づけるHotPDFの図
注釈のサブタイプはそれぞれ独自の方言ではなくISO 19444-1のジオメトリの流儀に従います

読み込み済み文書へXFDFを取り込む

HotPDFはXMLを解析し、要素ごとにNewLoadedAnnotationで新しい注釈を作り、その要素が指名するページへ付け、追加した注釈の数を返すことでXFDFを取り込みます。手順は書き出しと対称です。土台となるPDFを読み込み、レビュー担当者のファイルを渡してImportLoadedAnnotationsFromXFDFを呼び、それから読み込み済み文書を保存して新しいマークアップを残します。ファイルが見つからないかXMLが解析できない場合、関数は0を返し、読み込み済み文書には手が付きません

var
  Pdf: THotPDF;
  Added: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('report.pdf', '') > 0 then
    begin
      Added := Pdf.ImportLoadedAnnotationsFromXFDF('comments.xfdf');
      if Added > 0 then
        Pdf.SaveLoadedDocument('report-annotated.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

XFDFの各要素が自分のページ番号を名指ししているので、複数のファイルを順に取り込んでも注釈は本来書かれたページへ着地します。おかげで1回の保存の前に、複数のレビュー担当者のコメントを同じ読み込み済み文書へ集めても安全です。下の例は2人分を1つの統合コピーへまとめています。注釈のオブジェクトをファイルとしてやり取りするのではなくコードで作って編集する方法は、HotPDFがDelphiからPDF注釈オブジェクトを直接作成し編集するをご覧ください

var
  Pdf: THotPDF;
  Total, I: Integer;
  Files: array[0..1] of string;
begin
  Files[0] := 'alice-comments.xfdf';
  Files[1] := 'bob-comments.xfdf';
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('master.pdf', '') > 0 then
    begin
      Total := 0;
      for I := Low(Files) to High(Files) do
        Inc(Total, Pdf.ImportLoadedAnnotationsFromXFDF(Files[I]));
      if Total > 0 then
        Pdf.SaveLoadedDocument('master-merged.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

きれいに往復するもの、しないもの

HotPDFはISO 19444-1が居場所を与えている注釈のサブタイプを往復させ、それ以外は読み手が誤解しかねないものを出力する代わりに意図的に飛ばします。対応する集合は、実際のレビュー作業の大半を占めるマークアップの型を覆っています。テキストの注記、フリーテキスト、線、四角、円、多角形、折れ線、4種類のテキストマークアップ(ハイライト、下線、取り消し線、波線)、スタンプ、インク、キャレット、それに加えて添付ファイル、サウンド、墨消し、リンクの、全部で18のサブタイプです。この一覧から外れるサブタイプの注釈は書き出し時に見送られ、空で書かれるのではなく飛ばされるので、関数が返す数を水増しすることもありません

正直に断っておくべきなのはリッチテキストです。HotPDFは<contents-richtext>の本体を保つので、装飾されたテキストと素のコンテンツは旅を終えます。しかしXFDFが運ぶのはコメントのテキストとスタイルのマークアップであって、描画済みの外観ストリームではありません。ですから受け取る側のアプリケーションは、HotPDFのピクセルをそのまま再現するのではなく、自分のフォントとレイアウトでポップアップを描き直します。この往復は内容と意図に忠実であって、画面上の描画がピクセル単位で一致するものではないと捉えてください。装飾された内容が注釈のストリームではなくXFAのフォームデータにある場合は規則が異なり、その別の道筋はHotPDFがXFAのexData、リッチテキスト、ハイパーリンクをどう扱うかで扱っています

文字単位の扱いは見た目より厳格で、それがまさに望ましいところです。HotPDFはテキストを書くときISO 19444-1の5.8.2節のエスケープ規則を適用し、XML上意味を持つ文字と制御バイトを符号化します。おかげでアンパサンドや山括弧や改行を含むコメントも、規格に従うどのパーサーも受け付ける整った形のXMLになり、取り込み時には同じ規則が逆にたどられます。表計算ソフトから貼り付けた注記が、句読点もろとも壊れずに戻ってくるのはそのためです

注釈の交換は読み込み済み文書向けAPIの一断面にすぎず、残りとも組み合わさります。レビュー担当者のXFDFを取り込み、ページを調整するか文書のメタデータを編集し、ファイルを平坦化するか権限を付け直し、それから次の回に向けて新しいXFDFを書き出す、という具合です。そのすべてがDelphiとC++Builder向けの標準のHotPDF Delphiコンポーネントに入っており、そのリファレンスには対応する注釈サブタイプの全体像と、対になるフォームデータ用XFDF関数が記載されています