Artigo Técnico

Fila de Renderização de PDF em Segundo Plano em Delphi com o HotPDF

A classe THPDFBackgroundRenderer do HotPDF é um descendente de TThread que renderiza páginas de PDF carregadas em bitmaps numa thread de trabalho, para que um visualizador Delphi possa continuar a fazer scroll e a repintar enquanto uma página ainda está a ser rasterizada em segundo plano. THPDFBackgroundRenderer.RequestPage coloca em fila um índice de página para essa thread de trabalho, CancelAll descarta o que ainda esteja em espera, e GetCachedBitmap devolve um bitmap concluído que o chamador possui e tem de libertar. Fazer scroll a um contrato digitalizado de duzentas páginas em resolução de impressão apenas na thread de UI faz com que cada mudança de página pause a janela até o GDI terminar de a desenhar, exatamente a intermitência que o THPDFBackgroundRenderer existe para eliminar

Porque renderizar páginas de PDF numa thread em segundo plano?

Uma thread em segundo plano justifica a sua complexidade porque o motor de renderização de páginas do HotPDF é um verdadeiro intérprete de fluxo de conteúdo, não uma cópia barata de bitmap que regressa antes de alguém dar por isso: percorre operadores PDF, mantém uma pilha de estado gráfico, e rasteriza percursos, imagens e glifos através do GDI, o mesmo motor abordado em renderizar páginas de PDF carregadas para um TBitmap. Executar esse trabalho de forma síncrona dentro de um manipulador de scroll ou de pintura faz com que o ciclo de mensagens deixe de bombear até a chamada regressar, o que é exatamente o que é uma janela congelada. Colocar Application.ProcessMessages dentro da chamada de renderização não resolve isto: permite que a fila de mensagens esvazie, mas a própria renderização continua a deter a thread que a chamou, pelo que a janela repinta conteúdo obsoleto mais depressa enquanto o trabalho real não avançou nada. A única forma de manter um visualizador responsivo durante uma renderização genuinamente lenta é executar essa renderização noutro lugar, razão pela qual o THPDFBackgroundRenderer existe como subclasse de TThread em vez de um callback ou de um temporizador

Configurar uma fila de pedidos para um visualizador com scroll

THPDFBackgroundRenderer.Create recebe a instância THotPDF carregada e um DPI que permanece fixo durante toda a vida desse renderizador, pelo que cada página colocada em fila através de uma instância é renderizada a uma única resolução; um visualizador que suporte zoom precisa de um renderizador novo, não de uma propriedade DPI nova, sempre que o nível de zoom mude. RequestPage adiciona um índice de página a uma fila interna e regressa de imediato: não faz qualquer renderização a si próprio e nunca toca na thread de UI. Execute, o ponto de entrada TThread herdado que o HotPDF executa assim que se chama Start, retira um índice da frente dessa fila de cada vez, renderiza-o através da cache de páginas do documento, e armazena uma cópia indexada por página para que GetCachedBitmap a possa devolver mais 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 devolve nil até a cópia dessa página estar pronta, pelo que um padrão de polling num temporizador, como o acima, é suficiente; não existe um evento de "pronto" separado a configurar, o HotPDF resolve isto com uma simples verificação de nil em vez de uma API de notificação maior. A secção seguinte aborda o que CancelAll e essa chamada a Free estão efetivamente a fazer, porque ambos importam assim que as páginas começam a renderizar fora de ordem ou um scroll acontece mais depressa do que a fila consegue esvaziar

O atalho de uma chamada para uma única página

THotPDF.RenderLoadedPageToBitmapAsync existe para o caso comum de disparar exatamente uma página sem tocar diretamente em THPDFBackgroundRenderer: constrói o renderizador internamente, chama RequestPage uma vez, inicia a thread, e devolve a referência TThread ao chamador, que a possui e é responsável por a libertar. Obter o resultado passa por THotPDF.GetLoadedCachedRenderedBitmap em vez do próprio GetCachedBitmap do renderizador, porque GetLoadedCachedRenderedBitmap lê a cache partilhada do documento, indexada por página e DPI, a mesma cache que RenderLoadedPageToBitmapCached e o pré-carregador incorporado já preenchem — uma página que outra parte do visualizador já tenha renderizado a esse DPI pode regressar de imediato, antes mesmo de a thread em segundo plano que acabou de arrancar ter sido sequer agendada pelo sistema operativo

// 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;

É possível cancelar uma página já colocada em fila?

CancelAll só remove trabalhos ainda pousados na fila; uma página que o HotPDF já tenha retirado da frente e entregue à sua chamada de renderização continua até à conclusão, porque o THPDFBackgroundRenderer não tem qualquer mecanismo para interromper trabalho já em curso. Isso é uma troca razoável na prática — a renderização de uma única página raramente é suficientemente longa para justificar a complexidade adicional da preempção — mas um scroll rápido que dispara CancelAll em cada evento de scroll continua a pagar o custo de qualquer página que estivesse a meio da renderização no momento de cada cancelamento. A referência oficial é direta quanto a isto: uma renderização já em curso pode terminar antes de a thread ser encerrada

Execute tem um segundo comportamento, fácil de não notar: o ciclo termina assim que encontra a fila vazia, não fica em espera à espera de mais trabalho. Uma instância de THPDFBackgroundRenderer é, portanto, um trabalhador em lote de utilização única, não um serviço em segundo plano persistente — coloque em fila um punhado de páginas, chame Start, e assim que a última página em fila for renderizada, a thread do sistema operativo subjacente termina por conta própria. Chamar RequestPage de novo nessa mesma instância depois de Execute já ter esvaziado a fila não a reinicia, e é exatamente por isso que RequestPageWindow acima substitui a instância do renderizador em cada chamada em vez de tentar continuar a alimentar um único objeto de vida longa

É seguro tocar num TBitmap a partir de uma thread em segundo plano em Delphi?

Tocar num TBitmap a partir de uma thread em segundo plano é seguro no design do HotPDF, desde que apenas uma thread de cada vez opere sobre uma dada instância de bitmap, e o THPDFBackgroundRenderer impõe esse limite em vez de o deixar a cargo do chamador. Execute renderiza cada página dentro do próprio bloqueio de renderização do documento, a mesma secção crítica que cada chamada a RenderLoadedPageToBitmapCached e o pré-carregador incorporado PrefetchLoadedPages já partilham, pelo que o desenho GDI efetivo para uma dada página acontece exatamente numa thread de cada vez e nunca se sobrepõe a outra renderização desse documento. O bitmap resultante é um objeto detido pela thread de trabalho que o THPDFBackgroundRenderer nunca publica diretamente a um chamador

GetCachedBitmap, em vez disso, aloca um TBitmap totalmente novo e chama Assign sobre ele sob o próprio bloqueio separado do renderizador, pelo que a cópia acontece sempre enquanto Execute está impedido de substituir essa posição de cache por baixo dela — a thread chamadora recebe dados de pixel, nunca o handle original. Essa separação é também a razão para resistir à tentação de construir uma thread de renderização personalizada que chame diretamente as funções de renderização do HotPDF sem passar por THPDFBackgroundRenderer ou PrefetchLoadedPages: duas renderizações a competir pelas mesmas caches partilhadas e pelo mesmo grafo de objetos de um documento carregado é precisamente o cenário que o bloqueio interno do HotPDF existe para prevenir, e a classe do renderizador em segundo plano proporciona esse bloqueio de graça, em vez de o reimplementar

Em que difere isto do pré-carregamento de páginas incorporado no HotPDF?

PrefetchLoadedPages e THPDFBackgroundRenderer resolvem problemas relacionados mas diferentes: PrefetchLoadedPages, dado um intervalo de páginas, renderiza automaticamente toda essa vizinhança para a cache partilhada do documento na sua própria thread de trabalho, sem qualquer objeto de fila que o chamador tenha de criar ou gerir. THPDFBackgroundRenderer troca essa automação por controlo — o chamador decide exatamente que índices de página importam e por que ordem, e pode cancelar os que ainda estejam em fila sem tocar em qualquer intervalo que o pré-carregador incorporado esteja a aquecer noutro sítio. Ambos passam pelo mesmo bloqueio de renderização, pelo que um visualizador pode executar PrefetchLoadedPages para o caso habitual das próximas páginas e recorrer a THPDFBackgroundRenderer apenas quando surge algo fora desse padrão, como uma faixa de miniaturas a saltar diretamente para uma página em que o utilizador acabou de clicar

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;

Dois pormenores de ciclo de vida valem a pena transportar para código de produção. A cache ao nível do documento por trás de RenderLoadedPageToBitmapCached é limitada por RenderCacheCapacity, oito páginas por predefinição, e expulsa a entrada usada há mais tempo assim que fica cheia, mas a própria lista de resultados de uma instância de THPDFBackgroundRenderer não tem esse limite — mantém um bitmap por cada índice de página distinto alguma vez pedido através dessa instância até a própria instância ser libertada, pelo que um renderizador mantido vivo durante uma sessão inteira de scroll a alto DPI irá acumular de bom grado um bitmap de resolução total por cada página passada. O HotPDF também não cancela automaticamente um renderizador criado pelo chamador da forma como cancela o seu próprio pré-carregador antes de um documento carregar ou de se destruir a si próprio, uma vez que uma instância de THPDFBackgroundRenderer nunca é registada no objeto THotPDF para o qual aponta — pelo que o código chamador tem de cancelar e libertar cada renderizador construído contra um documento antes de recarregar ou libertar esse documento, a mesma disciplina de ordenação que o HotPDF aplica internamente a PrefetchLoadedPages

THPDFBackgroundRenderer é uma peça da fachada de documento carregado por trás da arquitetura de visualizador MVC do HotPDF, e combina-se naturalmente com os fluxos de trabalho ao nível de ficheiro em a Direct File API para PDFs de grande dimensão quando o próprio documento a ser percorrido é demasiado grande para carregar de forma despreocupada em primeiro lugar. A renderização em segundo plano, as filas de pedidos, e a cache de renderização aqui descritas fazem todas parte do componente HotPDF standard para Delphi e C++Builder