技術記事

カンマ小数点環境のDelphiアプリ向けロケール非依存PDF数値

Delphi向けのlosLab PDF Developer Library、PDFlibPasは、コンテンツストリームに書き込むすべての数値を、Windowsの地域設定が何であれ、ドットの小数点記号、指数なしで書き出します。v3.539.26から、AddPageMatrix、ScalePage、DeskewPage、RedactRegion、テキストからパスへの出力、そして再着色は、オペランドをPLDoubleToStrConstで整形します。さらにv3.539.33からは、それらの数値を読み戻すパーサーが、システムロケールの代わりにPLTryStrToFloatInvariantを使います。ドイツ語、フランス語、ブラジルのロケールのマシンでも、同じコードが米国のマシンと同じバイトを出します。ファイルフォーマットが許容できるのは、この挙動だけです

カンマ小数点のロケールはなぜエラーなしにPDFを壊すのか

カンマ小数点のロケールがPDFを黙って壊すのは、カンマがPDF構文では数値文字ではないからです。損傷は、意味の違う有効なトークンとして読めてしまいます。修正前のPLFloatToStrは、素のFloatToStr呼び出しにほかなりませんでした。そしてFloatToStrはFormatSettings.DecimalSeparatorに従います。カンマ区切りの環境では、AddPageMatrix(0.5, 0.5, 0, 0)が0,5 0 0 0,5 0 0 cmと書いていました。ISO 32000-1 §7.3.3が数値に許すのは、数字、1つのピリオド、先頭の符号、それだけです。コンテンツパーサーはこの行を、数値0とそれに続く未知のトークン,5として読み、cm演算子は間違ったオペランドを手にします。何もraiseせず、何もログしません。ページは、ずれた変換マトリクスのままただ描画されます。位置のずれた図形からロケール設定へ逆算するのは、惨めな午後です

2つ目の欠陥は1つ目の後ろに隠れていました。FloatToStrはffGeneralフォーマットを使い、桁が1E-4を下回ると指数表記へ切り替わります。そのため微小なオフセットは1E-5として出てきました。同じ§7.3.3は、PDFは指数形式をサポートしないと述べています。つまり米国ロケールのマシンでも、十分に小さい値さえあれば無効なオペランドを書けたのです。このリリースのリグレッションテストは両方の失敗形状を固定します。区切り文字をカンマへ切り替え、APIを呼び、できあがったコンテンツを走査して、カンマや指数を含むトークンがないか調べます

uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  OldSeparator: Char;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetPageDimensions(300, 200);
    Lib.DrawBox(10, 10, 20, 20, 1);
    OldSeparator := FormatSettings.DecimalSeparator;
    try
      FormatSettings.DecimalSeparator := ',';   // de-DEのデスクトップをシミュレート
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26以降の出力:0.5 0 0 0.25 0.00001 12.75 cm
      // 旧ビルドの出力:        0,5 0 0 0,25 1E-5 12,75 cm
      Writeln(Lib.GetPageContentToString);
    finally
      FormatSettings.DecimalSeparator := OldSeparator;
    end;
  finally
    Lib.Free;
  end;
end;
カンマ小数点デスクトップ上のPDFlibPasのAddPageMatrixは修正前、0,5 0 0 0,25 1E-5 12,75 cmと書いていました。PDFパーサーはこれを数値0と未知のトークンとして読み、cmは間違ったオペランドを持ち、ページは黙って変形します。PLDoubleToStrConstは有効なドット小数を書きます
カンマはPDF構文では数値文字ではありません。だから損傷は、意味の違う有効なトークンとして読めてしまいます。しかも1E-5のようなffGeneralの指数は、カンマロケールに限らず、どのロケールでも無効でした

2種類の数値、2系統のヘルパー

PDFlibPasの修正は厳格な分離です。人に見せる数値はロケールに従ってよく、マシンのために書く数値は決して従いません。PLFloatToStrとPLStrToFloatはユーザー向けテキストのためにPDFlibExtra.pasに残り、その宣言にはまさにその旨のコメントが付きました。PDF構文になるものはすべて、仕事に応じて固定の小数桁数を選んだPLDoubleToStrConstを通ります。マトリクスは6桁、座標とTJの調整値は4桁、色とFDF矩形は3桁です。v3.539.26の監査が触れた呼び出し箇所は、元のバグレポートが示唆したより多くなりました:

  • AddPageMatrix、ScalePage、DeskewPage。いずれも既存のページコンテンツの先頭へcmを前置します
  • ページ要素ビルダー。Tmのリセット、TJの進行、cmの変換を出力します
  • テキストからパスへのコンバーターにおけるグリフ配置マトリクスとアウトラインの点
  • RedactRegionが前置する黒塗りボックス、FDFエクスポートの/Rectの値、再着色が書くオペランド

PLDoubleToStrConstはFloatToStrFのラッパーではなく手書きのフォーマッターで、ここに関わる性質が3つあります。常にピリオドを書き、末尾のゼロを剥ぐので、0.5は0.500000ではなく0.5のままです。有限な入力には決して指数を書きません。そして要求精度より小さい非ゼロの値は、ゼロへ潰れるのではなく有効数字を保持します。PLDoubleToStrConst(1E-9, 6)は0.000000001を返し、およそ5E-16未満の値だけが0になります。最後のルールが存在するのは、微小なスケール係数をゼロへ丸めると、有効な行列が特異行列になるからです。直しているバグより悪い結果です

PDFlibPasは数値整形を2つに分けます。PLFloatToStrとPLStrToFloatはユーザー向けテキストのためにロケール依存のまま、PLDoubleToStrConstとPLTryStrToFloatInvariantはPDF構文になるものすべてを、ドット、指数なし、仕事ごとに固定の6、4、3桁の精度で整形します
不変のフォーマッターが手書きなのは意図的です。末尾のゼロを剥ぎ、決して指数を書かず、微小な値の有効数字を保持します。スケール係数をゼロへ丸めると、有効な行列が特異になるからです
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // マシン出力:ドット小数、指数なし、末尾ゼロは剥ぐ
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // 有効数字4桁を保持
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // マシン入力:EConvertErrorではなくソフトに失敗
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // コンテンツの数値にカンマは使われない
end;

読み込み側が書き出し側より危険な理由

読み込み側がより危険なのは、ロケール依存のパーサーが間違った数値を出すのではなく、throwするからです。PLStrToFloatはStrToFloatを呼び、テキストがシステムの区切りに一致しないとEConvertErrorを上げます。カンマ小数点のシステムでは、RecolorPageはありふれた0.5 g演算子に出会った瞬間に中断しました。変なページではなく、実在するすべてのページが失敗します。RenderPageRegionToFileは自分のドキュメントにあるクリップ形式"10.5,20.5,50.5,40.5"を拒否し、SVGの長さ属性、SVGエクスポートの色、注釈の頂点リスト、出力インテントのsolidity値は、拒否されるか黙ってデフォルトへ置き換わっていました。開発者のマシンでは完璧に動き、ミュンヘンの最初の客で落ちるライブラリは、偶然動いているDelphiコードについての記事のケースと同じく、テストした場所のおかげで正しく見えていただけの類のコードです

v3.539.33は、すべてのStrToFloatとTryStrToFloatの呼び出しを、入力の出所で分類しました。コンテンツストリームのオペランド、SVGの属性、ペインターの色文字列、カンマ区切りのクリップと頂点リストは、どれも固定のドット構文を持つため、PLTryStrToFloatInvariantを通ります。これはテキストをtrimし、PLInvariantFormatSettingsでパースし、空、不正、非有限の入力にはraiseの代わりにFalseを返します。カンマ区切りのリストに妥協の余地はありません。カンマはリストの区切りと小数点を両方務められないからです。同じパスで範囲外書き込みも直りました。RenderPageRegionToFileはかつて4要素のバッファーの先へ、5つ目のクリップ値を格納していました。PDFを1つのカラースペースへ変換するガイドで述べる再着色パイプラインにとっての実用上の結果は、RecolorPageとRecolorDocumentがカンマ小数点のシステムで中断しなくなったことです。呼び出し側がCheckDocumentPolicyへ入力するルール値だけは、寛容なヘルパーを使うパースのケースとして残ります。理由は次のセクションで

往復の片側だけ直すと何が起きるか

ロケールの往復の片側だけ直すと、動いていたコードが壊れます。だからこそv3.539.32の構造属性の変更は、ライターとリーダーを一緒に動かしました。SetStructElem*の各ラッパーは数値を文字列として中継します。SetStructElemBBoxは4つの値を1つの文字列へ整形し、AddTagAttribute経由で格納し、/Aのライターが後でその文字列をパースして、数値になるか配列になるか名前になるかを決めます。両端がシステムロケールを使っていたため、カンマ小数点のシステムでは往復は自己整合していました。バグが顔を出したのは、呼び出し側がドキュメントどおり"0.5"をAddTagAttributeへ渡したときだけです。リーダーはパースできず、PDF名の/0.5を出力しました。PDF/VCRのプレースホルダーは鏡像の問題を抱えていました。ライブラリがGTS_BBoxをドット付きで生成し、保存前にロケールで検証していたからです

ライターだけドットへ変えるのは、何もしないより悪かったはずです。すべてのSetStructElem*の値がロケール依存のリーダーの検査に落ちて、名前へ格下げされるからです。そこでライターは現在PLDoubleToStrConst(v, 6)を使い、リーダーは新しいPLTryStrToFloatLenientを使います。こちらはドット形式を先に試し、システムロケールへフォールバックします。かつて"1,25"を渡していたカンマロケールの呼び出し側は、今も数値1.25を受け取ります。トレードオフは意図的で、ドキュメント化されています。ドイツ語のシステムでは"1.500"はかつて名前になっていました。StrToFloatは桁区切りを拒否するからです。今は1.5として読めます。一方、リテラルのNANとINFの文字列は、もはや数値として受け入れられません

PDFlibPasのSetStructElemBBoxとその仲間は数値を文字列としてAddTagAttribute経由で中継し、/Aのライターがその文字列をパースし戻します。v3.539.32は両端を一緒に動かしました。PLDoubleToStrConstがドット付きで書き、PLTryStrToFloatLenientがドット優先、ロケールフォールバックで読むため、カンマロケールの1,25も今は1.25として読めます
ライターだけ直すと、構造化要素の属性がすべてPDF名へ格下げされていたはずです。往復は両端を一緒に動かすか、まったく動かさないか、どちらかなのです
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // カンマ小数点の呼び出し側
  Lib := TPDFlib.Create;
  try
    Lib.BeginTag('Figure', 'Sales chart', '');
    Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
    Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5');   // /SpaceAfter 0.5, was /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // still /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

NaNと無限大を止める場所

AddPageMatrix、ScalePage、RedactRegionは現在、NaNと無限大の引数を入口で拒否して0を返します。PDFの数値では表現できないからです。ScalePageはもともとゼロ以下の係数を拒んでいましたが、NaNは<= 0のテストを素通りします。NaNのスケールはフォーマッターまで旅を続けていました。v3.539.26の時点でそのフォーマッターはNaNにRoundを呼び、x87ユニットが不正演算をマスクしないWin32ではEInvalidOpを上げます。v3.539.31はPLDoubleToStrConstに、NaNへ0を書かせる最後の防衛線を与えました。ただし行列内のゼロは特異な変換なので、APIレベルの検査こそが本当の修正です。2つの境界は意図的にそのままです。メタファイルの状態文字列は1つのプロセス内でロケール付きのまま書かれ、読まれ、外へ出ません。だから放置です。そしてページ要素経路で1E-5を整形するテストは、レイヤーが書き直される前にコンテンツを読まなければなりません。ドキュメント精度でオペランドを再出力すると、その値が正当に0へ変わるからです

アプリがドット小数点の世界の外の客へ出荷されるなら、一番安全な習慣は、PDFlibPasのテストスイートが今使っているものです。PDF生成経路を一度、FormatSettings.DecimalSeparatorをカンマに設定して走らせ、出力を走査してカンマと指数を探します。パース済みの小数精度を保存する記事は同じ話のもう半分、既存ファイルから読んだ数値が保存時に正確なテキストを保つ方法を扱います。ダウンロード、APIリファレンス全文、トライアルビルドは、PDFlibPas Delphi PDF library product pageにあります