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:titley Subject →dc:description: una alternativa de lenguajerdf:Alt, comparada solo contra su ítemx-default(la etiqueta de lenguaje se compara sin distinguir mayúsculas); un Alt sinx-defaultcuenta como ausente - Author →
dc:creator: unrdf:Seqcon 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:Keywordsy Producer →pdf:Producer(namespacehttp://ns.adobe.com/pdf/1.3/): propiedades de texto simple - Creator →
xmp:CreatorTool, CreationDate →xmp:CreateDate, ModDate →xmp:ModifyDate(namespacehttp://ns.adobe.com/xap/1.0/): propiedades de texto simple
<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
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
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