技术文章

用 PDFium 组件原子化批量填写 PDF 表单

PDFium Component for Delphi 用 TPdf.SetFormFieldValues 原子化填写 AcroForm:你传入 1 到 256 条按页和 widget 寻址的类型化请求,组件要么在一次文档替换里应用所有值,要么一个都不动。某条请求无效时,TPdfFormValuesWriteReport.FailedRequest 会点名拖垮整批的那条请求

这份保证在无人盯着的任务里最重要。夜间导入把 CRM 行灌进理赔表单,或打印服务把保单数据叠印到预印模板上。逐字段跑这些任务,第一个坏值——一个不是合法 radio 导出值的代码、一根超过字段 MaxLen 的字符串——就让你手里攥着一份半旧半新的 PDF。什么都没崩,所以也没人被惊动。批量 API 的存在,就是为了让失败是一句干脆的拒绝,而不是一份写了一半的文档

为什么逐字段填表对数据导入很危险?

逐字段填表危险,是因为每次写入都各自提交,循环中途出错就会留下一份既不像源记录也不像原模板的文档。经典模式是对 FormField[Index] 赋值写循环,每次经 PDFium 的原生填表环境处理一个 widget。第 1 到 17 个值进去了,第 18 个抛异常,调用方现在必须精确知道哪些值已经进去才能撤销

大多数调用方根本不写撤销代码,写了的通常也错得很隐蔽:共享字段已经在它出现的每一页上都变了,checkbox 状态已经翻转。如果保存在 finally 块里跑,填了一半的文件就以完整的样子落盘。对套印任务,这比没有文件更糟——因为它会被印出来,还会有人签字

另一个安静的问题是焦点。经典 setter 驱动 PDFium 的交互式编辑器:杀掉既有焦点、聚焦目标 widget、全选文本、替换、再杀掉焦点。无界面任务无所谓,但在查看器里,它还会终结用户在另一个字段里开着的编辑会话。PDFium Component 3.231.0 引入的类型化写入路径完全不经过原生字段焦点,3.233.0 又把它从单字段扩展到跨页批量

SetFormFieldValues 如何用一次替换应用整批

TPdf.SetFormFieldValues 让每条请求都对着文档的一份捕获快照进行演练,一遍写完所有字段字典,再用单次替换换入完成的结果。原文档活到最后一刻,所以那一步之前的任何失败都碰不到它

Delphi 的 PDFium Component 五阶段原子批量填表序列:从 preflight 到验证换入,全有或全无
每条请求都对着同一份快照演练,活文档只在最后经验证的换入时被触碰

这个序列把保证和代价一并解释了:

  1. 先跑 preflight 检查:文档必须处于活动状态,请求数必须在 1..256,不得有打开的 page-object 或 edit-journal 事务,不得有原生字段持有焦点,文档必须授予填表或修改权限
  2. 每条请求按页范围和输入预算校验,FailedRequest 跟踪正在检查的索引
  3. 组件捕获编辑快照,在副本里规范化 AcroForm,然后按顺序应用每条请求,写出 /V、选择项的 /I,以及共享该字段的每个 widget 的 /AS
  4. 变更以一个稀疏增量更新(ISO 32000-1 §7.5.6 的意义下)追加到快照上,无关对象逐字节原样带过
  5. 一个独立的校验实例先加载候选字节;只有它顺利打开之后,组件才卸载活文档并加载新文档,若加载失败则回退到原快照

内部替换步骤与组件编辑日志快照用的是同一套机制,所以 撤销与崩溃恢复日志和批量写入不能重叠:先完成或取消日志事务。文档换入期间自动生命周期动作被抑制,文档打开脚本无法在替换中途改写字段或抢焦点

在 Delphi 里构建类型化请求批量

一个批量是 TPdfFormValueRequest 记录的动态数组,每条带 1 基的 Page、页内 0 基的 FieldIndex 和一个类型化的 TPdfFormValue。值记录带一个 Kind(fvkText、fvkChoice、fvkCheckBox 或 fvkRadioButton),外加按该类别使用的 Text、ChoiceIndices 和 Checked 成员

导入数据通常按字段名组织,所以第一件事是把名字翻译成页和 widget 地址。FieldIndex 按与 FormFieldCount、FormFieldInfo 相同的顺序数页面上的 widget 注释,简单的扫描就能用。助手对名字只取找到的第一个 widget——这对共享字段有影响,下一节解释

type
  TFieldAddress = record
    Page, FieldIndex: Integer;
  end;

function FindFieldAddress(Pdf: TPdf; const FieldName: WString;
  out Address: TFieldAddress): Boolean;
var
  P, I: Integer;
begin
  Result := False;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    for I := 0 to Pdf.FormFieldCount - 1 do
      if Pdf.FormFieldInfo[I].Name = FieldName then
      begin
        Address.Page := P;
        Address.FieldIndex := I;
        Exit(True);      // 只取第一个 widget:每个字段一条请求
      end;
  end;
end;

procedure AddRequest(var Requests: TPdfFormValueRequests;
  const FieldName: WString; Pdf: TPdf; const Value: TPdfFormValue);
var
  Address: TFieldAddress;
  N: Integer;
begin
  if not FindFieldAddress(Pdf, FieldName, Address) then
    raise Exception.CreateFmt('Template has no field "%s"', [FieldName]);
  N := Length(Requests);
  SetLength(Requests, N + 1);
  Requests[N].Page := Address.Page;
  Requests[N].FieldIndex := Address.FieldIndex;
  Requests[N].Value := Value;
end;

注意 AddRequest 遇到未知名字会抛异常而不是跳过。批量校验的是地址,不是你的映射,所以缺失的模板字段必须在这里失败,否则它永远不会失败。地址解析完之后,构建值并提交就很短了

var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
  V: TPdfFormValue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'claim-template.pdf';
    Pdf.Active := True;

    V := Default(TPdfFormValue);
    V.Kind := fvkText;
    V.Text := 'Maria Lopez';
    AddRequest(Requests, 'Claimant.Name', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkCheckBox;
    V.Checked := True;
    AddRequest(Requests, 'Consent', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkRadioButton;
    V.Text := 'Express';                // 要选中按钮的导出值
    AddRequest(Requests, 'Shipping', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkChoice;
    SetLength(V.ChoiceIndices, 1);
    V.ChoiceIndices[0] := 2;            // 字段 Opt 数组的 0 基索引
    AddRequest(Requests, 'Region', Pdf, V);

    if Pdf.SetFormFieldValues(Requests, Report) then
      Pdf.SaveAs('claim-filled.pdf')
    else
      LogBatchFailure(Requests, Report);
  finally
    Pdf.Free;
  end;
end;

在写任何东西之前,类型化类别会对照字段类型检查。文本值对准 checkbox、radio 值对准普通 checkbox、或对 push button 的写入,都会被拒。choice 索引必须有效、唯一且升序;多于一个索引要求 MultiSelect 标志,choice 字段里的自由文本只有可编辑 combo box 才接受。超过字段 MaxLen 的文本值被拒而不是被截断,只读字段直接拒绝。对带 NoToggleToOff 标志的 radio 组,空导出值——意思是“清空该组”——也被拒

为什么同一共享字段写两次会拖垮整批?

TPdf.SetFormFieldValues 拒绝解析到同一终端字段的两条请求,因为它们要写的是同一个值,组件不允许一条请求悄悄覆盖另一条。检查按字段属主进行而不是 widget,所以同一字段在不同页上的两个不同 widget 仍算重复

原因在 ISO 32000-1 §12.7.3.1 的字段模型。终端字段拥有值;它的 widget 注释只是展示这个值的场所。一个出现在每页页眉的 PolicyNumber 字段是一个带多个 widget 的字段,每个 widget 显示的都是同一个 /V。radio 组同理:每个按钮是 widget,组才是字段。经第 1 页的 widget 写一次 PolicyNumber、再经第 9 页的 widget 写一次,是同一字段被写两次,任何挑出获胜者的规则都在掩盖你映射里的 bug

所以批量把它当错误处理,并在 FailedRequest 里点名第二条请求。实践中修法在上游:像 FindFieldAddress 那样把每个字段名解析到唯一地址,让组件把值传播到其他 widget。组件在写入过程中就做了这件事,给共享 checkbox 或 radio 组的每个 widget 设置 /AS,让外观状态与字段值一致——这一拆分在 checkbox 字段值与 widget 状态一文里讲过

一个终端字段拥有值,两页上的 widget 展示它,重复请求让 Delphi PDFium Component 的整批失败
每个终端字段一条请求——组件只写一次 /V,并给共享它的每个 widget 设 /AS

读失败报告

失败的批量返回 False,不抛异常,原文档和每个字段值原封不动。TPdfFormValuesWriteReport 随后报告 Succeeded = False、UpdatedFields 与 UpdatedWidgets 均为零、问题请求的 0 基索引在 FailedRequest,以及 ErrorMessage 里一条人读得懂的原因

procedure LogBatchFailure(const Requests: TPdfFormValueRequests;
  const Report: TPdfFormValuesWriteReport);
var
  R: Integer;
begin
  R := Report.FailedRequest;
  if R >= 0 then
    Log(Format('Request %d (page %d, widget %d) rejected: %s',
      [R, Requests[R].Page, Requests[R].FieldIndex, Report.ErrorMessage]))
  else
    Log('Batch rejected as a whole: ' + Report.ErrorMessage);
end;

FailedRequest 为 -1 意味着拒绝属于事务本身而非某条请求:原生焦点还活着、文档已签名、表单是 XFA,或权限检查没过。非负值指向一条具体条目。对聚合预算超限,它指向把批量推过上限的那条请求——未必就是错的那条。在 Requests 旁边维护一个源列名的平行数组,就能把索引 14 直接翻成导入日志里的“第 2087 行,ShipDate 列”

成功时 UpdatedFields 等于请求数,UpdatedWidgets 数出每个被触碰的 widget,共享的也算——对模板来说是次廉价的健全性检查

你会撞上的前置条件与限制

查看器应用里最常见的拒绝是原生焦点:用户正在字段里打字时,SetFormFieldValues 连快照都不捕获就直接拒绝,挂起的编辑缓冲原样幸存。组件不会背着你丢掉焦点,因为那要么提交、要么弄丢用户没写完的文本。请显式调用 ClearFormFieldFocus——它以点击字段外的方式结束编辑会话——然后再重试

预算是固定的且有文档,可以照着给导入定规模:

  • 每批 1 到 256 条请求;更大的记录拆成多批,并接受原子性只按批成立
  • 每条请求至多 262,144 个 UTF-16 单元的文本和 4,096 个 choice 索引
  • 整批合计至多 1,048,576 个 UTF-16 单元的文本和 16,384 个 choice 索引
  • 快照解析器通常的文档预算照常适用,含 64 MiB 源和 65,536 个对象

成功的替换还会让调用之前获取的原生句柄、搜索和选择失效,因为底下的文档已经是新的。页面与视图激活状态和逻辑字段游标保留;你缓存的其他任何东西——注释或 page-object 句柄之类——必须重新获取

这与 XFDF 导入有什么不同?

TPdf.ImportXFDF 和 TPdf.SetFormFieldValues 都往字段里放数据,但 XFDF 导入是宽容合并,批量是严格事务。XFDF 导入按名字把值匹配到字段,跳过找不到的名字,忽略重复项,返回应用了多少条。合并审阅者批注时这恰到好处——部分结果依然有用

批量在每一点上都反着来。它显式寻址 widget,拒绝未知或类型不对的目标,把重复当错误,没有部分成功。数据是来自其他工具的交换文件时用 XFDF;映射归你自己的代码管、半途应用的记录就是缺陷时,用 SetFormFieldValues

XFDF 导入与 Delphi PDFium Component 原子 SetFormFieldValues 批量的决策矩阵:寻址方式、未知与重复目标、结果与适用场景
XFDF 宽容地合并交换文件,批量则拒绝未知名字、重复和部分结果

批量写入不覆盖什么

批量是一个有边界的 AcroForm profile,出了这个 profile 它选择拒绝而不是瞎猜。XFA 表单拒绝,需要专用编辑器。已签名文档拒绝——在既有签名下改写字段字典是签名工作流,不是填表操作;加密快照同样在 profile 之外。畸形或含糊的按钮导出数组拒绝,匹配多个按钮的 radio 导出值被拒,除非组设了 RadiosInUnison。事务还在写入前规范化直接字典字段,字段值看似回退背后的就是这种情形

外观值得认真一句。对发生变化的文本和 choice widget,批量丢弃过期的 /AP 而不是带着显示旧值的外观出货,并关掉 NeedAppearances,免得 PDFium 把表单里每个无关 widget 都重建一遍。PDFium 渲染时会再生缺失的外观。如果填好的 PDF 要进一条从不合成外观的严格打印管线,请先经 PDFium 渲染或压平,并用你自己的模板测试这条路径

SetFormFieldValues 和类型化值记录随 PDFium Component for Delphi and Lazarus 提供,产品页列出了完整的填表与 AcroForm API,以及渲染、编辑和查看器控件