Una sola página A4 renderizada a un zoom de lectura cómodo ocupa del orden de unos pocos megabytes de bitmap de 32 bits. Multiplicad eso por un contrato de 400 páginas y la aritmética deja de ser abstracta: renderizar todas las páginas de antemano supone pedirle a Windows más de un gigabyte de bitmaps que el usuario mirará de a una pantalla a la vez. La aplicación o bien se queda sin espacio de direcciones en una compilación de 32 bits, o bien pasa sus primeros segundos congelada mientras la GPU y el analizador de páginas procesan páginas a las que nadie ha llegado aún. Un lector de desplazamiento continuo tiene que parecer una larga cinta de páginas, pero no puede mantenerlas todas en memoria a la vez.
Esa tensión es el problema central aquí. PDFium VCL lo resuelve dentro de TPdfView, por lo que la mayor parte del trabajo consiste en elegir el modo de visualización correcto y entender lo que hace el componente en vuestro nombre. Las partes que no hace por vosotros, dimensionar las páginas para un flujo de lectura y mantener el desplazamiento rápido y fluido, son donde un poco de código gana su lugar. Si todavía estáis ensamblando el entorno visual circundante (barra de herramientas, miniaturas, cuadro de búsqueda), el tutorial del visor completo cubre ese terreno; aquí el tema es el desplazamiento en sí.
El diseño es un modo de visualización, no un panel de bitmaps
El instinto del trabajo con formularios VCL es recurrir a un cuadro de desplazamiento y apilar controles de imagen dentro de él, uno por página. Resistid esa tentación. Ese diseño os obliga a gestionar el posicionamiento de páginas, la aritmética del desplazamiento y la cuestión de la memoria todos a la vez, y reinventaréis cada uno de ellos de forma deficiente. TPdfView ya modela el documento como una secuencia continua de páginas y expone el diseño a través de su propiedad 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');
Eso es toda la configuración de desplazamiento continuo. dmSingleContinuous dispone las páginas en una única columna vertical con los espacios entre ellas gestionados internamente, y la vista se desplaza por esa columna como una superficie. No hay ningún control por página que conectar ni ningún manejador de desplazamiento que escribir para la navegación ordinaria. Notad la comprobación de Pdf.Active después de la asignación: abrir un documento nunca genera una excepción, por lo que un fichero dañado o protegido con contraseña deja Active en False sin ninguna excepción que capturar, y un visor que omite esta comprobación renderiza un panel en blanco y se culpa a sí mismo.
La misma propiedad lleva los modos de doble página. dmTwoPageContinuous coloca las páginas una al lado de la otra, dos por fila, para la lectura al estilo libro que requieren algunos documentos; dmTwoPageContinuousWithCover hace lo mismo pero deja que la página uno esté sola como portada para que los dobles restantes caigan en el límite natural par-impar. Los tres se desplazan continuamente. Cambiar entre ellos es una única asignación, lo que hace trivial añadir un cuadro combinado de modo de visualización más adelante.
Solo las páginas visibles se rasterizan
La razón por la que esto escala a un fichero de 400 páginas es que la columna es virtual. TPdfView conoce la altura de cada página a partir del árbol de páginas del documento, por lo que puede calcular la extensión total del desplazamiento y la posición de cada página sin rasterizar nada. La rasterización, el paso costoso que convierte el flujo de contenido de una página en píxeles, ocurre solo para las páginas que actualmente intersectan la ventana gráfica, más un pequeño margen para que una página esté lista cuando se desplace hacia la vista. Al desplazarse hacia abajo, las páginas que entran en la ventana gráfica se renderizan y las que la abandonan tienen sus bitmaps liberados. La memoria permanece proporcional a lo que cabe en pantalla, no a la longitud del documento.
Vale la pena interiorizar esto porque cambia la forma en que se razona sobre el coste. Abrir un documento de 400 páginas es barato: analiza la estructura, no el contenido. El coste es por página y se paga de forma diferida, en el momento en que una página se desplaza cerca. Un visor que parece instantáneo al abrirse y fluido al desplazarse no hace menos trabajo en total, sino que distribuye el trabajo a lo largo de la ruta de lectura real del usuario y descarta lo que queda atrás. La consecuencia práctica es que casi nunca se quiere forzar el renderizado de páginas por delante del usuario. Dejad que la vista decida qué es visible.
Dimensionar las páginas al ancho y dejar el zoom en paz
Una columna de lectura quiere páginas dimensionadas al ancho del panel, no fijadas a un zoom absoluto. FitMode hace esto y continúa haciéndolo a medida que se redimensiona la ventana.
PdfView.FitMode := pfmFitWidth; // each page fills the column width; height follows
Con pfmFitWidth el componente recalcula el zoom cada vez que la vista se redimensiona, de modo que la columna siempre llena el ancho disponible y las alturas de página, y por tanto la extensión del desplazamiento, se derivan de eso. Hay una trampa que coge a la gente: asignar Zoom directamente restablece FitMode a pfmNone. Eso es deliberado, porque un zoom manual y un ajuste automático son intenciones contradictorias, pero significa que un PdfView.Zoom := 1.0 extraviado en algún lugar del código desactiva silenciosamente el ajuste al ancho y el siguiente redimensionamiento deja de refluir. Si se ofrecen tanto un control de zoom como un botón de ajuste, tratadlos como un interruptor de modo: establecer uno borra el otro, y vosotros decidís cuál tiene prioridad.
Para controles de zoom absoluto que se lean de forma natural, la vista expone los zooms de ajuste como valores que se pueden aplicar o mostrar: PageWidthZoom[PageNumber] devuelve el zoom que ajustaría esa página al ancho, y el correspondiente PageZoom ajusta la página completa. Leer esos es la forma de poblar un menú «Ajustar al ancho» / «Ajustar página» sin codificar porcentajes mágicos que fallan en páginas apaisadas o de gran tamaño.
Mantener el desplazamiento rápido fluido con el renderizado progresivo
La ruta de renderizado predeterminada dibuja una página hasta su finalización antes de volver. Para una sola página eso está bien. Durante un desplazamiento rápido por un documento denso no lo está: cada página que pasa a toda velocidad inicia una rasterización completa, y si el usuario se desplaza más rápido de lo que las páginas pueden renderizarse, esos renderizados se acumulan y el panel tartamudea porque se está haciendo trabajo para páginas que ya están fuera de pantalla cuando termina. La solución es hacer que un renderizado sea cancelable y abandonarlo en el momento en que el usuario avanza.
RenderPageProgressive renderiza en fragmentos y comprueba un token de cancelación en cada límite de fragmento, de modo que un renderizado en vuelo de una página que acaba de desplazarse fuera puede descartarse en lugar de ejecutarse hasta el final.
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;
Lo que importa es el valor de retorno. prsDone significa que el bitmap está completamente pintado y merece la pena volcarle a pantalla; prsCancelled significa que una posición de desplazamiento más reciente sustituyó esta página, por lo que se descarta el resultado parcial en lugar de mostrarlo; prsFailed es un error genuino en esa página. La cancelación se sondea en los límites de fragmento en lugar de hacerse de forma preventiva, por lo que hay que esperar decenas de milisegundos de latencia entre llamar a Cancel y que el renderizado se detenga realmente. Eso sigue siendo mucho más barato que dejar que un renderizado de página completa obsoleto bloquee la cola. Pasar nil como token renderiza directamente hasta la finalización, que es la opción correcta para un renderizado puntual como una vista previa de impresión donde no hay nada que cancelar.
Cuando se llama a la forma de función de RenderPage, la que devuelve un TBitmap nuevo, recordad que quien llama es propietario del mismo y debe llamar a Free. En un bucle de desplazamiento que asigna un bitmap por página, olvidar esto es una fuga que crece con cada página que el usuario pasa, que es exactamente el fallo de memoria no acotada que el diseño continuo pretendía evitar. Renderizad en un bitmap reutilizado siempre que sea posible.
Con qué quedáis
El lector de desplazamiento continuo es en su mayor parte responsabilidad del componente. Se elige dmSingleContinuous para el diseño, se establece pfmFitWidth para que la columna refluya con la ventana, y se comprueba Pdf.Active para que un fichero malo falle de forma visible. La única pieza que vale la pena escribir uno mismo es el renderizado cancelable, porque un lector se juzga por cómo se comporta cuando alguien arrastra la barra de desplazamiento hasta el final de un documento largo y el panel sigue el ritmo o no. Todo lo demás, la selección de texto entre páginas, el resaltado de búsquedas, un árbol de marcadores, es trabajo de interfaz que se sitúa encima de esta superficie de desplazamiento, no dentro de ella.
Las API TPdfView, DisplayMode y RenderPageProgressive mostradas aquí forman parte del PDFium VCL Component para Delphi y Lazarus.