在 Acrobat 中重新计算总和,但在您自有的 Delphi 查看器中保持冻结的表单,几乎绝不是渲染 bug;除非宿主连接了 JS 平台,否则 PDFium 会禁用所有文档 JavaScript,而 PDFium 组件从 2.13.2 版本起自动连接该平台,因此只要加载了启用 V8 的 DLL,就会执行 AcroForm JavaScript、文档级脚本和字段计算;宿主通过一套事件来掌控全局,这些事件可以显示、重定向或否决脚本试图执行的一切操作
这单个句子解决了一个我们见过的各种形式的支持问题:“我的订单表单在 Acrobat 中计算行总计,但在我的应用中却没有”、“app.alert 永远不触发”、“日期字段没有自动格式化”;所有这些都追溯到相同的契约,理解它值得花十分钟时间,因为相同的契约也是您针对恶意文档的安全防线
为什么 PDF 表单计算在 PDFium 中不起作用?
PDFium 将文档 JavaScript 视为严格的选择性加入(Opt-in):如果表单填充环境的 m_pJsPlatform 成员为 NULL,则文档中的每个脚本都会被静默跳过;没有错误、没有日志行、没有部分执行;计算后的字段保留其最后存储的值, app.alert 消失,格式化脚本永远不会运行;始终随附其自有 JavaScript 引擎的 Acrobat 则可以愉快地运行相同的文件,这就是为什么该差异看起来非常像您代码中的 bug,而实际上它是底层库中故意设定的默认值
PDFium 组件对此还有第二层历史性的背景;到 2.13.1 版本为止,绑定仅在 XFA 初始化分支内部连接 m_pJsPlatform,因此普通的 AcroForm 文档(它们是脚本化 PDF 的绝大多数)即使在启用 V8 的 DLL 存在时,也根本无法获得 JavaScript 平台;2.13.2 版本将该连接从 XFA 决策中拉了出来:只要 V8FeaturesAvailable 返回 True, InitializeFormFill 现在就会构建 IPDF_JsPlatform 表,这与文档是否为 XFA 无关;实际结果是,文档级脚本、字段计算链和 app.alert 现在也能在普通的 AcroForm 文档中执行了
AcroForm JavaScript 在 Delphi 中需要哪个 V8 DLL?
AcroForm JavaScript 需要一个使用 V8 引擎编译的 PDFium 二进制文件,当在库首次加载之前全局 EnableV8Engine 标志为 True 时,PDFium 组件会将其加载为 pdfium.v8.dll;这里有一个值得坦诚说明的陷阱:组件通过预先扫描 /XFA 标记来自动检测 XFA 文档并为您翻转 EnableV8Engine,但是带有 JavaScript 的普通 AcroForm 文档并不携带 XFA 标记,因此不会发生自动检测;如果您的查看器应该运行表单脚本,请在首个文档加载之前由您自己设置一次该标志;加载了普通的 pdfium.dll 之后,进程就无法切换引擎了
uses PDFium;
procedure TViewerForm.FormCreate(Sender: TObject);
begin
// Must run before the singleton PDFium library first loads;
// once plain pdfium.dll is in the process it cannot be swapped
EnableV8Engine := True;
end;
procedure TViewerForm.OpenDocument(const FileName: string);
begin
FPdf.LoadDocument(FileName);
if not V8FeaturesAvailable then
StatusBar.SimpleText :=
'JavaScript disabled: pdfium.v8.dll not deployed';
end;
V8FeaturesAvailable 是真实的运行时探针:它报告加载 the 二进制文件是否实际解析了依赖 V8 的导出,因此无论安装程序最终部署了哪个 DLL,该检查都能工作;相同的引擎和相同的标志也驱动了动态 XFA 渲染;关于 XFA 检测和 V8 自动选择如何交互的背景,在使用 PDFium 检测 XFA 表单并提取 XFA 数据包中有所涵盖
用于警告、打印、邮寄和表单提交的宿主事件
脚本所做的每件可见事情,都会通过 TPdf 事件路由回您的代码,并且在未分配时,每个事件都默认执行安全的空操作(No-op); OnJavaScriptAlert 接收来自 app.alert 的消息、标题、按钮类型和图标,并允许您返回按下的按钮结果; OnJavaScriptResponse 服务于 app.response 输入提示(包括密码标志); OnJavaScriptBeep、 OnJavaScriptPrint、 OnJavaScriptMail 和 OnJavaScriptSubmitForm 暴露出带有其完整参数集的蜂鸣、打印、邮件和提交请求;即使没有分配这些事件的查看器,仍能正确渲染和计算,但该文档无法打开对话框、打印或发送任何内容:
procedure TViewerForm.PdfJavaScriptAlert(Sender: TObject;
const Msg, Title: WString; ButtonType, Icon: Integer;
var Result_: Integer; var Handled: Boolean);
begin
// Route app.alert through the VCL so it is owned by our window
Result_ := MessageBox(Handle, PWideChar(Msg), PWideChar(Title),
MB_OK or MB_ICONINFORMATION);
Handled := True;
end;
procedure TViewerForm.PdfJavaScriptSubmitForm(Sender: TObject;
const Url: WString; const Data: TBytes);
begin
// Nothing is transmitted unless this handler chooses to send it
LogAudit(Format('Document requested form submit to %s (%d bytes)',
[Url, Length(Data)]));
end;
默认行为的划分对于威胁建模很重要; OnJavaScriptMail 和 OnJavaScriptSubmitForm 是通知样式的:PDFium 组件本身不执行任何网络或 MAPI 操作,因此针对毫无准备的宿主调用 this.submitForm 或 this.mailDoc 的恶意文档完全无法达成任何目的;宿主必须通过分配处理程序并自行实现传输来选择加入,这意味着将数据移出机器的决策始终在您的代码中掌握着 URL 和负载时做出
如何阻止恶意 PDF 启动 URL?
URI 操作是历史上默认是主动而不是被动的唯一地方,这就是为什么它们获得了专用的否决权;在 2.13.1 版本之前, FFI_DoURIActionWithKeyboardModifier 回调会无条件地以 Shell 方式执行 XFA 字段提供的任何 URI,这使恶意文档能够通过点击启动任意 URL; OnXfaUriAction 关闭了该行为:组件在执行前会咨询该事件,而将 Handled 设置为 True 的处理程序将完全抑制 ShellExecuteW 的默认行为;将事件保留为未分配则出于兼容性保留了旧的行为,因此安全意识强的查看器应该分配它:
procedure TViewerForm.PdfXfaUriAction(Sender: TObject;
const Uri: WString; Modifiers: Integer; var Handled: Boolean);
begin
// Veto anything that is not plain https; the default would
// otherwise ShellExecuteW the URI as-is
if not SameText(Copy(Uri, 1, 8), 'https://') then
begin
LogAudit('Blocked URI action: ' + Uri);
Handled := True;
end;
end;
对 Scheme 实施允许列表是最低限度,更严格的查看器会向用户确认或将一切导向其自有的沙箱浏览器组件;同样的否决权理念也延伸到 v2.13.1 事件集: OnXfaEmail、 OnXfaHttpRequest 和 OnXfaOpenFile 各自暴露出请求操作的文本参数,而组件本身并不执行任何网络或文件传输;这些防护如何契合到包括渲染隔离在内的更广泛的非受信文档策略中,是在 Delphi 中构建安全 PDF 预览的主题
使用 SetFocusedFormFieldText 写入获得焦点的字段
在 2.13.2 版本中添加的 TPdf.SetFocusedFormFieldText 是 FocusedFormFieldText 的铺垫端伙伴:它通过 FORM_SelectAllText 选择获得焦点的字段的当前内容,并通过 FORM_ReplaceSelection 对其进行覆盖,完全像用户自己输入了替换内容一样;因为文本是通过交互式编辑路径输入进入的,任何附加到该字段的按键、格式和计算脚本都会与手动输入相同的方式触发,这使其成为保持 JavaScript 活性的查看器中进行编程方式填充的正确原语;字段遍历和焦点管理在使用 PDFium 组件的表单字段导航中有所涵盖:
if FPdf.FocusedFormFieldIndex >= 0 then
begin
if not FPdf.SetFocusedFormFieldText('42.50') then
ShowMessage('No focused field accepts text on this page');
// AcroForm: value commits to /V when focus leaves the field.
// XFA: the write stays in the in-memory edit buffer only and
// will not survive a save
end;
该注释中持久性的不对称性是实际存在的边界,而不是随口提的告诫;对于 AcroForm 文本和组合框(Combo)字段,编辑后的值在焦点丢失时提交到字段的 /V 条目中,并在保存时得以持久;对于 XFA 文本字段,写入仅落在内存中的编辑缓冲区内,因为 PDFium 并未公开可以将 Widget 值协调回 XFA datasets 数据包的公共 API,保存该文档将不会携带该修改;如果是必须持久化编辑 XFA 数据,请编辑 datasets XML 本身而不是 Widget
交付前值得了解的限制
仍有两个坦诚的边界;首先,PDFium 实现的 JavaScript API 是 Acrobat 对象模型的一个工作子集,集中在表单脚本编写的核心上:字段访问、计算和格式化事件、 app.alert 和 app.response、打印、邮寄和提交请求;倚赖于仅限 Acrobat 的奇特对象的文档将发生降级(通常是静默地),因此请测试您用户处理的实际表单,而不是假设完全对齐;其次,启用 V8 意味着交付 pdfium.v8.dll(这是一个比普通构建版本大得多的二进制文件);绝不需要脚本编写或 XFA 的查看器可以合法地保留更小的 DLL,并不连接 m_pJsPlatform,这本身也是合法的安全态势
摆脱这一切后的模式令人愉快地平淡:在启动时设置 EnableV8Engine,分配 Alert 和 Response 事件以使对话框看起来更原生,分配 OnXfaUriAction 并对 Mail 和 Submit 事件记录审计日志,并把其他一切保留在其空操作默认值,直到有需求另行说明;完整的事件参考和表单填充 API 表面在 PDFium Component 产品页面上有记录