Technischer Artikel

Byte-identische PDF-Ausgabe: reproduzierbare Saves in Delphi

Die PDFium Component macht reproduzierbare PDF-Ausgabe zu einem einzigen Aufruf: TPdf.SaveAsReproducible schreibt das Dokument so um, dass zweimal derselbe Inhalt byte-identische Dateien ergibt. Sie entfernt /CreationDate und /ModDate aus dem Document-Information-Dictionary, schreibt keine Trailer-/ID, hält die Objektreihenfolge stabil und meldet, was sie geändert hat, in einem TPdfReproducibleReport. Verschlüsselte Dateien werden abgelehnt, signierte brauchen ausdrückliche Zustimmung

Der Bedarf zeigt sich meist in einer Build-Pipeline. Jemand checkt generierte PDFs in ein Repository oder eine Golden-File-Testsuite ein, und jeder Lauf produziert einen Diff, obwohl sich auf keiner Seite etwas geändert hat. Oder ein Archiv dedupliziert per SHA-256 und legt still fünfzig Kopien derselben Rechnung ab, weil jeder Save eine neue Zeit in die Datei gestempelt hat. Ein PDF-Writer, der die Wanduhr einbettet, ist für eine Desktop-App in Ordnung und feindlich allem gegenüber, was Bytes als Identität behandelt

Warum erzeugt zweimal dasselbe PDF zu speichern unterschiedliche Bytes?

Zwei Saves eines unveränderten Dokuments unterscheiden sich wegen genau zweier Arten von Daten: Zeitstempeln im Document-Information-Dictionary und dem Datei-Identifier im Trailer. Alles andere, was ein sorgfältiger Writer ausgibt – Objektnummerierung, Objektreihenfolge, Cross-Reference-Layout – lässt sich deterministisch machen, und der Full-Rewrite-Pfad der PDFium Component tut das bereits: Objects werden in aufsteigender Objektnummer geschrieben, und die xref-Table folgt derselben Ordnung

Die Zeitstempel sind der offensichtliche Täter. ISO 32000-1 §14.3.3 definiert /CreationDate und /ModDate im /Info-Dictionary, und der native PDFium-Save schreibt bei jeder Serialisierung des Dokuments ein frisches /ModDate. Der Datei-Identifier ist der subtile. ISO 32000-1 §14.4 beschreibt /ID als zweielementiges Array aus Byte-Strings, wobei das erste Element die Originaldatei identifiziert und das zweite sich ändert, wenn die Datei revidiert wird. Writer leiten diese Strings typischerweise ab, indem sie etwas über die Datei hashen; gehört das Etwas zu den mit Zeitstempeln versehenen Bytes, erbt der Identifier den Nichtdeterminismus

TPdf.SaveAsReproducible in Delphi benutzen

TPdf.SaveAsReproducible hat zwei Overloads, einen, der in einen TStream schreibt, und einen für einen Dateinamen; beide geben Boolean zurück mit einem out Report: TPdfReproducibleReport und einem optionalen Parameter AllowSignedDocument, der auf False defaultet. Der Rückgabewert spiegelt Report.Succeeded; ist er False, sagt Report.ErrorMessage warum. Der Datei-Overload schreibt in eine temporäre Datei neben dem Ziel und ersetzt das Ziel erst nach einem erfolgreichen Save; ein fehlgeschlagener Lauf hinterlässt also kein halb geschriebenes PDF dort, wo früher Ihre Build-Ausgabe lag

var
  Pdf: TPdf;
  Report: TPdfReproducibleReport;
begin
  Pdf:= TPdf.Create(nil);
  try
    Pdf.FileName:= 'invoice-2026-10.pdf';
    Pdf.Active:= True;
    if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
      Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
        '%d timestamps stripped', [Report.SourceByteCount,
        Report.OutputByteCount, Report.KeptObjectCount,
        Report.PrunedObjectCount, Report.StrippedTimestampCount]))
    else
      Writeln('Reproducible save failed: ', Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

Die Report-Felder lohnen das Loggen in CI. StrippedTimestampCount ist normalerweise 2 bei einem Dokument, dessen /Info beide Daten trägt, und 0 bei einem ohne /Info-Dictionary überhaupt. PrunedObjectCount zählt Objects, die im nativen Save vorhanden waren, aber weder vom Catalog noch vom /Info-Dictionary aus erreichbar sind; der reproduzierbare Rewrite wirft sie weg, die Ausgabe ist also höchstens so groß wie ein gewöhnlicher Save

Warum das Entfernen der Zeitstempel nicht reichte

Die erste Implementierung entfernte beide Zeitstempel und produzierte trotzdem zwei verschiedene Dateien. Die Seiten passten, das /Info-Dictionary passte, und die Trailer unterschieden sich im zweiten Element von /ID. Die Wurzel war die Reihenfolge: Der Rewrite fragt zuerst PDFium nach einem nativen Save und verarbeitet diese Bytes nach, und der Trailer-Builder berechnete /ID, indem er die native Ausgabe hashte. Diese native Ausgabe enthielt noch das frische /ModDate; Hash und damit Identifier änderten sich also bei jedem Lauf, obwohl der Zeitstempel selbst nie die finale Datei erreichte

Das ist ein gutes Beispiel für eine allgemeine Regel reproduzierbarer Builds: Einen nichtdeterministischen Wert zu entfernen genügt nicht, wenn vorher irgendetwas daraus abgeleitet wurde. Der Fix war, /ID ganz aufzugeben. TPdf.SaveAsReproducible schreibt einen Trailer, der nur /Size, /Root und, wenn die Quelle einen hat, /Info enthält. Bleibt im Trailer nichts Zeitabhängiges übrig, vergleichen sich zwei aufeinanderfolgende Saves desselben Dokuments byte für byte gleich – genau so wird das Verhalten verifiziert

function SavesAreIdentical(Pdf: TPdf): Boolean;
var
  A, B: TMemoryStream;
  RA, RB: TPdfReproducibleReport;
begin
  A:= TMemoryStream.Create;
  B:= TMemoryStream.Create;
  try
    Result:= Pdf.SaveAsReproducible(A, RA) and
      Pdf.SaveAsReproducible(B, RB) and
      (A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
  finally
    B.Free;
    A.Free;
  end;
end;

Dictionary-Keys entfernen, ohne Strings zu korrumpieren

Die Zeitstempel werden durch einen tokenbewussten Scan des /Info-Object-Bodys entfernt, nicht durch Textsuche. Eine naive Byte-Ersetzung für /ModDate träfe auch einen /Subject- oder /Keywords-String, der zufällig diesen Text enthält, und Bytes aus einem Literal-String herauszuschneiden korrumpiert die Metadaten still, statt laut zu failen. Die PDFium Component neutralisiert zuerst Literal-Strings und Kommentare in einer Scan-Kopie des Bodys, akzeptiert einen Treffer dann nur, wo ein echter Name-Token beginnt, benutzt die PDF-Objektgrammatik, um das Ende des Werts dieses Keys zu finden, und überspringt das ganze Key-Value-Paar

Die praktische Konsequenz: Jeder andere Eintrag in /Info – Titel, Autor, Producer, Custom-Keys – überlebt unangetastet. Nur die zwei als Datum definierten Keys werden entfernt, und zwar nur aus dem Dictionary, auf das der /Info-Eintrag des Trailers tatsächlich zeigt

Was kostet das Weglassen von /ID bei verschlüsselten und signierten PDFs?

/ID wegzulassen ist für schlichte PDF-1.x-Ausgabe sicher, schließt aber Verschlüsselung aus und ändert, was mit Signaturen passiert. In ISO 32000-1 ist die Trailer-/ID optional, solange kein /Encrypt-Eintrag vorhanden ist, denn der Standard-Security-Handler mischt den ersten Identifier-String in den Encryption-Key ein. Eine Datei ohne /ID kann keine korrekt verschlüsselte Datei sein; TPdf.SaveAsReproducible versucht es also gar nicht: Trägt der Trailer /Encrypt, gibt sie False zurück, und Report.ErrorMessage sagt, das Dokument sei verschlüsselt. Entschlüsseln Sie zuerst, wenn Ihre Pipeline es erlaubt, oder halten Sie verschlüsselte Lieferobjekte aus Byte-Identitäts-Prüfungen heraus. Zielen Sie auf PDF 2.0, beachten Sie, dass ISO 32000-2 die Regel verschärft und /ID in jeder Datei verlangt; reproduzierbare Ausgabe passt also am natürlichsten zu PDF-1.x-Zielen. Wie die Komponente mit Versionszielen umgeht, behandelt der Artikel zu exakter PDF-Versions-Konformität in Delphi

Signierte Dokumente sind ein anderes Problem. TPdf.SaveAsReproducible ist ein voller, nicht-inkrementeller Rewrite; die Byte-Offsets wandern, und der /ByteRange der bestehenden Signaturen deckt die signierten Bytes nicht mehr ab. Reproduzierbare Ausgabe einer signierten Datei ist deshalb eine bewusste Entscheidung, die Gültigkeit der bestehenden Signaturen gegen stabile Bytes einzutauschen – deshalb wirft die Methode EPdfError, wenn SignatureCount größer als null ist, außer Sie übergeben AllowSignedDocument = True

if Pdf.SignatureCount > 0 then
  // Explizites Opt-in: der Rewrite verschiebt Byte-Offsets, bestehende
  // Signaturen dürften danach die Verifikation also nicht bestehen
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);

Ist das signierte Original der Wahrheits-Record, lassen Sie es, wie es ist, und benutzen reproduzierbare Ausgabe nur für abgeleitete Kopien, etwa eine normalisierte Rendition, die Sie in CI diffen. Für das Anfügen an eine geschützte Datei, ohne anzufassen, was schon da ist, siehe warum sich eine verschlüsselte PDF in Delphi nicht still patchen lässt

Grenzen, die man kennen sollte, bevor man sich auf Byte-Identität verlässt

Byte-Identität gilt für dasselbe Dokument, gespeichert vom selben Component-Build, und sie deckt exakt das ab, was die Methode anfasst. TPdf.SaveAsReproducible entfernt nur die beiden /Info-Daten; ein XMP-Metadaten-Stream mit eigenem xmp:CreateDate oder xmp:ModifyDate wird mit genau den Bytes durchgereicht, die das Dokument schon hält. Das ist über Saves stabil, heißt aber, dass XMP- und /Info-Daten nicht mehr dasselbe beschreiben – relevant für PDF/A-Profile, die verlangen, dass beide übereinstimmen; der Artikel zu PDF/A-Info- und XMP-Metadaten-Äquivalenz erklärt diese Regeln. Eine andere PDFium-Binärdatei oder Component-Version kann Objects zudem anders serialisieren; pinen Sie beides in jeder Pipeline, die Hashes maschinenübergreifend vergleicht

Innerhalb dieser Grenzen liefert die Methode, was CI-Diffs, Golden-File-Tests und Content-Addressed-Archive brauchen: gleiche Eingabe, gleiche Bytes. TPdf.SaveAsReproducible und TPdfReproducibleReport kommen mit der PDFium Component for Delphi and C++Builder, zusammen mit dem Rest ihrer Save-, Optimierungs- und Compliance-API

FAQ

Ändert TPdf.SaveAsReproducible den Seiteninhalt?

Nein. Sie entfernt die Keys /CreationDate und /ModDate aus dem /Info-Dictionary, lässt die Trailer-/ID weg und wirft Objects weg, die vom Catalog oder /Info aus unerreichbar sind; der Seiteninhalt wird unverändert geschrieben

Kann ich aus einer verschlüsselten PDF reproduzierbare Ausgabe erzeugen?

Nein. Verschlüsselung hängt an der Trailer-/ID, die reproduzierbare Ausgabe weglässt; die Methode gibt für verschlüsselte Quellen also False mit einer erklärenden ErrorMessage zurück

Warum braucht eine signierte PDF AllowSignedDocument?

Der reproduzierbare Save ist ein voller Rewrite, der Byte-Offsets verschiebt; bestehende Signaturen matchen ihre signierten Byte-Ranges also nicht mehr. AllowSignedDocument = True zu übergeben bestätigt, dass Sie diesen Tausch akzeptieren