Dieselbe Rechnung zweimal aus derselben Vorlage mit denselben Daten erzeugen, dann die beiden PDFs diffen. Zwei Stellen unterscheiden sich: die Hex-Strings im Trailer-/ID-Array und der ModDate-String im Document-Information-Dictionary, beide von der Uhr genommen. In der losLab PDF Library macht SetDeterministicDocumentID(1) plus ein festgesetztes ModDate die Dateien byte-identisch, bei einem Dokument ohne XMP-UUIDs oder Attachments pro Lauf
Der naheliegende erste Versuch ist, die Daten festzunageln und neu zu laufen. CreationDate und ModDate matchen jetzt, und die Hashes unterscheiden sich trotzdem, nun nur noch in zwei 32-stelligen Hex-Strings ganz am Ende der Datei, innerhalb von /ID [<…> <…>]. Dieser Identifier wird getrennt von den Metadaten berechnet, und solange er nicht aufhört, von der Save-Zeit abzuhängen, greift ein Build-Cache, der auf dem Datei-Hash keyed, nie, und ein Content-addressable Store hält einen Blob pro Lauf statt einen pro Dokument
Wozu das Trailer-ID-Array dient
Das Trailer-/ID identifiziert eine Datei; es ist keine Prüfsumme seines Inhalts. ISO 32000-1 §14.4 definiert es als Array aus zwei Byte-Strings. Das erste ist der permanente Bezeichner, vergeben bei Erstellung des Dokuments und dazu gedacht, jede spätere Bearbeitung zu überdauern. Das zweite ist der veränderliche Bezeichner, den ein Writer auffrischt, wann immer es die Datei ändert. Ein Dokumentenmanagementsystem vergleicht das Paar, um zu entscheiden, ob zwei Dateien Revisionen eines Dokuments sind oder zwei unverwandte. Tabelle 15 in §7.5.5 macht den Eintrag erforderlich, wenn der Trailer /Encrypt trägt, und optional sonst, und §14.4 hält fest, dass sein Fehlen Workflows behindern kann, die auf Datei-Identität bauen
Die Spezifikation sagt nie, wie der Wert zu berechnen ist. Sie schlägt einen MD5-Digest über Dinge wie die aktuelle Zeit, den Dateipfad, die Dateigröße und die Werte im Information-Dictionary vor, und die Uhr ist die Zutat, die das Ergebnis einzigartig macht. Einzigartigkeit ist genau das, was man von einem Identitätsmarker will, und genau das, was Reproduzierbarkeit zerschlägt. Per Default folgt die losLab PDF Library dem Vorschlag in seiner einfachsten Form, ein MD5 des aktuellen Zeitstempels, zwei eine Sekunde auseinander erzeugte Dokumente bekommen also unterschiedliche permanente Bezeichner, selbst wenn jedes andere Byte matcht
Einen reproduzierbaren Identifier einschalten
Der deterministische Modus ist ein Schalter pro Dokument und per Default aus, bestehende Ausgabe ändert sich also erst, wenn man darum bittet. SetDeterministicDocumentID nimmt 0 oder 1 und gibt 1 zurück, wenn der Wert akzeptiert wurde, 0 für alles andere; GetDeterministicDocumentID meldet die aktuelle Einstellung. SetDocumentIDSeed liefert einen expliziten Seed-String, und mit einem Seed an Bord ist der Identifier ein MD5 dieses Strings, geschrieben in beide Elemente des Arrays. GetDocumentFileID liest /ID[0] nach dem Save zurück, praktisch fürs Logging oder für eine Test-Assertion
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1); // direkt nach Create
Lib.SetDocumentIDSeed('invoice-4471-rev3');
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Invoice 4471');
Lib.SaveToFile('invoice.pdf');
FileID := Lib.GetDocumentFileID; // bei jedem Lauf derselbe Wert
finally
Lib.Free;
end;
end;
Der Identifier wird zur Save-Zeit berechnet. Den Schalter spät im Dokument-Build umzulegen funktioniert trotzdem, und ein geänderter Seed erreicht die Datei beim nächsten Full Save: mit Seed A speichern, erneut mit Seed B speichern, die beiden Ausgaben tragen unterschiedliche Identifier, und die Rückkehr zu Seed A liefert den ursprünglichen Wert wieder
Schaltet den Schalter aber direkt nach dem Erzeugen des Objekts ein. Fonts, Bilder, Grafikzustände und andere Seiten-Ressourcen bekommen Namen im Resource-Dictionary der Seite, und solange der deterministische Modus an ist, kommen diese Namen von einem Zähler. Alles, was vor dem Umschalten hinzukam, behält einen zufällig erzeugten Namen, was den Identifier stabil lässt, aber ein paar Bytes in den Content-Streams bei jedem Lauf ändert
Warum ändert sich der Identifier weiterhin, wenn kein Seed gesetzt ist?
Weil das Information-Dictionary Teil des Hashes ist. Lässt man den Seed leer, oder übergibt SetDocumentIDSeed einen leeren String, um einen zu löschen, leitet die losLab PDF Library den Identifier aus der Datei selbst ab: Sie schickt den serialisierten Body beim Schreiben durch SHA-256 und nutzt die ersten 16 Bytes des Digests für beide Elemente von /ID. Jede Änderung des Seiteninhalts ändert den Identifier, das ist der ganze Punkt. Der Haken: CreationDate und ModDate leben in diesem Body ebenfalls, und die Save-Routinen stempeln ModDate mit der aktuellen Zeit, sofern der Aufrufer sie nicht gesetzt hat
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(''); // /ID aus den gespeicherten Bytes ableiten
// Der Body-Hash deckt das Info-Dictionary ab, die Daten müssen also
// Build-Eingaben sein. Key 8 hindert den Save zudem am ModDate-Stempeln.
Lib.SetInformation(2, 'Quarterly Report'); // Title
Lib.SetInformation(5, 'reporting-service 4.2'); // Creator
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SaveToFile('report.pdf');
Key 8 leistet hier doppelte Arbeit, und das ist die Zeile, die Leute am häufigsten weglassen. ModDate über SetInformation zu setzen markiert das Datum als vom Aufrufer geliefert, und von da an lassen SaveToFile und SaveToString es in Ruhe. Ohne das schreibt jeder Save einen frischen Zeitstempel, der gehashte Body ändert sich, und der inhaltsabgeleitete Identifier ändert sich mit. Nehmen Sie die Daten aus dem Quell-Record oder aus einer fixen Epoche. Now hat in einem reproduzierbaren Build nichts verloren
Ein paar Dinge werden mit Absicht aus dem Hash herausgehalten. Zufällig erzeugte Ressourcennamen und die UUID in einem XMP-xmpMM:InstanceID-Eintrag werden vor dem Hashen normalisiert, zwei Läufe, die sich nur in diesen Tokens unterscheiden, bekommen also dieselbe /ID. Die Datei-Bytes unterscheiden sich an den Stellen trotzdem, ein PDF/A-artiges Dokument mit einer frischen Instance-UUID pro Lauf liefert also einen reproduzierbaren Identifier, aber keine byte-identische Datei. Eingebettete Attachments sind eine weitere Drift-Quelle: AddAssociatedFileFromString stempelt das eigene /ModDate des Attachments mit der aktuellen Zeit, und dieses Datum wird wie jedes andere Body-Byte gehasht
Expliziter Seed oder Inhalts-Hash
Für Build-Artefakte würden wir den Seed leer lassen. Ein inhaltsabgeleiteter Identifier ändert sich genau dann, wenn sich die Bytes ändern, ein Cache, der auf ihm keyed, kann also nie eine veraltete Datei ausliefern, und man hat keinen zweiten Schlüssel, der mit den Daten synchron gehalten werden müsste. Ein expliziter Seed passt zu einer anderen Aufgabe: ein downstream-Archiv, das /ID[0] als Dokument-Identität behandelt und denselben Identifier über Neuerzeugungen derselben Rechnung erwartet, selbst nach einem Vorlagenwechsel. Das trifft die Intention von §14.4 besser, zum Preis, dass zwei sichtbar verschiedene Dateien einen Identifier teilen können
Eine Konsequenz des Inhalts-Pfads verdient eine bewusste Entscheidung. Lädt man ein bestehendes PDF und speichert es als Ganzes mit eingeschaltetem deterministischem Modus, wird die hereinkommende /ID verworfen, und die neue hängt nur davon ab, was geschrieben wird. Zwei Quelldateien, die sich nur in ihren Trailer-Identifiern unterscheiden, kommen mit derselben /ID heraus. Genau das will ein normalisierender Build-Schritt. Bearbeiten Sie das Dokument eines Kunden und seine Identität muss überleben, lassen Sie den deterministischen Modus für diesen Job aus oder speichern Sie inkrementell
Warum bricht das Neuschreiben der ID eine verschlüsselte PDF?
Weil in einem verschlüsselten Dokument /ID[0] Schlüsselmaterial ist. ISO 32000-1 §7.6.3.3 Algorithmus 2 speist das erste Element des Dateibezeichners in die Berechnung des Verschlüsselungsschlüssels für den Standard-Sicherheitshandler bei den Revisionen 2 bis 4 ein, zusammen mit dem aufgefüllten Passwort, dem /O-Wert und den Berechtigungs-Bits. Dieser Schlüssel erzeugt dann den /U-String, den ein Reader beim Öffnen prüft. Der Schlüssel wird abgeleitet, wenn Sie Encrypt aufrufen oder eine verschlüsselte Datei laden, also vor jedem Save; den Identifier während des Saves zu ersetzen würde eine wohlgeformte Datei erzeugen, deren Passwortprüfung für jeden scheitert, Sie eingeschlossen
Die losLab PDF Library frischt den deterministischen Identifier daher nur bei unverschlüsselten Full Saves auf. Ein verschlüsseltes Dokument behält, welche /ID es schon hatte, und der Schalter hat auf diesen Pfad keinen Einfluss. Eine solche Datei mit dem Benutzerpasswort wieder zu öffnen funktioniert wie gewohnt. Schütten Sie verschlüsselte Ausgabe auch nicht in einen Byte-Vergleich: AES-Verschlüsselung nach §7.6.2 beginnt jeden verschlüsselten String und Stream mit einem zufälligen 16-Byte-Initialisierungsvektor, zwei Verschlüsselungen desselben Dokuments matchen also nie byte-um-byte. Brauchen Sie dort eine Regressionprüfung, entschlüsseln Sie beide und vergleichen Sie, was zurückkommt. Revisions-Handling und Berechtigungs-Bits behandelt der Durchgang zur PDF-Verschlüsselungs- und Berechtigungs-Auditing ausführlicher
Inkrementelle Saves behalten den ursprünglichen Identifier
Ein inkrementelles Update lässt jedes frühere Byte der Datei an seinem Platz und hängt eine neue Revision dahinter an. Die Beständigkeit von /ID[0] ist es, die einem Konsumenten sagt, dass die angehängte Revision zum selben Dokument gehört wie die davor. Sie umzuschreiben würde den bereits in der Datei sitzenden Revisionen widersprechen und Signatur-Workflows untergraben, denn eine Signatur deckt einen Byte-Bereich einer bestimmten Revision ab. Der Append-Modus fasst den Identifier im deterministischen Modus nie an, was das Verhalten aus dem Artikel zu PDF-inkrementellen Updates und Append to Stream unangetastet lässt
Ausgabe über die Zeit reproduzierbar halten
Jede spätere Änderung am Generator ist eine Gelegenheit, einen Uhrwert zurück in die Datei zu bringen: ein Wasserzeichen, das die Erzeugungszeit druckt, ein Codepfad, der eine Datei anhängt, eine andere Save-Option. Save-Optionen zählen so viel wie Inhalt: Objekte in komprimierte Objekt-Streams zu packen erzeugt ein völlig anderes Datei-Layout als die klassische Cross-Reference-Tabelle, erklärt in den Notizen zu Objekt-Streams und Cross-Reference-Streams, diese Einstellungen also pro Pipeline fixiert halten. Die billigste Verteidigung ist ein Test, der dasselbe Dokument zweimal im Speicher baut und die Bytes vergleicht
function BuildQuote(const Seed: WideString): AnsiString;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(Seed);
Lib.SetInformation(7, 'D:20260101000000Z');
Lib.SetInformation(8, 'D:20260101000000Z');
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Quote 8812');
Result := Lib.SaveToString;
finally
Lib.Free;
end;
end;
// Regressionsschutz: zwei unabhängige Builds, eine Bytefolge
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
Dasselbe Geschirr kann einen seriellen und einen parallelen RecompressStreams-Lauf auf derselben Quelle vergleichen: Sie in zwei TPDFlib-Instanzen laden, auf beiden deterministische IDs einschalten und ModDate festnageln, auf der einen SetOptimizationWorkers(1) setzen und auf der anderen eine höhere Worker-Zahl, und die beiden SaveToString-Ergebnisse diffen. Mit der Uhr aus beiden Dateien raus kommt jeder verbleibende Unterschied aus der Rekompression selbst
Reproduzierbare Trailer-Identifier, Metadaten-Kontrolle und inkrementelles Speichern sind alle Teil der losLab PDF Library for Delphi and C++Builder, deren Referenz auch die hier erwähnten Verschlüsselungsoptionen listet