Artículo técnico

Caché de render PDFium y zoom fluido en visores Delphi

Mantenga presionado el botón de zoom en un visor PDF ingenuo y observe el gráfico de CPU. Una sola pulsación de un control de zoom con autorrepetición dispara una docena o más de pasos de zoom por segundo, y si cada paso lanza un nuevo render de calidad completa de la página visible, los renders se acumulan más rápido de lo que terminan. La página se rasteriza bien de forma aislada, quizá 180 ms para un escaneo A4, pero ahora está ejecutando una docena de renders de 180 ms contra trabajo que el usuario ya dejó atrás. El visor se congela, un núcleo se clava en 100%, y para cuando la pantalla se pone al día el usuario ya se detuvo en un nivel de zoom de hace cuatro renders. 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 cuanto queda obsoleto

PDFium Component le entrega las piezas para ambas cosas y se mantiene al margen de la política. Obtiene bitmaps cuya propiedad es del llamador, un renderizador progresivo que acepta un token de cancelación, modos de ajuste que recalculan el zoom al redimensionar, y una llamada de mosaico para las páginas demasiado grandes como para rasterizarlas enteras. Lo que deliberadamente no provee es la caché en sí, porque la política de desalojo correcta depende de su viewport, del techo de memoria de su plataforma y de cómo se desplazan sus usuarios. Esa decisión es suya, y las consecuencias de equivocarse son exactamente la congelación y la fuga

Adónde se van los milisegundos y los megabytes

Ponga números al costo antes de diseñar nada. Una página A4 a 96 DPI mide unos 794 por 1123 píxeles, cerca de 3.5 MB como bitmap de 32 bits. Amplíe al 200% y eso se cuadruplica. Al 400% en una pantalla de alta DPI está asignando y llenando un único bitmap de página 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

Dos consecuencias salen directamente de esa aritmética. Una caché cuya clave ignora el nivel de zoom no sirve de nada, porque el gesto mismo que necesita acelerar, el zoom, produce un bitmap nuevo cada vez. Y una caché sin límite dejará a un proceso de 32 bits sin espacio de direcciones justo en los documentos donde la gente amplía con más ganas: escaneos densos de escrituras de propiedad, planos de ingeniería, mapas de gran formato. La caché tiene que estar bien indexada y firmemente acotada, y ninguna de las dos cosas es opcional

Qué pertenece a la clave de caché

Un bitmap en caché solo se puede reutilizar sin riesgo cuando todas las entradas que dieron forma a sus píxeles siguen coincidiendo. Eso significa el número de página, el zoom efectivo (o, lo que es lo mismo, las dimensiones de salida en píxeles), la rotación, la DPI del monitor y las opciones de render vigentes cuando se produjo. Una página renderizada con reAnnotations es una imagen distinta de la misma página sin ellas, y una pasada en escala de grises con reGrayscale es distinta otra vez. Quite cualquiera de estos elementos de la clave y los errores son predecibles: una superposición de anotaciones que persiste después de que un revisor borra el comentario, o una página que se vuelve borrosa en cuanto un usuario arrastra la ventana desde el panel de la laptop a un monitor 4K externo y la DPI cambia por debajo de un bitmap obsoleto

Búsqueda en la caché de render de PDFium en un visor Delphi donde la clave 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 descarta
La clave de caché cubre todas las entradas que dan forma a los píxeles, y el desalojo libera los bitmaps 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);   // ahora la caché es dueña de este bitmap
end;

En un acierto esto devuelve en microsegundos, que es justamente el objetivo. La pregunta más difícil es qué pasa con los bitmaps que caen fuera de la caché, y eso resulta ser una pregunta sobre quién es su dueño

Quién libera el bitmap

La forma de función de RenderPage devuelve un TBitmap cuya propiedad es del llamador. En una exportación de una sola vez esa propiedad es obvia y fácil de honrar. Dentro de una caché se convierte en la fuga más común de los visores PDF en Delphi, porque el diccionario pasa a tener la única referencia a cada bitmap, y un TDictionary simple libera claves y valores por usted solo si son tipos administrados. Un TBitmap no lo es. Desaloje una entrada sin llamar a Free y los píxeles quedan asignados sin que nada los apunte

La razón por la que esto se cuela es el tiempo. Una prueba de humo de diez minutos nunca amplía suficientes páginas distintas como para notarlo; la fuga solo se manifiesta después de que alguien se desplazó y amplió un documento largo durante un par de horas, momento en el cual el proceso retiene cientos de bitmaps de página huérfanos y la máquina empieza a paginar. Por eso el desalojo pertenece a la primera versión de la caché, no a una posterior. Acote la caché por bytes estimados, calculados como ancho por alto por cuatro, desaloje las páginas menos usadas recientemente que queden fuera del viewport y de la ventana de precarga, y libere cada bitmap a medida que lo quita. Para los dibujos genuinamente transitorios, las sobrecargas que renderizan en un TBitmap provisto por el llamador o directamente sobre un HDC le permiten saltarse por completo el baile de la propiedad. La vista previa de impresión es el caso obvio, ya que cada hoja se renderiza una vez y guardarla en caché no aporta nada

Render progresivo y cancelación honesta

Las sobrecargas simples de RenderPage bloquean hasta que la página termina, que es exactamente el comportamiento que no quiere mientras el usuario todavía mueve 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 consulta en los límites de cada bloque dentro del render, así que un token señalizado a mitad de un bloque surte efecto solo cuando ese bloque termina. En una página compleja, la latencia entre pedir y detenerse llega a decenas de milisegundos. Diseñe en torno a esa brecha en lugar de desear que no exista: cancele el token anterior en cuanto llega un nuevo valor de zoom, pero no suponga que el render viejo se detiene en el momento en que se lo pide

Línea de tiempo del render progresivo de PDFium en Delphi donde cada nueva solicitud de zoom cancela el token anterior, la cancelación cae en un límite de bloque, los renders superados devuelven prsCancelled y el intento final devuelve prsDone
Cada nueva solicitud de zoom cancela el token de render anterior, y la cancelación cae en un límite de bloque
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandona el render anterior en vuelo
  FTokenSource := TPdfCancellationTokenSource.New;  // unidad FPdfAsync

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

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // superado por una solicitud nueva: descartar en silencio
    prsFailed:    ShowRenderFailure;
  end;
end;

Durante la interacción, prsCancelled es el resultado normal, no el excepcional. La mayoría de los renders que inicia un gesto de zoom quedarán superados antes de terminar, así que trate la cancelación como algo rutinario y descarte el resultado en silencio. Una cola de render que registre cada cancelación como advertencia enterrará la única falla que de verdad importa bajo miles de líneas de ruido. Para que la pantalla no parezca muerta mientras corre el render real, empareje la ruta progresiva con un sustituto barato: escale el bitmap en caché anterior al nuevo zoom y preséntelo de inmediato. Se ve suave durante cien o doscientos milisegundos, pero se percibe como instantáneo, y le compra al render de calidad completa el tiempo que necesita para terminar o para que el gesto siguiente lo cancele

El modo de ajuste que el zoom apaga en silencio

La propiedad FitMode de un visor, con valor pfmFitPage o pfmFitWidth, recalcula el zoom en cada cambio de tamaño 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 comportamiento predeterminado es correcto: un usuario que escribió 150% a propósito no quiere que el siguiente cambio de tamaño de ventana lo descarte. Pero sorprende a cualquiera que conecte un botón de acercar como Zoom := Zoom * 1.25 y luego no logre entender por qué el ajuste 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 usted mismo la última elección de ajuste del usuario y reasignarla cuando vuelva a presionar el botón de ajuste. El componente no restaurará un modo que una asignación de zoom acaba de borrar, y no se espera que lo haga

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 parta de un escenario concreto. Digamos que el desplazamiento continuo mantiene la página visible más una página precargada arriba y otra abajo, junto a una tira de miniaturas. Al 100% en una pantalla de 96 DPI esos tres bitmaps de tamaño completo suman unos 3.5 MB cada uno, lo que no es nada. Al 300% en una pantalla 4K esos mismos tres bitmaps rondan los 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 acotado defiende la caché y RenderTile atiende los dibujos sobredimensionados
Cada duplicación del zoom cuadruplica la memoria de bitmaps, así que la caché necesita un tope duro y mosaicos para las páginas sobredimensionadas

Un valor predeterminado sensato para un proceso Delphi de 32 bits es un presupuesto de bitmaps de 256 MB con desalojo LRU. En 64 bits puede escalar con la RAM física, pero mantenga de todos modos un techo duro, porque la falla contra la que se protege no es que su proceso se caiga. Es que la máquina entera se ponga a castigar el archivo de paginación mientras su visor técnicamente sigue funcionando y el usuario se pregunta por qué todo lo demás se volvió lento. Un tope duro falla de forma predecible; una caché sin límite falla llevándose el escritorio consigo. Las miniaturas merecen su propio trato: renderice cada una una sola vez en su tamaño de destino pequeño y guárdela en un grupo separado que la lógica LRU nunca toque. Regenerar una miniatura de 120 píxeles reduciendo un bitmap de página completa de 60 MB es la manera más derrochadora posible de producir una estampilla

Algunas páginas sueltas derrotan cualquier presupuesto. Un plano de ingeniería tamaño E o un mapa grande renderizado entero al 400% es una asignación de varios cientos de megabytes, y ninguna política de desalojo vuelve eso aceptable. La respuesta ahí es dejar de renderizar páginas enteras. RenderTile rasteriza solo la región en el desplazamiento en píxeles (Left, Top) dentro de una página escalada nocionalmente a PageWidth por PageHeight, así que usted renderiza solo el rectángulo visible más un margen de un mosaico alrededor para un desplazamiento suave, y pliega los desplazamientos de mosaico dentro de la clave de caché junto al zoom. Mantenga fijas las dimensiones del mosaico en todo el documento. Los mosaicos fijos hacen que un cambio de DPI invalide toda la cuadrícula de forma limpia, mientras que los variables lo dejan persiguiendo costuras visibles entre regiones renderizadas a escalas ligeramente distintas

Dos funciones vecinas se suman calladamente a todo esto. Las pasadas de filtro de color, como la escala de grises o la inversión, se ejecutan después del render y producen un segundo bitmap de tamaño completo cada vez, lo que duplica la huella por página de cualquier vista que las use; ese costo es el tema de el filtrado de color para baja visión en visores PDF Delphi. Y un visor que resalta palabras durante la lectura en voz alta invalida la vista renderizada en cada palabra pronunciada, así que la interacción entre los redibujados del resaltado y la velocidad del habla importa más de lo que parece a primera vista, como se cubre en el resaltado TTS palabra por palabra

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