Technischer Artikel

Reproduzierbarer PDF-Output in Delphi: Byte-identische Saves

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 /CreationDate und /ModDate (ISO 32000-1 §14.3.3, Tabelle 317) als D:YYYYMMDDHHmmSS-Strings mit Zeitzonen-Suffix (§7.9.4), und das XMP-Paket wiederholt denselben Moment als xmp:CreateDate und xmp:ModifyDate. Alle werden aus einem einzigen Erstellungszeitstempel gerendert, den die Komponente bei ihrer Konstruktion aus Now nimmt
  • 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 /Perms und 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
Die vier Entropiequellen, durch die sich zwei HotPDF-Saves desselben Dokuments unterscheiden: FCreationDate, gestempelt aus Now, füttert die D:-Daten und das XMP-Paket, das Trailer-/ID hasht Dateiname, Uhr und GetTickCount, AES zieht Schlüsselmaterial aus der System-Zufallsquelle, und Dictionarys serialisieren in Einfügereihenfolge im Speicher
Jede Quelle ist für sich legitim und ISO 32000-1 will sie dort, doch zusammen machen sie die Datei zu einer Funktion davon, wann und wo sie geschrieben wurde, statt davon, was sie enthält

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

Wovon ReproducibleOutput in HotPDF festnagelt: Das Erstellungsdatum wird zu EncodeDate 2026, 1, 1, die Trailer-ID kommt von ComputeCanonicalDocumentIdentifier über den kanonischen Graphen mit genullten D:-Ziffern, AES-Schlüssel und Salts füllen mit $5A-Bytes, AESGenerateStaticIV füllt jeden Slot, und DeterministicDictionaryOrder sortiert jedes Dictionary
Die Substitutionen laufen in denselben Codepfaden, die sonst zur Uhr oder zum Zufallsgenerator greifen würden, also braucht es keinen separaten Aufräum-Durchlauf, und beide /ID-Elemente erhalten denselben inhaltsabgeleiteten Wert

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

Wo HotPDF-reproduzierbare Saves enden: _DateTimeToPdfDate hängt weiterhin den lokalen UTC-Offset an, also unterscheiden sich Golden-Files über Zeitzonen hinweg, SaveIncrementalUpdate hat keinen Reproducible-Zweig, weil ein Delta eine neue Änderung ist, und der Passthrough-Shortcut ist deaktiviert, sodass eine geladene Datei immer voll umgeschrieben wird
Reproduzierbarkeit hält über Läufe auf einer Maschine oder über Maschinen, die eine Zone teilen, und ein reproduzierbarer Save sollte gegen einen früheren reproduzierbaren Save gedifft werden, nie gegen die ursprüngliche Eingabe
  • 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 als D: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. SaveIncrementalUpdate behä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. SaveLoadedDocument kopiert 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. AddDocumentAttachment stempelt /Params /CreationDate und /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