Artigo Técnico

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

Um dicionário Info guardando /ModDate (D:20260827093659+08'00') e um pacote XMP guardando xmp:ModifyDate como 2026-08-27T01:36:59Z descrevem o mesmo segundo, e um validador PDF/A tem toda razão para aceitar esse par. Copie a string Subject do documento para dc:subject, porém, e o pacote falha mesmo quando o texto bate caractere por caractere. A ISO 19005-1 §6.7.3 pede valores equivalentes, guardados em propriedades XMP específicas, em formas RDF específicas

O relatório de bug que costuma abrir esse assunto parece inofensivo. Um sistema de gestão documental carimba um novo /ModDate no dicionário Info a cada save incremental e deixa o pacote XMP quieto. Meses depois, uma auditoria de arquivo marca milhares de arquivos. As duas datas estão lá; elas pararam de concordar na primeira edição, e uma checagem que só olha a existência da propriedade nunca viu isso. Edições de Title feitas por uma API só de Info falham do mesmo jeito, e uma string Author como Finance; Controlling que alguma ferramenta separou em dois itens dc:creator também

O que a ISO 19005-1 exige do Info e do XMP

A Tabela 1 da ISO 19005-1 pareia oito chaves Info com propriedades XMP, e a §6.7.3 diz que, uma vez que uma chave Info está presente, a propriedade XMP mapeada precisa conter um valor equivalente. Uma chave ausente não exige nada. O PDF/A-2 e o PDF/A-3 mantêm o mesmo requisito de equivalência para as mesmas oito chaves. O PDF/A-4 vai no outro sentido e restringe o próprio dicionário Info: ele só pode aparecer junto de um /PieceInfo de catálogo e então não pode guardar nada além de ModDate, o que o TPdf.ValidatePdfA reporta por meio de pvaiPdfA4InfoWithoutPieceInfo e pvaiPdfA4InfoInvalid

No PDFium Component, a comparação vive na ComparePdfAInfoAndXmp em FPdfPdfa.pas. Ela busca cada propriedade 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 mapeadas 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, e 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

A linha do Subject é onde templates montados à mão mais vezes erram. O dc:subject do Dublin Core é uma bag não ordenada de palavras-chave, e o nome convida as pessoas a jogar o Subject do Info nele. A comparação procura dc:description, não encontra nada, e reporta o Subject como pamsXmpMissing enquanto o texto "certo" fica a uma propriedade de distância. Três linhas nas formas corretas ficam assim:

<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>

Por que uma string correta ainda falha na comparação?

Uma string correta falha quando está no contêiner errado ou difere por um único code point. Valores de texto são comparados como sequências exatas de code points Unicode, sem trim, sem fold de maiúsculas, sem normalização. Um espaço no fim é uma divergência. Um é pré-composto de um lado contra um e decomposto mais acento agudo combinante do outro também é, mesmo que os dois normalmente sejam renderizados de forma idêntica na tela

Erros de forma têm um estado próprio. Um dc:title escrito como texto simples, um dc:creator declarado como rdf:Bag, ou um Seq de dc:creator com dois itens produzem todos pamsXmpTypeMismatch em vez de pamsValueMismatch, então um relatório consegue distinguir "a ferramenta escolheu a estrutura RDF errada" de "a ferramenta escreveu texto diferente". Os dois problemas normalmente vêm de lugares diferentes em um pipeline, e consertar o errado desperdiça uma tarde

O lado Info vem sempre da decodificação própria do PDFium de strings PDFDocEncoding e UTF-16BE por meio do FPDF_GetMetaText, o mesmo caminho que o TPdf.GetMetadata usa, então uma string com byte-order mark ou uma sequência octal escapada é lida do jeito que o motor lê. O lado XMP é limpo tanto quanto os bytes que o produziram. Um pacote montado com a code page errada pode carregar mojibake que nenhuma comparação perdoa, e é por isso que as armadilhas de codepage que corrompem metadados XMP sob Free Pascal pertencem à mesma checklist

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 à meia-noite. Quando os dois valores carregam um fuso, cada um é convertido para UTC primeiro: 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. Inventar um offset para o valor sem fuso seria um chute, e um chute que por acaso bate com o fuso do servidor passaria em uma máquina e falharia em outra. Um segundo fracionário não zero como .250 no XMP também força divergência, porque uma data PDF não tem como expressá-lo; .000 é aceito. Valores que não analisam de forma nenhuma são reportados 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

As duas gramáticas de data não são intercambiáveis, e os parsers seguram cada lado na dele. No lado PDF, as aspas simples ao redor dos minutos do offset são opcionais e Z é aceito. No lado XMP, o offset precisa de dois pontos: 2026-08-27T09:36:59+08:00 analisa, enquanto +0800 é pamsInvalidXmpDate. A hora XMP também precisa de pelo menos horas e minutos quando um T aparece, e offsets acima de 14 horas são rejeitados dos dois lados. Espaço em branco ao redor é aparado antes de a data ser analisada, uma cortesia que os campos de texto não recebem

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. O /Title () está presente, então o pacote precisa carregar também um título x-default vazio

Lendo o relatório por campo antes de tocar no arquivo

O TPdf.InspectPdfAMetadata retorna um TPdfAMetadataReport com um TPdfAMetadataComparison por campo. Cada entrada guarda o valor Info, o valor XMP e um TPdfAMetadataState, então uma falha pode ser explicada campo por campo. O MismatchFields coleta o conjunto que falha, o HasXmpPacket diz se um pacote foi encontrado, e o XmpParseError carrega a mensagem do parser quando um pacote existe mas não pode ser lido. Nesse último caso, todo campo presente reporta pamsXmpMissing, porque nada pôde ser lido do pacote

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 TPdf.ValidatePdfA incorpora a mesma comparação ao conjunto de problemas dele. A varredura de nível de byte, descrita em validação preflight PDF/A com PDFium Component, reporta pvaiMissingXmpDates quando o pacote não tem as duas propriedades de data e pvaiInfoXmpDateMismatch quando o Info carrega CreationDate ou ModDate mas o pacote não tem a propriedade de data correspondente. Por cima disso, o ValidatePdfA roda a comparação completa de valores e adiciona pvaiInfoXmpValueMismatch sempre que um pacote existe mas discorda do Info, incluindo um pacote ilegível. Um documento sem pacote nenhum é reportado apenas como pvaiMissingXmpMetadata, então um defeito nunca aparece duas vezes. A função em nível de stream ValidatePdfACompliance executa apenas a varredura de nível de byte

function MetadataGateOk(Pdf: TPdf): Boolean;
var
  Res: TPdfAValidationResult;
begin
  Res := Pdf.ValidatePdfA;
  if pvaiMissingXmpMetadata in Res.Issues then
    Writeln('No usable XMP packet: rebuild the file with SaveAsPdfA');
  if pvaiInfoXmpDateMismatch in Res.Issues then
    Writeln('Info has a date property the XMP packet lacks entirely');
  if pvaiInfoXmpValueMismatch in Res.Issues then
    Writeln('Mapped values disagree: run InspectPdfAMetadata for details');
  Result := Res.IsCompliant;
end;

O que o NormalizePdfAMetadata reescreve, e onde ele para

O TPdf.NormalizePdfAMetadata trata o dicionário Info como a fonte da verdade e reescreve apenas as propriedades XMP cujo campo caiu em MismatchFields. Title e Subject vão para o item x-default enquanto outras alternativas de idioma ficam intactas. Author vira um rdf:Seq de um item. Uma data Info com fuso é gravada como data XMP UTC canônica com sufixo Z, e uma sem fuso mantém os componentes locais. Namespaces desconhecidos, propriedades não relacionadas e propriedades XMP de chaves Info ausentes ficam intocados, e uma ligação de prefixo existente é reutilizada quando uma propriedade é gravada de volta

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

Tratar o Info como autoridade tem um custo que aparece em arquivos reais. Um Seq de dc:creator listando "Finance" e "Controlling" como duas pessoas é substituído por um item lendo Finance; Controlling, e um dc:title guardado no contêiner errado é reconstruído como um Alt novo guardando só x-default, então o que quer que o contêiner antigo guardasse se foi. Essa perda é aceitável aqui. A Tabela 1 permite exatamente um item de creator, a string Info já guarda a lista completa em uma peça, e o pacote original continua no arquivo-fonte se alguém precisar da lista mais rica

As recusas são deliberadas. Sem pacote XMP, o método dispara EPdfError: 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. Um pacote ilegível ou uma data Info malformada dispara EPdfXmpError antes de qualquer coisa ser gravada, porque uma data errada de aparência plausível no XMP é pior que nenhuma saída. Documentos assinados disparam EPdfError a menos que o chamador passe AllowSignedDocument = True; a saída é um save completo com os novos metadados anexados como uma atualização incremental, então os bytes assinados não são carregados adiante sem mudança

Esse caminho de save tem mais uma consequência. O NormalizePdfAMetadata grava um arquivo novo mesmo quando todo campo já bate, porque sincronizar nada ainda termina em um save. Chame o InspectPdfAMetadata primeiro e pule arquivos equivalentes; um job noturno que reescreve milhares de arquivos inalterados não ganha nada além de timestamps novos. A sobrecarga de arquivo grava através de um arquivo temporário e substitui o alvo atomicamente. O arquivo-fonte fica aberto enquanto o documento estiver carregado, então dê à saída um caminho diferente

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]);
      on E: EPdfError do         // entrada assinada sem AllowSignedDocument
        raise Exception.CreateFmt('Skipped %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

Equivalência é uma cláusula da ISO 19005-1. Um arquivo normalizado ainda precisa passar em fontes, cor, transparência e todas as outras checagens PDF/A, então o ValidatePdfA continua sendo o portão antes de um documento partir para armazenamento de longo prazo

Comparando um pacote sem documento carregado

A ComparePdfAInfoAndXmp e a SynchronizePdfAInfoToXmp são funções simples em FPdfPdfa que trabalham sobre um TPdfXmpPacket sem PDF aberto, o que serve para testes de unidade e pipelines que preenchem o XMP a partir de um template antes de o documento existir. A SynchronizePdfAInfoToXmp retorna o conjunto de campos que ela reescreveu e dispara EPdfXmpError quando recebe um pacote nil

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. Os campos de string do record carregam valores; só o Present decide quais deles o padrão realmente exige

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);
    // um xmp:ModifyDate divergente vira 2026-08-27T01:36:59Z,
    // um dc:creator divergente vira um rdf:Seq de um item
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

Rodar o mesmo template pela ComparePdfAInfoAndXmp depois deve retornar um MismatchFields vazio, o que faz uma asserção barata para uma etapa de build que regenera templates

Arquivos que outros sistemas seguem editando desviam um campo por vez, e o relatório por campo mostra qual se moveu. O InspectPdfAMetadata, o NormalizePdfAMetadata e o resto do ferramental de validação e conversão PDF/A fazem parte do PDFium Component para Delphi e C++Builder