当字段以直接字典存储时,PDF 表单字段值就会“回退”:PDFium 把页面 /Annots 数组里的那份副本提升为间接对象,而 /Fields 还留着第二份直接副本。PDFium Component for Delphi 处理类型化字段写入的方式,是在写入前把两处引用统一到同一个间接属主
这个症状通常读起来像闹鬼。写入返回 True,紧接着的下一次读取却把旧值递回来。保存、重开,旧值还在。什么都没抛,这份 PDF 在你手头每个查看器里都正常得不能再正常
症状:成功的写入留不下痕迹
失败模式很窄但极易复现:只出现在字段字典以内联方式(作为直接对象)而不是编号间接对象书写的表单上。大多数创作工具输出间接字段,所以问题能潜伏几个月,然后在某个特定生成器的文件上现身——常常是整个 /AcroForm 都内联写出的手搓导出器
两个观察放在一起就指向了原因:写入报告成功,而经表单层对字段的原生读取返回旧值。两个都见到时,别再怀疑你的字符串编码,开始看对象归属
为什么 PDF 表单字段值在写入成功后回退?
PDF 表单字段值回退,是因为两个独立字典都声称自己是同一个字段,而写入更新的是字段树根本不查的那份副本。字段的值住在它的 /V 条目里,ISO 32000-1 §12.7.3.1 定义了该条目在哪里才是权威:在从 /AcroForm /Fields 可达的字段字典上,沿 /Kids 下行到终端字段,可继承条目从 /Parent 解析
同一条款还允许了一个惹出全部麻烦的便利:字段恰有一个 widget 注释时,字段字典与 widget 注释字典可以合并成单个字典。在良构文件里,这个合并字典是一个间接对象,被两个地方引用:来自 /Fields(或父字段的 /Kids)和来自页面的 /Annots 数组。两处引用,一个对象,一个 /V
一个字典的两份副本
ISO 32000-1 §7.3.10 明确指出只有间接对象才有可共享的身份。直接字典没有对象编号,所以当生成器在 /Fields 和 /Annots 里都内联写出合并的字段/widget 字典时,文件里并不是一个被引用两次的对象,而是两个长得一样、碰巧键值相同的字典
PDFium 容忍这种文件,而且这么做就把分裂变成了永久性的。需要注释句柄时,PDFium 可以把页面 /Annots 数组里的直接 widget 字典提升为间接对象,但等价的直接副本仍待在 /AcroForm /Fields(或父字段的 /Kids)里的原处。从那一刻起,文档为一个逻辑字段持有两个属主。在页面注释上编辑 /V 的写入器,在字典层面获得的是货真价实的成功。而字段树——表单层和所有后续阅读器当作事实来源的东西——永远看不到这次修改
这就是为什么旧值能挺过保存与重开:新值被忠实地序列化进了一个字段层级并不拥有的对象
PDFium Component 如何恢复单一字段属主
PDFium Component 在每次类型化写入前规范化直接字段归属:把直接的 /AcroForm 和字段字典提升为间接对象,把每个直接 widget 绑定到与之匹配的页面注释,并重写 /Fields、/Kids、/Parent 和 /Annots,让每处引用都指向同一个间接对象。这之后才改 /V,字段值、所选 widget 和保存的文档共享一个属主
难的是身份。内容完全相同的两个直接字典证明不了它们是同一个 widget;一个表单完全可以合法地含有两个长得一样的 widget。所以规范化器拒绝猜测,要求每个直接 widget 带有显式且唯一的 /NM 注释名(ISO 32000-1 §12.5.2)。写入路径执行的规则很严格:
- 直接 widget 需要非空的
/NM,至多 1024 个 UTF-16 单元,且同一名称只能有一个页面注释携带 - 直接副本与页面注释必须序列化后完全一致,
/Parent和 PDFium 再生的/AP除外;任何其他差异都拒绝写入 - 两条父链接分别检查,所以页面注释上正确的
/Parent遮不住原始直接副本上冲突的那条 - 缺失的
/Parent仅在字段树归属唯一、答案无歧义时补上,等价的直接父副本被重绑到提升后的属主 - 重复的全限定字段名、成环或多重归属的字段树、签名字段,在发布任何东西之前拒绝
- 无关注释(如同页上的链接)和无关的
/AcroForm条目原样保留
规范化器还在硬预算下运行:字段树访问、页面或数组成员 4096,嵌套深度 64,新间接对象 256,每个提升字典 1 MiB,提升字节总量 4 MiB,检查的字典字节 128 MiB,单个全限定字段名 4096 个 UTF-16 单元、全表单 262144。恶意或损坏的文件撞上预算就带错误信息失败,而不是把一次填表变成拒绝服务
整个操作是事务性的。TPdf.SetFormFieldValues 捕获快照,构建包含规范化对象和新值的增量更新,加载候选,只有候选通过验证才替换活文档。规范化拒绝该文件,或批量中某条后续请求失败时,原文档、其值和逻辑字段游标都保持原样
在 Delphi 里写入并验证直接字段值
安全的模式是:写入,然后经原生表单层读回,即使写入返回 True 也把不匹配当作失败。TPdf.SetFormFieldValue 按当前页上的索引寻址字段,收一个类型化的 TPdfFormValue 并填充 TPdfFormValueWriteReport;TPdf.FormField[Index] 经 PDFium 的表单层读回值——它解析字段树,而不是信任 widget
uses
System.SysUtils, PDFium;
procedure WriteTextField(Pdf: TPdf; PageNo, FieldIndex: Integer;
const NewText: WString);
var
Value: TPdfFormValue;
Report: TPdfFormValueWriteReport;
begin
Pdf.PageNumber := PageNo; // SetFormFieldValue 面向当前页
Value := Default(TPdfFormValue);
Value.Kind := fvkText;
Value.Text := NewText;
if not Pdf.SetFormFieldValue(FieldIndex, Value, Report) then
raise Exception.CreateFmt('Field %d on page %d rejected: %s',
[FieldIndex, PageNo, Report.ErrorMessage]);
// 对照字段树验证,而不是对照我们自己的变量
if Pdf.FormField[FieldIndex] <> NewText then
raise Exception.CreateFmt('Field "%s" did not keep the new value',
[Pdf.FormFieldInfo[FieldIndex].Name]);
end;
在归属无法解析的文件上——比如直接 widget 没有 /NM——SetFormFieldValue 返回 False,Report.UpdatedWidgets 为零,Report.ErrorMessage 点名问题,比如 “Direct widgets require an explicit unique NM identity”。这正是你想要的行为:明确的拒绝,而不是无声的半次写入。还有一个前置条件值得知道:类型化写入只动注释、不获取原生焦点,所以原生字段编辑器还持有焦点时,整个事务在捕获快照之前就拒绝;请显式先调 Pdf.ClearFormFieldFocus,不要在循环里硬重试
多个字段、尤其跨页时,用批量形式。TPdf.SetFormFieldValues 接受 1 到 256 条请求,全部应用到一份候选上,要么全发要么不发。TPdfFormValuesWriteReport.FailedRequest 会告诉你是哪条请求弄坏了这批
procedure FillInvoiceHeader(Pdf: TPdf);
var
Requests: TPdfFormValueRequests;
Batch: TPdfFormValuesWriteReport;
begin
SetLength(Requests, 2);
Requests[0] := Default(TPdfFormValueRequest);
Requests[0].Page := 1;
Requests[0].FieldIndex := 0;
Requests[0].Value.Kind := fvkText;
Requests[0].Value.Text := 'ACME Tooling Inc.';
Requests[1] := Default(TPdfFormValueRequest);
Requests[1].Page := 2;
Requests[1].FieldIndex := 0;
Requests[1].Value.Kind := fvkText;
Requests[1].Value.Text := '2026-10-27';
if not Pdf.SetFormFieldValues(Requests, Batch) then
raise Exception.CreateFmt('Request %d failed: %s',
[Batch.FailedRequest, Batch.ErrorMessage]);
// Batch.UpdatedFields = 2;Batch.UpdatedWidgets 数出每个同步的 widget
end;
寻址同一终端字段的两条请求会被当作重复属主拒绝,而不是让最后一次写入获胜,源文档得以保留。checkbox 和 radio 值遵循同样的规则;按钮的 /V 与 /AS 之分见 PDFium 中 checkbox 字段值与 widget 状态
证明值能挺过保存与重开
同一会话内的读回证明活文档是自洽的;保存并重开证明文件是,这个检查便宜到值得放进你自己的集成测试
procedure SaveAndReopenCheck(Pdf: TPdf; const Target: string;
PageNo, FieldIndex: Integer; const Expected: WString);
var
Reopened: TPdf;
begin
if not Pdf.SaveAs(Target, saNoIncremental, pvUnknown) then
raise Exception.Create('Save failed');
Reopened := TPdf.Create(nil);
try
Reopened.FileName := Target;
Reopened.Active := True;
Reopened.PageNumber := PageNo;
if Reopened.FormField[FieldIndex] <> Expected then
raise Exception.Create('Saved file lost the field value');
finally
Reopened.Free;
end;
end;
另外两个字节级陷阱:Trim 与 trailer /ID
规范化直接字段意味着重读原始字典字节,这带出了两个更小的陷阱,对任何碰 PDF 语法的 Delphi 代码都适用
第一个是对 AnsiString 里原始 PDF 字节调 SysUtils.Trim。choice 选项这样的字面量字符串可以合法地包含带 FE FF 字节序标记和内嵌 NUL 字节的 UTF-16BE 文本。在 Unicode Delphi 里,SysUtils.Trim 收的是 string,所以这次调用会把 AnsiString 经当前 ANSI 代码页悄悄转成 UTF-16 再转回来。取决于那个代码页,这一来一回可能改写高位字节、损坏 BOM,甚至扰动紧随其后的分隔符。安全的做法让词法分析停在字节层面:按位置跳过 PDF 空白(ISO 32000-1 §7.2.2 列了 NUL、tab、LF、FF、CR 和空格),用 Copy 切片,只在确认某值是 PDF 文本字符串之后才解码
// 对原始 PDF 字节有风险:AnsiString -> string -> AnsiString(W1057/W1058)
Token := Trim(Token);
// 字节安全:按位置跳过 PDF 空白,再切片
P := 1;
while (P <= Length(Token)) and (Token[P] in [#0, #9, #10, #12, #13, ' ']) do
Inc(P);
Token := Copy(Token, P, MaxInt);
第二个陷阱在增量更新本身。ISO 32000-1 §7.5.5(Table 15)和 §14.4 要求 trailer 的 /ID 是两根字节字符串组成的数组。负责拼出稀疏更新标识符的内部助手只返回两根字符串的内容,不带 /ID 键也不带数组方括号,trailer 必须显式包成 /ID [ ... ]。PDFium 的宽松加载器和之后的完整重存都会藏住缺失的包装,所以可靠的检查是用独立严格解析器解析候选文件和已保存文件,找那两根 16 字节字符串。自己写增量更新时同样的陷阱成立;加密 PDF 的增量更新一文更深入地讲了 trailer 链
在你自己的 Delphi 表单代码里该做什么?
- 写入之后总是用
FormField[Index]读回值,测试里还要保存、重开、再读一次 - 把
False加ErrorMessage当信息用:点名NM、父冲突或重名的拒绝,意味着文件的归属真的含糊 - 生成 PDF 时,把字段和 widget 都发成间接对象,并给每个 widget 唯一的
/NM;这从源头消除歧义 - 多字段或跨页填写用
SetFormFieldValues,晚到的失败才不会留下半个已更新的表单 - 绝不对
AnsiString里的原始 PDF 字节跑Trim或其他stringRTL 助手
边界也要说清楚。已签名文档、XFA 表单、加密快照,以及 widget 归属无法唯一解析的文件,都留在声明的 profile 之外,选择拒绝而不是硬闯。如果你的工作流要在文档之间搬字段,归属问题只会更难;跨 PDF 文档嫁接 AcroForm 字段处理的就是那种情形
PDFium Component 把这种类型化、事务性的表单写入带给 Delphi、C++Builder 和 Lazarus 应用,渲染、文本提取和文档编辑也在其中。版本历史、授权与下载见 PDFium Component for Delphi 产品页