PDF Library for Delphi(PDFlibPas)は、v3.539.31からすべてのPDF数値に対して有効なJSONを出力します。GetObjectJSONは、ISO 32000-1が受け入れRFC 8259が拒否するトークン、たとえば-.25、+1.5、007.5を、1桁ずつ-0.25、1.5、7.5へ書き直します。GetDocumentJSONと解析レポートはNaNとInfinityにnullを書き込み、PLDoubleToStrはNaNに対して、エクスポートの途中でEInvalidOpを上げる代わりに0を書きます。修正前は、ライブラリが生成したJSONを、ライブラリ自身のリーダーがロード拒否するありさまでした
有効なPDF数値がなぜJSONを壊すのか
2つの文法は4つの小さな細部で食い違い、ソーステキストを尊重するPDFパーサーは、その細部を出力へそのまま運ぶからです。ISO 32000-1 §7.3.3は、数値がプラス符号で始まること、整数部を省略すること(.5)、素の点で終わること(4.)、先行ゼロを持つこと(007.5)を許します。RFC 8259 §6はどれも許しません。省略可能なマイナス、0か1から9で始まる整数部、小数点の後に最低1桁。プロデューサーはPDF形式を自由に書けますし、実際、多くのジェネレーターと手編集のファイルが書いています
漏れは、意図的な精度機能から来ました。v3.539.19以降、TPDFNumeric.Outputは実数についてトークナイザーがパースした正確なテキストを返します。これは保存時に校正済みの色値を正確に保つためのものです。パース済みPDF小数精度の保存で述べたとおりです。トークナイザーは入力の途中で.5を0.5へ、4.を4.0へすでに修正し、整数は値から再整形されるので、+3は3として戻ります。そのまま生き残るのは残りです。符号付きの先頭の点(-.25)、実数の明示的なプラス(+1.5)、先行ゼロ(007.5)。旧オブジェクトライターはOutputを"value":の直後に付け加え、ライブラリ自身のリーダーのTJSONParser.ParseNumberは、そのどれに対しても「Invalid JSON number」で止まります。エクスポートは成功し、再インポートはPDFLIB_ERROR_OBJECT_JSON_INVALID(105)で失敗したのです
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
JSON: AnsiString;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('legacy-drawing.pdf', '') = 0 then
raise Exception.Create('load failed');
// オブジェクト12は[-.25 +1.5 007.5]と書かれた配列
JSON := Lib.GetObjectJSON(12, 0);
// v3.539.31以降:値は-0.25、1.5、7.5として届く
// SetObjectJSONにオプションはないので0を渡す
if Lib.SetObjectJSON(12, JSON, 0) = 0 then
raise Exception.CreateFmt('round trip rejected, error %d',
[Lib.LastErrorCode]);
Lib.SaveToFile('legacy-drawing-roundtrip.pdf');
finally
Lib.Free;
end;
end;
PDFNumberTextToJSONがすべての桁を保つ方法
PDFNumberTextToJSONは、Doubleから再計算するのではなく、トークンを綴り直します。PDFlibObjectJSONのこの関数は省略可能な符号を読み、1つの小数点の前後の数字を集め、JSONが要求する編集だけを適用します。プラスを落とし、先行ゼロを1つ残して剥ぎ、整数部が空なら0を補い、素の末尾の点を落とし、マイナスを戻します。その他の文字を含むトークンや、数字を1つも含まないトークンはPLJSONNumber(Value, 10)へフォールバックします。これは値が有限でないときnullを書きます
-.25は-0.25に、+.5は0.5になります+1.5は1.5になります007.5は7.5になりますが、0.75はそのままです4.は、そのようなトークンがライターへ届けば4になります2.22221と1.250000は、末尾のゼロも含めすべての小数桁を保持します
保存されたDoubleから整形する方が短くて、間違いでした。精度修正が存在するのと同じ理由です。デフォルトの出力精度は小数4桁で、全精度の変換ですら10進リテラルに2進ノイズを加えかねません。桁を保てば、SetObjectJSONとImportObjectJSONは、各JSON数値テキストをPDFトークナイザーへ渡すため、まったく同じ値を再現します。保証はバイトでなく値についてです。再インポートの後、-.25は-0.25として格納、保存されます。両方の綴りは§7.3.3のもとで等しいものの、バイト単位のdiffはこの変更を検出します。署名に覆われたバイトを持つドキュメントで、エクスポートとインポートのサイクルをno-op扱いしないでください
JSONが表現できない数値はどうなるか
GetDocumentJSONは現在、NaNか無限大の数値すべてにnullを書きます。RFC 8259 §6にはどちらの構文もないからです。Infinityは思うより簡単に作れます。PDFトークナイザーは数字を繰り返し乗算でDoubleへ蓄積しますが、これは約1.8 × 10308で頭打ちです。300桁強の整数リテラルは黙って+Infになります。誠実なファイルにそんなリテラルは入りません。ファズされた敵対的なファイルには入ります。PascalのPDFパーサーを悪意あるファイルに対して堅牢化するのケースと同じテストコーパスに入れるべき理由です。旧ドキュメントライターは非整数をStr(D:0:6)で整形し、+Infには+Infというテキストを書きました。JSONコンシューマーでパースできるものはありません
nullは意図的に有損です。GetDocumentJSONの出力のコンシューマーは、数値が現れうるすべての場所でnullを受け入れなければなりません。そして欠落したキーではなく、「値は存在したが表現できない」と読むべきです。元のリテラルはドキュメントJSONから復元できません。これを気にするパイプラインは、デフォルト値へ置き換えるのではなく、オブジェクトをログに残してファイルを疑わしいものとして扱うべきです
NaN 1つでなぜSVGやJSONのエクスポートが中断できたのか
PLDoubleToStrは、ライブラリのコンテンツストリーム、SVG、XML、CSV、そしてほとんどのJSONの背後にある不変の数値フォーマッターですが、入力をスケールしてRoundを呼んでいました。Win32のようなターゲットではDelphiがx87のinvalid-operation例外をマスクしないため、Round(NaN)はEInvalidOpを上げます。例外はライターが出力の一部を出し終えた後に発火しました。1つの縮退した測定値、メトリックの0/0、呼び出し側が渡したNaNが、途中までのファイルを残したのです。PLDoubleToStrは現在、NaNに対して0を返します。整数分岐も±9.2e18へクランプするので、Infinityも有限のリテラルとして出ます
コンテンツストリームにとってゼロは正しい答えです。数値スロットは数値を保持しなければならないので。レポートにとっては間違った答えです。0はもっともらしい測定値だからです。違いを保ちたいJSONライターは、PDFlibExtraのPLJSONNumber(Value, Decimals)を使います。NaNやInfinityにはnullを書き、それ以外は不変の桁を書きます。PLJSONNumberは現在、GetSimilarImageDeduplicationReportJSON、GetAnnotationHitsJSON、そしてバーコード、デスキュー、構造化テキスト、PDF/VCRの各レポートを支えています。デスキューレポートはかつて非有限の角度に0を書いていましたが、今はnullを書きます
uses
SysUtils, PDFlibTypes, PDFlibExtra;
function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
B: PLStringBuilder;
begin
B := PLStringBuilder.Create(128);
try
// すべてのDoubleを先にテキストへ整形。PLJSONNumberは
// NaNやInfinityにnullを書き、常に小数点を使う
B.Append('{"page":').Append(Page)
.Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
.Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
.Append('}');
// B.Append(Angle)は禁物:Doubleのオーバーロードはユーザーロケールに従う
Result := B.ToString;
finally
B.Free;
end;
end;
ユーザーロケールはまだどこからJSONへ忍び込むか
地域設定に問い合わせるあらゆるフォーマッターを通ってです。そして機械可読出力の全監査は、ちょうど1つを残して見つけました。GetSimilarImageDeduplicationReportJSONのmaxAcceptedMeanErrorです。FloatToStrの薄いラッパーであるPLFloatToStrで書かれていました。小数点記号がカンマのデスクトップでは、レポートは"maxAcceptedMeanError":1,5を含みます。JSONパーサーはこれを値1と迷子のトークンとして読みます。このフィールドは、知覚画像重複排除の受け入れた最悪ピクセル誤差を報告するもので、現在はPLJSONNumber(Stats.MaxAcceptedMeanError, 6)を通ります。残る罠はPLStringBuilderです。DelphiではこれはSystem.SysUtils.TStringBuilderの素のエイリアスで、そのAppend(Double)オーバーロードはユーザーロケール経由で整形します。一方FPCビルドはライブラリのクラスを使うため、Free Pascalやen-USのマシンでのテストには決して引っかかりません
uses
System.SysUtils, System.JSON, PDFlibrary;
var
Lib: TPDFlib;
Report: WideString;
Parsed: TJSONValue;
begin
// テスト実行内でドイツ語やフランス語のデスクトップを再現
FormatSettings.DecimalSeparator := ',';
Lib := TPDFlib.Create;
try
// ほぼ重複する画像を本当に含むフィクスチャを使うこと、
// そうしないと平均誤差は0のまま、バグは隠れたまま
Lib.LoadFromFile('scanned-batch.pdf', '');
// しきい値2、2、4のドライラン:ドキュメントは変更されない
Lib.GetSimilarImageDeduplicationReportJSON(2, 2, 4, Report);
Parsed := TJSONObject.ParseJSONValue(Report);
if Parsed = nil then
raise Exception.Create('report is not valid JSON on a comma locale');
Parsed.Free;
finally
Lib.Free;
end;
end;
JSON出力のリグレッションスイートが正直であり続けるには、3つのフィクスチャが要ります。-.25、+1.5、007.5を運ぶページ、400桁の整数を保持するオブジェクト、カンマロケール下で走らせた任意のレポート。それぞれ目視ではなく厳格なパーサーで検証します。PDF Library for DelphiのオブジェクトJSON、ドキュメントJSON、解析レポートは、Delphi、C++Builder、Free Pascalを通じて同じ数値ルールを共有します。機能一覧の全体は、PDF Library for Delphi product pageにあります