技术文章

Delphi 里 Single 浮点如何损坏 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 的倍数

HotPDF 在 Delphi 里的 AcroForm 字段标志舍入图解:32 位 /Ff 掩码 $8000FFFF(2147549183)经过带 24 位有效数字的 Single,回来成了 $80010000,因为 2^31 到 2^32 之间可表示的 Single 值相距 256
2^31 以上 Delphi 的 Single 只能存 256 的倍数,就近舍入改写 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 开始给标志位编号,所以 bit 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)同时被清零,带掩码的密码框变成了单行纯文本字段
  • bit 32 以下,损害从 2^24 开始:16777219(Comb 加 ReadOnly 和 Required)舍成 16777220,丢掉 ReadOnly 和 Required,多出 NoExport

最后一种情况对 HotPDF 的影响超出表单编辑。ExportLoadedPagesStructured 用有效掩码构建它的 JSON 和 XML 表单模型,并在值抑制决策里使用 NoExport,所以一个被舍入的掩码可能藏起一个普通值,或报错 readOnly 和 required 成员。结构化导出路径在 v2.989.8 拿到了同样的精确读取

HotPDF 在 Delphi 里的 AcroForm 字段标志位图:/Ff 掩码 $8000FFFF 带位 32 和位 1 到 16,一次 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 和旧的非整数回退保持原样,只有字段标志消费者走精确路径

HotPDF 在 Delphi 里精确读取 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 返回有效掩码,沿 /Parent 链上溯的方式与 HotPDF 如何解析继承的 AcroForm 字段值并重置一文相同;THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) 写入完整的本地掩码。位 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 就写它,把顺序做对,正是保存后再打开的文件携带原始整数、而不是它的舍入近似的关键

让两个字段共用一个名字的 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 接受位 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 库