v3.539.30より前、losLab PDF LibraryのTPDFlib.ImportAnnotationsFromFDFStringは、パースしたFDF注釈エントリーの数を返しながら、ドキュメントには1つも追加していませんでした。数えた全エントリーが、全エントリー捨てられていたのです。v3.539.30からは、FDFインポーターはキーをどの順序でも読み、/Rectをロケールに依存せず正しくパースし、対応するエクスポーターは注釈の実際の/Rectを書き出します。そのためエクスポート、インポート、2度目のエクスポートで、バイト単位で同一のFDFが得られます。このノートの残りでは、1つの間違った開始オフセットがどうして完璧なサイレント失敗を生んだのか、その後ろにどの3つの欠陥が隠れていたのか、そして戻り値を信用する代わりにインポートを自分で検証するにはどうするかを説明します
シナリオはごく普通です。レビュアーが契約書に書き込みを入れ、コメントはFDFファイル(AcrobatではExport Comments)で運ばれ、あなたのDelphiサービスがImportAnnotationsFromFDFでクリーンなコピーへマージする。呼び出しは7を返し、ログには「7 comments imported」と出て、ジョブは緑になり、出力PDFにはコメントが1つもない。何もraiseされず、何も警告されず、その数字はもっともらしく見えます。ファイル内エントリーの本当の数だったからです。バグの取り得る最悪の形です。成功の唯一のシグナルが、報告対象の処理とは独立に計算されるカウンターであるような関数です
なぜImportAnnotationsFromFDFStringは成功を報告したのに何も追加しなかったのか
インポーターはすべての/Subtypeを空文字列として読み、注釈を生成するヘルパーは空のサブタイプで早期リターンし、一方呼び出し側は結果をインクリメントしていました。キーファインダーは/Subtypeの直後の位置、つまり値の前の空白を返します。ReadNameはその空白から始まり、最初の空白文字で止まるので、何も読む前に止まっていました。AddAnnotationToPageはサブタイプのない注釈の生成を拒否します。それ単体では正しい防御的選択ですが、戻り値のないprocedureで、Inc(Result)はその外側に置かれていました。それぞれのガードは単独では合理的です。組み合わさると、「何も動いていない」を「全部動いている」へ変換したのです。修正後のReadNameは空白をスキップし、PDF名オブジェクトの先頭の/を要求し、[、(、)を含むあらゆるデリミッターで止まります。そのため/Subtype/Textも/Subtype /TextもTextを返します
戻り値には、この修正の後でも注意が必要でした。v3.539.39まで、ImportAnnotationsFromFDFStringは/Annots配列内の整形された辞書ごとに結果をインクリメントし続けていました。0ベースの/Pageが範囲外のエントリーや、/Subtypeのないエントリーも含まれます。どちらも実際にはスキップされるものです。PDFlibPas v3.539.40からは、ImportAnnotationsFromFDFStringとImportAnnotationsFromFDFは、XFDFインポートと同じく実際に追加された注釈の数を返します。FDFヘルパーのAddAnnotationToPageはBooleanを返すようになり、カウンターは成功時だけ動きます。とはいえドキュメントを測る方が強い検査です。古いバージョンでも成り立つからで、下のスケッチはインポート前後で各ページのAnnotationCountを比較します
function TotalAnnotations(Lib: TPDFlib): Integer;
var
Page, Saved: Integer;
begin
Result := 0;
Saved := Lib.SelectedPage;
for Page := 1 to Lib.PageCount do
if Lib.SelectPage(Page) = 1 then
Inc(Result, Lib.AnnotationCount); // 選択中のページごと。ウィジェットも含む
Lib.SelectPage(Saved);
end;
var
Lib: TPDFlib;
Before, Reported, Added: Integer;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('contract.pdf', '');
Before := TotalAnnotations(Lib);
Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
Added := TotalAnnotations(Lib) - Before;
if Added <> Reported then // v3.539.40以降は一致する
Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
Lib.SaveToFile('contract-reviewed.pdf');
finally
Lib.Free;
end;
end;
最初のバグの後ろに隠れていた3つの欠陥
サブタイプだけ直しても、同じ関数の中にさらに3つのバグが露呈したはずでした。どれも、注釈が1つもページに届かなかったおかげで見えなかっただけです。1つ目、ReadNumberは位置を値渡しパラメーターで受けていたため、/Rectの4つの数値を順に読む処理が同じ場所を4回読みました。しかも先頭の[をスキップしないので、実質的には何も読めていません。2つ目、FindKeyはすべての検索で1本の前進カーソルを共有していました。エクスポーターは/Subtype、/Rect、/Page、/Contents、/T、/Subjの順に書きますが、インポーターは/Subtype、/Contents、/T、/Subj、/Page、/Rectの順に検索します。カーソルが/Contentsを通り過ぎた後は、/Pageと/Rectの検索が現在のエントリーの先まで走り、何も見つからないか、次の注釈のキーにマッチします。ライブラリが自分の出力を読めなかったのです。3つ目、数値はシステムの小数点記号に従うPLStrToFloatを通っていました。ISO 32000-1 §12.7.7はFDFをPDFオブジェクト構文と定義し、PDFの辞書キーに順序はありません(§7.3.7)。キー順を仮定するFDFパーサーは、ファイルを作ったツールがどこであれ、構造的に間違っているのです
修復済みのインポーターは、まず各エントリーの範囲を確定します。FindDictEndは開きの<<から対応する>>まで歩き、ネストした辞書を追跡し、バックスラッシュエスケープ付きのリテラル文字列の本体をスキップします。そのため(see section >> 4)のようなコメント内の>>が、エントリーを早く終わらせることはありません。以後のすべてのキー検索はエントリー自身の開始から始まり、その終わりに限定されます。キー順は無関係になり、ある注釈が別の注釈の/Pageを借用することもありません。キーマッチは名前の直後にデリミッターが来るものも受け入れます。/Contents(Hi)は/Contents (Hi)と同様に正しいためです。一方、単語境界のルールは、/Subjが/Subtypeの先頭に、/Tが/Typeにマッチするのを防ぎます。ReadNumberは位置をvarパラメーターで受け取り、空白と[をスキップし、PLTryStrToFloatInvariantでパースします。これは不正なトークンでraiseするのではなく、ソフトに失敗します。4つの矩形数値のどれかが失敗したら、中途半端な矩形を作るのではなく、4つすべてがゼロへフォールバックします
FDFの往復でなぜ注釈が自分の高さぶんずれるのか
旧エクスポーターは、間違った座標モデルで矩形を書いていました。注釈の/Rectはデフォルトユーザー空間の[llx lly urx ury]です(ISO 32000-1 §12.5.2、矩形は§7.9.5で定義)。FDFも同じ配列を運びます。ところがExportAnnotationsToFDFStringはGetAnnotRectExを呼んでいました。これはライブラリの描画座標、つまりSetOriginが制御する空間でLeft、Top、Width、Heightを報告するもので、それを[L T L+W T+H]として直列化していたのです。動くようになったインポーターは、その4つの値をそのままPDF矩形として書き戻します。上端が左下隅のあるべき場所に来て、往復するたびに注釈が自分の高さぶん上へ動きました。エクスポーターは現在、注釈自身の/Rectの数値を小数3桁、ドット区切り、指数なしでコピーし、保存された配列がないか4つの数値でない場合にだけ、計算した矩形へフォールバックします
これを固定するリグレッションテストは、そのまま真似する価値があります。インポーターの戻り値ではなく、ドキュメントと2度目のエクスポートにアサートするからです。期待カウントが2なのに注意してください。AddNoteAnnotationはText注釈とそのPopupを生成し、両方が運ばれます。テストはさらに、カンマ小数点の環境下でエクスポートとインポートを実行します。話のもう半分はここにあるのです
var
Source, Target: TPDFlib;
FDF: AnsiString;
OldSep: Char;
begin
Source := TPDFlib.Create;
Target := TPDFlib.Create;
try
Source.NewPages(1); // これで2ページになる
Source.SelectPage(2);
Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
Target.NewPages(1);
OldSep := FormatSettings.DecimalSeparator;
FormatSettings.DecimalSeparator := ','; // ドイツ語やフランス語のデスクトップをシミュレート
try
FDF := Source.ExportAnnotationsToFDFString; // それでも/Rect [50.5 ... と書く
Target.ImportAnnotationsFromFDFString(FDF);
finally
FormatSettings.DecimalSeparator := OldSep;
end;
Target.SelectPage(2);
Assert(Target.AnnotationCount = 2); // 注釈とそのポップアップ
Assert(Target.GetAnnotType(1) = 'Text');
Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
finally
Target.Free;
Source.Free;
end;
end;
FDF経路が何を運ぶかは、はっきりさせておきましょう。インポーターは各エントリーを/Type、/Subtype、/Rect、/Contents、/T、/Subjを持つ辞書として再構築します。色、フラグ、ボーダースタイル、ポップアップへのリンク、アピアランスストリームはこの経路の対象外で、エクスポーターはWidget注釈をスキップします。フォームフィールドはフォームデータ用のメソッドの担当だからです。どのデータがどのメソッドを通るかの全体像は、FDF、XFDF、XFAフォームデータインターチェンジの概説にあります。実際に届いたものを検査したい場合は、GetAnnotType、GetAnnotTitle、GetAnnotContentsExといったインデックス単位のリーダーを、アウトライン、注釈、アクションのイントロスペクションで扱っています
古いエクスポートのカンマ小数点FDFやXFDFファイルはどう読むか
FDFについては答えは明快です。カンマはPDF構文ではデリミッターではありません。したがって、カンマをちょうど1つ含みドットを含まない数値トークンは、カンマロケールのマシンで書かれた小数でしかあり得ません。以前のバージョンは実際にそのようなファイルを書いていました。たとえば/Rect [10,500 20,250 40,750 60,125]です。新しいReadNumberは、パース前にその1つのカンマをドットへ置き換えます。カンマ2つ、あるいはカンマとドットを持つトークンは、推測ではなく拒否されます。リーダーは指数表記も消費しません。ISO 32000-1 §7.3.3のとおり、PDFの数値は指数を使わないからです
XFDFは厄介です。XML属性ではカンマが区切りだからです。標準のXFDF(ISO 19444-1)はrect="50.5,80.25,70.75,100.125"やdashes="4,2"と書きます。一方、v3.539.28以前はカンマロケールのシステム上でrect="50,500 80,250 70,750 100,125"やopacity="0,600"と書き、標準のopacity="0.6"を読むときにはEConvertErrorで失敗していました。v3.539.29からは双方向とも不変で、レガシーな形状はXFDFNormalizeLegacyDecimalsだけが認識します。ただし属性が空白で分割されて期待どおりのトークン数(rectなら4、opacityとwidthなら1)になり、すべてのトークンが数字-カンマ-数字の形をしているときだけです。標準のrectは決してマッチしません。カンマ3つを含む1トークンか、カンマで終わるトークンのどちらかです。dashesは意図的に放置します。4,2は2つのダッシュ長かもしれないしレガシーの4.2かもしれず、両者を区別できるルールは存在しないからです
const
// エクスポーター順と違うキー順、さらに古いカンマロケール環境のカンマ小数
LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
'<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
'/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
'] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create; // 新規ドキュメントには1ページある
try
Lib.ImportAnnotationsFromFDFString(LegacyFDF);
Assert(Lib.AnnotationCount = 1);
Assert(Lib.GetAnnotTitle(1) = 'Alpha');
// ドット小数のXFDFとして再エクスポート:rect="10.500 20.250 40.750 60.125"
Writeln(Lib.ExportAnnotationsToXFDFString);
finally
Lib.Free;
end;
end;
注釈インポートのテストは何をアサートすべきか
有用なインポートテストは、ターゲットドキュメントの状態にアサートします。インポーター自身の申告だけを見てはいけません。テストスイートのどこもFDFインポート後のAnnotationCountをチェックしておらず、誰もが見ていた唯一の数字である戻り値こそが、このバグが傷つけなかった唯一の数字でした。ここで述べたすべての欠陥を捕まえられたアサーションは3つです。期待ページの注釈カウント、GetAnnotTypeかGetAnnotContentsExで読み戻す1つのフィールド、そして1度目とバイト単位で比較する2度目のエクスポート。この規律は、ドキュメント構造を一括で書き換えるあらゆるAPIに当てはまります。重複フォームフィールドのマージで述べた統合もその一例です。返ってくる合計ではなく、結果のツリーを検査してください。FDFとXFDFの注釈メソッドは、ファイル版と文字列版のバリアントとともに、losLab PDF Library for Delphi and C++Builderに同梱されています。コメントを往復で生き残らせたいならv3.539.30以降を、返されるカウントが追加実数と一致してほしいならv3.539.40以降を選んでください