Artículo técnico

Visor PDFium Delphi: tácticas de caché de render y zoom fluido

El ticket de soporte decía: "el visor se congela dos segundos cada vez que toco el slider de zoom". El documento era una escritura escaneada de 600 páginas, la máquina una laptop 4K, y el código hacía lo que hacen la mayoría de los primeros visores: volver a renderizar la página visible de forma sincrónica en cada evento de cambio del slider. No había nada malo con la velocidad de render; una página se rasterizaba en unos 180 ms. El problema era que un solo arrastre del slider dispara decenas de eventos de cambio, cada uno encolaba un render de calidad completa y ninguno podía cancelarse. Corregir esa clase de problema tiene menos que ver con acelerar renders y más con decidir qué renders no terminar. PDFium Component da a los visores Delphi, C++Builder y Lazarus los primitivos correctos, bitmaps propiedad del llamador, un renderizador progresivo con cancelación y modos de ajuste, y deja la política de caché en manos de ustedes, que es exactamente donde corresponde.

Dónde se van los milisegundos en un cambio de zoom

Sean concretos sobre el costo antes de diseñar la caché. Una página A4 a 96 DPI mide aproximadamente 794 por 1123 píxeles, unos 3.5 MB como bitmap de 32 bits. A 200% de zoom son cuatro veces más; a 400% en una pantalla de alto DPI están asignando y llenando un bitmap de 50 a 60 MB por página, y un visor de desplazamiento continuo mantiene varias páginas vivas a la vez. El costo de rasterización escala con los píxeles de salida, así que duplicar el zoom cuadruplica aproximadamente el tiempo de render junto con la memoria. Dos consecuencias se desprenden directamente: una caché que ignora el nivel de zoom en su clave no sirve, y una caché sin límite agotará un proceso de 32 bits justo en los documentos donde los usuarios hacen más zoom, escaneos densos y planos de gran formato.

Una clave de caché es un contrato con la pantalla

Un bitmap cacheado solo puede reutilizarse cuando coincide con todo lo que influyó en sus píxeles: número de página, zoom efectivo o tamaño de salida en píxeles, rotación, DPI del monitor y las opciones de render activas. Una página renderizada con reAnnotations no es la misma imagen que una sin ellas, y un render en escala de grises mediante reGrayscale es otro artefacto distinto. Dejen cualquiera de estos fuera de la clave y obtendrán los síntomas clásicos: superposiciones de anotación obsoletas después de una acción de revisión, o una página borrosa cuando el usuario arrastra la ventana a otro monitor.

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);   // the cache now owns this bitmap
end;

La ruta con acierto vuelve en microsegundos. La pregunta interesante es qué ocurre con los bitmaps que pierden su lugar, que es una pregunta de propiedad.

Quién libera el bitmap: la fuga que aparece después del almuerzo

La forma de función de RenderPage devuelve un TBitmap que pertenece al llamador. En una exportación de una sola vez eso es obvio; dentro de una caché se convierte en la fuga más común en visores PDF Delphi. En el momento en que el bitmap entra al diccionario, la caché tiene la única referencia, y la expulsión debe llamar a Free; un TDictionary simple no lo hará por ustedes. La fuga nunca aparece en una prueba de diez minutos; aparece después de que un paralegal desplazó escrituras durante tres horas, por eso la expulsión por presión de memoria pertenece al primer diseño y no al backlog. Limiten la caché por bytes estimados, ancho × alto × 4, expulsen con LRU las páginas fuera del viewport y de la ventana de prefetch, y liberen lo expulsado. Las sobrecargas que renderizan dentro de un TBitmap provisto por el llamador o directamente sobre un HDC evitan la propiedad para dibujos transitorios; son un buen encaje para vista previa de impresión, donde cachear no tiene sentido.

Render progresivo y cancelación honesta

Las llamadas sincrónicas bloquean hasta terminar; para el problema del arrastre del slider quieren RenderPageProgressive, que recibe un IPdfCancellationToken y devuelve prsDone, prsCancelled o prsFailed. El detalle de comportamiento crucial: la cancelación se revisa en fronteras de chunk dentro del render, no instantáneamente. Un token señalado a mitad de chunk termina primero su chunk actual, así que en una página compleja esperen latencia de cancelación en decenas de milisegundos y no cero. Diseñen para eso: señalen el token anterior en cuanto llegue un nuevo valor de zoom, pero no asuman que el bitmap viejo deja de cambiar en el instante en que lo piden.

procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandon the previous in-flight render
  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;

Traten prsCancelled como el resultado normal y frecuente durante la interacción, no como un error. Una cola de render que registra cada cancelación como advertencia enterrará la única línea de log que importa. Combinen la ruta progresiva con un placeholder barato: escalar el bitmap cacheado anterior al nuevo zoom se ve suave durante 100 a 200 ms y se siente instantáneo, lo que compra tiempo para que el render de calidad completa termine o sea reemplazado.

Zoom y FitMode: el reinicio silencioso

La propiedad FitMode del visor, pfmFitPage y pfmFitWidth, recalcula el zoom en cada cambio de tamaño. La trampa: asignar Zoom directamente reinicia FitMode a pfmNone. Es un valor predeterminado sensato, porque un usuario que eligió 150% no quiere que redimensionar la ventana lo deshaga, pero muerde a las barras de herramientas que implementan botones de zoom como Zoom := Zoom * 1.25 y luego se preguntan por qué ajustar al ancho dejó de funcionar en silencio. Si su UI ofrece ambos, persistan ustedes mismos la última elección de ajuste del usuario y restáurenla explícitamente cuando vuelva a presionar el botón de ajuste; no esperen que el componente recuerde un modo que la asignación de zoom acaba de descartar.

Un presupuesto de memoria que puedan defender

Los números hacen discutible la política. Supongan que el desplazamiento continuo mantiene la página visible más una página precargada en cada dirección, además de una tira de miniaturas. A 100% en una pantalla de 96 DPI son tres bitmaps de unos 3.5 MB cada uno: nada. A 300% en una pantalla 4K son tres bitmaps de aproximadamente 30 MB cada uno antes de que la caché conserve algo histórico. Un valor predeterminado defendible para un proceso Delphi de 32 bits es un presupuesto de 256 MB para bitmaps con expulsión LRU; para 64 bits, escalen con la RAM física pero mantengan un límite duro, porque el modo de falla no es que su proceso muera, sino que toda la computadora empiece a paginar mientras el visor "funciona". Las miniaturas deben renderizarse una vez a su propio tamaño pequeño de píxeles y mantenerse en un conjunto separado que nunca se expulsa: regenerar una miniatura de 120 píxeles reduciendo un bitmap de página de 60 MB es la forma más cara imaginable de dibujar una estampilla.

Para páginas individuales muy grandes, planos de ingeniería o mapas, renderizar la página completa con zoom alto deja de ser viable por generoso que sea el presupuesto, porque una hoja tamaño E a 400% es una asignación de cientos de megabytes. La vía de escape es el tiling: RenderTile rasteriza solo la región en el desplazamiento de píxel (Left, Top) de una página escalada a PageWidth × PageHeight, así que rendericen solo la región visible más un borde de un tile alrededor, y agreguen esos desplazamientos de tile a la clave de caché junto con el zoom. Mantengan dimensiones de tile fijas para que un cambio de DPI invalide limpiamente en lugar de producir uniones visibles.

El trabajo con filtros de color también multiplica la presión de caché: operaciones posrender como escala de grises o inversión producen bitmaps adicionales de tamaño completo, un costo examinado en filtrado de color para baja visión en visores PDF Delphi. Y si su visor resalta palabras durante texto a voz, la superposición de resaltado invalida la vista en cada palabra hablada; cómo interactúa eso con la velocidad de habla se cubre en resaltado TTS palabra por palabra.

Preguntas frecuentes

¿Por qué mi visor PDF Delphi filtra memoria al hacer zoom?

Casi siempre porque el TBitmap devuelto por RenderPage se cachea o se descarta sin llamar a Free. Ese bitmap pertenece al llamador; una caché que lo almacena debe liberarlo al expulsarlo y al destruir la caché.

¿Por qué cancelar un render no lo detiene de inmediato?

RenderPageProgressive consulta el token de cancelación en fronteras internas de chunk. En páginas complejas, un token señalado todavía completa su chunk actual, así que diseñen la UI para tolerar decenas de milisegundos de latencia de cancelación.

¿Por qué dejó de funcionar ajustar al ancho después de establecer Zoom?

Asignar Zoom reinicia FitMode a pfmNone por diseño. Restablezcan el modo de ajuste explícitamente cuando el usuario lo pida otra vez.

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