La mayoría de las páginas PDF se rasterizan en unos pocos milisegundos y nunca piensas en ello. Entonces un usuario abre un plano de ingeniería A1, una página repleta de decenas de miles de trazos vectoriales, o un cartel atestado de grupos de transparencia y máscaras suaves, y la única llamada que la pinta tarda dos o tres segundos. Si esa llamada se ejecuta en el hilo de la interfaz, la ventana deja de repintarse, la barra de título se vuelve gris y el sistema operativo ofrece cerrar la aplicación. El trabajo es legítimo. La página realmente necesita ese tiempo. El defecto es que el renderizado es una única llamada bloqueante indivisible, sin forma de tomar aire y sin forma de detenerse
Este artículo trata exactamente uno de esos dos problemas: cancelar un largo renderizado de una sola página sin congelar la interfaz. El usuario hizo clic en la página siguiente, o aplicó zoom, o cerró el documento, y el renderizado en curso es ahora trabajo desperdiciado que debería terminar en la primera oportunidad en lugar de ejecutarse hasta el final. Suavizar el desplazamiento y el zoom almacenando en caché lo que ya se rasterizó es un asunto aparte con su propio diseño, trateno en el artículo complementario enlazado al final. Aquí la única pregunta es cómo hacer que un renderizado progresivo responda a una solicitud de cancelación de forma rápida y limpia
La API de renderizado progresivo que PDFium ya incluye
PDFium anticipó la mitad del problema relativa a la congelación. Junto a la llamada única FPDF_RenderPageBitmap, expone una variante progresiva que divide una página en fragmentos de trabajo. Llamas una vez a FPDF_RenderPageBitmap_Start para configurar el renderizado contra un mapa de bits de destino, y luego llamas repetidamente a FPDF_RenderPage_Continue. Cada Continue rasteriza una porción acotada y devuelve un estado. FPDF_RENDER_TOBECONTINUED significa que queda más por hacer, FPDF_RENDER_DONE significa que la página está terminada, y FPDF_RENDER_FAILED significa que se detuvo por un error. Cuando el bucle termina, llamas a FPDF_RenderPage_Close para liberar el estado progresivo por página. Como el control vuelve a tu código entre porciones, puedes bombear mensajes, actualizar un indicador de progreso o comprobar si el trabajo sigue siendo deseado
El mecanismo que PDFium proporciona para decidir cuándo ceder es una estructura de callback llamada IFSDK_PAUSE. Se la entregas a Start y a cada Continue. Después de cada fragmento, PDFium llama a su puntero de función NeedToPauseNow, y si este devuelve un valor distinto de cero, el Continue actual se detiene de forma anticipada y devuelve el control con FPDF_RENDER_TOBECONTINUED. La estructura también lleva un campo version, que debe establecerse en 1, y un puntero user de forma libre que PDFium nunca toca y transmite intacto. Ese puntero intacto es todo el eje del diseño que sigue
Reutilizar la pausa como cancelación
La intención original de NeedToPauseNow es la segmentación temporal. Devuelve distinto de cero cuando se haya agotado tu presupuesto de fotograma, devuelve cero para seguir renderizando, y PDFium se pausa para que puedas hacer otra cosa antes de reanudar el mismo renderizado. El PDFium Component reutiliza esa misma señal para un verbo diferente. En lugar de responder «¿debería pausar y dejarte reanudar?», el callback responde «¿se ha cancelado este trabajo?». Ambos se corresponden limpiamente por lo que hace el bucle cuando ve la bandera. Una pausa genuina espera un Continue posterior; una cancelación no. Una vez que el bucle que llama observa que el token está cancelado, cierra el contexto de renderizado y nunca vuelve a llamar a Continue, de modo que el mismo retorno distinto de cero que PDFium lee como «detén este fragmento» se convierte, en efecto, en «detente para siempre»
La cancelación se expresa a través de una interfaz, IPdfCancellationToken, cuya propiedad IsCancelled pasa de false a true cuando alguna otra parte del programa pide que el renderizado se detenga. El puente entre esa interfaz Pascal y el callback C de PDFium es un único puntero. La referencia de interfaz del token se escribe en IFSDK_PAUSE.user, y un callback cdecl estático la lee de vuelta y la consulta. Este es el problema clásico de dejar que una biblioteca C llame de vuelta a Pascal: el callback tiene que ser una función simple con convención de llamada C, no un método, porque PDFium almacena e invoca un puntero de función desnudo que no sabe nada de objetos Pascal ni de Self
type
TPdfProgressivePause = record
Pause: IFSDK_PAUSE; // PDFium reads this; .user holds the token
Token: IPdfCancellationToken; // strong ref keeps the token alive
end;
function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
Token: IPdfCancellationToken;
begin
Result := 0;
if (pThis = nil) or (pThis^.user = nil) then
Exit;
Token := IPdfCancellationToken(pThis^.user);
if Token.IsCancelled then
Result := 1; // non-zero: PDFium stops this chunk
end;
El callback recupera el token convirtiendo pThis^.user de vuelta al tipo de interfaz y lee IsCancelled. Nada en él reserva, bloquea o se bloquea, lo cual importa porque PDFium lo llama en el hilo de renderizado después de cada fragmento y cualquier trabajo hecho aquí se suma al coste del renderizado en sí. La protección contra una estructura nil o un campo user nil significa que la misma función es segura de instalar incluso en un renderizado al que nunca se le dio un token real
Mantener el token vivo a lo largo del bucle
Convertir un puntero de interfaz a través de un Pointer en bruto y de vuelta es donde nacen los errores de tiempo de vida. Una IInterface en Delphi tiene conteo de referencias, y el contador solo se mueve cuando el compilador puede ver que se asigna una variable con tipo de interfaz. Almacenar el token únicamente como un puntero desnudo dentro de IFSDK_PAUSE.user lo ocultaría por completo al contador de referencias. Si la única otra referencia a ese token saliera de ámbito mientras el bucle Continue todavía se estaba ejecutando, el objeto se liberaría por debajo del callback, y el siguiente fragmento desreferenciaría un puntero colgante
Por eso el descriptor es un registro que contiene dos cosas, no una. El campo Pause es la estructura que PDFium lee. El campo Token es una referencia real con tipo de interfaz que el compilador cuenta, y existe por ninguna otra razón que la de fijar el token en memoria durante todo el tiempo que viva el registro. El registro es una variable local en la pila de la rutina de renderizado, así que permanece válido durante toda la duración del bucle y se desmantela solo cuando la rutina sale. El puntero desnudo en user y la referencia contada en Token nombran el mismo objeto; uno es lo que PDFium puede leer, el otro es lo que evita que ese objeto sea recolectado
var
Pause: TPdfProgressivePause;
EffectiveToken: IPdfCancellationToken;
begin
// ... choose EffectiveToken ...
// Strong ref first, then publish the same object to PDFium via .user.
Pause.Token := EffectiveToken;
Pause.Pause.version := 1;
Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
Pause.Pause.user := Pointer(EffectiveToken);
Cerrar el contexto de renderizado sin importar cómo termine el bucle
Cada llamada a FPDF_RenderPageBitmap_Start reserva estado progresivo que PDFium asocia con la página, y ese estado se libera únicamente con FPDF_RenderPage_Close. Hay tres formas de salir del bucle de control. La página termina y el último estado es FPDF_RENDER_DONE. El token se dispara y el bucle sale de forma anticipada informando de la cancelación. Algo falla y el estado es FPDF_RENDER_FAILED. Las tres deben llamar a Close, y la ruta de cancelación es la más fácil de equivocar, porque la forma natural de «ver la cancelación, salir» tiende a saltarse la limpieza de camino a la salida. Dejar Close sin alcanzar filtra el estado por página, y un visor que permite al usuario cancelar renderizado tras renderizado acumularía esa fuga en cada página abortada
La forma robusta pone el bucle y la clasificación del resultado dentro de un try y FPDF_RenderPage_Close en el finally correspondiente. El mapa de bits de destino se destruye en el mismo bloque. La cancelación puede salir del bucle a través de un Exit anticipado y el finally sigue ejecutándose, así que hay exactamente un lugar que libera el estado progresivo y no se puede eludir
Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
while Status = FPDF_RENDER_TOBECONTINUED do
begin
if EffectiveToken.IsCancelled then
begin
Result := prsCancelled;
Exit;
end;
Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
end;
if EffectiveToken.IsCancelled then
Result := prsCancelled
else if Status = FPDF_RENDER_DONE then
Result := prsDone
else
Result := prsFailed;
finally
// Frees the progressive state Start allocated; mandatory on every path.
FPDF_RenderPage_Close(FPage);
FPDFBitmap_Destroy(PdfBmp);
end;
El bucle comprueba el token antes de cada Continue además de apoyarse en el callback dentro de él. El callback acorta el fragmento actual; la comprobación del bucle impide que el siguiente empiece. Juntos acotan cuánto tarda una cancelación en surtir efecto a aproximadamente la duración de un fragmento
Tres desenlaces, y qué contiene el mapa de bits tras una cancelación
El punto de entrada público es TPdf.RenderPageProgressive, y devuelve un TPdfProgressiveStatus que es uno de prsDone, prsCancelled o prsFailed. Los valores reflejan las constantes FPDF_RENDER_* de PDFium en lenguaje idiomático Pascal, pero integran el caso de cancelación como un resultado de primera clase en lugar de un error
El punto que sorprende a la gente es qué contiene el mapa de bits de destino tras prsCancelled. No está en blanco. PDFium renderiza progresivamente en el mismo mapa de bits fragmento tras fragmento, así que cuando una cancelación detiene el bucle, el mapa de bits contiene lo que se haya pintado hasta ese momento, que es una imagen parcial: algunas bandas terminadas, el resto todavía mostrando el color de relleno. Si ese resultado parcial es útil depende del llamador. Un visor que está a punto de descartar el mapa de bits porque el usuario navegó a otra parte puede simplemente ignorarlo. Un visor que quiere mostrar una vista previa de bajo coste puede conservarlo. Lo que no debes hacer es asumir que prsCancelled implica un mapa de bits vacío o indefinido; implica una instantánea veraz de un renderizado inacabado
var
Bmp: TBitmap;
Token: IPdfCancellationToken;
Status: TPdfProgressiveStatus;
begin
Bmp := TBitmap.Create;
try
// Token starts un-cancelled; flip Token.IsCancelled from elsewhere
// (a UI action, a navigation event) to abort the render in flight.
Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
case Status of
prsDone: Image1.Picture.Assign(Bmp); // fully rendered
prsCancelled: ; // partial bitmap, usually discarded
prsFailed: ShowMessage('Render failed');
end;
finally
Bmp.Free;
end;
end;
El token nil y una ruta de callback sin ramificaciones
La cancelación es opcional. Un llamador que solo quiere renderizado progresivo por el beneficio del bombeo de mensajes, sin intención de abortar, debería poder pasar nil como token. La forma ingenua de soportar eso es esparcir comprobaciones de «si se proporcionó un token» por el callback y el bucle, lo que significa una ramificación en cada fragmento y un callback que tiene que manejar tanto un token real como su ausencia
La implementación lo evita sustituyendo un singleton cuando el llamador no pasa nada. Un token nil se intercambia por PdfNoCancellationToken, una interfaz cuyo IsCancelled es siempre false. A partir de ese punto, el callback y el bucle tienen un token que consultar en todos los casos, así que ninguno necesita una comprobación de nil ni una ruta especial. El token que nunca cancela simplemente siempre responde false, el callback siempre devuelve cero, y el renderizado se ejecuta hasta el final exactamente como lo haría uno no cancelable. El comportamiento opcional se modela como un token que nunca se dispara en lugar de como la ausencia de un token, lo que mantiene uniforme la ruta crítica
// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
EffectiveToken := AToken
else
EffectiveToken := PdfNoCancellationToken;
La forma que emerge es pequeña y vale la pena reiterarla, porque es la parte reutilizable. Una biblioteca C que admite un callback te da exactamente un canal para pasar estado a ese callback, el puntero user opaco. Pon una referencia de interfaz Pascal contada detrás de ese puntero, mantén una segunda referencia real viva junto a la estructura para que el objeto no pueda recolectarse a mitad de la llamada, y lee la interfaz de vuelta dentro de una función cdecl estática. Envuelve todo el bucle de control en un try y libera el contexto nativo en el finally. La misma plantilla se traslada a cualquier operación de PDFium progresiva o dirigida por callbacks donde el código Pascal tenga que mantener el control del tiempo de vida mientras C retiene un puntero
La cancelación es solo una mitad de un visor con buena capacidad de respuesta. La otra mitad consiste en no volver a renderizar páginas que ya dibujaste, y mantener el zoom y el desplazamiento suaves sirviendo mapas de bits en caché, lo cual se trata en nuestro artículo sobre la caché de renderizado y el rendimiento del zoom. Para saber cómo encaja el renderizado cancelable en un visor completo junto con la navegación, la selección y la búsqueda, vea la creación de un visor de PDF rico en funciones con el componente PDFium Component. El renderizado progresivo descrito aquí se distribuye como parte del PDFium Component para Delphi y Lazarus, junto con las API de carga, renderizado y formularios tratenas en otros lugares de este blog