技術記事

Delphiで保存時にPDFの小数精度を保つ仕組み

losLab PDF Developer LibraryであるPDFlibPasは、文書内のすべての実数について、解析したときの小数テキストをそのまま保持し、その値が一度も変更されていなければ、そのテキストを逐語的に書き戻します。v3.539.19以降、SetPrecisionの設定はライブラリが生成または編集した数値だけを対象とするので、普通に読み込んで保存するだけで、CalRGBの/Gammaが2.22221から2.2222に丸められ、誰も触っていないページの色がずれることはなくなりました。この変更はコードとしては小さく、パーサについて語っていることとしては大きいものです。デコードした値と出力するリテラルは別物であり、Doubleの往復変換は恒等変換ではありません

何も変えていない保存でなぜページの色がずれたのか

画像ではなく、色空間のパラメータが再フォーマットされていたからです。これを露見させたのは、手元のリグレッションコーパスにある35ページのオフィス文書で、全ページでヘッダ画像が使い回されています。これを読み込んでそのまま保存し直すと、画像ストリームは入力とバイト単位で同一のまま出力され、ストリームハッシュの比較も文書は変更なしと報告しました。ところが描画結果の比較は違う答えを出しました。35ページすべてでヘッダに画素の違いが出ており、他の場所には一切ありませんでした

ヘッダ画像はCalRGB色空間を通して描画されており、ISO 32000-1 §8.6.5.3はそれを/WhitePoint、省略可能な3要素の/Gamma配列、そして省略可能な9要素の/Matrixで定義しています。これらの配列は、色空間辞書の中のただの数値オブジェクトです。TPDFNumericはそれぞれをDoubleとして保持するだけで、それ以外は何も持っていませんでした。そしてTPDFNumeric.OutputがそのDoubleをPDFPrecNumを通してフォーマットし、この既定値は小数点以下4桁です。その結果、/Gammaは2.22221から2.2222になり、行列の1エントリは0.71519から0.7152になり、レンダラはわずかに違う校正値から、忠実にわずかに違う色を生成しました。画像のバイト列は無実でした。その周りの数値がそうではなかったのです。気持ちの悪いのは、これがいかに見えなかったかです。デコード後のストリームバイトを比較しても見えません。数値はストリームではなく辞書に住んでいるからです。添付のペイロードを比較しても見えません。変更レベルの記事で説明したリビジョン差分でさえ、正規化されたオブジェクト本体をフィンガープリントするので、両方のリビジョンが同じ値にハッシュされ、差分は同一だと報告します。捕まえられたのは描画だけであり、コーパスのベースラインが構造チェックだけを信じず、全ページを描画するのはそのためです

Delphiでの何も変えない保存でPDFlibPasがCalRGBの精度を失っていた箇所。解析済みの/Gamma 2.22221と行列の0.71519はTPDFNumericにDoubleとして入っており、OutputがPLDoubleToStrとPDFPrecNumで小数点以下4桁にフォーマットします。あらゆる構造チェックは文書を変更なしと報告し、描画比較だけが35ページすべてのヘッダ画像のずれを示します
画像のバイト列は無実でした。TPDFNumericがその周りの校正値をPDFPrecNumで再フォーマットしていたので、ストリームハッシュもフィンガープリント差分も同じリビジョンだと報告し、その間にレンダラは全ページでわずかに違う色を生成していました

解析した値と、書くべきリテラルは別物

PDFの実数は小数の文字列であり、ISO 32000-1 §7.3.3は、それが小数の文字列にすぎないと明言しています。基数表記も指数表記もありません。さらにAnnex Cが、実装が守るべき精度として、小数部でおよそ5桁の有効十進数字を挙げています。既定の出力精度4はすでにそれを下回っていますし、ゼロの近くではもっとひどくなります。PLDoubleToStrは値をスケールし、整数に丸め、結果が0なら0を出力します。ですから-0.000012345という行列エントリは桁が1つ減るのではなく、丸ごと消えてしまいます

既定値を上げても、崖が動くだけです。修正とは、Doubleがその数値そのものであるというふりをやめることです。TPDFStructure.Decodeのトークナイザが標準的な実数を認識したとき、つまりトークンが小数点を含み指数マーカーを含まないとき、変換後の値と並べて元のソーステキストを新しいFOriginalTextフィールドに格納します。そしてOutputはそのテキストを優先し、優先できるものがないときにだけフォーマットにフォールバックします

DelphiでPDFlibPasが解析済みの小数テキストを保つ仕組み。TPDFStructure.Decodeのトークナイザは、小数点を含み指数を含まないトークンについて元のリテラルをFOriginalTextに保持し、OutputはPLDoubleToStrを呼ぶ代わりにそのテキストを逐語的に書き出します。SetToはそれをクリアします。編集された数値は新しい数値だからです
デコードした値と出力するリテラルは別物です。解析済みテキストを優先すれば2.22221は正確なまま保たれ、ライブラリが生成した数値と編集済みの数値はPDFPrecNumに従い、この設定が手つかずの入力に届くことはありません
// Lib/PDFlibStruct.pas — 出力側の修正はこれで全部
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // 編集された数値は新しい数値
  FValue:= Value;
  FChanged:= True;
End;

2つの境界は意図的なものです。整数は保持しません。整数のフォーマットはすでに可逆だからです。6.02E23のような指数表記は、壊れた生成側のために入力では許容しますが、出力では保持しません。そのまま書き戻すと§7.3.3が禁じている構文を永続化してしまうからです。これらはライブラリが生成した数値と同じくフォーマッタを通ります。またトークナイザは、テキストを格納する前にいつもの最小限の修復を適用します。.5のような先頭ドットのリテラルは0.5として、5.のような末尾ドットのリテラルは5.0として保持されます。どちらもあらゆるリーダーにとって同じ数値であり、こちらのほうがはるかに広く受け入れられます

v3.539.19以降、SetPrecisionは何を保証するのか

TPDFlib.SetPrecisionは、ライブラリ自身が生成する数値の小数点以下の桁を制御するようになりました。ペインタを通して描画された値、NewNumericのようにDoubleから作成された数値、そして解析後にSetToで編集された値です。オブジェクトAPIを通してデコードされたテキスト、たとえばSetObjectFromStringに渡されたリテラルも、同じトークナイザを通り、同じように保持される点に注意してください。一度も変更されていない解析済みの小数は、設定に関係なく入力時の精度を保ちますし、読み込み後に設定を変えても、さかのぼって影響を受けることはありません。SetPrecisionのリファレンスは同じリリースでまさにこのとおりに更新しました。以前の文言は、この設定がファイル内のすべての数値に適用されるかのように読めたからです

クリアはChangedフラグから導出するのではなく、SetToの中で行います。この区別は重要です。保存のパイプラインは、オブジェクトを書き出した時点でChangedをリセットします。ですから「変更されていなければ元のテキストを出す」という形の検査では、同じセッション内で編集し、保存し、また編集した値に対して古いテキストを出し始めてしまいます。元のテキストを代入そのものに結び付けておけば、両者が食い違うことはありえません。リグレッションテストは、元のファイルの値を使ってこれらの振る舞いを1つずつ固定しています

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // 未編集の入力は逐語的に生き残る。4桁フォーマットでは
    // 0に潰れてしまう値を含めて
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // 編集すると元のテキストは捨てられ、PDFPrecNumに従う
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // 後から精度を下げても、未編集の入力には届かない
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

なぜコンテンツモデルは今も数値を正規化するのか

TPDFContentProgramが正準な数値オペランドを約束しており、その約束はコンテンツストリーム内での逐語的なテキストより価値があるからです。編集可能なコンテンツモデルは、グラフィックス状態トラッカーがその上に築かれているものと同じですが、これはNormalizeContentStreams、オプティマイザ、そしてEmitが、任意の入力から安定して比較可能な出力を生むために存在します。解析済みのオペランドが元のテキストをモデルに持ち込むと、0.50000 0 0 RGのようなオペレータ列が0.5 0 0 RGとは違う形で出力され、下流のあらゆる比較が生成側のフォーマットの癖に引きずられてぶれます

そこでモデルは、2つの入口で元のテキストを剥ぎ取ります。NormalizeContentNumbersは、パーサが各オペランドを積むときに、そして呼び出し側が渡したソースをデコードするときのSetOperandの中で実行されます。配列と辞書を再帰的にたどるので、破線パターン、TJ配列、マーク付きコンテンツのプロパティ辞書も対象になります。各数値に対してSetTo(AsDouble)を呼べば十分です。それこそがテキストをクリアする操作だからです。生のインライン画像データは、以前からそうだったように手を付けません

PDFlibPasのコンテンツモデルが今も数値を正規化する理由。NormalizeContentNumbersはパーサが各オペランドを積む場所とSetOperandの中で走り、配列と辞書を再帰的にたどって破線パターン、TJ配列、マーク付きコンテンツのプロパティ辞書を対象にします。SetTo AsDoubleが元のテキストをクリアするので、0.50000と0.5は同じ形で出力されます
正準な数値オペランドはコンテンツモデルの約束です。生のインライン画像データは手を付けられず、コンテンツストリームの外にある手つかずの辞書の数値は逐語的な保証を保ちます。ですから素のLoadFromFileとSaveToFileの組み合わせでも、それらは保持されます
// Lib/PDFlibContentModel.pas — コンテンツモデルは契約を守る
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

したがって呼び出し側にとっての実用的なルールは単純です。素のLoadFromFileに続くSaveToFileは、手をつけていないコンテンツストリームも手をつけていない辞書の数値も、そのままの状態で残します。NormalizeContentStreamsを通したページ、あるいはコンテンツモデルを通したあらゆる編集は、設計どおり正準な形で出てきます。そして文書の残りの部分は依然として保持されます。これは2つの別の要求であり、今では2つの別のことをします

何を犠牲にし、保証はどこで止まるのか

すべてのTPDFNumericがAnsiStringの参照を1つ余分に持ち、解析されたすべての小数が、オブジェクトの寿命のあいだソーステキストを生かし続けます。実数を何百万も持つ文書では、それは現実のメモリであり、手を振って片付けるのではなく、大規模文書のあらゆる計測に含めるべきものです。またこの保証は、その数値自身の文書に限定されます。文書間でオブジェクトをコピーしたり、オブジェクトAPIを通して値を再構成したりすると新しい数値が生まれ、それは他の新しい数値と同じく出力精度に従います。このリリースが何を主張し、何を主張しないかは正確に述べておく価値があります。手つかずの文書を読み込んで保存すると、レンダラが実際に消費する校正値が保たれるようになりました。これがコーパスのベースラインが検査している性質です。バイト単位で同一の出力を主張するものではありません。それはオブジェクト番号、ストリーム圧縮、そして決定論的PDF IDの記事で扱っているtrailerの識別子にも依存します。また、他のソフトウェアが生成したファイルの丸めの違いをフィンガープリント差分が見えるようにするものでもありません。それらは今も正規化された本体をハッシュするからです。この教訓はCalRGBをはるかに超えて一般化します。パーサが変換後の値だけを保持していると、あらゆる保存が編集になり、それに気づく唯一の方法は描画結果を見ることです。数値の扱いとSetPrecisionのセマンティクスは、losLab PDF Developer Libraryの製品ページに記載されています