Artículo técnico

Cola de renderizado de PDF en segundo plano en Delphi con HotPDF

La clase THPDFBackgroundRenderer de HotPDF es un descendiente de TThread que renderiza las páginas de PDF cargadas en mapas de bits sobre un hilo de trabajo, de modo que un visor de Delphi puede seguir desplazándose y repintándose mientras una página todavía se está rasterizando en segundo plano. THPDFBackgroundRenderer.RequestPage encola un índice de página para ese hilo de trabajo, CancelAll descarta lo que todavía esté esperando, y GetCachedBitmap devuelve un mapa de bits terminado que pasa a ser propiedad de quien llama y que este debe liberar. Desplazad un contrato escaneado de doscientas páginas a resolución de impresión solo en el hilo de interfaz y cada cambio de página congela la ventana hasta que GDI termina de dibujarla, que es exactamente el tirón que THPDFBackgroundRenderer existe para eliminar

¿Por qué renderizar páginas de PDF en un hilo en segundo plano?

Un hilo en segundo plano se gana su complejidad porque el renderizador de páginas de HotPDF es un auténtico intérprete de flujo de contenido, no una copia barata de mapa de bits que vuelve antes de que nadie lo note: recorre operadores PDF, mantiene una pila de estado gráfico y rasteriza trazados, imágenes y glifos a través de GDI, el mismo motor cubierto en el renderizado de páginas de PDF cargadas a un TBitmap. Ejecutad ese trabajo de forma síncrona dentro de un gestor de desplazamiento o de pintado y el bucle de mensajes deja de bombear hasta que la llamada regresa, que es exactamente lo que es una ventana congelada. Insertar Application.ProcessMessages dentro de la llamada de renderizado no soluciona esto: permite que la cola de mensajes se vacíe, pero el propio renderizado sigue ocupando el hilo que hizo la llamada, así que la ventana repinta contenido obsoleto más deprisa mientras el trabajo real no ha avanzado ni un paso. La única forma de mantener un visor receptivo durante un renderizado genuinamente lento es ejecutar ese renderizado en otro sitio, razón por la cual THPDFBackgroundRenderer existe como una subclase de TThread en lugar de como un callback o un temporizador

Configurar una cola de solicitudes para un visor con desplazamiento

THPDFBackgroundRenderer.Create recibe la instancia THotPDF ya cargada y un DPI que permanece fijo durante toda la vida de ese renderizador, así que cada página encolada a través de una instancia se renderiza a una única resolución; un visor que admite zoom necesita un renderizador nuevo, no una propiedad DPI nueva, cada vez que cambia el nivel de zoom. RequestPage añade un índice de página a una cola interna y regresa de inmediato: no realiza ningún renderizado por sí misma y nunca toca el hilo de interfaz. Execute, el punto de entrada heredado de TThread que HotPDF ejecuta en cuanto llamáis a Start, extrae un índice cada vez del principio de esa cola, lo renderiza a través de la caché de páginas del documento y almacena una copia indexada por página para que GetCachedBitmap pueda devolverla más tarde

type
  TViewerForm = class(TForm)
    RenderPollTimer: TTimer;
    procedure RenderPollTimerTimer(Sender: TObject);
  private
    FDoc: THotPDF;
    FRenderer: THPDFBackgroundRenderer;
    FPendingPage: Integer;
    procedure RequestPageWindow(CenterPage: Integer);
  end;

procedure TViewerForm.RequestPageWindow(CenterPage: Integer);
var
  I: Integer;
begin
  if FRenderer <> nil then
  begin
    FRenderer.CancelAll;
    FRenderer.Free;
  end;
  FRenderer := THPDFBackgroundRenderer.Create(FDoc, 150);
  for I := CenterPage - 1 to CenterPage + 1 do
    if (I >= 0) and (I < FDoc.LoadedPageCount) then
      FRenderer.RequestPage(I);
  FPendingPage := CenterPage;
  FRenderer.Start;
end;

procedure TViewerForm.RenderPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  if FRenderer = nil then Exit;
  Bmp := FRenderer.GetCachedBitmap(FPendingPage);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

GetCachedBitmap devuelve nil hasta que la copia de esa página está lista, así que un patrón de sondeo mediante temporizador como el de arriba resulta suficiente; no hay ningún evento independiente de «listo» que conectar, HotPDF resuelve esto con una simple comprobación de nil en lugar de una API de notificación más elaborada. La siguiente sección cubre lo que realmente hacen CancelAll y esa llamada a Free, porque ambas cosas importan en cuanto las páginas empiezan a renderizarse fuera de orden o un desplazamiento sucede más rápido de lo que la cola puede vaciarse

El atajo de una sola llamada para una única página

THotPDF.RenderLoadedPageToBitmapAsync existe para el caso habitual de lanzar exactamente una página sin tocar THPDFBackgroundRenderer directamente: construye el renderizador internamente, llama a RequestPage una vez, arranca el hilo y devuelve la referencia TThread a quien llama, que pasa a ser su propietario y responsable de liberarla. Recuperar el resultado pasa por THotPDF.GetLoadedCachedRenderedBitmap en lugar de por el propio GetCachedBitmap del renderizador, porque GetLoadedCachedRenderedBitmap lee la caché compartida del documento, indexada por índice de página y DPI, la misma caché que ya rellenan RenderLoadedPageToBitmapCached y el precargador integrado, de modo que una página que ya haya renderizado alguna otra parte del visor a ese DPI puede devolverse de inmediato, antes incluso de que el sistema operativo haya llegado a planificar el hilo en segundo plano recién iniciado

// A simpler alternative to the queue above, for one page at a time.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // waits if a prior page is still rendering
  FAsyncWorker := Pdf.RenderLoadedPageToBitmapAsync(PageIndex, 150);
  FPendingPage := PageIndex;
end;

procedure TViewerForm.AsyncPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  Bmp := Pdf.GetLoadedCachedRenderedBitmap(FPendingPage, 150);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

¿Se puede cancelar una página que ya está encolada?

CancelAll solo elimina los trabajos que todavía están en la cola; una página que HotPDF ya haya extraído del principio y entregado a su llamada de renderizado sigue hasta completarse, porque THPDFBackgroundRenderer no tiene ningún mecanismo para interrumpir un trabajo ya en curso. En la práctica es una compensación razonable, un único renderizado de página rara vez dura lo suficiente como para que la apropiación anticipada compense la complejidad añadida, pero un desplazamiento rápido que dispare CancelAll en cada evento de desplazamiento sigue pagando el coste de la página que estuviera a mitad de renderizado en el momento de cada cancelación. La referencia oficial es directa al respecto: un renderizado ya en marcha puede terminar antes de que el hilo finalice

Execute tiene un segundo comportamiento fácil de pasar por alto: el bucle termina en cuanto encuentra la cola vacía, no se queda inactivo esperando que llegue más trabajo. Una instancia de THPDFBackgroundRenderer es, por tanto, un trabajador por lotes de un solo uso, no un servicio persistente en segundo plano: encolad un puñado de páginas, llamad a Start, y en cuanto se haya renderizado la última página encolada el hilo de sistema operativo subyacente termina por sí solo. Volver a llamar a RequestPage sobre esa misma instancia después de que Execute ya haya vaciado la cola no la reinicia, que es exactamente la razón por la que RequestPageWindow, arriba, sustituye la instancia del renderizador en cada llamada en lugar de intentar seguir alimentando un mismo objeto de larga vida

¿Es seguro tocar un TBitmap desde un hilo en segundo plano en Delphi?

Tocar un TBitmap desde un hilo en segundo plano es seguro en el diseño de HotPDF siempre que solo un hilo opere en cada momento sobre una instancia de mapa de bits dada, y THPDFBackgroundRenderer hace cumplir ese límite en lugar de dejarlo en manos de quien llama. Execute renderiza cada página dentro de la propia sección crítica de renderizado del documento, la misma que ya comparten cada llamada a RenderLoadedPageToBitmapCached y el precargador integrado PrefetchLoadedPages, de modo que el dibujo GDI real para una página dada ocurre en exactamente un hilo cada vez y nunca se solapa con otro renderizado de ese documento. El mapa de bits resultante es un objeto propiedad del hilo de trabajo que THPDFBackgroundRenderer nunca publica directamente a quien llama

GetCachedBitmap, en cambio, reserva un TBitmap completamente nuevo y llama a Assign sobre él bajo el propio bloqueo independiente del renderizador, de modo que la copia siempre ocurre mientras Execute tiene bloqueada la posibilidad de sustituir esa ranura de caché por debajo, así que el hilo que llama recibe datos de píxeles, nunca el handle original. Esa separación es también la razón para resistirse a construir un hilo de renderizado personalizado que llame directamente a las funciones de renderizado de HotPDF sin pasar por THPDFBackgroundRenderer ni por PrefetchLoadedPages: dos renderizados compitiendo contra las cachés compartidas y el grafo de objetos del mismo documento cargado es precisamente el escenario que el bloqueo interno de HotPDF existe para evitar, y la clase de renderizado en segundo plano os da ese bloqueo gratis en lugar de teneros que reimplementarlo

¿En qué se diferencia esto de la precarga de páginas integrada de HotPDF?

PrefetchLoadedPages y THPDFBackgroundRenderer resuelven problemas relacionados pero distintos: PrefetchLoadedPages, dado un rango de páginas, renderiza automáticamente todo ese entorno en la caché compartida del documento en su propio hilo de trabajo, sin ningún objeto de cola que quien llama tenga que crear o gestionar. THPDFBackgroundRenderer cambia esa automatización por control: quien llama decide exactamente qué índices de página importan y en qué orden, y puede cancelar los que todavía estén en cola sin tocar el rango que el precargador integrado esté calentando en otro sitio. Ambos pasan por el mismo bloqueo de renderizado, así que un visor puede usar PrefetchLoadedPages para el caso habitual de las próximas páginas y recurrir a THPDFBackgroundRenderer solo cuando surja algo fuera de ese patrón, como una tira de miniaturas que salta directamente a una página que el usuario acaba de pulsar

begin
  // PrefetchLoadedPages takes a 1-based "start-end" range string, while
  // RequestPage below stays 0-based like every other loaded-page index.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Reach for THPDFBackgroundRenderer only for a page outside that
  // window, such as a thumbnail the user just clicked.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

Dos detalles de ciclo de vida merecen llevarse al código de producción. La caché a nivel de documento que respalda RenderLoadedPageToBitmapCached está acotada por RenderCacheCapacity, ocho páginas por defecto, y expulsa la entrada usada menos recientemente en cuanto se llena, pero la propia lista de resultados de una instancia de THPDFBackgroundRenderer no tiene ese límite: conserva un mapa de bits por cada índice de página distinto solicitado alguna vez a través de esa instancia hasta que la propia instancia se libera, así que un renderizador mantenido vivo durante toda una sesión de desplazamiento a DPI elevado acumulará alegremente un mapa de bits a resolución completa por cada página por la que se haya pasado. HotPDF tampoco cancela automáticamente un renderizador creado por quien llama del modo en que cancela su propio precargador antes de que un documento se cargue o se destruya a sí mismo, ya que una instancia de THPDFBackgroundRenderer nunca queda registrada en el objeto THotPDF al que apunta, así que el código que llama tiene que cancelar y liberar cada renderizador construido contra un documento antes de recargar o liberar ese documento, la misma disciplina de orden que HotPDF aplica internamente a PrefetchLoadedPages

THPDFBackgroundRenderer es una pieza más de la fachada de documento cargado detrás de la arquitectura de visor MVC de HotPDF, y combina de forma natural con los flujos de trabajo a nivel de archivo de la Direct File API para PDF de gran tamaño cuando el documento que se está desplazando es en sí mismo demasiado grande como para cargarlo de forma despreocupada. El renderizado en segundo plano, las colas de solicitud y la caché de renderizado descritos aquí forman parte del componente HotPDF estándar para Delphi y C++Builder