PDFium Component 现在为它初始化的每一个表单填写环境都把 FPDF_FORMFILLINFO.version 设为 2,因为一个原生 PDFium 构建能接受哪个版本,是那个构建的属性,而不是被打开文档的属性。启用了 XFA 的 pdfium.v8.dll 直接拒绝版本 1,所以以前通过它打开一份普通的 AcroForm PDF,会在 FPDFDOC_InitFormFillEnvironment 里失败,而现场连 XFA 的影子都没有。v3.116.0 的修复很小,但它背后的错误很普遍,值得点明:协议版本字段描述的是对面所期待的内存布局,绝不能由「你恰好用不用得上那个布局承载的功能」推导出来
为什么普通 PDF 配 pdfium.v8.dll 会让 FPDFDOC_InitFormFillEnvironment 失败?
环境初始化失败,是因为启用 XFA 的 PDFium 构建会先校验 version 字段,然后才做别的;而旧的封装逻辑只要当前文档不是 XFA 表单,就递给它一个 1。在 Delphi 宿主里症状是 TPdf.InitializeFormFill 抛出 EPdfError,消息为 Cannot initialize form fill environment,而且是在打开一份普普通通的发票或税务表单时抛的——那些文件里除了 AcroForm 文本字段什么都没有。同一个文件对着普通 pdfium.dll 打开得好好的;同一个 DLL 打开真正的 XFA 文档也好好的。只有 V8 构建配非 XFA 文档这一种组合会坏,而这恰恰是宿主为了拿到 AcroForm JavaScript 打开 EnableV8Engine 之后、或者 LoadDocument 里的自动选择因为更早的一个 XFA 文件已经把进程锁定到 pdfium.v8.dll 之后所处的组合。这个锁定是进程级的:EnableV8Engine 在第一次 LoadLibrary 之前就被读取,而 XFA 构建一旦加载,之后每一份普通 PDF 都在同一个二进制上走同一套环境初始化。宿主没做错事;是封装在填写记录时问错了问题。如果你还在纠结到底该发哪个二进制,我们关于部署 PDFium DLL 与诊断加载失败那篇笔记讲了普通版与 V8 版的选择;本文假定 V8 构建已经在进程里了
FPDF_FORMFILLINFO 里的版本字段到底承诺了什么?
FPDF_FORMFILLINFO.version 告诉 PDFium 它被允许读这条记录里的哪些字段,而公共头文件 fpdf_formfill.h 把可接受的取值与库是怎么编译的绑在一起,而不是与文档绑在一起。换句话说,这份契约有三部分。版本 1 覆盖从 FFI_Invalidate 到 FFI_DoGoToAction 那批稳定回调,加上 m_pJsPlatform 指针。不带 XFA 模块的构建接受 1 或 2,取 2 时它还会调用那些额外的实验性回调。带 XFA 模块的构建要求 2,没有商量余地,而且头文件把这句要求重复了两遍,好像预料到人们会漏看。契约里从头到尾没有一处提到文档。版本是对你所分配的那条记录的陈述:填 2 就意味着你承诺 m_pJsPlatform 之后的内存确实存在,并且里面要么是合法的函数指针,要么是 NULL
版本 2 那一段就是整个 XFA 机制住的地方。它从 xfa_disabled 开始——一个 FPDF_BOOL,头文件说它在版本 2 以下被忽略,只有编译进 XFA 模块时才有意义——接着是十七个函数指针,从 FFI_DisplayCaret 到 FFI_DoURIActionWithKeyboardModifier。文档里写着这些指针对 XFA 是必需的,否则应设为 NULL。这句话就是整个修复的关键。对那些槽位来说,NULL 不是错误状态,而是「宿主没有在驱动 XFA」的文档规定状态。一条用 FillChar 清干净、然后标成版本 2 的记录,在非 XFA 构建上满足契约的程度与版本 1 的记录完全一样,而它是 XFA 构建唯一会接受的记录
旧的选择逻辑把 ABI 和文档绑在一起
缺陷只是一个单独看还挺合理的条件判断。TPdf.InitializeFormFill 从三个事实算出 RuntimeReady 标志:文档通过 TPdf.XFA 报告自己是 XFA 表单类型;XFA 字符串辅助函数通过 XfaFeaturesAvailable 解析成功;V8 导出通过 V8FeaturesAvailable 解析成功。在 v3.116.0 之前,这个标志还顺带用来选版本
// v3.115.0 及更早:ABI 版本跟着文档走
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
if RuntimeReady then
FFormFillInfo.Info.version := 2
else
FFormFillInfo.Info.version := 1;
// ...而运行时不存在的分支又把它钉了一次
else if XFA then
begin
FFormFillInfo.Info.version := 1;
FFormFillInfo.Info.xfa_disabled := 1;
if Assigned(FOnXfaRuntimeMissing) then
FOnXfaRuntimeMissing(Self);
end;
手里拿着头文件去读它,失败就一目了然。对每一份普通 AcroForm 文档,RuntimeReady 都是 false,于是每一份普通文档都宣告自己是版本 1。在 pdfium.dll 上这没问题。而在 pdfium.v8.dll 上——也就是那个启用 XFA 的构建——PDFium 检查这个字段,发现它低于要求的 2,返回一个空的 FPDF_FORMHANDLE,再由 CheckPdf 变成上面那个异常。旧代码的意图是防御性的:保持版本 1,好让 XFA 构建永远不去读那些没赋值的版本 2 槽位。它防的是一个头文件早就排除掉的问题,却造出了头文件明确警告过的问题。修正后的代码只在前头决定一次版本,依据是这条记录物理上是什么
procedure TPdf.InitializeFormFill;
var
RuntimeReady: Boolean;
begin
FXfaRuntimeUsable := False;
FXfaPageCountOverride := -1; // 哨兵值:使用静态页面树
if not FormFill then
Exit;
FillChar(FFormFillInfo, SizeOf(FFormFillInfo), 0);
FFormFillInfo.Pdf := Self;
// 上面分配并清空了完整的版本 2 记录。PDFium 在没有 XFA 时
// 也接受版本 2,而在每一个启用 XFA 的构建里都要求版本 2,
// 包括当前文档根本不含 XFA 表单的情况。
FFormFillInfo.Info.version := 2;
FFormFillInfo.Info.xfa_disabled := 1;
// RuntimeReady 只把关 XFA 回调和 xfa_disabled,从不把关版本。
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
...
RuntimeReady 仍然该待的地方:回调和 xfa_disabled
RuntimeReady 保有它作为 XFA 行为把关者的职责,只是不再碰记录布局了。版本 1 的那些回调——FFI_Invalidate、FFI_SetTimer、FFI_GetPage、FFI_DoURIAction、FFI_DoGoToAction 以及那一段里的其他函数——是无条件接上的,因为 AcroForm 和 XFA 都依赖它们。十七个版本 2 指针只在 RuntimeReady 分支里赋值,同时把 xfa_disabled := 0。当文档是 XFA 而运行时不在时,记录保持在版本 2、xfa_disabled 为 1、版本 2 的槽位留成 NULL,封装则触发 OnXfaRuntimeMissing,让宿主可以建议改用 pdfium.v8.dll 重启。环境建好之后,FPDF_LoadXFA 只在 RuntimeReady 为真时才调用,而只有它返回真才会置上 FXfaRuntimeUsable——TPdf.XfaRuntimeAvailable 报告的正是它
if RuntimeReady then
begin
FFormFillInfo.Info.xfa_disabled := 0; // 0 表示启用 XFA
FFormFillInfo.Info.FFI_DisplayCaret := FormFillDisplayCaret;
FFormFillInfo.Info.FFI_GetCurrentPageIndex := FormFillGetCurrentPageIndex;
FFormFillInfo.Info.FFI_SetCurrentPage := FormFillSetCurrentPage;
FFormFillInfo.Info.FFI_GotoURL := FormFillGotoURL;
FFormFillInfo.Info.FFI_GetPageViewRect := FormFillGetPageViewRect;
FFormFillInfo.Info.FFI_PageEvent := FormFillPageEvent;
FFormFillInfo.Info.FFI_PopupMenu := FormFillPopupMenu;
FFormFillInfo.Info.FFI_OpenFile := FormFillOpenFile;
FFormFillInfo.Info.FFI_EmailTo := FormFillEmailTo;
// ... FFI_UploadTo 到 FFI_DoURIActionWithKeyboardModifier
end
else if XFA then
begin
// 运行时不在了:保持版本 2,让 XFA 停用,并告诉宿主。
if Assigned(FOnXfaRuntimeMissing) then
FOnXfaRuntimeMissing(Self);
end;
FFormHandle := FPDFDOC_InitFormFillEnvironment(FDocument, FFormFillInfo.Info);
CheckPdf(FFormHandle <> nil, 'Cannot initialize form fill environment');
if RuntimeReady then
FXfaRuntimeUsable := FPDF_LoadXFA(FDocument) <> 0;
自己写绑定时,那一段里有两个细节很容易弄错。FXfaPageCountOverride 在任何别的事情发生之前先被重置为哨兵值 -1,于是 PageCount 会退回静态页面树,直到 FFI_PageEvent 报告重新分页;那里如果是 0,就会悄悄宣称这是一个空文档。另外,版本 2 的每个回调都是静态的 cdecl 例程,它从记录里找回所属的 TPdf,并在返回 PDFium 之前吞掉任何 Pascal 异常——这正是我们关于在 Delphi 中加固 PDFium ABI 那篇笔记为 FFI_OpenFile 讲明的纪律。版本改动没有放松任何一条
DLL 里没有 XFA 模块时,版本 2 安全吗?
安全,理由在记录里,而不在库的什么承诺里。对非 XFA 构建,头文件说版本 2 会导致那些实验性回调也被调用,所以问题变成 PDFium 去看的时候会发现什么。TPdfFormFillInfo 是一条 packed 记录,它的 Info 成员就是完整的 FPDF_FORMFILLINFO,包含每一个版本 2 字段,而 InitializeFormFill 在碰任何一个字节之前先用 FillChar 把整条记录清空。所以在一份普通文档加普通 pdfium.dll 的组合上,库看到的是版本 2、xfa_disabled 已置位、每个实验性槽位都是 NULL——这恰恰是头文件为「不实现 XFA 的宿主」规定的状态。库里没有一条被截断的记录可供读越界,因为这条记录从一开始就不比版本 2 短。旧逻辑防的那个布局错配,Pascal 声明早就消掉了
值得诚实说出的边界,是记录覆盖不到的那一条。在普通文档上填版本 2,并不会打开 JavaScript、XFA 脚本,也不会打开那些回调背后的任何宿主事件。m_pJsPlatform 只在 V8FeaturesAvailable 为真时才挂上;XFA 除非 RuntimeReady 为真否则一直停用;而 TPdf.XFA 无论环境谈成了什么,都继续按 FPDF_GetFormType 报告表单类型。想知道动态 XFA 到底会不会渲染的宿主,应该在 Active 变真之后继续读 XfaRuntimeAvailable——就像我们关于检测 XFA 表单与提取 XFA 包那篇笔记建议的那样——而不是从版本字段推断任何事
procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
// 当文档是 XFA、而加载的 pdfium.dll 跑不了引擎时,从
// InitializeFormFill 里触发。表单环境照样会打开,
// 因为两种情况下传的都是版本 2;停用的只是 XFA 运行时。
StatusBar.SimpleText :=
'XFA form detected; restart with pdfium.v8.dll to enable 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; // 在 pdfium.v8.dll 下打开普通 PDF 不再抛异常
if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
ShowStaticXfaWarning;
end;
协议版本和功能可用性是两条不同的轴
这个修复引出的通用规则是:回调结构里的版本字段回答的是「这条记录有多大、你被允许从里面读什么」,而功能探测回答的是「这些槽位里哪些会真正起作用」。前者由原生二进制和你编译时用的 Pascal 声明固定;后者随文档、随 DLL 导出表、随宿主配置而变。把两者压成一个布尔值很诱人,因为 XFA 这个案例恰好两样都需要;可一旦某个构建强制了最低版本,这种压缩就会在每一份不需要该功能的文档上崩掉。XFA 表单——ISO 32000-1 §12.7.8 描述为与 AcroForm 字典并存的 XML 载荷——在这里就是那个功能;记录布局才是协议,而 PDFium 有权在它去看文件之前先坚持要那个布局。同样的形状在任何给结构标版本的 C 库里都会出现:viewer-info 块、渲染选项记录、平台回调表。安全的做法就是修正后的 InitializeFormFill 所遵循的那一套:声明你理解的最新布局,把它完全清空,无条件把版本设成与该布局匹配的值,然后让能力检查决定填充哪些槽位。如果将来某个 PDFium 头文件加了版本 3,要改的是声明和那一处赋值,而不是一个依赖文档的分支——那种分支总会在某个没人测过的组合上出错
修正后的表单填写初始化在面向 Delphi、Lazarus 和 C++Builder 的 PDFium Component 里交付,Win32 和 Win64 都适用,因为两个构建共用同一份记录声明。如果你的应用已经为了 JavaScript 驱动的 AcroForm 选择 pdfium.v8.dll,那么正是这次改动让它能用同一个二进制打开你 PDF 存档里的其余文件,而不必为表单环境开特例