Artículo técnico

Reutilizar una instancia THotPDF entre documentos en Delphi

El error dice Please load the document before using BeginDoc, y casi siempre aparece en la segunda vuelta. El primer documento se escribe sin problemas. Después se le pide a la misma instancia de THotPDF que inicie un segundo documento, BeginDoc lanza una excepción y el mensaje habla de cargar un documento, que es justo lo contrario de lo que el código intenta hacer. Ese desajuste entre el síntoma y el mensaje es lo que hace que este error se quede grabado. El verdadero asunto es el ciclo de vida del componente, y en cuanto eso encaja el error deja de ser un misterio

Ciclo de vida de un documento THotPDF con Create, BeginDoc, EndDoc y Free por cada archivo de salida
Una instancia de THotPDF corresponde a un documento: Create, BeginDoc, dibujar, EndDoc, Free

Una instancia de THotPDF es un documento, no una fábrica de documentos

El modelo mental tentador es que THotPDF es un objeto de servicio que se arranca una vez y al que se le van pasando documentos, igual que se mantiene abierta una conexión a base de datos y se ejecuta consulta tras consulta a través de ella. No es eso. Una instancia modela un único documento en construcción, y su máquina de estados interna parte de la premisa de que recorre el camino una sola vez: desde vacío, pasando por un documento abierto, hasta un archivo guardado. BeginDoc abre ese camino y marca la instancia como que tiene un documento en curso. EndDoc serializa todo en FileName y lo cierra. Llamar de nuevo a BeginDoc sobre esa misma instancia ya terminada le pide que vuelva a entrar en un estado del que nunca salió limpiamente, y la guarda que salta es la que casualmente menciona la carga en su mensaje, porque internamente las condiciones "listo para empezar" y "tiene un documento cargado" se comprueban juntas

Así que el mensaje despista, pero la guarda hace su trabajo. Se niega a dejarle iniciar un documento nuevo encima de un componente que todavía cree estar a medio documento. La solución no es burlar la guarda. Es dejar de reutilizar una instancia ya gastada

El ciclo de vida, en el orden en que tiene que ocurrir

Todo documento que HotPDF escribe desde cero sigue los mismos cuatro compases, y el orden no es negociable. Create reserva el componente. BeginDoc abre el documento y fija las decisiones estructurales, de modo que todo lo que afecta al archivo completo (tamaño de página, compresión, cifrado, nombre del archivo de salida) tiene que establecerse entre Create y BeginDoc. Después se dibuja. Después EndDoc escribe los bytes en disco. Free libera la instancia. Las llamadas de dibujo situadas antes de BeginDoc no tienen página donde caer; las propiedades de documento completo asignadas después de él se ignoran sin la menor queja

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // abre el documento
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // escribe invoice.pdf y lo cierra
  finally
    Pdf.Free;                            // una instancia, un documento
  end;
end;

Léalo como la unidad de trabajo. Un Create, un BeginDoc, un EndDoc, un Free, un archivo en disco. En el instante en que quiera un segundo archivo, está iniciando una nueva unidad de trabajo, lo que significa una nueva instancia

Lo que debería significar "reutilizar": una instancia nueva por archivo

La versión que falla intenta ser frugal con la asignación de memoria: construye el componente una vez, recorre un lote en bucle y llama a BeginDoc y EndDoc dentro del bucle. La segunda iteración lanza la excepción. La versión que funciona trata cada salida como su propio objeto de vida corta, y el coste de crear un componente es insignificante frente al trabajo de maquetar y serializar un PDF, así que no hay nada que ahorrar acaparando la instancia

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // instancia nueva en cada pasada
    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;

El try/finally que queda dentro del bucle es la parte que merece defenderse en la revisión de código. Si BeginDoc o cualquier llamada de dibujo lanza una excepción a mitad de un documento, la instancia de esa iteración se libera igualmente antes de que empiece la siguiente, así que un registro defectuoso no deja abandonado un componente a medio construir ni envenena el resto de la ejecución. Saque el Create fuera del bucle para "optimizar" y volverá al fallo original, ahora disfrazado de bucle por lotes

Modificar un archivo existente es otro punto de entrada

Hay una segunda lectura de "reutilizar" que es del todo legítima: no quiere un documento en blanco, quiere abrir un PDF que ya existe y cambiarlo. Ese camino no pasa por BeginDoc en absoluto, y precisamente por eso el mensaje de error menciona la carga. Se carga el archivo, se edita y se guarda con el nombre que se prefiera

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 devuelve el número de páginas, y un valor de cero o inferior significa que la carga falló, así que conviene comprobarlo antes de tocar CurrentPage. El emparejamiento importa: un documento abierto con LoadFromFile se guarda con SaveLoadedDocument, no con la pareja BeginDoc/EndDoc, que pertenece a los documentos que se crean desde la nada. Mezclar ambos es la forma más habitual de confundir a la misma máquina de estados que produjo el error original. Mantenga los dos flujos separados mentalmente: BeginDoc ... EndDoc crea, LoadFromFile ... SaveLoadedDocument edita

El problema del bloqueo de archivos es real, y la respuesta no es matar ventanas de visores

El error de reutilización suele viajar con una segunda queja, y ambas acaban enredadas porque aparecen en el mismo flujo de regenerar el archivo. Un usuario abre el PDF que acaba de producirse, lo deja abierto en Acrobat o Foxit y después lanza una reconstrucción. EndDoc intenta escribir en la misma ruta, el sistema operativo lo rechaza porque el visor mantiene un modo de compartición de lectura que bloquea a los escritores, y se obtiene un fallo de acceso denegado. Este sí es un problema genuino de bloqueo de archivos en Windows y no un problema de estado del componente, y merece una respuesta de verdad en lugar de un apaño

El apaño que circula, enumerar las ventanas de primer nivel y enviar WM_CLOSE a cualquiera cuyo título parezca el de un visor de PDF, es el instinto equivocado. Cruza los límites entre procesos para cerrar ventanas que su programa no posee, adivina qué es un visor por el texto del título y puede tirar a la basura las anotaciones sin guardar de un usuario sin preguntar. Trate todo ese enfoque como una mala señal. La solución fiable es no escribir nunca en una ruta que otro proceso pueda estar reteniendo. Serialice en un archivo temporal dentro del mismo directorio y después cámbielo de sitio con un renombrado atómico una vez que EndDoc haya terminado bien. Si un visor sigue teniendo abierto el archivo antiguo, el renombrado o bien tiene éxito limpiamente o bien falla de forma ruidosa, y usted muestra un mensaje claro en lugar de pelearse con el bloqueo

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // Archivo temporal en el MISMO directorio que el destino: un renombrado
  // dentro de un volumen NTFS cambia el nombre de forma atómica, mientras que
  // un movimiento entre volúmenes degenera en copiar y borrar y pierde esa garantía
  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;                    // aquí el archivo temporal ya está completo en disco
    finally
      Pdf.Free;
    end;

    // Colocar en su sitio. TFile.Move se niega a sobrescribir, así que primero
    // se limpia un destino obsoleto; si un visor aún retiene el archivo antiguo,
    // lo que falla, y de forma ruidosa, es el borrado, antes de tocar los bytes buenos
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // o bien: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // nunca dejar abandonado un temporal a medio escribir
    raise;
  end;
end;

Dos notas honestas sobre ese código. TFile.Move y el clásico RenameFile se traducen ambos en el mismo renombrado de Windows, que solo es atómico cuando origen y destino están en el mismo volumen, y exactamente por eso el archivo temporal va al directorio de destino y no a TPath.GetTempPath. Y la pareja borrar-y-mover no es en sí misma un paso atómico: hay una breve ventana en la que no existe ninguno de los dos archivos. Para una aplicación de escritorio que regenera un informe esa ventana es irrelevante; quien necesite un contrato más fuerte dentro del mismo volumen puede llamar directamente a ReplaceFile o MoveFileEx de Win32 con MOVEFILE_REPLACE_EXISTING, que condensa el intercambio en una sola llamada

Para un servidor de gran volumen que regenera documentos constantemente, la disciplina más limpia es escribir cada salida con un nombre único (una marca de tiempo o un identificador de trabajo) para que dos ejecuciones nunca compitan por una misma ruta, y dejar que una política de retención aparte limpie los archivos antiguos. El patrón es una línea de disciplina de nombres por petición

// Una ruta de salida por petición: dos trabajos concurrentes nunca pueden
// competir por el mismo nombre, así que no hay baile de renombrados ni bloqueo que perder
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

Un identificador de petición o de trabajo sirve igual de bien que el GUID cuando el framework circundante ya le entrega uno, y hace que el nombre del archivo pueda rastrearse hasta una línea de log sin coste alguno. En cualquier caso el principio es el mismo: diseñe de modo que el archivo que está escribiendo sea suyo y solo suyo en el momento de escribirlo. El bloqueo desaparece no porque haya forzado el cierre de una ventana, sino porque nada más está tocando esos bytes

La forma de la solución

Reduzca ambos problemas a sus raíces y los dos tratan de respetar límites. El error de la máquina de estados le pide respetar el límite de la instancia: un THotPDF, un documento, y después soltarlo y crear otro. El error del bloqueo de archivos le pide respetar el límite del archivo: escribir donde nadie más esté leyendo y después mover el resultado a su sitio. Ninguno de los dos exige parchear la biblioteca ni automatizar el escritorio. Ambos se resuelven tratando cada documento como una unidad de trabajo autocontenida, creada desde cero, escrita limpiamente y liberada, que es el mismo patrón que hace predecible el resto del componente

Las llamadas BeginDoc, EndDoc, LoadFromFile y SaveLoadedDocument mostradas aquí forman parte del HotPDF Delphi Component para Delphi y C++Builder