技術文章

為什麼 Single 浮點數會毀掉 Delphi 的 AcroForm 欄位旗標

HotPDF Delphi Component 把每個 PDF 數字存在 THPDFNumericObject.Value,宣告為 Single,而 Single 裝不下每個 32 位元整數。AcroForm 欄位旗標(/Ff)是 32 位元遮罩,所以任何高位元被設立的遮罩在載入時就被捨入了:$8000FFFF 讀回來變成 $80010000。從 v2.989.5 起,欄位旗標讀取改為精確解析原始整數 token,再把它的位元複製進既有的 Integer API

就算您從不碰 PDF 表單,這個 bug 也值得細看。沒有崩潰,沒有例外,多數測試檔案照樣通過,因為多數真實遮罩都很小。失敗只在高水位位元與低位常用旗標同時出現時現身,而且它不是弄丟一個位元,是一次改寫了好幾個。把位元遮罩存進浮點型別是 Delphi 的經典陷阱,這就是它在產品程式碼裡的樣子

Single 為什麼會弄丟 AcroForm 欄位旗標位元?

Delphi 的 Single 有 24 位元尾數,所以它能精確表示 2^24(16777216)以下的每個整數,再往上就有縫隙。在 2^31 到 2^32 之間,相鄰的 Single 值相距 256。bit 32 被設立的欄位旗標遮罩正好落在那個區間,所以它的低八位、往往更多,被捨入到 256 的最近倍數

Delphi 的 HotPDF AcroForm 欄位旗標捨入示意圖:32 位元 /Ff 遮罩 $8000FFFF(2147549183)經過 24 位元尾數的 Single,回來變成 $80010000,因為 2^31 到 2^32 之間可表示的 Single 值相距 256
超過 2^31 之後,Delphi 的 Single 只存得下 256 的倍數,round-to-nearest 於是改寫 32 位元欄位旗標遮罩的低部位元組,全程沒有任何例外

ISO 32000-1 §7.3.3 把整數與實數物件定義成兩種不同的數字,附錄 C 為兩者列出不同的實作上限,所以 PDF 整數沒有義務塞得進 32 位元浮點數。欄位旗標條目在表 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、裝著浮點數的 Variant、或解成浮點數的 JSON 數字,都不是位元欄位該用的容器,哪怕您湊手測試的值都很小。Double 把精確範圍推到 2^53,對 32 位元遮罩藏住了問題,但型別表達的意圖仍然是錯的。請用 Cardinal、Integer 或 Int64,並從來源文字轉換,而不是從已經被捨入的浮點數

哪些欄位旗標翻轉:Radio、Pushbutton、Password 與 Multiline

上面兩個遮罩不是隨手挑的;它們展示了一次捨入如何改變閱讀器看到的欄位型別。PDF 的旗標位元從 1 起算,所以第 n 位的值是 2^(n-1)。ISO 32000-1 §12.7.3.1 的表 226 為每個欄位定義位元 1 到 3,表 228 定義按鈕旗標,表 230 定義文字欄位旗標

  • $8000FFFF 變 $80010000:位元 16(Radio,$8000,表 228)被清除、位元 17(Pushbutton,$10000)被設立,單選按鈕變成了推壓按鈕
  • 同一次捨入也清掉位元 1 到 3,表 226 的 ReadOnly、Required 與 NoExport 隨 Radio 一起消失
  • $80003FFF 變 $80004000:表 230 的位元 13(Multiline,$1000)與位元 14(Password,$2000)同時被清除,遮蔽的密碼框變成單行純文字欄位
  • 位元 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 到 16、添上 Pushbutton 位元 17,變成 $80010000
ISO 32000-1 表 226、228 與 230 的旗標成群移動:Radio 翻成 Pushbutton,而 ReadOnly、Required、NoExport、Multiline 與 Password 可能在同一次捨入中消失

HotPDF 怎麼讀出精確的 32 位元整數 token

HotPDF Delphi Component 修好了讀取端,而沒有動數值物件模型。解析器本來就把原始 token 文本記在 Single 值旁邊,於是欄位旗標讀取把那個 token 當整數解析,放進一個有上界的 Int64 累加,再把得到的 32 位元複製進公開 API 回傳的 Integer。通用數值 API 與舊的非整數回退保持原樣,只有欄位旗標的消費端走精確路徑

Delphi 中 HotPDF 的精確 AcroForm 欄位旗標讀取路徑:解析器把來源 token 保存在 Single 值旁,TryParseFlagMask 放進有上界的 Int64 累加,32 位元以 Integer 抵達 GetLoadedFormFieldFlags 與 SetLoadedFormFieldFlags
欄位旗標解析的是記住的 token 文本而不是捨入後的 Single,所以高位元遮罩的兩種寫法 2147549183 與 -2147418113 帶著完全相同的位元到達

解析器得同時接受真實檔案拼寫高位元遮罩的兩種方式。有些寫入器發出無號值 2147549183,它超過附錄 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;
  // 帶號 token 到 -2^31,無號 token 到 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;

有兩個細節是刻意的。上界在每次乘法之前檢查,所以 400 位數的 token 會乾淨地失敗,而不是讓 Int64 溢位。而 3.0 這樣的 token 會被拒絕,把呼叫方送回 Round(Value);精確路徑改進整數遮罩,但不為畸形輸入發明新語意

從應用程式碼看,簽名什麼都沒變。THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer 回傳生效遮罩,沿著HotPDF 如何解析繼承的 AcroForm 欄位值並重設描述的同一條 /Parent 鏈向上走,THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) 則寫入完整的本地遮罩。bit 32 被設立的遮罩就是一個負的 Integer,所以請搬移位元,不要轉換數值:

const
  FF_RADIO      = $00008000;  // 位元 16,ISO 32000-1 表 228
  FF_PUSHBUTTON = $00010000;  // 位元 17,表 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 兩個捷徑從生效的繼承遮罩出發,翻轉一個位元,其餘每個位元保持精確。要填的是值而不是旗標的話,請看用 HotPDF 在已載入 PDF 中設定表單欄位值

為什麼 Value 與 RememberSourceToken 的順序很重要?

順序重要是因為 THPDFNumericObject 只在 Single 值未被改動時才信任它記住的 token。RememberSourceToken 存 token 時連同當前 Value 的快照一起存,SourceToken getter 在 Value 與快照不同時立刻回傳空字串。正是這條失效規則,防止被編輯過的數字帶著過期的原文被存出去

這條規則在寫入端變成陷阱。先記 token 再指派值,快照裡就是舊值,getter 回報沒有 token,序列化器退回格式化捨入後的 Single。程式庫裡每個旗標寫入者,包括捷徑與原生欄位建構子,都遵守同一個順序:

// 正確:快照與 Value 相符,精確 token 能活過存檔
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 只要 token 有效就寫出記住的 token,順序對了,存檔再開啟的檔案才會帶著原始整數,而不是它的捨入近似

讓兩個欄位共用同一個名稱的 null token

同一版還修了測試這些遮罩時發現的第二個解析器缺陷:從流讀入的字典裡的 null 值從未被消費。HotPDF 會先窺看下一個 token 來分類,而那次窺看把流位置留在 token 開頭。流式字典讀取器的 otNull 分支建立了 null 物件,卻沒有越過 null 的四個位元組

後果不是卡死,而是靜默的誤讀。下一次鍵查找從錯誤位置向後掃,越過當前字典的結尾,讀到下一個間接物件的鍵,那些鍵接著覆寫了當前欄位。在欄位清單裡,症狀是兩個不同的欄位回報同名同值。陣列與 span 解析器本來就正確消費 null,所以修法是在那一個分支把流推進四個位元組,而不是重寫通用解析器。不動的游標與讓 HotPDF 卡死在畸形輸入上的 PDF 解析器游標停滯是同一家族的 bug,只是症狀不同

這次修復也釐清了繼承。本地的 /Ff null,或解析成 null 的參照,視同不存在,查找繼續往上;明確的 0 則是真正的遮罩,會覆蓋父節點

{$R-} 指令能證明什麼、不能證明什麼

對旗標 bug 的第一個假設是大型遮罩指派給 Integer 時觸發範圍檢查錯誤。這個假設錯了,原因是一個值得知道的 Delphi 細節:HPDFDoc.pas 裡放在 ParseSimpleDictionary 之前的局部 {$R-} 指令沒有配對的還原,於是該單元裡排在它後面的每個函式都在範圍檢查關閉下編譯。Delphi 不會在函式結尾重置開關指令,include 檔則在被打包進來的位置繼承指令狀態

所以命令列的 -$R+ 或 -Cr 只能證明單元的初始設定,對更下方的某個特定函式說不了什麼。要知道某個常式是否開著範圍檢查,先找它上方最近的指令,再用真實的邊界值確認行為。兩個較舊的局部旗標消費端從未丟出假設預言的範圍例外,所以那個沒出現的例外不能當成已修復的證據。真正的缺陷是跨越已知旗標位元的 Single 捨入

精確 token 讀取對自己的適用範圍也很誠實。它不改變單元級指令狀態,不賦予保留位元任何意義,也不宣稱每種位元組合都是合法欄位。它保證的是:檔案裡的 32 位元,就是您的程式碼看到的 32 位元

FAQ

為什麼不把 THPDFNumericObject.Value 改成 Double 或 Int64?

改公開欄位的型別會破壞每個讀取或指派它的呼叫方,而 PDF 數字是實數與整數各半。保留 Single、從原始 token 讀欄位旗標,在不動任何既有宣告或呼叫慣例的前提下修好了位元遮罩

SetLoadedFormFieldFlags 接受 bit 32 被設立的遮罩嗎?

接受。把遮罩當帶號 Integer 傳入;$80000002 與 -2147483646 是同樣的 32 位元。GetLoadedFormFieldFlags 在存檔重載後回傳同樣的值,Required 也在

哪個 HotPDF 版本開始精確讀取 AcroForm 欄位旗標?

精確欄位旗標讀取與 null token 修復隨 HotPDF v2.989.5 推出,結構化 JSON 與 XML 匯出在 v2.989.8 換上精確遮罩。更早的版本對超過 2^24 的遮罩一律經 Single 捨入

精確 AcroForm 欄位旗標、繼承欄位查找與已載入表單編輯都是HotPDF Component for Delphi and C++Builder 的一部分,這是一個用於建立、載入與編輯 PDF 文件的原生 PDF 程式庫