Artigo Técnico

Visualizador de PDF Personalizado no Delphi com HotPDF: a Arquitetura MVC

O HotPDF separa seu visualizador de PDF para Delphi em duas peças: THPDFViewerModel, uma classe simples que detém o estado de zoom, rotação, busca, destaque e navegação sem nenhuma dependência de handle de janela, e THPDFViewer, um controle baseado em TScrollBox que transforma esse estado em pixels. Essa divisão é o que permite que a lógica do visualizador rode, e seja testada, sem nunca criar um formulário

A maioria dos controles de visualizador personalizados não é assim. O nível de zoom mora em um campo privado do controle, a navegação de página limita seus próprios limites dentro do handler OnClick de um botão, e a única forma de saber se Ctrl+scroll respeita um teto de zoom é rodar a aplicação, clicar e observar. Um controle construído desse jeito funciona bem até precisar de uma suíte de regressão, ou de um segundo hospedeiro — um diálogo de pré-visualização de impressão, uma trilha de miniaturas, um revisor em lote sem nenhuma janela visível — e o estado necessário acaba soldado a um TWinControl que insiste em ter um handle real antes de fazer qualquer coisa

Por que um controle de visualizador de PDF precisa de uma divisão MVC?

Um visualizador de PDF precisa desse tipo de divisão porque seu estado e sua apresentação mudam por motivos diferentes e em ritmos diferentes. Índice de página, zoom, rotação de visualização, resultados de busca e regiões de destaque são estado de negócio: podem ser calculados, validados e serializados sem um único pixel na tela. Pintar um bitmap, capturar o mouse e desenhar um retângulo de seleção por arraste são preocupações de apresentação que só fazem sentido quando um controle já existe. O HotPDF mantém o primeiro grupo em THPDFViewerModel, uma classe sem nenhum ancestral de janela VCL, e o segundo grupo em THPDFViewer, que possui uma instância de modelo e reage a ela — mais próximo de um par Model-View do que de um MVC de três camadas de livro-texto, já que não há uma classe Controller separada e o próprio THPDFViewer transforma eventos brutos de teclado e mouse em chamadas ao modelo. O que importa mais do que o rótulo é a direção da dependência: nada em THPDFViewerModel exige um Handle, um loop de mensagens ou uma área de trabalho visível, o que é exatamente o que permite que a própria suíte de testes do HotPDF exercite paginação, limitação de zoom, comandos de teclado e conversões de coordenada de ida e volta via DUnitX sem abrir uma janela

uses
  DUnitX.TestFramework,
  HPDFDoc, HPDFViewerModel;

type
  [TestFixture]
  TViewerModelTests = class
  public
    [Test]
    procedure ZoomInStopsAtTheTopPresetLevel;
  end;

procedure TViewerModelTests.ZoomInStopsAtTheTopPresetLevel;
var
  Doc: THotPDF;
  Model: THPDFViewerModel;
begin
  Doc := THotPDF.Create(nil);
  Model := THPDFViewerModel.Create;
  try
    Doc.LoadFromFile('sample.pdf');
    Model.Document := Doc;
    Model.Zoom := 64.0;          // top of the preset table (6400%)
    Model.ZoomIn;                // already at the ceiling
    Assert.AreEqual(64.0, Model.Zoom, 0.0001);
  finally
    Model.Free;
    Doc.Free;
  end;
end;

O que o THPDFViewerModel realmente detém

THPDFViewerModel detém tudo que um visualizador precisa para responder o que deveria estar atualmente na tela, sem deter como desenhar isso. PageIndex, PageNumber e PageCount rastreiam a posição; Zoom e ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) rastreiam a escala; ViewRotation rastreia uma rotação não destrutiva na tela que nunca toca a própria entrada /Rotate da página. Métodos de navegação — FirstPage, PriorPage, NextPage, LastPage — e métodos de zoom — ZoomIn, ZoomOut, percorrendo uma tabela fixa de dezenove níveis predefinidos de 5% a 6400% — também moram aqui, junto com FindAll/FindNext/FindPrevious para busca de texto e AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions para anotações de página persistentes que quem chama queira manter entre renderizações. O modelo detém tanto a saída quanto a entrada: CreateCurrentPageSnapshot e CreateCurrentPageMetafile exportam exatamente a página atualmente na tela, e PrintCurrentView envia essa mesma visualização atual — página atual, DPI derivado do zoom atual, rotação atual — para um TPrinter, uma tarefa mais restrita e específica da visualização do que o pipeline de impressão de todo o documento coberto em o passo a passo de impressão com TPrinter do HotPDF. Toda mutação relevante também dispara um evento correspondente — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — de modo que um assinante descobre o que mudou sem precisar consultar continuamente

Como o THPDFViewer sabe quando repintar?

O THPDFViewer sabe quando repintar porque se inscreve no modelo em vez de adivinhar. O construtor de THPDFViewer cria um THPDFViewerModel privado, depois conecta cada um de seus eventos de notificação — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — a um handler privado correspondente. O trabalho de cada handler é pequeno: chamar RefreshDocument, o método que de fato rasteriza a página atual através do mesmo renderizador de página com cache descrito em os internos de renderização de página para bitmap do HotPDF, depois compõe caixas de destaque e resultados de busca por cima e aplica a rotação de visualização atual. Propriedades publicadas como PageIndex, Zoom, ZoomMode e ViewRotation são encaminhadores finos — o getter lê FModel.PageIndex, o setter escreve em FModel.PageIndex — de modo que, pelo Object Inspector ou pelo código, o controle parece deter o estado diretamente, mesmo que THPDFViewerModel seja o único lugar onde esse estado de fato existe. Quem chama também não está limitado ao subconjunto encaminhado: THPDFViewer expõe o próprio modelo por meio de uma propriedade somente leitura Model: THPDFViewerModel, de modo que código que queira FindFormFieldAt ou PrefetchCurrentPageSnapshots — nenhum dos dois reexposto pelo controle — pode ultrapassar o wrapper e chamar o modelo diretamente

procedure THPDFViewer.RefreshDocument;
var
  Bitmap: TBitmap;
  DPI: Integer;
begin
  // simplified: the real method also resolves fit-mode DPI
  // and composites highlight and search-hit rectangles first
  if (FModel.Document = nil) or (FModel.PageIndex < 0) then Exit;
  DPI := Round(96 * FModel.Zoom);
  Bitmap := FModel.Document.RenderLoadedPageToBitmapCached(FModel.PageIndex, DPI);
  try
    FModel.ApplyViewRotation(Bitmap);
    FImage.Picture.Bitmap.Assign(Bitmap);
  finally
    Bitmap.Free;
  end;
end;

BeginUpdate e EndUpdate: interrompendo tempestades de redesenho

BeginUpdate e EndUpdate existem porque uma única mudança lógica frequentemente toca várias partes do estado ao mesmo tempo, e repintar depois de cada parte seria desperdício e visualmente ruidoso. Trocar o documento carregado é o exemplo mais claro: atribuir THPDFViewerModel.Document reinicia a rotação de visualização, limpa os resultados de busca, limpa as regiões de destaque e pula para a página um, e cada uma dessas etapas normalmente dispara seu próprio evento de mudança. THPDFViewerModel envolve essa sequência em BeginUpdate/EndUpdate, um par com contagem de referências em que chamadas aninhadas só disparam OnBeginUpdate na transição para a chamada mais externa e OnEndUpdate na transição de volta para fora dela. THPDFViewer rastreia essa mesma profundidade do seu lado e pula RefreshDocument para cada evento granular enquanto a contagem estiver acima de zero, depois repinta exatamente uma vez quando o lote se encerra. Os eventos granulares continuam disparando durante o lote, então um assinante que só se importa com OnSearchChange ainda é notificado; é apenas a repintura do próprio controle que é reduzida a uma chamada em vez de quatro

Como o destaque por arraste (marquee) mapeia um arraste de mouse de volta para coordenadas do PDF?

O destaque por arraste mapeia um arraste de mouse de volta para coordenadas do PDF por meio de um par de métodos do modelo construídos exatamente para essa ida e volta: PagePointToView e ViewPointToPage. Ambos recebem um índice de página, um DPI e um ponto, e ambos resolvem a transformação em dois estágios — primeiro a própria entrada /Rotate da página e sua origem PDF no canto inferior esquerdo, depois a ViewRotation separada e não destrutiva da visualização e a origem de dispositivo no canto superior esquerdo do visualizador — especificamente para que a direção inversa consiga desfazer os dois estágios em ordem estritamente reversa e fazer a conversão de ida e volta corretamente nas dezesseis combinações de rotação de página e rotação de visualização. THPDFViewer chama ViewPointToPage quando o usuário solta o mouse depois de arrastar um retângulo no modo de interação vimHighlight, transforma os dois pontos de dispositivo em um THPDFRectangle no espaço da página e o entrega a Model.AddHighlightRegion. Um detalhe que vale conhecer se você for construir algo parecido: a captura do mouse pertence ao visualizador descendente de TScrollBox, não ao TImage filho onde o bitmap é pintado, porque TControl.MouseCapture é protegido e só o controle pai pode reivindicá-lo — de modo que um arraste que sai dos limites da imagem antes de o botão ser solto ainda é resolvido pelo próprio MouseMove/MouseUp sobrescrito do visualizador, em vez de ser silenciosamente descartado pelo controle filho

var
  ViewPt, PagePt: THPDFViewerPoint;
  Rect: THPDFRectangle;
begin
  ViewPt.X := 240;   // device pixels inside the rendered image
  ViewPt.Y := 96;
  if Model.ViewPointToPage(Model.PageIndex, ViewPt, PagePt,
     RenderedDPI) then                 // DPI you last rendered at
  begin
    Rect.Left := PagePt.X - 40;  Rect.Bottom := PagePt.Y - 10;
    Rect.Right := PagePt.X + 40; Rect.Top := PagePt.Y + 10;
    Model.AddHighlightRegion(Model.PageIndex, Rect);
  end;
end;

O que a divisão traz além de uma suíte de testes passando

O ganho não se limita a testes passando em um job de CI sem sessão de área de trabalho. Como THPDFViewer encaminha para THPDFViewerModel em vez de duplicar sua lógica, o HotPDF conseguiu adicionar um terceiro consumidor — THPDFViewerAction e subclasses concretas como THPDFZoomInAction e THPDFFindNextAction — que conectam navegação, zoom, busca e rotação a um TActionList padrão do Delphi, de modo que um botão de barra de ferramentas ou um item de menu pode conduzir o visualizador de forma declarativa, habilitando-se automaticamente com base em se um visualizador está atualmente resolvido como o alvo da ação. Nenhuma parte dessa camada precisou saber nada sobre bitmaps ou GDI; ela chama Viewer.NextPage ou Viewer.Model.FindNext, e a cadeia de eventos existente cuida da repintura. E como nada em THPDFViewerModel referencia TScrollBox, TImage ou um handle de janela, a máquina de estados por baixo também não está soldada a esse único controle — o mesmo modelo poderia estar por trás de uma superfície de renderização diferente sem tocar em uma linha sequer de lógica de navegação, zoom ou busca

Onde o cache de renderização ajuda, e onde não ajuda

O cache de renderização de THPDFViewerModel ajuda dentro de um documento já carregado, mas não muda o que carregar esse documento custa em primeiro lugar. CreatePageSnapshot, CreateCurrentPageSnapshot e os métodos de pré-carregamento PrefetchPageSnapshots/PrefetchCurrentPageSnapshots passam todos pelo mesmo renderizador com cache indexado por página e DPI, de modo que voltar a uma página já vista no mesmo nível de zoom é um acerto de cache em vez de uma nova renderização, e pré-carregar um pequeno raio de páginas vizinhas suaviza o caso comum de um leitor avançando página por página. Nada disso, porém, toca o custo da chamada inicial LoadFromFile, e um visualizador construído para abrir o que quer que um usuário arraste até ele eventualmente encontra um arquivo grande o bastante para tornar essa chamada o verdadeiro gargalo. Para a alternativa em camadas, baseada em handle, a um carregamento completo — que vale a pena conhecer antes de esse dia chegar — veja o artigo complementar sobre a Direct File API para PDFs grandes

As classes Model e View descritas aqui são mais duas peças da mesma superfície de documento carregado usada em todo o componente HotPDF para Delphi e C++Builder, construído para ser conduzido a partir de um formulário, de um TActionList, ou de nenhum dos dois