PDFlibPas 可以把一个内嵌文件挂到某个具体页面上,而不是挂到整个文档:在页面字典里写入 /AF 数组,而文件本体仍然登记在文档级 EmbeddedFiles 名称树中。ISO 32000-2 第 14.13 节描述的正是这种拆分,它让阅读器能回答文档级附件回答不了的问题:这份数据到底属于哪一页
它的用例比一般附件更具体。一份勘测报告,每页都带着自己图表背后的原始测量序列;一批扫描件,每页都保留着生成其文字层的 OCR 结果;一套图纸,每张图都携带渲染它的 CAD 提取物。这几种场景里,文档级附件列表都只能变成一堆靠文件名里的页码来区分的文件——那是约定,不是结构
一份文件本体,两处引用来源
结构上关键的一点是:页面级关联不会制造任何第二副本。文件只内嵌一次,并且和文档级附件一样登记在 EmbeddedFiles 名称树里,用的是同一套文件规格(file specification)机制。不同的只是引用及其关系键写在哪里:写进页面字典,而不是文档 catalog
由此带来两个后果。第一,只认识文档级附件的阅读器依然找得到文件本体,因为它就在这类阅读器会查的名称树里。第二,清除页面关联移除的是绑定,不是文件。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 定义了一套词表——Source、Data、Alternative、Supplement、EncryptedPayload、FormData、Schema 和 Unspecified——下游工具会按它来处理:Data 表示图表背后的数字,Source 表示页面由哪个文档生成,Alternative 表示等价表示。就算你的流水线里暂时没人读它,也从词表里选,因为链路上的下一个工具说不定会读
为什么同一个查找需要双向的 FollowRef?
因为「跟随引用」回答的是两个不同的问题,代码必须知道自己问的是哪一个。跟随间接引用的键查找返回引用指向的对象;不跟随的查找返回引用本身。两者都对,用错的那一个产生的是静默的错误行为,而不是报错
读取关联文件展示了第一个方向。要拿到文件规格 /EF 和 /F 键背后内嵌流的对象号,查找就不能跟随引用,因为一跟随,引用就被解析成了流对象本身,对象号就没了。这条规则可以推广:任何需要对象身份而非对象内容的代码路径,都必须拿原始引用
可选内容则展示了相反的方向,而且这个坑找起来更费劲。可选内容属性字典是以间接对象的形式写进 catalog 的,读回时不跟随引用拿到的就是一个引用而不是字典。接下来对它做类型检查必然失败,于是那个看起来很自然的回退分支——没有配置就创建一份——开始执行,把已经存在的配置覆盖掉。全程没有任何异常。可选内容组与图层里描述的那些图层,就这样悄悄丢掉了默认可见性状态
这条教训在这两个案例之外同样成立。当一次查找可能返回引用也可能返回对象时,裸类型检查不是错误处理:它是一个终将因错误原因被走进的分支。每个调用点到底需要什么,要明确决定;能直接回答问题的公开 API(比如可选内容计数属性)优先于伸手去够保护级访问器拿 catalog 字典
// 文档级附件与页面级关联可以共存。一个内嵌文件
// 也可以同时在文档级被标记为关联
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 里的元数据与一致性工作,决定的是这些附件路线一开始有哪些可供你选择