Artigo Técnico

Visualizador de PDF com Rolagem Contínua em Delphi com PDFium Component

Uma única página A4 renderizada em um zoom de leitura confortável consome cerca de alguns megabytes de um bitmap de 32 bits. Multiplique isso por um contrato de 400 páginas e a aritmética deixa de ser abstrata: renderize cada página antecipadamente e você estará solicitando ao Windows mais de um gigabyte de bitmaps que o usuário verá uma tela por vez. O aplicativo fica sem espaço de endereço em uma compilação de 32 bits ou passa os primeiros segundos travado enquanto o GPU e o analisador de página processam páginas para as quais ninguém rolou ainda. Um leitor com rolagem contínua deve parecer uma fita alta de páginas, mas não pode realmente manter todas elas na memória ao mesmo tempo

Essa tensão é todo o problema aqui. O PDFium Component resolve isso dentro de TPdfView, portanto, a maior parte do trabalho consiste em escolher o modo de exibição correto e entender o que o componente está fazendo por você. Las partes que ele não faz por você, como dimensionar páginas para um fluxo de leitura e manter a rolagem rápida responsiva, são onde um pouco de código se mostra útil. Se você ainda estiver montando a interface ao redor (barra de ferramentas, miniaturas, caixa de pesquisa), o passo a passo de um visualizador repleto de recursos aborda esse assunto; aqui, o tema é a própria rolagem

O layout é um modo de exibição, não um painel de bitmaps

O instinto comercial ao trabalhar com formulários VCL é usar um scroll box e empilhar controles de imagem dentro dele, um por página. Resista a isso. Esse design força você a gerenciar o posicionamento da página, a matemática de rolagem e a questão da memória de uma só vez, e você reinventará cada um deles de forma inadequada. O TPdfView já modela o documento como um fluxo contínuo de páginas e expõe o layout por meio de sua propriedade DisplayMode

Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;

PdfView.DisplayMode := dmSingleContinuous;   // one page wide, scrolls vertically

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Could not open the document');

Essa é toda a configuração de rolagem contínua. O dmSingleContinuous organiza as páginas em uma única coluna vertical com os espaçamentos entre elas gerenciados internamente, e a exibição rola por essa coluna como uma única superfície. Não há controle por página a ser configurado e nenhum manipulador de rolagem a ser programado para a navegação comum. Observe a verificação em Pdf.Active após a atribuição: abrir um documento nunca gera exceções, de modo que um arquivo danificado ou protegido por senha deixa Active como False sem nenhuma exceção a ser capturada, e um visualizador que pula essa verificação renderiza um painel em branco e assume a culpa

A mesma propriedade gerencia os modos de propagação. O dmTwoPageContinuous coloca as páginas lado a lado, duas por linha, para a leitura em estilo de livro que alguns documentos exigem; o dmTwoPageContinuousWithCover faz o mesmo, mas permite que a página um fique isolada como capa para que as páginas restantes fiquem alinhadas na fronteira natural par-ímpar. Todos os três rolam continuamente. Alternar entre eles é uma simples atribuição, o que torna a adição de uma caixa de combinação de modo de exibição algo trivial mais tarde

Apenas as páginas visíveis são rasterizadas

A razão pela qual isso funciona para um arquivo de 400 páginas é que a coluna é virtual. O TPdfView conhece a altura de cada página a partir da árvore de páginas do documento, de modo que pode calcular a extensão total de rolagem e a posição de cada página sem rasterizar nada. A rasterização, a etapa cara que transforma o fluxo de conteúdo de uma página em pixels, ocorre apenas para as páginas que atualmente cruzam a porta de visualização (viewport), além de uma pequena margem para que a página esteja pronta quando for rolada para a tela. Conforme você rola para baixo, as páginas que entram na viewport são renderizadas e as páginas que saem têm seus bitmaps liberados. A memória permanece proporcional ao que cabe na tela, não ao comprimento do documento

Vale a pena internalizar isso porque altera a maneira como você pensa sobre os custos. Abrir um documento de 400 páginas é barato: analisa-se a estrutura, não o conteúdo. O custo é por página e é pago tardiamente, no momento em que uma página é rolada para perto. Um visualizador que parece instantâneo ao abrir e fluido ao rolar não está fazendo menos trabalho geral; ele está distribuindo o trabalho ao longo do caminho de leitura real do usuário e descartando o que fica para trás. A consequência prática é que você quase nunca deseja forçar a renderização de páginas antes do usuário. Deixe a visualização decidir o que é visível

Dimensione as páginas de acordo com a largura, e deixe o zoom de lado

Uma coluna de leitura exige páginas dimensionadas de acordo com a largura do painel, e não fixadas em um zoom absoluto. O FitMode faz isso e continua fazendo conforme a janela é redimensionada

Com o pfmFitWidth, o componente recalcula o zoom sempre que a visualização é redimensionada, de modo que a coluna sempre preenche a largura disponível e as alturas das páginas — e, portanto, a extensão de rolagem — seguem a partir daí. Há uma armadilha comum: atribuir o Zoom diretamente redefine o FitMode de volta para pfmNone. Isso é deliberado, pois um zoom manual e um ajuste automático são intenções contraditórias, mas significa que um PdfView.Zoom := 1.0 perdido em algum lugar do seu código desativa silenciosamente o ajuste à largura e o próximo redimensionamento deixa de ajustar o layout. Se você oferecer tanto um controle de zoom quanto um botão de ajuste, trate-os como uma alternância de modo: definir um limpa o outro, e você decide qual deles prevalece

Para controles de zoom absoluto que proporcionam uma leitura natural, a visualização expõe os zooms de ajuste como valores que você pode aplicar ou exibir: PageWidthZoom[PageNumber] retorna o zoom que ajustaria a página à largura, e o correspondente PageZoom ajusta a página inteira. Ler esses valores é como você preenche um menu "Ajustar à Largura" / "Ajustar à Página" sem codificar porcentagens mágicas que dão errado em páginas em formato paisagem ou de tamanho excessivo

Mantenha a rolagem rápida responsiva com renderização progressiva

O fluxo de renderização padrão desenha uma página por completo antes de retornar. Para uma única página, isso funciona bem. Durante uma rolagem rápida por um documento denso, não: cada página que passa rapidamente inicia uma rasterização completa e, se o usuário estiver rolando mais rápido do que as páginas podem ser renderizadas, essas renderizações se acumulam e o painel gagueja porque o trabalho está sendo feito para páginas que já saíram da tela no momento em que ele termina. A solução é tornar a renderização cancelável e abandoná-la no momento em que o usuário prossegue

O RenderPageProgressive renderiza em blocos e verifica um token de cancelamento em cada limite de bloco, de modo que uma renderização em andamento de uma página que acabou de sair da tela possa ser descartada em vez de executada até o fim

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Cancel whatever was rendering; the old token is now signaled.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // bitmap is complete, paint it
    prsCancelled: Exit;                // superseded, discard this result
    prsFailed:    ShowMessage('Render failed for page ' + IntToStr(PageNo));
  end;
end;

O formato relevante é o valor de retorno. O prsDone significa que o bitmap está totalmente desenhado e vale a pena ser exibido na tela; o prsCancelled significa que uma nova posição de rolagem substituiu esta página, de modo que você descarta o resultado parcial em vez de mostrá-lo; o prsFailed é um erro real naquela página. O cancelamento é verificado em intervalos entre blocos e não preventivamente, portanto, espere algumas dezenas de milissegundos de latência entre a chamada a Cancel e a parada real da renderização. Isso ainda é muito mais barato do que deixar uma renderização de página inteira obsoleta bloquear a fila. Passar nil como o token renderiza diretamente até a conclusão, o que é a escolha certa para uma renderização única, como uma pré-visualização de impressão, onde não há nada a ser cancelado

Quando você chama a função RenderPage, aquela que retorna um novo TBitmap, lembre-se de que o chamador é o proprietário e deve usar Free para liberá-lo. Em um loop de rolagem que aloca um bitmap por página, esquecer disso gera um vazamento de memória que cresce a cada página que o usuário percorre, o que é exatamente a falha de memória ilimitada que o design contínuo deveria evitar. Sempre que possível, renderize em um bitmap reutilizado

O que resta para você

O leitor com rolagem contínua é entregue majoritariamente pelo próprio componente. Você escolhe o dmSingleContinuous para o layout, define o pfmFitWidth para que a coluna se ajuste com a janela e verifica o Pdf.Active para que um arquivo corrompido falhe de forma explícita. A única parte que vale a pena escrever por conta própria é a renderização cancelável, pois um leitor é julgado pelo comportamento quando alguém arrasta a barra de rolagem até o final de um longo documento e o painel acompanha a velocidade ou não. Tudo o que vem depois — seleção de texto entre páginas, destaque de pesquisa, árvore de marcadores — é um trabalho de interface que fica sobre essa superfície de rolagem, e não dentro dela

As APIs TPdfView, DisplayMode e RenderPageProgressive mostradas aqui fazem parte do PDFium Component para Delphi e Lazarus