你发布了一个把每个文件都标记为 PDF/A-1b 的转换器,客户的档案系统照单全收了一整年,然后一次审计把整批文件送进 veraPDF,结果三分之一被判为不合规。没有崩溃,没有抛出异常,文件在你桌上的每一个阅读器里都能正常打开。它们只是不符合你盖上去的那个标准标记。这是归档 PDF 最常见的失效模式,也正是“我们设置了标志”永远不等于“它通过了验证”的原因
关于 PDFium 和 PDF/A,首先要明白的一点是:引擎本身与此无关。PDFium 负责渲染、解析和写入 PDF,但它的公开接口里没有 ConvertToPDFA,没有 OutputIntent 写入器,也没有 XMP API。归档合规的每一个环节——XMP 数据包、OutputIntent 及其 ICC 配置文件、目录标记、验证——全部位于 PDFiumPas 自身,在一个约 2,000 行的纯 Pascal 单元(FPdfPdfa.pas)中,它解析已保存的字节并通过增量更新重写它们。知道工作发生在哪里,就知道 bug 藏在哪里——而它们并不藏在 PDFium 里
PDF/A 实际要求什么,陷阱又在哪里
PDF/A 不是单一格式。ISO 19005 定义了三个部分(PDF/A-1、-2、-3),每个部分之内又划分了承诺各不相同的一致性级别。Level B(basic,基础级)只保证视觉外观可重现。Level A(accessible,可访问级)在 B 之上增加了标签结构树和 Unicode 映射。仅存在于部分 2 和部分 3 的 Level U 介于两者之间:无需完整结构树也能获得可靠的 Unicode 文本。ISO 19005-1 没有 Level U,库把这条约束直接编进了代码
实践中真正会咬人的只是少数几条格式规则。加密被完全禁止(ISO 19005-1 §6.1.3 及其后续版本):PDF/A 文件不能携带 /Encrypt 字典。文档必须通过 OutputIntent 声明输出渲染条件,其目标必须是一个有效的 ICC 配置文件(§6.2.3.2)。一致性声明本身必须以 XMP 元数据的形式出现在 PDF/A 标识模式之下。Level A 还额外要求 §6.8 逻辑结构,即让文档可供机器读取的标签树。任何一项缺失,一致性验证器都会拒绝该文件,哪怕它渲染得再完美
一次调用生成归档
PDFiumPas 把整条流水线封装在 TPdf.SaveAsPdfA 之后。这个简单的重载接收一个目标一致性等级,默认为 PDF/A-1b——对常见的“让它永远可以渲染”的场景来说,这正是正确的默认值
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'invoice.pdf';
Pdf.Active := True;
// 默认一致性等级为 pac1b(PDF/A-1b)
if Pdf.SaveAsPdfA('invoice_archive.pdf') then
// 文件现在携带 XMP、sRGB OutputIntent 和目录标记
else
raise Exception.Create('PDF/A save failed');
finally
Pdf.Free;
end;
end;
在底层这是一个两步动作。SaveAsPdfA 先让 PDFium 用 FPDF_SaveAsCopy 序列化文档,再把字节流交给 InjectPdfAMarkers,由它以增量更新的形式附加 XMP 元数据、带内嵌 ICC 配置文件的 sRGB OutputIntent,以及一个重写后的目录。源从位置 0 读取,目标也从位置 0 写入;原始对象树原封不动,标记紧跟在现有的 %%EOF 之后写入。如果你要的是字节而不是文件,SaveAsPdfAToStream 接收一个 TStream 和相同的选项
用选项记录选择一致性等级
要以特定的部分和级别为目标,请传入一个 TPdfASaveOptions 记录。它的 Conformance 字段接收一个 TPdfAConformance 值。这个枚举涵盖且仅涵盖所有有效组合:部分 1 的 pac1b、pac1a;部分 2 的 pac2b、pac2u、pac2a;部分 3 的 pac3b、pac3u、pac3a,外加验证端使用的 pacUnknown 和 pacNone。没有 pac1u,因为标准中不存在这个级别
var
Pdf: TPdf;
Opts: TPdfASaveOptions;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'report.pdf';
Pdf.Active := True;
Opts := TPdfASaveOptions.Default;
Opts.Conformance := pac2u; // PDF/A-2u:可靠的 Unicode 文本
Opts.Title := 'Quarterly Report 2026';
Opts.Author := 'Finance';
// 将 IccProfileData 留空即可使用内置的 sRGB IEC61966-2.1 配置文件
if not Pdf.SaveAsPdfA('report_a2u.pdf', Opts) then
raise Exception.Create('PDF/A-2u save failed');
finally
Pdf.Free;
end;
end;
记录的大部分字段都可以留空。将 Title、Author、Subject、Keywords、Creator 和 Producer 留空,SaveAsPdfA 会通过 FPDF_GetMetaText 从文档的 Info 字典自动填充。将 CreationDate 和 ModDate 留空,它会用当前的 UTC 时间填充两个 XMP 日期。将 DocumentId 和 InstanceId 留空,库会通过 FPDF_GetFileIdentifier 预先填充,失败时回退到一个由源字节派生的确定性 ID。唯一值得刻意覆盖的字段是 IccProfileData:留空表示使用内置的 sRGB IEC61966-2.1 配置文件,但 CMYK 或灰度工作流应当提供自己的配置文件
为什么 Level A 会降级,以及为什么这是诚实的做法
这里有一个细节,会让以为“设了标志就等于有了保证”的人栽跟头。你完全可以对一个没有标签树的文档请求 pac1a,但 PDF/A-1a 要求 §6.8 逻辑结构,而库无法凭空为一个无标签的 PDF 制造出结构树。SaveAsPdfA 不会输出一个声称 Level A 却过不了验证的文件,而是检查是否存在真正的标签结构(/StructTreeRoot 加上带 /Marked true 的 /MarkInfo),若不存在就降低声明级别:pac1a 变为 pac1b,pac2a 变为 pac2b,三个部分依此类推。内部的辅助函数是 PdfAIsLevelA 和 PdfADowngradeToLevelB
这背后的理由值得说透:诚实声明自己所达到级别的文件,比谎报一个并未达到的级别的文件更有用。Level U 的处理方式不同。检测真实的 Unicode 覆盖情况意味着做一个天真的“它有没有 /ToUnicode”测试,这会过度降级合法文档(WinAnsi 及类似编码是豁免的),因此保存端按调用方的声明原样输出 U 声明,把差异留给验证端去标记。如果你需要一个保证为 Level A 的归档,请在转换之前为文档加上标签;转换器不会发明不存在的结构
只有真正的验证器才能抓到的 ICC 陷阱
这是一次教训最深的失败:库自带的检查器通过了,而 ISO 19005 的参考验证器 veraPDF 却没有通过。PDF/A 要求 OutputIntent 的目标配置文件是有效的 ICCBased 流,而 §6.2.3.2 要求验证器把该流当作色彩空间来验证。ICCBased 流必须声明 /N,即颜色分量数。早期版本的注入器写 ICC 流字典时只写了 /Length 而没有 /N,于是 veraPDF 拒绝了结果并报出“The N entry (value null)... is missing”
它阴险的地方在于,这种拒绝只在 PDF/A-1b 和 -1a 下触发。部分 2 和部分 3 的一致性模型不会对目标配置文件执行这项特定检查,于是完全相同的注入结构在 pac2b、pac3b 和 pac2u 下验证通过,却仅因 pdfaid:part 的值在 pac1b 下失败。单元测试永远看不到它,因为库自己的 ValidatePdfACompliance 只检查 /DestOutputProfile 键是否存在,不看流字典里到底是什么。内部测试一路绿灯;真实的归档验证却失败了
修复方案是 IccComponentCount:它读取 ICC 头部偏移 16 处的数据色彩空间签名并映射为分量数——GRAY 为 1,RGB 、Lab 和 XYZ 为 3,CMYK 为 4,未知配置文件默认为 3。这个计数作为 /N 写入流字典。它是计算出来的,而不是硬编码为 3,这样通过 IccProfileData 提供 CMYK 或灰度配置文件的调用方也能得到正确的值。更广泛的教训在于方法论:库内检查器和权威验证器各有盲区,PDF/A 输出必须对照 veraPDF 这样的参考实现做端到端测试,而不能信任自检。支撑干净归档的同一套增量更新纪律,在验证压缩的对象流和 xref 流中有专门讨论——这很重要,因为注入器所消费的现代 PDF 往往建立在交叉引用流之上
加密、xref 流以及其他边界情况
由于 ISO 19005 禁止加密,保存路径会在写入前剥离加密。SaveAsPdfA 在序列化时应用 FPDF_REMOVE_SECURITY,因此加密的源文件(用其密码加载)在进入归档的途中即被解密。在未加密的文档上这是一个空操作,不会改变任何东西。由此得出的结论与 HotPDF 从另一个方向施加的约束相同:单个文件不可能既是加密的又是 PDF/A。当工作流两者都需要时,答案是两份产物——一份用于分发的加密副本,以及一份单独的干净归档副本
还有一个边界情况在被咬到之前是看不见的:使用纯交叉引用流且不带 trailer 关键字的 PDF 1.5+ 文档。注入器要读取 trailer 来找到源 /Info 并附加增量更新,它必须接受 xref 流的形式,否则这类文档会在标记被悄悄丢弃的情况下被原样复制过去。ISO 32000-1 §7.5.6 明确允许在 xref 流文档之后跟随一个传统 trailer 的增量更新,/Prev 指向 xref 流的偏移量——这正是注入器输出的结构。PDFium 自己的 FPDF_SaveAsCopy 总是写传统 trailer,所以在正常流水线中注入器永远不会遇到纯 xref 流的源,但读取路径会处理来自其他地方的这类文档
在相信声明之前先验证
库附带一个字节级检查器 TPdf.ValidatePdfA,它返回一个 TPdfAValidationResult。其 Conformance 字段报告检测到的级别,Issues 是一组 TPdfAValidationIssue 值的集合;便捷方法 IsCompliant 只有在检测到真实级别且问题集合为空时才为真。在批处理中,请把它当作快速的第一道闸门来运行
var
Pdf: TPdf;
Res: TPdfAValidationResult;
Issue: TPdfAValidationIssue;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'invoice_archive.pdf';
Pdf.Active := True;
Res := Pdf.ValidatePdfA;
if Res.IsCompliant then
Writeln('Conformant: detected level ', Ord(Res.Conformance))
else
for Issue in Res.Issues do
Writeln('Issue: ', Ord(Issue));
finally
Pdf.Free;
end;
end;
对这个检查器能带来什么要实事求是。字节级检查器能以高置信度抓出结构性问题(缺少 OutputIntent、被禁止的动作、出现 /Encrypt、在部分 1 禁止透明度的地方使用了透明度),字体嵌入检测使用计数启发式,刻意只报告高置信度信号,而不去逐字形追踪覆盖情况。它不做的是内容流操作符分析——那需要一个完整的内容解析器,在设计上就超出了范围。作为发布闸门,请将库内检查器与 veraPDF 搭配使用:检查器即时完成、无需 DLL、随处可运行,而 veraPDF 是权威。把这种搭配接入批处理运行,正是批量印前检查报告 CLI的主题,也是这项验证在真实归档工作流中的归宿
本文展示的 SaveAsPdfA、InjectPdfAMarkers 和 ValidatePdfA API 随支持 Delphi、C++Builder 和 Lazarus/FPC 的 PDFium Component 一起发布。产品页面链接了完整的 API 参考文档,包括完整的一致性枚举以及这些示例背后的选项记录