Artigo Técnico

Equivalência de metadados Info e XMP em PDF/A no Delphi

O PDFium Component checa a equivalência de metadados Info-XMP em PDF/A com o TPdf.InspectPdfAMetadata e a repara com o TPdf.NormalizePdfAMetadata. A ISO 19005-1 (conforme corrigida pela Cor.1) exige que cada uma das oito entradas Info mapeadas, de Title a ModDate, carregue o mesmo valor que sua propriedade XMP, não que meramente exista; a checagem lê a forma RDF correta, casa namespaces por URI e compara datas como instantes

O relatório de bug que costuma abrir essa conversa parece inofensivo. Um sistema de gestão documental carimba um novo /ModDate no dicionário Info a cada save incremental, deixa o pacote XMP quieto, e seis meses depois uma auditoria de arquivo marca milhares de arquivos como não conformes. As duas datas estão lá. Elas simplesmente pararam de concordar na primeira edição, e uma checagem de presença nunca notou. Edições de Title feitas por uma API só de Info, e uma string Author como Finance; Controlling que alguma ferramenta separou em dois itens dc:creator, falham do mesmo jeito

Por que o PDF/A rejeita metadados que existem nos dois lugares?

O PDF/A rejeita porque a ISO 19005-1 §6.7.3 é uma regra de valor, não de presença: a Tabela 1 mapeia oito chaves Info para propriedades XMP, e uma vez que uma chave Info está presente, a propriedade XMP mapeada precisa conter um valor equivalente. O scanner de nível de byte descrito em validação preflight PDF/A com PDFium Component só confirma que xmp:CreateDate e xmp:ModifyDate existem (pvaiMissingXmpDates). Desde a v3.72.0, o TPdf.ValidatePdfA roda adicionalmente a comparação completa de valores e adiciona pvaiInfoXmpValueMismatch ao conjunto de problemas quando um pacote XMP existe mas discorda do Info (um pacote que não pode ser analisado conta como discordando). Um pacote ausente continua reportado como pvaiMissingXmpMetadata, então os dois problemas nunca contam em dobro o mesmo defeito

Que forma RDF cada propriedade XMP mapeada precisa?

Cada um dos oito mapeamentos tem um tipo XMP fixo, e um valor correto no contêiner errado mesmo assim falha. A ComparePdfAInfoAndXmp em FPdfPdfa.pas busca propriedades por URI de namespace, então um pacote que liga http://purl.org/dc/elements/1.1/ a um prefixo incomum é lido exatamente como um que usa dc. As formas exigidas são:

  • Title → dc:title e Subject → dc:description: uma alternativa de idioma rdf:Alt, comparada apenas contra o item x-default dela (a tag de idioma é casada sem diferenciar maiúsculas); um Alt sem x-default conta como ausente
  • Author → dc:creator: um rdf:Seq com exatamente um item de texto guardando a string Info inteira, então uma lista de autores separada por ponto e vírgula continua uma única entrada
  • Keywords → pdf:Keywords e Producer → pdf:Producer (namespace http://ns.adobe.com/pdf/1.3/): propriedades de texto simples
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (namespace http://ns.adobe.com/xap/1.0/): propriedades de texto simples
Os oito pares mapeados que a ComparePdfAInfoAndXmp checa para equivalência de metadados PDF/A no Delphi: Title e Subject precisam de um rdf:Alt com um item x-default, Author de um rdf:Seq de um item, Keywords, Producer, Creator e as duas datas são texto simples, cada um buscado pela URI de namespace XMP em vez de prefixo
Uma vez que uma chave Info está presente, a ISO 19005-1 exige que a propriedade XMP mapeada contenha um valor equivalente na forma RDF exigida, então um valor no contêiner errado mesmo assim falha
<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>

Valores de texto são comparados como sequências exatas de code points Unicode, sem trim, fold de maiúsculas ou normalização. Um espaço no fim, ou um é pré-composto de um lado e um e decomposto mais acento combinante do outro, é uma divergência genuína. O lado Info sempre vem da decodificação própria do PDFium de texto PDFDocEncoding e UTF-16 por meio do FPDF_GetMetaText, o que evita que a biblioteca reimplemente decodificação de strings e erre sutilmente; o lado XMP é limpo tanto quanto os bytes que o produziram, e é por isso que as armadilhas de codepage que corrompem metadados XMP sob Free Pascal importam aqui também

Quando uma data PDF e uma data XMP são iguais?

Uma data PDF e uma data XMP são iguais quando descrevem o mesmo instante até o segundo, com o mesmo conhecimento de fuso dos dois lados. Os dois parsers aceitam precisão reduzida legal, então D:2026 e 2026 significam ambos 1º de janeiro de 2026, 00:00:00. Quando os dois valores carregam um fuso, eles são convertidos para UTC antes da comparação: D:20260827093659+08'00' equivale a 2026-08-27T01:36:59Z. Quando nenhum carrega fuso, os componentes locais são comparados como escritos. Quando só um lado tem fuso, o resultado é pamsValueMismatch, porque inventar um offset seria um chute. Um segundo fracionário não zero como .250 no XMP também força divergência, já que uma data PDF não tem como expressá-lo e arredondá-lo para fora em silêncio esconderia uma discordância real; .000 é aceito. Valores não analisáveis são reportados separadamente como pamsInvalidInfoDate ou pamsInvalidXmpDate

Como o PDFium Component decide que uma data Info de PDF e uma data XMP são iguais no Delphi: dois fusos convertem para UTC e comparam instantes, dois valores sem fuso comparam-se como escritos, um fuso sozinho é um pamsValueMismatch, um segundo fracionário não zero não pode ser expresso, e valores não analisáveis são reportados separadamente
Igualdade significa o mesmo instante até o segundo com o mesmo conhecimento de fuso dos dois lados, então inventar um offset ou arredondar para fora um segundo fracionário esconderia uma discordância real

Presença tem regra própria. O TPdfAMetadataValues.Present é um set preenchido percorrendo o dicionário /Info do trailer ativo, e ele mantém "chave ausente" separado de "chave presente com string vazia". Uma chave ausente rende pamsNotRequired e não exige nada do XMP; /Title () está presente, então o pacote XMP precisa carregar também um título x-default vazio

Como inspecionar metadados Info e XMP antes de salvar?

O TPdf.InspectPdfAMetadata devolve um TPdfAMetadataReport com um TPdfAMetadataComparison por campo, cada um guardando o valor Info, o valor XMP e um TPdfAMetadataState, então uma falha pode ser explicada sem fazer engenharia reversa de uma flag única de validação. O MismatchFields resume o conjunto que falha, o HasXmpPacket diz se um pacote foi encontrado, e o XmpParseError carrega a mensagem do parser quando o pacote existe mas não pode ser lido

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;

O que o NormalizePdfAMetadata muda, e o que ele recusa?

O TPdf.NormalizePdfAMetadata trata o dicionário Info como a fonte da verdade e reescreve apenas as propriedades XMP cujo campo caiu em MismatchFields; todo o resto do pacote sobrevive. Title e Subject são gravados no item x-default enquanto outras alternativas de idioma ficam intactas, Author vira um rdf:Seq de um item, namespaces desconhecidos e propriedades não relacionadas são preservados, e propriedades XMP de chaves Info ausentes não são tocadas. Uma data Info com fuso é gravada como data XMP UTC canônica com sufixo Z; uma sem fuso mantém os componentes locais dela. A sobrecarga de arquivo salva através de um arquivo temporário e uma substituição atômica, e a atualização XMP em si é anexada como uma atualização incremental

O que o NormalizePdfAMetadata reescreve ao reparar metadados PDF/A no Delphi com PDFium Component: o Info é a fonte da verdade, apenas as entradas MismatchFields são gravadas de volta como texto Alt x-default, um Seq de um item ou uma data UTC canônica, enquanto namespaces desconhecidos, propriedades não relacionadas e propriedades de chaves ausentes sobrevivem intocadas
O reparo recusa um pacote XMP ausente, uma data Info malformada e documentos assinados, porque montar um conjunto completo de metadados PDF/A é trabalho do SaveAsPdfA, não de um conserto pontual de equivalência

As recusas são deliberadas. Sem pacote XMP o método dispara EPdfError, porque montar um conjunto completo de identificação e metadados PDF/A é trabalho do SaveAsPdfA, coberto em criar arquivos de arquivamento PDF/A com PDFium Component. Uma data Info malformada dispara EPdfXmpError em vez de gravar um valor errado de aparência plausível, e nada é salvo. Documentos assinados são rejeitados a menos que quem chame passe AllowSignedDocument = True. Equivalência é uma regra da ISO 19005-1 também, então um arquivo normalizado não é automaticamente um conforme

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;                      // já consistente, deixa o arquivo em paz
    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      // data Info malformada ou pacote ilegível
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

Rodando a comparação no seu próprio pacote XMP

A ComparePdfAInfoAndXmp e a SynchronizePdfAInfoToXmp são funções simples em FPdfPdfa que trabalham num TPdfXmpPacket sem documento carregado, o que serve para testes de unidade e pipelines que montam XMP a partir de um template. A única armadilha é o Present: um record inicializado com Default(TPdfAMetadataValues) tem um set vazio, todo campo então reporta pamsNotRequired, e a comparação passa de graça não importa quais valores você tenha preenchido

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 decide quais campos são obrigatórios; só valores são ignorados
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

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

Se o seu pipeline arquiva documentos que outros sistemas seguem editando, pareie uma varredura noturna de InspectPdfAMetadata com o NormalizePdfAMetadata para os arquivos que desviarem, e mantenha o ValidatePdfA como o gate antes que qualquer coisa saia para armazenamento de longo prazo. O relatório tipado, o caminho de reparo e o resto do ferramental PDF/A vêm no PDFium Component para Delphi e C++Builder