HotPDF separa su visor de PDF para Delphi en dos piezas: THPDFViewerModel, una clase sencilla que gestiona 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. Esta división es lo que permite que la lógica del visor se ejecute, y se someta a pruebas, sin necesidad de crear jamás un formulario
La mayoría de los controles de visor personalizados no tienen este aspecto. El nivel de zoom vive en un campo privado del control, la navegación entre páginas limita sus márgenes dentro del gestor OnClick de un botón, y la única forma de saber si Ctrl+desplazamiento respeta un tope de zoom es ejecutar la aplicación, hacer clic y mirar. Un control construido así funciona bien hasta que necesita una batería de pruebas de regresión, o un segundo anfitrión, un diálogo de vista previa de impresión, una tira de miniaturas, un revisor por lotes sin ninguna ventana visible, y resulta que el estado que necesitáis está soldado a un TWinControl que insiste en tener un handle real antes de hacer nada
¿Por qué necesita un control de visor de PDF una separació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: pueden calcularse, validarse y serializarse sin un solo píxel en pantalla. Pintar un mapa de bits, capturar el ratón y dibujar un rectángulo de selección tipo marquesina son preocupaciones de presentación que solo tienen sentido cuando ya existe un control. HotPDF mantiene el primer grupo en THPDFViewerModel, una clase sin ningún ancestro de ventanas de la 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 libro de texto en tres capas, ya que no existe una clase Controlador independiente y el propio THPDFViewer convierte los eventos de teclado y ratón en bruto 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 batería de pruebas de HotPDF ejercite la paginación, el límite de zoom, los comandos de teclado y las conversiones de coordenadas de ida y vuelta a través de DUnitX sin abrir ninguna 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é gestiona realmente THPDFViewerModel
THPDFViewerModel gestiona todo lo que un visor necesita para responder qué debería mostrarse en pantalla en cada momento, sin gestionar 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 predefinidos del 5 % al 6400 %, también viven aquí, junto con FindAll/FindNext/FindPrevious para la búsqueda de texto y AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions para las anotaciones de página persistentes que quien llama quiere conservar entre renderizados. El modelo gestiona tanto la salida como la entrada: CreateCurrentPageSnapshot y CreateCurrentPageMetafile exportan exactamente la página que hay en ese momento 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 cubierto en el recorrido de HotPDF sobre la impresión con TPrinter. Cada mutación relevante también dispara el evento correspondiente, OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange, de modo que un suscriptor se entera de qué ha cambiado sin necesidad de sondear
¿Cómo sabe THPDFViewer cuándo repintar?
THPDFViewer sabe cuándo repintar porque se suscribe al modelo en lugar de adivinarlo. El constructor de THPDFViewer crea un THPDFViewerModel privado y a continuación conecta cada uno de sus eventos de notificación, OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange, a un gestor privado correspondiente. El trabajo de cada gestor es pequeño: llamar a RefreshDocument, el método que realmente rasteriza la página actual a través del mismo renderizador de página en caché descrito en los detalles internos de renderizado de página a mapa de bits de HotPDF, y luego compone encima los cuadros de resaltado y los resultados de búsqueda y aplica la rotación de vista actual. Las propiedades publicadas como PageIndex, Zoom, ZoomMode y ViewRotation son simples reenviadores, el getter lee FModel.PageIndex, el setter escribe FModel.PageIndex, de modo que, tanto desde el Inspector de Objetos como desde el código, el control parece contener el estado directamente, aunque THPDFViewerModel es el único lugar donde ese estado realmente reside. Quienes llaman tampoco están limitados al subconjunto reenviado: THPDFViewer expone el propio modelo a través de una propiedad de solo lectura Model: THPDFViewerModel, de modo que el código que necesite FindFormFieldAt o PrefetchCurrentPageSnapshots, ninguno de los cuales reexpone el control, puede saltarse el envoltorio y llamar directamente al modelo
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 repintado
BeginUpdate y EndUpdate existen porque un único cambio lógico a menudo afecta a varias piezas de estado a la vez, y repintar tras cada pieza sería costoso y visualmente ruidoso. Sustituir el documento cargado es el ejemplo más claro: asignar THPDFViewerModel.Document reinicia la rotación de vista, borra los resultados de búsqueda, borra 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 recuento de referencias en el que las llamadas anidadas solo disparan OnBeginUpdate en la transición hacia la llamada más externa y OnEndUpdate en la transición de vuelta hacia fuera. THPDFViewer rastrea esa misma profundidad por su lado y omite RefreshDocument para cada evento granular mientras el contador esté por encima de cero, y repinta exactamente una vez cuando el lote se cierra. Los eventos granulares se siguen disparando durante el lote, así que un suscriptor a quien solo le interese OnSearchChange se sigue enterando; lo único que se colapsa en una sola llamada en lugar de cuatro es el propio repintado del control
¿Cómo traduce el resaltado tipo marquesina un arrastre del ratón de vuelta a coordenadas PDF?
El resaltado tipo marquesina traduce un arrastre del ratón de vuelta a coordenadas PDF mediante un par de métodos del modelo construidos precisamente para ese trayecto de ida y vuelta: PagePointToView y ViewPointToPage. Ambos reciben un índice de página, un DPI y un punto, y ambos resuelven la transformación en dos etapas, primero la propia entrada /Rotate de la página y su origen PDF en la esquina inferior izquierda, después la ViewRotation independiente y no destructiva de la vista y el origen de dispositivo en la esquina superior izquierda del visor, precisamente para que la dirección inversa pueda deshacer las dos etapas en orden estrictamente contrario y completar correctamente el trayecto de ida y vuelta en las dieciséis combinaciones posibles de rotación de página y rotación de vista. THPDFViewer llama a ViewPointToPage cuando el usuario suelta el ratón tras arrastrar un rectángulo en modo de interacción vimHighlight, convierte los dos puntos de dispositivo en un THPDFRectangle en espacio de página y se lo entrega a Model.AddHighlightRegion. Un detalle que merece la pena conocer si construís algo parecido: la captura del ratón pertenece al visor descendiente de TScrollBox, no al TImage hijo sobre el que 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 soltar el botón se sigue resolviendo 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 esta división más allá de una batería 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 añadir 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ú puede gobernar el visor de forma declarativa, habilitándose automáticamente en función de si un visor está resuelto en ese momento como destino de la acción. Nada de esa capa tuvo que saber nada sobre mapas de bits o GDI; simplemente llama a Viewer.NextPage o a Viewer.Model.FindNext, y la cadena de eventos existente se encarga del repintado. Y como nada en THPDFViewerModel hace referencia a TScrollBox, TImage ni a 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 en caché indexado por página y DPI, de modo que volver a una página ya vista con el mismo nivel de zoom es un acierto de caché en lugar de un nuevo renderizado, y precargar un pequeño radio de páginas vecinas suaviza el caso habitual de un lector que avanza página a página. Nada de eso afecta, sin embargo, al coste de la llamada inicial a LoadFromFile, y un visor construido para abrir lo que un usuario arrastre hasta él tarde o temprano se encuentra con un archivo lo bastante grande como para que esa llamada se convierta en el verdadero cuello de botella. Para conocer la alternativa escalonada y basada en handles a una carga completa, algo que merece la pena saber antes de que llegue ese día, véase el artículo complementario sobre la Direct File API para PDF de gran tamaño
Las clases Modelo y Vista 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 gobernarse desde un formulario, desde una TActionList, o desde ninguno de los dos