Artículo técnico

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

Un diccionario Info con /ModDate (D:20260827093659+08'00') y un paquete XMP con xmp:ModifyDate como 2026-08-27T01:36:59Z describen el mismo segundo, y un validador PDF/A tiene todas las razones para aceptar ese par. Copiad el string Subject del documento a dc:subject, sin embargo, y el paquete falla incluso cuando el texto coincide carácter por carácter. La ISO 19005-1 §6.7.3 pide valores equivalentes, guardados en propiedades XMP concretas, en formas RDF concretas

El bug report que suele abrir este tema parece inofensivo. Un sistema de gestión documental estampa un /ModDate nuevo en el diccionario Info en cada guardado incremental y deja el paquete XMP tranquilo. Meses después una auditoría del archivo marca miles de ficheros. Las dos fechas están; dejaron de coincidir en la primera edición, y una comprobación que solo mira la existencia de la propiedad nunca lo vio. Las ediciones de título hechas por una API solo-Info fallan igual, y también falla un string Author como Finance; Controlling que alguna herramienta partió en dos ítems dc:creator

Qué exige la ISO 19005-1 a Info y XMP

La Tabla 1 de la ISO 19005-1 empareja ocho claves Info con propiedades XMP, y la §6.7.3 dice que en cuanto una clave Info está presente, la propiedad XMP mapeada debe llevar un valor equivalente. Una clave ausente no exige nada. PDF/A-2 y PDF/A-3 mantienen el mismo requisito de equivalencia para las mismas ocho claves. PDF/A-4 va por otro camino y restringe el propio diccionario Info: solo puede aparecer junto a un /PieceInfo de catálogo y entonces no guardar nada más que ModDate, algo que TPdf.ValidatePdfA informa mediante pvaiPdfA4InfoWithoutPieceInfo y pvaiPdfA4InfoInvalid

En PDFium Component la comparación vive en ComparePdfAInfoAndXmp en FPdfPdfa.pas. Busca cada propiedad por URI de namespace, así que un paquete que liga http://purl.org/dc/elements/1.1/ a un prefijo inusual se lee exactamente igual que uno que usa dc. Las formas mapeadas son:

  • Title → dc:title y Subject → dc:description: una alternativa de lengua rdf:Alt, comparada solo contra su ítem x-default; la etiqueta de lengua se empareja sin distinguir mayúsculas, y 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, de modo 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 y no por prefijo
En cuanto una clave Info está presente, la ISO 19005-1 exige que la propiedad XMP mapeada guarde un valor equivalente en la forma RDF requerida, así que un valor en el contenedor equivocado también falla

La fila de Subject es donde las plantillas hechas a mano se equivocan más a menudo. El dc:subject de Dublin Core es una bolsa sin orden de palabras clave, y el nombre invita a soltar en él el Subject de Info. La comparación busca dc:description, no encuentra nada e informa Subject como pamsXmpMissing mientras el texto "correcto" está a una propiedad de distancia. Tres líneas en las formas correctas se ven así:

<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 qué un string correcto sigue fallando la comparación?

Un string correcto falla cuando está en el contenedor equivocado o difiere en un solo code point. 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 es un desfase. También lo es una é precompuesta a un lado contra una e descompuesta más acento agudo combinante al otro, aunque las dos suelen renderizarse idénticas en pantalla

Los errores de forma tienen su propio estado. Un dc:title escrito como texto plano, un dc:creator declarado como rdf:Bag, o un Seq dc:creator con dos ítems producen todos pamsXmpTypeMismatch en lugar de pamsValueMismatch, de modo que un informe puede distinguir "la herramienta eligió la estructura RDF equivocada" de "la herramienta escribió texto distinto". Los dos problemas suelen venir de sitios distintos de un pipeline, y arreglar el equivocado cuesta una tarde

El lado Info viene siempre de la decodificación propia de PDFium de strings PDFDocEncoding y UTF-16BE a través de FPDF_GetMetaText, el mismo camino que usa TPdf.GetMetadata, de modo que un string con una marca de orden de bytes o una secuencia octal escapada se lee como lo lee el motor. El lado XMP está tan limpio como los bytes que lo produjeron. Un paquete ensamblado con la página de códigos equivocada puede llevar mojibake que ninguna comparación perdonará, razón por la que las trampas de página de códigos que corrompen metadatos XMP bajo Free Pascal pertenecen a la misma lista de comprobación

¿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 a medianoche. Cuando ambos valores llevan zona, cada uno se convierte primero a UTC: D:20260827093659+08'00' es igual 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. Inventar un offset para el valor sin zona sería una suposición, y una suposición que por casualidad coincida con la zona horaria del servidor pasaría en una máquina y fallaría en otra. Un segundo fraccionario distinto de cero como .250 en XMP también fuerza un desfase, porque una fecha PDF no tiene forma de expresarlo; .000 se acepta. Los valores que no se parsean en absoluto se informan como pamsInvalidInfoDate o pamsInvalidXmpDate

Cómo decide PDFium Component que una fecha Info de PDF y una fecha XMP son iguales en Delphi: dos zonas se convierten a UTC y comparan instantes, dos valores sin zona se comparan tal como están escritos, una zona sola es un pamsValueMismatch, un segundo fraccionario distinto de cero no se puede expresar, y los valores no parseables se informan 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 un desacuerdo real

Las dos gramáticas de fecha no son intercambiables, y los parsers obligan a cada lado a la suya. En el lado PDF, los apóstrofos alrededor de los minutos del offset son opcionales y se acepta Z. En el lado XMP el offset necesita dos puntos: 2026-08-27T09:36:59+08:00 se parsea, mientras que +0800 es pamsInvalidXmpDate. La hora XMP también necesita al menos horas y minutos en cuanto aparece una T, y los offsets de más de 14 horas se rechazan en ambos lados. El espacio en blanco circundante se recorta antes de parsear una fecha, una cortesía que los campos de texto no reciben

La presencia tiene una regla propia. TPdfAMetadataValues.Present es un conjunto que se rellena recorriendo el diccionario /Info del trailer activo, y mantiene "clave ausente" separado de "clave presente con un string vacío". Una clave ausente produce pamsNotRequired. /Title () está presente, así que el paquete debe llevar también un título x-default vacío

Leer el informe por campo antes de tocar el fichero

TPdf.InspectPdfAMetadata devuelve un TPdfAMetadataReport con un TPdfAMetadataComparison por campo. Cada entrada lleva el valor Info, el valor XMP y un TPdfAMetadataState, de modo que un fallo se puede explicar campo a campo. MismatchFields recoge el conjunto fallido, HasXmpPacket dice si se encontró un paquete, y XmpParseError lleva el mensaje del parser cuando existe un paquete pero no se puede leer. En ese último caso cada campo presente informa pamsXmpMissing, porque no se pudo leer nada del paquete

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;

TPdf.ValidatePdfA dobla la misma comparación dentro de su conjunto de incidencias. El escaneo a nivel de bytes, descrito en validación preflight PDF/A con PDFium Component, informa pvaiMissingXmpDates cuando al paquete le faltan ambas propiedades de fecha y pvaiInfoXmpDateMismatch cuando Info lleva CreationDate o ModDate pero el paquete no tiene ninguna propiedad de fecha que coincida. Encima de eso, ValidatePdfA ejecuta la comparación completa de valores y añade pvaiInfoXmpValueMismatch siempre que exista un paquete pero discrepe de Info, paquete ilegible incluido. Un documento sin paquete alguno se informa solo como pvaiMissingXmpMetadata, de modo que un defecto nunca aparece dos veces. La función a nivel de stream ValidatePdfACompliance hace solo el escaneo a nivel de bytes

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;

Qué reescribe NormalizePdfAMetadata y dónde se detiene

TPdf.NormalizePdfAMetadata trata el diccionario Info como la fuente de la verdad y reescribe solo las propiedades XMP cuyo campo acabó en MismatchFields. Title y Subject van al ítem x-default mientras las demás alternativas de lengua quedan intactas. Author se convierte en un rdf:Seq de un ítem. Una fecha Info con zona se escribe como fecha XMP UTC canónica con sufijo Z, y una sin zona conserva sus componentes locales. Los namespaces desconocidos, las propiedades sin relación y las propiedades XMP de claves Info ausentes se dejan tranquilas, y se reutiliza una ligadura de prefijo existente cuando se escribe de vuelta una propiedad

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 namespaces desconocidos, propiedades sin relación y propiedades de claves ausentes sobreviven intactos
La reparación rechaza un paquete XMP ausente y una fecha Info malformada, y por defecto un documento firmado, porque construir un conjunto completo de metadatos PDF/A es trabajo de SaveAsPdfA, no de un arreglo de equivalencia dirigido

Hacer a Info autoridad tiene un coste que aparece en ficheros reales. Un Seq dc:creator que lista "Finance" y "Controlling" como dos personas se sustituye por un ítem que dice Finance; Controlling, y un dc:title guardado en el contenedor equivocado se reconstruye como un Alt fresco que solo contiene x-default, así que lo que guardara el contenedor viejo desaparece. Esa pérdida es aceptable aquí. La Tabla 1 permite exactamente un ítem de creator, el string Info ya lleva la lista completa en una pieza, y el paquete original sigue en el fichero fuente si alguien necesita la lista más rica

Los rechazos son deliberados. Sin paquete XMP el método lanza EPdfError: construir una identificación y un conjunto de metadatos PDF/A completos es trabajo de SaveAsPdfA, cubierto en crear ficheros de archivo PDF/A con PDFium Component. Un paquete ilegible o una fecha Info malformada lanza EPdfXmpError antes de escribir nada, porque una fecha equivocada de aspecto plausible en XMP es peor que ninguna salida. Los documentos firmados lanzan EPdfError salvo que quien llama pase AllowSignedDocument = True; la salida es un guardado completo con los metadatos nuevos añadidos como actualización incremental, de modo que los bytes firmados no se transportan sin cambios

Esa vía de guardado tiene una consecuencia más. NormalizePdfAMetadata escribe un fichero nuevo incluso cuando cada campo ya coincide, porque sincronizar nada acaba igualmente en un guardado. Llamad antes a InspectPdfAMetadata y saltad los ficheros equivalentes; un trabajo nocturno que reescribe miles de archivos sin cambios no compra nada más que timestamps nuevos. La sobrecarga con fichero escribe a través de un fichero temporal y sustituye el objetivo atómicamente. El fichero fuente permanece abierto mientras el documento esté cargado, así que dad a la salida una ruta distinta

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 consistente, dejar el fichero tranquilo
    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 paquete ilegible
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
      on E: EPdfError do         // entrada firmada sin AllowSignedDocument
        raise Exception.CreateFmt('Skipped %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

La equivalencia es una cláusula de la ISO 19005-1. Un fichero normalizado todavía tiene que pasar fuentes, color, transparencia y todas las demás comprobaciones PDF/A, así que ValidatePdfA sigue siendo la puerta antes de que un documento se marche a almacenamiento a largo plazo

Comparar un paquete sin documento cargado

ComparePdfAInfoAndXmp y SynchronizePdfAInfoToXmp son funciones planas de FPdfPdfa que trabajan sobre un TPdfXmpPacket sin ningún PDF abierto, lo que les viene bien a los tests unitarios y a los pipelines que rellenan el XMP desde una plantilla antes de que exista el documento. SynchronizePdfAInfoToXmp devuelve el conjunto de campos que reescribió y lanza EPdfXmpError cuando le entregan un paquete nil

La única trampa es Present. Un record inicializado con Default(TPdfAMetadataValues) tiene un conjunto vacío, cada campo informa entonces pamsNotRequired, y la comparación pasa sin importar qué valores hayáis rellenado. Los campos string del record llevan valores; solo Present decide cuáles de ellos exige realmente el estándar

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 solos se ignoran
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

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

Pasar la misma plantilla por ComparePdfAInfoAndXmp después debería devolver un MismatchFields vacío, lo que constituye una aserción barata para un paso de build que regenera plantillas

Los archivos que otros sistemas siguen editando derivan un campo cada vez, y el informe por campo muestra cuál se movió. InspectPdfAMetadata, NormalizePdfAMetadata y el resto de las herramientas de validación y conversión PDF/A forman parte de PDFium Component para Delphi y C++Builder