Technisch artikel

PDF/A Info- en XMP-metadata-equivalentie in Delphi

Een Info-dictionary met /ModDate (D:20260827093659+08'00') en een XMP-pakket met xmp:ModifyDate als 2026-08-27T01:36:59Z beschrijven dezelfde seconde, en een PDF/A-validator heeft alle reden dat paar te accepteren. Kopieert u de Subject-tekenreeks van het document naar dc:subject, dan faalt het pakket echter zelfs als de tekst teken voor teken matcht. ISO 19005-1 §6.7.3 vraagt om equivalente waarden, gehouden in specifieke XMP-eigenschappen, in specifieke RDF-vormen

De bugrapportage die dit onderwerp meestal opent, ziet er onschuldig uit. Een documentmanagementsysteem stempelt bij elke incrementele opslag een verse /ModDate in het Info-dictionary en laat het XMP-pakket met rust. Maanden later vlagt een archiefaudit duizenden bestanden. Beide datums staan erin; ze zijn gestopt met overeenkomen bij de eerste edit, en een controle die alleen naar het bestaan van de eigenschap kijkt, zag het nooit. Title-edits via een Info-only-API falen op dezelfde manier, en net zo goed een Author-tekenreeks als Finance; Controlling die een of andere tool splitste in twee dc:creator-items

Wat ISO 19005-1 eist van Info en XMP

Table 1 van ISO 19005-1 koppelt acht Info-sleutels aan XMP-eigenschappen, en §6.7.3 zegt dat zodra een Info-sleutel aanwezig is, de gemapte XMP-eigenschap een equivalente waarde moet dragen. Een afwezige sleutel eist niets. PDF/A-2 en PDF/A-3 houden dezelfde equivalentie-eis voor dezelfde acht sleutels aan. PDF/A-4 gaat de andere kant op en beperkt het Info-dictionary zelf: het mag alleen naast een catalogus-/PieceInfo verschijnen en mag dan niets behalve ModDate bevatten, wat TPdf.ValidatePdfA meldt via pvaiPdfA4InfoWithoutPieceInfo en pvaiPdfA4InfoInvalid

In PDFium Component woont de vergelijking in ComparePdfAInfoAndXmp in FPdfPdfa.pas. Hij zoekt elke eigenschap op bij namespace-URI, dus een pakket dat http://purl.org/dc/elements/1.1/ aan een ongebruikelijk prefix bindt, wordt precies zo gelezen als een dat dc gebruikt. De gemapte vormen zijn:

  • Title → dc:title en Subject → dc:description: een rdf:Alt-taalalternatief, alleen vergeleken met zijn x-default-item; de taaltag wordt case-insensitief gematcht, en een Alt zonder x-default telt als afwezig
  • Author → dc:creator: een rdf:Seq met precies één tekstitem dat de hele Info-tekenreeks bevat, zodat een met puntkomma's gescheiden auteurslijst één entry blijft
  • Keywords → pdf:Keywords en Producer → pdf:Producer (namespace http://ns.adobe.com/pdf/1.3/): eenvoudige teksteigenschappen
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (namespace http://ns.adobe.com/xap/1.0/): eenvoudige teksteigenschappen
De acht gemapte paren die ComparePdfAInfoAndXmp controleert op PDF/A-metadata-equivalentie in Delphi: Title en Subject hebben een rdf:Alt met een x-default-item nodig, Author een rdf:Seq met één item, Keywords, Producer, Creator en de twee datums zijn gewone tekst, elk opgezocht bij XMP-namespace-URI in plaats van prefix
Zodra een Info-sleutel aanwezig is, eist ISO 19005-1 dat de gemapte XMP-eigenschap een equivalente waarde in de vereiste RDF-vorm bevat, dus een waarde in de verkeerde container faalt alsnog

De Subject-rij is waar handgebouwde sjablonen het vaakst de mist ingaan. Dublin Core's dc:subject is een ongeordende zak sleutelwoorden, en de naam nodigt uit om de Info-Subject erin te gooien. De vergelijking zoekt naar dc:description, vindt niets en meldt Subject als pamsXmpMissing terwijl de "juiste" tekst één eigenschap verderop zit. Drie regels in de correcte vormen zien er zo uit:

<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>

Waarom faalt een correcte tekenreeks alsnog in de vergelijking?

Een correcte tekenreeks faalt wanneer hij in de verkeerde container zit of op één codepunt verschilt. Tekstwaarden worden vergeleken als exacte Unicode-codepuntreeksen, zonder trimmen, zonder case-folding en zonder normalisatie. Een afsluitende spatie is een mismatch. Net zo goed een voorgecombineerde é aan de ene kant tegen een gedecomposeerde e plus combining acute accent aan de andere, ook al renderen de twee op het scherm meestal identiek

Vormfouten krijgen hun eigen status. Een dc:title als kale tekst geschreven, een dc:creator als rdf:Bag gedeclareerd, of een dc:creator-Seq met twee items leveren allemaal pamsXmpTypeMismatch op in plaats van pamsValueMismatch, zodat een rapport "de tool koos de verkeerde RDF-structuur" kan onderscheiden van "de tool schreef andere tekst". De twee problemen komen in een pipeline meestal van verschillende plekken, en het verkeerde fixen kost een middag

De Info-kant komt altijd uit de eigen decoding door PDFium van PDFDocEncoding- en UTF-16BE-tekenreeksen via FPDF_GetMetaText, hetzelfde pad dat TPdf.GetMetadata gebruikt, dus een tekenreeks met een byte-order mark of een ge-escapete octale reeks wordt gelezen zoals de engine hem leest. De XMP-kant is net zo schoon als de bytes die hem produceerden. Een pakket dat met de verkeerde codepagina is in elkaar gezet kan mojibake meedragen die geen enkele vergelijking vergeeft, en daarom horen de codepaginavalkuilen die XMP-metadata onder Free Pascal corrumperen op dezelfde checklist

Wanneer is een PDF-datum gelijk aan een XMP-datum?

Een PDF-datum en een XMP-datum zijn gelijk wanneer ze hetzelfde tijdstip tot op de seconde beschrijven met aan beide kanten dezelfde kennis van de tijdzone. Beide parsers accepteren legale gereduceerde precisie, dus D:2026 en 2026 betekenen allebei 1 januari 2026 om middernacht. Dragen beide waarden een zone, dan wordt elk eerst naar UTC omgezet: D:20260827093659+08'00' is gelijk aan 2026-08-27T01:36:59Z. Draagt geen van beide een zone, dan worden de lokale onderdelen vergeleken zoals ze geschreven zijn

Heeft slechts één kant een zone, dan is het resultaat pamsValueMismatch. Voor de zonloze waarde een offset verzinnen zou een gok zijn, en een gok die toevallig matcht met de tijdzone van de server zou op de ene machine slagen en op de andere falen. Een fractionele seconde ongelijk aan nul, zoals .250 in XMP, forceert ook een mismatch, want een PDF-datum heeft geen manier om hem uit te drukken; .000 wordt geaccepteerd. Waarden die helemaal niet parsen worden gemeld als pamsInvalidInfoDate of pamsInvalidXmpDate

Hoe PDFium Component in Delphi beslist dat een PDF Info-datum en een XMP-datum gelijk zijn: twee zones worden naar UTC omgezet en vergelijken tijdstippen, twee waarden zonder zone worden vergeleken zoals ze staan, één zone alleen is een pamsValueMismatch, een fractionele seconde ongelijk aan nul is niet uit te drukken, en onparseerbare waarden worden apart gemeld
Gelijkheid betekent hetzelfde tijdstip tot op de seconde met aan beide kanten dezelfde kennis van de tijdzone, dus een offset verzinnen of een fractionele seconde wegronden zou een echte tegenstrijdigheid verhullen

De twee datumgrammatica's zijn niet uitwisselbaar, en de parsers houden elke kant aan zijn eigen. Aan de PDF-kant zijn de apostrofes rond de offsetminuten optioneel en wordt Z geaccepteerd. Aan de XMP-kant wil de offset een dubbele punt: 2026-08-27T09:36:59+08:00 parseert, terwijl +0800 een pamsInvalidXmpDate is. XMP-tijd wil ook ten minste uren en minuten zodra er een T staat, en offsets boven 14 uur worden aan beide kanten geweigerd. Omliggende witruimte wordt weggetrimd voordat een datum wordt geparseerd, een beleefdheid die tekstvelden niet krijgen

Aanwezigheid heeft een eigen regel. TPdfAMetadataValues.Present is een set die wordt gevuld door de /Info-dictionary van de actieve trailer te doorlopen, en hij houdt "sleutel afwezig" apart van "sleutel aanwezig met een lege tekenreeks". Een afwezige sleutel levert pamsNotRequired op. /Title () is aanwezig, dus het pakket moet ook een lege x-default-titel dragen

Het rapport per veld lezen voordat u het bestand aanraakt

TPdf.InspectPdfAMetadata geeft een TPdfAMetadataReport terug met één TPdfAMetadataComparison per veld. Elke entry draagt de Info-waarde, de XMP-waarde en een TPdfAMetadataState, zodat een mislukking veld voor veld is uit te leggen. MismatchFields verzamelt de falende set, HasXmpPacket zegt of er een pakket is gevonden, en XmpParseError draagt de parsermelding mee als er een pakket is dat niet leesbaar is. In dat laatste geval meldt elk aanwezig veld pamsXmpMissing, want er was niets uit het pakket te lezen

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 vouwt dezelfde vergelijking in zijn issueset. De byte-level scan, beschreven in PDF/A-preflightvalidatie met PDFium Component, meldt pvaiMissingXmpDates als het pakket beide dateigenschappen mist en pvaiInfoXmpDateMismatch als Info CreationDate of ModDate draagt maar het pakket geen matchende dateigenschap heeft. Daarbovenop draait ValidatePdfA de volledige waardevergelijking en voegt pvaiInfoXmpValueMismatch toe zodra er een pakket is dat met Info strijdig is, een onleesbaar pakket inbegrepen. Een document zonder pakket wordt alleen als pvaiMissingXmpMetadata gemeld, zodat één defect nooit dubbel opduikt. De stream-level functie ValidatePdfACompliance voert alleen de byte-level scan uit

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;

Wat NormalizePdfAMetadata herschrijft, en waar hij stopt

TPdf.NormalizePdfAMetadata behandelt het Info-dictionary als de bron van de waarheid en herschrijft alleen de XMP-eigenschappen waarvan het veld in MismatchFields belandde. Title en Subject gaan naar het x-default-item terwijl andere taalalternatieven intact blijven. Author wordt een rdf:Seq met één item. Een Info-datum met zone wordt als canonieke UTC-XMP-datum met Z-achtervoegsel geschreven, en een zonloze houdt zijn lokale onderdelen. Onbekende namespaces, ongerelateerde eigenschappen en XMP-eigenschappen voor afwezige Info-sleutels worden met rust gelaten, en een bestaande prefixbinding wordt hergebruikt wanneer een eigenschap wordt teruggeschreven

Wat NormalizePdfAMetadata herschrijft bij het repareren van PDF/A-metadata in Delphi met PDFium Component: Info is de bron van de waarheid, alleen MismatchFields-entries worden teruggeschreven als x-default Alt-tekst, een Seq met één item of een canonieke UTC-datum, terwijl onbekende namespaces, ongerelateerde eigenschappen en eigenschappen van afwezige sleutels onaangetast overleven
De reparatie weigert een ontbrekend XMP-pakket en een misvormde Info-datum, en standaard ook een ondertekend document, want een volledige PDF/A-metadataset bouwen is de taak van SaveAsPdfA, niet van een gerichte equivalentiefix

Info autoritatief maken heeft een prijs die op echte bestanden opduikt. Een dc:creator-Seq die "Finance" en "Controlling" als twee mensen opsomt, wordt vervangen door één item met Finance; Controlling, en een dc:title in de verkeerde container wordt herbouwd als verse Alt met alleen x-default, dus wat de oude container bevatte is weg. Dat verlies is hier acceptabel. Table 1 staat precies één creator-item toe, de Info-tekenreeks bevat de volledige lijst al in één stuk, en het originele pakket ligt nog steeds in het bronbestand als iemand de rijkere lijst nodig heeft

De weigeringen zijn bewust. Zonder XMP-pakket gooit de methode EPdfError: een volledige PDF/A-identificatie- en metadataset bouwen is de taak van SaveAsPdfA, behandeld in PDF/A-archiefbestanden maken met PDFium Component. Een onleesbaar pakket of een misvormde Info-datum gooit EPdfXmpError voordat er iets wordt geschreven, want een ogend plausibele verkeerde datum in XMP is erger dan geen uitvoer. Ondertekende documenten gooien EPdfError tenzij de beller AllowSignedDocument = True doorgeeft; de uitvoer is een volledige opslag met de nieuwe metadata als incrementele update toegevoegd, dus de ondertekende bytes worden niet ongewijzigd overgenomen

Dat opslagpad heeft nog één gevolg. NormalizePdfAMetadata schrijft een nieuw bestand zelfs als elk veld al matcht, want niets synchroniseren eindigt nog steeds in een opslag. Roep eerst InspectPdfAMetadata aan en sla equivalente bestanden over; een nachtelijke job die duizenden ongewijzigde archieven herschrijft, koopt niets anders dan nieuwe bestandstijdstempels. De bestands-overload schrijft via een tijdelijk bestand en vervangt het doel atomair. Het bronbestand blijft geopend zolang het document geladen is, geef de uitvoer dus een ander pad

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;                      // al consistent, laat het bestand met rust
    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      // misvormde Info-datum of onleesbaar pakket
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
      on E: EPdfError do         // ondertekende invoer zonder AllowSignedDocument
        raise Exception.CreateFmt('Skipped %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

Equivalentie is één clausule van ISO 19005-1. Een genormaliseerd bestand moet nog steeds door fonts, kleur, transparantie en elke andere PDF/A-controle zien te komen, dus ValidatePdfA blijft de poort voordat een document naar langetermijnopslag vertrekt

Een pakket vergelijken zonder geladen document

ComparePdfAInfoAndXmp en SynchronizePdfAInfoToXmp zijn kale functies in FPdfPdfa die op een TPdfXmpPacket werken zonder open PDF, wat unit-tests en pipelines past die XMP vanuit een sjabloon vullen voordat het document bestaat. SynchronizePdfAInfoToXmp geeft de set velden terug die hij herschreef en gooit EPdfXmpError als hij een nil-pakket krijgt

De enige valkuil is Present. Een record dat met Default(TPdfAMetadataValues) is geïnitialiseerd heeft een lege set, meldt dan elk veld als pamsNotRequired, en de vergelijking slaagt ongeacht welke waarden u invulde. De tekenreeksvelden van het record dragen waarden; alleen Present beslist welke daarvan de standaard werkelijk eist

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 beslist welke velden verplicht zijn; alleen waarden worden genegeerd
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

  Packet := TPdfXmpPacket.Parse(TFile.ReadAllText(TemplateFile, TEncoding.UTF8));
  try
    Changed := SynchronizePdfAInfoToXmp(Info, Packet);
    // een afwijkende xmp:ModifyDate wordt 2026-08-27T01:36:59Z,
    // een afwijkende dc:creator wordt een rdf:Seq met één item
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

Hetzelfde sjabloon daarna door ComparePdfAInfoAndXmp halen zou een lege MismatchFields moeten opleveren, wat een goedkope assertie is voor een buildstap die sjablonen regenereert

Archieven die andere systemen blijven bewerken drijven één veld per keer af, en het rapport per veld toont welk veld verschoof. InspectPdfAMetadata, NormalizePdfAMetadata en de rest van de PDF/A-validatie- en conversietooling zitten in PDFium Component for Delphi and C++Builder