技术文章

Delphi 里的 PDF/A Info 与 XMP 元数据等价性

PDFium Component 用 TPdf.InspectPdfAMetadata 检查 PDF/A 的 Info 与 XMP 元数据等价性,用 TPdf.NormalizePdfAMetadata 修复它。ISO 19005-1(含 Cor.1 勘误)要求八个映射 Info 条目——从 Title 到 ModDate——每一个都与对应 XMP 属性持有相同的值,而不只是存在;这个检查会读正确的 RDF 形状、按 URI 匹配命名空间、把日期当时间点来比

通常引出这个话题的缺陷报告看着人畜无害。一个文档管理系统每次增量保存都往 Info 字典里盖一个新的 /ModDate,XMP packet 碰都不碰,半年后档案审计把几千份文件标成不合格。两个日期都在。只是从第一次编辑起它们就不一致了,而存在性检查从没发现。通过 Info 专属 API 改标题,还有像 Finance; Controlling 这样被某些工具拆成两个 dc:creator 条目的 Author 字符串,都以同样方式翻车

为什么 PDF/A 拒绝两处都存在的元数据?

PDF/A 拒它是因为 ISO 19005-1 §6.7.3 是一条值规则,不是存在性规则:表 1 把八个 Info 键映射到 XMP 属性,而 Info 键一旦存在,映射的 XMP 属性就必须持有等价值。PDFium Component 的 PDF/A preflight 校验里讲的字节级扫描器只确认 xmp:CreateDate 和 xmp:ModifyDate 存在(pvaiMissingXmpDates)。从 v3.72.0 起,TPdf.ValidatePdfA 额外跑完整的值比较,在 XMP packet 存在但与 Info 不一致时把 pvaiInfoXmpValueMismatch 加进问题集(解析不了的 packet 也算不一致)。缺 packet 仍然报 pvaiMissingXmpMetadata,所以两个问题不会把同一个缺陷算两遍

每个映射的 XMP 属性需要什么 RDF 形状?

八个映射各有一个固定的 XMP 类型,值再对、容器错了照样失败。FPdfPdfa.pas 里的 ComparePdfAInfoAndXmp 按命名空间 URI 查属性,所以把 http://purl.org/dc/elements/1.1/ 绑到一个冷门前缀上的 packet,读起来与用 dc 的完全一样。要求的形状是:

  • Title → dc:title 和 Subject → dc:description:rdf:Alt 语言备选,只与它的 x-default 条目比较(语言标签不区分大小写匹配);没有 x-default 的 Alt 算缺失
  • Author → dc:creator:rdf:Seq,恰好一个文本条目装下整个 Info 字符串,分号分隔的作者列表保持单一条目
  • Keywords → pdf:Keywords 和 Producer → pdf:Producer(命名空间 http://ns.adobe.com/pdf/1.3/):简单文本属性
  • Creator → xmp:CreatorTool、CreationDate → xmp:CreateDate、ModDate → xmp:ModifyDate(命名空间 http://ns.adobe.com/xap/1.0/):简单文本属性
ComparePdfAInfoAndXmp 在 Delphi 里为 PDF/A 元数据等价性检查的八个映射对示意图:Title 和 Subject 需要带 x-default 条目的 rdf:Alt,Author 要单条目的 rdf:Seq,Keywords、Producer、Creator 和两个日期是简单文本,都按 XMP 命名空间 URI 而不是前缀查找
Info 键一旦存在,ISO 19005-1 就要求映射的 XMP 属性以要求的 RDF 形状持有等价值,所以容器错了值再对也失败
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report 2026</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>Finance; Controlling</rdf:li></rdf:Seq></dc:creator>
<pdf:Producer>PDFium Component</pdf:Producer>

文本值按精确的 Unicode 码点序列比较,不去空白、不折大小写、不做归一化。一个尾部空格,或者一边是预组合的 é、另一边是分解的 e 加组合重音符,都是真不匹配。Info 一侧始终来自 PDFium 自己通过 FPDF_GetMetaText 对 PDFDocEncoding 和 UTF-16 文本的解码,这让库不必重新实现字符串解码、也不会错得微妙;XMP 一侧的干净程度取决于产出它的那些字节,这也是Free Pascal 下损坏 XMP 元数据的 codepage 陷阱在这里同样要命的原因

PDF 日期和 XMP 日期什么时候算相等?

PDF 日期和 XMP 日期在描述同一时间点、精确到秒、且两边时区知识一致时相等。两个解析器都接受合法的降精度写法,所以 D:2026 和 2026 都表示 2026 年 1 月 1 日 00:00:00。两个值都带时区时,先转成 UTC 再比较:D:20260827093659+08'00' 等于 2026-08-27T01:36:59Z。两边都不带时区时,按原样比较本地分量。只有一边带时区时,结果是 pamsValueMismatch——编造一个偏移就是瞎猜。XMP 里像 .250 这样的非零小数秒也会强制不匹配,因为 PDF 日期表达不了它,悄悄舍掉会把真实的不一致藏起来;.000 可以接受。解析不了的值分别报 pamsInvalidInfoDate 或 pamsInvalidXmpDate

PDFium Component 在 Delphi 里如何判定 PDF Info 日期与 XMP 日期相等示意图:两个时区转 UTC 比时间点,两个无时区值按原样比较,单边时区是 pamsValueMismatch,非零小数秒无法表达,解析不了的值单独报告
相等指精确到秒的同一时间点、两边时区知识一致,所以编造偏移或舍掉小数秒都会把真实的不一致藏起来

存在性有自己的规则。TPdfAMetadataValues.Present 是一个集合,靠遍历活动 trailer 的 /Info 字典填充,它把「键缺席」与「键存在但为空字符串」分开。缺席的键给出 pamsNotRequired,对 XMP 没有任何要求;/Title () 是存在的,所以 XMP packet 也必须带一个空的 x-default 标题

保存之前怎么检查 Info 和 XMP 元数据?

TPdf.InspectPdfAMetadata 返回一个 TPdfAMetadataReport,每个字段一条 TPdfAMetadataComparison,各带 Info 值、XMP 值和一个 TPdfAMetadataState,所以失败可以被解释清楚,不用去逆向某个校验标志。MismatchFields 汇总失败集合,HasXmpPacket 告诉你有没有找到 packet,XmpParseError 在 packet 存在但读不了时携带解析器消息

uses
  System.SysUtils, PDFium, FPdfPdfa;

const
  FieldNames: array[TPdfAMetadataField] of string = (
    'Title', 'Author', 'Subject', 'Keywords',
    'Creator', 'Producer', 'CreationDate', 'ModDate');
  StateNames: array[TPdfAMetadataState] of string = (
    'not required', 'equivalent', 'XMP missing', 'XMP type mismatch',
    'value mismatch', 'invalid Info date', 'invalid XMP date');

procedure ReportMetadata(Pdf: TPdf);
var
  Report: TPdfAMetadataReport;
  Item: TPdfAMetadataComparison;
begin
  Report := Pdf.InspectPdfAMetadata;
  if Report.XmpParseError <> '' then
    Writeln('XMP packet unreadable: ', Report.XmpParseError)
  else if not Report.HasXmpPacket then
    Writeln('No XMP packet at all');
  for Item in Report.Comparisons do
    if not Item.IsEquivalent then
      Writeln(Format('%-12s %-18s Info="%s" XMP="%s"',
        [FieldNames[Item.Field], StateNames[Item.State],
         Item.InfoValue, Item.XmpValue]));
end;

NormalizePdfAMetadata 改什么,又拒绝什么?

TPdf.NormalizePdfAMetadata 把 Info 字典当作事实来源,只重写落入 MismatchFields 的那些字段的 XMP 属性;packet 里其他东西都活下来。Title 和 Subject 写进 x-default 条目,其他语言备选原样保留;Author 变成单条目 rdf:Seq;未知命名空间和不相关属性被保留;缺席 Info 键的 XMP 属性原样不动。带时区的 Info 日期写成带 Z 后缀的规范 UTC XMP 日期;无时区的保持本地分量。文件重载通过临时文件加原子替换保存,XMP 更新本身作为增量更新追加

PDFium Component 在 Delphi 里修复 PDF/A 元数据时 NormalizePdfAMetadata 重写什么示意图:Info 是事实来源,只有 MismatchFields 条目被写回成 x-default Alt 文本、单条目 Seq 或规范 UTC 日期,未知命名空间、不相关属性和缺席键属性原样保留
修复拒绝缺失的 XMP packet、畸形的 Info 日期和签名文档,因为构建完整的 PDF/A 元数据集是 SaveAsPdfA 的活,不是定点等价修复的活

这些拒绝是刻意的。没有 XMP packet 时方法抛 EPdfError,因为构建完整的 PDF/A 标识与元数据集是 SaveAsPdfA 的活,见用 PDFium Component 创建 PDF/A 归档文件。畸形的 Info 日期抛 EPdfXmpError 而不是写一个看起来挺像样的错值,什么都不保存。签名文档被拒,除非调用方传 AllowSignedDocument = True。等价性也只是 ISO 19005-1 的规则之一,所以归一化过的文件不自动就是合格文件

uses
  System.SysUtils, PDFium, FPdfPdfa, FPdfXmp;

procedure NormalizeArchive(const Source, Target: string);
var
  Pdf: TPdf;
  Report: TPdfAMetadataReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    Report := Pdf.InspectPdfAMetadata;
    if Report.IsEquivalent then
      Exit;                      // 已一致,别动这个文件
    if not Report.HasXmpPacket then
      raise Exception.Create('No XMP packet: convert with SaveAsPdfA instead');
    try
      if not Pdf.NormalizePdfAMetadata(Target) then
        raise Exception.Create('Normalized save failed');
    except
      on E: EPdfXmpError do      // 畸形 Info 日期或读不了的 packet
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

在自己的 XMP packet 上跑比较

ComparePdfAInfoAndXmp 和 SynchronizePdfAInfoToXmp 是 FPdfPdfa 里的普通函数,直接在 TPdfXmpPacket 上工作、不需要加载文档,适合单元测试和从模板装配 XMP 的流水线。唯一的坑是 Present:用 Default(TPdfAMetadataValues) 初始化的记录集合为空,每个字段都报 pamsNotRequired,比较无论你填了什么值都空转通过

uses
  System.SysUtils, System.IOUtils, FPdfPdfa, FPdfXmp;

procedure AlignTemplate(const TemplateFile: string);
var
  Info: TPdfAMetadataValues;
  Packet: TPdfXmpPacket;
  Changed: TPdfAMetadataFields;
begin
  Info := Default(TPdfAMetadataValues);
  Info.Title := 'Quarterly Report 2026';
  Info.Author := 'Finance; Controlling';
  Info.ModDate := 'D:20260827093659+08''00''';
  // Present 决定哪些字段是必填的;只有值会被无视
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

  Packet := TPdfXmpPacket.Parse(TFile.ReadAllText(TemplateFile, TEncoding.UTF8));
  try
    Changed := SynchronizePdfAInfoToXmp(Info, Packet);
    // xmp:ModifyDate 现在是 2026-08-27T01:36:59Z,dc:creator 是单条目 rdf:Seq
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

如果你的流水线归档的文档会被其他系统持续编辑,就用每晚一次的 InspectPdfAMetadata 扫描搭配 NormalizePdfAMetadata 修理漂移的文件,并把 ValidatePdfA 留作任何东西进长期存储之前的闸门。强类型报告、修复路径和其余 PDF/A 工具都随 PDFium Component for Delphi and C++Builder 发布