Die HotPDF Delphi Component produziert byte-identische PDF-Ausgabe über Saves hinweg, wenn die Eigenschaft ReproducibleOutput auf True steht: Sie pinnt die Info-/CreationDate und /ModDate auf ein festes Datum, ersetzt die Wanduhr-Dokument-ID durch einen geseedeten oder inhaltsabgeleiteten Hash, setzt Konstanten für jedes zufällige Byte ein, das die AES-Verschlüsselungspfade sonst ziehen würden, und sortiert jedes Dictionary, das sie serialisiert. Der Flag existiert für Regression-Suiten und Build-Artefakt-Vergleiche, nicht für Produktionsdokumente, und die Gründe für diese Grenze sind der interessante Teil. Das Szenario, das die Funktion antreibt, ist ein Golden-File-Test. Sie rendern eine Rechnung, committen das PDF und behaupten, dass der Build von morgen dieselben Bytes produziert. Tut er nie. Die Datei öffnet in jedem Viewer tadellos, der Text ist identisch, der Seitenbaum ist identisch, und der Diff leuchtet trotzdem an vier oder fünf Stellen auf. Wer schon einmal versucht hat, einen PDF-Generator unter einen Byte-Level-Regressionstest zu nehmen, ist an diese Mauer gelaufen, und der Fix ist nicht „Zeitstempel entfernen“, sondern eine präzise Buchführung über jede Stelle, an der der Writer etwas anderes als das Dokument selbst konsultiert
Warum unterscheiden sich zwei Saves desselben PDFs?
Zwei Saves desselben Dokuments unterscheiden sich, weil ein PDF-Writer, HotPDF eingeschlossen, vier Entropiequellen konsultiert, die mit Seiteninhalt nichts zu tun haben: die Wanduhr, die Dokument-ID, den kryptografischen Zufallszahlengenerator und die Speicherreihenfolge der Dictionary-Einträge. Jede ist für sich legitim. ISO 32000-1 will sie dort. Sie machen die Datei schlicht zu einer Funktion davon, wann und wo sie geschrieben wurde, statt davon, was sie enthält
- Die Uhr. Das Info-Dictionary trägt
/CreationDateund/ModDate(ISO 32000-1 §14.3.3, Tabelle 317) alsD:YYYYMMDDHHmmSS-Zeichenketten mit Zeitzonen-Suffix (§7.9.4), und das XMP-Paket wiederholt denselben Zeitpunkt alsxmp:CreateDateundxmp:ModifyDate. HotPDF stempelt beide ausFCreationDate, das der Konstruktor mitNowinitialisiert, also unterscheiden sich die beiden Saves in der Sekunde, in der sie geschrieben wurden - Die ID. Das Trailer-
/ID-Array (ISO 32000-1 §14.4) hält eine permanente ID und eine Änderungs-ID. HotPDFs Standardrezept hasht für das erste Element den Dateinamen zusammen mit der aktuellen Zeit auf die Millisekunde und für das zweite das plusGetTickCount. Zwei IDs, zwei frische Werte bei jedem Lauf - Die Zufallsbytes. Standard-Security hängt an der ID und an echter Zufälligkeit. Bei AES-256 kommen der Datei-Verschlüsselungsschlüssel, die Validierungs- und Schlüssel-Salts und jeder CBC-Initialisierungsvektor aus der System-Zufallsquelle (ISO 32000-2 §7.6.4.4.7 verlangt zufällige Salts). Weil
/U,/UE,/Ound/OEalle aus diesen Bytes berechnet werden, ändert sich ein verschlüsseltes Dokument in seiner Gesamtheit, selbst wenn der Klartext es nicht tut. Die älteren Algorithmen falten das erste/ID-Element in den Schlüssel (ISO 32000-1 §7.6.3.3, §7.6.3.4), also reicht eine frische ID allein, um die Datei neu zu verschlüsseln - Die Ordnung. Ein PDF-Dictionary ist eine ungeordnete Zuordnung, und ein Writer, der seine In-Memory-Liste durchläuft, emittiert Keys in Einfügereihenfolge. Jeder Codepfad, der ein Resource-Dictionary in anderer Sequenz baut, oder ein geladenes Dokument, das aus anderem Layout geparst wurde, produziert eine legale, aber textuell andere Datei
Was legt ReproducibleOutput fest?
ReproducibleOutput := True vor BeginDoc oder vor SaveLoadedDocument zu setzen ersetzt jede der vier Quellen durch einen festen Wert, und zwar in denselben Codepfaden, die sonst zur Uhr oder zum Zufallsgenerator greifen würden, also braucht es keinen separaten Aufräum-Durchlauf. Beachten Sie, was in der Liste oben fehlt: der Inhalt. Fonts, Seitenstreams, Bilddaten und die Cross-Reference-Tabelle sind für dieselbe Eingabe bereits deterministisch; das Rauschen lebt vollständig in den Metadaten und der Security-Schicht, deshalb kann eine gezielte Eigenschaft es entfernen. Die Eigenschaft steht als Default auf False, und nichts in der Bibliothek 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; // vor BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Innerhalb von BeginDoc weist der Reproducible-Zweig FCreationDate := EncodeDate(2026, 1, 1) zu und seeded die Dokument-ID mit MD5CalcString('HotPDF-reproducible-seed') statt des Dateiname-plus-Uhr-Digests. Diese eine Zuweisung deckt beide Info-Daten und beide XMP-Daten ab, denn alle vier werden aus demselben Feld gerendert. Wird die Datei schließlich geschrieben, fragt BuildDocumentIdentifiers bei ComputeCanonicalDocumentIdentifier nach der Trailer-ID: Er exportiert den ganzen Objektgraphen in kanonischer Reihenfolge, nullt die Ziffern jeder D:-Datumszeichenkette, die er findet, damit die Zeitstempel nicht über den Hash zurückleaken können, und nimmt den MD5 des Ergebnisses. Beide Elemente von /ID erhalten diesen Wert. Dieselbe inhaltsabgeleitete ID kommt zum Einsatz, wenn ein geladenes Dokument verschlüsselt wird, ohne je BeginDoc zu passieren – der Fall bei ActivateProtection auf einer Datei, die Sie mit LoadFromFile geöffnet haben
Die Zufallsbytes sind die am wenigsten offensichtliche Substitution. Die AES-256-Schlüsselroutine hüllt ihre Zufallsquelle in einen lokalen Helfer, der unter dem Flag FillChar(P^, Count, $5A) für den 32-Byte-Datei-Verschlüsselungsschlüssel und für jeden 8-Byte-Salt aufruft, und die AES-128- und AES-256-String- und Stream-Encryptoren wechseln von AESGenerateRandomIV zu AESGenerateStaticIV, das den Initialisierungsvektor mit 14 * (1 + I) für Slot I füllt. Mit Schlüssel, Salts und Vektoren alle fest kommen /U, /UE, /O, /OE und jeder verschlüsselte Stream beim zweiten Lauf identisch heraus. Schließlich schaltet SaveToStream DeterministicDictionaryOrder ein, sobald der Reproducible-Flag gesetzt ist, und der Serializer insertion-sortiert dann jedes Dictionary nach den rohen Bytes seiner Key-Namen, kürzeres Präfix zuerst, mit dem Originalindex als Tie-Breaker. Das ist dieselbe Ordnung, die der Diagnose-Writer nutzt, beschrieben im Artikel zum Von-Hand-Bearbeiten eines PDFs und anschließenden Reparieren; der Reproducible-Flag borgt sich nur die Ordnung, nicht den Rest des Plain-Text-Layouts jenes Writers
Warum sickerte die Wanduhr trotz festen Datums durch?
Der Fix in v2.752.2 existiert, weil das feste Erstellungsdatum ursprünglich im Konstruktor entschieden wurde, und der Konstruktor kann eine Eigenschaft nicht kennen, die der Aufrufer noch nicht gesetzt hat. Die normale Aufruffolge ist Create, dann ReproducibleOutput := True, dann BeginDoc. Zum Konstruktionszeitpunkt ist FReproducibleOutput noch False, also bekam FCreationDate Now und behielt es. ID und Zufallsbytes waren korrekt festgenagelt, also stimmten die beiden Dateien fast überall überein und unterschieden sich in genau zwei Datumszeichenketten und zwei XMP-Feldern. Die Zuweisung in den Reproducible-Zweig von BeginDoc zu verlegen, neben die geseedete ID, setzte die Entscheidung an den Punkt, an dem die Eigenschaft ihren Endwert hat
Der Regressionstest, der das verpasst hat, ist mehr wert als der Fix. Zwei Saves, die beide innerhalb derselben Wanduhr-Sekunde laufen, schreiben zufällig dieselbe D:-Zeichenkette, und der Bytevergleich besteht für einen Bug, der auf jeder langsameren Maschine scheitert. Der korrigierte Test schläft 1100 ms zwischen den beiden Saves, damit der PDF-Zeitstempel garantiert eine Sekundengrenze überschreitet, fährt den Fall für Plain-, AES-128- und AES-256-Ausgabe mit echten Passwörtern bei den beiden verschlüsselten Varianten und vergleicht die beiden Buffer mit CompareMem, wobei er beim Fehlschlag den ersten abweichenden Offset meldet, damit der Diff auf ein konkretes Objekt zeigt statt auf eine ganze Datei. Ein Bytevergleich beweist Determinismus und sonst nichts, also halten Sie eine separate Assertion bereit, die die verschlüsselte Ausgabe mit dem Benutzer-Passwort neu lädt und eine Seitenzahl liest; eine Änderung, die die Datei gleichzeitig stabil und unlesbar macht, darf nicht durch die Kraft eines grünen Diffes durchrutschen
function SaveOnce(const Target: string): TBytes;
var
Pdf: THotPDF;
Stream: TFileStream;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := aes256;
Pdf.ActivateProtection := True;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, Stream.Size);
if Stream.Size > 0 then
Stream.ReadBuffer(Result[0], Stream.Size);
finally
Stream.Free;
end;
end;
// im Testkörper
A := SaveOnce(PathA);
TThread.Sleep(1100); // eine andere PDF-Zeitstempel-Sekunde erzwingen
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
'two saves under ReproducibleOutput must be byte-identical');
Ist ein reproduzierbares verschlüsseltes PDF noch sicher?
Nein. Ein unter ReproducibleOutput verschlüsseltes Dokument ist in keinem sinnvollen Sinn geschützt, und der Flag muss für alles aus sein, was das Testverzeichnis verlässt. Der AES-256-Datei-Verschlüsselungsschlüssel sind zweiunddreißig Bytes $5A, die Salts sind acht Bytes $5A, und die Initialisierungsvektoren folgen einem veröffentlichten arithmetischen Muster. Das Passwort schaltet weiterhin die /UE- und /OE-Hüllen frei, aber der eingepackte Schlüssel ist eine Konstante, also kann jeder, der die Konstante kennt, jeden Content-Stream ohne jedes Passwort entschlüsseln. Dass die Salts fest sind, nimmt zudem die dokumentindividuelle Einzigartigkeit, auf die sich ISO 32000-2 §7.6.4.4.7 verlässt, damit identische Passwörter nicht identische /U-Zeichenketten über Dateien hinweg liefern. Lesen Sie den Artikel zur AES-256-Einrichtung für das, was die Verschlüsselungseigenschaften versprechen, solange die Zufallsquelle intakt ist; unter dem Reproducible-Flag sind diese Versprechen ausgesetzt
Der ID-Trade-off ist subtiler. ISO 32000-1 §14.4 sieht vor, dass das zweite /ID-Element sich bei jeder Änderung wandelt, damit Werkzeuge eine aktualisierte Datei von ihrem Vorfahren unterscheiden können, und ein reproduzierbarer Save schreibt denselben Wert in beide Slots. Weil dieser Wert ein Hash des kanonischen Objektgraphen ist, bekommen zwei Dokumente mit unterschiedlichem Inhalt weiterhin unterschiedliche IDs, was besser ist als eine Konstante. Aber der Seed, den BeginDoc für die Schlüsselableitung nutzt, ist für jedes Dokument auf jeder Maschine derselbe String, und ein Reader, der über /ID Dateien unterscheidet – ein Annotation-Cache oder ein Formdaten-Sidecar etwa –, wird jedes reproduzierbare File verwechseln, das zufällig denselben Hash hat
Was deckt der Flag nicht ab?
ReproducibleOutput entfernt die Entropie, die der Writer selbst einführt; Entropie, die über die Umgebung oder über Codepfade hineinkommt, die er nicht kontrolliert, kann er nicht entfernen, und drei davon sind leicht zu stolpern
- Das Zeitzonen-Suffix.
_DateTimeToPdfDatehängt den lokalen UTC-Offset an, also sindD:20260101000000+08'00'auf einem Build-Agent undD:20260101000000-05'00'auf einem anderen unterschiedliche Bytes für dasselbe feste Datum. Reproduzierbarkeit hält über Läufe auf einer Maschine oder über Maschinen, die eine Zeitzone teilen; pinnen Sie die Zone des Agents, wenn Ihre Golden-Files reisen - Inkrementelle Updates.
SaveIncrementalUpdateberechnet seine Änderungs-ID aus dem Zielpfad,GetTickCountund der aktuellen Zeit, ohne Reproducible-Zweig, denn ein inkrementeller Abschnitt ist per Definition eine neue Änderung. Vergleichen Sie Full Rewrites, nicht angehängte Deltas - Der Passthrough-Shortcut.
SaveLoadedDocumentkopiert eine unveränderte, unverschlüsselte Quelldatei normalerweise byte für byte, statt sie neu zu serialisieren. Der Reproducible-Flag deaktiviert diesen Shortcut und erzwingt ein Full Rewrite, damit die Ordnungs- und ID-Regeln greifen, was bedeutet, dass der reproduzierbare Save einer geladenen Datei langsamer ist als der Default und nie eine Kopie der Eingabe ist. Diffen Sie ihn gegen einen früheren reproduzierbaren Save, nie gegen das Original
Noch eine Lehre aus demselben Release, darüber, was eine bestehende Prüfung beweist und was nicht. Ein PDF/X-6-Testfixture rief CharProcs.DeleteValue('A') auf, was einen direkt gehaltenen Glyph-Stream freigab, dann denselben Pointer wieder einfügte, und reichte separat ein direktes ExtGState-Objekt sowohl an ein Resource-Dictionary als auch an ein Pattern weiter. Der Konformitätsvalidator bestand zeitweise auf jenem Use-after-free und der Doppelbesitz, weil er las, was der freigegebene Speicher zufällig hielt. Wenn eine strukturelle Prüfung flackert, sehen Sie sich das Eigentum der Testeingabe an, bevor Sie sich den Validator ansehen. Reproduzierbare Ausgabe macht diese Disziplin billiger: Sind zwei Saves einmal byte-identisch, ist die einzige verbleibende Quelle eines Flackerns der Objektgraph selbst, und ein struktureller Diff ab dem Katalog abwärts findet ihn
Die hier beschriebenen Eigenschaften ReproducibleOutput, DeterministicDictionaryOrder und Verschlüsselung kommen in der Standard-HotPDF Delphi Component für Delphi und C++Builder daher, und derselbe Flag treibt das eigene Regression-Korpus der Bibliothek, also ist das Verhalten, das Sie in einer Testsuite bekommen, das Verhalten, mit dem die Komponente getestet wird