Technischer Artikel

Wenn ein No-Op-PDF-Save Info und XMP korrumpiert

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

Geteilte Info-String-Mutation in PDFlibPas: /CreationDate und /ModDate verweisen legal auf ein String-Objekt 2728 0 R, das alte SetRawInfo rief SetTo auf dem Objekt auf, auf das der Key auflöste, und schrieb beide Daten mit der Save-Zeit um, und das neue SetRawInfo fügt unter dem Key einen frischen String hinzu und erhält den Hex-String-Modus
Das Aktualisieren eines Dictionary-Eintrags ersetzt jetzt die Referenz dieses Eintrags, statt das geteilte Objekt zu verändern, sodass ein automatischer ModDate-Write die CreationDate nicht mehr verändern kann, und das abgelöste Objekt bleibt für andere Referenzen erhalten
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

Lazy-XMP-Initialisierungsreihenfolge in PDFlibPas: Das XMP-Objekt vor dem Aufruf von GetMetadata anzulegen lässt den Fast Path ein Default-Paket serialisieren und dc:creator, Custom-Namespaces und Standards-Identifikation fallen lassen, während das Erfassen von Source vor TPDFlibXMP.Create den ursprünglichen /Metadata-Stream aus dem Katalog lädt
Jeder Save löste den Tausch aus, weil SetInfo XMP initialisiert, damit xmp:ModifyDate mit /ModDate in Schritt bleibt, also läuft jede Lazy-Initialisierung im Dokument jetzt durch ein einziges EnsureXMP, das das bestehende Paket erfasst, bevor es das Objekt anlegt
// 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

Nicht-visuelle semantische Verifikation für PDFlibPas-Saves: Ein unabhängiger Parser ohne geteilten Code snapshotet das Info-Dictionary minus /ModDate, Outline- und Ziel-Seiten, Formularwerte, Anhang-Hashes und den XMP-Baum und vergleicht dann Quelle gegen gespeicherte Datei, wobei /ModDate, xmp:ModifyDate und xmp:MetadataDate als erwartete Änderungen herausfallen
Pixel und Stream-Hashes bleiben durch beide Defekte hindurch byte-identisch, also arbeitet der Vergleich mit aufgelöster Semantik statt Objektnummern, und überlebende Metadaten werden ehrlich als erhalten gemeldet, nicht als schema-valid oder PDF/UA- und PDF/A-konform

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