Article technique

Annuler, rétablir et récupérer après un plantage en Delphi pour PDF

PDFium Component dote un éditeur PDF d'une véritable annulation et d'un véritable rétablissement au niveau du document, plus un dispositif de récupération après plantage, via un journal d'édition. Vous encapsulez les changements dans BeginEditTransaction et CommitEditTransaction, et chaque transaction validée devient un delta bidirectionnel que UndoEdit et RedoEdit peuvent rejouer dans les deux sens. SaveRecoverySnapshot persiste ensuite ce journal afin qu'une application tuée en cours de session puisse reconstruire le travail de l'utilisateur

Le vide que cela comble est précis. La capacité d'annulation native ne s'applique qu'au champ de formulaire qui a actuellement le focus, si bien qu'une zone de texte se souvient de ses propres éditions et rien d'autre ne s'en souvient. L'insertion, la suppression et le réordonnancement de pages, les changements d'objets de page, les annotations, les pièces jointes et les métadonnées n'ont aucun journal du tout. Un éditeur bâti sur cette seule base peut offrir une annulation à l'intérieur d'un champ et nulle part ailleurs, ce qui n'est pas ce que les utilisateurs entendent par annulation

Pourquoi des instantanés plutôt qu'un journal d'opérations ?

La conception évidente consiste à enregistrer des opérations sémantiques — « page 4 supprimée », « annotation 7 déplacée » — et à les inverser. Cela fonctionne jusqu'à ce que l'ensemble d'opérations grandisse, moment auquel chaque nouvelle fonctionnalité a besoin de son inverse, et toute opération dont l'inverse n'est pas exact corrompt silencieusement le document sur une longue chaîne d'annulations

Le journal capture plutôt l'état du document aux frontières des transactions et stocke la différence. Chaque delta conserve le plus long préfixe et suffixe commun et porte à la fois les octets supprimés et insérés, si bien qu'il peut s'appliquer en avant ou en arrière. Les nouvelles fonctionnalités d'édition n'ont besoin d'aucun support d'annulation dédié : quoi qu'elles changent se trouve dans l'instantané

Schéma de l'annulation PDF au niveau du document en Delphi où le journal d'édition de PDFium Component empile des deltas bidirectionnels sur un instantané racine, chaque entrée étant protégée par des condensés SHA-256 pendant la relecture d'annulation et de rétablissement
Chaque transaction validée stocke le préfixe, les octets supprimés et insérés, plus les condensés SHA-256 des deux états, si bien qu'un seul enregistrement se rejoue en avant ou en arrière sans opération inverse par fonctionnalité

Cette approche dépend d'un prérequis. Un enregistrement complet ordinaire produit des identifiants et des champs temporels non déterministes, si bien qu'enregistrer deux fois un document non modifié peut produire des octets différents. Les instantanés sont donc d'abord normalisés via la voie de sortie reproductible. Sans cette étape, une transaction vide ressemblerait à une modification, et l'annulation échouerait sa propre vérification puisque le condensé calculé au commit ne correspondrait plus au document ensuite

Les transactions en pratique

Chaque transaction prend une étiquette optionnelle, celle qu'un éditeur affiche dans son menu d'annulation :

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;               // profondeur d'annulation
    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 page revient
  finally
    Pdf.Free;
  end;
end;

Un commit qui échoue — parce que la capture a échoué, qu'un budget a été dépassé ou que le delta n'a pas pu être construit — restaure la base capturée à BeginEditTransaction et recharge, plutôt que de laisser une transaction active ou un journal à moitié écrit derrière lui. C'est le mode d'échec qui compte le plus, car un journal dans un état incohérent empoisonne chaque annulation suivante

Vérification à chaque étape

Chaque delta stocke le condensé SHA-256 du document avant et après, ainsi que la longueur de préfixe et les deux charges utiles. L'annulation, le rétablissement, la relecture importée et la reconstruction inverse vérifient tous le condensé actuel et la région qu'ils s'apprêtent à remplacer avant de toucher quoi que ce soit, puis vérifient le condensé résultant ensuite

Le déplacement dans le journal est tout aussi prudent. La position n'avance qu'après que le PDF cible a été validé par un objet document indépendant et a remplacé avec succès l'actuel. Si cela échoue, l'instantané courant est restauré comme repli, si bien qu'une annulation échouée laisse l'utilisateur là où il était plutôt que dans un état cassé

Le format sérialisé est vérifié comme des données, jamais pris en confiance comme une entrée : nombre magique, version, valeurs d'entrée et de position, chaîne de condensés, longueurs d'étiquette, longueurs de correctif et budget de stockage total sont tous validés. Un journal importé est un fichier qui a pu être tronqué, édité ou produit par une version différente, et le traiter comme hostile est la seule posture sûre

Que se passe-t-il quand le journal se remplit ?

Trois limites bornent la croissance : nombre d'entrées, octets cumulés des deltas et taille d'un instantané unique. Quand la capacité est atteinte, l'entrée la plus ancienne est évincée et l'empreinte racine avance

Ce dernier point a une conséquence à laquelle il faut prêter attention lors de la conception. Un journal exporté ne peut être appliqué qu'à un document correspondant à l'empreinte racine qu'il rapporte, ou à une empreinte qu'il a enregistrée. Une fois l'éviction survenue, le journal ne remonte plus jusqu'au fichier d'origine. L'enregistrement automatique doit donc traiter l'instantané racine correspondant et le journal comme une seule unité de récupération atomique — conserver le journal sans sa base revient à conserver quelque chose qui ne peut pas être rejoué

Les dispositifs de récupération après plantage

Le dispositif de récupération n'intègre délibérément pas le PDF source. Il stocke le nom d'affichage de la source, les empreintes SHA-256 des états source et récupéré, et le journal d'édition. La récupération nécessite donc le fichier d'origine, que l'appelant charge en premier ; le dispositif vérifie ensuite les deux empreintes en rejouant le journal en arrière puis en avant :

Séquence de récupération après plantage pour l'édition PDF en Delphi montrant les écritures atomiques de SaveRecoverySnapshot après chaque commit, un plantage en cours de session, et la vérification d'empreinte de RestoreRecoverySnapshot contre le PDF d'origine
Les commits remplacent atomiquement un petit dispositif scellé par empreinte ; la récupération recharge d'abord le PDF d'origine, puis vérifie les deux empreintes enregistrées en rejouant le journal dans les deux sens
// Après chaque commit réussi, rafraîchit le fichier de récupération
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
  Pdf.SaveRecoverySnapshot('contract.recovery');

// Session suivante, après un plantage : charge l'original, puis restaure
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');

Ne pas intégrer la source garde les fichiers de récupération petits, ce qui rend abordable un enregistrement automatique par commit sur un document de n'importe quelle taille. Un condensé externe couvre l'en-tête, le nom de la source et le journal, si bien qu'un dispositif tronqué ou altéré est rejeté avant même qu'un delta ne soit analysé, et le volume total de lecture est plafonné

Les écritures de fichier vont vers un fichier temporaire du répertoire cible puis sont remplacées atomiquement, et définir le chemin de récupération sur le PDF source est explicitement refusé. L'enregistrement automatique ne s'exécute qu'après un commit, une annulation ou un rétablissement réussi ayant modifié le document. Si l'écriture disque échoue, les états du document et du journal en mémoire sont déjà valides, si bien que l'exception rapporte un échec de persistance plutôt qu'une perte de données, et l'appelant peut corriger le chemin et réessayer

Les documents signés sont refusés par défaut

Un instantané de transaction est une nouvelle révision normalisée, pas les octets signés d'origine, si bien que valider une transaction sur un document signé invalide la signature. Démarrer une transaction sur un tel document est refusé sauf si l'appelant passe AllowSignedDocument, et l'import de journal ainsi que la restauration de récupération reviennent par défaut au même refus

C'est un ralentisseur délibéré plutôt qu'une interdiction. Certains flux de travail éditent légitimement un document signé puis le resignent ensuite, et l'option existe pour eux. Ce qu'elle empêche, c'est le cas accidentel, où un éditeur retire silencieusement toute validité à un document qu'un utilisateur croit encore signé. Le volet vérification de cette histoire est traité dans l'inspection des signatures numériques PDF et des niveaux PAdES

Pour un éditeur, trois habitudes rentabilisent le journal. Encapsulez chaque action visible par l'utilisateur dans une transaction étiquetée, afin que le menu d'annulation reflète le modèle mental de l'utilisateur plutôt que votre pile d'appels. Dimensionnez la limite d'entrées en fonction du document, puisque cinquante instantanés d'un scan de 200 Mo n'est pas un réglage modeste. Et écrivez l'instantané de récupération à chaque commit plutôt que sur une minuterie, car une minuterie garantit que le travail perdu est exactement tout ce qui s'est passé après le dernier tic. Les opérations d'édition au niveau du document qui en tirent parti sont traitées dans les mises à jour incrémentales de PDF chiffrés

La journalisation d'édition, les dispositifs de récupération et les primitives d'édition sous-jacentes sont livrés dans le même composant pour Delphi, C++Builder et Lazarus ; l'API complète est décrite sur la page du composant PDFium pour Delphi