Technischer Artikel

PDF-Rückgängig, Wiederherstellen und Absturzwiederherstellung in Delphi

PDFium Component gibt einem PDF-Editor über ein Bearbeitungsjournal echtes dokumentweites Rückgängigmachen und Wiederholen sowie ein Absturzwiederherstellungspaket. Sie umschließen Änderungen mit BeginEditTransaction und CommitEditTransaction, und jede committete Transaktion wird zu einem bidirektionalen Delta, das UndoEdit und RedoEdit in beide Richtungen abspielen können. SaveRecoverySnapshot persistiert dieses Journal, sodass eine mitten in der Sitzung beendete Anwendung die Arbeit des Benutzers wiederherstellen kann

Die Lücke, die dies schließt, ist spezifisch. Die native Rückgängig-Fähigkeit gilt nur für das Formularfeld, das gerade den Fokus hat, sodass ein Textfeld seine eigenen Bearbeitungen erinnert und sonst nichts. Seiteneinfügung, -löschung und -umordnung, Änderungen an Seitenobjekten, Annotationen, Anhänge und Metadaten haben überhaupt kein Journal. Ein Editor, der allein darauf aufbaut, kann Rückgängig innerhalb eines Feldes anbieten und sonst nirgends, was nicht das ist, was Benutzer unter Rückgängig verstehen

Warum Snapshots statt eines Operationsprotokolls?

Der naheliegende Entwurf besteht darin, semantische Operationen aufzuzeichnen — „Seite 4 gelöscht“, „Annotation 7 verschoben“ — und sie umzukehren. Das funktioniert, bis der Operationssatz wächst; ab diesem Punkt braucht jedes neue Feature eine Umkehrung, und jede Operation, deren Umkehrung nicht exakt ist, beschädigt das Dokument still über eine lange Rückgängig-Kette

Das Journal erfasst stattdessen den Dokumentzustand an Transaktionsgrenzen und speichert die Differenz. Jedes Delta hält das längste gemeinsame Präfix und Suffix fest und trägt sowohl die entfernten als auch die eingefügten Bytes, sodass es vorwärts oder rückwärts angewendet werden kann. Neue Bearbeitungsfunktionen benötigen keine eigene Rückgängig-Unterstützung: Was immer sie ändern, steckt im Snapshot

Diagramm des dokumentweiten PDF-Rückgängigmachens in Delphi, bei dem das Bearbeitungsjournal der PDFium Component bidirektionale Deltas über einem Wurzel-Snapshot stapelt, wobei jeder Eintrag beim Rückgängig- und Wiederholen-Abspielen durch SHA-256-Digests abgesichert ist
Jede committete Transaktion speichert Präfix, entfernte und eingefügte Bytes sowie SHA-256-Digests beider Zustände, sodass ein Eintrag vorwärts oder rückwärts abgespielt werden kann, ohne dass jedes Feature eine eigene Umkehroperation braucht

Dieser Ansatz hängt von einer Voraussetzung ab. Ein gewöhnliches vollständiges Speichern erzeugt nicht-deterministische Kennungen und Zeitfelder, sodass das zweimalige Speichern eines unveränderten Dokuments unterschiedliche Bytes liefern kann. Snapshots werden daher zunächst über den reproduzierbaren Ausgabepfad normalisiert. Ohne diesen Schritt würde eine leere Transaktion wie eine Änderung aussehen, und Rückgängig würde seine eigene Verifikation nicht bestehen, weil der beim Commit berechnete Digest anschließend nicht mehr zum Dokument passen würde

Transaktionen in der Praxis

Jede Transaktion nimmt ein optionales Label entgegen, das ein Editor in seinem Rückgängig-Menü anzeigt:

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfEditJournalOptions;
  Info: TPdfEditJournalInfo;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfEditJournalOptions.Default;
    Options.MaxEntries := 50;               // Rückgängig-Tiefe
    Options.MaxStoredBytes := 256 * 1024 * 1024;
    Pdf.ConfigureEditJournal(Options);

    Pdf.BeginEditTransaction('Delete cover page');
    try
      Pdf.DeletePage(1);
      if not Pdf.CommitEditTransaction then
        raise Exception.Create('commit failed - document was reloaded');
    except
      Pdf.RollbackEditTransaction;
      raise;
    end;

    Info := Pdf.GetEditJournalInfo;
    Writeln(Format('entries=%d position=%d stored=%d bytes',
      [Info.EntryCount, Info.Position, Info.StoredBytes]));

    if Pdf.EditCanUndo then
      Pdf.UndoEdit;                          // die Seite kommt zurück
  finally
    Pdf.Free;
  end;
end;

Ein fehlgeschlagenes Commit — weil die Erfassung fehlschlug, ein Budget überschritten wurde oder das Delta nicht gebaut werden konnte — stellt die bei BeginEditTransaction erfasste Baseline wieder her und lädt neu, statt eine aktive Transaktion oder ein halb geschriebenes Journal zu hinterlassen. Das ist der Fehlermodus, der am meisten zählt, denn ein Journal in inkonsistentem Zustand vergiftet jedes nachfolgende Rückgängig

Verifikation bei jedem Schritt

Jedes Delta speichert den SHA-256-Digest des Dokuments vorher und nachher, zusammen mit der Präfixlänge und beiden Nutzdaten. Rückgängig, Wiederholen, importiertes Abspielen und die umgekehrte Rekonstruktion prüfen alle den aktuellen Digest und den Bereich, den sie ersetzen wollen, bevor sie irgendetwas berühren, und verifizieren anschließend den resultierenden Digest

Die Bewegung durch das Journal ist ebenso vorsichtig. Die Position rückt nur vor, nachdem das Ziel-PDF durch ein unabhängiges Dokumentobjekt validiert wurde und das aktuelle erfolgreich ersetzt hat. Schlägt das fehl, wird der aktuelle Snapshot als Fallback wiederhergestellt, sodass ein fehlgeschlagenes Rückgängig den Benutzer dort belässt, wo er war, statt in einem kaputten Zustand

Das serialisierte Format wird als Daten geprüft, nicht als Eingabe vertraut: Magic Number, Version, Eintrags- und Positionswerte, die Digest-Kette, Label-Längen, Patch-Längen und das Gesamt-Speicherbudget werden alle validiert. Ein importiertes Journal ist eine Datei, die abgeschnitten, bearbeitet oder von einer anderen Version erzeugt worden sein könnte, und sie als feindlich zu behandeln ist die einzig sichere Haltung

Was passiert, wenn das Journal voll ist

Drei Grenzen begrenzen das Wachstum: Eintragsanzahl, kumulative Delta-Bytes und die Größe eines einzelnen Snapshots. Ist die Kapazität erreicht, wird der älteste Eintrag verdrängt und der Wurzel-Fingerprint rückt vor

Dieser letzte Teil hat eine Konsequenz, um die man das Design herum gestalten sollte. Ein exportiertes Journal lässt sich nur auf ein Dokument anwenden, das zu dem von ihm gemeldeten Wurzel-Fingerprint passt, oder zu einem Fingerprint, den es erfasst hat. Sobald eine Verdrängung stattgefunden hat, reicht das Journal nicht mehr bis zur Originaldatei zurück. Autosave muss daher den passenden Wurzel-Snapshot und das Journal als eine atomare Wiederherstellungseinheit behandeln — das Journal ohne seine Basis zu behalten heißt, etwas zu behalten, das nicht abgespielt werden kann

Absturzwiederherstellungspakete

Das Wiederherstellungspaket bettet bewusst nicht das Quell-PDF ein. Es speichert den Anzeigenamen der Quelle, SHA-256-Fingerprints des Quell- und des wiederhergestellten Zustands sowie das Bearbeitungsjournal. Die Wiederherstellung erfordert daher die Originaldatei, die der Aufrufer zuerst lädt; das Paket verifiziert dann beide Fingerprints, indem es das Journal vorwärts und rückwärts abspielt:

Ablauf der Absturzwiederherstellung bei der PDF-Bearbeitung in Delphi: atomare SaveRecoverySnapshot-Schreibvorgänge nach jedem Commit, ein Absturz mitten in der Sitzung und die Fingerprint-Verifikation von RestoreRecoverySnapshot gegen das Original-PDF
Commits ersetzen atomar ein kleines, mit Fingerprint versiegeltes Paket; die Wiederherstellung lädt zuerst das Original-PDF und verifiziert dann beide erfassten Fingerprints, indem sie das Journal in beide Richtungen abspielt
// Nach jedem erfolgreichen Commit die Wiederherstellungsdatei aktualisieren
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
  Pdf.SaveRecoverySnapshot('contract.recovery');

// Nächste Sitzung, nach einem Absturz: das Original laden, dann wiederherstellen
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');

Die Quelle nicht einzubetten hält Wiederherstellungsdateien klein, was Autosave bei jedem Commit auch bei Dokumenten beliebiger Größe erschwinglich macht. Ein äußerer Digest deckt Header, Quellname und Journal ab, sodass ein abgeschnittenes oder manipuliertes Paket abgelehnt wird, bevor überhaupt ein Delta geparst wird, und das gesamte Lesevolumen ist gedeckelt

Dateischreibvorgänge gehen in eine temporäre Datei im Zielverzeichnis und werden dann atomar ersetzt, und das Setzen des Wiederherstellungspfads auf das Quell-PDF wird explizit verweigert. Autosave läuft nur nach einem erfolgreichen, ändernden Commit, Rückgängig oder Wiederholen. Schlägt das Schreiben auf die Festplatte fehl, sind die Dokument- und Journalzustände im Speicher bereits gültig, sodass die Exception einen Persistenzfehler statt eines Datenverlusts meldet, und der Aufrufer kann den Pfad korrigieren und es erneut versuchen

Signierte Dokumente werden standardmäßig verweigert

Ein Transaktions-Snapshot ist eine neue normalisierte Revision, nicht die ursprünglichen signierten Bytes, sodass das Committen einer Transaktion an einem signierten Dokument die Signatur ungültig macht. Das Beginnen einer Transaktion an einem solchen Dokument wird verweigert, sofern der Aufrufer nicht AllowSignedDocument übergibt, und Journal-Import sowie Wiederherstellung folgen standardmäßig derselben Verweigerung

Das ist eine bewusste Bremsschwelle und kein Verbot. Manche Workflows bearbeiten legitim ein signiertes Dokument und signieren es anschließend neu, und das Flag existiert für sie. Was es verhindert, ist der versehentliche Fall, in dem ein Editor stillschweigend die Gültigkeit eines Dokuments entfernt, das ein Benutzer für weiterhin signiert hält. Die Verifikationsseite dieser Geschichte ist unter Prüfen digitaler PDF-Signaturen und PAdES-Level behandelt

Für einen Editor sorgen drei Gewohnheiten dafür, dass sich das Journal lohnt. Umschließen Sie jede für den Benutzer sichtbare Aktion in einer beschrifteten Transaktion, damit sich das Rückgängig-Menü wie das mentale Modell des Benutzers liest statt wie Ihr Call-Stack. Bemessen Sie das Eintragslimit am Dokument, denn fünfzig Snapshots eines 200-MB-Scans sind keine bescheidene Einstellung. Und schreiben Sie den Wiederherstellungs-Snapshot bei jedem Commit statt nach einem Timer, denn ein Timer garantiert, dass die verlorene Arbeit genau das ist, was nach dem letzten Tick passiert ist. Dokumentweite Bearbeitungsoperationen, die davon profitieren, sind unter inkrementelle Updates verschlüsselter PDFs behandelt

Bearbeitungsjournal, Wiederherstellungspakete und die zugrundeliegenden Bearbeitungsprimitiven werden in derselben Komponente für Delphi, C++Builder und Lazarus ausgeliefert; die vollständige API ist auf der Seite der PDFium Component für Delphi beschrieben