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 páginas de PDF cargadas en mapas de bits sobre un hilo de trabajo, de modo que un visor de Delphi pueda seguir desplazándose y repintando 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 entrega un mapa de bits terminado que queda bajo propiedad de quien llama y que debe liberar. Desplazarse por un contrato escaneado de doscientas páginas a resolución de impresión solo en el hilo de la interfaz hace que cada cambio de página pause la ventana hasta que GDI termine de dibujarla, que es exactamente el tartamudeo 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 verdadero intérprete de flujo de contenido, no una copia barata de mapa de bits que retorna 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 renderizar páginas de PDF cargadas a un TBitmap. Ejecutar ese trabajo de forma síncrona dentro de un manejador de desplazamiento o pintura detiene el bombeo del bucle de mensajes hasta que la llamada retorna, que es lo que en realidad es una ventana congelada. Colocar Application.ProcessMessages dentro de la llamada de renderizado no arregla esto: permite que la cola de mensajes se vacíe, pero el renderizado en sí sigue poseyendo el hilo que lo llamó, así que la ventana repinta contenido obsoleto más rápido mientras el trabajo real no ha avanzado en absoluto. La única forma de mantener un visor responsivo durante un renderizado genuinamente lento es ejecutar ese renderizado en otro lugar, por eso THPDFBackgroundRenderer existe como una subclase de TThread en lugar de un callback o un temporizador

Configurar una cola de solicitudes para un visor con desplazamiento

THPDFBackgroundRenderer.Create toma 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 sola 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 agrega un índice de página a una cola interna y retorna de inmediato: no realiza ningún renderizado por sí mismo y nunca toca el hilo de la interfaz. Execute, el punto de entrada de TThread heredado que HotPDF ejecuta una vez que se llama a Start, extrae un índice del frente de esa cola a la vez, 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 con temporizador como el de arriba es suficiente; no hay un evento de "listo" separado que conectar, HotPDF resuelve esto con una simple comprobación de nil en lugar de una API de notificación más grande. La siguiente sección cubre qué hacen realmente CancelAll y esa llamada a Free, porque ambos importan una vez que las páginas empiezan a renderizarse fuera de orden o un desplazamiento ocurre 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 común de disparar exactamente una página sin tocar THPDFBackgroundRenderer directamente: construye el renderizador internamente, llama a RequestPage una vez, inicia el hilo, y devuelve la referencia TThread a quien llama, que la posee y es responsable de liberarla. Obtener el resultado pasa por THotPDF.GetLoadedCachedRenderedBitmap en lugar del propio GetCachedBitmap del renderizador, porque GetLoadedCachedRenderedBitmap lee la caché compartida del documento indexada por página y DPI, la misma caché que RenderLoadedPageToBitmapCached y el precargador integrado ya llenan; una página que alguna otra parte del visor ya renderizó a ese DPI puede regresar de inmediato, incluso antes de que el sistema operativo haya programado 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 esperando en la cola; una página que HotPDF ya sacó del frente y entregó a su llamada de renderizado sigue hasta completarse, porque THPDFBackgroundRenderer no tiene ningún mecanismo para interrumpir un trabajo ya en curso. Eso es una compensación razonable en la práctica —el renderizado de una sola página rara vez es lo bastante largo como para que la preempción valga la complejidad adicional— pero un desplazamiento rápido que dispara CancelAll en cada evento de scroll igual paga el costo de cualquier 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 queda inactivo esperando que llegue más trabajo. Una instancia de THPDFBackgroundRenderer es, por lo tanto, un trabajador por lotes de un solo uso, no un servicio persistente en segundo plano —encole un puñado de páginas, llame a Start, y una vez que la última página encolada se haya renderizado, el hilo del sistema operativo subyacente termina por sí solo. Volver a llamar a RequestPage en esa misma instancia después de que Execute ya haya vaciado la cola no la reinicia, que es exactamente por qué RequestPageWindow arriba reemplaza la instancia del renderizador en cada llamada en lugar de intentar seguir alimentando un solo 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 sobre una instancia de mapa de bits dada a la vez, y THPDFBackgroundRenderer hace cumplir ese límite en lugar de dejarlo a criterio de quien llama. Execute renderiza cada página dentro del propio bloqueo de renderizado del documento, la misma sección crítica que ya comparten cada llamada a RenderLoadedPageToBitmapCached y el precargador integrado PrefetchLoadedPages, así que el dibujo GDI real para una página dada ocurre en exactamente un hilo a la vez y nunca se superpone 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 asigna un TBitmap completamente nuevo y llama a Assign sobre él bajo el propio bloqueo separado del renderizador, así que la copia siempre ocurre mientras Execute tiene bloqueado el reemplazo de esa ranura de caché por debajo; el hilo que llama obtiene datos de píxeles, nunca el handle original. Esa separación es también la razón para resistir la tentación de armar un hilo de renderizado personalizado que llame a las funciones de renderizado de HotPDF directamente sin pasar por THPDFBackgroundRenderer o 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 renderizador en segundo plano le da ese bloqueo de forma gratuita en lugar de tener 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 vecindario 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 encolados sin tocar el rango que el precargador integrado esté calentando en otro lugar. Ambos pasan por el mismo bloqueo de renderizado, así que un visor puede ejecutar PrefetchLoadedPages para el caso ordinario de las siguientes páginas y recurrir a THPDFBackgroundRenderer solo cuando surge algo fuera de ese patrón, como una tira de miniaturas que salta directamente a una página que el usuario acaba de hacer clic

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 vale la pena llevarse al código de producción. La caché a nivel de documento detrás de RenderLoadedPageToBitmapCached está acotada por RenderCacheCapacity, ocho páginas por defecto, y desaloja la entrada usada menos recientemente una vez 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 instancia misma se libera, así que un renderizador mantenido vivo durante toda una sesión de desplazamiento a DPI alto acumulará con gusto un mapa de bits de resolución completa por cada página desplazada. HotPDF tampoco cancela automáticamente un renderizador creado por quien llama de la forma 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 se registra 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 de la fachada de documento cargado detrás de la arquitectura de visor MVC de HotPDF, y combina naturalmente con los flujos de trabajo a nivel de archivo en la API de archivo directo para PDF grandes cuando el documento que se está desplazando es en sí mismo demasiado grande para cargarlo casualmente en primer lugar. El renderizado en segundo plano, las colas de solicitudes y la caché de renderizado descritos aquí son todos parte del componente HotPDF estándar para Delphi y C++Builder