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 の倍数へ丸められます
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 で同じ正確な読みを受けました
HotPDF は正確な 32 ビット整数トークンをどう読むか
HotPDF Delphi Component は数値オブジェクトモデルを変えずに読み取り側を修正しました。パーサはもともと Single 値の隣に元のトークンテキストを覚えているので、フィールドフラグのリーダーはそのトークンを整数としてパースし、上限付きの Int64 に累積し、結果の 32 ビットを公開 API が返す Integer へコピーします。汎用の数値 API と旧来の非整数フォールバックはそのまま残り、正確な経路を取るのはフィールドフラグの消費者だけです
パーサは実在ファイルが上位ビットマスクを綴る両方の方法を受け付けなければなりません。符号なし値 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 の一部です