技术文章

PDF 表单字段值为何回退:Delphi 中的直接字段

当字段以直接字典存储时,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 的写入器,在字典层面获得的是货真价实的成功。而字段树——表单层和所有后续阅读器当作事实来源的东西——永远看不到这次修改

这就是为什么旧值能挺过保存与重开:新值被忠实地序列化进了一个字段层级并不拥有的对象

Delphi 中的 PDFium Component:两个直接字典声称同一个字段,写入命中被提升的注释,字段树仍读旧的 /Fields 副本
写入被忠实地序列化进字段层级不拥有的对象,旧值因此挺过保存与重开

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 的 PDFium Component 如何统一直接 AcroForm 字段归属:快照、NM 身份检查、引用统一、值写入与经验证发布,各带明确的拒绝路径
直接字段字典在 /V 改动之前被提升并重绑到同一个间接属主,归属含糊时干脆拒绝

在 Delphi 里写入并验证直接字段值

安全的模式是:写入,然后经原生表单层读回,即使写入返回 True 也把不匹配当作失败。TPdf.SetFormFieldValue 按当前页上的索引寻址字段,收一个类型化的 TPdfFormValue 并填充 TPdfFormValueWriteReport;TPdf.FormField[Index] 经 PDFium 的表单层读回值——它解析字段树,而不是信任 widget

Delphi 中配合 PDFium Component 的 PDF 表单字段写入与验证模式:类型化 SetFormFieldValue、经 FormField 读回,再做保存与重开检查
返回 True 只证明字典变了——要经字段树读回,并测试保存与重开
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 或其他 string RTL 助手

边界也要说清楚。已签名文档、XFA 表单、加密快照,以及 widget 归属无法唯一解析的文件,都留在声明的 profile 之外,选择拒绝而不是硬闯。如果你的工作流要在文档之间搬字段,归属问题只会更难;跨 PDF 文档嫁接 AcroForm 字段处理的就是那种情形

PDFium Component 把这种类型化、事务性的表单写入带给 Delphi、C++Builder 和 Lazarus 应用,渲染、文本提取和文档编辑也在其中。版本历史、授权与下载见 PDFium Component for Delphi 产品页