Technisch artikel

PDF ongedaan maken, opnieuw doen en crashherstel in Delphi

PDFium Component geeft een PDF-editor via een edit journal echte ongedaanmaak- en opnieuw-doen-functionaliteit op documentniveau, plus een crashherstelpakket. U verpakt wijzigingen in BeginEditTransaction en CommitEditTransaction, en elke vastgelegde transactie wordt een bidirectionele delta die UndoEdit en RedoEdit in beide richtingen kunnen afspelen. SaveRecoverySnapshot bewaart dat journal, zodat een toepassing die midden in een sessie wordt afgebroken, het werk van de gebruiker kan herstellen

Het gat dat dit vult, is specifiek. De native ongedaanmaakfunctie geldt alleen voor het formulierveld dat op dat moment de focus heeft, zodat een tekstvak zijn eigen bewerkingen onthoudt en verder niets. Het invoegen, verwijderen en herordenen van pagina's, wijzigingen aan pagina-objecten, annotaties, bijlagen en metadata hebben helemaal geen journal. Een editor die alleen daarop is gebouwd, kan ongedaan maken bieden binnen een veld en nergens anders, wat niet is wat gebruikers bedoelen met ongedaan maken

Waarom snapshots in plaats van een bewerkingslogboek?

Het voor de hand liggende ontwerp is om semantische bewerkingen vast te leggen — "pagina 4 verwijderd", "annotatie 7 verplaatst" — en ze om te keren. Dat werkt totdat de bewerkingsset groeit, want dan heeft elke nieuwe functie een inverse nodig, en elke bewerking waarvan de inverse niet exact is, beschadigt het document stilzwijgend over een lange ongedaanmaakketen

Het journal legt in plaats daarvan de documentstatus vast op transactiegrenzen en bewaart het verschil. Elke delta houdt het langste gemeenschappelijke prefix en suffix vast en draagt zowel de verwijderde als de ingevoegde bytes, zodat het voorwaarts of achterwaarts kan worden toegepast. Nieuwe bewerkingsfuncties hebben geen ongedaanmaakondersteuning nodig: wat ze ook veranderen, het zit in het snapshot

Diagram van ongedaan maken op documentniveau voor PDF in Delphi, waarbij het edit journal van PDFium Component bidirectionele deltas stapelt boven een rootsnapshot, elke entry bewaakt door SHA-256-digests tijdens het afspelen van undo en redo
Elke vastgelegde transactie bewaart prefix, verwijderde en ingevoegde bytes plus SHA-256-digests van beide statussen, zodat één record voorwaarts of achterwaarts wordt afgespeeld zonder inverse bewerking per functie

Die aanpak hangt af van één voorwaarde. Een gewone volledige opslag produceert niet-deterministische identifiers en tijdvelden, zodat een ongewijzigd document twee keer opslaan verschillende bytes kan opleveren. Snapshots worden daarom eerst genormaliseerd via het reproduceerbare outputtraject. Zonder die stap zou een lege transactie eruitzien als een wijziging, en zou ongedaan maken zijn eigen verificatie niet doorstaan, omdat de digest die bij het vastleggen werd berekend achteraf niet met het document zou overeenkomen

Transacties in de praktijk

Elke transactie neemt een optioneel label, wat een editor toont in zijn ongedaanmaakmenu:

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;               // ongedaanmaakdiepte
    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;                          // de pagina komt terug
  finally
    Pdf.Free;
  end;
end;

Een commit die mislukt — omdat het vastleggen mislukte, een budget werd overschreden of de delta niet kon worden opgebouwd — herstelt de baseline die is vastgelegd bij BeginEditTransaction en herlaadt, in plaats van een actieve transactie of een half geschreven journal achter te laten. Dit is de faalmodus die het meest van belang is, want een journal in een inconsistente staat vergiftigt elke volgende ongedaanmaking

Verificatie bij elke stap

Elke delta bewaart de SHA-256-digest van het document ervoor en erna, samen met de prefixlengte en beide payloads. Undo, redo, geïmporteerd afspelen en omgekeerde reconstructie controleren allemaal de huidige digest en de regio die ze willen vervangen voordat ze iets aanraken, en verifiëren daarna de resulterende digest

Verplaatsing door het journal is even voorzichtig. De positie schuift alleen op nadat de doel-PDF is gevalideerd door een onafhankelijk documentobject en de huidige met succes heeft vervangen. Als dat mislukt, wordt het huidige snapshot als terugvaloptie hersteld, zodat een mislukte ongedaanmaking de gebruiker achterlaat waar hij was in plaats van in een kapotte staat

Het geserialiseerde formaat wordt gecontroleerd als data, niet vertrouwd als invoer: magic number, versie, entry- en positiewaarden, de digestketen, labellengtes, patchlengtes en het totale opslagbudget worden allemaal gevalideerd. Een geïmporteerd journal is een bestand dat mogelijk is afgekapt, bewerkt of geproduceerd door een andere versie, en het als vijandig behandelen is de enige veilige houding

Wat gebeurt er wanneer het journal vol raakt

Drie limieten begrenzen de groei: aantal entries, cumulatieve deltabytes en de grootte van één snapshot. Wanneer de capaciteit is bereikt, wordt de oudste entry verwijderd en schuift de rootvingerafdruk op

Dat laatste deel heeft een gevolg waar het de moeite waard is ontwerpmatig rekening mee te houden. Een geëxporteerd journal kan alleen worden toegepast op een document dat overeenkomt met de rootvingerafdruk die het rapporteert, of een vingerafdruk die het heeft vastgelegd. Zodra verwijdering heeft plaatsgevonden, reikt het journal niet meer terug tot het oorspronkelijke bestand. Automatisch opslaan moet daarom het bijpassende rootsnapshot en het journal behandelen als één atomaire hersteleenheid — het journal bewaren zonder zijn basis is iets bewaren dat niet kan worden afgespeeld

Crashherstelpakketten

Het herstelpakket bevat bewust niet de bron-PDF zelf. Het bewaart de weergavenaam van de bron, SHA-256-vingerafdrukken van de bron- en herstelde statussen, en het edit journal. Herstel vereist daarom het originele bestand, dat de aanroeper eerst laadt; het pakket verifieert vervolgens beide vingerafdrukken door het journal achterwaarts en voorwaarts af te spelen:

Crashherstelsequentie voor PDF-bewerking in Delphi met atomaire SaveRecoverySnapshot-writes na elke commit, een crash midden in een sessie, en vingerafdrukverificatie door RestoreRecoverySnapshot tegen de originele PDF
Commits vervangen atomair een klein, met een vingerafdruk verzegeld pakket; herstel herlaadt eerst de originele PDF en verifieert daarna beide vastgelegde vingerafdrukken door het journal in beide richtingen af te spelen
// Ververs het herstelbestand na elke geslaagde commit
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
  Pdf.SaveRecoverySnapshot('contract.recovery');

// Volgende sessie, na een crash: laad het origineel, herstel dan
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');

De bron niet insluiten houdt herstelbestanden klein, wat automatisch opslaan per commit betaalbaar maakt voor een document van elke grootte. Een buitenste digest dekt de header, bronnaam en het journal, zodat een afgekapt of gemanipuleerd pakket wordt afgewezen voordat er ook maar één delta wordt geparseerd, en het totale leesvolume wordt begrensd

Bestandsschrijfacties gaan naar een tijdelijk bestand in de doelmap en worden vervolgens atomair vervangen, en het instellen van het herstelpad op de bron-PDF wordt expliciet geweigerd. Automatisch opslaan draait alleen na een geslaagde gewijzigde commit, undo of redo. Als het schrijven naar schijf mislukt, zijn de in-memory document- en journalstatus al geldig, dus rapporteert de exception een persistentiefout in plaats van gegevensverlies, en kan de aanroeper het pad herstellen en opnieuw proberen

Ondertekende documenten worden standaard geweigerd

Een transactiesnapshot is een nieuwe genormaliseerde revisie, niet de originele ondertekende bytes, dus maakt het vastleggen van een transactie op een ondertekend document de handtekening ongeldig. Het starten van een transactie op zo'n document wordt geweigerd, tenzij de aanroeper AllowSignedDocument meegeeft, en journalimport en herstelrestore vallen standaard terug op dezelfde weigering

Dit is een bewuste drempel en geen verbod. Sommige workflows bewerken legitiem een ondertekend document en ondertekenen het daarna opnieuw, en de vlag bestaat voor hen. Wat het voorkomt, is het onbedoelde geval, waarin een editor stilzwijgend de geldigheid ontneemt van een document waarvan een gebruiker denkt dat het nog ondertekend is. De verificatiekant van dat verhaal wordt behandeld in het inspecteren van digitale PDF-handtekeningen en PAdES-niveaus

Voor een editor betalen drie gewoontes het journal terug. Verpak elke voor de gebruiker zichtbare actie in één gelabelde transactie, zodat het ongedaanmaakmenu leest als het mentale model van de gebruiker in plaats van als uw call stack. Stem de entrylimiet af op het document, want vijftig snapshots van een scan van 200 MB is geen bescheiden instelling. En schrijf het herstelsnapshot bij elke commit in plaats van op een timer, want een timer garandeert dat het verloren werk precies is wat er gebeurde na de laatste tik. Bewerkingen op documentniveau die hiervan profiteren, worden behandeld in incrementele updates voor versleutelde PDF's

Edit-journalling, herstelpakketten en de bewerkingsprimitieven eronder worden geleverd in dezelfde component voor Delphi, C++Builder en Lazarus; de volledige API wordt beschreven op de PDFium Component voor Delphi-pagina