你释出了一个会将每个文件标記为 PDF/A-1b 的转换器,客戶的紀录系統将它们摄取 (ingest) 了一年,然后一次稽核将整批文件跑过 veraPDF,結果有三分之一被判定为不合规。没有发生当機、没有引发例外,文件在你桌上的每一个查看器中都能順利打开。它们只是不符合你在上面蓋的标準印章。这是归档用 PDF 的常態失敗模式,这也是为什么“我们设置了旗标”永遠不等於“它通过了验证”
关於 PDFium 和 PDF/A,首先要了解的是,引擎本身跟它一点关係都没有。PDFium 負責渲染、解析和写入 PDF,但它的公开接口没有 ConvertToPDFA、没有 OutputIntent 写入器,也没有 XMP API。归档合规性的每一个環節——XMP 封包、OutputIntent 及其 ICC 设置档、目录标記 (catalog markers)、验证——都存在於 PDFiumPas 内部,在一个大約 2,000 行的純 Pascal 單元 (FPdfPdfa.pas) 中,它負責解析保存的字节,并透过漸進式更新 (incremental update) 重新写入它们。了解工作是在哪里進行的,就会知道错误藏在哪里,而它们并没有藏在 PDFium 里
PDF/A 实际的要求是什么,陷阱又在哪里
PDF/A 不是一种單一格式。ISO 19005 定义了三個部分 (PDF/A-1, -2, -3),而在每个部分中,又定义了保证不同事项的一致性等級 (conformance levels)。Level B (basic) 僅保证视觉外觀是可重现的。Level A (accessible) 在 B 的基础上增加了标签结构树 (tagged structure tree) 和 Unicode 对应。Level U 僅存在於第 2 和第 3 部分,介於两者之間:具有可靠的 Unicode 文字,但没有完整的结构树。ISO 19005-1 没有 Level U,程式庫直接編码了这项限制
实际上,这项格式的规则中只有少数几條会成为陷阱。完全禁止加密(ISO 19005-1 條款 6.1.3 及其后續版本):PDF/A 文件不能带有 /Encrypt 字典。文件必须透过 OutputIntent 宣告一个输出渲染條件 (output rendering condition),且其目标位置必须是有效的 ICC 设置档(條款 6.2.3.2)。一致性声明 (conformance claim) 本身必须作为 XMP 詮释数据 (metadata) 出现在 PDF/A 識別結构 (identification schema) 之下。Level A 額外要求條款 6.8 的邏輯結构,也就是让文件具備機器可读性的标签树。只要遗漏其中任何一项,一致性验证器就会拒絕该文件,即使它能完美渲染
产生归档档的單一呼叫
PDFiumPas 将整個管线封裝在 TPdf.SaveAsPdfA 之后。这个簡單的多载方法接收一个目标一致性,并预设为 PDF/A-1b,这对於常見的“让它永遠可渲染”案例來说,是正確的预设值
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.LoadFromFile('invoice.pdf');
// Default conformance is pac1b (PDF/A-1b)
if Pdf.SaveAsPdfA('invoice_archive.pdf') then
// file now carries XMP, sRGB OutputIntent, and catalog markers
else
raise Exception.Create('PDF/A save failed');
finally
Pdf.Free;
end;
end;
在底层,这是一个两階段的动作。SaveAsPdfA 首先要求 PDFium 使用 FPDF_SaveAsCopy 序列化文件,然后将该字节流交給 InjectPdfAMarkers,后者会附加 XMP 詮释数据、带有嵌入式 ICC 设置档的 sRGB OutputIntent,以及一个重写后的目录 (catalog),作为一个漸進式更新 (incremental update)。來源是从位置 0 读取,目标位置则是从位置 0 写入;原始的对象树保持不變,而标記则会掛载在现有的 %%EOF 之后。如果你需要的是字节而不是文件,SaveAsPdfAToStream 接收一个 TStream 和相同的选项
使用选项紀录 (options record) 选择一致性
要以特定部分和等級为目标,请传递一个 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.LoadFromFile('report.pdf');
Opts := TPdfASaveOptions.Default;
Opts.Conformance := pac2u; // PDF/A-2u: reliable Unicode text
Opts.Title := 'Quarterly Report 2026';
Opts.Author := 'Finance';
// Leave IccProfileData empty to use the built-in sRGB IEC61966-2.1 profile
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 会降級,以及为什么这是誠实的选择
这里有一个微妙之处,会让期望旗标就是保证的人跌跤。你可以对一个没有标签树 (tag tree) 的文件要求 pac1a,但 PDF/A-1a 需要條款 6.8 的邏輯結构,而程式庫无法憑空从一个没有标签的 PDF 中製造出结构树。与其发出一个宣稱符合 Level A 卻没有通过验证的文件,SaveAsPdfA 会检查是否有真正的标签結构(/StructTreeRoot 加上带有 /MarkInfo 的 /Marked true),如果不存在,就会降級该声明:在所有三個部分中,pac1a 變成 pac1b,pac2a 變成 pac2b,依此类推。内部的輔助函式是 PdfAIsLevelA 和 PdfADowngradeToLevelB
背后的理由值得清楚说明:一个誠实宣告其符合等級的文件,比一个謊报其未達到等級的文件更有用。Level U 的处理方式不同。偵測真正的 Unicode 涵蓋范围会需要一个天真的“它有 /ToUnicode 嗎”測試,这会过度降級合法的文件(WinAnsi 及类似的編码是豁免的),所以保存端会依据呼叫端的宣告发出 U 的声明,并将差異留給验证端去标記。如果你需要一个保证为 Level A 的归档档,请在转换之前为文件加上标签;转换器不会发明不存在的結构
只有真正的验证器才抓得到的 ICC 陷阱
这是让我们學到最深刻教訓的一次失敗,因为程式庫自己的检查器通过了,而 veraPDF(ISO 19005 的参考验证器)卻没有通过。PDF/A 要求 OutputIntent 的目标位置设置档必须是一个有效的 ICCBased 流,而條款 6.2.3.2 规定验证器必须将该流验证为色彩空間 (colour space)。ICCBased 流必须宣告 /N,也就是色彩元件的数量。早期版本的注入器写入 ICC 流字典时只有 /Length 而没有 /N,於是 veraPDF 拒絕了该結果并提示 "The N entry (value null)... is missing"
这件事之所以陰險,是因为这个拒絕只会在 PDF/A-1b 和 -1a 时觸发。第 2 和第 3 部分的一致性模型并没有对目标位置设置档执行那個特定的检查,所以完全相同的注入結构在 pac2b、pac3b 和 pac2u 下都通过了验证,卻僅僅因为 pac1b 的值而在 pdfaid:part 下失敗。單元測試永遠无法看見它,因为程式庫自己的 ValidatePdfACompliance 只检查了 /DestOutputProfile 鍵是否存在,而没有检查流字典里面的内容。内部測試維持綠燈;真正的归档验证卻失敗了
修復方式是 IccComponentCount,它读取位於 ICC 表头偏移量 16 的数据色彩空間簽章,并将其对应到元件数量:GRAY 是 1,RGB 、Lab 和 XYZ 是 3,CMYK 是 4,未知设置档预设为 3。这个計数作为 /N 進入流字典。它是計算出來的,而不是硬編码为 3,因此透过 IccProfileData 提供 CMYK 或灰階设置档的呼叫端,仍然能獲得正確的值。更广泛的教訓在於方法論:程式庫内建的检查器和权威的验证器都有各自的盲点,而 PDF/A 输出必须跟像 veraPDF 这样的参考实作進行端到端的測試,而不是信任自我检查。確保归档乾淨的漸進式更新紀律,在使用 PDFium VCL 验证压缩的对象和 xref 流中有探討,这很重要,因为注入器所消費的现代 PDF 經常是建立在交叉参照流 (cross-reference streams) 上的
加密、xref 流以及其他边界情況
由於 ISO 19005 禁止加密,保存路径在写入前会将其剝離。SaveAsPdfA 在序列化时会套用 FPDF_REMOVE_SECURITY,因此加密的來源(使用其密码加载)在進入归档档的途中会被解密。在未加密的文件上这是一个空操作,不会改變任何东西。其必然結果就跟 HotPDF 从另一个方向所执行的限制一样:單一文件不可能既是加密的又是 PDF/A。当一个工作流需要这两者时,答案就是产出两个成品,一个用於发佈的加密副本,以及一个用於归档的独立乾淨副本
还有一个在咬到你之前看不見的边界情況:使用純交叉参照流且不带有 trailer 关鍵字的 PDF 1.5+ 文件。注入器会读取预告区 (trailer) 來寻找來源的 /Info,并附加其漸進式更新,它必须接受 xref 流的形式,否则这样的文件就会在标記被默默丟棄的情況下被直接复制过去。ISO 32000-1 條款 7.5.6 明確允許在 xref 流文件之后跟隨一个传統的预告区漸進式更新,并使用指向 xref 流偏移量的 /Prev,这正是注入器所发出的結构。PDFium 自己的 FPDF_SaveAsCopy 总是写入传統的预告区,所以在正常管线中,注入器永遠不会遇到純 xref 流的來源,但读取路径会处理來自其他地方的这类文件
在信任声明前進行验证
本程式庫隨附了一个字节等級的检查器 TPdf.ValidatePdfA,它会返回一个 TPdfAValidationResult。其 Conformance 字段报告偵測到的等級,而 Issues 是一个 TPdfAValidationIssue 值的集合 (set);便利方法 IsCompliant 只有在偵測到真实的等級且問题集合为空时才为真。在批次处理中,请将其作为快速的第一道关卡來执行
var
Pdf: TPdf;
Res: TPdfAValidationResult;
begin
Pdf := TPdf.Create(nil);
try
Pdf.LoadFromFile('invoice_archive.pdf');
Res := Pdf.ValidatePdfA;
if Res.IsCompliant then
Writeln('Conformant: detected level ', Ord(Res.Conformance))
else
Writeln('Issues found: ', SizeOf(Res.Issues), ' flags set');
finally
Pdf.Free;
end;
end;
对这能为你带來什么好处要誠实以对。字节等級的检查器能以高置信度抓出結构性問题(缺少 OutputIntent、禁止的操作、出现 /Encrypt、在第 1 部分禁止透明度的地方使用了透明度),而字体嵌入偵測使用了一种計数啟发式演算法,刻意只回报高置信度的訊号,而不是去追蹤每个字符的涵蓋范围。它不会做的是内容流操作符分析,这会需要一个完整的内容解析器,在设計上就排除了。作为发佈的关卡,请将程式庫内建的检查器与 veraPDF 搭配使用:检查器是即时的,且无需 DLL 即可在任何地方执行,而 veraPDF 则是权威的。将这种配对串接進一个批次执行中,是批次预检报告 CLI 的主题,这也是这项验证在真实归档工作流中的歸宿
这里展示的 SaveAsPdfA、InjectPdfAMarkers 和 ValidatePdfA API,隨附於 Delphi、C++Builder 和 Lazarus/FPC 的 PDFium Component 之中。产品页面連結了完整的 API 参考手冊,包含这几个範例背后的完整一致性列舉和选项紀录