Technischer Artikel

Deterministische PDF-ID in Delphi für reproduzierbare Builds

Die losLab-PDF-Library kann für identische Eingaben byte-identische PDF-Ausgaben erzeugen, sobald man SetDeterministicDocumentID(1) aufruft. Standardmäßig ist das Trailer-/ID-Array ein MD5-Digest der Systemzeit, sodass sich zwei Läufe desselben Generators mindestens in diesen Bytes unterscheiden. Der deterministische Modus leitet /ID stattdessen von einem stabilen Seed ab, was reproduzierbare Builds wiederherstellt

Das Symptom zeigt sich meist in CI, bevor jemand gezielt danach sucht. Das Template hat sich nicht geändert, der Eingabedatensatz hat sich nicht geändert, die Schriften haben sich nicht geändert, und die erzeugte PDF-Datei hasht bei jedem Pipeline-Lauf trotzdem anders. Build-Caches greifen nie. Content-addressable Storage sammelt pro nächtlichem Build einen frischen Blob an. Byte-genaue Regressionsdiffs schlagen bei Dateien an, die niemand angerührt hat. Verfolgt man den Diff bis zu den tatsächlichen Bytes zurück, sind es fast immer dieselbe Handvoll Hex-Ziffern im Datei-Trailer

Wozu das Trailer-ID-Array dient

Das Trailer-/ID ist ein Dateiidentitätsmerkmal, keine Prüfsumme des Inhalts. ISO 32000-1 §14.4 definiert es als Array aus zwei Byte-Strings: Das erste Element ist der permanente Bezeichner, der beim Erstellen des Dokuments vergeben wird und jede spätere Bearbeitung überdauern soll, und das zweite Element ist der veränderliche Bezeichner, den ein Writer bei jeder Änderung der Datei auffrischt. Zusammen erlauben sie einem System zu entscheiden, ob zwei Dateien Revisionen eines Dokuments oder zwei unabhängige Dokumente sind. §7.5.5 macht den Eintrag in der Praxis effektiv verpflichtend, da der Trailer /ID tragen muss, sobald er auch /Encrypt trägt

Nichts in der Spezifikation sagt, wie der Wert zu berechnen ist. Die Empfehlung ist ein Digest aus Dingen wie der aktuellen Zeit, dem Dateipfad, der Dateigröße und dem Dokumentinformations-Dictionary, und die Systemzeit ist die Zutat, die das Ergebnis eindeutig macht. Das ist genau die Eigenschaft, die man sich für Identität wünscht, und genau die Eigenschaft, die Reproduzierbarkeit zerstört, weshalb das ein expliziter Schalter sein muss statt eine stille Verhaltensänderung

Warum erzeugt derselbe Build jedes Mal eine andere PDF-Datei?

Weil der Standard-Bezeichner vom Moment der Erzeugung abgeleitet wird. Historisch baute die losLab-PDF-Library die /ID-Strings aus einem MD5 des aktuellen Zeitstempels auf, sodass ein zweimal im Abstand einer Sekunde erstelltes Dokument zwei unterschiedliche permanente Bezeichner trägt, selbst wenn jedes andere Byte in der Datei identisch ist. Die nachgelagerten Kosten sind real: Ein Build-System, das Artefakte über Hashes referenziert, kann einen PDF-Schritt nie wiederverwenden, ein deduplizierender Object-Store behält eine Kopie pro Build statt eine Kopie pro Dokument, und ein Reviewer, der auf einen binären Diff schaut, muss beweisen, dass die einzige Änderung Rauschen ist, bevor er dem Rest des Diffs vertraut. Deterministische /ID-Erzeugung existiert, um dieses Rauschen zu entfernen, im selben Geist wie die Arbeit an Layout-Stabilität, beschrieben in den Notizen zu Object Streams und Cross-Reference-Streams

Auf einen reproduzierbaren Bezeichner umschalten

Der deterministische Modus ist Opt-in, pro Dokument, und standardmäßig aus, sodass bestehende Ausgaben unverändert bleiben, bis man ihn anfordert. SetDeterministicDocumentID akzeptiert 0 oder 1 und liefert 1 zurück, wenn der Wert akzeptiert wurde, 0 für alles außerhalb des Bereichs; GetDeterministicDocumentID meldet den aktuellen Zustand. SetDocumentIDSeed liefert einen expliziten Seed-String, der über allem anderen gewinnt, und die Übergabe eines leeren Seeds kehrt zum abgeleiteten Seed zurück. GetDocumentFileID liest /ID[0] nach dem Speichern zurück, sodass man es protokollieren oder darauf assertieren kann

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

Die Auffrischung geschieht beim Speichern, nicht beim Umlegen des Flags, sodass das späte Aktivieren des deterministischen Modus während eines Dokument-Builds trotzdem wirkt. Das bedeutet auch, dass ein geänderter Seed erst beim nächsten vollständigen Speichern in die Datei gelangt: Seed A setzen, speichern, Seed B setzen, speichern, und die beiden Dateien tragen unterschiedliche Bezeichner, während das Wiederherstellen von Seed A den ursprünglichen Wert wiederherstellt. Ein expliziter Seed ist die richtige Wahl, wann immer das eigene Dokument einen natürlichen stabilen Schlüssel hat, etwa eine Rechnungsnummer, eine Datensatz-Revision oder einen Git-Commit-Bezeichner, weil das den Bezeichner von beiläufigen Metadaten entkoppelt

Woher kommt der Seed, wenn man keinen selbst liefert?

Ohne expliziten Seed leitet die losLab-PDF-Library einen aus Dokumentzustand ab, der bei identischen Neuerzeugungen invariant sein sollte: der PDF-Versions-Header, die Seitenzahl und jeder Eintrag im Dokumentinformations-Dictionary. String- und Namenswerte werden wörtlich übernommen, andere Objekttypen tragen ihre serialisierte Form bei, und das Ganze wird in die /ID-Strings gehasht. Die wichtige Konsequenz ist, dass CreationDate und ModDate Teil des Informations-Dictionary und daher konstruktionsbedingt Teil des Seeds sind. Zwei Läufe erhalten nur dann denselben Bezeichner, wenn sie wirklich dieselben Dokumentmetadaten erzeugen

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
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');

Das Fixieren von ModDate mit Schlüssel 8 leistet doppelte Arbeit, und das ist der Teil, der viele auf dem falschen Fuß erwischt. Eine deterministische /ID allein macht die Datei nicht byte-identisch, weil der Speicherpfad ModDate mit der aktuellen Zeit stempelt, sofern der Aufrufer sie nicht explizit gesetzt hat. Schlüssel 8 zu setzen markiert den Wert als vom Aufrufer geliefert und unterdrückt diesen Stempel. Wer eine reproduzierbare Datei will, nicht nur einen reproduzierbaren Bezeichner, sollte Metadaten-Zeitstempel als Build-Eingaben behandeln: aus dem Quelldatensatz oder aus einer festen Epoche ableiten, niemals aus Now

Warum bricht das Neuschreiben der ID eine verschlüsselte PDF-Datei?

Weil /ID[0] in einem verschlüsselten Dokument nicht bloß Metadaten sind, sondern Schlüsselmaterial. 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. Der abgeleitete Schlüssel erzeugt dann den /U-Validierungsstring, den ein Reader beim Öffnen prüft, und der Dateischlüssel wird abgeleitet und zwischengespeichert, wenn man Encrypt aufruft oder wenn ein verschlüsseltes Dokument geladen wird, beides geschieht vor dem Speichern. Den Bezeichner während des Speicherns neu zu schreiben, würde daher eine strukturell gültige Datei erzeugen, deren /U-Prüfung beim erneuten Öffnen fehlschlägt: keine subtile Beschädigung, sondern ein Dokument, das niemand öffnen kann, auch man selbst nicht. Deshalb ist die deterministische Auffrischung auf Dokumente beschränkt, die keinen Verschlüsselungszustand tragen, und deshalb behält ein verschlüsseltes Dokument, welches /ID es auch immer schon hatte, deterministischer Modus hin oder her, und die Einstellung hat auf diesen Pfad schlicht keine Wirkung. Die zugehörige Revisionsbehandlung und Berechtigungssemantik wird in der Durchsicht zu PDF-Verschlüsselung und Berechtigungsprüfung behandelt. Zu beachten ist auch, dass der Verschlüsselungs-Wiederherstellungspfad nur /ID[1], den Änderungsbezeichner, auffrischt, genau wie in §14.4 vorgesehen

Warum inkrementelle Speicherungen den ursprünglichen Bezeichner behalten

Die zweite Grenze ist der Append-Modus. Ein inkrementelles Update lässt jedes frühere Byte der Datei unangetastet und schreibt eine neue Revision dahinter, und die Beständigkeit von /ID[0] nach §14.4 ist es, die einem Konsumenten sagt, dass die neue Revision zum selben Dokument gehört wie die alte. Es neu zu schreiben, würde diese Verbindung kappen, den bereits in der Datei sitzenden Revisionen widersprechen und mit Signatursemantik kollidieren, da eine Signatur einen Byte-Bereich einer bestimmten Revision eines bestimmten Dokuments abdeckt. Die losLab-PDF-Library frischt den deterministischen Bezeichner deshalb nur bei vollständigen Speicherungen auf und nie während des Append-Modus, was die im Artikel zu inkrementellen PDF-Updates und Append an einen Stream beschriebene Garantie intakt hält

Ein Engpasspunkt für die Bezeichner-Erzeugung

Jede /ID-Erzeugung in der losLab-PDF-Library läuft nun durch eine einzige interne Routine, NewFileIDString, was den deterministischen Schalter vertrauenswürdig macht statt zu einem Flicken auf einem einzelnen Codepfad. Leere Dokumenterstellung, verzögerte Erstellung eines fehlenden /ID-Arrays bei Bedarf und der Wiederherstellungspfad des Verschlüsselungs-Fingerabdrucks rufen alle diese Routine auf, sodass es genau eine Stelle gibt, an der die Systemzeit zurücksickern könnte. Es bedeutet auch, dass künftige Varianten, etwa ein inhaltsabgeleiteter Bezeichner, eine Änderung an einer Funktion sind, statt eines Audits des gesamten Serializers

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;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

Diesen Vergleich in die eigene Testsuite verdrahten, bevor man sich anderswo auf reproduzierbare Ausgaben verlässt, denn er schlägt laut an, sobald ein neues Feature einen Zeitstempel wieder einschleppt. Reproduzierbarkeit ist eine Eigenschaft, die sonst still verfällt, und eine einzige Assertion über zwei speicherinterne Speicherungen kostet fast nichts, bei jedem Build ausgeführt zu werden

Die hier gezeigte API für deterministische Bezeichner ist Teil der losLab-PDF-Library für Delphi und C++Builder, zusammen mit der vollständigen Referenz für Dokumentinformation, Verschlüsselung und inkrementelles Speichern