Articolo tecnico

Annulla, ripeti e ripristino da crash per PDF in Delphi

PDFium Component offre a un editor PDF un vero annulla e ripeti a livello documento, più un pacchetto di ripristino da crash, tramite un journal delle modifiche. Racchiudete le modifiche in BeginEditTransaction e CommitEditTransaction, e ogni transazione confermata diventa un delta bidirezionale che UndoEdit e RedoEdit possono riprodurre in entrambe le direzioni. SaveRecoverySnapshot rende persistente quel journal così un'applicazione terminata a metà sessione può ricostruire il lavoro dell'utente

La lacuna che questo colma è specifica. La capacità di annullamento nativa si applica solo al campo del form che ha attualmente il focus, quindi una casella di testo ricorda le proprie modifiche e nient'altro. L'inserimento, l'eliminazione e il riordino delle pagine, le modifiche agli oggetti di pagina, le annotazioni, gli allegati e i metadati non hanno alcun journal. Un editor costruito su questo soltanto può offrire l'annullamento dentro un campo e da nessun'altra parte, il che non è ciò che gli utenti intendono per annullamento

Perché snapshot invece di un log di operazioni?

Il design ovvio è registrare operazioni semantiche — "eliminata pagina 4", "spostata annotazione 7" — e invertirle. Funziona finché il set di operazioni cresce, momento in cui ogni nuova funzionalità richiede un inverso, e qualsiasi operazione il cui inverso non sia esatto corrompe silenziosamente il documento lungo una lunga catena di annullamenti

Il journal cattura invece lo stato del documento ai confini delle transazioni e memorizza la differenza. Ogni delta mantiene il prefisso e il suffisso comuni più lunghi e porta sia i byte rimossi sia quelli inseriti, così può essere applicato in avanti o all'indietro. Le nuove funzionalità di editing non richiedono alcun supporto per l'annullamento: qualunque cosa cambino è dentro lo snapshot

Diagramma dell'annullamento PDF a livello documento in Delphi in cui il journal delle modifiche di PDFium Component impila delta bidirezionali sopra uno snapshot radice, ogni voce protetta da digest SHA-256 durante la riproduzione di annulla e ripeti
Ogni transazione confermata memorizza prefisso, byte rimossi e inseriti più i digest SHA-256 di entrambi gli stati, così un unico record si riproduce in avanti o all'indietro senza un'operazione inversa per ciascuna funzionalità

Questo approccio dipende da un prerequisito. Un normale salvataggio completo produce identificatori e campi temporali non deterministici, quindi salvare due volte un documento non modificato può produrre byte diversi. Gli snapshot vengono quindi prima normalizzati attraverso il percorso di output riproducibile. Senza questo passaggio, una transazione vuota apparirebbe come una modifica, e l'annullamento fallirebbe la propria verifica perché il digest calcolato al commit non corrisponderebbe al documento in seguito

Le transazioni nella pratica

Ogni transazione accetta un'etichetta opzionale, che è ciò che un editor mostra nel proprio menu di annullamento:

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;               // profondità di annullamento
    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;                          // la pagina ritorna
  finally
    Pdf.Free;
  end;
end;

Un commit che fallisce — perché la cattura è fallita, un budget è stato superato o il delta non ha potuto essere costruito — ripristina la baseline catturata a BeginEditTransaction e ricarica, invece di lasciare una transazione attiva o un journal scritto a metà. Questa è la modalità di fallimento che conta di più, perché un journal in uno stato incoerente avvelena ogni annullamento successivo

Verifica a ogni passaggio

Ogni delta memorizza il digest SHA-256 del documento prima e dopo, insieme alla lunghezza del prefisso ed entrambi i payload. L'annullamento, la ripetizione, la riproduzione importata e la ricostruzione inversa verificano tutti il digest corrente e la regione che intendono sostituire prima di toccare qualsiasi cosa, poi verificano il digest risultante in seguito

Lo spostamento attraverso il journal è altrettanto cauto. La posizione avanza solo dopo che il PDF di destinazione è stato validato da un oggetto documento indipendente e ha sostituito con successo quello corrente. Se questo fallisce, lo snapshot corrente viene ripristinato come fallback, così un annullamento fallito lascia l'utente dov'era invece che in uno stato rotto

Il formato serializzato viene controllato come dato, non fidato come input: numero magico, versione, valori di voce e posizione, la catena di digest, le lunghezze delle etichette, le lunghezze delle patch e il budget totale di archiviazione sono tutti validati. Un journal importato è un file che potrebbe essere stato troncato, modificato o prodotto da una versione diversa, e trattarlo come ostile è l'unica postura sicura

Cosa succede quando il journal si riempie

Tre limiti delimitano la crescita: il conteggio delle voci, i byte cumulativi dei delta e la dimensione di un singolo snapshot. Quando la capacità viene raggiunta, la voce più vecchia viene espulsa e l'impronta radice avanza

Quest'ultimo aspetto ha una conseguenza attorno a cui vale la pena progettare. Un journal esportato può essere applicato solo a un documento che corrisponda all'impronta radice che riporta, o a un'impronta che ha registrato. Una volta avvenuta l'espulsione, il journal non risale più al file originale. Il salvataggio automatico deve quindi trattare lo snapshot radice corrispondente e il journal come un'unica unità di ripristino atomica — conservare il journal senza la sua base significa conservare qualcosa che non può essere riprodotto

Pacchetti di ripristino da crash

Il pacchetto di ripristino deliberatamente non incorpora il PDF sorgente. Memorizza il nome visualizzato della sorgente, le impronte SHA-256 degli stati sorgente e ripristinato, e il journal delle modifiche. Il ripristino richiede quindi il file originale, che il chiamante carica per primo; il pacchetto verifica poi entrambe le impronte riproducendo il journal all'indietro e in avanti:

Sequenza di ripristino da crash per l'editing PDF in Delphi che mostra scritture atomiche di SaveRecoverySnapshot dopo ogni commit, un crash a metà sessione, e la verifica delle impronte di RestoreRecoverySnapshot rispetto al PDF originale
I commit sostituiscono in modo atomico un piccolo pacchetto sigillato da impronta; il ripristino ricarica prima il PDF originale, poi verifica entrambe le impronte registrate riproducendo il journal in entrambe le direzioni
// Dopo ogni commit riuscito, aggiorna il file di ripristino
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
  Pdf.SaveRecoverySnapshot('contract.recovery');

// Sessione successiva, dopo un crash: carica l'originale, poi ripristina
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');

Non incorporare la sorgente mantiene piccoli i file di ripristino, ed è ciò che rende sostenibile il salvataggio automatico a ogni commit su un documento di qualsiasi dimensione. Un digest esterno copre l'intestazione, il nome della sorgente e il journal, così un pacchetto troncato o manomesso viene rifiutato prima che venga analizzato qualsiasi delta, e il volume totale di lettura è limitato

Le scritture su file vanno in un file temporaneo nella directory di destinazione e vengono poi sostituite in modo atomico, e impostare il percorso di ripristino sul PDF sorgente viene esplicitamente rifiutato. Il salvataggio automatico gira solo dopo un commit, un annullamento o una ripetizione riusciti che abbiano cambiato qualcosa. Se la scrittura su disco fallisce, gli stati del documento e del journal in memoria sono già validi, quindi l'eccezione segnala un fallimento di persistenza anziché una perdita di dati, e il chiamante può correggere il percorso e riprovare

I documenti firmati sono rifiutati per impostazione predefinita

Uno snapshot di transazione è una nuova revisione normalizzata, non i byte firmati originali, quindi confermare una transazione su un documento firmato invalida la firma. Avviare una transazione su un documento del genere viene rifiutato a meno che il chiamante non passi AllowSignedDocument, e l'importazione del journal e il ripristino di recovery si comportano per impostazione predefinita allo stesso modo del rifiuto

Questo è un ostacolo deliberato, non un divieto. Alcuni flussi di lavoro modificano legittimamente un documento firmato e lo firmano di nuovo in seguito, e il flag esiste per loro. Ciò che previene è il caso accidentale, in cui un editor toglie silenziosamente la validità a un documento che un utente crede sia ancora firmato. Il lato della verifica di questa storia è trattato in ispezionare le firme digitali PDF e i livelli PAdES

Per un editor, tre abitudini fanno rendere il journal. Racchiudete ogni azione visibile all'utente in una singola transazione etichettata, così il menu di annullamento si legge come il modello mentale dell'utente e non come il vostro call stack. Dimensionate il limite delle voci in base al documento, dato che cinquanta snapshot di una scansione da 200 MB non è un'impostazione modesta. E scrivete lo snapshot di ripristino a ogni commit anziché a intervalli, perché un timer garantisce che il lavoro perso sia esattamente ciò che è successo dopo l'ultimo intervallo. Le operazioni di editing a livello documento che beneficiano di questo sono trattate in aggiornamenti incrementali di PDF cifrati

Il journal delle modifiche, i pacchetti di ripristino e le primitive di editing sottostanti sono forniti nello stesso componente per Delphi, C++Builder e Lazarus; l'API completa è descritta nella pagina di PDFium Component per Delphi