Technischer Artikel

PDF/A-Info- und XMP-Metadaten-Äquivalenz in Delphi

Ein Info-Dictionary mit /ModDate (D:20260827093659+08'00') und ein XMP-Paket mit xmp:ModifyDate als 2026-08-27T01:36:59Z beschreiben dieselbe Sekunde, und ein PDF/A-Validator hat jeden Grund, dieses Paar zu akzeptieren. Kopiert man den Subject-String des Dokuments aber in dc:subject, failt das Paket, selbst wenn der Text Zeichen für Zeichen passt. ISO 19005-1 in §6.7.3 verlangt äquivalente Werte, gehalten in bestimmten XMP-Properties, in bestimmten RDF-Formen

Der Bug-Report, der dieses Thema üblicherweise eröffnet, wirkt harmlos. Ein Dokumentenmanagementsystem stempelt bei jedem inkrementellen Speichern ein frisches /ModDate ins Info-Dictionary und lässt das XMP-Paket in Ruhe. Monate später flaggt ein Archiv-Audit Tausende Dateien. Beide Daten sind da; sie haben bei der ersten Änderung aufgehört, sich zu einigen, und eine Prüfung, die nur nach der Existenz der Property schaut, hat es nie gesehen. Titel-Änderungen über eine Info-only-API failen genauso, und ein Author-String wie Finance; Controlling, den irgendein Tool in zwei dc:creator-Items aufgespalten hat, ebenso

Was ISO 19005-1 von Info und XMP verlangt

Tabelle 1 von ISO 19005-1 paart acht Info-Keys mit XMP-Properties, und §6.7.3 sagt: Sobald ein Info-Key vorhanden ist, muss die gemappte XMP-Property einen äquivalenten Wert tragen. Ein fehlender Key verlangt nichts. PDF/A-2 und PDF/A-3 behalten dieselbe Äquivalenzforderung für dieselben acht Keys. PDF/A-4 geht den anderen Weg und schränkt das Info-Dictionary selbst ein: Es darf nur neben einer Catalog-/PieceInfo auftauchen und dann nichts außer ModDate halten, was TPdf.ValidatePdfA über pvaiPdfA4InfoWithoutPieceInfo und pvaiPdfA4InfoInvalid meldet

In PDFium Component wohnt der Vergleich in ComparePdfAInfoAndXmp in FPdfPdfa.pas. Er schlägt jede Property über den Namespace-URI nach, also wird ein Paket, das http://purl.org/dc/elements/1.1/ an ein ungewöhnliches Präfix bindet, exakt so gelesen wie eines mit dc. Die gemappten Formen sind:

  • Title → dc:title und Subject → dc:description: eine rdf:Alt-Sprachalternative, verglichen nur gegen ihr x-default-Item; der Sprachtag wird case-insensitive gematcht, ein Alt ohne x-default zählt als fehlend
  • Author → dc:creator: ein rdf:Seq mit exakt einem Text-Item, das den ganzen Info-String hält, sodass eine semikolongetrennte Autorenliste ein einzelner Eintrag bleibt
  • Keywords → pdf:Keywords und Producer → pdf:Producer (Namespace http://ns.adobe.com/pdf/1.3/): schlichte Text-Properties
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (Namespace http://ns.adobe.com/xap/1.0/): schlichte Text-Properties
Die acht gemappten Paare, die ComparePdfAInfoAndXmp auf PDF/A-Metadaten-Äquivalenz in Delphi prüft: Title und Subject brauchen ein rdf:Alt mit einem x-default-Item, Author ein ein-elementiges rdf:Seq, Keywords, Producer, Creator und die beiden Daten sind schlichter Text, jeweils nachgeschlagen über den XMP-Namespace-URI statt über das Präfix
Sobald ein Info-Key vorhanden ist, verlangt ISO 19005-1 von der gemappten XMP-Property einen äquivalenten Wert in der geforderten RDF-Form, ein Wert im falschen Container failt also trotzdem

Die Subject-Zeile ist die Stelle, an der handgebaute Templates am häufigsten danebengehen. Dublin Cores dc:subject ist eine ungeordnete Sammlung (Bag) von Schlüsselwörtern, und der Name lädt dazu ein, den Info-Subject dort abzulegen. Der Vergleich sucht dc:description, findet nichts und meldet Subject als pamsXmpMissing, während der "richtige" Text eine Property weiter sitzt. Drei Zeilen in den korrekten Formen sehen so aus:

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

Warum scheitert ein korrekter String trotzdem am Vergleich?

Ein korrekter String scheitert, wenn er im falschen Container sitzt oder um einen einzigen Codepunkt abweicht. Textwerte werden als exakte Unicode-Codepunkt-Sequenzen verglichen, ohne Trimming, Case-Folding oder Normalisierung. Ein angehängtes Leerzeichen ist ein Mismatch. Ebenso ein vorkomponiertes é auf der einen Seite gegen ein zerlegtes e plus kombinierenden Akut auf der anderen, obwohl beide auf dem Bildschirm meist identisch aussehen

Formfehler bekommen einen eigenen Zustand. Ein dc:title als schlichter Text, ein dc:creator als rdf:Bag deklariert oder ein dc:creator-Seq mit zwei Items erzeugen jeweils pamsXmpTypeMismatch statt pamsValueMismatch, sodass ein Report "das Tool hat die falsche RDF-Struktur gewählt" von "das Tool hat anderen Text geschrieben" unterscheiden kann. Die beiden Probleme kommen meist von unterschiedlichen Stellen in einer Pipeline, und das falsche zu fixen verschwendet einen Nachmittag

Die Info-Seite stammt immer aus PDFiums eigener Dekodierung von PDFDocEncoding- und UTF-16BE-Strings über FPDF_GetMetaText, derselbe Pfad, den TPdf.GetMetadata nutzt, also wird ein String mit einer Byte-Order-Mark oder einer escapeten Oktalsequenz so gelesen, wie die Engine ihn liest. Die XMP-Seite ist nur so sauber wie die Bytes, die sie hervorgebracht haben. Ein mit der falschen Codepage zusammengesetztes Paket kann Mojibake tragen, die kein Vergleich vergibt, weshalb die Codepage-Fallen, die XMP-Metadaten unter Free Pascal korrumpieren auf dieselbe Checkliste gehören

Wann sind ein PDF-Datum und ein XMP-Datum gleich?

Ein PDF-Datum und ein XMP-Datum sind gleich, wenn sie denselben Zeitpunkt auf die Sekunde beschreiben, mit derselben Zeitzonen-Information auf beiden Seiten. Beide Parser akzeptieren legale reduzierte Präzision, D:2026 und 2026 bedeuten also beide den 1. Januar 2026, Mitternacht. Tragen beide Werte eine Zone, wird jede zuerst nach UTC konvertiert: D:20260827093659+08'00' entspricht 2026-08-27T01:36:59Z. Trägt keiner eine Zone, werden die lokalen Komponenten verglichen, wie sie dastehen

Hat nur eine Seite eine Zone, ist das Ergebnis pamsValueMismatch. Dem zonelosen Wert einen Offset zu erfinden wäre Raten, und ein Rateergebnis, das zufällig zur Zeitzone des Servers passt, würde auf einer Maschine durchgehen und auf einer anderen scheitern. Eine Sekundenbruchteil ungleich null wie .250 in XMP erzwingt ebenfalls einen Mismatch, denn ein PDF-Datum hat keine Möglichkeit, sie auszudrücken; .000 wird akzeptiert. Werte, die sich gar nicht parsen lassen, werden als pamsInvalidInfoDate oder pamsInvalidXmpDate gemeldet

Wie PDFium Component in Delphi entscheidet, dass ein PDF-Info-Datum und ein XMP-Datum gleich sind: Zwei Zonen werden nach UTC konvertiert und als Zeitpunkte verglichen, zwei zonelose Werte werden verglichen, wie sie dastehen, eine Zone allein ist ein pamsValueMismatch, eine von null verschiedene Sekundenbruchteil lässt sich nicht ausdrücken, und nicht parsebare Werte werden separat gemeldet
Gleichheit heißt derselbe Zeitpunkt auf die Sekunde mit derselben Zeitzonen-Information auf beiden Seiten, ein erfundener Offset oder eine weggerundete Sekundenbruchteil würde also einen echten Widerspruch verstecken

Die beiden Datums-Grammatiken sind nicht austauschbar, und die Parser halten jede Seite an ihre eigene. Auf der PDF-Seite sind die Apostrophe um die Offset-Minuten optional, und Z wird akzeptiert. Auf der XMP-Seite braucht der Offset einen Doppelpunkt: 2026-08-27T09:36:59+08:00 parst, während +0800 ein pamsInvalidXmpDate ist. XMP-Zeit braucht zudem mindestens Stunden und Minuten, sobald ein T auftaucht, und Offsets über 14 Stunden werden auf beiden Seiten zurückgewiesen. Umgebender Leerraum wird vor dem Parsen eines Datums getrimmt, eine Höflichkeit, die Textfelder nicht bekommen

Presence hat ihre eigene Regel. TPdfAMetadataValues.Present ist ein Set, gefüllt beim Durchlaufen des /Info-Dictionarys des aktiven Trailers, und es hält "Key fehlt" von "Key vorhanden mit leerem String" getrennt. Ein fehlender Key ergibt pamsNotRequired. /Title () ist vorhanden, das Paket muss also ebenfalls einen leeren x-default-Titel tragen

Den Feld-für-Feld-Report lesen, bevor man die Datei anfasst

TPdf.InspectPdfAMetadata liefert einen TPdfAMetadataReport mit je einem TPdfAMetadataComparison pro Feld. Jeder Eintrag hält den Info-Wert, den XMP-Wert und einen TPdfAMetadataState, sodass sich ein Fehlschlag Feld für Feld erklären lässt. MismatchFields sammelt die failende Menge, HasXmpPacket verrät, ob ein Paket gefunden wurde, und XmpParseError trägt die Parser-Meldung, wenn ein Paket existiert, aber nicht lesbar ist. In diesem letzten Fall meldet jedes vorhandene Feld pamsXmpMissing, denn aus dem Paket ließ sich nichts lesen

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 faltet denselben Vergleich in sein Issue-Set. Der Byte-Level-Scan, beschrieben in PDF/A-Preflight-Validierung mit PDFium Component, meldet pvaiMissingXmpDates, wenn dem Paket beide Datums-Properties fehlen, und pvaiInfoXmpDateMismatch, wenn Info CreationDate oder ModDate trägt, das Paket aber keine passende Datums-Property hat. Darüber hinaus fährt ValidatePdfA den vollständigen Wertvergleich und nimmt pvaiInfoXmpValueMismatch auf, sobald ein Paket existiert, aber dem Info widerspricht, ein unlesbares Paket eingerechnet. Ein Dokument ohne jegliches Paket wird nur als pvaiMissingXmpMetadata gemeldet, damit ein Defekt nie doppelt auftaucht. Die Stream-Level-Funktion ValidatePdfACompliance macht allein den Byte-Level-Scan

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;

Was NormalizePdfAMetadata neu schreibt, und wo es aufhört

TPdf.NormalizePdfAMetadata behandelt das Info-Dictionary als die Quelle der Wahrheit und schreibt nur die XMP-Properties neu, deren Feld in MismatchFields gelandet ist. Title und Subject wandern ins x-default-Item, während andere Sprachalternativen intakt bleiben. Author wird zu einem ein-elementigen rdf:Seq. Ein Info-Datum mit Zone wird als kanonisches UTC-XMP-Datum mit Z-Suffix geschrieben, eines ohne Zone behält seine lokalen Komponenten. Unbekannte Namespaces, unverbundene Properties und XMP-Properties für fehlende Info-Keys bleiben unangetastet, und beim Zurückschreiben einer Property wird eine bestehende Präfix-Bindung wiederverwendet

Was NormalizePdfAMetadata bei der Reparatur von PDF/A-Metadaten in Delphi mit PDFium Component neu schreibt: Info ist die Quelle der Wahrheit, nur MismatchFields-Einträge werden zurückgeschrieben als x-default-Alt-Text, ein ein-elementiges Seq oder ein kanonisches UTC-Datum, während unbekannte Namespaces, unrelated Properties und Properties fehlender Keys unangetastet überleben
Die Reparatur verweigert ein fehlendes XMP-Paket und ein fehlerhaftes Info-Datum, standardmäßig auch ein signiertes Dokument, denn einen vollständigen PDF/A-Metadatensatz zu bauen ist Sache von SaveAsPdfA, nicht eines gezielten Äquivalenz-Fixes

Info zur Autorität zu machen hat Kosten, die auf echten Dateien auftauchen. Ein dc:creator-Seq, das "Finance" und "Controlling" als zwei Personen listet, wird durch ein einziges Item mit Finance; Controlling ersetzt, und ein dc:title im falschen Container wird als frisches Alt neu gebaut, das nur x-default hält, also ist weg, was der alte Container hielt. Dieser Verlust ist hier akzeptabel. Tabelle 1 erlaubt genau ein Creator-Item, der Info-String hält die volle Liste bereits am Stück, und das Originalpaket liegt weiterhin in der Quelldatei, falls jemand die reichhaltigere Liste braucht

Die Verweigerungen sind Absicht. Ohne XMP-Paket wirft die Methode EPdfError: Einen vollständigen PDF/A-Identifikations- und Metadatensatz zu bauen ist Sache von SaveAsPdfA, behandelt in PDF/A-Archivdateien erzeugen mit PDFium Component. Ein unlesbares Paket oder ein fehlerhaftes Info-Datum wirft EPdfXmpError, bevor irgendetwas geschrieben wird, denn ein plausibel aussehendes falsches Datum in XMP ist schlimmer als keine Ausgabe. Signierte Dokumente werfen EPdfError, sofern der Aufrufer nicht AllowSignedDocument = True übergibt; die Ausgabe ist ein vollständiges Speichern, bei dem die neuen Metadaten als inkrementelles Update angehängt werden, die signierten Bytes werden also nicht unverändert übernommen

Dieser Speicherpfad hat eine weitere Konsequenz. NormalizePdfAMetadata schreibt eine neue Datei, selbst wenn jedes Feld bereits passt, denn nichts zu synchronisieren endet trotzdem in einem Speichern. Rufen Sie zuerst InspectPdfAMetadata auf und überspringen Sie äquivalente Dateien; ein nächtlicher Job, der Tausende unveränderter Archive neu schreibt, bringt nichts ein außer neuen Datei-Zeitstempeln. Der Datei-Overload schreibt über eine temporäre Datei und ersetzt das Ziel atomar. Die Quelldatei bleibt geöffnet, solange das Dokument geladen ist, geben Sie der Ausgabe also einen anderen Pfad

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;                      // bereits konsistent, Datei in Ruhe lassen
    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      // fehlerhaftes Info-Datum oder unlesbares Paket
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
      on E: EPdfError do         // signierte Eingabe ohne AllowSignedDocument
        raise Exception.CreateFmt('Skipped %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

Äquivalenz ist eine Klausel von ISO 19005-1. Eine normalisierte Datei muss weiterhin Schriften, Farbe, Transparenz und jede andere PDF/A-Prüfung bestehen, also bleibt ValidatePdfA das Gate, bevor ein Dokument in die Langzeitarchivierung geht

Ein Paket vergleichen, ohne ein Dokument zu laden

ComparePdfAInfoAndXmp und SynchronizePdfAInfoToXmp sind schlichte Funktionen in FPdfPdfa, die auf einem TPdfXmpPacket arbeiten, ohne dass ein PDF geöffnet ist — gut für Unit-Tests und Pipelines, die XMP aus einem Template befüllen, bevor das Dokument existiert. SynchronizePdfAInfoToXmp gibt die Menge der neu geschriebenen Felder zurück und wirft EPdfXmpError, wenn man ihr ein nil-Paket gibt

Die eine Falle ist Present. Ein mit Default(TPdfAMetadataValues) initialisierter Record hat ein leeres Set, jedes Feld meldet dann pamsNotRequired, und der Vergleich geht durch, ganz gleich, welche Werte Sie eingetragen haben. Die String-Felder des Records tragen Werte; nur Present entscheidet, welche davon die Norm tatsächlich verlangt

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 entscheidet, welche Felder Pflicht sind; Werte allein werden ignoriert
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

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

Dieselbe Vorlage danach durch ComparePdfAInfoAndXmp zu schicken sollte ein leeres MismatchFields zurückgeben, was eine billige Assertion für einen Build-Schritt ist, der Templates neu erzeugt

Archive, die andere Systeme weiter bearbeiten, driften ein Feld nach dem anderen, und der Feld-für-Feld-Report zeigt, welches sich bewegt hat. InspectPdfAMetadata, NormalizePdfAMetadata und der Rest des PDF/A-Validierungs- und Konvertierungswerkzeugs sind Teil der PDFium Component für Delphi und C++Builder