技术文章

PDFium Component Dynamic XFA:页数是个差值

Delphi 查看器里的动态 XFA 表单增删页面时,PDFium Component 从 v3.126.1 起经 TPdf.PageCount 和 TPdf.OnXfaPageCountChanged 报告新的总数,因为原生页面事件携带的是增/删差值而不是总数。v3.126.1 的 Windows V8 库还让输入命中区跟着迁移的字段走,v3.126.2 则在布局回调返回之后重载过期的页面句柄。挑起这一切的 bug 报告来自一张报销单:连点两次 Add Row,表单长到两页,页码指示器还骄傲地写着 1 of 1。往一个挪到第 2 页的字段里打字,按键落在看不见的地方。这些东西在人人上来就测的定长示例表单上从不出现,原因值得每个内嵌表单查看器的人知道

动态 XFA 表单重排版时会发生什么?

动态 XFA 表单没有固定的页面清单,页数是布局的输出,用户每改一次数据都可能变。XFA 3.3 把表单描述成一棵 subform 树;重复 subform 由一个 instanceManager 控制,_Row.addInstance() 这样的脚本克隆出多一行。布局处理器随后把内容重新灌进页面区域,可能加页、减页,或把既有字段推到另一页。ISO 32000-1 §12.7.8 只定义 XFA 包怎么在 PDF 里搭载;之后发生的一切都属于 XFA 引擎,在 PDFium Component 里就是跑在宿主进程中的 PDFium 自家 XFA 布局。所以 Delphi 查看器打交道的是一份页数、页面尺寸、widget 位置全是活动状态的文档。宿主若不作此想,三件事会出岔子:

  • 宿主为导航、滚动范围和页码控件缓存的页数过期,更糟的是被错误数字更新
  • 迁移的字段在新区位画边框,编辑器和鼠标命中区却留在旧坐标
  • 查看器攥着布局已替换掉的页面句柄,点击与绘制去了该表单里已不存在的页面

把行编辑持久化到保存再打开是另一个有自己规矩的问题;本文只谈查看器内运行时发生的事

动态 XFA 需要哪个 PDFium 运行时?

PDFium Component 里的动态 XFA 需要原生库的 V8/XFA 构建,由 PDFium 单元里的全局变量 EnableV8Engine 在首个文档加载之前选定。任何一个 TPdf 第一次加载库时,进程就认下了那一个 DLL,而普通 PDFium 构建根本跑不了 XFA 引擎。文档打开时 TPdf 确实会瞄一眼文件找 XFA 标记并自动切到 V8 构建,但前提是该进程尚未加载过普通库。要是已经认错了方向,TPdf.OnXfaRuntimeMissing 会触发一次,宿主可以借机请用户重启。启动时显式设好这个标志,就免了猜。承载 XFA 事件的 FPDF_FORMFILLINFO 回调结构也必须与 DLL 匹配;背景在FPDF_FORMFILLINFO version 2 与 XFA 回调 ABI 一文,检测 XFA 表单并读取其数据包讲打开查看器之前怎么区分表单类型

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // 在第一个 TPdf 加载原生库之前做决定:
  // 进程事后无法从 pdfium.dll 切到 pdfium.v8.dll
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

两页的表单,PageCount 为什么报 1?

v3.126.1 之前,PDFium Component 把原生页面事件的 page_count 实参当文档总数存储,而那个实参其实是新旧页数之差的绝对值。PDFium 在一轮布局结束后抛 FFI_PageEvent,事件类型为 page added 或 page removed;内部先更新自己存的页数,再传出 abs(new - old)。初始布局时旧页数为零,差值等于总数,三页的静态示例照预期报三页。这正是定长测试表单从没暴露这个 bug 的原因。动态表单第一次从一页长到两页时,差值是 1,包装层就把 TPdf.PageCount 和 OnXfaPageCountChanged 的 NewCount 参数都设成了 1。从三页表单里删一行,则朝反方向产出同类胡话

把差值累加到旧值上也不是安全的修法。初始化与布局回调的先后顺序意味着包装层不能总拿自己之前的计数当基线,累加和会漂。从 v3.126.1 起,回调无视那个实参,改为对文档调 FPDF_GetPageCount,从刚完成的布局里读总数。然后清掉缓存的页面场景,把该总数存为 TPdf.PageCount 背后的 XFA 页数覆盖值,此后才抛 OnXfaPageCountChanged。你的处理器跑起来时,NewCount 和 FPdf.PageCount 已经一致

PDFium Component 动态 XFA 示意图:加一行把一页表单重排成两页,FFI_PageEvent 以 abs(new 减 old) 传出差值,旧包装层报 TPdf.PageCount 1,而 v3.126.1 读 FPDF_GetPageCount 报出正确总数
原生页面事件报的是增或减的差值,不是总数,所以 v3.126.1 无视实参、先读完成的布局再抛 OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+:NewCount 是完成布局的总数,绝不是差值。
  // 这跑在 PDFium 的布局回调里:只更新宿主 UI 状态,
  // 别在这里关文档或重载页面
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // 每次页面重载后触发,包括延迟的 XFA 刷新
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

事件只对布局会在运行时变化的 Full XFA 表单触发。静态 XFA 和 AcroForm 文档从不抛它,两种都处理的查看器可以照常挂着同一个处理器。不挂也安全;TPdf.PageCount 背后的覆盖值照常生效,事件存在的意义只是让宿主刷新自己缓存的东西

字段搬家后,输入框为什么留在旧页面?

边框动了、编辑器没动,是因为原生 XFA 通知器拿一个矩形跟它自己比。布局改变已加载 widget 的几何时,PDFium 本应注意到新矩形并对该 widget 调 PerformLayout,后者会重新摆放文本编辑器及其命中区。那道检查拿 GetWidgetRect() 与 RecacheWidgetRect() 对比。两个函数返回的是同一个成员的 const 引用,而重新缓存就地覆写那个成员,于是比较永远看到两个相同的值,已加载的 widget 就跳过了重排版

症状是在一个测试改动 subform 高度、既有字段跨到下一页时浮出来的。两套 V8 架构上,字段边框画在新区位,敲进去的文字和鼠标命中区却留在原先的 Y 坐标。显式触发重排版修不了,重载页面也修不了,因为 widget 仍然相信自己的几何是最新的。v3.126.1 随附的 Windows V8 库在重新缓存之前按值复制旧矩形、再比较那份副本,迁移的 widget 于是重排版,编辑值恰好出现在边框所在处。这是原生层修复:它随 DLL 走,所以只更新 Pascal 单元、留着旧 pdfium.v8.dll 的话,错位的命中区原样不动。催生它的回归检查先把幸存行编辑成非默认值,再要求该值出现在字段新位置——不然用默认值重建的行会伪装成通过

PDFium Component widget 重排版示意图:旧的自比较里 GetWidgetRect 与 RecacheWidgetRect 返回同一个共享成员,迁移的 widget 跳过 PerformLayout;v3.126.1 Windows V8 按值复制后比较,把编辑器与鼠标命中区重新摆到重画的边框上
拿矩形跟自己比永远相等,于是边框动了、敲入的文字和点击却原地不动,直到检查先按值存下一份副本

TPdfView 怎么重载页面、而不从 PDFium 脚下抽走句柄?

从 v3.126.2 起,TPdfView 把 XFA 布局变更后的页面重载推迟到原生调用栈展开完毕。页面事件通常在 PDFium 还在处理输入时触发:用户点了 Add Row 按钮,点击跑了脚本,脚本改了实例数,布局在同一次原生调用里完成。那一刻关掉再重开页面句柄,会释放调用方还在用的对象。v3.126.2 之前查看器只做自我失效,显示中的页面句柄可能继续指着布局前状态;用户若恰在最后一页、而那页消失了,选中的页号就出了界

延迟刷新分几小步走,它们正好解释了宿主看到的行为:

  1. 页面事件回调把视图标记为有待 XFA 布局刷新,并投递一条私有窗口消息;消息到达前重复的事件并成一次刷新
  2. 还没有窗口句柄的视图保留待处理标志、从 CreateWnd 投递消息;换文档、视图失活或销毁则清除该标志
  3. 消息到达时,视图清掉文本选择、搜索高亮和焦点字段索引,因为三者指向的都是旧布局
  4. 选中页被钳到新 PageCount;页号变了走正常切页,否则重载当前页,并重新应用适配模式
  5. 布局若一页不剩,视图卸载旧页面句柄,而不是去画一个已不存在的页面
PDFium Component TPdfView 延迟 XFA 刷新示意图:原生布局调用栈内的页面事件只标记待刷新并投递窗口消息,消息随后清掉过期选择状态、把页面钳到新 PageCount,再重载或卸载页面句柄
重载等原生调用栈展开完才动:投递的消息合并重复事件,然后视图钳页号、重载页面并抛 OnPageChange

同样的约束也适用于你自己的代码。OnXfaPageCountChanged 跑在那个原生布局回调里,把它当通知对待:在那里更新标签、控件范围和工具栏状态,重一点的活——比如关文档、开另一份——用投递消息排队,等回调返回后再跑。TPdfView.OnPageChange 会在视图真正重载页面时告诉你,那时读 PdfView1.PageNumber 拿到的就是钳过的值。Tab 键遍历与表单查看器打开时跑的 FormType 检查,见PDFium Component 的 PDF 表单字段导航

点 Full XFA 字段为什么会抛「Cannot open text page」?

Full XFA 页面没有 PDF 文本页,而 v3.126.2 之前查看器默认的文本选择和链接检测偏偏要去加载一个。TPdfView.AllowUserTextSelection 保持默认 True 时,悬停会向文本层要鼠标下的字符,鼠标抬起的一次点击会对页面文本跑自动 URL 探测。Full XFA 页面上文本页开不了,于是往字段里普普通通点一下,就可能以 Cannot open text page 异常收场。从 v3.126.2 起,TPdf.FormType 为 ftXfaFull 且 XFA 运行时可用时,两条内部路径都返回空结果,默认设置照常工作,字段输入保持可用

对 Full XFA 文档关掉 AllowUserTextSelection 仍是合理的 UI 选择:没有页面文本可选,拖拽手势也不该启动选择模式。不过它替代不了升级:更早的版本上,点击触发的 URL 探测不依赖那个属性,关着选择照样可能撞上同一个异常

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormType 读的是已打开的文档,所以在 FPdf.Active := True 之后调用
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // Full XFA 页面上没有 PDF 文本层;字段保持可编辑
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

打字在 v3.126.2 里也需要单独的修理。原生 XFA 文本编辑器收到字符时并不替换选择:FORM_OnChar 在光标处插入,Backspace 只删一个字符,于是选中一个值再打字,新旧文字肩并肩。PDFium Component 现在记住点击落在 XFA 文本字段上,只要存在选择、且文档授予填表或修改权限,就把敲入字符、Backspace 和 Delete 走 FORM_ReplaceSelection。只读 XFA 字段能否变更仍由原生编辑器决定,所以表单里标了只读的字段即便在允许填表的文档里也守着自己的值。把 TPdfView.AllowFormEvents 设为 False 也会停掉这条键盘路由,只读查看器保持只读

速查:Delphi 查看器里的动态 XFA

症状原因修复于
表单长到两页后页数显示 1原生页面事件传的是增/删差值,不是总数v3.126.1(包装层)
字段边框动了,敲入文字与命中区留在原地已加载 widget 在自比较后跳过重排版v3.126.1(Windows V8 库)
查看器绘制或路由输入到布局前的页面状态重排版后页面句柄未重载v3.126.2(延迟刷新)
点击字段抛 Cannot open text page无文本层的页面上跑文本选择与 URL 探测v3.126.2
对选中值打字变成追加而非替换原生 XFA 编辑器在光标处插入v3.126.2
  • 任何文档加载之前把 EnableV8Engine 设为 True,并为普通库先被加载的情况处理 OnXfaRuntimeMissing
  • 总数从 TPdf.PageCount 或 OnXfaPageCountChanged 的 NewCount 参数读;永远不要自己加减页数
  • OnXfaPageCountChanged 处理器保持轻量,它跑在原生布局回调里
  • 在 TPdfView.OnPageChange 里同步当前页指示器,它在延迟重载钳完页号之后触发
  • v3.126.1 或更新的 Windows V8 DLL 与单元一起部署;widget 重排版修复在原生代码里
  • 用一个真的会变页数、把编辑过的字段挪过页界符的表单来测,因为定长示例把这份清单上的 bug 全藏了

动态 XFA 把页数和字段几何变成活动值,查看器只有从完成的布局取值、并在安全的时机重载页面才能保持正确。这两件事 PDFium Component 都在 TPdf 和 TPdfView 内部处理了,宿主只需要听。详情与下载见 PDFium Component for Delphi 产品页