Articolo tecnico

Riutilizzare una istanza THotPDF su più documenti in Delphi

L'errore recita Please load the document before using BeginDoc, e si presenta quasi sempre al secondo giro. Il primo documento viene scritto senza problemi. Poi alla stessa istanza di THotPDF viene chiesto di iniziarne un secondo, BeginDoc solleva l'eccezione e il messaggio parla di caricare un documento, cioè l'opposto di quello che il codice sta tentando di fare. È proprio lo scarto fra il sintomo e il messaggio a rendere memorabile questo caso. Il vero argomento è il ciclo di vita del componente, e una volta afferrato quello l'errore smette di essere misterioso

Ciclo di vita del documento THotPDF con Create, BeginDoc, EndDoc e Free per ogni file di uscita
Una istanza THotPDF corrisponde a un documento: Create, BeginDoc, disegno, EndDoc, Free

Una istanza THotPDF è un documento, non una fabbrica di documenti

Il modello mentale allettante è che THotPDF sia un oggetto di servizio da istanziare una volta sola e a cui dare in pasto documenti, come si terrebbe aperta una connessione al database eseguendovi query su query. Non è così. Una istanza modella un singolo documento in costruzione, e la sua macchina a stati interna porta con sé il presupposto di percorrere il tragitto una volta sola: da vuoto, attraverso un documento aperto, fino a un file salvato. BeginDoc apre quel tragitto e segna l'istanza come titolare di un documento in lavorazione. EndDoc serializza tutto su FileName e chiude la partita. Chiamare di nuovo BeginDoc sulla stessa istanza esaurita le chiede di rientrare in uno stato da cui non è mai uscita in modo pulito, e la guardia che scatta è quella il cui messaggio parla di caricamento, perché internamente la condizione «pronto a iniziare» e quella «ha un documento caricato» vengono verificate insieme

Il messaggio è quindi fuorviante, ma la guardia sta facendo il suo mestiere. Si rifiuta di lasciarvi aprire un documento nuovo sopra un componente che si crede ancora a metà documento. La soluzione non è aggirare la guardia. È smettere di riutilizzare una istanza già consumata

Il ciclo di vita, nell'ordine in cui deve avvenire

Ogni documento che HotPDF scrive da zero segue gli stessi quattro tempi, e l'ordine non è negoziabile. Create alloca il componente. BeginDoc apre il documento e fissa le scelte strutturali, perciò tutto ciò che riguarda il file intero (dimensione della pagina, compressione, cifratura, nome del file di uscita) va impostato fra Create e BeginDoc. Poi disegnate. Poi EndDoc scrive i byte su disco. Free rilascia l'istanza. Le chiamate di disegno collocate prima di BeginDoc non hanno una pagina su cui atterrare; le proprietà valide per l'intero documento assegnate dopo vengono ignorate senza un fiato

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // apre il documento
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // scrive invoice.pdf e lo chiude
  finally
    Pdf.Free;                            // una istanza, un documento
  end;
end;

Leggetelo come unità di lavoro. Un Create, un BeginDoc, un EndDoc, un Free, un file su disco. Nell'istante in cui volete un secondo file, state avviando una nuova unità di lavoro, il che significa una nuova istanza

Che cosa dovrebbe significare «riutilizzo»: una istanza nuova per ogni file

La versione che si rompe cerca di risparmiare sull'allocazione: costruisce il componente una volta, cicla su un lotto, chiama BeginDoc ed EndDoc dentro il ciclo. La seconda iterazione solleva l'eccezione. La versione che funziona tratta ogni uscita come un oggetto di breve vita a sé stante, e il costo di allocazione di un componente è irrisorio rispetto al lavoro di impaginare e serializzare un PDF, quindi non c'è nulla da guadagnare tenendosi stretta l'istanza

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // nuova istanza a ogni passaggio
    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;

Il try/finally che sta dentro il ciclo è la parte da difendere in sede di revisione. Se BeginDoc o una qualsiasi chiamata di disegno solleva una eccezione a metà di un documento, l'istanza di quella iterazione viene comunque liberata prima che inizi la successiva, così un record difettoso non lascia in giro un componente costruito a metà che avvelena il resto dell'esecuzione. Tirate fuori il Create sopra il ciclo per «ottimizzare» e siete di nuovo al bug originale, stavolta in veste di ciclo su lotto

Modificare un file esistente è un punto di ingresso diverso

C'è una seconda lettura di «riutilizzo» del tutto legittima: non volete un documento vuoto, volete aprire un PDF che esiste già e cambiarlo. Quel percorso non passa affatto per BeginDoc, ed è esattamente per questo che il messaggio di errore nomina il caricamento. Caricate il file, lo modificate e lo salvate con il nome che preferite

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 restituisce il numero di pagine, e un valore pari a zero o inferiore significa che il caricamento è fallito, quindi conviene controllarlo prima di toccare CurrentPage. L'abbinamento conta: un documento aperto con LoadFromFile si salva con SaveLoadedDocument, non con la coppia BeginDoc/EndDoc, che appartiene ai documenti scritti dal nulla. Mescolare i due è il modo più comune di confondere la stessa macchina a stati che ha prodotto l'errore iniziale. Tenete i due flussi separati anche mentalmente: BeginDoc ... EndDoc crea, LoadFromFile ... SaveLoadedDocument modifica

Il problema del lock sul file è reale, e la risposta non è chiudere a forza le finestre dei visualizzatori

L'errore di riutilizzo viaggia spesso in compagnia di una seconda lamentela, e le due si aggrovigliano perché affiorano nello stesso flusso di rigenerazione del file. Un utente apre il PDF appena prodotto, lo lascia aperto in Acrobat o Foxit, poi fa scattare una ricostruzione. EndDoc tenta di scrivere sullo stesso percorso, il sistema operativo rifiuta perché il visualizzatore detiene una condivisione in lettura che blocca gli scrittori, e ottenete un fallimento per accesso negato. Questo è genuinamente un problema di file locking di Windows più che di stato del componente, e merita una risposta vera invece di un espediente

L'espediente che circola, enumerare le finestre di primo livello e inviare WM_CLOSE a qualunque titolo somigli a un visualizzatore PDF, è l'istinto sbagliato. Attraversa i confini di processo per chiudere finestre che il vostro programma non possiede, indovina i visualizzatori dal testo del titolo e può buttare via le annotazioni non salvate di un utente senza chiedere. Considerate tutto quell'approccio un cattivo odore. La correzione affidabile è non scrivere mai su un percorso che un altro processo potrebbe tenere occupato. Serializzate su un file temporaneo nella stessa directory, poi mettetelo al suo posto con una rinomina atomica appena EndDoc è andato a buon fine. Se un visualizzatore tiene ancora aperto il vecchio file, la rinomina o riesce in modo pulito o fallisce rumorosamente, e voi mostrate un messaggio chiaro invece di combattere contro il lock

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // File temporaneo nella STESSA directory del bersaglio: una rinomina
  // dentro un solo volume NTFS scambia il nome in modo atomico, mentre uno
  // spostamento fra volumi degrada a copia-più-cancella e perde la garanzia
  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;                    // qui il temporaneo è completo su disco
    finally
      Pdf.Free;
    end;

    // Scambio in posizione. TFile.Move rifiuta di sovrascrivere: eliminate
    // prima un bersaglio obsoleto; se un visualizzatore tiene ancora il
    // vecchio file, fallisce la cancellazione, rumorosamente, prima dei byte buoni
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // oppure: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // mai lasciare un temporaneo a metà
    raise;
  end;
end;

Due note oneste su quel codice. TFile.Move e il classico RenameFile mappano entrambi sulla stessa rinomina di Windows, atomica solo quando origine e destinazione stanno sullo stesso volume, ed è proprio per questo che il file temporaneo finisce nella directory di destinazione anziché in TPath.GetTempPath. E la coppia cancella-poi-sposta non è a sua volta un unico passo atomico: c'è una breve finestra in cui nessuno dei due file esiste. Per una applicazione desktop che rigenera un rapporto quella finestra è irrilevante; chi ha bisogno di una garanzia più forte sullo stesso volume può chiamare direttamente la Win32 ReplaceFile oppure MoveFileEx con MOVEFILE_REPLACE_EXISTING, che condensa lo scambio in una sola chiamata

Per un server ad alto volume che rigenera documenti di continuo, la disciplina più pulita è scrivere ogni uscita sotto un nome univoco (una marca temporale o un identificativo di job) così che due esecuzioni non contendano mai lo stesso percorso, lasciando a una politica di conservazione separata il compito di ripulire i vecchi file. Lo schema costa una riga di disciplina sui nomi per ogni richiesta

// Un percorso di uscita per richiesta: due job concorrenti non possono mai
// contendersi lo stesso nome, niente balletto di rinomine e nessun lock perso
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

Un identificativo di richiesta o di job funziona bene quanto il GUID quando il framework circostante ve ne consegna già uno, e rende il nome del file tracciabile fino a una riga di log senza costi aggiuntivi. In un modo o nell'altro il principio è lo stesso: progettate in modo che il file che state scrivendo sia soltanto vostro nel momento in cui lo scrivete. Il lock sparisce non perché avete forzato la chiusura di una finestra ma perché nessun altro sta toccando quei byte

La forma della soluzione

Riducete i due problemi alle loro radici e scoprirete che entrambi parlano di rispettare confini. L'errore della macchina a stati vi chiede di onorare il confine dell'istanza: un THotPDF, un documento, poi lasciatelo andare e createne un altro. L'errore di lock vi chiede di onorare il confine del file: scrivete dove nessun altro sta leggendo, poi spostate il risultato al suo posto. Nessuno dei due richiede di modificare la libreria o di pilotare il desktop. Entrambi discendono dal trattare ogni documento come una unità di lavoro autonoma, creata da zero, scritta in modo pulito e rilasciata, che è lo stesso schema che rende prevedibile il resto del componente

Le chiamate BeginDoc, EndDoc, LoadFromFile e SaveLoadedDocument mostrate qui fanno parte del HotPDF Delphi Component per Delphi e C++Builder