Artigo Técnico

Visualizador de PDF Personalizado em Delphi com o HotPDF: a Arquitetura MVC

O HotPDF divide o seu visualizador de PDF em Delphi em duas peças: THPDFViewerModel, uma classe simples que detém o estado de zoom, rotação, pesquisa, realce e navegação sem qualquer dependência de handle de janela, e THPDFViewer, um controlo baseado em TScrollBox que transforma esse estado em pixels. Esta divisão é o que permite que a lógica do visualizador seja executada, e testada, sem nunca criar um formulário

A maioria dos controlos de visualizador personalizados não é assim. O nível de zoom vive num campo privado do controlo, a navegação de páginas limita os seus valores dentro de um manipulador OnClick de um botão, e a única forma de saber se o Ctrl+scroll respeita um teto de zoom é executar a aplicação, clicar, e olhar. Um controlo construído dessa forma funciona bem até precisar de uma suite de regressão, ou de um segundo hospedeiro — uma caixa de diálogo de pré-visualização de impressão, uma faixa de miniaturas, um revisor em lote sem qualquer janela visível — e o estado de que se precisa acaba por estar soldado a um TWinControl que insiste em ter um handle real antes de fazer o que quer que seja

Porque precisa um controlo de visualizador de PDF de uma divisão MVC?

Um visualizador de PDF precisa deste tipo de divisão porque o seu estado e a sua apresentação mudam por razões diferentes e a ritmos diferentes. O índice de página, o zoom, a rotação de vista, os resultados de pesquisa e as regiões de realce são estado de negócio: podem ser calculados, validados e serializados sem um único pixel no ecrã. Pintar um bitmap, capturar o rato, e desenhar um retângulo de seleção em laço são preocupações de apresentação que só fazem sentido a partir do momento em que existe um controlo. O HotPDF mantém o primeiro grupo em THPDFViewerModel, uma classe sem qualquer antepassado de janela VCL, e o segundo grupo em THPDFViewer, que detém uma instância de modelo e reage a ela — mais próximo de um par Modelo-Vista do que de um MVC de três camadas de livro escolar, uma vez que não existe uma classe Controlador separada e o próprio THPDFViewer transforma eventos de teclado e rato em bruto 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 ciclo de mensagens, ou um ambiente de trabalho visível, o que é precisamente o que permite à própria suite de testes do HotPDF conduzir a paginação, a limitação de zoom, os comandos de teclado, e as conversões de coordenadas de ida e volta através do 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 efetivamente detém

O THPDFViewerModel detém tudo o que um visualizador precisa para responder ao que deve estar atualmente no ecrã, sem deter a forma de o desenhar. 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 no ecrã que nunca toca na própria entrada /Rotate da página. Os métodos de navegação — FirstPage, PriorPage, NextPage, LastPage — e os métodos de zoom — ZoomIn, ZoomOut, que percorrem uma tabela fixa de dezanove níveis predefinidos de 5% a 6400% — vivem também aqui, a par de FindAll/FindNext/FindPrevious para pesquisa de texto e AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions para anotações de página persistentes que o chamador queira manter entre renderizações. O modelo detém tanto a saída como a entrada: CreateCurrentPageSnapshot e CreateCurrentPageMetafile exportam exatamente a página atualmente no ecrã, e PrintCurrentView envia essa mesma vista atual — página atual, DPI derivado do zoom atual, rotação atual — para um TPrinter, um trabalho mais restrito, delimitado à vista, do que o pipeline de impressão ao nível do documento abordado em o guia de impressão via TPrinter do HotPDF. Cada mutação relevante também dispara um evento correspondente — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — pelo que um subscritor fica a saber o que mudou sem ter de fazer polling

Como sabe o THPDFViewer quando repintar?

O THPDFViewer sabe quando repintar porque subscreve o modelo em vez de adivinhar. O construtor do THPDFViewer cria um THPDFViewerModel privado, depois liga cada um dos seus eventos de notificação — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — a um manipulador privado correspondente. O trabalho de cada manipulador é pequeno: chamar RefreshDocument, o método que efetivamente rasteriza a página atual através do mesmo motor de renderização de páginas em cache descrito em os pormenores internos de renderização de página para bitmap do HotPDF, depois compõe caixas de realce e resultados de pesquisa por cima e aplica a rotação de vista atual. Propriedades publicadas como PageIndex, Zoom, ZoomMode, e ViewRotation são simples reencaminhadoras — o getter lê FModel.PageIndex, o setter escreve FModel.PageIndex — pelo que, a partir do Object Inspector ou do código, o controlo parece deter o estado diretamente, mesmo que THPDFViewerModel seja o único sítio onde esse estado efetivamente vive. Os chamadores também não estão limitados ao subconjunto reencaminhado: THPDFViewer expõe o próprio modelo através de uma propriedade só de leitura Model: THPDFViewerModel, pelo que código que precise de FindFormFieldAt ou PrefetchCurrentPageSnapshots — nenhum dos quais o controlo reexpõe — pode ultrapassar o invólucro 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: travar tempestades de redesenho

BeginUpdate e EndUpdate existem porque uma única alteração lógica frequentemente toca em várias peças de estado ao mesmo tempo, e repintar depois de cada peça seria dispendioso e visualmente ruidoso. Trocar o documento carregado é o exemplo mais claro: atribuir THPDFViewerModel.Document repõe a rotação de vista, limpa os resultados de pesquisa, limpa as regiões de realce, e salta para a página um, e cada um destes passos normalmente dispara o seu próprio evento de alteração. O 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 saída de volta. O THPDFViewer rastreia essa mesma profundidade do seu lado e salta RefreshDocument para cada evento granular enquanto a contagem estiver acima de zero, depois repinta exatamente uma vez quando o lote se fecha. Os eventos granulares continuam a disparar durante o lote, pelo que um subscritor que só se importe com OnSearchChange continua a ser notificado; é apenas o repintar do próprio controlo que fica reduzido a uma chamada em vez de quatro

Como converte o realce em laço um arrastamento do rato de volta em coordenadas PDF?

O realce em laço converte um arrastamento do rato de volta em coordenadas PDF através 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 duas fases — primeiro a própria entrada /Rotate da página e a sua origem PDF no canto inferior esquerdo, depois a rotação de vista separada e não destrutiva, ViewRotation, e a origem de dispositivo no canto superior esquerdo do visualizador — especificamente para que a direção inversa possa desfazer as duas fases em ordem estritamente inversa e converter corretamente de ida e volta em todas as dezasseis combinações de rotação de página e rotação de vista. O THPDFViewer chama ViewPointToPage quando o utilizador solta o rato depois de arrastar um retângulo no modo de interação vimHighlight, converte os dois pontos de dispositivo num THPDFRectangle no espaço da página, e entrega-o a Model.AddHighlightRegion. Um pormenor que vale a pena conhecer se construir algo semelhante: a captura do rato pertence ao visualizador descendente de TScrollBox, não ao TImage filho onde o bitmap é pintado, porque TControl.MouseCapture é protegido e só o controlo pai pode reivindicá-lo — pelo que um arrastamento que saia dos limites da imagem antes de o botão ser solto continua a resolver-se através do próprio MouseMove/MouseUp sobreposto do visualizador, em vez de ser silenciosamente descartado pelo controlo 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 proporciona para além de uma suite de testes toda verde

O benefício não se limita a testes a passar num trabalho de CI sem sessão de ambiente de trabalho. Porque o THPDFViewer reencaminha para o THPDFViewerModel em vez de duplicar a sua lógica, o HotPDF conseguiu acrescentar um terceiro consumidor — THPDFViewerAction e subclasses concretas como THPDFZoomInAction e THPDFFindNextAction — que ligam navegação, zoom, pesquisa, e rotação a uma TActionList Delphi standard, pelo que um botão de barra de ferramentas ou um item de menu pode conduzir o visualizador de forma declarativa, ativando-se automaticamente consoante um visualizador esteja atualmente resolvido como o alvo da ação. Nada nessa camada teve de saber fosse o que fosse sobre bitmaps ou GDI; chama Viewer.NextPage ou Viewer.Model.FindNext, e a cadeia de eventos existente encarrega-se do repintar. E porque 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 controlo — o mesmo modelo poderia estar por trás de uma superfície de renderização diferente sem tocar numa única linha de lógica de navegação, zoom, ou pesquisa

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

A cache de renderização do THPDFViewerModel ajuda dentro de um documento carregado, mas não altera o que carregar esse documento custa em primeiro lugar. CreatePageSnapshot, CreateCurrentPageSnapshot, e os métodos de pré-carregamento PrefetchPageSnapshots/PrefetchCurrentPageSnapshots passam todos pelo mesmo motor de renderização em cache, indexado por página e DPI, pelo que voltar a paginar para uma página já vista ao mesmo nível de zoom é um acerto de cache em vez de uma nova renderização, e o pré-carregamento de um pequeno raio de páginas vizinhas suaviza o caso comum de um leitor a paginar em frente uma página de cada vez. Contudo, nada disto toca no custo da chamada inicial LoadFromFile, e um visualizador construído para abrir seja o que for que um utilizador arraste até ele acaba, mais cedo ou mais tarde, por encontrar um ficheiro suficientemente grande para tornar essa chamada o verdadeiro estrangulamento. Para a alternativa faseada, baseada em handle, a um carregamento completo — que vale a pena conhecer antes de esse dia chegar — consulte o artigo complementar sobre a Direct File API para PDFs de grande dimensão

As classes Model e View aqui descritas 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ídas para serem conduzidas a partir de um formulário, de uma TActionList, ou de nenhum dos dois