Artículo técnico

Visor de PDF con desplazamiento continuo en Delphi con PDFium Component

Una sola página A4 renderizada a un zoom de lectura cómodo ocupa unos pocos megabytes de mapa de bits de 32 bits. Si multiplicamos eso por un contrato de 400 páginas, la aritmética deja de ser abstracta: si renderiza cada página por adelantado, le estará solicitando a Windows más de un gigabyte de mapas de bits que el usuario solo mirará pantalla por pantalla. La aplicación se quedará sin espacio de direcciones en una compilación de 32 bits o pasará sus primeros segundos congelada mientras la GPU y el analizador de páginas procesan páginas a las que nadie se ha desplazado todavía. Un lector con desplazamiento continuo debe sentirse como una única cinta alta de páginas, pero no puede contener todas ellas en memoria al mismo tiempo

Esa tensión representa todo el problema aquí. El Componente PDFium lo resuelve internamente en TPdfView, por lo que la mayor parte del trabajo consiste en elegir el modo de visualización correcto y comprender qué está haciendo el componente en su nombre. Los aspectos que no resuelve por usted (dimensionar las páginas para un flujo de lectura y mantener la capacidad de respuesta en el desplazamiento rápido) es donde un poco de código justifica su uso. Si todavía está ensamblando el diseño exterior (barra de herramientas, miniaturas, cuadro de búsqueda), la guía para la creación de un visor de PDF con funciones completas cubre ese terreno; aquí el tema central es el desplazamiento propiamente dicho

El diseño es un modo de visualización, no un panel de mapas de bits

El instinto al trabajar con formularios VCL es recurrir a una caja de desplazamiento (scroll box) y apilar controles de imagen en su interior, uno por página. Evítelo. Ese diseño le obliga a encargarse del posicionamiento de las páginas, la matemática del desplazamiento y la gestión de la memoria a la vez, y terminará reinventando cada uno de ellos de mala manera. El componente 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;   // una página de ancho, se desplaza verticalmente

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('No se pudo abrir el documento');

Esta es toda la configuración para el desplazamiento continuo. dmSingleContinuous distribuye las páginas en una sola columna vertical gestionando los espacios intermedios internamente, y la vista se desplaza a través de esa columna como una única superficie. No hay necesidad de conectar controles por página ni de escribir manejadores de desplazamiento para la navegación común. Tenga en cuenta la comprobación sobre Pdf.Active tras la asignación: la apertura de un documento nunca genera excepciones, por lo que un archivo dañado o protegido por contraseña deja a Active en False sin ninguna excepción que capturar, y un visor que omita esta comprobación mostrará un panel en blanco y asumirá el fallo

La misma propiedad admite los modos de doble página (spread). dmTwoPageContinuous coloca las páginas una al lado de la otra, dos por fila, para la lectura estilo libro que requieren algunos documentos; dmTwoPageContinuousWithCover realiza lo mismo pero permite que la primera página aparezca sola como portada para que las páginas dobles restantes se ajusten al límite par-impar natural. Los tres modos se desplazan de manera continua. Alternar entre ellos es una simple asignación, lo que facilita enormemente añadir un cuadro combinado para el modo de visualización más adelante

Solo se renderizan las páginas visibles

La razón por la que esto funciona con un archivo 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 únicamente para las páginas que actualmente intersectan con la ventana de visualización (viewport), más un pequeño margen para que la página esté lista en el momento en que entre en vista. A medida que se desplaza hacia abajo, las páginas que entran en la ventana se renderizan y las que salen liberan sus mapas de bits. La memoria consumida se mantiene proporcional a lo que cabe en pantalla, no a la longitud del documento

Conviene interiorizar esto porque cambia la forma de evaluar el rendimiento. Abrir un documento de 400 páginas es rápido: analiza la estructura, no el contenido. El costo computacional es por página y se asume de forma diferida (lazy), en el instante en que el desplazamiento se aproxima a la página. Un visor que se siente instantáneo al abrirse y fluido al desplazarse no realiza 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 querrá forzar la renderización de páginas por delante de la posición del usuario. Deje que la vista decida qué es visible

Ajustar el tamaño de las páginas al ancho, y no modificar el zoom

Una columna de lectura requiere que el tamaño de las páginas se ajuste al ancho del panel, no que esté fijado a un zoom absoluto. FitMode se encarga de esto y lo mantiene a medida que se cambia el tamaño de la ventana

PdfView.FitMode := pfmFitWidth;   // cada página llena el ancho de la columna; la altura se adapta

Con pfmFitWidth, el componente vuelve a calcular el zoom cada vez que se cambia el tamaño de la vista, por lo que la columna siempre llena el ancho disponible y la altura de las páginas (y por tanto la extensión del desplazamiento) se derivan de ello. Hay una trampa en la que suele caer la gente: asignar Zoom directamente restablece FitMode de nuevo a pfmNone. Esto es intencionado, debido a que un zoom manual y un ajuste automático son intenciones contradictorias, pero significa que un PdfView.Zoom := 1.0 accidental en su código desactiva silenciosamente el ajuste al ancho y el siguiente cambio de tamaño dejará de reorganizar la visualización. Si ofrece tanto un control de zoom como un botón de ajuste, trátelos como un cambio de modo: activar uno desactiva el otro, y usted decide cuál tiene prioridad

Para controles de zoom absolutos que se lean de forma natural, la vista expone los valores de zoom de ajuste para que pueda aplicarlos o mostrarlos: PageWidthZoom[PageNumber] devuelve el zoom necesario para ajustar esa página al ancho, y el correspondiente PageZoom ajusta la página completa. Leer estos valores le permitirá poblar un menú de "Ajustar al ancho" / "Ajustar a la página" sin necesidad de codificar a fuego porcentajes mágicos que fallan en páginas apaisadas o de gran tamaño

Mantener la fluidez en el desplazamiento rápido mediante renderizado progresivo

La ruta de renderizado predeterminada dibuja una página por completo antes de retornar. Para una sola página esto está bien. Durante un desplazamiento rápido por un documento denso no lo está: cada página que pasa velozmente inicia una rasterización completa y, si el usuario se desplaza más rápido de lo que las páginas pueden renderizarse, esos renders se acumulan y el panel se ralentiza debido a que se realiza trabajo para páginas que ya están fuera de la pantalla en el momento en que se completa. La solución consiste en hacer que la renderización sea cancelable y abandonarla en el instante en que el usuario continúe desplazándose

El método RenderPageProgressive renderiza en bloques y comprueba un token de cancelación en el límite de cada bloque, por lo que una renderización en curso de una página que acaba de salir de la vista 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
  // Cancelar cualquier renderizado en curso; el antiguo token queda señalado.
  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:      ;                    // el mapa de bits está completo, pintarlo
    prsCancelled: Exit;                // reemplazado, descartar este resultado
    prsFailed:    ShowMessage('Falló el renderizado para la página ' + IntToStr(PageNo));
  end;
end;

Lo que resulta decisivo es el valor de retorno. prsDone significa que el mapa de bits está completamente pintado y listo para mostrarse en pantalla; prsCancelled significa que una posición de desplazamiento más nueva reemplazó a esta página, por lo que descarta el resultado parcial en lugar de mostrarlo; prsFailed representa un error real en esa página. La cancelación se evalúa mediante sondeo en los límites de los bloques y no de forma preventiva, por lo que cabe esperar decenas de milisegundos de latencia entre la llamada a Cancel y la detención efectiva del renderizado. Eso sigue siendo mucho más conveniente que dejar que un renderizado de página completa obsoleto bloquee la cola. Pasar nil como token ejecuta el renderizado directamente hasta el final, que es la opción correcta para una renderización única como una vista previa de impresión donde no hay nada que cancelar

Cuando, en su lugar, llama a la función RenderPage (la que devuelve un nuevo objeto TBitmap), recuerde que el llamador es el propietario y debe liberarlo mediante Free. En un bucle de desplazamiento que asigna un mapa de bits por página, olvidar esto representa una fuga de memoria que crece con cada página que pasa el usuario, que es precisamente el fallo de memoria ilimitada que el diseño continuo debía evitar. Renderice en un mapa de bits reutilizado siempre que sea posible

El resultado final

El lector con desplazamiento continuo es un resultado que proporciona en su mayor parte el componente. Usted elige dmSingleContinuous para el diseño, establece pfmFitWidth para que la columna se adapte a la ventana y comprueba Pdf.Active para que un archivo defectuoso falle de forma evidente. La única parte que vale la pena escribir por uno mismo es el renderizado cancelable, debido a que un lector se evalúa por cómo se comporta cuando alguien arrastra la barra de desplazamiento hasta el final de un documento largo y el panel responde adecuadamente o no. Todo lo que va más allá de eso (selección de texto entre páginas, resaltado de búsquedas, un árbol de marcadores) es trabajo de interfaz que se sitúa sobre esta superficie de desplazamiento y no dentro de ella

Las API de TPdfView, DisplayMode y RenderPageProgressive mostradas aquí forman parte del Componente PDFium para Delphi y Lazarus