HotPDF separa su visor de PDF en Delphi en dos piezas: THPDFViewerModel, una clase simple que posee el estado de zoom, rotación, búsqueda, resaltado y navegación sin depender de ningún handle de ventana, y THPDFViewer, un control basado en TScrollBox que convierte ese estado en píxeles. Esa división es lo que permite que la lógica del visor se ejecute, y se pruebe, sin crear jamás un formulario
La mayoría de los controles de visor personalizados no se ven así. El nivel de zoom vive en un campo privado del control, la navegación de páginas limita sus bordes dentro del manejador OnClick de un botón, y la única forma de saber si Ctrl+scroll respeta un tope de zoom es ejecutar la aplicación, hacer clic y mirar. Un control construido de esa manera funciona bien hasta que necesita una suite de regresión, o un segundo anfitrión —un diálogo de vista previa de impresión, un carril de miniaturas, un revisor por lotes sin ninguna ventana visible— y el estado que se necesita resulta estar soldado a un TWinControl que insiste en tener un handle real antes de hacer nada
¿Por qué un control de visor de PDF necesita una división MVC?
Un visor de PDF necesita este tipo de división porque su estado y su presentación cambian por razones y a ritmos distintos. El índice de página, el zoom, la rotación de vista, los resultados de búsqueda y las regiones resaltadas son estado de negocio: se pueden calcular, validar y serializar sin un solo píxel en pantalla. Pintar un mapa de bits, capturar el mouse y dibujar un rectángulo de selección tipo marquesina son preocupaciones de presentación que solo tienen sentido una vez que existe un control. HotPDF mantiene el primer grupo en THPDFViewerModel, una clase sin ningún ancestro de ventanas de VCL, y el segundo grupo en THPDFViewer, que posee una instancia del modelo y reacciona a ella —más cercano a un par Modelo-Vista que a un MVC de tres capas de libro de texto, ya que no hay una clase Controlador separada y el propio THPDFViewer convierte los eventos crudos de teclado y mouse en llamadas al modelo. Lo que importa más que la etiqueta es la dirección de la dependencia: nada en THPDFViewerModel requiere un Handle, un bucle de mensajes, o un escritorio visible, que es precisamente lo que permite que la propia suite de pruebas de HotPDF ejecute la paginación, el límite de zoom, los comandos de teclado y los cálculos de ida y vuelta de coordenadas a través de DUnitX sin abrir una ventana
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;
Qué posee realmente THPDFViewerModel
THPDFViewerModel posee todo lo que un visor necesita para responder qué debería estar actualmente en pantalla, sin poseer cómo dibujarlo. PageIndex, PageNumber y PageCount rastrean la posición; Zoom y ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) rastrean la escala; ViewRotation rastrea una rotación en pantalla no destructiva que nunca toca la entrada /Rotate propia de la página. Los métodos de navegación —FirstPage, PriorPage, NextPage, LastPage— y los métodos de zoom —ZoomIn, ZoomOut, que recorren una tabla fija de diecinueve niveles preestablecidos del 5% al 6400%— también viven aquí, junto con FindAll/FindNext/FindPrevious para la búsqueda de texto y AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions para anotaciones de página persistentes que quien llama quiere conservar entre renderizados. El modelo posee tanto la salida como la entrada: CreateCurrentPageSnapshot y CreateCurrentPageMetafile exportan exactamente la página que está actualmente en pantalla, y PrintCurrentView envía esa misma vista actual —página actual, DPI derivado del zoom actual, rotación actual— a un TPrinter, un trabajo más acotado y limitado a la vista que el pipeline de impresión a nivel de documento que cubre el recorrido de impresión con TPrinter de HotPDF. Cada mutación que importa también dispara un evento correspondiente —OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange— para que un suscriptor se entere de qué cambió sin necesidad de sondear
¿Cómo sabe THPDFViewer cuándo repintar?
THPDFViewer sabe cuándo repintar porque se suscribe al modelo en lugar de adivinar. El constructor de THPDFViewer crea un THPDFViewerModel privado, y luego conecta cada uno de sus eventos de notificación —OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange— a un manejador privado correspondiente. El trabajo de cada manejador es pequeño: llamar a RefreshDocument, el método que realmente rasteriza la página actual mediante el mismo renderizador de páginas con caché descrito en los internos de renderizado de página a mapa de bits de HotPDF, y luego compone los cuadros de resaltado y los resultados de búsqueda encima y aplica la rotación de vista actual. Propiedades publicadas como PageIndex, Zoom, ZoomMode y ViewRotation son simples reenviadores —el getter lee FModel.PageIndex, el setter escribe FModel.PageIndex— así que, desde el Inspector de Objetos o desde el código, el control parece contener el estado directamente, aunque THPDFViewerModel es el único lugar donde ese estado realmente vive. Quienes llaman tampoco están limitados al subconjunto reenviado: THPDFViewer expone el propio modelo mediante una propiedad de solo lectura Model: THPDFViewerModel, así que el código que quiere FindFormFieldAt o PrefetchCurrentPageSnapshots —ninguno de los cuales el control vuelve a exponer— puede pasar por alto el envoltorio y llamar al modelo directamente
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 y EndUpdate: frenar las tormentas de redibujado
BeginUpdate y EndUpdate existen porque un solo cambio lógico a menudo toca varias piezas de estado a la vez, y repintar después de cada pieza sería costoso y visualmente ruidoso. Intercambiar el documento cargado es el ejemplo más claro: asignar THPDFViewerModel.Document reinicia la rotación de vista, limpia los resultados de búsqueda, limpia las regiones resaltadas y salta a la página uno, y cada uno de esos pasos normalmente dispara su propio evento de cambio. THPDFViewerModel envuelve esa secuencia en BeginUpdate/EndUpdate, un par con conteo de referencias donde las llamadas anidadas solo disparan OnBeginUpdate en la transición hacia la llamada más externa y OnEndUpdate en la transición de regreso hacia afuera. THPDFViewer rastrea esa misma profundidad de su lado y omite RefreshDocument para cada evento granular mientras el conteo esté por encima de cero, y luego repinta exactamente una vez cuando el lote se cierra. Los eventos granulares se siguen disparando durante el lote, así que un suscriptor que solo le interesa OnSearchChange sigue enterándose; lo único que se colapsa a una sola llamada en lugar de cuatro es el propio repintado del control
¿Cómo mapea el resaltado tipo marquesina un arrastre de mouse de vuelta a coordenadas de PDF?
El resaltado tipo marquesina mapea un arrastre de mouse de vuelta a coordenadas de PDF mediante un par de métodos del modelo construidos exactamente para ese viaje de ida y vuelta: PagePointToView y ViewPointToPage. Ambos toman un índice de página, un DPI y un punto, y ambos resuelven la transformación en dos etapas —primero la entrada /Rotate propia de la página y su origen PDF en la esquina inferior izquierda, luego la ViewRotation separada y no destructiva de la vista y el origen de dispositivo en la esquina superior izquierda del visor— específicamente para que la dirección inversa pueda deshacer las dos etapas en orden estrictamente contrario y funcionar correctamente de ida y vuelta en las dieciséis combinaciones de rotación de página y rotación de vista. THPDFViewer llama a ViewPointToPage cuando el usuario suelta el mouse tras arrastrar un rectángulo en modo de interacción vimHighlight, convierte los dos puntos de dispositivo en un THPDFRectangle en el espacio de página, y se lo entrega a Model.AddHighlightRegion. Un detalle que vale la pena conocer si se construye algo similar: la captura del mouse pertenece al visor descendiente de TScrollBox, no al TImage hijo donde se pinta el mapa de bits, porque TControl.MouseCapture es protegido y solo el control padre puede reclamarlo —así que un arrastre que sale de los límites de la imagen antes de que se suelte el botón todavía se resuelve a través de los propios MouseMove/MouseUp sobrescritos del visor, en lugar de perderse silenciosamente en el control hijo
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;
Qué gana con la división más allá de una suite de pruebas en verde
El beneficio no se limita a que las pruebas pasen en un trabajo de CI sin sesión de escritorio. Como THPDFViewer reenvía a THPDFViewerModel en lugar de duplicar su lógica, HotPDF pudo agregar un tercer consumidor —THPDFViewerAction y subclases concretas como THPDFZoomInAction y THPDFFindNextAction— que conectan la navegación, el zoom, la búsqueda y la rotación a una TActionList estándar de Delphi, de modo que un botón de barra de herramientas o un elemento de menú pueda controlar el visor de forma declarativa, habilitándose automáticamente según si un visor se resuelve actualmente como el objetivo de la acción. Ninguna parte de esa capa tuvo que saber nada sobre mapas de bits o GDI; llama a Viewer.NextPage o Viewer.Model.FindNext, y la cadena de eventos existente se encarga del repintado. Y como nada en THPDFViewerModel hace referencia a TScrollBox, TImage o un handle de ventana, la máquina de estados subyacente tampoco está soldada a ese único control —el mismo modelo podría estar detrás de una superficie de renderizado distinta sin tocar una sola línea de lógica de navegación, zoom o búsqueda
Dónde ayuda la caché de renderizado, y dónde no
La caché de renderizado de THPDFViewerModel ayuda dentro de un documento ya cargado, pero no cambia lo que cuesta cargar ese documento en primer lugar. CreatePageSnapshot, CreateCurrentPageSnapshot, y los métodos de precarga PrefetchPageSnapshots/PrefetchCurrentPageSnapshots pasan todos por el mismo renderizador con caché indexado por página y DPI, así que volver a una página que ya se vio con el mismo nivel de zoom es un acierto de caché en lugar de un re-renderizado, y precargar un pequeño radio de páginas vecinas suaviza el caso común de un lector que avanza página por página. Nada de eso, sin embargo, toca el costo de la llamada inicial a LoadFromFile, y un visor construido para abrir lo que sea que un usuario le arrastre eventualmente se encuentra con un archivo suficientemente grande como para que esa llamada se convierta en el verdadero cuello de botella. Para la alternativa escalonada basada en handles frente a una carga completa —que vale la pena conocer antes de que llegue ese día— vea el artículo complementario sobre la API de archivo directo para PDF grandes
Las clases Model y View descritas aquí son dos piezas más de la misma superficie de documento cargado que se usa en todo el componente HotPDF para Delphi y C++Builder, construido para ser controlado desde un formulario, desde una TActionList, o desde ninguno de los dos