Artículo técnico

Descarga progresiva y cancelación con PDFium (FPDFAvail)

El PDFium Component abre un PDF que todavía se está descargando mediante TPdfProgressiveDocument, una subclase de TPdf que envuelve la API de disponibilidad FPDFAvail_* de PDFium. BeginProgressiveLoad arranca la sesión, CheckDocumentAvailability reporta qué rangos de bytes aún necesita PDFium, OpenProgressiveDocument abre el archivo en cuanto existen bytes suficientes, y CancelProgressiveLoad abandona una descarga interrumpida sin fugar handles nativos. Lo difícil no es el camino feliz. Un visor sobre una conexión inestable verá usuarios cerrar la pestaña al 25 por ciento, cambiar de idea, y volver a abrir el mismo enlace, y cada una de esas sesiones abortadas lleva un handle de disponibilidad nativo, dos records de callback C, un adaptador de stream y un lote de peticiones de rango en vuelo que deben liberarse en exactamente el orden correcto

¿Cómo carga TPdfProgressiveDocument un PDF que sigue descargándose?

TPdfProgressiveDocument mantiene vivo un proveedor de disponibilidad de PDFium mientras un stream de acceso aleatorio se va llenando, y pregunta a ese proveedor antes de cada paso de parseo si los bytes que quiere están presentes. BeginProgressiveLoad(AStream, AFileSize, AOwnsStream, AInitialAvailableByteCount) toma el stream de respaldo más el tamaño lógico del archivo remoto, cablea un callback IsDataAvail y un callback AddSegment en dos records, y llama a FPDFAvail_Create. Cuando PDFium pregunta si un rango está presente, el componente responde que sí si el rango cae dentro del prefijo contiguo descrito por AvailableByteCount o dentro de un rango ya completado vía el planificador RangeRequests, y el evento OnDataAvailable puede override el veredicto para almacenes dispersos. Cada llamada a CheckDocumentAvailability devuelve uno de tres valores TPdfDataAvailability (pdaAvailable, pdaNotAvailable, pdaError) y entrega los rangos que PDFium pidió como un array TPdfDownloadRanges ordenado y fusionado, ya encolado en el planificador con prioridad rrpImmediate

// FetchRange es tu transporte (HTTP Range GET, socket, lector de blobs):
// escribe Size bytes en Offset dentro de Store y devuelve cuántos llegaron
function FetchRange(Store: TStream; Offset, Size: UInt64): UInt64; forward;

procedure OpenWhileDownloading(Pdf: TPdfProgressiveDocument; Store: TStream;
  RemoteSize: UInt64);
const
  MaxRounds = 64;
var
  Hints: TPdfDownloadRanges;
  State: TPdfDataAvailability;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Pdf.BeginProgressiveLoad(Store, RemoteSize, False);
  State := pdaNotAvailable;
  for Round := 1 to MaxRounds do
  begin
    State := Pdf.CheckDocumentAvailability(Hints);
    if State <> pdaNotAvailable then
      Break;
    // Las pistas ya están encoladas; escribe primero los bytes, y luego completa
    while Pdf.RangeRequests.TryDequeue(Request) do
      Pdf.RangeRequests.CompleteRequest(Request,
        FetchRange(Store, Request.Offset, Request.Size));
  end;
  if State <> pdaAvailable then
    raise EPdfError.Create('The document could not be discovered');
  Pdf.OpenProgressiveDocument;
end;

Dos detalles de ese bucle son de carga estructural. El tope de rondas importa porque un enlace muerto hace que CheckDocumentAvailability pida los mismos rangos para siempre, y un bucle sin tope convierte un fallo de red en una UI colgada. El orden importa porque el planificador serializa su propio estado con una sección crítica pero no hace nada por TStream.Position del almacén de respaldo: un hilo de transporte debe escribir los bytes de la respuesta en el stream antes de llamar a CompleteRequest, porque en el momento en que una finalización se publica PDFium puede leer ese rango, y los escritores concurrentes necesitan I/O posicionado o un candado propio

El bucle de disponibilidad de TPdfProgressiveDocument en PDFium Component: BeginProgressiveLoad crea el proveedor FPDFAvail, CheckDocumentAvailability entrega pistas de descarga ordenadas y fusionadas encoladas con prioridad rrpImmediate, el transporte escribe bytes en el almacén antes de que CompleteRequest publique cada rango a PDFium, y el bucle se limita a 64 rondas porque un enlace muerto sigue pidiendo los mismos rangos
Escribe los bytes y luego completa la petición: en el momento en que una finalización se publica PDFium puede leer ese rango, y nada te protege la posición del stream

¿Por qué AvailableByteCount se niega a retroceder?

AvailableByteCount solo crece, y el setter lanza EPdfError con "Available byte count cannot move backwards" cuando intentas encogerlo. Una vez que el callback IsDataAvail le ha dicho a PDFium que un rango existe, el parser ya puede haber leído y cacheado objetos de él, así que retirar esos bytes después dejaría las respuestas de disponibilidad inconsistentes con lo que PDFium ya consumió. El mismo setter rechaza valores mayores que LogicalFileSize y lanza "No progressive load is active" fuera de una sesión, razón por la que los bytes que ya tienes antes de que empiece la carga van en el argumento AInitialAvailableByteCount de BeginProgressiveLoad y no en una asignación de propiedad hecha demasiado pronto. Si tu almacén de descarga se llena desordenado, no intentes expresarlo por el prefijo: completa los rangos vía el planificador o responde vía OnDataAvailable

¿Cuándo puede abrirse realmente un PDF descargado a medias?

Solo un PDF linealizado (ISO 32000-1 Anexo F, el layout "Fast Web View") se abre antes de que llegue el archivo entero; un PDF no linealizado aún necesita cada byte. OpenProgressiveDocument comprueba la propiedad Linearization (plnUnknown, plnNotLinearized, plnLinearized) y enruta en consecuencia: un archivo linealizado se abre vía FPDFAvail_GetDocument en cuanto están presentes la sección de primera página y las tablas de pistas, mientras que un archivo no linealizado se abre vía FPDF_LoadCustomDocument sobre el mismo record de acceso a archivo y se trata como legible solo en bloque. El enrutado existe por una razón concreta. Llamar a FPDFAvail_GetDocument sobre un archivo no linealizado puede devolver un handle no nulo cuyo conteo de páginas es cero, un documento que parece abierto y está vacío. En la propia suite de tests del componente, un fixture linealizado de 51 páginas llega a pdaAvailable y se abre con su árbol de páginas completo mientras el almacén de descarga disperso todavía no cubre el archivo

Cómo enruta OpenProgressiveDocument una descarga parcial en PDFium Component: un archivo linealizado se abre vía FPDFAvail_GetDocument en cuanto llegan la sección de primera página y las tablas de pistas, un archivo no linealizado necesita FPDF_LoadCustomDocument y cada byte, y LoadAvailablePage comprueba la disponibilidad del formulario con FPDFAvail_IsFormAvail antes de la comprobación de página, esquivando la trampa del handle no nulo de cero páginas
Solo los archivos linealizados ganan ventaja; en cualquier otro FPDFAvail_GetDocument puede devolver un documento con pinta de abierto y cero páginas, que es justo lo que el enrutado evita
function WaitForPage(Pdf: TPdfProgressiveDocument; Store: TStream;
  PageNumber: Integer): Boolean;
var
  Hints: TPdfDownloadRanges;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Result := False;
  for Round := 1 to 64 do
    case Pdf.LoadAvailablePage(PageNumber, Hints) of
      pdaAvailable:
        Exit(True);   // PageNumber es ahora la página activa
      pdaError:
        Exit(False);
      pdaNotAvailable:
        while Pdf.RangeRequests.TryDequeue(Request) do
          Pdf.RangeRequests.CompleteRequest(Request,
            FetchRange(Store, Request.Offset, Request.Size));
    end;
end;

LoadAvailablePage toma un número de página base 1 y aplica el orden que PDFium espera: antes de la primera comprobación de página ejecuta CheckFormAvailability, que envuelve a FPDFAvail_IsFormAvail, y solo después llama a FPDFAvail_IsPageAvail. Un resultado pfaNotPresent es la respuesta normal para un documento sin AcroForm y no bloquea nada. Cuando la página está lista, LoadAvailablePage la convierte en la página activa, así que un visor puede renderizar la página 1 de un folleto linealizado mientras las demás páginas siguen en tránsito; FirstAvailablePageNumber te dice qué página designa el diccionario de linealización como la primera, ya convertido del índice base cero de PDFium

¿Qué libera CancelProgressiveLoad, y en qué orden?

CancelProgressiveLoad desmonta una sesión en cuatro pasos que no se pueden reordenar: cancelar el planificador de rangos, cerrar el documento, destruir el handle de disponibilidad con FPDFAvail_Destroy, y luego disponer los records de callback y liberar el adaptador de stream. Cancelar el planificador primero incrementa su contador de generación, suelta toda petición pendiente y en vuelo, y dispara OnCancelRequest por cada una en vuelo, así que una finalización de transporte que aterrice después lleva la generación vieja y CompleteRequest devuelve False sin tocar nada. El documento debe cerrarse antes de que desaparezcan el handle de disponibilidad y el adaptador porque PDFium puede llamar de vuelta al proveedor de acceso a archivos mientras cierra un documento, y si el adaptador ya no está ese callback lee memoria liberada

El orden fijo de desmontaje de CancelProgressiveLoad en PDFium Component: cancelar primero el planificador de rangos para que las finalizaciones tardías golpeen el contador de generación incrementado y devuelvan False, cerrar el documento antes de que desaparezca el adaptador de acceso a archivos, destruir el handle de disponibilidad con FPDFAvail_Destroy, y solo entonces disponer los records de callback y liberar el adaptador de stream
Un método idempotente limpia por igual un arranque fallido, una cancelación de usuario y el destructor; con un hilo worker escribiendo en el almacén, la propiedad del stream se queda en tus manos
procedure TDownloadForm.FormCreate(Sender: TObject);
begin
  FPdf := TPdfProgressiveDocument.Create(nil);
  // El planificador vive lo mismo que FPdf, así que cablea una vez
  FPdf.RangeRequests.OnCancelRequest := RangeCancelled;
end;

procedure TDownloadForm.RangeCancelled(Sender: TObject; RequestId: UInt64;
  Attempt: Cardinal);
begin
  FTransport.Abort(RequestId);   // tu código: cierra ese socket o petición
end;

procedure TDownloadForm.CancelButtonClick(Sender: TObject);
begin
  FPdf.CancelProgressiveLoad;
  // ProgressiveLoading = False, Active = False, AvailableByteCount = 0
end;

El método es idempotente y es el único camino de limpieza para tres situaciones: un BeginProgressiveLoad que falla a mitad de construcción, una cancelación explícita del usuario, y el destructor. BeginProgressiveLoad también lo llama antes de arrancar, así que reiniciar el mismo objeto sobre una URL nueva es seguro sin cancelación explícita. Una decisión de propiedad es tuya acertarla: si un hilo worker escribe en el stream de respaldo, pasa AOwnsStream = False y libera el stream tú después de que el worker haya parado, porque con la propiedad transferida la cancelación libera el stream mientras puede seguir llegando una escritura tardía. Las excepciones lanzadas dentro de OnCancelRequest se tragan por petición, de modo que un transporte fallido no pueda bloquear las cancelaciones restantes

¿Cómo demuestra la suite de lifecycle que el camino de cancelación no fuga?

La suite de estrés de lifecycle del PDFium Component ejercita una descarga interrumpida estilo red en cada ciclo mixto. Cada ciclo arranca una carga progresiva cuyo almacén solo guarda un cuarto de los bytes del fixture, exige pdaNotAvailable con una lista de pistas no vacía, llama a CancelProgressiveLoad, y verifica que el objeto no reporta ni ProgressiveLoading ni Active; luego recorre el mismo camino de streaming hasta el final con disponibilidad completa, OpenProgressiveDocument, un render y un cierre. La ejecución mixta por defecto cubre 100 ciclos medidos con 600 aperturas, 2300 renders y 100 cancelaciones progresivas, y la memoria privada muestreada creció 8.21 MiB contra un presupuesto de 32 MiB. La suite cuenta las cancelaciones progresivas aparte de las cancelaciones de callbacks de render, porque una descarga abortada y un bucle de render que se para antes son eventos distintos con criterios de aceptación distintos

Donde el camino progresivo deja de ayudar

Conviene conocer unos cuantos límites antes de construir un visor encima de esto. Las features que necesitan los bytes del archivo original rechazan un origen progresivo incompleto en lugar de adivinar: ReadXmpPacket falla explícitamente y la validación de firmas reporta Indeterminate hasta que el archivo entero esté presente. El test de disponibilidad por defecto asume un prefijo contiguo, así que un transporte que descarga rangos desordenados debe completarlos vía RangeRequests o responder vía OnDataAvailable, o PDFium seguirá pidiendo bytes que ya tienes. Un archivo no linealizado no gana nada en tiempo hasta primera página, así que si el primer pintado rápido importa, linealiza el archivo en el lado del servidor. Y CancelProgressiveLoad no cierra tus sockets por sí solo; OnCancelRequest es el gancho donde eso ocurre

Para el camino simple de adaptador de stream que carga un archivo local completo bajo demanda, mira hacer streaming de PDFs grandes bajo demanda con PDFium; para abrir un PDF que vive dentro de un buffer mayor, mira la carga por byte range para PDFs incrustados. Cancelar un render lento de una página ya cargada es un mecanismo aparte, cubierto en el renderizado progresivo de páginas cancelable. TPdfProgressiveDocument y su planificador de rangos se envían con el PDFium Component para Delphi y C++Builder