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:titleand Subject →dc:description: anrdf:Altlanguage alternative, compared against itsx-defaultitem only; the language tag is matched case-insensitively, and an Alt withoutx-defaultcounts as missing - Author →
dc:creator: anrdf:Seqwith exactly one text item holding the whole Info string, so a semicolon-separated author list stays a single entry - Keywords →
pdf:Keywordsand Producer →pdf:Producer(namespacehttp://ns.adobe.com/pdf/1.3/): simple text properties - Creator →
xmp:CreatorTool, CreationDate →xmp:CreateDate, ModDate →xmp:ModifyDate(namespacehttp://ns.adobe.com/xap/1.0/): simple text properties
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
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
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