Articolo tecnico

Equivalenza tra Info e metadati XMP per PDF/A in Delphi

PDFium Component verifica l'equivalenza Info-XMP dei metadati PDF/A con TPdf.InspectPdfAMetadata e la ripara con TPdf.NormalizePdfAMetadata. ISO 19005-1 (come corretta da Cor.1) richiede che ciascuna delle otto voci Info mappate, da Title a ModDate, porti lo stesso valore della sua proprietà XMP, non semplicemente che esista; il controllo legge la forma RDF corretta, abbina i namespace per URI e confronta le date come istanti

Il bug report che di solito apre questa conversazione sembra innocuo. Un sistema di gestione documentale timbra un nuovo /ModDate nel dizionario Info a ogni salvataggio incrementale, lascia in pace il pacchetto XMP, e sei mesi dopo un audit d'archivio segnala migliaia di file come non conformi. Entrambe le date ci sono. Hanno solo smesso di concordare alla prima modifica, e un controllo di presenza non se n'è mai accorto. Modifiche al Title fatte tramite una API solo-Info, e una stringa Author come Finance; Controlling che qualche strumento ha spezzato in due voci dc:creator, falliscono allo stesso modo

Perché PDF/A respinge metadati che esistono in entrambi i posti?

PDF/A la respinge perché ISO 19005-1 §6.7.3 è una regola sul valore, non sulla presenza: la Tabella 1 mappa otto chiavi Info su proprietà XMP, e una volta che una chiave Info è presente, la proprietà XMP mappata deve contenere un valore equivalente. Lo scanner a livello di byte descritto nella validazione preflight PDF/A con PDFium Component conferma solo che xmp:CreateDate e xmp:ModifyDate esistano (pvaiMissingXmpDates). Dalla v3.72.0, TPdf.ValidatePdfA esegue inoltre il confronto completo dei valori e aggiunge pvaiInfoXmpValueMismatch all'insieme dei problemi quando un pacchetto XMP esiste ma è in disaccordo con Info (un pacchetto non parsabile conta come in disaccordo). Un pacchetto assente resta segnalato come pvaiMissingXmpMetadata, così i due problemi non contano mai due volte lo stesso difetto

Quale forma RDF richiede ciascuna proprietà XMP mappata?

Ciascuna delle otto mappature ha un tipo XMP fisso, e un valore corretto nel contenitore sbagliato fallisce comunque. ComparePdfAInfoAndXmp in FPdfPdfa.pas cerca le proprietà per URI di namespace, così un pacchetto che lega http://purl.org/dc/elements/1.1/ a un prefisso insolito viene letto esattamente come uno che usa dc. Le forme richieste sono:

  • Title → dc:title e Subject → dc:description: un'alternativa di lingua rdf:Alt, confrontata solo con la sua voce x-default (il tag di lingua è confrontato senza distinguere maiuscole); un Alt senza x-default conta come assente
  • Author → dc:creator: un rdf:Seq con esattamente una voce di testo che contiene l'intera stringa Info, così un elenco di autori separati da punto e virgola resta una voce unica
  • Keywords → pdf:Keywords e Producer → pdf:Producer (namespace http://ns.adobe.com/pdf/1.3/): proprietà di testo semplici
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (namespace http://ns.adobe.com/xap/1.0/): proprietà di testo semplici
Le otto coppie mappate che ComparePdfAInfoAndXmp verifica per l'equivalenza dei metadati PDF/A in Delphi: Title e Subject richiedono un rdf:Alt con una voce x-default, Author un rdf:Seq a una voce, Keywords Producer Creator e le due date sono testo semplice, ciascuna cercata per URI di namespace XMP invece che per prefisso
Una volta che una chiave Info è presente, ISO 19005-1 richiede che la proprietà XMP mappata contenga un valore equivalente nella forma RDF richiesta, quindi un valore nel contenitore sbagliato fallisce comunque
<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>

I valori di testo vengono confrontati come sequenze esatte di code point Unicode, senza trimming, folding di maiuscole o normalizzazione. Uno spazio in coda, oppure una é precomposta da un lato e una e decomposta con accento combinato dall'altro, è un disaccordo autentico. Il lato Info viene sempre dalla decodifica di PDFium stessa del testo PDFDocEncoding e UTF-16 tramite FPDF_GetMetaText, il che evita alla libreria di re-implementare la decodifica delle stringhe e sbagliarla in modo sottile; il lato XMP è pulito solo quanto i byte che lo hanno prodotto, ed è per questo che le trappole di codepage che corrompono i metadati XMP sotto Free Pascal contano anche qui

Quando una data PDF e una data XMP sono uguali?

Una data PDF e una data XMP sono uguali quando descrivono lo stesso istante al secondo, con la stessa conoscenza del fuso orario su entrambi i lati. Entrambi i parser accettano la precisione ridotta lecita, quindi D:2026 e 2026 significano entrambi 1 gennaio 2026, 00:00:00. Quando entrambi i valori portano un fuso, vengono convertiti in UTC prima del confronto: D:20260827093659+08'00' equivale a 2026-08-27T01:36:59Z. Quando nessuno dei due porta un fuso, le componenti locali vengono confrontate così come scritte. Quando solo un lato ha un fuso, il risultato è pamsValueMismatch, perché inventarsi un offset sarebbe un'interpretazione. Un secondo frazionario non nullo come .250 in XMP forza anch'esso un disaccordo, visto che una data PDF non ha modo di esprimerlo e arrotondarlo via in silenzio nasconderebbe un disaccordo reale; .000 è accettato. I valori non parsabili vengono segnalati separatamente come pamsInvalidInfoDate o pamsInvalidXmpDate

Come PDFium Component decide che una data Info PDF e una data XMP sono uguali in Delphi: due fusi si convertono in UTC e confrontano gli istanti, due valori senza fuso si confrontano come scritti, un fuso da solo è un pamsValueMismatch, un secondo frazionario non nullo non è esprimibile, e i valori non parsabili vengono segnalati separatamente
L'uguaglianza significa lo stesso istante al secondo con la stessa conoscenza del fuso su entrambi i lati, quindi inventarsi un offset o arrotondare via un secondo frazionario nasconderebbe un disaccordo reale

La presenza ha la sua regola. TPdfAMetadataValues.Present è un insieme riempito percorrendo il dizionario /Info del trailer attivo, e tiene separato "chiave assente" da "chiave presente con stringa vuota". Una chiave assente dà pamsNotRequired e non pretende nulla da XMP; /Title () è presente, quindi il pacchetto XMP deve portare anche un titolo x-default vuoto

Come si ispezionano i metadati Info e XMP prima del salvataggio?

TPdf.InspectPdfAMetadata restituisce un TPdfAMetadataReport con un TPdfAMetadataComparison per campo, ciascuno con il valore Info, il valore XMP e uno TPdfAMetadataState, così un fallimento si può spiegare senza fare reverse engineering di un singolo flag di validazione. MismatchFields riassume l'insieme dei campi che falliscono, HasXmpPacket dice se un pacchetto è stato trovato, e XmpParseError porta il messaggio del parser quando il pacchetto esiste ma non si può leggere

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;

Che cosa cambia NormalizePdfAMetadata, e che cosa rifiuta?

TPdf.NormalizePdfAMetadata tratta il dizionario Info come fonte di verità e riscrive solo le proprietà XMP il cui campo è finito in MismatchFields; tutto il resto del pacchetto sopravvive. Title e Subject vengono scritti nella voce x-default mentre le altre alternative di lingua restano intatte, Author diventa un rdf:Seq a una voce, i namespace sconosciuti e le proprietà non correlate vengono preservati, e le proprietà XMP per chiavi Info assenti restano intoccate. Una data Info con fuso viene scritta come data XMP UTC canonica con suffisso Z; una senza fuso conserva le sue componenti locali. L'overload su file salva attraverso un file temporaneo e una sostituzione atomica, e l'aggiornamento XMP in sé viene accodato come aggiornamento incrementale

Che cosa riscrive NormalizePdfAMetadata quando ripara i metadati PDF/A in Delphi con PDFium Component: Info è la fonte di verità, solo le voci di MismatchFields vengono riscritte come testo Alt x-default, un Seq a una voce o una data UTC canonica, mentre namespace sconosciuti, proprietà non correlate e proprietà di chiavi assenti sopravvivono intatte
La riparazione rifiuta un pacchetto XMP assente, una data Info malformata e i documenti firmati, perché costruire un insieme completo di metadati PDF/A è compito di SaveAsPdfA, non di una correzione mirata di equivalenza

I rifiuti sono deliberati. Senza pacchetto XMP il metodo solleva EPdfError, perché costruire un insieme completo di identificazione e metadati PDF/A è compito di SaveAsPdfA, trattato in creare file PDF/A di archiviazione con PDFium Component. Una data Info malformata solleva EPdfXmpError invece di scrivere un valore sbagliato dall'aspetto plausibile, e nulla viene salvato. I documenti firmati vengono respinti a meno che il chiamante non passi AllowSignedDocument = True. Anche l'equivalenza è una regola di ISO 19005-1, quindi un file normalizzato non è automaticamente 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;                      // già coerente, lasciate stare il file
    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      // data Info malformata o pacchetto illeggibile
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

Eseguire il confronto sul vostro pacchetto XMP

ComparePdfAInfoAndXmp e SynchronizePdfAInfoToXmp sono funzioni semplici in FPdfPdfa che lavorano su un TPdfXmpPacket senza alcun documento caricato, il che sta bene ai test unitari e alle pipeline che assemblano XMP da un template. L'unica trappola è Present: un record inizializzato con Default(TPdfAMetadataValues) ha un insieme vuoto, ogni campo allora riporta pamsNotRequired, e il confronto passa vacuo qualunque valore abbiate riempito

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 quali campi sono obbligatori; i valori da soli vengono ignorati
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

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

Se la vostra pipeline archivia documenti che altri sistemi continuano a modificare, abbinate una passata notturna di InspectPdfAMetadata a NormalizePdfAMetadata per i file che derivano, e tenete ValidatePdfA come cancello prima che qualcosa parta per l'archiviazione a lungo termine. Il report tipizzato, il percorso di riparazione e il resto degli strumenti PDF/A viaggiano nel PDFium Component per Delphi e C++Builder