Artículo técnico

Equivalencia de metadatos Info y XMP en PDF/A con Delphi

PDFium Component comprueba la equivalencia de metadatos Info-XMP de PDF/A con TPdf.InspectPdfAMetadata y la repara con TPdf.NormalizePdfAMetadata. ISO 19005-1 (con la corrección Cor.1) exige que cada una de las ocho entradas Info mapeadas, de Title a ModDate, lleve el mismo valor que su propiedad XMP, no que meramente exista; la comprobación lee la forma RDF correcta, empareja namespaces por URI y compara fechas como instantes

El bug report que suele abrir esta conversación parece inofensivo. Un sistema de gestión documental estampa un nuevo /ModDate en el diccionario Info en cada guardado incremental, deja el packet XMP en paz, y seis meses después una auditoría de archivo marca miles de archivos como no conformes. Las dos fechas están ahí. Simplemente dejaron de coincidir en la primera edición, y una comprobación de presencia jamás se enteró. Ediciones de Title hechas a través de una API solo Info, y un string Author como Finance; Controlling que alguna herramienta partió en dos ítems dc:creator, fallan igual

¿Por qué PDF/A rechaza metadatos que existen en ambos sitios?

PDF/A lo rechaza porque ISO 19005-1 §6.7.3 es una regla de valor, no de presencia: la Tabla 1 mapea ocho claves Info a propiedades XMP, y en cuanto una clave Info está presente, la propiedad XMP mapeada debe albergar un valor equivalente. El escáner a nivel de bytes que describe la validación preflight PDF/A con PDFium Component solo confirma que xmp:CreateDate y xmp:ModifyDate existen (pvaiMissingXmpDates). Desde la v3.72.0, TPdf.ValidatePdfA ejecuta además la comparación completa de valores y añade pvaiInfoXmpValueMismatch al conjunto de incidencias cuando existe un packet XMP que discrepa de Info (un packet que no se puede parsear cuenta como discrepancia). Un packet ausente sigue reportándose como pvaiMissingXmpMetadata, así que las dos incidencias jamás cuentan dos veces el mismo defecto

¿Qué forma RDF necesita cada propiedad XMP mapeada?

Cada uno de los ocho mapeos tiene un tipo XMP fijo, y un valor correcto en el contenedor equivocado también falla. ComparePdfAInfoAndXmp en FPdfPdfa.pas busca las propiedades por URI de namespace, así que un packet que ligue http://purl.org/dc/elements/1.1/ a un prefijo inusual se lee exactamente igual que uno que usa dc. Las formas requeridas son:

  • Title → dc:title y Subject → dc:description: una alternativa de lenguaje rdf:Alt, comparada solo contra su ítem x-default (la etiqueta de lenguaje se compara sin distinguir mayúsculas); un Alt sin x-default cuenta como ausente
  • Author → dc:creator: un rdf:Seq con exactamente un ítem de texto que contiene el string completo de Info, así que una lista de autores separada por punto y coma sigue siendo una única entrada
  • Keywords → pdf:Keywords y Producer → pdf:Producer (namespace http://ns.adobe.com/pdf/1.3/): propiedades de texto simple
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (namespace http://ns.adobe.com/xap/1.0/): propiedades de texto simple
Los ocho pares mapeados que ComparePdfAInfoAndXmp comprueba para la equivalencia de metadatos PDF/A en Delphi: Title y Subject necesitan un rdf:Alt con un ítem x-default, Author un rdf:Seq de un ítem, Keywords, Producer, Creator y las dos fechas son texto simple, cada uno buscado por URI de namespace XMP en vez de por prefijo
En cuanto una clave Info está presente, ISO 19005-1 exige que la propiedad XMP mapeada albergue un valor equivalente en la forma RDF requerida, así que un valor en el contenedor equivocado también falla
<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>

Los valores de texto se comparan como secuencias exactas de code points Unicode, sin recortes, plegado de mayúsculas ni normalización. Un espacio final, o una é precompuesta a un lado y una e descompuesta más acento combinante al otro, es un mismatch genuino. El lado Info viene siempre del decodificado propio de PDFium de texto PDFDocEncoding y UTF-16 a través de FPDF_GetMetaText, lo que evita que la biblioteca reimplemente el decodificado de strings y se equivoque de forma sutil; el lado XMP está tan limpio como los bytes que lo produjeron, por eso las trampas de codepage que corrompen metadatos XMP bajo Free Pascal también importan aquí

¿Cuándo son iguales una fecha PDF y una fecha XMP?

Una fecha PDF y una fecha XMP son iguales cuando describen el mismo instante al segundo, con el mismo conocimiento de zona horaria por ambos lados. Los dos parsers aceptan precisión reducida legal, así que D:2026 y 2026 significan ambos 1 de enero de 2026, 00:00:00. Cuando ambos valores llevan zona, se convierten a UTC antes de comparar: D:20260827093659+08'00' equivale a 2026-08-27T01:36:59Z. Cuando ninguno lleva zona, los componentes locales se comparan tal como están escritos. Cuando solo un lado tiene zona, el resultado es pamsValueMismatch, porque inventar un offset sería una apuesta. Un segundo fraccionario distinto de cero, como .250 en XMP, también fuerza un mismatch, porque una fecha PDF no tiene forma de expresarlo y redondearlo en silencio escondería una discrepancia real; .000 se acepta. Los valores no parseables se reportan por separado como pamsInvalidInfoDate o pamsInvalidXmpDate

Cómo decide PDFium Component en Delphi que una fecha Info de PDF y una fecha XMP son iguales: dos zonas se convierten a UTC y comparan instantes, dos valores sin zona se comparan tal como están escritos, una única zona es un pamsValueMismatch, un segundo fraccionario distinto de cero no se puede expresar, y los valores no parseables se reportan por separado
La igualdad significa el mismo instante al segundo con el mismo conocimiento de zona horaria por ambos lados, así que inventar un offset o redondear un segundo fraccionario escondería una discrepancia real

La presencia tiene su propia regla. TPdfAMetadataValues.Present es un set que se llena recorriendo el diccionario /Info del trailer activo, y mantiene «clave ausente» aparte de «clave presente con un string vacío». Una clave ausente produce pamsNotRequired y no exige nada al XMP; /Title () está presente, así que el packet XMP debe llevar también un título x-default vacío

¿Cómo inspeccionas los metadatos Info y XMP antes de guardar?

TPdf.InspectPdfAMetadata devuelve un TPdfAMetadataReport con un TPdfAMetadataComparison por campo, cada uno con el valor Info, el valor XMP y un TPdfAMetadataState, de modo que un fallo se puede explicar sin hacer ingeniería inversa de un flag de validación único. MismatchFields resume el conjunto que falla, HasXmpPacket te dice si se encontró un packet, y XmpParseError lleva el mensaje del parser cuando el packet existe pero no se puede leer

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;

¿Qué cambia NormalizePdfAMetadata y a qué se niega?

TPdf.NormalizePdfAMetadata trata el diccionario Info como la fuente de la verdad y reescribe solo las propiedades XMP cuyo campo cayó en MismatchFields; todo lo demás del packet sobrevive. Title y Subject se escriben en el ítem x-default mientras las otras alternativas de lenguaje quedan intactas, Author pasa a ser un rdf:Seq de un ítem, los namespaces desconocidos y las propiedades no relacionadas se conservan, y las propiedades XMP de claves Info ausentes se dejan tal cual. Una fecha Info con zona se escribe como fecha XMP canónica en UTC con sufijo Z; una sin zona conserva sus componentes locales. La sobrecarga de archivo guarda a través de un archivo temporal y un reemplazo atómico, y la actualización XMP en sí se añade como incremental update

Qué reescribe NormalizePdfAMetadata al reparar metadatos PDF/A en Delphi con PDFium Component: Info es la fuente de la verdad, solo las entradas de MismatchFields se escriben de vuelta como texto Alt x-default, un Seq de un ítem o una fecha UTC canónica, mientras que los namespaces desconocidos, las propiedades no relacionadas y las propiedades de claves ausentes sobreviven intactas
La reparación se niega a un packet XMP ausente, a una fecha Info malformada y a documentos firmados, porque construir un conjunto completo de metadatos PDF/A es trabajo de SaveAsPdfA, no de un fix de equivalencia quirúrgico

Las negativas son deliberadas. Sin packet XMP el método lanza EPdfError, porque construir una identificación PDF/A completa y un conjunto de metadatos es trabajo de SaveAsPdfA, cubierto en cómo crear archivos PDF/A de archivado con PDFium Component. Una fecha Info malformada lanza EPdfXmpError en vez de escribir un valor incorrecto con cara de verosímil, y no se guarda nada. Los documentos firmados se rechazan salvo que quien llame pase AllowSignedDocument = True. La equivalencia es además una regla más de ISO 19005-1, así que un archivo normalizado no es automáticamente uno 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;                      // ya es consistente, deja el archivo en 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      // fecha Info malformada o packet ilegible
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

Ejecutar la comparación sobre tu propio packet XMP

ComparePdfAInfoAndXmp y SynchronizePdfAInfoToXmp son funciones planas de FPdfPdfa que trabajan sobre un TPdfXmpPacket sin documento cargado, lo que les va a unit tests y a pipelines que montan el XMP desde una plantilla. La única trampa es Present: un record inicializado con Default(TPdfAMetadataValues) tiene un set vacío, todos los campos reportan entonces pamsNotRequired, y la comparación pasa de vacío pase lo que pase con los valores que hayas rellenado

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 qué campos son obligatorios; los valores por sí solos se ignoran
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

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

Si tu pipeline archiva documentos que otros sistemas siguen editando, combina un barrido nocturno de InspectPdfAMetadata con NormalizePdfAMetadata para los archivos que se desvíen, y mantén ValidatePdfA como la puerta antes de que nada salga hacia el almacenamiento a largo plazo. El report tipado, el camino de reparación y el resto de la herramienta PDF/A vienen en el PDFium Component para Delphi y C++Builder