Technical Article

PDF/A Info and XMP Metadata Equivalence in Delphi

PDFium Component checks PDF/A Info-to-XMP metadata equivalence with TPdf.InspectPdfAMetadata and repairs it with TPdf.NormalizePdfAMetadata. ISO 19005-1 (as corrected by Cor.1) requires each of the eight mapped Info entries, Title through ModDate, to carry the same value as its XMP property, not merely to exist; the check reads the correct RDF shape, matches namespaces by URI and compares dates as instants

The bug report that usually starts this conversation looks harmless. A document management system stamps a new /ModDate into the Info dictionary on every incremental save, leaves the XMP packet alone, and six months later an archive audit flags thousands of files as non-conformant. Both dates are there. They just stopped agreeing on the first edit, and a presence check never noticed. Title edits made through an Info-only API, and an Author string like Finance; Controlling that some tool split into two dc:creator items, fail the same way

Why does PDF/A reject metadata that exists in both places?

PDF/A rejects it because ISO 19005-1 §6.7.3 is a value rule, not a presence rule: Table 1 maps eight Info keys to XMP properties, and once an Info key is present, the mapped XMP property must hold an equivalent value. The byte-level scanner described in PDF/A preflight validation with PDFium Component only confirms that xmp:CreateDate and xmp:ModifyDate exist (pvaiMissingXmpDates). Since v3.72.0, TPdf.ValidatePdfA additionally runs the full value comparison and adds pvaiInfoXmpValueMismatch to the issue set when an XMP packet exists but disagrees with Info (a packet that cannot be parsed counts as disagreeing). A missing packet stays reported as pvaiMissingXmpMetadata, so the two issues never double-count the same defect

What RDF shape does each mapped XMP property need?

Each of the eight mappings has a fixed XMP type, and a correct value in the wrong container still fails. ComparePdfAInfoAndXmp in FPdfPdfa.pas looks properties 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 using dc. The required 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); 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
<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>

Text values are compared as exact Unicode code-point sequences, with no trimming, case folding or normalization. A trailing space, or a precomposed é on one side and a decomposed e plus combining accent on the other, is a genuine mismatch. The Info side always comes from PDFium's own decoding of PDFDocEncoding and UTF-16 text through FPDF_GetMetaText, which keeps the library from re-implementing string decoding and getting it subtly wrong; the XMP side is only as clean as the bytes that produced it, which is why the codepage traps that corrupt XMP metadata under Free Pascal matter here too

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, 00:00:00. When both values carry a zone, they are converted to UTC before comparison: 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, because inventing an offset would be a guess. A non-zero fractional second such as .250 in XMP also forces a mismatch, since a PDF date has no way to express it and silently rounding it away would hide a real disagreement; .000 is accepted. Unparseable values are reported separately 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

Presence has its own rule. 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 and demands nothing from XMP; /Title () is present, so the XMP packet must carry an empty x-default title as well

How do you inspect Info and XMP metadata before saving?

TPdf.InspectPdfAMetadata returns a TPdfAMetadataReport with one TPdfAMetadataComparison per field, each holding the Info value, the XMP value and a TPdfAMetadataState, so a failure can be explained without reverse-engineering a single validation flag. MismatchFields summarizes the failing set, HasXmpPacket tells you whether a packet was found, and XmpParseError carries the parser message when the packet exists but cannot be read

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;

What does NormalizePdfAMetadata change, and what does it refuse?

TPdf.NormalizePdfAMetadata treats the Info dictionary as the source of truth and rewrites only the XMP properties whose field landed in MismatchFields; everything else in the packet survives. Title and Subject are written into the x-default item while other language alternatives stay intact, Author becomes a one-item rdf:Seq, unknown namespaces and unrelated properties are preserved, and XMP properties for absent Info keys are left untouched. A zoned Info date is written as a canonical UTC XMP date with a Z suffix; a zone-less one keeps its local components. The file overload saves through a temporary file and an atomic replace, and the XMP update itself is appended as an incremental update

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, while unknown namespaces, unrelated properties and absent-key properties survive untouched
The repair refuses a missing XMP packet, a malformed Info date and signed documents, because building a complete PDF/A metadata set is the job of SaveAsPdfA, not of a targeted equivalence fix

The refusals are deliberate. With no XMP packet the method raises EPdfError, because building a complete PDF/A identification and metadata set is the job of SaveAsPdfA, covered in creating PDF/A archival files with PDFium Component. A malformed Info date raises EPdfXmpError rather than writing a plausible-looking wrong value, and nothing is saved. Signed documents are rejected unless the caller passes AllowSignedDocument = True. Equivalence is one rule of ISO 19005-1, too, so a normalized file is not automatically a conformant one

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]);
    end;
  finally
    Pdf.Free;
  end;
end;

Running the comparison on your own XMP packet

ComparePdfAInfoAndXmp and SynchronizePdfAInfoToXmp are plain functions in FPdfPdfa that work on a TPdfXmpPacket with no document loaded, which suits unit tests and pipelines that assemble XMP from a template. The one trap is Present: a record initialized with Default(TPdfAMetadataValues) has an empty set, every field then reports pamsNotRequired, and the comparison passes vacuously no matter what values you filled in

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);
    // xmp:ModifyDate is now 2026-08-27T01:36:59Z, dc:creator a one-item rdf:Seq
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

If your pipeline archives documents that other systems keep editing, pair a nightly InspectPdfAMetadata sweep with NormalizePdfAMetadata for the files that drift, and keep ValidatePdfA as the gate before anything leaves for long-term storage. The typed report, the repair path and the rest of the PDF/A tooling ship in the PDFium Component for Delphi and C++Builder