Artículo técnico

Visor PDFium en Delphi: caché de render y zoom fluido

Mantenga pulsado el botón de zoom en un visor PDF naïve y observe la gráfica de CPU. Una sola pulsación de un control de zoom de auto-repetición dispara una docena o más pasos de zoom por segundo, y si cada paso arranca un re-render de calidad completa de la página visible, los renderizados se acumulan más rápido de lo que se completan. La página se rasteriza bien de forma aislada, quizás 180 ms para un escaneo A4, pero ahora está corriendo una docena de renderizados de 180 ms contra trabajo que el usuario ya ha dejado atrás. El visor se bloquea, un núcleo se clava al 100 %, y para cuando la pantalla se pone al día el usuario se ha detenido en un nivel de zoom de hace cuatro renderizados. La cura no es un rasterizador más rápido. Es una caché que devuelve páginas terminadas al instante y un bucle de render dispuesto a abandonar el trabajo en el momento en que se vuelve obsoleto

PDFium Component le entrega las partes para ambos y se mantiene al margen de la política. Obtiene mapas de bits propiedad del llamador, un renderizador progresivo que toma un token de cancelación, modos de ajuste que recalculan el zoom al redimensionar, y una llamada de teselado para páginas demasiado grandes para rasterizar enteras. Lo que deliberadamente no proporciona es la caché misma, porque la política de expulsión correcta depende de su ventana gráfica, del límite de memoria de su plataforma y de cómo se desplazan sus usuarios. Esa decisión es suya de hacer bien, y las consecuencias de equivocarse son exactamente el bloqueo y la fuga

A dónde van los milisegundos y los megabytes

Ponga números al costo antes de diseñar nada. Una página A4 a 96 DPI es aproximadamente 794 por 1123 píxeles, unos 3,5 MB como un mapa de bits de 32 bits. Haga zoom al 200 % y eso cuadruplica. Al 400 % en una pantalla de alto DPI está reservando y rellenando un mapa de bits de página única de 50 a 60 MB, y un visor de desplazamiento continuo mantiene varias páginas vivas a la vez. El costo de rasterización sigue a los píxeles de salida, así que cada duplicación del zoom cuadruplica aproximadamente tanto el tiempo de render como la memoria juntos

Dos consecuencias caen directamente de esa aritmética. Una caché cuya clave ignora el nivel de zoom no vale nada, porque precisamente el gesto que necesita acelerar — hacer zoom — produce un mapa de bits nuevo cada vez. Y una caché sin límite llevará a un proceso de 32 bits a quedarse sin espacio de direcciones precisamente en los documentos donde la gente más hace zoom: escaneos densos de títulos de propiedad, planos de ingeniería, mapas de gran formato. La caché tiene que estar correctamente codificada por clave y firmemente limitada, y ninguna de las dos es opcional

Qué pertenece a la clave de caché

Un mapa de bits cacheado es seguro de reutilizar solo cuando cada entrada que moldeó sus píxeles sigue coincidiendo. Eso significa el número de página, el zoom efectivo (o equivalentemente las dimensiones de píxeles de salida), la rotación, el DPI del monitor, y las opciones de render que estaban vigentes cuando se produjo. Una página renderizada con reAnnotations es una imagen distinta de la misma página sin ellas, y un pase en escala de grises a través de reGrayscale es distinto de nuevo. Quite cualquiera de estos de la clave y los errores son predecibles: un overlay de anotación que permanece después de que un revisor borra el comentario, o una página que se vuelve borrosa en el instante en que un usuario arrastra la ventana de un panel de portátil a un monitor 4K externo y el DPI cambia bajo un mapa de bits obsoleto

Búsqueda en la caché de render de PDFium en un visor Delphi donde la clave de caché combina página, zoom, rotación, DPI del monitor y opciones de render, un acierto devuelve el bitmap en microsegundos, y el desalojo libera cada bitmap que suelta
La clave de caché cubre toda entrada que da forma a los píxeles, y el desalojo libera los mapas de bits que descarta
function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // la caché pasa a ser dueña de este bitmap
end;

En un acierto esto devuelve en microsegundos, que es todo el punto. La pregunta más difícil es qué les pasa a los mapas de bits que caen de la caché, y eso resulta ser una pregunta sobre quién los posee

Quién libera el mapa de bits

La forma funcional de RenderPage devuelve un TBitmap que posee el llamador. En una exportación única esa propiedad es obvia y fácil de honrar. Dentro de una caché se convierte en la fuga más común en visores PDF de Delphi, porque el diccionario ahora retiene la única referencia a cada mapa de bits, y un TDictionary simple libera claves y valores por usted solo si son tipos gestionados. Un TBitmap no lo es. Expulse una entrada sin llamar a Free y los píxeles siguen reservados sin nada que apunte a ellos

La razón por la que esto se cuela es el momento. Una prueba de humo de diez minutos nunca hace zoom en suficientes páginas distintas para notarlo; la fuga solo se muestra después de que alguien se haya desplazado y hecho zoom en un documento largo durante un par de horas, momento en el que el proceso retiene cientos de mapas de bits de página huérfanos y la máquina empieza a paginar. Por eso la expulsión pertenece a la primera versión de la caché, no a una posterior. Limite la caché por bytes estimados, calculados como anchura por altura por cuatro, expulse las páginas usadas-menos-recientemente que se sientan fuera de la ventana gráfica y la ventana de prefetch, y libere cada mapa de bits al eliminarlo. Para dibujos que son genuinamente transitorios, las sobrecargas que renderizan en un TBitmap proporcionado por el llamador o directamente en un HDC le permiten saltarse la danza de propiedad por completo. La vista previa de impresión es el caso obvio, ya que renderiza cada hoja una vez y cachearla no aporta nada

Render progresivo y cancelación honesta

Las sobrecargas simples de RenderPage se bloquean hasta que la página termina, que es exactamente el comportamiento que no quiere mientras el usuario sigue moviendo el control de zoom. Para eso recurre a RenderPageProgressive. Toma un IPdfCancellationToken y devuelve uno de prsDone, prsCancelled o prsFailed. El detalle de comportamiento que sorprende a la gente es que la cancelación no es instantánea. El token se sondea en los límites de fragmento dentro del render, así que un token que señaliza en medio de un fragmento solo surte efecto cuando ese fragmento termina. En una página compleja la latencia entre pedir y parar corre a decenas de milisegundos. Diseñe en torno a ese hueco en lugar de desear que desaparezca: cancele el token anterior en el instante en que llega un nuevo valor de zoom, pero no asuma que el render antiguo se detiene en el momento en que lo pide

Cronología del renderizado progresivo de PDFium en Delphi donde cada nueva petición de zoom cancela el token anterior, la cancelación aterriza en un límite de chunk, los renders sustituidos devuelven prsCancelled, y el intento final devuelve prsDone
Cada nueva petición de zoom cancela el token de render anterior, y la cancelación aterriza en una frontera de fragmento
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandona el render anterior en curso
  FTokenSource := TPdfCancellationTokenSource.New;  // FPdfAsync unit

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // superseded by a newer request: drop silently
    prsFailed:    ShowRenderFailure;
  end;
end;

Durante la interacción, prsCancelled es el resultado normal, no el excepcional. La mayoría de los renderizados que un gesto de zoom inicia serán reemplazados antes de terminar, así que trate la cancelación como rutina y descarte el resultado silenciosamente. Una cola de render que registra cada cancelación como advertencia enterrará el único fallo que realmente importa bajo miles de líneas de ruido. Para evitar que la pantalla parezca muerta mientras corre el render real, empareje la ruta progresiva con un sustituto barato: escale el mapa de bits cacheado anterior al nuevo zoom y preséntelo de inmediato. Se ve suave durante cien o doscientos milisegundos, pero se lee como instantáneo, y le compra al render de calidad completa el tiempo que necesita para terminar o ser cancelado por el siguiente gesto

El modo de ajuste que el zoom desactiva silenciosamente

La propiedad FitMode de un visor, fijada a pfmFitPage o pfmFitWidth, recalcula el zoom en cada redimensionado para que la página siga encajando a medida que cambia la ventana. La trampa es que asignar Zoom directamente restablece FitMode a pfmNone. Como predeterminado eso es correcto: un usuario que escribió deliberadamente 150 % no quiere que el próximo redimensionado de ventana lo descarte. Pero sorprende a cualquiera que cablee un botón de aumentar zoom como Zoom := Zoom * 1.25 y luego no logra entender por qué ajustar-al-ancho dejó de responder tras el primer clic. Si su barra de herramientas ofrece tanto zoom explícito como modos de ajuste, tiene que recordar la última elección de ajuste del usuario usted mismo y reasignarla cuando vuelvan a pulsar el botón de ajuste. El componente no restaurará un modo que una asignación de zoom acaba de borrar, y no se supone que deba hacerlo

Un presupuesto de memoria que puede defender

Un presupuesto que puede escribir es un presupuesto que puede defender en una revisión de código, así que empiece desde un escenario concreto. Digamos que el desplazamiento continuo mantiene la página visible más una página prefetched por encima y por debajo, junto con una franja de miniaturas. Al 100 % en una pantalla de 96 DPI esos tres mapas de bits a tamaño completo suman unos 3,5 MB cada uno, que no es nada. Al 300 % en una pantalla 4K los mismos tres mapas de bits son aproximadamente 30 MB cada uno, y eso antes de que la caché haya retenido una sola página histórica. El crecimiento está en el gesto, no en el documento

Aritmética de memoria de bitmaps de PDFium para un visor Delphi donde cada duplicación del zoom cuadruplica la memoria de página, el desplazamiento continuo mantiene tres páginas vivas, un presupuesto LRU limitado defiende la caché, y RenderTile maneja dibujos sobredimensionados
Cada duplicación del zoom cuadruplica la memoria de mapas de bits, de modo que la caché necesita un tope duro y teselas para páginas sobredimensionadas

Un predeterminado sensato para un proceso Delphi de 32 bits es un presupuesto de mapa de bits de 256 MB bajo expulsión LRU. En 64 bits puede escalar con la RAM física, pero mantenga un límite duro en cualquier caso, porque el fallo que está guardando no es que su proceso se cuelgue. Es toda la máquina haciendo thrashing de su archivo de paginación mientras su visor técnicamente sigue en marcha y el usuario se pregunta por qué todo lo demás se ralentizó. Un límite duro falla de forma predecible; una caché sin límite falla llevándose el escritorio consigo. Las miniaturas merecen su propio tratamiento: renderice cada una una vez a su pequeño tamaño objetivo y manténgala en un pool separado que la lógica LRU nunca toque. Regenerar una miniatura de 120 píxeles reescalando un mapa de bits de página completa de 60 MB es la forma más desperdiciadora posible de producir un sello de correos

Algunas páginas individuales derrotan cualquier presupuesto. Un plano de ingeniería tamaño E o un mapa grande renderizado entero al 400 % es una reserva de varios cientos de megabytes, y ninguna política de expulsión lo hace aceptable. La respuesta ahí es dejar de renderizar páginas enteras. RenderTile rasteriza solo la región en el desplazamiento de píxel (Left, Top) dentro de una página nocionalmente escalada a PageWidth por PageHeight, así que renderiza solo el rectángulo visible más un margen de un tile a su alrededor para un paneo fluido, y doble los desplazamientos de tile en la clave de caché junto al zoom. Mantenga las dimensiones de tile fijas a lo largo del documento. Los tiles fijos significan que un cambio de DPI invalida toda la cuadrícula limpiamente, mientras que los tiles variables le dejan persiguiendo costuras visibles entre regiones renderizadas a escalas ligeramente distintas

Dos características adyacentes añaden silenciosamente a todo esto. Los pases de filtro de color como escala de grises o inversión corren después del render y producen un segundo mapa de bits a tamaño completo cada vez, doblando la huella por página de cualquier vista que los use; ese costo es el tema de filtrado de color de baja visión para visores PDF Delphi. Y un visor que resalta palabras durante la conversión de texto a voz invalida la vista renderizada en cada palabra hablada, así que la interacción entre los repintados de resaltado y la velocidad de habla importa más de lo que parece al principio, como se cubre en resaltado TTS palabra por palabra

Las sobrecargas de renderizado, los códigos de estado progresivos y el propio componente visor están documentados en la página del producto de PDFium Component