Technical Article

PDF/A Info and XMP Metadata Equivalence in Delphi

An Info dictionary holding /ModDate (D:20260827093659+08'00') and an XMP packet holding xmp:ModifyDate as 2026-08-27T01:36:59Z describe the same second, and a PDF/A validator has every reason to accept that pair. Copy the document's Subject string into dc:subject, though, and the packet fails even when the text matches character for character. ISO 19005-1 §6.7.3 asks for equivalent values, held in specific XMP properties, in specific RDF shapes

The bug report that usually opens this topic looks harmless. A document management system stamps a fresh /ModDate into the Info dictionary on every incremental save and leaves the XMP packet alone. Months later an archive audit flags thousands of files. Both dates are there; they stopped agreeing on the first edit, and a check that only looks for the property's existence never saw it. Title edits made through an Info-only API fail the same way, and so does an Author string like Finance; Controlling that some tool split into two dc:creator items

What ISO 19005-1 requires of Info and XMP

Table 1 of ISO 19005-1 pairs eight Info keys with XMP properties, and §6.7.3 says that once an Info key is present, the mapped XMP property must carry an equivalent value. An absent key demands nothing. PDF/A-2 and PDF/A-3 keep the same equivalence requirement for the same eight keys. PDF/A-4 goes the other way and restricts the Info dictionary itself: it may appear only alongside a catalog /PieceInfo and then hold nothing but ModDate, which TPdf.ValidatePdfA reports through pvaiPdfA4InfoWithoutPieceInfo and pvaiPdfA4InfoInvalid

In PDFium Component the comparison lives in ComparePdfAInfoAndXmp in FPdfPdfa.pas. It looks every property up by namespace URI, so a packet that binds http://purl.org/dc/elements/1.1/ to an unusual prefix is read exactly like one that uses dc. The mapped shapes are:

  • Title → dc:title and Subject → dc:description: an rdf:Alt language alternative, compared against its x-default item only; the language tag is matched case-insensitively, and an Alt without x-default counts as missing
  • Author → dc:creator: an rdf:Seq with exactly one text item holding the whole Info string, so a semicolon-separated author list stays a single entry
  • Keywords → pdf:Keywords and Producer → pdf:Producer (namespace http://ns.adobe.com/pdf/1.3/): simple text properties
  • Creator → xmp:CreatorTool, CreationDate → xmp:CreateDate, ModDate → xmp:ModifyDate (namespace http://ns.adobe.com/xap/1.0/): simple text properties
The eight mapped pairs that ComparePdfAInfoAndXmp checks for PDF/A metadata equivalence in Delphi: Title and Subject need an rdf:Alt with an x-default item, Author a one-item rdf:Seq, Keywords Producer Creator and the two dates are simple text, each looked up by XMP namespace URI rather than prefix
Once an Info key is present, ISO 19005-1 requires the mapped XMP property to hold an equivalent value in the required RDF shape, so a value in the wrong container still fails

The Subject row is where hand-built templates go wrong most often. Dublin Core's dc:subject is an unordered bag of keywords, and the name invites people to drop the Info Subject into it. The comparison looks for dc:description, finds nothing, and reports Subject as pamsXmpMissing whilst the "right" text sits one property away. Three lines in the correct shapes look like this:

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

Why does a correct string still fail the comparison?

A correct string fails when it sits in the wrong container or differs by a single code point. Text values are compared as exact Unicode code-point sequences, with no trimming, case folding or normalisation. A trailing space is a mismatch. So is a precomposed é on one side against a decomposed e plus combining acute accent on the other, even though the two usually render identically on screen

Shape errors get their own state. A dc:title written as plain text, a dc:creator declared as rdf:Bag, or a dc:creator Seq with two items all produce pamsXmpTypeMismatch rather than pamsValueMismatch, so a report can tell "the tool picked the wrong RDF structure" apart from "the tool wrote different text". The two problems usually come from different places in a pipeline, and fixing the wrong one wastes an afternoon

The Info side always comes from PDFium's own decoding of PDFDocEncoding and UTF-16BE strings through FPDF_GetMetaText, the same path TPdf.GetMetadata uses, so a string with a byte-order mark or an escaped octal sequence is read the way the engine reads it. The XMP side is only as clean as the bytes that produced it. A packet assembled with the wrong code page can carry mojibake that no comparison will forgive, which is why the codepage traps that corrupt XMP metadata under Free Pascal belong on the same checklist

When are a PDF date and an XMP date equal?

A PDF date and an XMP date are equal when they describe the same instant to the second with the same time-zone knowledge on both sides. Both parsers accept legal reduced precision, so D:2026 and 2026 both mean 1 January 2026 at midnight. When both values carry a zone, each is converted to UTC first: D:20260827093659+08'00' equals 2026-08-27T01:36:59Z. When neither carries a zone, the local components are compared as written

When only one side has a zone, the result is pamsValueMismatch. Inventing an offset for the zone-less value would be a guess, and a guess that happens to match the server's time zone would pass on one machine and fail on another. A non-zero fractional second such as .250 in XMP also forces a mismatch because a PDF date has no way to express it; .000 is accepted. Values that do not parse at all are reported as pamsInvalidInfoDate or pamsInvalidXmpDate

How PDFium Component decides that a PDF Info date and an XMP date are equal in Delphi: two zones convert to UTC and compare instants, two zone-less values compare as written, one zone alone is a pamsValueMismatch, a non-zero fractional second cannot be expressed, and unparseable values are reported separately
Equality means the same instant to the second with the same time-zone knowledge on both sides, so inventing an offset or rounding away a fractional second would hide a real disagreement

The two date grammars are not interchangeable, and the parsers hold each side to its own. On the PDF side, the apostrophes around the offset minutes are optional and Z is accepted. On the XMP side the offset needs a colon: 2026-08-27T09:36:59+08:00 parses, whilst +0800 is pamsInvalidXmpDate. XMP time also needs at least hours and minutes once a T appears, and offsets beyond 14 hours are rejected on both sides. Surrounding whitespace is trimmed before a date is parsed, a courtesy that text fields do not get

Presence has a rule of its own. TPdfAMetadataValues.Present is a set filled by walking the active trailer's /Info dictionary, and it keeps "key absent" apart from "key present with an empty string". An absent key yields pamsNotRequired. /Title () is present, so the packet must carry an empty x-default title as well

Reading the per-field report before touching the file

TPdf.InspectPdfAMetadata returns a TPdfAMetadataReport with one TPdfAMetadataComparison per field. Each entry holds the Info value, the XMP value and a TPdfAMetadataState, so a failure can be explained field by field. MismatchFields collects the failing set, HasXmpPacket says whether a packet was found, and XmpParseError carries the parser message when a packet exists but cannot be read. In that last case every present field reports pamsXmpMissing, because nothing could be read from the 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 folds the same comparison into its issue set. The byte-level scan, described in PDF/A preflight validation with PDFium Component, reports pvaiMissingXmpDates when the packet lacks both date properties and pvaiInfoXmpDateMismatch when Info carries CreationDate or ModDate but the packet has no matching date property. On top of that, ValidatePdfA runs the full value comparison and adds pvaiInfoXmpValueMismatch whenever a packet exists but disagrees with Info, an unreadable packet included. A document with no packet at all is reported as pvaiMissingXmpMetadata only, so one defect never shows up twice. The stream-level ValidatePdfACompliance function performs the byte-level scan alone

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;

What NormalizePdfAMetadata rewrites, and where it stops

TPdf.NormalizePdfAMetadata treats the Info dictionary as the source of truth and rewrites only the XMP properties whose field landed in MismatchFields. Title and Subject go into the x-default item whilst other language alternatives stay intact. Author becomes a one-item rdf:Seq. A zoned Info date is written as a canonical UTC XMP date with a Z suffix, and a zone-less one keeps its local components. Unknown namespaces, unrelated properties and XMP properties for absent Info keys are left alone, and an existing prefix binding is reused when a property is written back

What NormalizePdfAMetadata rewrites when repairing PDF/A metadata in Delphi with PDFium Component: Info is the source of truth, only MismatchFields entries are written back as x-default Alt text, a one-item Seq or a canonical UTC date, whilst unknown namespaces, unrelated properties and absent-key properties survive untouched
The repair refuses a missing XMP packet and a malformed Info date, and by default a signed document, because building a complete PDF/A metadata set is the job of SaveAsPdfA, not of a targeted equivalence fix

Making Info authoritative has a cost that shows up on real files. A dc:creator Seq listing "Finance" and "Controlling" as two people is replaced by one item reading Finance; Controlling, and a dc:title stored in the wrong container is rebuilt as a fresh Alt holding only x-default, so whatever the old container held is gone. That loss is acceptable here. Table 1 allows exactly one creator item, the Info string already holds the full list in one piece, and the original packet is still in the source file if anyone needs the richer list

The refusals are deliberate. With no XMP packet the method raises EPdfError: building a complete PDF/A identification and metadata set is the job of SaveAsPdfA, covered in creating PDF/A archival files with PDFium Component. An unreadable packet or a malformed Info date raises EPdfXmpError before anything is written, because a plausible-looking wrong date in XMP is worse than no output. Signed documents raise EPdfError unless the caller passes AllowSignedDocument = True; the output is a full save with the new metadata appended as an incremental update, so the signed bytes are not carried over unchanged

That save path has one more consequence. NormalizePdfAMetadata writes a new file even when every field already matches, since synchronising nothing still ends in a save. Call InspectPdfAMetadata first and skip equivalent files; a nightly job that rewrites thousands of unchanged archives buys nothing but new file timestamps. The file overload writes through a temporary file and replaces the target atomically. The source file stays open for as long as the document is loaded, so give the output a different path

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;                      // already consistent, leave the file alone
    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      // malformed Info date or unreadable packet
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
      on E: EPdfError do         // signed input without AllowSignedDocument
        raise Exception.CreateFmt('Skipped %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

Equivalence is one clause of ISO 19005-1. A normalised file still has to pass fonts, colour, transparency and every other PDF/A check, so ValidatePdfA remains the gate before a document leaves for long-term storage

Comparing a packet with no document loaded

ComparePdfAInfoAndXmp and SynchronizePdfAInfoToXmp are plain functions in FPdfPdfa that work on a TPdfXmpPacket with no PDF open, which suits unit tests and pipelines that fill XMP from a template before the document exists. SynchronizePdfAInfoToXmp returns the set of fields it rewrote and raises EPdfXmpError when handed a nil packet

The one trap is Present. A record initialised with Default(TPdfAMetadataValues) has an empty set, every field then reports pamsNotRequired, and the comparison passes no matter what values you filled in. The record's string fields carry values; only Present decides which of them the standard actually demands

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 decides which fields are mandatory; values alone are ignored
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

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

Running the same template through ComparePdfAInfoAndXmp afterwards should return an empty MismatchFields, which makes a cheap assertion for a build step that regenerates templates

Archives that other systems keep editing drift one field at a time, and the per-field report shows which one moved. InspectPdfAMetadata, NormalizePdfAMetadata and the rest of the PDF/A validation and conversion tooling are part of PDFium Component for Delphi and C++Builder