技术文章

Delphi 动态 XFA 表单运行时:HotPDF 事务

HotPDF 在 Delphi 中通过 TXFAWidgetRuntime 填写动态 XFA 表单,这是一个宿主机中立的部件层,把每次字段编辑当作一个事务:快照、验证、计算、重排,然后整体发布或整体回滚。它在你的 VCL 或 FMX 宿主机内单线程运行,不需要安装 Acrobat,并且在分配任何东西之前执行每一项预算

凡是把文档软件交付到政府或保险业务的人,对这个场景都不陌生。一份理赔单或纳税申报以 PDF 形式到达,其页面内容只有一句“Please wait... if this message is not eventually replaced”提示,而每个真实字段都住在只有 Adobe Acrobat 才渲染的 XFA 包里。你的用户想在你的应用程序里填它。栅格化也救不了你,因为表单随数据录入增长行数,第三行之后的布局已经不是文件交付时的布局

为什么动态 XFA 仍是值得解决的问题

动态 XFA 经久不衰,因为已部署的表单比承载它们的格式活得长。ISO 32000-1 §12.7.8 把 XFA 描述为 AcroForm 字典上的 /XFA 条目,装着一个 XDP 包流,而 ISO 32000-2 弃用了整套机制;弃用把它移出了路线图,却没有移出野外,按 XFA 3.3 规范制作的表单仍在签发、仍有法律效力。静态 XFA 可以约简成普通部件注释,调用 ApplyXFAAsAcroForm 时 HotPDF 就是这么做的,取舍见把 XFA 表单展平为 AcroForm 字段。动态 XFA 是另一种动物:它的 occur 范围、可增长文本和 calculate 脚本让字段集成为数据的函数,所以在用户输完之前不存在可供展平的固定注释清单。TXFAWidgetRuntime 填的正是这个缺口:保持 XFA DOM 存活,每次接受的编辑后重算布局,并交给宿主机一个已定位部件的扁平数组去绘制和命中测试

运行时交给宿主机应用的是什么?

交给你的是几何和状态,没有任何假定 UI 工具包的东西。TXFAWidgetRuntime 暴露 WidgetCountWidgets[I],即携带 IDNameKindPageIndex、以 PDF 点为单位的 BoundsValueEditValue 以及 FocusedEditingReadOnlyValid 标志的 TXFAWidgetState 记录,而绘制、光标渲染和键盘路由留在你的代码里。部件身份稳定且有序:每个部件得到形如 name[n]ID,其中 n 按布局顺序数该字段名此前的出现次数,于是重复子表单的第二行是 amount[1]。这个身份在重建后存活,也是 FocusWidgetBeginEditDispatchEventHitTest 通用的语言。对已在 THotPDF 实例中打开的文档,CreateLoadedXFAWidgetRuntime 抽取 XDP 包,取第一个页面框作为布局页面尺寸,文件完全不带 XFA 时返回 nil

var
  Pdf: THotPDF;
  Runtime: TXFAWidgetRuntime;
  WidgetID: AnsiString;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('claim-dynamic.pdf');
    Runtime := Pdf.CreateLoadedXFAWidgetRuntime;   // 没有 /XFA 时为 nil
    if Runtime = nil then
      Exit;
    try
      for I := 0 to Runtime.WidgetCount - 1 do
        Memo1.Lines.Add(Format('%s p%d [%.1f %.1f %.1f %.1f] = %s',
          [string(Runtime.Widgets[I].ID), Runtime.Widgets[I].PageIndex,
           Runtime.Widgets[I].Bounds.Left, Runtime.Widgets[I].Bounds.Top,
           Runtime.Widgets[I].Bounds.Right, Runtime.Widgets[I].Bounds.Bottom,
           string(Runtime.Widgets[I].Value)]));
      // 页面空间命中测试,最上层部件胜出
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

字段提交时什么必须是原子的?

这次编辑能碰到的一切,而这远不止是字段值。CommitEdit 在写任何东西之前调用 CaptureSnapshot,快照覆盖四样:TXFADocument.SaveToBytes 序列化的 XFA DOM、完整的 TXFAWidgetState 交互记录数组、LastCalculationPassesLastReflowPasses 计数器、当前 Warnings.Count。只存节点值是诱人的捷径,而它是错的,因为 calculate 脚本或未解析的绑定可能调用 EnsureValueNode,物化出编辑开始时并不存在的数据节点;只恢复值的方案没有办法删掉它们,于是一次被拒绝的编辑会在 datasets 包里留下永久的结构残渣。提交序列本身是严格的:写候选值、对被编辑字段跑 validate、跑 calculate 到不动点、重排到布局稳定,任何阶段任何失败都走 FailAndRestore,把快照字节装进全新 TXFADocument,重建部件列表,重放记录的交互状态,重置计数器,把 Warnings 截回快照长度。LastDiagnostic 在失败时装着原因,在恢复自身也抛异常的病态情形下装着字面量 XFA transaction rollback failed

HotPDF 把一次 XFA 字段提交当作一个事务,在验证、计算、重排之前捕获序列化 DOM、每个部件状态、轮次计数器和警告计数,然后四者一起发布或一起恢复
CommitEdit 在写任何东西之前快照四类状态,于是失败的 validate、calculate 或重排不会留下结构残渣
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // 只读,或没有该部件
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // 范围非法,或劈开代理对
    Exit;
  end;
  Result := Runtime.CommitEdit;             // 全有或全无
  if not Result then
    // 文档、部件、计数器和警告都已回到
    // 编辑前状态;焦点部件只是被标记为无效
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection 值得单独一段,因为它是最便宜地拒绝畸形输入的位置。它拒绝劈开 UTF-16 代理对的选区,拒绝含未成对高低代理项的替换文本,拒绝任何长于 MaxValueChars 的结果。在按键层抓住这些,意味着事务机制永远不必去回退一个写了一半的星平面字符

重建进私有列表,一次交换发布

部件重建绝不能被观察到半成品,所以 RebuildWidgets 构建一个完全独立的持有型 TObjectList,最后用一次赋值把它换入。原因不是好看:TXFALayoutEngine.ComputeLayout 在重建进行期间运行,并通过你提供的 MeasureText 函数回调宿主机代码,撞到部件上限时它会抛 EXFAWidgetRuntimeError。如果运行时就地改它的活列表,两条路径都会让宿主机拿着一个半旧布局半新布局的列表,里面的 DataNode 指针指向一个即将回滚的文档。随后重排收敛由 LayoutSignature 判定:一个由部件数加每个 ID、页索引和四舍五入到四位小数的包围盒构成的字符串。CommitEdit 重建、比较签名、重复,直到两个连续签名一致或轮次预算耗尽。签名根本没变过时 LastReflowPasses 保持 0,这就是你区分纯值编辑与真正让表单增长的编辑的方法;交互状态按部件 ID 跨越每次重建携带,于是焦点和进行中的编辑在行插入后存活

HotPDF XFA 运行时把部件列表重建进独立的持有型列表,布局在期间运行并回调宿主机度量代码,然后用一次赋值发布完成的列表,宿主机不可能观察到半成品
重建发生在私有列表中,因为 ComputeLayout 可能中途抛异常,而 LayoutSignature 判定两次连续重排何时收敛

为什么绑定字段会读到错的记录?

因为脚本在缺少数据上下文的情况下跑了。一个带显式 <bind match="dataRef" ref="$record.actual"/> 的字段和一个以同一数据节点命名的字段是指向同一个值的两个不同部件;带 <occur max="2"/> 的重复子表单会产生若干共享名字、只在所属数据行上不同的部件。对着文档根评估验证和计算,它们每一个都会把 this 解析成整个 datasets 包里第一个匹配节点,于是第二行悄悄验证了第一行。HotPDF 的避免方式是:布局产出部件条目时把解析好的 DataNode 存在上面,然后把该节点穿进 HPDFXFAEvaluateFieldScript 的两处调用,xfskValidatexfskCalculate 都一样。当计算目标是一个尚不存在的绑定时,同一个上下文决定 EnsureValueNode 对哪个节点创建;完全无法解析绑定时,提交以 XFA calculation target is not bound 干净失败,而不是写进错误的行。这些脚本背后的 FormCalc 语义呼应 AcroForm 文档从AcroForm 格式化与计算脚本得到的东西,但这里的解析规则是 XFA 作用域而不是字段名作用域

预算在副作用之前检查,不是之后

运行时里每个限制都是前置条件,因为在分配已经发生之后才执行的预算算不上预算。TXFAWidgetRuntimeOptions.Default 交付 MaxWidgets 10000、MaxValueChars 1048576、MaxCalculationPasses 16、MaxReflowPasses 4,默认 TXFAFormScriptOptionsMaxOperations 100000、MaxElapsedMilliseconds 500。底下 XFA DOM 施加自己的 TXFADOMLimits:解压输入输出各 128 MB 上限、最多 1024 个包拼接、1000000 个节点、256 层嵌套深度。两个细节比数字本身更要紧。第一,脚本预算是事务级的而不是每脚本的:CommitEdit 播下一个剩余操作数计数器和一个单调截止时间,每次 validate 和 calculate 调用都从同一个计数器支取、只拿到剩余毫秒数,于是一个有两百个计算字段的表单不能把 500 ms 花两百遍。第二,截止时间来自可注入的 MonotonicMilliseconds 函数,这让耗时行为在测试套件里可复现,而不是在忙碌构建代理上掷硬币

HotPDF XFA 运行时的预算分层,从部件与值上限,经脚本操作数与时间上限,直到 XFA DOM 上限,事务内每次调用共享一个操作数计数器和一个截止时间
脚本预算是事务级而非每脚本的,于是两百个计算字段不能各自申领一份新的 500 ms
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // 默认 10000
  Options.MaxCalculationPasses := 8;                       // 默认 16
  Options.MaxReflowPasses := 2;                            // 默认 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // 整个事务
  Options.ScriptOptions.Limits.MaxElapsedMilliseconds := 200;
  Options.MeasureText :=
    function(const AText: UnicodeString; const AFont: TXFAFontSpec;
      AMaxWidth: Double): TXFATextExtent
    begin
      Result := MeasureWithHostCanvas(AText, AFont, AMaxWidth);
    end;
  Runtime := TXFAWidgetRuntime.Create(XDPBytes, 612, 792, Options);
  try
    Runtime.OnLayoutChanged :=
      procedure
      begin
        RepaintAllPages;   // 只在重排真的移动了部件时触发
      end;
    // ……驱动表单……
  finally
    Runtime.Free;
  end;
end;

运行时在哪里止步,为什么大声说出来

运行时刻意不做通用 XFA 脚本引擎。DispatchEvent 原生处理 enterexit 活动,靠移动焦点;对其他每个带脚本的活动,它用具体、稳定的诊断拒绝而不是假装支持:提到 addInstanceremoveInstanceinstanceManager 的脚本返回 XFA runtime does not support event-driven instance mutation,碰 .presence 的返回 presence 对应措辞,其余一律返回 XFA runtime does not support this event script。一个可以写分支判断的可预测拒绝,胜过在你的样本文件上能跑、在客户文件上就分叉的半成品仿真

线程模型同样直白:一个运行时实例属于一个线程,没有内部锁,因为布局引擎会伸手回调宿主机度量代码,给它加锁就是一颗等重绘来引爆的死锁。字段内富文本遵循与库中其余各处相同的保守路线,exData 载荷按 XFA exData 富文本与超链接中所述处理;签名和按钮部件以 ReadOnly 返回,不支持的 UI 种类以 xwkUnsupported 露面,而不是伪装成一个悄悄丢数据的可编辑文本框

合在一起,这就是 Delphi 里动态 XFA 的一个可行答案:保持 DOM 存活,让每次编辑成为要么完整落地要么片甲不留的事务,给每一轮设界,并明确什么在范围之外。如果你在理赔、税务或福利工作流里评估它,XFA 运行时随 HotPDF Delphi PDF 组件交付,旁边就是这类项目通常最终一起需要的 AcroForm、展平和渲染路径