PDFlibPas 这一原生 Delphi 和 C++Builder PDF 库,为 PDF 文档提供了两个彼此独立的自动行为挂载位置:WillClose、WillSave、DidSave、WillPrint 和 DidPrint 等文档级生命周期动作存储在 Catalog 的 /AA 字典中,而页面级生命周期动作——Open 和 Close——则存储在每个 Page 对象自己的 /AA 字典中。混淆这两个容器,是生命周期动作静默失效最常见的原因
促成这些需求的场景都很常见。财务团队希望报表模板在打印真正开始的瞬间写入打印时间戳并记录打印者,而不是仅仅因为文件打开就执行。表单密集型工作流需要在阅读器的 PDF 客户端允许关闭窗口之前,自动将字段值推送到服务器,从而避免关闭标签页意味着编辑内容丢失。多页报告希望显示一个仅在某一页出现在屏幕上时才可见的页面专属横幅。对于这类行为,PDF 实际上还在文档级和页面级之下提供了第三层:附加到单个表单字段或链接自身 /A 条目的动作,这正是另一篇关于交互式表单动作和 JavaScript 的文章的主题;但本文只讨论上面的两个层级:整个文档和单个页面
哪些触发器位于文档 Catalog 的 /AA 中
Catalog /AA 字典中有五个触发器,每一个都会响应影响整个文档而非单个页面的事件。ISO 32000-1 §12.6.3(触发事件)将文档级键列为 WC、WS、DS、WP 和 DP——写入 /AA 字典的两个字母字面量名称——分别对应 WillClose、WillSave、DidSave、WillPrint 和 DidPrint;PDFlibPas 也在 TPDFlibDocumentActionTrigger 枚举中完整映射了这一组:datWillClose、datWillSave、datDidSave、datWillPrint、datDidPrint。SetDocumentAction 是附加这五种动作的统一入口,它接收的 ActionKind 参数是十个 PDF_ACTION_BUILDER_* 常量之一,这些常量由库中所有动作构建器调用共享,范围从普通 URI、脚本到目标跳转。GoTo、远程文件、嵌入文件或 Launch 动作在触发后实际执行什么,与它们附加的位置是不同的问题;这正是另一篇关于 GoTo、远程、嵌入和启动动作的文章讨论的内容——本文关注的是容器问题,即 Catalog 还是 Page,而不是动作类型问题
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.AddStandardFont(4);
Lib.DrawText(40, 700, 'Quarterly statement');
Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
'https://example.com/audit/will-save', '', 0, 0);
Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_SUBMIT,
'https://example.com/forms/submit', 'CustomerName;OrderTotal', 0, 0);
Lib.SaveToFile('statement.pdf');
finally
Lib.Free;
end;
end;
页面级触发器与文档级触发器有何不同
页面级触发器只会对它所附加的那个 Page 对象触发,PDFlibPas 将它存储在该页面自己的 /AA 字典中,而不是 Catalog 的字典中。页面触发器只有两个,即 Open 和 Close,对应 ISO 32000-1 为页面附加动作字典定义的 O 和 C 键;PDFlibPas 通过 SetPageAction 将它们暴露为 patOpen 和 patClose,并将动作附加到通过 SelectPage 当前选中的页面——当你第一次遍历文档并以为一次调用会应用到所有页面时,这个细节非常重要,因为它从来不会这样做。附加任一类型的触发器还会提高文件所需的最低 PDF 版本,而两个容器要求的版本下限不同:PDFlibPas 第一次写入 Catalog /AA 条目时,会将文档至少提升到 PDF 1.4;第一次写入 Page /AA 条目时,则至少提升到 PDF 1.5,无论其中使用的是哪种动作类型。这是叠加在动作自身要求之上的容器级要求,因此一个单独使用时只需 PDF 1.1 的普通 URI 动作,一旦包裹在页面打开触发器中,仍会将整个文件提升到 PDF 1.5
Lib.SelectPage(3);
Lib.SetPageAction(patOpen, PDF_ACTION_BUILDER_JAVASCRIPT,
'app.alert("Section 3: internal review only");', '', 0, 0);
Lib.SetPageAction(patClose, PDF_ACTION_BUILDER_WEB,
'https://example.com/analytics/page-3-closed', '', 0, 0);
读取和移除生命周期动作
GetDocumentActionInfo 和 GetPageActionInfo 都会返回 TPDFlibActionInfo 记录;当某个触发器没有附加任何内容时,Kind 字段始终返回 akNone,因此在信任记录中的其他字段之前应先检查 Kind——URI、JavaScript、FileName 以及其余字段,只有在 Kind 实际报告的那一种动作类型下才有意义,因为同一记录结构会在构建器能够生成的所有动作类型之间复用。RemoveDocumentAction 和 RemovePageAction 各自清除一个触发器,发现并移除内容时返回 1,触发器已经为空时返回 0;当被移除的条目是 /AA 字典中剩下的最后一个条目时,PDFlibPas 会删除现在已为空的 /AA 字典本身,而不是在 Catalog 或页面上留下悬空且无意义的容器
var
Info: TPDFlibActionInfo;
begin
Info := Lib.GetDocumentActionInfo(datWillSave);
if Info.Kind = akURI then
WriteLn('WillSave calls out to: ', string(Info.URI));
if Lib.RemoveDocumentAction(datWillSave) = 1 then
Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
'https://example.com/audit/will-save-v2', '', 0, 0);
end;
PDF/A 是否允许生命周期动作
不允许。PDF/A 一致性检查会拒绝整个附加动作容器,而不仅仅是听起来有风险的动作类型,因为 ISO 19005 限制了 PDF 的交互式动作模型,其前提是归档文件必须在数十年后仍以相同方式呈现,不能依赖届时可能不存在的脚本引擎或网络连接。SetLifecycleAction 是 SetDocumentAction 和 SetPageAction 共用的构建器,它会先检查 PDFAMode,再查看 ActionKind;因此,仅打开公司网页的 URI 动作,或只表示跳转到下一页的 Named 动作,也会和危险动作一样被拦截——安全审查员通常不会标记的动作同样无法通过,因为这一限制基于结构而非逐项判断。实际风险在于拒绝是静默发生的:SetDocumentAction 和 SetPageAction 都会返回 0 而不引发异常,因此如果调用方从不检查返回值,最终交付的文档可能会悄然缺少本应携带的触发器
Lib.SetPDFAMode(2); // PDF/A-1b
if Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_NAMED,
'', '', 0, 0) = 0 then
// rejected: PDF/A-1b forbids Catalog /AA, even a plain Named action
WriteLn('lifecycle action not attached');
有一个不对称之处值得牢记。RemoveDocumentAction 和 RemovePageAction 从不检查 PDFAMode,因此加载一个已经带有不符合规范的生命周期动作的文件,并在保存为符合 PDF/A 的文件之前将它们移除,可以按预期正常工作——只有写入路径,也就是附加新触发器的路径,才会受到合规模式的限制
没有 WillOpen 触发器时,打开即打印应如何实现
Catalog /AA 字典根本没有 WillOpen 条目,这是设计如此——ISO 32000-1 中的文档级 /AA 恰好定义了五个键:WillClose、WillSave、DidSave、WillPrint 和 DidPrint;其中没有任何一个会仅仅因为文件被打开而触发。打开时钩子位于独立的 Catalog 条目 /OpenAction 中,PDFlibPas 通过自己的一组调用提供该功能,其中包括 SetOpenActionJavaScript、SetOpenActionDestination 和 SetOpenActionNamedDestination;这些调用都不会触碰 /AA 字典或 TPDFlibDocumentActionTrigger 枚举。两种机制可以组合使用,而这通常正是打开即打印模板所需要的方案:构建模板,使其 /OpenAction 启动打印作业,通常是由 JavaScript 动作调用阅读器自身的打印命令;打印本身会让 WillPrint 和 DidPrint 获得运行对象——在页面进入打印队列之前写入时间戳,并在打印完成后写入审计记录
这些触发器在不同 PDF 阅读器中的可靠性如何
即使不考虑 PDF/A,也不是每个阅读器都会运行这些触发器,因此应将生命周期动作视为请求,而不是保证。Acrobat 和大多数完整的桌面阅读器会忠实执行整组触发器,但现实中有很大一部分 PDF 使用场景根本不会处理附加动作字典:浏览器内嵌阅读器、大多数移动阅读器,以及几乎所有服务器端渲染或文本提取管线,要么完全忽略 /AA,要么只支持其中很小的一部分;WillPrint 和 DidPrint 通常最不可靠,因为无头转换没有可供它们挂接的打印操作。如果 WillClose 提交表单动作是捕获表单数据的唯一途径,那么这条途径并不可靠——应将它与显式提交按钮配合使用,并把自动触发器视为对恰好支持它的阅读器的一项便利功能
文档、页面和字段触发器是同一底层动作字典机制的三个层级;容器明确之后,剩下的就是选择正确的 ActionKind 常量并检查返回代码。这些生命周期触发器以及本文涉及的更广泛动作构建器 API,都作为标准PDFlibPas Delphi PDF 库的一部分提供,完整的触发器和动作类型参考请见产品文档