技術記事

Delphi で Single が AcroForm フィールドフラグを壊す理由

HotPDF Delphi Component はすべての PDF 数値を THPDFNumericObject.Value に格納していて、その宣言は Single です。そして Single はすべての 32 ビット整数を保持できません。AcroForm のフィールドフラグ(/Ff)は 32 ビットのマスクなので、上位ビットの立ったマスクはロード時に丸められていました。$8000FFFF は $80010000 として返ってきます。v2.989.5 から、フィールドフラグの読み取りは元の整数トークンを正確にパースし、そのビットを既存の Integer API へコピーします

このバグは PDF フォームに触らない人にも一度よく見る価値があります。クラッシュも例外もなく、ほとんどのテストファイルは通っていました。ほとんどの実在マスクは小さいからです。失敗が姿を見せたのは、上位ビットと下位の一般的なフラグが同時に立ったときだけで、しかも 1 ビットを失うのではなく、複数のビットを一気に書き換えていました。ビットマスクを浮動小数点型に格納するのは Delphi の古典的な罠で、これが本番コードでの姿です

Single はなぜ AcroForm フィールドフラグのビットを失うのか

Delphi の Single は 24 ビットの仮数を持つため、2^24(16777216)までの整数はすべて正確に表せますが、それを超えると隙間なくは表せません。2^31 から 2^32 の間では、連続する Single の値は 256 ずつ離れます。ビット 32 の立ったフィールドフラグマスクはちょうどこの範囲に住んでいるため、下位 8 ビット、しばしばそれ以上が、256 の倍数へ丸められます

Delphi の HotPDF AcroForm フィールドフラグ丸めの図解。32 ビットの /Ff マスク $8000FFFF(2147549183)が 24 ビット仮数の Single を通ると、表現可能な Single の値が 2^31 から 2^32 の間で 256 ずつ離れているため $80010000 として戻る
2^31 を超えると Delphi の Single は 256 の倍数しか格納できず、最も近い値への丸めが 32 ビットのフィールドフラグマスクの下位バイトを例外なしに書き換えます

ISO 32000-1 §7.3.3 は整数と実数を別種の数として定義し、附属書 C はそれぞれに異なる実装限界を列挙します。つまり PDF の整数には 32 ビットの浮動小数点に収まる義務はありません。フィールドフラグのエントリは Table 221 で整数と定義され、数量としてではなくビットごとに読まれます。数量のごくわずかな丸めは無害ですが、マスクの丸めは隣のビットへ桁借りのように波及します。全体の効果は素の Delphi 数行で再現できます

var
  S: Single;
  Mask: Int64;
begin
  S := $8000FFFF;              // 2147549183、Single では表現できない
  Mask := Round(S);            // 2147549184
  Writeln(IntToHex(Mask, 8));  // 80010000: ビット 1-16 が消え、ビット 17 が立つ

  S := $80003FFF;
  Mask := Round(S);
  Writeln(IntToHex(Mask, 8));  // 80004000: ビット 1-14 が消え、ビット 15 が立つ
end;

この教訓は PDF の外にも一般化します。Single、Double、float を入れた Variant、float にデコードされた JSON 数値は、たまたまテストに使った値が小さくても、ビットフィールドの容器としては間違っています。Double は正確な範囲を 2^53 まで押し広げ、32 ビットのマスクでは問題を隠しますが、それでも型が間違った意図を表明していることには変わりません。Cardinal、Integer、Int64 を使い、すでに丸め済みの float からではなくソーステキストから変換してください

どのフラグが反転するか:Radio、Pushbutton、Password、Multiline

上の 2 つのマスクは無作為ではありません。丸め 1 回でリーダーが見るフィールドタイプがどう変わるかを示しています。PDF はフラグビットを 1 から数えるので、ビット n の値は 2^(n-1) です。ISO 32000-1 §12.7.3.1 の Table 226 は全フィールド共通のビット 1 から 3 を、Table 228 はボタン系のフラグを、Table 230 はテキストフィールドのフラグを定義します

  • $8000FFFF から $80010000: ビット 16(Radio、$8000、Table 228)が消えビット 17(Pushbutton、$10000)が立つため、ラジオボタンがプッシュボタンに化けます
  • 同じ丸めはビット 1 から 3 も消すため、Table 226 の ReadOnly、Required、NoExport が Radio と一緒に消えます
  • $80003FFF から $80004000: Table 230 のビット 13(Multiline、$1000)とビット 14(Password、$2000)が両方消え、マスクされたパスワードボックスが 1 行の平文テキストフィールドになります
  • ビット 32 未満では被害は 2^24 から始まります。16777219(Comb と ReadOnly、Required)は 16777220 に丸められ、ReadOnly と Required が消えて NoExport が加わります

最後のケースはフォーム編集以外でも HotPDF に影響しました。ExportLoadedPagesStructured は実効マスクから JSON と XML のフォームモデルを組み立て、値の抑止判定に NoExport を使うため、丸められたマスクは普通の値を隠したり readOnly と required メンバーを誤って報告したりできたのです。構造化エクスポートの経路は v2.989.8 で同じ正確な読みを受けました

Delphi の HotPDF AcroForm フィールドフラグのビットマップ。ビット 32 とビット 1 から 16 が立った /Ff マスク $8000FFFF は Single の 1 回の丸めでビット 1 から 16 を失い、Pushbutton のビット 17 を得て $80010000 になる
ISO 32000-1 の Table 226、228、230 のフラグは集団で動きます。Radio が Pushbutton に反転するのと同じ丸めステップで、ReadOnly、Required、NoExport、Multiline、Password が消え得ます

HotPDF は正確な 32 ビット整数トークンをどう読むか

HotPDF Delphi Component は数値オブジェクトモデルを変えずに読み取り側を修正しました。パーサはもともと Single 値の隣に元のトークンテキストを覚えているので、フィールドフラグのリーダーはそのトークンを整数としてパースし、上限付きの Int64 に累積し、結果の 32 ビットを公開 API が返す Integer へコピーします。汎用の数値 API と旧来の非整数フォールバックはそのまま残り、正確な経路を取るのはフィールドフラグの消費者だけです

Delphi における HotPDF の正確な AcroForm フィールドフラグ読み取り経路。パーサは Single 値の隣にソーストークンを保持し、TryParseFlagMask が上限付き Int64 に累積し、32 ビットは Integer として GetLoadedFormFieldFlags と SetLoadedFormFieldFlags に届く
フィールドフラグは丸められた Single ではなく覚えておいたトークンテキストをパースするため、上位ビットマスクの両表記、2147549183 と -2147418113 は同一のビットで届きます

パーサは実在ファイルが上位ビットマスクを綴る両方の方法を受け付けなければなりません。符号なし値 2147549183 を出す書き手もいれば(Annex C の 2^31 - 1 という整数限界は超えていますが実務ではよくある)、符号付き値 -2147418113 を出す書き手もいます。両者とも同じビットを記述します。上限付きパーサのバージョンは次のようになります

function TryParseFlagMask(const Token: AnsiString; out Mask: Cardinal): Boolean;
var
  P, Digit: Integer;
  Negative: Boolean;
  Number, Limit: Int64;
begin
  Result := False;
  Mask := 0;
  if Token = '' then Exit;
  P := 1;
  Negative := Token[1] = '-';
  if Token[1] in ['+', '-'] then Inc(P);
  if P > Length(Token) then Exit;
  // 符号付きトークンは -2^31 まで、符号なしは 2^32 - 1 まで
  if Negative then Limit := 2147483648 else Limit := 4294967295;
  Number := 0;
  while P <= Length(Token) do
  begin
    if not (Token[P] in ['0'..'9']) then Exit;  // 実数は旧フォールバックへ
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // 上限チェック済み、オーバーフローなし
    Number := Number * 10 + Digit;
    Inc(P);
  end;
  if Negative and (Number <> 0) then Number := 4294967296 - Number;
  Mask := Cardinal(Number);
  Result := True;
end;

2 つの細部は意図的です。上限は各乗算の前にチェックされるため、400 桁のトークンは Int64 をオーバーフローさせるのではなくきれいに失敗します。そして 3.0 のようなトークンは拒否され、呼び出し元を Round(Value) に戻します。正確な経路は、整数マスクを改善しつつ、不正形態のものに新しいセマンティクスを発明しません

アプリケーションコードからは、シグネチャは何も変わりません。THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer は実効マスクを返し、HotPDF が継承された AcroForm フィールド値を解決しリセットする方法で述べたのと同じやり方で /Parent チェーンをたどります。THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) は完全なローカルマスクを書き込みます。ビット 32 の立ったマスクは単なる負の Integer なので、値を変換するのではなくビットを移動してください

const
  FF_RADIO      = $00008000;  // ビット 16、ISO 32000-1 Table 228
  FF_PUSHBUTTON = $00010000;  // ビット 17、Table 228
var
  Pdf: THotPDF;
  I, Native: Integer;
  Bits: Cardinal;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    for I := 0 to Pdf.GetFormFieldCount - 1 do
    begin
      Native := Pdf.GetLoadedFormFieldFlags(I);
      Move(Native, Bits, SizeOf(Bits));   // 同じ 32 ビット、変換なし
      if (Bits and FF_RADIO) <> 0 then
        Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
    end;

    Bits := $80000002;                    // 上位ビットと Required
    Move(Bits, Native, SizeOf(Native));   // Native = -2147483646
    Pdf.SetLoadedFormFieldFlags(0, Native);
    Pdf.SaveLoadedDocument('survey-out.pdf');
  finally
    Pdf.Free;
  end;
end;

継承されたマスクの読み取りは子辞書に触れません。書き込みは選択したフィールドにローカルエントリとしてマスク全体を置き換えます。SetLoadedFormFieldReadOnly と SetLoadedFormFieldRequired のショートカットは実効継承マスクから出発して 1 ビットだけ反転し、他のビットをすべて正確に保ちます。フラグではなく値の充填なら、HotPDF でロード済み PDF のフォームフィールド値を設定するを参照してください

Value と RememberSourceToken の順序が重要な理由

順序が重要なのは、THPDFNumericObject が Single 値が変わっていない間だけ覚えたトークンを信頼するからです。RememberSourceToken はトークンを現在の Value のスナップショットと一緒に格納し、SourceToken のゲッターは Value がそのスナップショットと異なった瞬間に空文字列を返します。この無効化規則があるから、編集された数値が古い元テキストのまま保存されずに済みます

この規則は書き込み側では罠になります。トークンを先に覚えて値を後から代入すると、スナップショットは古い値を握り、ゲッターはトークンなしを報告し、シリアライザは丸められた Single のフォーマットにフォールバックします。ショートカットもネイティブフィールドコンストラクタも含め、ライブラリ内のすべてのフラグ書き込みが同じ順序に従います

// 正しい:スナップショットが Value と一致し、正確なトークンが保存で生き残る
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// 誤り:Value が変わる前にスナップショットが取られるため、SourceToken は
// 無効化され、代わりに丸められた Single が書かれる
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

SaveNumericObject は有効なトークンがあればそれを書くため、順序を正すことが、保存して開き直したファイルに丸め近似ではなく元の整数を運ばせるポイントです

2 つのフィールドに 1 つの名前を共有させた null トークン

同じリリースは、これらのマスクのテスト中に見つかった 2 つ目のパーサ欠陥も修正しました。ストリームから読んだ辞書内の null 値が消費されていなかったのです。HotPDF は次のトークンを覗いて分類し、そのチェックでストリーム位置はトークンの先頭に置かれます。ストリーミング辞書リーダーの otNull 分岐は null オブジェクトを作ったものの、null の 4 バイトを越えて前進しませんでした

帰結はハングではなく無言の誤読でした。次のキー探索が誤った場所から前方に走り、現在の辞書の末尾を飛び越えて、続く間接オブジェクトからキーを読み、それが現在のフィールドを上書きしました。フィールドリストでの症状は、2 つの異なるフィールドが同じ名前と同じ値を報告することでした。配列と span のパーサは null を正しく消費していたため、修正は汎用パーサを書き直す代わりに、その 1 つの分岐でストリームを 4 バイト前進させるものです。動かないカーソルは、不正形態 PDF で HotPDF をハングさせた PDF パーサのカーソル停滞と同族のバグで、症状が違うだけです

修正は継承も明確にしました。ローカルの /Ff null、または null に解決される参照は不在として扱われ、探索は親へ続きます。一方、明示的な 0 は親を上書きする本物のマスクです

{$R-} 指令が証明することとしないこと

フラグバグの最初の仮説は、大きなマスクを Integer に代入したときの範囲チェックエラーでした。これは誤りでしたが、理由は知っておく価値のある Delphi の細部です。HPDFDoc.pas の ParseSimpleDictionary の前に置かれたローカルの {$R-} 指令には対応する復元がなく、ユニット内のその後の全関数が範囲チェックオフでコンパイルされていました。Delphi は関数の末尾でスイッチ指令をリセットせず、include ファイルはインクルードされた時点の指令状態を引き継ぎます

したがって、コマンドラインの -$R+ や -Cr が証明するのはユニットの初期設定だけです。ずっと下にある個々の関数については何も語りません。あるルーチンで範囲チェックが有効かを知るには、上方向で最も近い指令を見つけ、それから実際の境界値で振る舞いを確認します。2 つの古いローカルフラグ消費者は仮説が予言した範囲例外を決して上げなかったため、その欠けた例外を修正の証拠に引用することはできませんでした。本当の欠陥は既知のフラグビットを横断する Single の丸めだったのです

正確なトークン読み取りはその範囲についても正直です。ユニット全体の指令状態は変えず、予約ビットに意味を与えず、すべてのビット組み合わせが有効なフィールドだと主張しません。保証するのは、ファイルの中の 32 ビットがあなたのコードの見る 32 ビットであることだけです

FAQ

THPDFNumericObject.Value を Double や Int64 に変えないのか

公開フィールドの型を変えると、それを読む、代入するすべての呼び出し元が壊れますし、PDF の数値は整数であるのと同じくらい実数でもあります。Single を保ち、フィールドフラグを元のトークンから読むことで、既存の宣言や呼び出し規約を 1 つも変えずにビットマスクのバグを直せます

SetLoadedFormFieldFlags はビット 32 の立ったマスクを受け付けるか

はい。マスクは符号付き Integer として渡します。$80000002 と -2147483646 は同じ 32 ビットです。GetLoadedFormFieldFlags は保存して再ロードした後も Required を含め同じ値を返します

どの HotPDF バージョンが正確な AcroForm フィールドフラグを読むか

正確なフィールドフラグ読み取りと null トークン修正は HotPDF v2.989.5 で登場し、構造化 JSON と XML エクスポートは v2.989.8 で正確なマスクを採用しました。それより前のバージョンは 2^24 を超えるマスクを Single 経由で丸めます

正確な AcroForm フィールドフラグ、継承フィールド探索、ロード済みフォーム編集は、PDF 文書の作成・ロード・編集のためのネイティブ PDF ライブラリ HotPDF Component for Delphi and C++Builder の一部です