技术文章

用 PDFlibPas 实现 PDF 2.0 的页面级关联文件

PDFlibPas 可以把一个内嵌文件挂到某个具体页面上,而不是挂到整个文档:在页面字典里写入 /AF 数组,而文件本体仍然登记在文档级 EmbeddedFiles 名称树中。ISO 32000-2 第 14.13 节描述的正是这种拆分,它让阅读器能回答文档级附件回答不了的问题:这份数据到底属于哪一页

它的用例比一般附件更具体。一份勘测报告,每页都带着自己图表背后的原始测量序列;一批扫描件,每页都保留着生成其文字层的 OCR 结果;一套图纸,每张图都携带渲染它的 CAD 提取物。这几种场景里,文档级附件列表都只能变成一堆靠文件名里的页码来区分的文件——那是约定,不是结构

一份文件本体,两处引用来源

结构上关键的一点是:页面级关联不会制造任何第二副本。文件只内嵌一次,并且和文档级附件一样登记在 EmbeddedFiles 名称树里,用的是同一套文件规格(file specification)机制。不同的只是引用及其关系键写在哪里:写进页面字典,而不是文档 catalog

由此带来两个后果。第一,只认识文档级附件的阅读器依然找得到文件本体,因为它就在这类阅读器会查的名称树里。第二,清除页面关联移除的是绑定,不是文件。ClearPageAssociatedFiles 只把页面与关联文件解绑,文件本体通过名称树依然可达,这是保守的做法:一个声明为「清除关联」的操作,不应该悄悄销毁文档其他部分可能引用的数据

PDFlibPas 写出的 PDF 2.0 文档中页面级关联文件的结构:文件本体只内嵌一次并登记在文档 catalog 的 EmbeddedFiles 名称树下,页面字典则携带一个 /AF 数组,以 AFRelationship 键引用同一份文件规格,因此 ClearPageAssociatedFiles 只解除绑定而不销毁数据
页面级关联添加的是第二个引用,不是第二份副本:只认识文档级附件的阅读器仍能在名称树里找到文件本体,清除页面绑定后内嵌流依然可达

这个函数有一个刻意收窄的成功条件,值得知道:只有当页面确实带有 /AF 键时它才报告成功。从未有过关联的页面会返回失败,而不是 cheerfully 地确认一句,这样调用方就不会把一次空操作误当成清理完成

var
  Lib: TPDFlib;
  Idx, I: Integer;
begin
  Lib := TPDFlib.Create(nil);
  try
    Lib.LoadFromFile('survey-report.pdf');

    // 附上生成第 3 页图表的原始测量序列
    Idx := Lib.AddPageAssociatedFileFromFile(3,
      'series-03.csv',            // 磁盘上的文件
      'measurements.csv',         // PDF 内的显示名
      'text/csv',                 // MIME 类型
      'Raw measurement series for figure 3',
      'Data');                    // AFRelationship,见 ISO 32000-2 14.13

    if Idx < 0 then
      raise Exception.Create('page association refused');

    for I := 0 to Lib.GetPageAssociatedFileCount(3) - 1 do
      Writeln('page 3 associated file, embedded index ',
        Lib.GetPageAssociatedFileEmbeddedIndex(3, I));

    Lib.SaveToFile('survey-report-with-data.pdf');
  finally
    Lib.Free;
  end;
end;

关系字符串在实践中不是随便填的自由文本。ISO 32000-2 定义了一套词表——SourceDataAlternativeSupplementEncryptedPayloadFormDataSchemaUnspecified——下游工具会按它来处理:Data 表示图表背后的数字,Source 表示页面由哪个文档生成,Alternative 表示等价表示。就算你的流水线里暂时没人读它,也从词表里选,因为链路上的下一个工具说不定会读

为什么同一个查找需要双向的 FollowRef?

因为「跟随引用」回答的是两个不同的问题,代码必须知道自己问的是哪一个。跟随间接引用的键查找返回引用指向的对象;不跟随的查找返回引用本身。两者都对,用错的那一个产生的是静默的错误行为,而不是报错

读取关联文件展示了第一个方向。要拿到文件规格 /EF/F 键背后内嵌流的对象号,查找就不能跟随引用,因为一跟随,引用就被解析成了流对象本身,对象号就没了。这条规则可以推广:任何需要对象身份而非对象内容的代码路径,都必须拿原始引用

可选内容则展示了相反的方向,而且这个坑找起来更费劲。可选内容属性字典是以间接对象的形式写进 catalog 的,读回时不跟随引用拿到的就是一个引用而不是字典。接下来对它做类型检查必然失败,于是那个看起来很自然的回退分支——没有配置就创建一份——开始执行,把已经存在的配置覆盖掉。全程没有任何异常。可选内容组与图层里描述的那些图层,就这样悄悄丢掉了默认可见性状态

这条教训在这两个案例之外同样成立。当一次查找可能返回引用也可能返回对象时,裸类型检查不是错误处理:它是一个终将因错误原因被走进的分支。每个调用点到底需要什么,要明确决定;能直接回答问题的公开 API(比如可选内容计数属性)优先于伸手去够保护级访问器拿 catalog 字典

PDFlibPas 实现的 PDF 查找中引用跟随的决策图:读取文件规格下的 /EF 和 /F 时不能跟随引用,因为内嵌流的对象号正是答案;而 catalog 中以间接对象形式存在的 /OCProperties 字典必须跟随,否则失败的类型检查会静默覆盖已有的可选内容配置
同一次查找回答两个不同的问题:要身份就拿原始引用,要内容就拿解析后的对象,用裸类型检查代替这个决定,最终会在不抛异常的情况下走进错误的分支
// 文档级附件与页面级关联可以共存。一个内嵌文件
// 也可以同时在文档级被标记为关联
if Lib.IsEmbeddedFileAssociated(0) = 0 then
  Lib.SetEmbeddedFileAssociated(0, 1, 'Supplement');

Writeln('document associated files: ', Lib.GetAssociatedFileCount);
Writeln('page 3 associated files  : ',
        Lib.GetPageAssociatedFileCount(3));

// 清除只是解绑页面;文件本体仍留在名称树里
if Lib.ClearPageAssociatedFiles(3) > 0 then
  Writeln('page 3 associations removed, payloads still reachable');

一致性模式对附件做了什么

归档类规范会限制能内嵌什么,而且这道限制是在入口处执行的,不是在保存时。PDF/A-1 完全禁止内嵌文件,PDF/A-2 只允许内嵌 PDF/A 文档,PDF/A-3 则是把内嵌放开到任意文件类型的那一档——混合式发票格式正是看中了这一点才构建在它上面

当当前一致性模式不允许时,PDFlibPas 会在调用的那一刻拒绝附件,而不是几百个操作之后的输出阶段。这是一个关于「在哪报错代价最小」的刻意选择:调用点的拒绝会点名你正在添加的那个文件;保存时的拒绝只能指向一个文档,留你自己去从四十个附件里猜是哪个惹的祸

这也是关联文件在电子发票里如此常见的原因。混合式发票是一份人能读的 PDF,附带着机器可读的 XML 负载并用正确的关系键标记,而且容器规范和关系键都是规格的一部分,不是约定。构建 Factur-X 与 ZUGFeRD 混合发票讲的就是这套构造,元数据一侧见 PDF/A-3 的 XMP 扩展架构

什么时候该按页关联,而不是按文档?

当下游需要知道数据属于哪一页时——且仅在这时。文档级附件更简单、阅读器支持更广,只要负载描述的是整个文档(发票 XML、签名清单、源码归档),它就够用。只有当负载真正以页面为作用域、页面身份本身是语义的一部分时,才动用页面级关联

支持度才是现实约束。页面级关联文件是 PDF 2.0 的构造,阅读器支持比文档级附件薄。好在无论哪种方式文件本体都在名称树里,忽略页面上 /AF 的阅读器仍然会在附件列表里显示这个文件,所以降级是优雅的。但如果页面绑定对你的下游是必需品而不是锦上添花的元数据,去验证你真正面向的那个阅读器,别靠假设

页面级关联文件、文档级附件,以及同时管着两者的归档规范闸门,都随 PDFlibPas Delphi PDF library 提供。如果你还要顺手修进入时的旧文件,带元数据修复转换 PDF/A 里的元数据与一致性工作,决定的是这些附件路线一开始有哪些可供你选择