技術記事

HotPDFでDelphiのPDF数値フォーマットをInvariantに保つ

PDFファイルに到達するすべての実数は、オペレーティングシステムが小数点セパレータをどう思っていようと、ピリオドで書かれなければなりません。HotPDF Delphi ComponentはそれらすべてをHPDFTypesの1つのInvariant変換レイヤーに通します。そのためドイツ、フランス、ブラジルのワークステーションでも0.5 0 0 0.5 5.5 7.25 cmを出力し、適合するリーダが絶対にパースしないカンマ区切り版は出力しません

2行の修正に聞こえます。実際は違いました。この欠陥は副作用によって何年もカモフラージュされ、誰も数値フォーマットコードとは思わない3つのサブシステムに再発し、あるコンパイラでは消えて別のコンパイラでは生き残りました。そして最後の層は小数点セパレータとはまったく無関係でした

小数点カンマのロケールがPDFコンテンツストリームを壊すのはなぜか

ISO 32000-1 §7.3.3は数値オブジェクトを、オプションの符号、10進数字、そして最大1つのピリオドと定義するからです。カンマはその生成規則の一部ではなく、§7.2.2のもとではデリミタでもありません。だからリーダは意図した1つのオペランドではなく、別のものを見ます。レックスに失敗するトークンが見え、それを含むオペレータは破棄されます。グラフィックスステート行列の中の単一の0,5が、その周りのq ... Qブロック全体を破壊します。この失敗はフィールドで特徴的な形をとります。開発者のマシンでは決して再現せず、1つの国からのサポートチケットとして届き、構文エラーではなくレンダリングバグとして読まれます。ほとんどのビューアが不正なオペレータを黙って落として残りのページを描画するからです

HotPDFが生成するPDFコンテンツストリームでのロケール破損の図。小数点カンマのホストプロセスはrgとcmオペレータの内側に0,5を出力し、ISO 32000-1の数値文法はピリオドしか受け付けないためトークンはレックスに失敗し、ビューアは周囲のq Qブロックごとオペレータを黙って破棄します
カンマは数値の生成規則の一部でもデリミタでもないため、1つの不正なオペランドがエラーなしにリダクション矩形とステッチされたページを取り除きます
// DecimalSeparator = ','で動作するホストプロセス
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);

// 追加された/Contentsがかつて運んでいたもの:
//   0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
//   q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// ISO 32000-1 7.3.3は「0,5」の生成規則を持たないため、リダクション
// 矩形とステッチされたページはどちらもエラーなしに消える

欠陥を何年も隠してきた副作用

HotPDFは作成経路の数値のほとんどを_CutFloatで書き、その関数の初期リビジョンは静かに敵対的なことをしました。呼び出しのたびにホストプロセスのグローバルなFormatSettings.DecimalSeparatorへ'.'を代入したのです。これはコンポーネントをホストするアプリケーションのロケール状態を壊し、スレッドセーフでもありませんが、面白い結果は3つ目です。いったんコンテンツが生成されるとプロセスセパレータはピリオドになり、ライブラリの他の場所にある裸のFloatToStrとStrToFloatはすべて偶然に正しくなりました。ほとんどすべてのサンプルが使う作成→編集の順序はそれで通り、隠されたのは最初の_CutFloat呼び出しより前に走るすべての経路でした。読み込み文書エディタのStitchLoadedPageとRedactLoadedRect、テキストと画像のスタンプ、複合ウォーターマーク、Format('%.2f')を使うバーコード描画、/DAデフォルトアピアランスパーサ、Type 1フォントサブシステム全体です

HotPDFの_CutFloat副作用のタイムライン。最初の呼び出しがグローバルなFormatSettings.DecimalSeparatorへピリオドを代入し、以降の裸のFloatToStrをすべて偶然に正しくする一方、読み込みエディタのStitchLoadedPageとRedactLoadedRect、バーコード経路、/DAパーサ、Type 1フォントサブシステムは壊れたままです
ほとんどすべてのサンプルが使う作成→編集の順序が欠陥を見えなくしました。最初の変換より後はすべて、反転したグローバルセパレータを継承したからです

Type 1のパースはその中で最も醜い形で失敗しました。FontMatrix、BlueValues、ステムヒント、Type 2 charstring内の実数は、Adobe Type 1 Font Format仕様によりピリオドで書かれたPostScript実数です。カンマロケールで0.001をパースするとEConvertErrorが発生し、外側のtry/exceptがそれを飲み込み、フォント行列はゼロのままでした。そのフォントのすべてのグリフはその後崩れました。これはロケール問題ではなくフォント問題に見え、埋め込みグリフレンダリングのデバッグに午後を費やさせます

散らばったパッチではなく1つの変換レイヤー

HotPDFが今従う規則は率直です。PDF、PostScript、JSON、JavaScriptの構文へ書き込まれる、あるいはそこから読み出されるあらゆる数値は、明示的なInvariant変換を通ります。そしてどこにも、グローバルなFormatSettingsの副作用に依存するコードはありません。HPDFTypesは4つの変換と、それらが共有する設定レコード、バーコード経路用の1つの固定精度ヘルパを所有します

// Lib/HPDFTypes.pas - 単一のInvariant変換面
function HPDFInvariantFormatSettings: TFormatSettings;

function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
  out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
  const Default: Extended): Extended;

function HPDFFormatInvariant2(Value: Extended): string;

2つのユニットがすでに独自のプライベートなInvariantFormatSettings関数を持っていました。HPDFContentStreamとHPDFTextExtractionです。両方とも並行実装のまま残すのではなく、1行のフォワード宣言に縮小されました。Type 1サブシステムは意図的な例外です。VCL依存を持たずHPDFTypesも参照しないため、同じ意味論を持つHPDFType1Core.Type1StrToFloatというローカルミラーを維持します。10行の関数の複製は、グラフィックスレイヤーをフォントパーサに引きずり込むより小さな負債だという考え方です。他の2か所は意図的にそのまま残されました。HPDFHTMLImportはCSS長さを、ピリオドをロケールセパレータへ意図的に交換してからロケールでパースします。それはその入力にとって正しいことです。HPDFType1CB.GlyphCommandsとFontBBox文字列は、呼び出し側が自分のStrToFloatで読み戻すパブリックAPIで、同じロケールで生成と消費をすれば自己整合的です。Invariant出力はカンマ地域のすべての呼び出し側を壊してしまいます

HPDF_HAS_FORMAT_SETTINGSはコンパイラ間で何を保証するのか

サポートされるすべてのツールチェーンでInvariant経路が実際にコンパイルされることを保証します。明白なバージョンゲートはそれをしませんでした。Delphi XE2はTFormatSettings.Createと、TFormatSettingsを受け取るFloatToStr / TryStrToFloatオーバーロードを導入したため、最初の直感は各呼び出しサイトに{$IFDEF XE2+}, InvariantFormatSettings{$ENDIF}と書くことです。その直感には盲点があります。HotPDF.incはFree PascalのもとでD2009+を定義しますが、XE2+は決して定義しません。XE2+はDelphiのバージョン番号に関する質問に答えるものだからです。一方Free Pascal 3.2.2はそれらのオーバーロードを無条件で同梱します。したがってそれらのゲートはすべてFPCビルドでロケール依存の分岐に劣化しました。黙って、まさに元の報告が出たプラットフォーム上でです。HPDF_HAS_FORMAT_SETTINGSはHotPDF.incのFPCブロックとXE2+ブロックの両方で定義され、数値ヘルパが検査する唯一のシンボルです

{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
  Result := DefaultFormatSettings;   // FPCにはTFormatSettings.Createがない
{$ELSE}
  Result := TFormatSettings.Create;
{$ENDIF}
  Result.DecimalSeparator := '.';
  Result.ThousandSeparator := ',';
end;
{$ENDIF}

関数の内部には1つのコンパイラ分岐が生き残ります。Free PascalにはTFormatSettings.Createがなく、DefaultFormatSettingsから始めなければならないからです。変換に供給されるのは2つの数値セパレータだけで、日付と通貨フィールドは現在のロケールが供給するものを保持します。XE2より前のDelphiにはオーバーロードがまったくなく、そこでヘルパはプロセスロケールで変換してから結果の文字列のセパレータ文字を書き直すフォールバックをします。醜いですが、グローバル状態には決して触れません

3つ目の層:SVGが許しPDFが許さない指数

小数点セパレータの修正は必要ですが、十分ではありません。W3C SVG 1.1 §4.2は数値の生成規則で指数を認め、XPSマークアップも同様です。だから1E-6は入り口では完全に有効な座標ですが、ISO 32000-1 §7.3.3は指数をまったく認めません。Invariantフォーマットを生き延びた値でも、1E-6としてコンテンツストリームに載れば拒否されます。FloatToStrFの固定フォーマットも、値がRTLの桁範囲を出ると指数形式にフォールバックできます。そこでHPDFSafeSVG.SVGFloatはInvariant変換の後に独自の作業をします。1.0E-7未満のものはゼロに流し、その後Eが生き残ったら指数を取り除き、手動で小数点を移動して、シフトが必要な側にゼロを詰めます。SVGFiniteはフォーマッタに到達する前にNaNと±1.0E30の外を拒否して入り口を守ります。SVGReadNumberとSVGSkipSeparatorsはRTLに委ねず、入ってくる方向を文字ごとに処理します

HotPDFがPDFオペランドを書く前に指数を取り除くパイプライン。SVGFiniteはNaNと1.0E30を超える値を拒否し、Invariant変換はピリオドを残し、微小な大きさはゼロに流れ、Eが生き残ったHPDFFloatToInvariantStrの出力は指数が取り除かれて小数点が手動で移動されます
SVG 1.1は指数を認め、ISO 32000-1は認めないため、変換境界は1E-6を適合リーダがパースできる素の10進表記に書き直します
// Lib/HPDFSafeSVG.pas - PDFオペランド用の10進表記
if Abs(Value) < 0.0000001 then
  Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
  ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
  SetLength(S, ExponentAt - 1);
  // ... 小数点をExponentValueだけ移動し、'0'で埋める
  // 左または右に埋めてから符号を付け直す
end;

これはSVGページエクスポートとそのグラフィックスステート処理を供給するのと同じTSVGBufferで、HPDFCreateXPSDocumentHandlerとTHPDFHandledDocumentFormatのhdfXPSメンバーを通じてXPSとOXPS文書をPDFへ変換するとき、同じ数値規律がインポート側にも当てはまります。マークアップ形式は数値構文に寛大です。PDFコンテンツストリームはそうではなく、その違いを解決しなければならないのが変換境界です

ロケールを仮定せず再現する回帰テスト

他人のマシンでしか現れないロケールバグは、ロケールを自分で設定するテストで固定する価値があり、HotPDFは2つ持っています。LoadedEditsEmitInvariantNumbersUnderCommaLocaleは最初の編集の前にFormatSettings.DecimalSeparatorへカンマを割り当て、RedactLoadedRect、StampLoadedPageText、StitchLoadedPage、複合スタンプを実行し、ページコンテンツのバイトを読み戻して、正確なオペレータ文字列が存在することと、バッファのどこにもカンマがないことの両方をアサートします。DocumentCreationEmitsInvariantNumbersAndKeepsHostLocaleは残りの半分と原罪をカバーします。同じカンマロケールで、固定の小数点以下2桁フォーマットのDirectDrawBarcodeを含む作成経路を駆動し、コンテンツストリームにカンマがなく、EndDocが戻ったときFormatSettings.DecimalSeparatorがまだカンマであることをアサートします

2つ目のアサーションこそ、書かれた日に元の欠陥を捕まえていたでしょう。ディスクに書くものだけでなく、ライブラリがホストに対して何をするかをテストするからです。この教訓はPDFを超えて一般化します。PostScript、JSON、JavaScriptを含む、固定の語彙文法を持つあらゆる形式は、境界で明示的なInvariant変換を必要とします。グローバル設定に手を伸ばして正しい答えを得るのは、誰かが別の順序、別のスレッド、別のコンパイラでコードを呼ぶまで機能し、その頃には失敗はフォーマット問題以外の何にでも見えます

ここで説明した数値フォーマットレイヤーは、DelphiとC++Builder向けのHotPDF Delphi Componentの一部として、それに依存する読み込み文書編集、バーコード、SVGエクスポート、XPS変換の経路とともに同梱されます