Zwei PDFs, geschrieben von demselben Rechnungscode eine Sekunde auseinander, sind fast nie dieselbe Datei. Ein Binärvergleich stolpert zuerst im Info-Dictionary, wo sich /CreationDate um eine Sekunde verschoben hat, dann im XMP-Paket, dann an der Trailer-/ID. Schaltet man AES-256 ein, kollabiert der Vergleich auf fast jedem Stream, denn der Datei-Verschlüsselungsschlüssel selbst wurde frisch generiert. Die Daten vor dem Vergleich zu maskieren repariert die ersten beiden Unterschiede und keinen der übrigen. Ein Golden-File-Test wird erst dann vertrauenswürdig, wenn jeder Wert, den der Writer von irgendwo außerhalb des Dokuments zieht, an der Stelle festgenagelt wird, an der er gelesen wird
Vier Werte, die nie aus dem Dokument kamen
Die HotPDF Delphi Component füllt wie jeder konforme PDF-Writer vier Arten von Werten von außerhalb des Seiteninhalts: die Uhr, den Dokument-Identifikator, die kryptografische Zufallsquelle und die Speicherreihenfolge der Dictionary-Einträge. ISO 32000-1 hat für jeden davon einen Grund
- Die Uhr. Das Info-Dictionary trägt
/CreationDateund/ModDate(ISO 32000-1 §14.3.3, Tabelle 317) alsD:YYYYMMDDHHmmSS-Strings mit Zeitzonen-Suffix (§7.9.4), und das XMP-Paket wiederholt denselben Moment alsxmp:CreateDateundxmp:ModifyDate. Alle werden aus einem einzigen Erstellungszeitstempel gerendert, den die Komponente bei ihrer Konstruktion ausNownimmt - Der Identifikator. Das Trailer-
/ID-Array (§14.4) hält einen permanenten Identifikator und einen Modifikations-Identifikator. Standardmäßig ist der erste ein MD5 aus Dateiname plus aktueller Zeit bis auf die Millisekunde, und der zweite hasht diesen Wert erneut zusammen mit dem System-Tick-Zähler - Die Zufallsbytes. Ein AES-256-Dokument braucht einen 32-Byte-Datei-Verschlüsselungsschlüssel, ein 8-Byte-Validierungs-Salt und ein 8-Byte-Key-Salt für
/U, ein weiteres Paar für/O, vier Filler-Bytes in/Permsund einen 16-Byte-Initialisierungsvektor vor jedem verschlüsselten String und Stream. ISO 32000-2 §7.6.4.4.7 will die Salts zufällig. RC4 und AES-128 leiten ihren Schlüssel aus den Passwörtern und dem ersten/ID-Element ab (ISO 32000-1 §7.6.3.3), ein frischer Identifikator allein reicht also, um die ganze Datei neu zu verschlüsseln - Die Reihenfolge. Ein PDF-Dictionary ist eine ungeordnete Abbildung (§7.3.7). Ein Writer, der über seine Speicherliste läuft, gibt die Schlüssel in der Reihenfolge aus, in der der Code sie zufällig hinzufügte, sodass zwei Codepfade, die dasselbe Ressourcen-Dictionary in anderer Sequenz bauen, eine legale, aber textuell verschiedene Datei erzeugen
Alles andere folgt bereits aus der Eingabe: Content Streams, komprimierte Bilddaten und Cross-Reference-Offsets wiederholen sich bei gleichen Zeichenausgaben. Sogar das eine Feld, das zufällig aussieht, ist keines: Das Sechs-Buchstaben-Präfix eines eingebetteten Font-Subsets (ISO 32000-1 §9.6.4, der ABCDEF+-Teil von ABCDEF+Arial) wird aus der Glyphenmenge berechnet, die das Subset behält, derselbe Text liefert also bei jedem Lauf dasselbe Präfix. Das Rauschen sitzt in den Metadaten und der Sicherheitsschicht — deshalb kann eine einzige Eigenschaft es entfernen
Was ReproducibleOutput festnagelt
ReproducibleOutput := True vor BeginDoc ersetzt jede der vier Quellen durch einen festen Wert, und zwar in denselben Codepfaden, die sonst zur Uhr oder zum Zufallsgenerator greifen würden. Es gibt keinen Aufräum-Durchlauf danach. Die Property defaultet auf False, und nichts in der Library schaltet sie für Sie ein
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // muss vor BeginDoc gesetzt werden
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Unter dem Flag setzt BeginDoc den Erstellungszeitstempel auf den 1. Januar 2026, 00:00 Ortszeit, und weil sowohl die Info-Daten als auch die XMP-Daten aus diesem einen Wert gerendert werden, deckt eine einzige Zuweisung alles ab. Derselbe Zweig sät den internen Dokument-Identifikator aus einem konstanten String statt aus Dateiname und Uhr. Beim Schreiben der Datei wird die Trailer-/ID als MD5 über einen kanonischen Export des gesamten Objektgraphen berechnet, in dem die Ziffern jedes D:-Datums-Strings zuerst genullt werden, damit sich kein Zeitstempel durch den Hash zurückschleicht. Beide Elemente von /ID erhalten diesen Wert. Ein geladenes Dokument, das Sie verschlüsseln, ohne je BeginDoc aufzurufen (nach LoadFromFile ActivateProtection setzen), bekommt denselben inhaltsabgeleiteten Identifikator
Die Zufallsbytes werden durch Konstanten ersetzt. Der AES-256-Dateischlüssel, alle vier Salts und der /Perms-Filler werden mit $5A gefüllt, und jeder CBC-Initialisierungsvektor, für AES-128-Strings und Streams wie für AES-256, wird zum Muster 14 * (1 + I) für Byte I. Dieses Muster ist mit Absicht nicht null. Ein All-Null-IV ist die lehrbuchschwache Wahl für CBC, und eine Regressionssuite hat kein Geschäft damit, das zu normalisieren. Mit festgelegtem Schlüssel, Salts und Vektoren kommen /U, /UE, /O, /OE, /Perms und jeder verschlüsselte Stream beim zweiten Lauf identisch heraus
Schließlich schaltet SaveToStream DeterministicDictionaryOrder ein, wann immer das Reproducible-Flag gesetzt ist, und zwar bevor es zwischen dem linearisierten, dem Object-Stream- und dem klassischen Layout wählt — Ausgaben mit LinearizeOutput oder UseObjectStreams werden also ebenfalls sortiert. Die Schlüssel werden nach ihren rohen Bytes geordnet, ein Schlüssel, der Präfix eines anderen ist, sortiert zuerst, und die ursprüngliche Position bricht Gleichstände. Das ist die Ordnung, die der Diagnose-Writer nutzt, beschrieben in dem Artikel zum manuellen Bearbeiten einer PDF und deren Reparatur danach; das Reproducible-Flag borgt sich die Ordnung und keines von dessen Plain-Text-Layout. DeterministicDictionaryOrder allein zu aktivieren ist ein Halbmaß. Die Schlüssel fluchten, aber die Info-Daten und beide Trailer-/ID-Strings tragen weiter die Uhr, also unterscheiden sich zwei Saves weiter an denselben vier Stellen
Ein Test, der nicht durch Glück bestehen kann
Das Datum wird in BeginDoc festgenagelt, weil der Konstruktor läuft, bevor der Aufrufer überhaupt Gelegenheit hatte, die Property zu setzen. Die normale Sequenz ist Create, dann ReproducibleOutput := True, dann BeginDoc — nur das letzte davon sieht das Flag mit seinem endgültigen Wert. Diese Reihenfolge hat eine direkte Konsequenz für das Testdesign. Zwei Saves, die in derselben Wanduhr-Sekunde landen, schreiben zufällig denselben D:-String, ein schneller Test besteht also, egal ob das Datum genagelt ist — und fällt Monate später auf einem langsameren Build-Agenten aus
Die Korrektur ist billig: 1100 ms zwischen die beiden Saves legen, damit der Zeitstempel garantiert eine Sekundengrenze überschreitet, den Fall für Plain-, AES-128- und AES-256-Ausgaben laufen lassen, mit echten Passwörtern bei den verschlüsselten Varianten, und das erste abweichende Byte-Offset melden statt eines nackten true oder false. Ein Offset zeigt auf ein Objekt. Ein Boolean zeigt auf die ganze Datei
uses
System.SysUtils, System.IOUtils, HPDFDoc;
function SaveOnce(const Target: string; Encrypt: Boolean;
KeyType: THPDFKeyType): TBytes;
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.VerifySavedOutput := True; // strikter Reload direkt nach EndDoc
if Encrypt then
begin
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := KeyType; // aes128 oder aes256
Pdf.ActivateProtection := True;
end;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Result := TFile.ReadAllBytes(Target);
end;
// Ihr eigener Helfer: -1 bei Gleichheit, sonst das erste abweichende Offset
function FirstDifference(const A, B: TBytes): Integer;
var
I, N: Integer;
begin
N := Length(A);
if Length(B) < N then
N := Length(B);
for I := 0 to N - 1 do
if A[I] <> B[I] then
Exit(I);
if Length(A) <> Length(B) then
Result := N
else
Result := -1;
end;
procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
A, B: TBytes;
Offset: Integer;
begin
A := SaveOnce('run-a.pdf', Encrypt, KeyType);
Sleep(1100); // eine andere Zeitstempel-Sekunde erzwingen
B := SaveOnce('run-b.pdf', Encrypt, KeyType);
Offset := FirstDifference(A, B);
if Offset >= 0 then
raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;
Rufen Sie CheckReproducible(False, aes128), CheckReproducible(True, aes128) und CheckReproducible(True, aes256) auf; der Schlüsseltyp wird beim Plain-Lauf ignoriert. VerifySavedOutput verdient seinen Platz in diesem Test. Ist es an, lädt EndDoc die Datei, die es gerade geschrieben hat, in einer frischen THotPDF-Instanz mit deaktivierter Cross-Reference-Wiederherstellung neu, mit dem Benutzerpasswort, wenn das Dokument geschützt ist, und wirft, falls dieses Laden scheitert. Eine Änderung, die die Ausgabe stabil und zugleich unlesbar macht, rutscht durch einen grünen Diff hindurch — der Reload stoppt sie
Ist ein reproduzierbares verschlüsseltes PDF noch sicher?
Nein. Ein unter ReproducibleOutput verschlüsseltes Dokument hat keinen echten Schutz, und das Flag gehört für alles aus, was das Testverzeichnis verlässt. Der AES-256-Dateischlüssel sind zweiunddreißig Bytes $5A. Das Passwort schützt weiter die /UE- und /OE-Hüllen, aber was sie umhüllen, ist eine veröffentlichte Konstante — wer sie kennt, entschlüsselt jeden Content Stream ohne Passwort. Feste Salts nehmen zudem die Eindeutigkeit pro Datei weg, auf die ISO 32000-2 §7.6.4.4.7 setzt, damit dasselbe Passwort nicht in zwei Dateien denselben /U-String erzeugt. Der AES-256-Setup-Artikel beschreibt, was die Verschlüsselungs-Properties mit intakter Zufallsquelle liefern; unter dem Reproducible-Flag gilt davon nichts. Hängen Sie die Property an eine nur für Tests gedachte Konfiguration, nicht an eine Einstellung, die ein Produktions-Codepfad erreichen kann
AES-GCM wird anders behandelt. Mit CryptKeyLength := aesgcm (nur PDF 2.0) zieht die Komponente das 8-Byte-Nonce-Präfix aus der System-Zufallsquelle, egal was ReproducibleOutput sagt, und zählt dann für jeden verschlüsselten String oder Stream nach oben. Das ist das richtige Verhalten. GCM scheitert kläglich, wenn eine Nonce unter demselben Schlüssel sich wiederholt: Zwei Ciphertexts lecken das XOR ihrer Plaintexts, und ein Angreifer kann genug rekonstruieren, um Authentifizierungs-Tags zu fälschen. Ein genagelter Schlüssel plus eine genagelte Nonce machten diese Wiederholung in jeder Datei der Suite zur Gewissheit. Verschlüsselte Golden-Files nutzen deshalb aes128 oder aes256, die im CBC-Modus laufen
Der Identifikator ist ein weicherer Kompromiss. ISO 32000-1 §14.4 will, dass sich das zweite /ID-Element bei jeder Modifikation ändert, und ein reproduzierbarer Save schreibt denselben Wert in beide Slots. Da dieser Wert den Objektgraphen hasht, bekommen unterschiedliche Inhalte weiterhin unterschiedliche Identifikatoren. Ein Reader, der einen Cache auf /ID aufbaut, einen Annotationsspeicher oder ein Formdaten-Sidecar etwa, behandelt zwei reproduzierbare Saves desselben Inhalts weiterhin als eine Datei. In einer Testsuite ist genau das, was Sie wollen
Eingaben, die außerhalb des Writers bleiben
ReproducibleOutput deckt die Entropie ab, die der Writer selbst einführt. Ein paar weitere Eingaben kommen von der Build-Maschine oder aus Operationen, die per Definition einen neuen Moment aufzeichnen
- Das Zeitzonen-Suffix. Jeder PDF-Datumsstring endet im lokalen UTC-Offset, dasselbe feste Datum liest sich also auf dem einen Build-Agenten als
D:20260101000000+08'00'und auf dem anderen alsD:20260101000000-05'00'. Golden-Files halten über Läufe auf einer Maschine oder über Maschinen, die eine Zone teilen. Wandern die Dateien zwischen Standorten, nageln Sie die Zeitzone der Agenten fest - Inkrementelle Updates.
SaveIncrementalUpdatebehält den permanenten Identifikator der Quelle und baut einen neuen Modifikations-Identifikator aus dem vorherigen, dem Zielpfad, dem Tick-Zähler und der aktuellen Zeit. Ein angehängter Abschnitt ist per Definition eine neue Modifikation. Vergleichen Sie vollständige Umschreibungen, nicht angehängte Deltas - Der Passthrough-Shortcut.
SaveLoadedDocumentkopiert eine unveränderte, unverschlüsselte Quelle normalerweise Byte für Byte, statt sie neu zu serialisieren. Unter dem Flag wird die Datei immer neu geschrieben, damit Ordnungs- und Identifikatorregeln greifen — das macht den Save langsamer und nie zu einer Kopie der Eingabe. Differenzieren Sie ihn gegen einen früheren reproduzierbaren Save, nie gegen das Original - Selbst hinzugefügte Zeitstempel.
AddDocumentAttachmentstempelt/Params/CreationDateund/ModDate(ISO 32000-1 §7.11.4) der eingebetteten Datei mit dem Moment des Aufrufs, und eine Signatur hält ihre Signierzeit in/M. Diese Daten beschreiben Ereignisse, die der Aufrufer verlangt hat, das Flag lässt sie in Ruhe. Halten Sie Attachments und Signaturen aus Byte-Level-Fällen raus oder maskieren Sie diese Strings im Vergleich - Installierte Fonts. Ein TrueType-Subset wird aus der Font-Datei geschnitten, die auf der Maschine installiert ist, die den Test ausführt. Zwei Agenten mit unterschiedlichen Arial-Releases betten für denselben Text unterschiedliche Glyphenprogramme ein, und das Subset-Präfix kann mit wechseln. Liefern Sie die Font-Dateien mit dem Build-Image aus
Geladene Dokumente folgen denselben Regeln. Property vor dem Laden setzen, die Änderungen machen und über SaveLoadedDocument speichern — die Ausgabe ist ein normalisiertes Umschreiben, das Sie als Referenz für den nächsten Lauf einchecken können
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.ReproducibleOutput := True; // vor dem Laden setzen
if Pdf.LoadFromFile('incoming.pdf') <= 0 then
raise Exception.Create('incoming.pdf did not load');
// ... zu testende Änderungen ...
Pdf.SaveLoadedDocument('normalized.pdf'); // vollständiges Umschreiben, sortierte Schlüssel
finally
Pdf.Free;
end;
end;
Byte-identische Saves stellen außerdem einer flackernden Konformitätsprüfung das Licht aus. Ist der Writer erst stabil, reagiert eine Prüfung, die beim einen Lauf besteht und beim nächsten scheitert, auf den Objektgraphen, den der Test baut — etwa ein direktes Dictionary, das zwei Eltern bekommt —, untersuchen Sie also die Testeingabe, bevor Sie den Validator ansehen. Ein struktureller Diff ab dem Katalog zeigt, welches Objekt zwischen den beiden Läufen gewandert ist
Die oben verwendeten Properties ReproducibleOutput, DeterministicDictionaryOrder, VerifySavedOutput und die Verschlüsselungs-Properties gehören zur HotPDF Delphi Component, die für Delphi und C++Builder ausgeliefert wird