技术文章

Delphi 中的 PDF/UA-2 验证:不只是 UA-1 第二部分

PDFlibPas 在 Delphi 中通过 TPDFlib.CheckFilePDFUACompliance(target part 传 2)验证 PDF/UA-2(ISO 14289-2)文件:它运行的是一条独立的 PDF/UA-2 检查路径,通过 PDF 2.0 命名空间和 /RoleMapNS 解析结构角色,把截断或无法读取的输入直接报告为失败,并返回一份从不宣称完整认证的覆盖报告

最后这一点比听起来更重要。在无障碍验证这个领域,绿色对勾一旦被复制进采购表格,就再也不会有人质疑。一个悄悄跳过标准一半、却照样打印“pass”的验证器,比没有验证器更糟,因为它把未知变成了伪已知。PDFlibPas 的立场正好相反:机械能查的就查,没查的大声说出来,并把每一次“未能跑完”都当作一条发现,而不是当作成功

为什么不能用 PDF/UA-1 规则验证 PDF/UA-2

PDF/UA-2 不是把编号加一的 PDF/UA-1。PDF/UA-1 站在 PDF 1.7 和一张扁平角色映射表之上;PDF/UA-2 则站在 PDF 2.0(ISO 32000-2)之上,在这里每个结构元素都可以属于某个命名空间,而每个命名空间自带自己的角色映射表。拿 UA-1 检查器去查 UA-2 文件,既会漏掉真问题,也会捏造假问题

假问题才是尴尬的部分。PDF/UA-1 验证器通常会强制两条规则:Note 元素必须带 ID,以及不带编号的 H 标题该怎么用。这两条规则都搬不过去。在 PDF 2.0 结构命名空间里,脚注用 FENote 表达,标题从 H1 一直到 Hn,没有六层上限。PDF/UA-2 更进一步,干脆禁止不带编号的 H 角色。把 UA-1 的标题逻辑套到这棵树上,你要么抱怨标准允许的东西,要么放过标准唯一禁止的东西

所以 PDFlibPas 的 PDF/UA-2 检查配置走的是一条独立代码路径,而不是老路径上的一个开关。它也拒绝复用 UA-1 的条款编号。与 UA-1 检查器共用的诊断项,在 UA-2 报告里会重新绑定到 ISO 14289-2;凡是条款编号尚未对照标准文本核实过的地方,报告就把条款字段留空,而不是猜一个。空的条款不如一个笃定的条款编号看着舒服,但在没人核实过映射关系的情况下,这是唯一站得住脚的输出

PDFlibPas 如何通过 RoleMapNS 解析角色

PDFlibPas 把每个结构角色解析到已知命名空间里的某个标准角色,跨命名空间沿着 /RoleMapNS 链追踪,并把任何终止于自定义角色、形成循环或违反映射规则的链报告出来。这是 PDF/UA-2 结构验证的核心,因为辅助技术只认标准角色;自定义的 /InvoiceLine 在落到 P 或 TR 这类角色之前毫无意义

解析器从元素的 /S 名称和它的 /NS 命名空间出发。没有命名空间时,元素隐式属于 PDF 1.7 标准命名空间,适用的是结构树根里的老式 /RoleMap;有命名空间时,适用的是命名空间字典自己的 /RoleMapNS,而且映射可以通过一个双元素数组指向另一个命名空间里的角色。由此得到的检查项都很具体:

  • 结构元素引用的命名空间必须是一个间接字典,带有效的 /NS URI,并且必须列在结构树根的 /Namespaces 数组里(诊断 10054)
  • 自定义角色最终必须解析到 PDF 1.7 命名空间、PDF 2.0 命名空间(http://iso.org/pdf2/ssn)或 MathML 命名空间里的某个标准角色;循环链和断头链报告为 10055
  • 标准角色不允许被重映射成自定义角色,角色也不允许在自己的显式命名空间内部被重映射
  • 结构根必须恰好包含一个 PDF 2.0 命名空间里的 Document 元素(10057),并且每个元素的 /P 必须指向它实际的父元素(10058)
  • 不带编号的 H,或者没有映射到 PDF 2.0 角色的老式 PDF 1.7 Note,报告为 10056

有一个细节值得抄进你自己的工具链:PDFlibPas 验证的是每一条声明过的映射,而不只是从树上可达的那几条。角色映射表里一条当前没有任何元素在用的坏条目,仍然是潜在缺陷,因为下一次内容更新就会开始用它。MathML 的处理也很诚实:MathML 命名空间被承认为有效的解析目标,但 MathML 词汇表本身不做验证,覆盖报告里也照实这么说

在 Delphi 里运行一次 PDF/UA-2 检查

TPDFlib.CheckFilePDFUACompliance 是单次调用的入口:传入文件、密码、target part 和选项,拿回来一个装着编码问题条目的字符串列表句柄。句柄为零且 LastErrorCode 等于零,表示已实现的检查没有发现任何问题;LastErrorCode 非零则表示调用本身失败了

var
  Lib: TPDFlib;
  Issues, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // TargetPart:0 = 从 XMP pdfuaid:part 探测,1 = PDF/UA-1,2 = PDF/UA-2
    // Options:1 = 遇到第一个失败即停止,0 = 收集全部问题
    Issues := Lib.CheckFilePDFUACompliance('report.pdf', '', 2, 0);
    if Issues = 0 then
    begin
      if Lib.LastErrorCode <> 0 then
        Writeln('Validation did not run, error ', Lib.LastErrorCode)
      else
        Writeln('No issues from the implemented PDF/UA-2 checks');
    end
    else
    try
      for I := 1 to Lib.GetStringListCount(Issues) do  // 条目下标从 1 开始
        Writeln(Lib.GetStringListItem(Issues, I));
    finally
      Lib.ReleaseStringList(Issues);
    end;
  finally
    Lib.Free;
  end;
end;

target part 要刻意去选。传 0 会从 XMP 元数据里读 pdfuaid:part 并据此挑选检查配置,这对通用收件箱验证器是合理的;对你们自己的输出管线,请显式传 2。如果你的生成器忘了写标识,自动探测就会退回 UA-1 规则,你验证的就是错误的标准;显式传 2 则会对缺失的 part 给出诊断 10050,若 pdfuaid:rev 不是 2024 给出 10051,若实际 PDF 版本不是 2.0 给出 10052。在生成端,TPDFlib.SetPDFUA2Mode 负责写入这份标识、把版本升到 2.0,并把 PDF 2.0 结构命名空间设为默认;命名空间模型本身在PDF 2.0、PDF/A-4 与 PDF/UA-2 命名空间走读一文里有完整讲解

验证器预算耗尽时会发生什么

PDFlibPas 选择 fail-closed:PDF/UA-2 检查器一旦撞上遍历上限、循环、解析器限额或打不开的文档,就会发出一条诊断,而没有诊断的结果永远不会被提升为通过。诊断 10059 正是预留给这种“读取器没能跑完”的情形

这些预算是明确写进覆盖 JSON 的:结构深度 256、结构对象 100000 个、角色映射链最多 256 跳。设这些数字,是因为结构树来自不可信输入。一个恶意或单纯损坏的文件可以把元素嵌套几千层深、让子元素指回祖先,或者构造一条串起几百个命名空间的角色映射链。没有预算的验证器要么崩溃要么挂死;有预算却没有诊断的验证器干的坏事更隐蔽:它提前停下,在已读的部分里没发现任何问题,然后报告成功

同一原则在上一层同样适用。如果底层读取器返回的分数低于 100 却没有给出任何问题行——解析在任何规则运行前就中断时会出现这种情况——PDFlibPas 会插入一条 10059,让空列表不会被误当成干净结果。检查过程中抛出的异常同样会变成一条 10059。这只是一点代码量,却堵住了验证管线欺骗主人最常见的口子

与 PDF/A 共享一次解析并生成报告

做批量处理时,validation pipeline API 只解析文件一次,然后基于共享的解析结果跑多项检查。RunPDFUAValidationPipelineTask 是这个家族里负责 PDF/UA 的成员,接受与单次调用函数相同的 target part 取值,于是 PDF/A 加 PDF/UA-2 的预检不必为一份大文档付两次完整读取的代价

var
  Pipe, PDFAIssues, UAIssues: Integer;
  Json: WideString;
begin
  Pipe := Lib.BeginValidationPipeline('archive.pdf', '');
  if Pipe = 0 then
    Exit;
  try
    PDFAIssues := Lib.RunValidationPipelineTask(Pipe, 1, 0);       // 1 = PDF/A
    UAIssues := Lib.RunPDFUAValidationPipelineTask(Pipe, 2, 0);    // PDF/UA-2
    // ... 按上一个示例的方式检查并释放这两个句柄 ...
    Writeln('Parses: ', Lib.GetValidationPipelineParseCount(Pipe));
    // ComplianceTests:1 = PDF/A,2 = PDF/UA,0 = 两者;ReportFormat 1 = JSON
    Json := Lib.CreateValidationPipelineReport(Pipe, 0, 0, 1);
  finally
    Lib.ReleaseValidationPipeline(Pipe);
  end;
end;

文件标识为 part 2 时,结构化报告会把无障碍部分标注为 PDF/UA-2 和 ISO 14289-2,且每个 PDF/UA 部分都带着 "fullCertification": false 和内嵌的覆盖对象。文本版预检报告会加一行说明:本次只运行了已实现的检查,完整的无障碍认证还需要补充验证和人工审阅。更完整的报告格式、规则 ID 和 CI 卡点模式,见PDF/A 与 PDF/UA 预检指南

覆盖报告到底保证了什么

TPDFlib.GetPDFUAReadValidationCoverageJSON 只保证一件事:精确列出 PDF/UA-2 检查器查了什么、没查什么。对 part 2,它返回固定为 false 的 fullCertification、一个 checked 数组、一个 unverified 数组,以及各项遍历上限。它不保证文件无障碍,而且它的设计让任何代码路径都无法让它改口

var
  Coverage: WideString;
begin
  Coverage := Lib.GetPDFUAReadValidationCoverageJSON(2);
  // 把它和验证结果存在一起,而不是拿它替代验证结果
  TFile.WriteAllText('report.coverage.json', Coverage, TEncoding.UTF8);
  if Pos('"fullCertification":false', Coverage) > 0 then
    Writeln('Machine checks only; schedule a human accessibility review');
end;

checked 列表覆盖标识信息(part、revision、prefix)、实际生效的 PDF 版本、标题元数据、/MarkInfo、DisplayDocTitle、命名空间引用、角色映射解析、文档根、结构父指针、树循环,以及 H 和 Note 规则。unverified 列表同样具体:内容标记与阅读顺序、结构包含关系、字体与 Unicode 映射、注释与表单、自然语言、替代描述、MathML 词汇表,以及人工无障碍审阅。其中好几项根本无法机械判定。任何解析器都无法告诉你,一段 alt text 是否真的描述了那张图表,阅读顺序对靠听的人来说是否通顺

把覆盖 JSON 当作结果的一部分。每次验证运行都归档它,等审计者问起某个日期的“通过”到底是什么意思,你就能交出精确的检查范围,而不是靠记忆加版本号去拼凑

与 veraPDF 交叉核对

PDF/UA-2 核心已用 veraPDF 1.30.3 命令行验证器的 ua2 配置做过交叉核对,在覆盖范围重叠的地方,两边结果一致。一个独立生成的有效核心样本在两个工具下都通过;一个声明 pdfuaid:rev 2023 的样本和一个使用不带编号 H 角色的样本在两个工具下都失败

第四个样本才最值得说。它包含完全没有标记的绘图内容,veraPDF 报了它而 PDFlibPas 核心检查没有报,因为内容标记不在当前覆盖范围内。这个缺口正是 content-tagging-and-reading-order 排在 unverified 列表第一位的原因。多跑一个验证器是便宜的保险,两个工具意见相左往往是摸清各自检查边界最快的办法。同一问题的生成端——如何一开始就构造能通过的树——见tagged PDF 结构树指南

FAQ

PDFlibPas 会把文件认证为符合 PDF/UA-2 吗

不会。PDFlibPas 运行针对 PDF/UA-2 已实现的机器检查,返回的覆盖报告里 fullCertification 恒为 false,并列出每一个仍需其他工具或人工审阅的领域

CheckFilePDFUACompliance 应该传什么 TargetPart 值

确定文件必须是 PDF/UA-2 时传 2,这样缺失标识会被报告出来。传 0 可从 XMP pdfuaid:part 自动探测 part,传 1 则强制使用 PDF/UA-1 规则

PDF/UA-2 报告里的诊断 10059 是什么意思

诊断 10059 表示验证器没能跑完:文件无法解析或打开、超出某项遍历上限、发现循环,或者发生了异常。它一律计为失败,绝不计为通过

PDF/UA-2 验证、共享 validation pipeline 和 PDF 2.0 生成 API 都包含在 losLab PDF Library for Delphi 里,随附的还有 PDF/A 预检与结构化验证报告