Article technique

Réutilisation d'une instance THotPDF pour plusieurs documents dans Delphi

L'erreur indique Please load the document before using BeginDoc, et elle apparaît presque toujours la deuxième fois. Le premier document s'écrit bien. Ensuite, il est demandé à la même instance de THotPDF d'en démarrer un deuxième, BeginDoc se déclenche (raises), et le message pointe vers le chargement d'un document, ce qui est l'opposé de ce que le code essaie de faire. L'inadéquation entre le symptôme et le message est ce qui rend cette erreur tenace. Le véritable sujet est le cycle de vie du composant, et une fois qu'il est compris, l'erreur cesse d'être mystérieuse

THotPDF document lifecycle showing Create, BeginDoc, EndDoc, and Free per output file
Une instance THotPDF correspond à un document : Create, BeginDoc, draw, EndDoc, Free.

Une instance THotPDF est un document, pas une fabrique de documents

Le modèle mental tentant est que THotPDF est un objet de service que vous lancez une fois et auquel vous fournissez des documents, de la même manière que vous garderiez une connexion de base de données ouverte et exécuteriez des requêtes les unes après les autres à travers elle. Ce n'est pas le cas. Une instance modélise la construction d'un seul document, et sa machine à états interne suppose qu'elle parcourt le chemin une seule fois : d'un état vide, en passant par un document ouvert, vers un fichier enregistré. BeginDoc ouvre cette voie et marque l'instance comme ayant un document en cours. EndDoc sérialise tout vers FileName et le ferme. Appeler BeginDoc à nouveau sur la même instance terminée lui demande de revenir dans un état qu'elle n'a jamais quitté proprement, et la garde (guard) qui se déclenche est celle dont le message mentionne le chargement, car en interne les conditions "prêt à commencer" et "a un document chargé" sont vérifiées ensemble

Le message est donc trompeur, mais la garde fait son travail. Elle refuse de vous laisser démarrer un nouveau document par-dessus un composant qui croit toujours qu'il est en cours de document. La solution n'est pas de contourner la garde. Il s'agit d'arrêter de réutiliser une instance épuisée

Le cycle de vie, dans l'ordre où il doit se dérouler

Chaque document que HotPDF écrit à partir de zéro suit les mêmes quatre temps, et l'ordre n'est pas négociable. Create alloue le composant. BeginDoc ouvre le document et fixe les choix structurels, de sorte que tout ce qui affecte l'ensemble du fichier (taille de page, compression, chiffrement, nom de fichier de sortie) doit être défini entre Create et BeginDoc. Ensuite, vous dessinez. Puis EndDoc écrit les octets sur le disque. Free libère l'instance. Les appels de dessin placés avant BeginDoc n'ont pas de page sur laquelle atterrir ; les propriétés de l'ensemble du document assignées après celui-ci sont ignorées sans plainte

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // opens the document
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // writes invoice.pdf, closes it out
  finally
    Pdf.Free;                            // one instance, one document
  end;
end;

Lisez cela comme l'unité de travail. Un Create, un BeginDoc, un EndDoc, un Free, un fichier sur le disque. Au moment où vous souhaitez un deuxième fichier, vous démarrez une nouvelle unité de travail, ce qui signifie une nouvelle instance

Ce que "réutilisation" devrait signifier : une nouvelle instance par fichier

La version qui échoue essaie d'être frugale en matière d'allocation : construire le composant une fois, boucler sur un lot, appeler BeginDoc et EndDoc à l'intérieur de la boucle. La deuxième itération lance une erreur. La version qui fonctionne traite chaque sortie comme son propre objet éphémère, et le coût d'allocation de création d'un composant est trivial à côté du travail de mise en page et de sérialisation d'un PDF, il n'y a donc rien à gagner en thésaurisant l'instance

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // new instance each pass
    try
      Pdf.FileName := Names[I] + '.pdf';
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 12);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Statement for ' + Names[I]);
      Pdf.EndDoc;
    finally
      Pdf.Free;
    end;
  end;
end;

Le try/finally situé à l'intérieur de la boucle est la partie qui mérite d'être défendue en revue (de code). Si BeginDoc ou n'importe quel appel de dessin déclenche une erreur au milieu d'un document, l'instance de cette itération est tout de même libérée avant que la suivante ne commence, de sorte qu'un mauvais enregistrement n'abandonne pas un composant à moitié construit et n'empoisonne pas le reste de l'exécution. Sortez le Create au-dessus de la boucle pour "optimiser" et vous êtes de retour au bogue d'origine, qui porte maintenant le déguisement d'une boucle de lot (batch loop)

Modifier un fichier existant est un point d'entrée différent

Il y a une deuxième lecture de "réutilisation" qui est tout à fait légitime : vous ne voulez pas un document vierge, vous voulez ouvrir un PDF qui existe déjà et le modifier. Ce chemin ne passe pas du tout par BeginDoc, ce qui est exactement la raison pour laquelle le message d'erreur mentionne le chargement. Vous chargez le fichier, le modifiez et l'enregistrez sous le nom que vous choisissez

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('contract.pdf');
    if PageCount > 0 then
    begin
      Pdf.CurrentPage.SetFont('Arial', [fsBold], 10);
      Pdf.CurrentPage.TextOut(40, 30, 0, 'REVIEWED');
      Pdf.SaveLoadedDocument('contract-reviewed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

LoadFromFile renvoie le nombre de pages, et une valeur inférieure ou égale à zéro signifie que le chargement a échoué, il est donc utile de vérifier avant de toucher CurrentPage. L'association a de l'importance : un document que vous avez ouvert avec LoadFromFile est enregistré avec SaveLoadedDocument, et non avec la paire BeginDoc/EndDoc, qui appartient aux documents que vous créez de toutes pièces. Mélanger les deux est la façon la plus courante de perturber la même machine à états qui a produit l'erreur d'origine. Gardez les deux flux mentalement séparés : BeginDoc ... EndDoc crée, LoadFromFile ... SaveLoadedDocument édite

Le problème de verrouillage de fichier est réel, et la réponse n'est pas de tuer les fenêtres du visualiseur

L'erreur de réutilisation s'accompagne souvent d'une deuxième plainte, et les deux s'entremêlent car elles font surface dans le même flux de travail de régénération de fichier. Un utilisateur ouvre le PDF que vous venez de produire, le laisse ouvert dans Acrobat ou Foxit, puis déclenche une reconstruction. EndDoc essaie d'écrire sur le même chemin, le système d'exploitation refuse car le visualiseur détient un partage en lecture (read share) qui bloque les écrivains (writers), et vous obtenez un échec d'accès refusé. Il s'agit véritablement d'un problème de verrouillage de fichier Windows plutôt que d'un problème d'état de composant, et cela mérite une vraie réponse plutôt qu'une solution de contournement

La solution de contournement qui circule, énumérant les fenêtres de niveau supérieur (top-level windows) et publiant WM_CLOSE à tout ce dont le titre ressemble à un visualiseur PDF, est un mauvais instinct. Elle franchit les frontières des processus pour fermer des fenêtres que votre programme ne possède pas, elle devine les visualiseurs par le texte du titre, et elle peut jeter les annotations non enregistrées d'un utilisateur sans demander. Considérez toute cette approche comme un code smell (une mauvaise pratique). La solution fiable consiste à ne jamais écrire sur un chemin qu'un autre processus pourrait détenir. Sérialisez dans un fichier temporaire dans le même répertoire, puis remettez-le en place avec un renommage atomique une fois que EndDoc a réussi. Si un visualiseur a toujours l'ancien fichier ouvert, le renommage réussit proprement ou échoue bruyamment, et vous affichez un message clair plutôt que de combattre le verrou

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // Temp file in the SAME directory as the target: a rename inside one
  // NTFS volume swaps the name atomically, while a cross-volume move
  // degrades to copy-plus-delete and loses that guarantee
  TempPath := TPath.Combine(TPath.GetDirectoryName(FinalPath),
    TGUID.NewGuid.ToString + '.pdf.tmp');
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.FileName := TempPath;
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 11);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
      Pdf.EndDoc;                    // the temp file is complete on disk here
    finally
      Pdf.Free;
    end;

    // Swap into place. TFile.Move refuses to overwrite, so clear a stale
    // target first; if a viewer still holds the old file, the delete is
    // what fails, loudly, before the good bytes are touched
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // or: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // never strand a half-written temp file
    raise;
  end;
end;

Deux notes de bas de page honnêtes sur ce code. TFile.Move et le classique RenameFile correspondent tous deux au même renommage Windows, qui n'est atomique que lorsque la source et la destination se trouvent sur le même volume, et c'est exactement pourquoi le fichier temporaire va dans le répertoire de destination plutôt que dans TPath.GetTempPath. Et la paire suppression-puis-déplacement (delete-then-move) n'est pas en soi une étape atomique : il y a une brève fenêtre au cours de laquelle aucun fichier n'existe. Pour une application de bureau régénérant un rapport, cette fenêtre n'a pas d'importance ; les lecteurs qui ont besoin d'un contrat plus solide sur le même volume peuvent appeler l'API Win32 ReplaceFile ou MoveFileEx avec MOVEFILE_REPLACE_EXISTING directement, ce qui condense l'échange en un seul appel

Pour un serveur à haut volume qui régénère constamment des documents, la discipline la plus propre consiste à écrire chaque sortie sous un nom unique (un horodatage ou un ID de tâche (job id)) de sorte que deux exécutions ne se disputent jamais un chemin, et de laisser une stratégie de rétention distincte nettoyer les anciens fichiers. Le modèle est une ligne de discipline de nommage par requête

// One output path per request: two concurrent jobs can never contend
// for the same name, so no rename dance and no lock to lose
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

Un ID de requête (request id) ou un ID de tâche (job id) fonctionne tout aussi bien que le GUID lorsque le framework environnant vous en fournit déjà un, et cela permet de retracer gratuitement le nom du fichier vers une ligne de journal (log). Dans tous les cas, le principe est le même : concevez pour que le fichier que vous écrivez soit le vôtre uniquement au moment où vous l'écrivez. Le verrou disparaît non pas parce que vous avez forcé la fermeture d'une fenêtre, mais parce que rien d'autre ne touche aux octets

La forme de la solution

Dépouillez les deux problèmes de leurs racines et ils concernent tous deux le respect des frontières. L'erreur de la machine à états vous demande de respecter la limite de l'instance : un THotPDF, un document, puis de le lâcher et d'en créer un autre. L'erreur de verrouillage de fichier vous demande d'honorer la limite du fichier : écrire là où rien d'autre ne lit, puis déplacer le résultat à sa place. Ni l'un ni l'autre ne nécessite de corriger la bibliothèque ou de scripter le bureau. Les deux découlent du traitement de chaque document comme une unité de travail autonome, créée de toutes pièces, écrite proprement et libérée, ce qui est le même modèle qui rend le reste du composant prévisible

Les appels BeginDoc, EndDoc, LoadFromFile et SaveLoadedDocument présentés ici font partie du Composant HotPDF pour Delphi et C++Builder