一个归档档导入关卡 (archive ingestion gate) 拒絕了一批 "PDF/A-2b" 文件,这些文件在桌面上的每个查看器中都能正常打开。供应商发誓他们是合规的。其实不然:每一个文件都在目录 (catalog) 中隐藏了 JavaScript 动作,这是肉眼隨便看看永遠不会发现的,而像 veraPDF 这样完整的 PDF/A 验证器则会立即标示出來。問题在於,没有人想在 Delphi 批次服务 (batch service) 上掛载一个 Java 工具鏈,只为了每个文件回答一个“是或否”的問题。这正是 PDFium 元件 (PDFium Component) 中的 ValidatePdfACompliance 所填補的空白,而它如何在完全不解析内容流的情況下達成判定,非常值得深入了解
为什么 PDFium 本身无法回答这个問题
首先要誠实面对的是:搭售的 pdfium.dll 完全没有 PDF/A 功能。它的公开接口上没有 ConvertToPDFA,没有输出意图 (OutputIntent) 写入器,也没有 XMP API。此函式庫中关於 PDF/A 的每个部分 (包含写入端和检查端) 都完全使用 Pascal 写在 FPdfPdfa.pas 中,并透过字节等級 (byte-level) 解析加上增量更新 (incremental update) 來運作。因此,当你呼叫验证器时,你并不是在詢問 Chromium 的渲染器。你正在对文件的結构化字节执行一个 Pascal 标記扫描器 (token scanner)
这个公开 API 刻意设計得很小巧。一个函式会从位置 0 读取流并返回一个記录 (record):
function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;
type
TPdfAValidationResult = record
Conformance: TPdfAConformance; // pacUnknown, pacNone, pac1b, pac2u, ...
Issues: TPdfAValidationIssues; // a set of TPdfAValidationIssue
function IsCompliant: Boolean; // True only when level <> unknown/none
end; // AND Issues is empty
IsCompliant 編码了在关卡中至关重要的规则:只有在偵測到真正的合规等級 (conformance level) 且問题集合 (issue set) 为空时,文件才算通过。解析成功但未找到 pdfaid 标記 (marker) 时会解析为 pacNone,这明確表示未通过。这与 批次预检报告 CLI (batch preflight report CLI) 从外部提出的觀点相同:在一个无法識別的文件上得到空白的发现列表,并不代表它完全健康
在任何标記扫描之前剝離流主体 (stream bodies)
这是最重要的一个实作細節,也是如果你編写自己的扫描器时最容易出错的地方。偵測器透过搜寻带分隔符号的名稱标記 (name tokens) (例如 /JavaScript、/LZWDecode、/BM) 來寻找違规。如果你扫描原始文件字节,则内嵌的二進位流主体、压缩图片、ICC 设置档 (ICC profiles)、字体程式 (font programs) 将会隨機包含看起來像那些标記的字节序列。你会回报“找到”了 /AA 或 /3D,只因为 JPEG 里面的三個字节剛好拼出了它。那簡直是製造误判的工廠
解決方案是 PdfStructureBytes:它会走訪文件,并将每个 stream 和 endstream 关鍵字之間的字节清空为空白字符,同时保持字典 (dictionary) 結构完好无缺。只有在那之后才会执行扫描。验证器中的每一个名稱标記检查都是在这个被剝離的副本上運作的。如果你要从本文带走一个觀念,那就是这个。相同的原则也反映在 PDF/UA 验证器中,它保留了自己的该常式副本,因为这两个标準是独立演進的
29 個問题及其各自的含义
TPdfAValidationIssue 是一个被記录下來的合約。其序数是固定的,因为 DUnitX 測試、展示程式 (demos) 和报告层都依賴於它们,因此新的发现結果只会附加在最后面。截至 v1.63.0,总共有 29 個成員。它们分为几个家族:
- 中繼数据与識別码 (Metadata and identity):
pvaiMissingXmpMetadata、pvaiMissingPdfAIdentifier、pvaiMissingTrailerId(ISO 19005-1 6.1.3)、pvaiMissingXmpDates - 色彩与输出 (Color and output):
pvaiMissingOutputIntent、pvaiMissingIccProfile,以及当 DeviceRGB 和 DeviceCMYK 同时出现时的pvaiMixedDeviceColorSpaces(6.2.3.3) - 所有部分的严格禁令 (Hard prohibitions for every part):
pvaiEncryptionPresent(完全禁止使用/Encrypt字典)、pvaiJavaScriptPresent、pvaiForbiddenAction、pvaiAdditionalActions、pvaiLzwUsed、pvaiXfaPresent、pvaiNeedAppearancesTrue、pvaiForbiddenAnnotation - 字体 (Fonts):
pvaiFontNotEmbedded和更严格的pvaiUnembeddedFont,加上針对没有pvaiUnicodeMappingMissing卻宣告为 Level U 的/ToUnicode - 标記 (Tagging):当合规性 (conformance)=A 的宣告没有标記結构 (tagged structure) 时的
pvaiLevelAStructureMissing
新增在序数 24 到 29 之間的最新的六個成員,涵蓋了审閱者实际会遇到的微妙情況:pvaiTrappedTrue (资訊字典中的 /Trapped /True,这是一个“假朋友”,因为该值必须为 False 或 Unknown)、pvaiForbiddenActionSubtype (将声音 (Sound) 或影片 (Movie) 用作动作,而不僅僅是注释)、pvaiTransparentColorSpace (非 Normal 的混合模式 (blend mode),或是 /CA//ca 不等於 1.0)、pvaiAnnotationDictViolation、pvaiUnembeddedFont 以及 pvaiMixedDeviceColorSpaces
感知各部分的门檻:A-1 很严格,A-2 和 A-3 放寬了
PDF/A 并非只有一本规则書。从 PDF/A-2 开始,明確允許 PDF/A-1 所禁止的三件事:透明度 (一个 /Transparency 群組或一个作用中的 /SMask,6.4)、可選内容 (optional content) (/OCProperties,6.1.13) 以及内嵌文件 (/EmbeddedFiles 或 /EF,6.1.11)。一个在每个文件上都将这三者标示为違规的單純验证器,将会大规模地拒絕完全有效的 PDF/A-2 文件
因此,验证器会透过 PdfAPartOf 从 pdfaid 标記读取部分編号 (part number),并将那些检查关在 PartNo = 1 的门檻后面。針对新的透明度問题的混合模式和注释 Alpha 值检查也类似,僅限於 part-1:
if PartNo = 1 then
begin
if PdfHasName(Struct, '/BM') then
if not PdfHasBMNormal(Struct) then // only /Normal or /Compatible allowed
Include(Result.Issues, pvaiTransparentColorSpace);
if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
Include(Result.Issues, pvaiTransparentColorSpace);
end;
有一个保守的预设值值得一提:当完全没有 pdfaid 标記时,该部分会被視为最严格的 1。理由是,一个无法識別的文件应该受到最严格的规则約束,而不是让它輕易过关。JavaScript、禁止动作、LZW、XFA、NeedAppearances、禁止注释以及未内嵌的字体在每一个部分都是被禁止的,所以这些检查永遠不会放在门檻后面
展开对象流 (object streams),让任何东西都无所遁形
PDF 1.5 引入了交叉参考流 (cross-reference stream) 和对象流 (/Type /ObjStm),它们为單純的字节扫描器製造了盲点。目录 (catalog)、输出意图 (OutputIntent)、动作字典 (action dictionary),任何本身不是流的东西,都可以在 ObjStm 内部進行 Flate 压缩。扫描原始結构,你将什么都看不到,然后回报一个其实一点也不乾淨的文件为乾淨
PdfExpandObjectStreams 彌補了这个漏洞。在任何检查执行之前,验证器会進行 Data := PdfExpandObjectStreams(Data)。这个常式会找到每个 ObjStm,读取它的 /N 和 /First 表头 (header) 以取得包含的对象編号和偏移量,使用 PdfInflate (RTL zlib,Delphi 上的 System.ZLib 和 FPC 上的 zstream) 将主体解压缩,然后将每个包含的对象作为普通的 N 0 obj ... endobj 附加到该字节副本的末端。现有的标記检查便能找到这些对象,而无须改變其邏輯
有两个限制让这项操作變得乾淨而非脆弱。流对象、中繼数据、ICC 设置档和字体程式,都不能存在於对象流中,只有非流字典可以,因此展开操作永遠只处理字典,并且附加的对象不带有任何会干擾主体剝離过程的 stream 关鍵字。而且因为附加的内容落在 %%EOF 之后,从 startxref 反向搜寻仍然能找到原始的预告 (trailer)。交叉参考流预告本身在稍早的 v1.49.3 中就已經处理过,方法是直接从純文字的 xref 流字典读取 Root、Size 和 ID,这是一个在关於验证对象和交叉参考流的姊妹篇中探討过的主题;对象流的工作只需要加上解压缩步驟,而不需要解码 type-2 xref 项目或展开 PNG 预測器 (PNG predictor)
字节等級 (byte-level) 检查器的誠实限制
这是一个预检工具,而不是一个經过認证的验证器,而且其界限是真实存在的。字体内嵌是一种計数啟发式方法 (counting heuristic),要把它做对需要進行一次值得了解的修正。原始的检查使用了 PdfCountName('/FontDescriptor'),但是每一种字体都会貢獻两个 /FontDescriptor 标記,一个是來自字体字典的参考,另一个是描述符 (descriptor) 对象本身的 /Type,所以相对於 N 個内嵌程式,計数是 2N,导致该測試結果永遠为真。修正的方法是 PdfCountDescriptorRefs,它只計算 /FontDescriptor N G R 参考形式,每种字体一个,并且只有在内嵌程式真正較少时才会引发 pvaiUnembeddedFont:
K := PdfCountDescriptorRefs(Struct); // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
+ PdfCountName(Struct, '/FontFile2')
+ PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
Include(Result.Issues, pvaiUnembeddedFont);
即使修正了,它还是很粗略:一个混合文件中,如果每个描述符剛好都有某些 FontFile,仍然可能让個別不合规的字体成为漏网之魚。展开对象流也有一个已知的副作用,它会暴露出 AcroForm /DR 所攜带的 standard-14 预设资源 (例如 /Helv),而此啟发式方法会盡責地报告它们为未内嵌,儘管 veraPDF 让它们通过,因为它们实际上从未被用來渲染。内容流操作員等級 (operator-level) 的检查 (6.2.10) 则完全超出了范围,因为它们需要完整的内容解析而不是字节扫描。将验证器視为一个快速、无依赖的第一道关卡,用來捕捉标記注入无法修復的違规行为,并保留一个完整的验证器來進行最终認证
这只是故事中关於检查的一半。互補的写入端,也就是 SaveAsPdfA 注入 XMP、输出意图 (OutputIntent) 和 sRGB ICC 设置档,并誠实地将没有标記結构的 Level A 请求降級的地方,是建立在相同的字节等級機制之上。这两半都包含在 PDFium Component for Delphi 之中,这是一个單一的 VCL 封裝 (package),在没有任何外部执行階段可供安裝的情況下,覆蓋於一个純 Pascal 的 PDF/A 实作之上