一份只有几个文本字段、毫无 XFA 内容的 AcroForm 发票,用普通 pdfium.dll 打开一切正常;可同一个文件,一旦进程跑的是开启 XFA 的 pdfium.v8.dll,就能让 FPDFDOC_InitFormFillEnvironment 返回 NULL。两次运行中文件的字节完全相同。不同的只是一个整数:PDFium 的 XFA 构建要求 FPDF_FORMFILLINFO.version 为 2,而一个对无 XFA 文档写 1 的绑定,在 PDFium 看第一个回调之前就被挡在门外
FPDFDOC_InitFormFillEnvironment 为什么拒收纯 AcroForm PDF?
拒收来自二进制本身,文档没有发言权。表单填充头文件——PDFium 的 fpdf_formfill.h 及其 Pascal 翻译 FPdfFormFill.pas——写明启用 XFA 模块编译的构建要求版本 2,同一段注释里说了两遍。不带 XFA 的构建接受 1 或 2。在一个按文档决定版本的绑定下,二进制与文档的四种组合里只有一种失败:版本 1 的记录交给 pdfium.v8.dll,配一份普通 AcroForm 文件。普通 DLL 接受这种记录。V8 DLL 对任何文档都接受版本 2 记录。而真正的 XFA 表单在 V8 DLL 上,按文档定版本的绑定永远不会给它版本 1——所以出事的格子恰恰是谁都想不到去测的那个
手写绑定在这里只看到 FPDFDOC_InitFormFillEnvironment 返回 NULL,再无线索可查。PDFium Component 里的 TPdf 遇到被拒的环境会在 Active := True 时报 EPdfError,但它一律写版本 2,这个格子在那边永远不会出现。掉进这个格子很容易。宿主为了让 AcroForm JavaScript 跑起来,把全局 EnableV8Engine 设为 True,从此进程里每个 PDF 都走 V8 二进制。或者宿主根本没碰这个旗标,但打开的第一个文件在 AcroForm 字典里有 /XFA 条目、catalog 里有 /NeedsRendering true:PDFium 库尚未加载时,TPdf 会扫描文件找这两个标记,并在加载 DLL 前打开 EnableV8Engine。无论哪条路,这个选择对进程余下部分都成立,之后打开的每个纯 AcroForm 文件都会撞上 XFA 构建。最初该带哪个二进制,PDFium DLL 部署与加载失败排查指南有讲;下文都假设 V8 构建已经加载
FPDF_FORMFILLINFO 的 version 字段承诺了什么
FPDF_FORMFILLINFO.version 告诉 PDFium 它可以读记录的多少。版本 1 覆盖稳定部分:Release、从 FFI_Invalidate 到 FFI_DoGoToAction 的回调,以及 m_pJsPlatform 指针。版本 2 追加一段实验性尾部。不带 XFA 的构建收到版本 2 也会调用它在那里找到的实验性回调;带 XFA 的构建则少于版本 2 一概不收。头文件从头到尾没提文档半个字。写 2 就是在承诺:m_pJsPlatform 之后的内存存在,而且其中每个槽位要么是有效的 cdecl 函数,要么是 NULL
尾部从 xfa_disabled 开始——一个版本 2 以下被忽略的 FPDF_BOOL——接着是从 FFI_DisplayCaret 到 FFI_DoURIActionWithKeyboardModifier 的十七个函数指针。其中十五个,FFI_DisplayCaret 到 FFI_PutRequestURL,文档写明 "Required for XFA, otherwise set to NULL"。最后两个,FFI_OnFocusChange 和 FFI_DoURIActionWithKeyboardModifier,对每个宿主都是可选的,头文件还补充说,不设 FFI_DoURIActionWithKeyboardModifier 的版本 2 嵌入者,其 URI 点击会被转给 FFI_DoURIAction。合起来看,对不驱动 XFA 的宿主,NULL 就是规范规定的值。用 FillChar 清零、盖上版本 2 的记录,在普通构建上与版本 1 记录一样满足契约,而且它还是 XFA 构建唯一肯收的记录
JavaScript 一侧是同一个模式。不带 V8 的构建不用 m_pJsPlatform;V8 构建上,NULL 指针让文档照常渲染但 JavaScript 不跑。它指向的 IPDF_JsPlatform 记录有自己的 version 字段,头文件说这一个目前必须是 2
陷阱:让文档来选 ABI 版本
错误就是一个单看还挺合理的条件判断。手写绑定本来就要决定 XFA 回调值不值得接,顺手让同一个测试去定版本号很诱人
// 手写绑定里很诱人的写法:版本跟着文档走
FillChar(FormInfo, SizeOf(FormInfo), 0);
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
if IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable then
FormInfo.version := 2
else
FormInfo.version := 1; // 每个非 XFA 文档都拿到 1
FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// 在 pdfium.v8.dll 上配纯 AcroForm 文件,这里 FormHandle 是 nil
这个分支的用意是防御性的:把版本停在 1,XFA 构建就永远不会读宿主没填的槽位。但上一行的 FillChar 已经把这些槽位填成了 NULL,而头文件恰恰定义 NULL 是不跑 XFA 的宿主的正确值。这个分支防的是一个声明早已消除的危险,却一头撞进头文件警告的那个。而且测 XFA 文件抓不到它,因为那些文件每次都走版本 2 路径
PDFium Component 反过来建这份记录。只要 TPdf.FormFill 为 True,打开文档就先清空整条记录、写版本 2 和 xfa_disabled := 1,然后才问文档需要什么。作为任何绑定都适用的规则:版本由你声明的记录决定,别的什么都不许碰它。下面是你自己代码里的那种形状,THostFormFillInfo 包装在更下面声明
// FormInfo 是你查看器对象的一个 THostFormFillInfo 字段,寿命超过
// 表单句柄;HostInvalidate、HostGetPage 等都是你自己的
// cdecl 回调
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;
FormInfo.Info.version := 2; // 与声明的布局永远一致
FormInfo.Info.xfa_disabled := 1; // 未证明可用之前,XFA 保持关闭
FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... 其余版本 1 回调
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
RuntimeReady := IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable;
if RuntimeReady then
begin
FormInfo.Info.xfa_disabled := 0;
FormInfo.Info.FFI_DisplayCaret := HostDisplayCaret;
FormInfo.Info.FFI_PageEvent := HostPageEvent;
// ... 其余版本 2 槽位
end;
FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo.Info);
if FormHandle = nil then
raise Exception.Create('Cannot initialize form fill environment');
XfaRunning := RuntimeReady and (FPDF_LoadXFA(Doc) <> 0);
RuntimeReady 和 V8FeaturesAvailable 该放在哪一层
能力检查决定哪些槽位被填,在 PDFium Component 里它们从不碰布局。TPdf 用三个事实计算就绪:文档经 TPdf.XFA 报告 XFA 表单类型;全局 XfaFeaturesAvailable 在已加载 DLL 里找到 XFA 字符串辅助导出;V8FeaturesAvailable 找到 V8 导出。版本 1 回调对每个文档都接上,因为 AcroForm 和 XFA 都依赖它们。全部十七个版本 2 槽位与清零的 xfa_disabled,只在三个事实齐备时才赋值。环境建立后,FPDF_LoadXFA 只在这个情形下运行,且只有非零返回才让 TPdf.XfaRuntimeAvailable 报 True。FPDF_LoadXFA 连不带 XFA 引擎的构建都有导出,在那里只是返回 false,所以只查导出表并不够
文档是 XFA 而运行时缺失时,记录停在版本 2、xfa_disabled 为 1、尾部全 NULL。环境照常打开,XFA 内容保持静态,TPdf 触发 OnXfaRuntimeMissing,让宿主可以建议换 pdfium.v8.dll 重启。m_pJsPlatform 遵循第三条规则:只要 V8FeaturesAvailable 为真就挂上,纯 AcroForm 文件也一样——这正是文档级脚本、字段计算和 app.alert 能在 V8 构建下运行的原因。PageCount 在 XFA 经 FFI_PageEvent 重新分页之前读静态页面树;从那时起新总数取自 FPDF_GetPageCount,经 OnXfaPageCountChanged 报告
宿主一侧,两个属性回答两个不同的问题。XFA 告诉你文件里有什么,来自 FPDF_GetFormType,与二进制无关。XfaRuntimeAvailable 告诉你引擎这次是否真的为这份文档启动了。在 Active 变 True 之后读第二个,检测 XFA 表单与提取 XFA 数据包一文就是这么做的,version 字段则完全不要掺进判断
procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
// 文档打开时触发:文档是 XFA,但已加载的 DLL 跑不了引擎。
// 表单环境仍然存在,XFA 处于禁用状态
StatusBar.SimpleText :=
'XFA form detected; restart with pdfium.v8.dll for dynamic rendering';
end;
procedure TMainForm.OpenDocument(const FileName: string);
begin
Pdf.Active := False;
Pdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
Pdf.FormFill := True;
Pdf.FileName := FileName;
Pdf.Active := True; // 纯 AcroForm 文件在 pdfium.v8.dll 下也照常打开
if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
ShowStaticXfaNotice; // 你自己的 UI 例程
end;
版本还没被读,布局错误就先弄坏了记录
版本号写得再对,它背后的字节不在 C 编译器放的位置也是白搭。几种绑定错误会让 PDFium 无论记录带哪个版本都读错,其中一种在 Win32 上隐形:把 FPDF_FORMFILLINFO 声明成 packed record。Win32 上每个字段四字节,packed 与对齐布局碰巧重合。Win64 上 C 结构在 version 后垫四字节让 Release 落在 8 字节边界,xfa_disabled 后再垫一次;packed 的 Pascal 声明把两处空隙都丢掉,每个回调指针错位四字节。FPdfFormFill.pas 里的声明因此用的是普通 record
第二个错误是截断的记录:翻译到 m_pJsPlatform 就停了,却写版本 2。PDFium 于是从记录后面的任意内存里读尾部。第三个是生命周期。头文件说传给 FPDFDOC_InitFormFillEnvironment 的记录必须保持有效,直到返回的表单句柄被关闭,所以放在打开文档那个过程的局部变量里的记录,过程一返回就是悬垂指针。TPdf 把记录存成组件的字段,外面再包一层记录:PDFium 结构放在偏移 0,紧跟着一个指回所属 TPdf 的反向指针,每个静态回调都靠它找回自己的对象
type
// Win64 上错误:packed 丢掉 C 结构在 version 之后的填充,
// 于是 Release 在这里位于偏移 4,在 PDFium 里位于偏移 8
TBadFormFillInfo = packed record
version: Integer;
Release: Pointer;
// ... 其余字段
end;
// 你自己的包装:PDFium 记录在前,反向指针在后
THostFormFillInfo = record
Info: FPDF_FORMFILLINFO; // 偏移 0,pThis 可以直接转回
Owner: TObject;
end;
PHostFormFillInfo = ^THostFormFillInfo;
procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
left, top, right, bottom: Double); cdecl;
begin
try
// TMyViewer 和 InvalidatePdfRect 代表你自己的查看器类
TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
page, left, top, right, bottom);
except
// Pascal 异常绝不能穿过 PDFium 的 C++ 栈帧展开
end;
end;
还有两个槽位要小心。FFI_GetLocalTime 按值返回 16 字节的 FPDF_SYSTEMTIME,Win32 上 Delphi 与 MSVC 对隐藏结果指针放在哪意见不合,Pascal 实现会破坏调用方的 FPDF_FORMFILLINFO。现在的 PDFium 把该成员标为未用,Pascal 声明带着留 nil 的注释;PDFium Component 正是这么做的。组件安装的每个回调都捕获 Pascal 异常并返回文档规定的失败值,因为 C ABI 没有把异常带回边界的途径。Delphi 里加固 PDFium ABI 的长文以 FFI_OpenFile 为例走了一遍这套纪律
协议版本与功能可用是两条轴
回调结构里的版本字段回答记录有多大、可以从中读什么。功能检测回答那些槽位里哪些会真正做事。前者由原生二进制和你编译所对的声明固定;后者按文档、按 DLL 导出表、按宿主设置而变。XFA 这个案例同时需要两个答案,于是很诱人把它们折成一个布尔值——而这个折法在某个构建强制最低版本的那一刻就断了。XFA 表单——ISO 32000-1 §12.7.8 把它描述为伴随 AcroForm 字典携带的 XML 载荷——就是本文故事里的那个功能。记录布局是协议,PDFium 有权在读文件之前先检查布局
同样的结构出现在每个给结构体定版本的 C 库里:FPDF_LIBRARY_CONFIG、IPDF_JsPlatform、任何带 size 或 version 头的回调表。声明你能理解的最新布局,彻底清零,无条件写匹配的版本,再按文档与 DLL 实际能做什么去填槽位。如果将来的 fpdf_formfill.h 引入版本 3,工作量就是一条新声明加一处改动的赋值。按文档内容分支的做法,却要为构建与文件的每个组合重新过一遍,而它弄错的那个组合通常正是没人打开过的那个
这里描述的记录处理是面向 Delphi、Lazarus 和 C++Builder 的 PDFium Component 的一部分,同一份声明同时服务 Win32 与 Win64 构建。已经为 JavaScript 驱动的 AcroForm 带上 pdfium.v8.dll 的应用,可以用这一个二进制打开档案里的其余文件——XFA 与否——表单环境无需特殊处理