Ein PDF-Save, der nichts ändert, kann Dokumentmetadaten trotzdem auf zwei Arten korrumpieren: Wenn sich /CreationDate und /ModDate dasselbe String-Objekt teilen, schreibt ein automatisches ModDate-Update, das in dieses Objekt hineinschreibt, beide um, und wenn das XMP-Objekt angelegt wird, bevor der ursprüngliche /Metadata-Stream gelesen ist, ersetzt ein Default-Paket das Original. Die PDF Library for Delphi ersetzt Dictionary-Referenzen, statt geteilte Objekte zu verändern, und erfasst das bestehende Paket, bevor die Lazy-XMP-Initialisierung läuft
Speichern 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, und die Content-Stream-Hashes stimmen. Eine Datei kann jede solche Prüfung bestehen und trotzdem an zwei Stellen falsch sein, die Ihnen kein Renderer je zeigen würde. Beide Fallen sitzen im Read-Modify-Write-Pfad, durch den jede echte Bearbeitung läuft, also reicht jeder x-beliebige Save, um sie auszulösen, und nur ein zweiter, unabhängiger Parser, der die nicht-visuelle Semantik der beiden Dateien vergleicht, deckt sie auf
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 ein Writer, der das Objekt statt des Keys aktualisiert, damit beide ändert. 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, und reale Dateien machen genau das
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. Ein naives SetRawInfo sucht das Objekt unter dem Key und ruft 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 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;
Die Regel 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 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 genauso schwer. Ein Stream-wertiger Eintrag wird ersetzt, ohne den alten Stream vorher zu leeren, denn SetTo('') an ihm würde den Wert für jeden anderen Key leeren, der noch auf diesen Stream zeigt. Und das abgelöste Objekt wird nicht gelöscht, denn die Struktur besitzt es, und andere Referenzen brauchen es womöglich noch
// Naiv: das Objekt verändern, auf das der Key gerade auflöst
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Korrekt: 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));
Ein Test dafür sollte das Aliasing absichtlich bauen, statt sich auf eine Datei zu verlassen, die zufällig Objekte teilt: 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 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. Eine lazy initialisierte Sequenz wie 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 diese Falle versteckt: Der Info-Dictionary-Vergleich aus der ersten 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 das Objekt, das in der Zeile davor angelegt wurde
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Richtig: zuerst den /Metadata-Stream erfassen, dann anlegen und laden
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
Die PDF Library for Delphi hält diese Reihenfolge an einer Stelle fest. TPDFDocument.EnsureXMP erfasst Source := GetMetadata, bevor TPDFlibXMP.Create läuft, und jede Lazy-Initialisierung im Dokument geht darüber: 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 laufen ebenso ü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 naheliegende Weg, sie zu entfernen – Zeichen löschen, bis man auf <?xpacket stößt –, scheitert an gültiger Eingabe: 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öscht eine solche Schleife bei einem solchen Paket das gesamte, gültige Dokument. Der Serializer findet das schließende ?> der Deklaration und entfernt nur das. Ein Retention-Check zum Kopieren läuft 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 ist ein Präprozessorsymbol. NOVCL ist für Free-Pascal-Builds gesetzt, aber das XMP-Backend hängt am Betriebssystem, nicht am Framework, denn PDFlibXMP.pas definiert NO_XMP nur, wenn OS_WINDOWS fehlt. Bewachte man die Info-zu-XMP-Synchronisation in SetInfo mit NOVCL, hätte ein Windows-Lazarus-Build ein funktionierendes XMP-Objekt und ein SetInfo, das stillschweigend darauf verzichtet, es zu aktualisieren. Der Guard ist 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 ist auch kein Ersatz für korrektes Referenzhandling, denn es vermeidet nur den einen Write, der das automatische Update auslöst. Referenzen zu ersetzen, statt geteilte Objekte zu verändern, und das Paket vor der Initialisierung zu erfassen, 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 Fallen lassen jede Seite und jeden Content-Stream byte-identisch. Die Prüfung, die sie erwischt, 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 eine geteilte Datumsumschreibung so lange unentdeckt bleiben kann
Wenn Sie eine Ebene unter diesen Regeln 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