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 的倍数
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 怎么读出精确的 32 位整数 token
HotPDF Delphi Component 修好了读取侧,数字对象模型不动。解析器本来就把原始 token 文本记在 Single 值旁边,于是字段标志读取器把这个 token 按整数解析,累加进有界的 Int64,再把得到的 32 位复制进公共 API 返回的 Integer。通用数字 API 和旧的非整数回退保持原样,只有字段标志消费者走精确路径
解析器必须同时接受真实文件拼高位掩码的两种写法。有的写入方发无符号值 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 库