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:titley Subject →dc:description: una alternativa de idiomardf:Alt, comparada contra su itemx-defaultúnicamente; el tag de idioma se empareja sin distinguir mayúsculas, y un Alt sinx-defaultcuenta como ausente - Author →
dc:creator: unrdf:Seqcon 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:Keywordsy Producer →pdf:Producer(namespacehttp://ns.adobe.com/pdf/1.3/): propiedades de texto simples - Creator →
xmp:CreatorTool, CreationDate →xmp:CreateDate, ModDate →xmp:ModifyDate(namespacehttp://ns.adobe.com/xap/1.0/): propiedades de texto simples
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
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
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