A PDF save that changes nothing can still corrupt document metadata in two ways: when /CreationDate and /ModDate reference the same string object, an automatic ModDate update that writes into that object rewrites both, and when the XMP object is created before the original /Metadata stream is read, a default packet replaces the original. PDF Library for Delphi replaces dictionary references instead of mutating shared objects, and captures the existing packet before lazy XMP initialization
The setting is the least interesting operation a PDF library performs: load a file, save it under a new name, touch nothing in between. Pages render identically before and after, and content stream hashes match. A file can pass every such check and still be wrong in two places that no renderer would ever show you. Both traps sit in the read-modify-write path that every real edit goes through, so any save at all is enough to trigger them, and only a second, independent parser comparing non-visual semantics of the two files reveals them
Why does saving a PDF change its CreationDate?
Because the document information dictionary is allowed to reference one indirect string object from two keys, and a writer that updates the object rather than the key changes both. ISO 32000-1 §7.3.10 lets any dictionary value be an indirect reference, and nothing in §14.3.3 Table 317 says the value under /CreationDate must be a different object from the value under /ModDate. A producer that wrote the same timestamp twice at creation time can, perfectly legally, point both keys at a single 2728 0 R, and real-world files do exactly that
The trigger is the automatic modification date. Unless UserModDate is set, SaveToFile calls SetInfo('ModDate', ...) with the current time before writing, which reaches SetRawInfo. A naive SetRawInfo looks up the object under the key and, if it finds a TPDFString, calls SetTo on it. That is an in-place write to whatever object the key currently resolves to, and when that object is shared, /CreationDate reports the save time too. The document still opens, prints and renders pixel-for-pixel as before, so a visual regression suite passes without a blink
var
Lib: TPDFlib;
Before, After: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('design.pdf', '');
Before := Lib.GetInformation(7); // 7 = CreationDate, 8 = ModDate
Lib.SaveToFile('design-resaved.pdf');
Lib.LoadFromFile('design-resaved.pdf', '');
After := Lib.GetInformation(7);
if Before <> After then
Log('a save that changed nothing rewrote CreationDate');
finally
Lib.Free;
end;
end;
The rule in TPDFDocument.SetRawInfo is small and the principle behind it is general: updating a dictionary entry replaces that entry's reference, never the object it happened to resolve to. The code reads the existing TPDFStringMode so a hex string stays hex and a literal string stays literal, then adds a fresh string from FStructure.NewString(Value, StringMode) under the key. Two other details matter as much. A stream-valued entry is replaced without clearing the old stream first, because SetTo('') on it would empty the value for every other key still pointing at that stream. And the superseded object is not deleted, because the structure owns it and other references may still need it
// Naive: mutate whatever object the key currently resolves to
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Correct: keep the representation, replace only this key's reference
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
A test for this should build the aliasing deliberately rather than rely on a file that happens to share objects: one hex string referenced from both date keys, one direct string shared by /Title and /Subject, one stream shared by /Author and /Keywords. After updating one key of each pair, the other must still read its original value and the updated string must still be hex. The public reference for SetInformation states the guarantee in one sentence: updating an Info field replaces only that field, even when other fields reference the same object
Why does an existing XMP packet get replaced by defaults?
Because of the order of two lines. TPDFDocument.GetMetadata has a fast path: when the XMP field is already assigned, it returns XMP.SaveToString instead of decoding the /Metadata stream from the catalog. A lazy initialization written as XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); reads naturally and is wrong: by the time GetMetadata runs, XMP is assigned, so the "source" being loaded is the serialized default packet of an object created one line earlier. The original packet, with its dc:creator, custom namespaces and any standards identification, never reaches the object and is overwritten on save. The same automatic modification date is enough to trigger it, because SetInfo initializes XMP before it touches the Info dictionary so that xmp:ModifyDate stays in step with /ModDate. Note what this trap hides behind: the Info dictionary comparison from the first one passes, since /Author and /Title in /Info are untouched. Only the XMP tree changed, and only a check that parses and compares that tree notices
// Wrong: GetMetadata now serializes the object created on the previous line
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Right: capture the /Metadata stream first, then create and load
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
PDF Library for Delphi keeps that order in one place. TPDFDocument.EnsureXMP captures Source := GetMetadata before TPDFlibXMP.Create, and every lazy initialization in the document goes through it: SetInfo, SetXMPInformation, GetXMPInformation, the PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR and PDF/UA mode setters, and the metadata repair path. Public entry points such as SetXMPProperty go through EnsureXMP as well, and GetXMPProperty reads through GetDocumentMetadata, so the whole surface shares one initialization order. A single correct copy of a three-line sequence is worth more than ten copies that happen to agree today
Two smaller traps on the same path
The XMP serializer on Windows uses the platform XML writer, which emits an XML declaration that the packet must not carry. The obvious way to strip it, deleting characters until reaching <?xpacket, fails on valid input: ISO 16684-1 §7.3.2 makes the xpacket wrapper optional, and a producer that writes a bare <x:xmpmeta> element is within the standard, so on such a packet that loop deletes the entire, valid document. The serializer locates the closing ?> of the declaration and removes only that. A retention check worth copying runs twice, once with the wrapper and once with it cut away, and asserts that a custom-namespace marker and the original author survive SetInfo, GetMetadata, SaveToString and a reload. The second trap is a preprocessor symbol. NOVCL is set for Free Pascal builds, but the XMP backend is gated by the operating system, not by the framework, since PDFlibXMP.pas defines NO_XMP only when OS_WINDOWS is absent. Guarding Info-to-XMP synchronization in SetInfo with NOVCL would leave a Windows Lazarus build with a working XMP object and a SetInfo that silently skips updating it. The guard is NO_XMP, so a Windows Free Pascal application gets the same synchronization as Delphi
How do you keep the original ModDate on a pass-through save?
Set KeepModDate in TPDFlibSaveOptions and save through SaveToFileOptions. The option sets UserModDate for the duration of the call, and SaveToFile then skips the automatic timestamp, which is also the step that lazily initializes the XMP object. A document whose metadata you never touched, and for which no compliance mode was enabled, keeps both its Info dictionary and its /Metadata stream as loaded. Calling SetInformation(8, ...) has the same effect permanently, because setting the modification date yourself marks it as user-controlled
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // no automatic /ModDate, no lazy XMP init
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Be honest about what this buys you. KeepModDate is the right choice for a pass-through step whose output should describe the same revision as its input, and it is the wrong choice for anything that actually edits content, because §14.3.3 expects /ModDate to reflect the most recent modification. It is also no substitute for correct reference handling, since it only avoids the one write that triggers the automatic update. Replacing references instead of mutating shared objects, and capturing the packet before initialization, are what make an ordinary save safe; the option is what makes a deliberate no-op honest
How do you verify that a save changed nothing but the ModDate?
Not with pixels and not with stream hashes, because both traps leave every page and every content stream byte-identical. The check that catches them is a non-visual semantic snapshot taken by an independent parser, one that shares no code with the library under test, from the source file and from the saved file, followed by a structural comparison. The snapshot covers the Info dictionary with /ModDate excluded, the outline tree with each bookmark resolved to a page number rather than an object number, named destinations and link targets resolved the same way, form field values, attachment bytes as hashes, and the XMP packet parsed as a tree rather than compared as text. Object numbers are deliberately not part of it, since a full rewrite renumbers everything and a comparison keyed on them would report noise
The exclusions are as important as the inclusions. /ModDate, xmp:ModifyDate and xmp:MetadataDate are expected to change and are dropped before comparing; a file whose source carried no XMP at all is not penalized for gaining a packet. What the check does not claim is equally explicit: retaining an existing packet says nothing about whether that packet is schema-valid or whether the document meets PDF/UA or any PDF/A part. Those are separate questions with separate tools, and conflating "the metadata survived" with "the metadata is compliant" is how a shared-date rewrite can stay hidden for a long time
If you work at the level below these rules, the mechanics of how a save rewrites objects are covered in incremental updates and append-only saving, which is the one save mode where a shared object is simply left where it was, and in modification levels and revision diffing, which is the other place a stale or rewritten date misleads a reader. The repair-side view of the same Info and XMP pair, where the two halves are made to agree rather than merely preserved, is in converting to PDF/A and repairing metadata
PDF Library for Delphi is a native Pascal PDF library for Delphi, C++Builder and Lazarus, and the read-modify-write path described here is the same one every edit in your own process goes through, so the guarantees above apply whether you save once or a thousand times a day — see the PDF Library for Delphi product page for the supported compilers and platforms