Die PDF Library for Delphi v3.539.18 und v3.539.20 beheben zwei Arten, wie ein PDF-Save, der nichts ändert, trotzdem Dokumentmetadaten korrumpieren konnte: Wenn sich /CreationDate und /ModDate auf dasselbe String-Objekt bezogen, schrieb das automatische ModDate-Update beide um, und wenn das XMP-Objekt angelegt wurde, bevor der ursprüngliche /Metadata-Stream gelesen war, ersetzte ein Default-Paket das Original. Die Fixes ersetzen Dictionary-Referenzen, statt geteilte Objekte zu verändern, und erfassen das bestehende Paket vor der Lazy-XMP-Initialisierung
Das bloße Durchreichen ist die uninteressanteste Operation, die eine PDF-Bibliothek ausführt: Datei laden, unter neuem Namen speichern, dazwischen nichts anfassen. Die Seiten rendern vorher und nachher identisch. Die Content-Stream-Hashes stimmen. Die Datei bestand jede Prüfung, die wir hatten, und war trotzdem an zwei Stellen falsch, die Ihnen kein Renderer je zeigen würde. Beide Defekte saßen im Read-Modify-Write-Pfad, durch den jede echte Bearbeitung läuft, also reichte jeder x-beliebige Save, um sie auszulösen, und gefunden wurden beide erst, als ein zweiter, unabhängiger Parser die nicht-visuelle Semantik der beiden Dateien verglich
Warum verändert das Speichern eines PDFs seine CreationDate?
Weil das Document-Information-Dictionary von zwei Keys aus auf dasselbe indirekte String-Objekt verweisen darf und die Bibliothek das Objekt statt des Keys aktualisiert hat. ISO 32000-1 §7.3.10 erlaubt, dass jeder Dictionary-Wert eine indirekte Referenz ist, und nichts in §14.3.3 Tabelle 317 sagt, dass der Wert unter /CreationDate ein anderes Objekt sein muss als der Wert unter /ModDate. Ein Producer, der denselben Zeitstempel beim Erstellen zweimal geschrieben hat, kann völlig legal beide Keys auf ein einziges 2728 0 R zeigen lassen – genau das hat ein CJK-Design-Dokument in unserem lokalen Korpus getan
Der Auslöser ist das automatische Änderungsdatum. Sofern UserModDate nicht gesetzt ist, ruft SaveToFile vor dem Schreiben SetInfo('ModDate', ...) mit der aktuellen Zeit auf, was bei SetRawInfo landet. Das alte SetRawInfo suchte das Objekt unter dem Key und rief bei einem TPDFString SetTo daran auf. Das ist ein In-place-Schreibzugriff auf das Objekt, auf das der Key gerade auflöst, und wenn dieses Objekt geteilt ist, meldet /CreationDate jetzt ebenfalls die Save-Zeit. Das Dokument öffnet, druckt und rendert weiter pixelgenau wie vorher, eine visuelle Regression-Suite besteht also ohne zu zucken
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;
Der Fix in TPDFDocument.SetRawInfo ist klein, und das Prinzip dahinter ist allgemein: Das Aktualisieren eines Dictionary-Eintrags ersetzt die Referenz dieses Eintrags, nie das Objekt, auf das er zufällig aufgelöst hat. Der neue Code liest den bestehenden TPDFStringMode, damit ein Hex-String hex bleibt und ein Literal-String literal bleibt, und fügt dann über FStructure.NewString(Value, StringMode) einen frischen String unter dem Key hinzu. Zwei weitere Details wiegen so viel wie die Hauptänderung. Der alte Zweig für einen Stream-wertigen Eintrag leerte den Stream vor dem Ersetzen mit SetTo(''), was den Wert für jeden anderen Key geleert hätte, der noch auf diesen Stream zeigte – dieses Leeren ist weg. Und das abgelöste Objekt wird nicht gelöscht, denn die Struktur besitzt es, und andere Referenzen brauchen es womöglich noch
// Vorher: das Objekt verändern, auf das der Key gerade auflöst
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Nachher: die Darstellung halten, nur die Referenz dieses Keys ersetzen
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Die Regression in Tests\SharedInfoSemantics.inc baut das Aliasing absichtlich, statt auf eine Korpusdatei zu setzen: ein Hex-String, auf den beide Datums-Keys verweisen, ein direkter String, geteilt von /Title und /Subject, ein Stream, geteilt von /Author und /Keywords. Nach dem Aktualisieren eines Keys je Paar muss der andere weiterhin seinen Originalwert lesen, und der aktualisierte String muss weiterhin hex sein. Die öffentliche Referenz zu SetInformation hält die Garantie jetzt in einem Satz fest: Das Aktualisieren eines Info-Felds ersetzt nur dieses Feld, auch wenn andere Felder auf dasselbe Objekt verweisen
Warum wird ein bestehendes XMP-Paket durch Defaults ersetzt?
Wegen der Reihenfolge von zwei Zeilen. TPDFDocument.GetMetadata hat einen Fast Path: Ist das XMP-Feld bereits zugewiesen, gibt es XMP.SaveToString zurück, statt den /Metadata-Stream aus dem Katalog zu dekodieren. Mehrere Aufrufstellen initialisierten lazy mit XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); – liest sich natürlich und ist falsch: In dem Moment, in dem GetMetadata läuft, ist XMP bereits zugewiesen, also ist die geladene „Quelle“ das serialisierte Default-Paket eines Objekts, das eine Zeile vorher angelegt wurde. Das Originalpaket mit seinem dc:creator, seinen Custom-Namespaces und jeder Standards-Identifikation erreicht das Objekt nie und wird beim Speichern überschrieben. Dasselbe automatische Änderungsdatum reicht als Auslöser, denn SetInfo initialisiert XMP, bevor es das Info-Dictionary anfasst, damit xmp:ModifyDate mit /ModDate in Schritt bleibt. Beachten Sie, hinter was sich dieser Defekt versteckt: Der Info-Dictionary-Vergleich aus dem ersten Bug besteht, denn /Author und /Title in /Info sind unangetastet. Nur der XMP-Baum hat sich geändert, und nur eine Prüfung, die diesen Baum parst und vergleicht, merkt es
// Falsch: GetMetadata serialisiert jetzt das Objekt aus der Zeile davor
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Richtig: zuerst den /Metadata-Stream erfassen, dann anlegen und laden
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
Der Fix tut zwei Dinge. TPDFDocument.EnsureXMP erfasst jetzt Source := GetMetadata, bevor TPDFlibXMP.Create läuft, und jede Lazy-Initialisierung im Dokument wurde durch einen Aufruf davon ersetzt: SetInfo, SetXMPInformation, GetXMPInformation, die Modus-Setter für PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR und PDF/UA sowie der Metadaten-Reparaturpfad. Öffentliche Einstiegspunkte wie SetXMPProperty liefen bereits über EnsureXMP, und GetXMPProperty liest über GetDocumentMetadata, also teilt sich die gesamte Oberfläche eine Initialisierungsreihenfolge. Eine einzige korrekte Kopie einer Drei-Zeilen-Sequenz ist mehr wert als zehn Kopien, die zufällig heute übereinstimmen
Zwei kleinere Fallen auf demselben Pfad
Der XMP-Serializer unter Windows nutzt den plattformeigenen XML-Writer, der eine XML-Deklaration emittiert, die das Paket nicht tragen darf. Der alte Code entfernte sie, indem er Zeichen löschte, bis er auf <?xpacket stieß. ISO 16684-1 §7.3.2 macht den xpacket-Wrapper optional, und ein Producer, der ein nacktes <x:xmpmeta>-Element schreibt, ist im Rahmen des Standards, also löschte die Schleife bei einem solchen Paket das gesamte, gültige Dokument. Der Serializer findet jetzt das schließende ?> der Deklaration und entfernt nur das. Tests\XMPRetentionSemantics.inc läuft seinen Retention-Check zweimal, einmal mit Wrapper und einmal mit abgeschnittenem Wrapper, und prüft, dass ein Custom-Namespace-Marker und der ursprüngliche Autor SetInfo, GetMetadata, SaveToString und einen Reload überleben. Die zweite Falle war ein Präprozessorsymbol: Die Info-zu-XMP-Synchronisation in SetInfo war mit NOVCL bewacht, das für Free-Pascal-Builds gesetzt ist, aber das XMP-Backend hängt am Betriebssystem, nicht am Framework, denn PDFlibXMP.pas definiert NO_XMP nur, wenn OS_WINDOWS fehlt. Ein Windows-Lazarus-Build hatte daher ein funktionierendes XMP-Objekt und ein SetInfo, das stillschweigend darauf verzichtete, es zu aktualisieren. Der Guard ist jetzt NO_XMP, eine Windows-Free-Pascal-Anwendung bekommt also dieselbe Synchronisation wie Delphi
Wie behalten Sie das ursprüngliche ModDate bei einem Pass-through-Save?
Setzen Sie KeepModDate in TPDFlibSaveOptions und speichern Sie über SaveToFileOptions. Die Option setzt UserModDate für die Dauer des Aufrufs, und SaveToFile überspringt dann den automatischen Zeitstempel – dieselbe Stelle, die auch das XMP-Objekt lazy initialisiert. Ein Dokument, dessen Metadaten Sie nie angefasst haben und für das kein Compliance-Modus aktiviert war, behält sein Info-Dictionary und seinen /Metadata-Stream so, wie sie geladen wurden. SetInformation(8, ...) aufzurufen hat dieselbe Wirkung dauerhaft, denn wer das Änderungsdatum selbst setzt, markiert es als nutzergesteuert
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // kein automatisches /ModDate, keine Lazy-XMP-Init
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Seien Sie ehrlich dazu, was Ihnen das einbringt. KeepModDate ist die richtige Wahl für einen Pass-through-Schritt, dessen Ausgabe dieselbe Revision beschreiben soll wie seine Eingabe, und die falsche Wahl für alles, was tatsächlich Inhalt bearbeitet, denn §14.3.3 erwartet, dass /ModDate die jüngste Änderung widerspiegelt. Es repariert auch nicht rückwirkend eine Bibliothek, die geteilte Objekte verändert; es vermeidet nur den einen Write, der den Defekt offengelegt hat. Beide Fixes oben sind es, die einen gewöhnlichen Save sicher machen, und die Option ist es, die einen beabsichtigten No-op ehrlich macht
Wie prüfen Sie, dass ein Save nichts außer dem ModDate geändert hat?
Nicht mit Pixeln und nicht mit Stream-Hashes, denn beide Defekte lassen jede Seite und jeden Content-Stream byte-identisch. Die Prüfung, die sie erwischt hat, ist ein nicht-visueller Semantik-Snapshot, aufgenommen von einem unabhängigen Parser, der keinen Code mit der getesteten Bibliothek teilt, aus der Quelldatei und aus der gespeicherten Datei, gefolgt von einem strukturellen Vergleich. Der Snapshot deckt das Info-Dictionary ohne /ModDate ab, den Outline-Baum, bei dem jedes Bookmark zu einer Seitenzahl statt einer Objektnummer aufgelöst wird, Named Destinations und Link-Ziele, genauso aufgelöst, Formularfeldwerte, Anhang-Bytes als Hashes und das XMP-Paket als geparsten Baum statt als Textvergleich. Objektnummern sind bewusst nicht Teil davon, denn ein Full Rewrite nummeriert alles neu, und ein Vergleich, der auf ihnen hängt, würde Rauschen melden
Die Ausschlüsse sind so wichtig wie die Einschlüsse. /ModDate, xmp:ModifyDate und xmp:MetadataDate dürfen sich ändern und fallen vor dem Vergleich heraus; eine Datei, deren Quelle überhaupt kein XMP trug, wird nicht dafür bestraft, dass sie ein Paket dazubekommt. Ebenso explizit ist, was die Prüfung nicht behauptet: Ein bestehendes Paket zu erhalten sagt nichts darüber, ob dieses Paket schema-valid ist oder ob das Dokument PDF/UA oder irgendeinen PDF/A-Teil erfüllt. Das sind getrennte Fragen mit getrennten Werkzeugen, und „die Metadaten haben überlebt“ mit „die Metadaten sind konform“ zu vermischen ist der Grund, warum der erste Bug so lange unentdeckt blieb. Auf der Bibliotheksseite laufen die beiden Regressionen jetzt bei jedem targeted Pass über Delphi Win32 und Win64 sowie Free Pascal Win32 und Win64, und der semantische Vergleich ist eine Bestehensbedingung für das Real-Document-Korpus-Benchmark
Wenn Sie eine Ebene unter diesen Fixes arbeiten: Die Mechanik, wie ein Save Objekte umschreibt, steht in inkrementellen Updates und append-only-Speichern – dem einen Save-Modus, bei dem ein geteiltes Objekt schlicht dort bleibt, wo es war – und in Modification Levels und Revision-Diffing, dem anderen Ort, an dem ein veraltetes oder umgeschriebenes Datum einen Leser in die Irre führt. Die Reparatur-Sicht auf dasselbe Info-und-XMP-Paar, bei der die beiden Hälften zur Übereinstimmung gebracht statt nur bewahrt werden, steht in Konvertieren zu PDF/A und Reparieren von Metadaten
Die PDF Library for Delphi ist eine native Pascal-PDF-Bibliothek für Delphi, C++Builder und Lazarus, und der hier beschriebene Read-Modify-Write-Pfad ist derselbe, durch den jede Bearbeitung in Ihrem eigenen Prozess läuft, also gelten die Garantien oben, ob Sie einmal oder tausendmal am Tag speichern – die unterstützten Compiler und Plattformen finden Sie auf der Produktseite der PDF Library for Delphi