PDF Library for Delphi v3.539.18 and v3.539.20 fix two ways a PDF save that changes nothing could still corrupt document metadata: when /CreationDate and /ModDate referenced the same string object, the automatic ModDate update rewrote both, and when the XMP object was created before the original /Metadata stream was read, a default packet replaced the original. The fixes replace dictionary references instead of mutating shared objects, and capture 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 rendered identically before and after. Content stream hashes matched. The file passed every check we had, and it was still wrong in two places that no renderer would ever show you. Both defects sat in the read-modify-write path that every real edit goes through, so any save at all was enough to trigger them, and both were found only when a second, independent parser compared non-visual semantics of the two files
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 the library was updating the object rather than the key. 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, which is exactly what a CJK design document in our local corpus did
The trigger is the automatic modification date. Unless UserModDate is set, SaveToFile calls SetInfo('ModDate', ...) with the current time before writing, which reaches SetRawInfo. The old SetRawInfo looked up the object under the key and, if it found a TPDFString, called SetTo on it. That is an in-place write to whatever object the key currently resolves to, and when that object is shared, /CreationDate now 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 fix 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 new 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 as the headline change. The old branch for a stream-valued entry cleared the stream with SetTo('') before replacing it, which would have emptied the value for every other key still pointing at that stream, so that clearing is gone. And the superseded object is not deleted, because the structure owns it and other references may still need it
// Before: mutate whatever object the key currently resolves to
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// After: 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));
The regression in Tests\SharedInfoSemantics.inc builds the aliasing deliberately rather than relying on a corpus file: 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 now 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. Several call sites initialized lazily with XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, which 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 defect hides behind: the Info dictionary comparison from the first bug 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);
The fix does two things. TPDFDocument.EnsureXMP now captures Source := GetMetadata before TPDFlibXMP.Create, and every lazy initialization in the document was replaced by a call to 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 already went through EnsureXMP, 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 found 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 old code stripped it by deleting characters until it reached <?xpacket. 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 the loop deleted the entire, valid document. The serializer now locates the closing ?> of the declaration and removes only that. Tests\XMPRetentionSemantics.inc runs its retention check 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 was a preprocessor symbol: Info-to-XMP synchronization in SetInfo was guarded by NOVCL, which 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. A Windows Lazarus build therefore had a working XMP object and a SetInfo that silently skipped updating it. The guard is now 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 also does not retroactively fix a library that mutates shared objects; it only avoids the one write that exposed the defect. Both fixes above are what make an ordinary save safe, and 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 defects leave every page and every content stream byte-identical. The check that caught 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 the first bug hid for as long as it did. On the library side the two regressions now run on every targeted pass across Delphi Win32 and Win64 and Free Pascal Win32 and Win64, and the semantic comparison is a pass condition for the real-document corpus benchmark
If you work at the level below these fixes, 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