Artigo Técnico

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

A classe THPDFBackgroundRenderer do HotPDF é uma descendente de TThread que renderiza páginas de PDF carregadas em bitmaps em uma worker thread, de modo que um visualizador em Delphi pode continuar rolando e repintando enquanto uma página ainda está sendo rasterizada em segundo plano. THPDFBackgroundRenderer.RequestPage enfileira um índice de página para essa worker thread, CancelAll descarta o que ainda estiver esperando, e GetCachedBitmap devolve um bitmap concluído que quem chama passa a possuir e deve liberar. Role um contrato digitalizado de duzentas páginas em resolução de impressão apenas na thread de UI, e cada virada de página pausa a janela até o GDI terminar de desenhá-la, exatamente a travada que THPDFBackgroundRenderer existe para eliminar

Por que renderizar páginas de PDF em uma thread de segundo plano?

Uma thread de segundo plano justifica sua complexidade porque o renderizador de páginas do HotPDF é um interpretador de content stream de verdade, não uma cópia barata de bitmap que retorna antes que alguém perceba: ele percorre operadores PDF, mantém uma pilha de estado gráfico e rasteriza paths, imagens e glifos por meio do GDI, o mesmo motor coberto em renderizando páginas de PDF carregadas para um TBitmap. Execute esse trabalho de forma síncrona dentro de um handler de scroll ou de pintura, e o loop de mensagens para de bombear até a chamada retornar, o que é exatamente o que uma janela congelada é. Colocar Application.ProcessMessages dentro da chamada de renderização não resolve isso: permite que a fila de mensagens seja esvaziada, mas a própria renderização continua ocupando a thread que a chamou, de modo que a janela repinta conteúdo obsoleto mais rápido enquanto o trabalho real não avançou nada. A única forma de manter um visualizador responsivo durante uma renderização genuinamente lenta é rodar essa renderização em outro lugar, motivo pelo qual THPDFBackgroundRenderer existe como uma subclasse de TThread em vez de um callback ou um timer

Configurando uma fila de requisições para um visualizador com rolagem

THPDFBackgroundRenderer.Create recebe a instância THotPDF carregada e um DPI que permanece fixo durante toda a vida daquele renderizador, de modo que cada página enfileirada por uma instância renderiza em uma única resolução; um visualizador que suporta zoom precisa de um renderizador novo, não de uma propriedade de DPI nova, sempre que o nível de zoom muda. RequestPage anexa um índice de página a uma fila interna e retorna imediatamente: não faz renderização nenhuma por si só e nunca toca a thread de UI. Execute, o ponto de entrada herdado de TThread que o HotPDF executa assim que você chama Start, retira um índice da frente dessa fila por vez, o renderiza por meio do cache de páginas do documento, e armazena uma cópia indexada por página para que GetCachedBitmap possa devolvê-la depois

Diagrama de pipeline do HotPDF de THPDFBackgroundRenderer em que chamadas à RequestPage da thread de UI enchem uma fila interna, uma thread de trabalho a drena sob o lock de renderização compartilhado para o cache do documento, e uma sondagem por timer do GetCachedBitmap entrega ao chamador uma cópia fresca do bitmap ou nil
As solicitações inseridas na thread de UI são consumidas uma a uma pela thread de trabalho, e cada bitmap concluído volta como uma cópia de propriedade do chamador através de uma simples verificação por timer
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 retorna nil até que a cópia daquela página esteja pronta, de modo que um padrão de consulta por timer como o de cima já é suficiente; não existe um evento separado de "pronto" para conectar, o HotPDF resolve isso com uma simples verificação de nil em vez de uma API de notificação maior. A próxima seção cobre o que CancelAll e aquela chamada Free de fato fazem, porque ambos importam assim que as páginas começam a renderizar fora de ordem ou uma rolagem acontece mais rápido do que a fila consegue esvaziar

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

THotPDF.RenderLoadedPageToBitmapAsync existe para o caso comum de disparar exatamente uma página sem tocar diretamente em THPDFBackgroundRenderer: ele constrói o renderizador internamente, chama RequestPage uma vez, inicia a thread e devolve a referência TThread para quem chamou, que passa a possuí-la e é responsável por liberá-la. Recuperar o resultado passa por THotPDF.GetLoadedCachedRenderedBitmap em vez do GetCachedBitmap do próprio renderizador, porque GetLoadedCachedRenderedBitmap lê o cache compartilhado do documento indexado por página e DPI, o mesmo cache que RenderLoadedPageToBitmapCached e o pré-carregador embutido já populam — uma página que outra parte do visualizador já renderizou naquele DPI pode voltar imediatamente, antes mesmo de a thread de segundo plano recém-iniciada ter sido agendada pelo sistema operacional

// Uma alternativa mais simples à fila acima, para uma página de cada vez.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // aguarda se uma página anterior ainda está renderizando
  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 que já está enfileirada?

CancelAll só remove tarefas ainda sentadas na fila; uma página que o HotPDF já retirou da frente e entregou à sua chamada de renderização continua até a conclusão, porque THPDFBackgroundRenderer não tem mecanismo para interromper um trabalho já em andamento. Essa é uma escolha razoável na prática — a renderização de uma única página raramente é longa o bastante para justificar a complexidade adicional da preempção — mas uma rolagem rápida que dispara CancelAll a cada evento de scroll ainda paga o custo de qualquer página que estivesse no meio da renderização no momento de cada cancelamento. A referência oficial é direta sobre isso: uma renderização já em andamento pode terminar antes de a thread ser finalizada

HotPDF: retrato do CancelAll descartando trabalhos de renderização na fila enquanto uma página já retirada da fila continua renderizando até o fim, mais a nota de ciclo de vida one-shot de que uma instância de renderizador drenada nunca reinicia
CancelAll limpa apenas as entradas ainda esperando na fila, a página em andamento sempre termina, e uma instância esvaziada deve ser substituída em vez de reutilizada

Execute tem um segundo comportamento fácil de passar despercebido: o loop termina assim que encontra a fila vazia, ele não fica ocioso esperando mais trabalho chegar. Uma instância de THPDFBackgroundRenderer é, portanto, um worker de lote de uso único, não um serviço de segundo plano persistente — enfileire um punhado de páginas, chame Start, e assim que a última página enfileirada tiver renderizado, a thread do sistema operacional subjacente termina por conta própria. Chamar RequestPage novamente na mesma instância depois que Execute já esvaziou a fila não a reinicia, motivo exato pelo qual RequestPageWindow acima substitui a instância do renderizador a cada chamada em vez de tentar continuar alimentando um único objeto de vida longa

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

Tocar um TBitmap a partir de uma thread de segundo plano é seguro no design do HotPDF, desde que apenas uma thread por vez opere sobre uma determinada instância de bitmap, e THPDFBackgroundRenderer impõe essa fronteira em vez de deixá-la a cargo de quem chama. Execute renderiza cada página dentro do próprio lock de renderização do documento, a mesma seção crítica que toda chamada RenderLoadedPageToBitmapCached e o pré-carregador embutido PrefetchLoadedPages já compartilham, de modo que o desenho GDI real de uma determinada página acontece em exatamente uma thread por vez e nunca se sobrepõe a outra renderização daquele documento. O bitmap resultante é um objeto de posse da worker thread que THPDFBackgroundRenderer nunca publica diretamente para quem chamou

GetCachedBitmap, em vez disso, aloca um TBitmap totalmente novo e chama Assign nele sob o próprio lock separado do renderizador, de modo que a cópia sempre acontece enquanto Execute está bloqueado de substituir aquele slot de cache por baixo dela — a thread que chama recebe dados de pixel, nunca o handle original. Essa separação também é o motivo para resistir à tentação de montar uma thread de renderização personalizada que chame as funções de renderização do HotPDF diretamente, sem passar por THPDFBackgroundRenderer ou PrefetchLoadedPages: duas renderizações competindo pelos mesmos caches compartilhados e grafo de objetos do mesmo documento carregado é precisamente o cenário que o bloqueio interno do HotPDF existe para prevenir, e a classe de renderizador de segundo plano oferece esse bloqueio de graça, em vez de você reimplementá-lo

Como isso difere do pré-carregamento de páginas embutido do HotPDF?

PrefetchLoadedPages e THPDFBackgroundRenderer resolvem problemas relacionados, mas diferentes: PrefetchLoadedPages, dado um intervalo de páginas, renderiza toda essa vizinhança no cache compartilhado do documento automaticamente, em sua própria worker thread, sem nenhum objeto de fila para quem chama criar ou gerenciar. THPDFBackgroundRenderer troca essa automação por controle — quem chama decide exatamente quais índices de página importam e em que ordem, e pode cancelar os que ainda estão enfileirados sem tocar em qualquer intervalo que o pré-carregador embutido esteja aquecendo em outro lugar. Ambos passam pelo mesmo lock de renderização, de modo que um visualizador pode rodar PrefetchLoadedPages para o caso comum das próximas páginas e recorrer a THPDFBackgroundRenderer apenas quando algo fora desse padrão surgir, como uma tira de miniaturas pulando direto para uma página que o usuário acabou de clicar

begin
  // PrefetchLoadedPages recebe uma string de intervalo "início-fim" baseada em 1, enquanto
  // RequestPage abaixo permanece baseado em 0 como qualquer outro índice de página carregada.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Recorra a THPDFBackgroundRenderer apenas para uma página fora dessa
  // janela, como uma miniatura que o usuário acabou de clicar.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

Dois detalhes de ciclo de vida valem a pena carregar para código de produção. O cache do documento inteiro por trás de RenderLoadedPageToBitmapCached é limitado por RenderCacheCapacity, oito páginas por padrão, e descarta a entrada usada há mais tempo assim que fica cheio, mas a própria lista de resultados de uma instância de THPDFBackgroundRenderer não tem esse limite — ela mantém um bitmap por índice de página distinto já requisitado através daquela instância até que a própria instância seja liberada, de modo que um renderizador mantido vivo durante toda uma sessão de rolagem em DPI alto vai acumular alegremente um bitmap em resolução total por página rolada. O HotPDF também não cancela automaticamente um renderizador criado por quem chama, da mesma forma que cancela seu próprio pré-carregador antes de um documento carregar ou se destruir, já que uma instância de THPDFBackgroundRenderer nunca é registrada no objeto THotPDF ao qual aponta — de modo que o código que chama precisa cancelar e liberar cada renderizador construído contra um documento antes de recarregar ou liberar esse documento, a mesma disciplina de ordenação que o HotPDF aplica internamente a PrefetchLoadedPages

Comparação do HotPDF do aquecimento automático de vizinhos do PrefetchLoadedPages embutido contra a fila controlada pelo chamador de THPDFBackgroundRenderer, com ambos os caminhos serializados por um lock de renderização de todo o documento
PrefetchLoadedPages automatiza a janela comum de próximas páginas, enquanto THPDFBackgroundRenderer atende saltos explícitos como cliques em miniaturas, e ambos os caminhos se serializam no mesmo lock de renderização

THPDFBackgroundRenderer é uma peça da fachada de documento carregado por trás da arquitetura MVC de visualizador do HotPDF, e combina naturalmente com os fluxos em nível de arquivo em a Direct File API para PDFs grandes quando o próprio documento sendo rolado já é grande demais para carregar despreocupadamente em primeiro lugar. Renderização em segundo plano, filas de requisição e o cache de renderização descritos aqui fazem todos parte do componente HotPDF padrão para Delphi e C++Builder