Technical Article

Renderizado progresivo de PDF cancelable en Delphi (PDFium)

La mayoría de las páginas PDF se rasterizan en unos pocos milisegundos y usted nunca piensa 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 póster atestado de grupos de transparencia y máscaras suaves, y la única llamada que lo pinta tarda dos o tres segundos. Si esa llamada se ejecuta en el hilo de la interfaz de usuario, la ventana deja de repintarse, la barra de título se vuelve gris y el sistema operativo ofrece matar la aplicación. El trabajo es legítimo. La página realmente necesita ese tiempo. El defecto es que el renderizado es una llamada de bloqueo indivisible sin forma de salir a tomar aire y sin forma de detenerse

Este artículo trata sobre exactamente uno de esos dos problemas: cancelar un renderizado largo de una sola página sin congelar la interfaz de usuario. El usuario hizo clic en la página siguiente, hizo zoom o cerró el documento, y el renderizado en vuelo es ahora trabajo desperdiciado que debería terminar en la primera oportunidad en lugar de ejecutarse hasta su finalización. Suavizar el desplazamiento y el zoom almacenando en caché lo que ya se rasterizó es un asunto aparte con su propio diseño, que se trata en el artículo complementario enlazado al final. Aquí la única pregunta es cómo hacer que un renderizado progresivo responda a una petición de cancelación de forma rápida y limpia

La API de renderizado progresivo que PDFium ya incluye

PDFium previó la mitad del problema referente a la congelación. Junto al FPDF_RenderPageBitmap de un solo disparo, expone una variante progresiva que divide una página en trozos de trabajo. Se llama a FPDF_RenderPageBitmap_Start una vez para configurar el renderizado contra un mapa de bits de destino, y luego se llama a FPDF_RenderPage_Continue repetidamente. Cada Continue rasteriza una porción delimitada y devuelve un estado. FPDF_RENDER_TOBECONTINUED significa que hay 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 se llama a FPDF_RenderPage_Close para liberar el estado progresivo por página. Debido a que el control regresa a su código entre porciones, usted puede 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 devolución de llamada llamada IFSDK_PAUSE. Se la entrega a Start y a cada Continue. Después de cada trozo, PDFium llama a su puntero a función NeedToPauseNow, y si eso devuelve un valor distinto de cero, el Continue actual se detiene antes de tiempo y devuelve el control con FPDF_RENDER_TOBECONTINUED. La estructura también lleva un campo version, que debe establecerse en 1, y un puntero de forma libre user que PDFium nunca toca y pasa intacto. Ese puntero intacto es toda la bisagra del diseño que sigue

Reutilización de la pausa como cancelación

La intención original de NeedToPauseNow es la división del tiempo. Devuelva un valor distinto de cero cuando su presupuesto de fotogramas se agote, devuelva cero para seguir renderizando, y PDFium hace una pausa para que usted pueda 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 a "¿debería hacer una pausa y dejar que reanude?", la devolución de llamada responde a "¿se ha cancelado este trabajo?". Ambas se corresponden limpiamente debido a 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 llamador observa que el token está cancelado, cierra el contexto de renderizado y nunca vuelve a llamar a Continue, por lo que el mismo retorno distinto de cero que PDFium lee como "detener este trozo" se convierte, en efecto, en "detenerse para siempre"

La cancelación se expresa a través de una interfaz, IPdfCancellationToken, cuya propiedad IsCancelled cambia de falso a verdadero cuando alguna otra parte del programa pide que el renderizado se detenga. El puente entre esa interfaz Pascal y la devolución de llamada C de PDFium es un único puntero. La referencia de interfaz del token se escribe en IFSDK_PAUSE.user, y una devolución de llamada estática cdecl la vuelve a leer y la consulta. Este es el problema clásico de dejar que una biblioteca C devuelva la llamada a Pascal: la devolución de llamada tiene que ser una función simple con convención de llamada C, no un método, porque PDFium almacena e invoca un puntero a función desnudo que no sabe nada sobre objetos Pascal o 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;

La devolución de llamada recupera el token al convertir (cast) pThis^.user de vuelta al tipo de interfaz y lee IsCancelled. Nada en ella asigna, bloquea o detiene, lo que importa porque PDFium la llama en el hilo de renderizado después de cada trozo y cualquier trabajo realizado aquí se suma al costo del renderizado en sí. La protección contra una estructura nula o un campo user nulo 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 durante todo el bucle

Convertir un puntero de interfaz a través de un Pointer sin formato y de vuelta es donde nacen los fallos de tiempo de vida. Una IInterface en Delphi cuenta por referencias, y la cuenta solo se mueve cuando el compilador puede ver que se asigna una variable de tipo de interfaz. Almacenar el token únicamente como un puntero desnudo dentro de IFSDK_PAUSE.user lo ocultaría completamente del contador de referencias. Si la única otra referencia a ese token saliera de su ámbito mientras el bucle Continue aún se estuviera ejecutando, el objeto se liberaría por debajo de la devolución de llamada, y el siguiente trozo desreferenciaría un puntero colgante

Es por eso que el descriptor es un registro que contiene dos cosas, no una. El campo Pause es la estructura que lee PDFium. El campo Token es una referencia real de tipo de interfaz que el compilador cuenta, y no existe por otra razón que fijar el token en la memoria durante el tiempo que viva el registro. El registro es una variable local en la pila de la rutina de renderizado, por lo que se mantiene válido durante toda la duración del bucle y solo se desmonta 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, la otra 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 asigna un estado progresivo que PDFium asocia a la página, y ese estado solo se libera mediante FPDF_RenderPage_Close. Hay tres formas de salir del bucle conductor. La página termina y el último estado es FPDF_RENDER_DONE. El token salta y el bucle sale anticipadamente 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 cancelar, 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 permita al usuario cancelar un renderizado tras otro acumularía esa filtración en cada página abortada

La forma robusta pone el bucle y la clasificación de resultados 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 dejar el bucle a través de un Exit temprano y el finally todavía se ejecuta, por lo que hay exactamente un lugar que libera el estado progresivo y no puede ser eludido

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 la devolución de llamada de su interior. La devolución de llamada acorta el trozo actual; la comprobación del bucle impide que comience el siguiente. Juntos delimitan el tiempo que tarda en hacer efecto una cancelación a aproximadamente la duración de un trozo

Tres resultados y qué contiene el mapa de bits después de 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 idioma Pascal, pero incluyen el caso de cancelación como un resultado de primera clase en lugar de como un error

El punto que atrapa a la gente es qué contiene el mapa de bits de destino después de prsCancelled. No está en blanco. PDFium renderiza progresivamente en el mismo mapa de bits trozo a trozo, así que cuando una cancelación detiene el bucle, el mapa de bits retiene todo lo que se hubiera pintado hasta ese momento, lo cual es una imagen parcial: algunas bandas hechas, el resto mostrando aún el color de relleno. El hecho de que ese resultado parcial sea útil depende del llamador. Un visor que esté a punto de desechar el mapa de bits porque el usuario navegó a otra parte puede simplemente ignorarlo. Un visor que quiera mostrar una vista previa de bajo costo puede conservarlo. Lo que no debe hacer es asumir que prsCancelled implica un mapa de bits vacío o indefinido; implica una instantánea fiel de un renderizado sin terminar

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 nulo y una ruta de devolución de llamada sin ramificaciones

La cancelación es de participación voluntaria (opt-in). Un llamador que solo quiere renderizado progresivo por el beneficio de bombeo de mensajes, sin intención de abortar, debería poder pasar nil para el token. La forma ingenua de soportar esto es esparcir comprobaciones "si se suministró un token" a través de la devolución de llamada y el bucle, lo que significa una ramificación en cada trozo y una devolución de llamada que tiene que manejar tanto un token real como su ausencia

La implementación evita esto sustituyendo un singleton cuando el llamador no pasa nada. Un token nil se intercambia por PdfNoCancellationToken, una interfaz cuya IsCancelled siempre es falsa. A partir de ese punto, la devolución de llamada y el bucle tienen un token que consultar en cada caso, de modo que ninguno necesita una comprobación de nulidad y ninguno necesita una ruta especial. El token de nunca cancelar simplemente siempre responde falso, la devolución de llamada siempre devuelve cero, y el renderizado se ejecuta hasta su finalización 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 la ruta activa uniforme

// 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 surge es pequeña y vale la pena replantearla, porque es la parte reutilizable. Una biblioteca C que soporta una devolución de llamada le da exactamente un canal para pasar estado a esa devolución de llamada, el puntero de usuario opaco. Ponga una referencia de interfaz Pascal contada detrás de ese puntero, mantenga viva una segunda referencia real junto a la estructura para que el objeto no pueda ser recolectado a mitad de llamada, y vuelva a leer la interfaz dentro de una función estática cdecl. Envuelva todo el bucle conductor en un try y libere el contexto nativo en el finally. La misma plantilla se traslada a cualquier operación progresiva de PDFium o impulsada por devolución de llamada donde el código Pascal tenga que mantener el control del tiempo de vida mientras C sostiene un puntero

La cancelación es solo la mitad de un visor con buena capacidad de respuesta. La otra mitad es no volver a renderizar las páginas que ya dibujó, y mantener el zoom y el desplazamiento fluidos sirviendo mapas de bits almacenados en caché, lo cual se cubre en nuestro artículo sobre almacenamiento en caché de renderizado y rendimiento de zoom. Para saber cómo encaja el renderizado cancelable en un visor completo junto a la navegación, la selección y la búsqueda, vea construcción de un visor de PDF rico en funciones con el componente VCL de PDFium. El renderizado progresivo que aquí se describe se distribuye como parte del PDFium Component para Delphi y Lazarus junto con las API de carga, renderizado y formularios cubiertas en otras partes de este blog