Artículo técnico

Transmisión de archivos PDF enormes bajo demanda con PDFium en Delphi

Un archivo escaneado puede llegar a varios gigabytes en un único PDF. Un visor que abre un archivo así normalmente quiere mostrar una página, quizá el índice, quizá una página a la que el usuario saltó desde un marcador. Cargar el archivo completo en memoria para renderizar dos páginas es derrochador en todos los frentes: quema espacio de direcciones, deja al usuario esperando tras una larga lectura inicial, y en un proceso Delphi de 32 bits puede fallar directamente antes de que aparezca una sola página. PDFium se construyó pensando en esto. Puede cargar un documento a través de una devolución de llamada que pide los rangos de bytes concretos que necesita, cuando los necesita, y nunca exige el archivo entero de una vez. Hay un límite que conviene aclarar de entrada: este canal de streaming describe el archivo con una longitud de 32 bits, así que sirve un único archivo de hasta 4 GiB, lo cual cubre casi todos los archivos escaneados en la práctica. Un archivo que supere esa línea no es el territorio de este artículo; en su lugar, conviene dividirlo en volúmenes en el momento del escaneo o abrirlo mediante una estrategia de acceso directo, y la salvaguarda que hace cumplir ese límite con honestidad tiene su propia sección más abajo

El componente expone esa vía mediante un adaptador de flujo. Le entrega cualquier TStream, y PDFium extrae bloques de ese flujo bajo demanda. El archivo puede residir en disco, en un campo blob de base de datos, o detrás de cualquier otro descendiente de TStream, y nada de eso se copia en memoria de antemano

Cómo pide bytes PDFium

La API en C de PDFium carga un documento a partir de un objeto suministrado por quien llama, descrito por la estructura FPDF_FILEACCESS. La estructura tiene tres partes que importan aquí: un campo de longitud, una devolución de llamada de lectura y un parámetro de usuario opaco. El punto de entrada que la consume es FPDF_LoadCustomDocument. Una vez que PDFium tiene esa estructura, analiza el tráiler, localiza la tabla de referencias cruzadas y, a partir de ahí, solo lee lo que exige cada operación concreta. Abrir el documento toca la cola del archivo y un puñado de objetos del catálogo. Renderizar la página 400 lee los flujos de contenido y los recursos de esa página, y nada más

Esta es la diferencia entre una carga almacenada en búfer y una carga en streaming. Una carga en búfer lee el archivo de principio a fin antes de que PDFium vea el byte cero. Una carga en streaming invierte la relación: PDFium dirige las lecturas, y los bytes que nunca se tocan nunca se leen. Para un archivo de varios gigabytes visto una página a la vez, esa es la diferencia entre una carga inutilizable y una instantánea

Diagrama de arquitectura contrastando una carga con búfer, que copia un PDF de varios gigabytes a memoria antes de analizar, con el streaming, donde PDFium solicita rangos de bytes a un TStream de Delphi por FPDF_FILEACCESS
Abrir cuesta solo el trailer y el catálogo; renderizar la página 400 tira por el callback de los bytes de la página 400 y de nada más

El adaptador de flujo

El adaptador que tiende un puente entre un TStream de Delphi y FPDF_FILEACCESS es TPdfStreamAdapter. Su constructor recibe el flujo y un indicador de propiedad, captura la longitud del flujo una sola vez, rellena el registro FPDF_FILEACCESS y conecta la devolución de llamada de lectura. Cuando PDFium llama después con un desplazamiento y un tamaño, el adaptador posiciona el flujo en ese desplazamiento y copia exactamente ese rango en el búfer que PDFium proporcionó

// Literal del componente: el puente de flujo a FPDF_FILEACCESS
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen es un unsigned long de 32 bits. Rechaza un flujo
  // que se truncaría en silencio más allá de los 4 GiB.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

El indicador de propiedad decide quién libera el flujo. Pase False y quien llama conserva el flujo y debe mantenerlo vivo durante toda la vida del documento. Pase True y el adaptador toma el control, liberando el flujo cuando el documento se cierra. En cualquier caso, el flujo tiene que sobrevivir a cada lectura que PDFium vaya a realizar, porque PDFium conserva el puntero FPDF_FILEACCESS y llamará de vuelta en cualquier momento mientras el documento esté abierto, no solo durante la carga inicial

Por qué la devolución de llamada es una función estática

La devolución de llamada de lectura que PDFium almacena en m_GetBlock es un simple puntero a función en C con la convención de llamada cdecl. Un método de Delphi no se puede usar directamente, porque un método lleva un argumento oculto Self del que un llamador en C no sabe nada y que nunca proporcionará. El adaptador, por tanto, declara la devolución de llamada como una class function marcada cdecl; static, que compila a una función independiente con el diseño de marco en C que PDFium espera y sin ningún Self implícito

Eso resuelve la convención de llamada pero plantea una segunda pregunta: sin Self, ¿cómo llega la devolución de llamada al flujo concreto del que se supone que debe leer? La respuesta es el parámetro de usuario opaco. Cuando el adaptador construye el registro, almacena su propio puntero de instancia en m_Param. PDFium devuelve ese mismo puntero como primer argumento de cada devolución de llamada. La función estática lo convierte de vuelta a TPdfStreamAdapter y despacha la lectura contra el flujo de esa instancia. Este es el trampolín estándar para transportar contexto de objeto a través de una frontera en C que no tiene noción de objetos

Diagrama del trampolín cdecl que transporta las peticiones de bloques de PDFium desde la frontera C hacia una instancia TPdfStreamAdapter de Delphi y colapsa las excepciones en un valor de retorno cero
Un callback cdecl estático no esconde ningún Self implícito, de modo que m_Param devuelve la instancia del adaptador a cada invocación y toda excepción Pascal se pliega en un retorno cero
// Literal del componente: el trampolín cdecl de vuelta a la instancia
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // recupera la instancia a partir de m_Param
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // informa del fallo mediante el valor de retorno, nunca lanzando una excepción
  end;
end;

El límite de 4 GiB y por qué necesita una salvaguarda

Aquí es de donde procede el límite planteado en la introducción. El campo de longitud m_FileLen de FPDF_FILEACCESS es un valor sin signo de 32 bits. Su longitud representable más grande queda un byte por debajo de 4 GiB. Un TStream informa de su tamaño como un Int64, así que un flujo puede describir muchos más bytes de los que el campo puede contener. En el momento en que el tamaño de un flujo supera ese límite, no hay ninguna manera honesta de decirle a PDFium cuán largo es el archivo

La respuesta equivocada es asignar el tamaño y dejar que se desborde. Truncar una longitud de 5 GiB en un campo de 32 bits produce un número pequeño y de apariencia plausible, y PDFium analizará entonces el archivo creyendo que termina más o menos un gigabyte más adelante. El tráiler y la tabla de referencias cruzadas viven en el final real del archivo, mucho más allá de la longitud truncada, así que el análisis falla de una manera que no tiene nada que ver con la causa real. Estaría depurando un error de referencias cruzadas en un archivo perfectamente válido, sin ninguna pista de que un entero se desbordó dos capas más arriba

El adaptador, en cambio, rechaza la entrada. El constructor compara el tamaño del flujo con High(FPDF_DWORD) y lanza EPdfError en el instante en que el flujo es demasiado grande para describirlo. Un error explícito e inmediato nombra el problema real en el momento de la construcción. Un truncamiento silencioso lo esconde detrás de un síntoma engañoso que perseguiría mucho más tarde. El límite de 4 GiB es una restricción genuina de esta vía de carga, y lo honesto es sacarlo a la luz en voz alta en lugar de disimularlo con aritmética que simplemente compila. Cuando un archivo cruza genuinamente esa línea, los remedios prometidos al principio viven fuera de esta API: dividir el escaneo en archivos por volumen que se mantengan cada uno por debajo del límite, o dejar el documento en disco y servirlo mediante un diseño de acceso directo construido sobre desplazamientos de 64 bits en lugar de a través de FPDF_FILEACCESS

Diagrama de decisión protegiendo el límite de 4 GiB de FPDF_FILEACCESS, donde un TStream de Delphi sobredimensionado lanza EPdfError de inmediato en lugar de dar la vuelta en silencio al campo de longitud declarado
Un EPdfError inmediato supera a la aritmética que solo compila: un m_FileLen con desbordamiento manda la depuración tras un rastro ficticio de referencias cruzadas

Los fallos no deben cruzar la frontera

Una lectura puede fallar. El flujo podría ser un objeto respaldado por red que agota el tiempo de espera, un manejador de blob que se cerró por debajo de usted, o un archivo que se truncó después de abrirse el documento. El contrato de PDFium para la devolución de llamada de lectura es un valor de retorno: distinto de cero para el éxito, cero para el fallo. Es un marco en C, y no tiene maquinaria para capturar o propagar una excepción de Pascal

Por eso el trampolín envuelve el posicionamiento y la lectura en un try/except que absorbe la excepción y devuelve cero. Si se permitiera que una excepción de Delphi se propagara fuera de la devolución de llamada, se desenrollaría a través de los marcos de pila cdecl de PDFium, que nunca se construyeron para ser desenrollados por la maquinaria de excepciones de Pascal. El resultado es, en el mejor caso, un comportamiento indefinido, y en el peor, un fallo grave dentro del analizador de PDF sin pila utilizable. Devolver cero mantiene el fallo dentro del contrato. PDFium ve una lectura de bloque fallida, aborta la operación limpiamente, y FPDF_LoadCustomDocument informa de que el documento no se pudo cargar, lo que el componente expone como un EPdfError en el lado de Pascal, donde corresponde

Abrir un documento de esta manera

El método del componente que dirige la vía de streaming es LoadCustomDocument, declarado como un método distinto en lugar de otra sobrecarga de LoadDocument, para que pasar un TMemoryStream nunca aterrice accidentalmente en la vía en búfer. Construye el adaptador, llama a FPDF_LoadCustomDocument y mantiene vivo el adaptador durante toda la vida del documento cargado

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Cede la propiedad del flujo a Pdf: libera FileStream cuando el documento se cierra.
    Pdf.LoadCustomDocument(FileStream, True);
    // Hasta ahora, PDFium solo ha leído el tráiler y el catálogo.
    // Renderizar una página extrae solo los bytes de esa página a través de la devolución de llamada.
    // ... renderice o inspeccione páginas aquí ...
  finally
    Pdf.Free;  // cierra el documento, lo cual libera el adaptador y el flujo
  end;
end;

La misma llamada funciona para un TMemoryStream, un flujo de blob de un conjunto de datos de base de datos, o un descendiente de TStream personalizado. La carga bajo demanda se gana su lugar cuando el archivo es grande y solo se va a leer una parte de él: un visor de archivos, un generador de miniaturas que muestrea unas pocas páginas, un índice de búsqueda que extrae una página a la vez. Cuando el archivo es pequeño o va a leerlo entero de todos modos, una carga en búfer es más simple y la maquinaria de streaming no le aporta nada. El factor decisivo es la proporción entre los bytes que realmente va a tocar y los bytes que contiene el archivo

Una vez que las páginas llegan en streaming bajo demanda, la siguiente preocupación es mantener las páginas renderizadas con capacidad de respuesta mientras el usuario hace zoom y se desplaza, lo cual se cubre en nuestra nota sobre el caché de renderizado y el rendimiento del zoom. Cuando el documento en streaming es uno que un visor debe mostrar pero sin dejar que el usuario lo exporte o lo altere, las técnicas de el recorrido de vista previa segura de PDF combinan de forma natural con esta vía de carga. Ambas se construyen sobre la carga en streaming aquí descrita, que se incluye como parte del PDFium Component para Delphi y C++Builder junto a las API de renderizado, extracción de texto y anotaciones cubiertas en otras partes de este blog