Artículo técnico

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

Un diccionario Info que lleva /ModDate (D:20260827093659+08'00') y un packet XMP que lleva 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. Copie el string Subject del documento a dc:subject, sin embargo, y el packet falla incluso cuando el texto coincide carácter por carácter. El ISO 19005-1 §6.7.3 pide valores equivalentes, guardados en propiedades XMP específicas, en formas RDF específicas

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

Qué exige el ISO 19005-1 de Info y XMP

La Tabla 1 del ISO 19005-1 empareja ocho claves Info con propiedades XMP, y la §6.7.3 dice que una vez que una clave Info está presente, la propiedad XMP mapeada debe cargar un valor equivalente. Una clave ausente no exige nada. PDF/A-2 y PDF/A-3 conservan el mismo requisito de equivalencia para las mismas ocho claves. PDF/A-4 va por otro lado y restringe el propio diccionario Info: solo puede aparecer junto a un /PieceInfo del catálogo y entonces no llevar nada más que ModDate, lo que TPdf.ValidatePdfA reporta 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 packet 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 idioma rdf:Alt, comparada contra su item x-default únicamente; el tag de idioma se empareja sin distinguir mayúsculas, y un Alt sin x-default cuenta como ausente
  • Author → dc:creator: un rdf:Seq con exactamente un item de texto que carga el string Info completo, de modo que una lista de autores separada por punto y coma queda como una única entrada
  • Keywords → pdf:Keywords y Producer → pdf:Producer (namespace http://ns.adobe.com/pdf/1.3/): propiedades de texto simples
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (namespace http://ns.adobe.com/xap/1.0/): propiedades de texto simples
Los ocho pares mapeados que ComparePdfAInfoAndXmp verifica para la equivalencia de metadatos PDF/A en Delphi: Title y Subject necesitan un rdf:Alt con un item x-default, Author un rdf:Seq de un item, Keywords, Producer, Creator y las dos fechas son texto simple, cada uno buscado por URI de namespace XMP y no por prefijo
Una vez que una clave Info está presente, ISO 19005-1 exige que la propiedad XMP mapeada cargue un valor equivalente en la forma RDF requerida, así que un valor en el contenedor equivocado igual 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 un bag sin orden de keywords, y el nombre invita a meter el Subject de Info ahí. La comparación busca dc:description, no encuentra nada, y reporta 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 aún falla la comparación?

Un string correcto falla cuando está en el contenedor equivocado o difiere por un solo code point. Los valores de texto se comparan como secuencias exactas de code points Unicode, sin recorte, sin plegado de mayúsculas y sin normalización. Un espacio al final es un desacuerdo. También lo es una é precompuesta de un lado contra una e descompuesta más acento agudo combinante del otro, aunque los dos suelen renderizarse idénticos 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 de dc:creator con dos items producen todos pamsXmpTypeMismatch en lugar de pamsValueMismatch, así que un reporte puede distinguir "la herramienta eligió la estructura RDF equivocada" de "la herramienta escribió texto distinto". Los dos problemas suelen venir de lugares distintos en un pipeline, y arreglar el equivocado desperdicia una tarde

El lado Info siempre viene 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, así 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 packet armado con la página de códigos equivocada puede llevar mojibake que ninguna comparación perdonará, razón por la cual las trampas de codepage que corrompen metadatos XMP bajo Free Pascal pertenecen a la misma lista de verificació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 de ambos lados. Ambos parsers aceptan precisión reducida legal, así que D:2026 y 2026 significan ambos 1 de enero de 2026 a medianoche. Cuando ambos valores cargan zona, cada uno se convierte a UTC primero: D:20260827093659+08'00' es igual a 2026-08-27T01:36:59Z. Cuando ninguno carga 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 adivinar, y una adivinanza que casualmente 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 desacuerdo porque una fecha PDF no tiene forma de expresarlo; .000 se acepta. Los valores que no se parsean en absoluto se reportan como pamsInvalidInfoDate o pamsInvalidXmpDate

Cómo decide PDFium Component que una fecha Info PDF y una fecha XMP son iguales en Delphi: dos zonas se convierten a UTC y comparan instantes, dos valores sin zona se comparan 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 reportan por separado
Igualdad significa el mismo instante al segundo con el mismo conocimiento de zona horaria en 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 le exigen a cada lado la suya. Del lado PDF, los apóstrofos alrededor de los minutos del offset son opcionales y se acepta Z. Del lado XMP el offset necesita dos puntos: 2026-08-27T09:36:59+08:00 se parsea, mientras que +0800 es pamsInvalidXmpDate. La hora XMP además necesita al menos horas y minutos una vez que 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 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 string vacío". Una clave ausente da pamsNotRequired. /Title () está presente, así que el packet debe cargar también un título x-default vacío

Leer el reporte por campo antes de tocar el archivo

TPdf.InspectPdfAMetadata devuelve un TPdfAMetadataReport con un TPdfAMetadataComparison por campo. Cada entrada carga el valor Info, el valor XMP y un TPdfAMetadataState, así que un fallo se puede explicar campo por campo. MismatchFields reúne el conjunto que falla, HasXmpPacket dice si se encontró un packet, y XmpParseError carga el mensaje del parser cuando el packet existe pero no se puede leer. En ese último caso cada campo presente reporta pamsXmpMissing, porque nada se pudo leer del 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;

TPdf.ValidatePdfA dobla la misma comparación en su set de issues. El escaneo a nivel de bytes, descrito en validación preflight PDF/A con PDFium Component, reporta pvaiMissingXmpDates cuando el packet carece de ambas propiedades de fecha y pvaiInfoXmpDateMismatch cuando Info lleva CreationDate o ModDate pero el packet no tiene ninguna propiedad de fecha que coincida. Encima de eso, ValidatePdfA corre la comparación de valores completa y agrega pvaiInfoXmpValueMismatch siempre que exista un packet que discrepe con Info, incluido uno ilegible. Un documento sin packet en absoluto se reporta solo como pvaiMissingXmpMetadata, así que un defecto nunca aparece dos veces. La función a nivel de stream ValidatePdfACompliance ejecuta 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 verdad y reescribe solo las propiedades XMP cuyo campo cayó en MismatchFields. Title y Subject van al item x-default mientras otras alternativas de idioma quedan intactas. Author se convierte en un rdf:Seq de un item. Una fecha Info con zona se escribe como fecha XMP canónica UTC con sufijo Z, y una sin zona conserva sus componentes locales. Los namespaces desconocidos, las propiedades no relacionadas y las propiedades XMP de claves Info ausentes se dejan quietos, y una ligadura de prefijo existente se reutiliza cuando una propiedad se escribe de vuelta

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

Hacer que Info sea la autoridad tiene un costo que aparece en archivos reales. Un Seq de dc:creator que lista "Finance" y "Controlling" como dos personas se reemplaza por un item que lee Finance; Controlling, y un dc:title guardado en el contenedor equivocado se reconstruye como un Alt fresco que solo lleva x-default, así que lo que el contenedor viejo tenía se pierde. Esa pérdida es aceptable aquí. La Tabla 1 permite exactamente un item de creator, el string Info ya carga la lista completa en una sola pieza, y el packet original sigue en el archivo fuente si alguien necesita la lista más rica

Las negaciones son deliberadas. Sin packet XMP el método lanza EPdfError: construir una identificación PDF/A completa y un set de metadatos es trabajo de SaveAsPdfA, cubierto en creación de archivos PDF/A para archivado con PDFium Component. Un packet ilegible o una fecha Info malformada lanzan EPdfXmpError antes de que se escriba cualquier cosa, porque una fecha equivocada de apariencia 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 nuevos metadatos agregados como actualización incremental, así que los bytes firmados no se arrastran sin cambios

Ese camino de guardado tiene una consecuencia más. NormalizePdfAMetadata escribe un archivo nuevo incluso cuando cada campo ya coincide, ya que sincronizar nada igual termina en un guardado. Llame primero a InspectPdfAMetadata y salte los archivos equivalentes; un trabajo nocturno que reescribe miles de archivos sin cambios no compra nada más que marcas de tiempo nuevas. La sobrecarga de archivo escribe a través de un archivo temporal y reemplaza el objetivo atómicamente. El archivo fuente se queda abierto mientras el documento esté cargado, así que dele 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, deje el archivo quieto
    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]);
      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 sola cláusula del ISO 19005-1. Un archivo normalizado todavía tiene que pasar fuentes, color, transparencia y todas las demás verificaciones PDF/A, así que ValidatePdfA sigue siendo la compuerta antes de que un documento salga hacia almacenamiento de largo plazo

Comparar un packet sin documento cargado

ComparePdfAInfoAndXmp y SynchronizePdfAInfoToXmp son funciones comunes de FPdfPdfa que trabajan sobre un TPdfXmpPacket sin ningún PDF abierto, lo que les viene bien a las pruebas unitarias y a los pipelines que llenan el XMP desde una plantilla antes de que el documento exista. SynchronizePdfAInfoToXmp devuelve el conjunto de campos que reescribió y lanza EPdfXmpError cuando se le entrega un packet nil

La única trampa es Present. Un record inicializado con Default(TPdfAMetadataValues) tiene un set vacío, cada campo entonces reporta pamsNotRequired, y la comparación pasa sin importar qué valores usted haya llenado. Los campos string del record cargan 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 que discrepa se vuelve 2026-08-27T01:36:59Z,
    // un dc:creator que discrepa se vuelve un rdf:Seq de un item
    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 se desvían un campo a la vez, y el reporte por campo muestra cuál se movió. InspectPdfAMetadata, NormalizePdfAMetadata y el resto de la herramienta de validación y conversión PDF/A son parte del PDFium Component para Delphi y C++Builder