Technical Article

Transmisión de PDF grandes bajo demanda con PDFium en Delphi

Un archivo escaneado puede alcanzar varios gigabytes en un solo PDF. Un visor que abre dicho archivo generalmente desea mostrar una página (tal vez la tabla de contenido o una página a la que el usuario saltó desde un marcador). Leer todo el archivo en la memoria para renderizar dos páginas es un desperdicio en todos los aspectos: consume espacio de direcciones, detiene al usuario detrás de una lectura inicial prolongada y, en un proceso Delphi de 32 bits, puede fallar por completo antes de que aparezca una sola página. PDFium fue diseñado con esto en mente. Puede cargar un documento a través de un callback que solicita los rangos de bytes específicos que necesita, cuando los necesita, y nunca exige todo el archivo a la vez.

El componente expone esa ruta a través de un adaptador de flujo. Usted le entrega cualquier TStream y PDFium extrae bloques de ese flujo bajo demanda. El archivo puede estar en el disco, en un campo blob de una base de datos o detrás de cualquier otro descendiente de TStream, y nada de ello se copia a la memoria por adelantado.

Cómo solicita bytes PDFium

La API de C de PDFium carga un documento desde un objeto suministrado por el llamador descrito por la estructura FPDF_FILEACCESS. La estructura consta de tres partes que importan aquí: un campo de longitud, un callback 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 trailer, localiza la tabla de referencia cruzada y, a partir de ese momento, lee solo lo que requiere una operación determinada. Abrir el documento toca el final del archivo y un puñado de objetos de catálogo. Renderizar la página 400 lee los flujos de contenido y recursos para esa página y nada más.

Esta es la diferencia entre una carga en búfer y una carga por transmisión (streaming). Una carga en búfer lee el archivo de extremo a extremo antes de que PDFium vea el byte cero. Una carga por transmisión 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.

El adaptador de flujo

El adaptador que conecta un TStream de Delphi con FPDF_FILEACCESS es TPdfStreamAdapter. Su constructor toma el flujo y una bandera de propiedad, captura la longitud del flujo una vez, completa el registro FPDF_FILEACCESS y conecta el callback de lectura. Cuando PDFium realiza una llamada posterior 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ó.

// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
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 is a 32-bit unsigned long. Refuse a stream
  // that would silently truncate past 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;

La bandera de propiedad decide quién libera el flujo. Pase False y el llamador conservará el flujo y deberá mantenerlo activo durante toda la vida útil del documento. Pase True and el adaptador se hará cargo, liberando el flujo cuando el documento se cierre. En cualquier caso, el flujo debe sobrevivir a cada lectura que PDFium realice, porque PDFium conserva el puntero FPDF_FILEACCESS e invocará el callback en cualquier momento mientras el documento esté abierto, no solo durante la carga inicial.

Por qué el callback es una función estática

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

Eso resuelve la convención de llamadas pero plantea una segunda pregunta: sin Self, ¿cómo llega el callback al flujo específico 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 callback. La función estática lo convierte de nuevo a un TPdfStreamAdapter y envía la lectura contra el flujo de esa instancia. Este es el trampolín estándar para transferir el contexto de un objeto a través de un límite de C que no tiene noción de objetos.

// Verbatim from the component: the cdecl trampoline back to the instance
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);   // recover the instance from 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;  // report failure by return value, never by raising
  end;
end;

El límite de 4 GiB y por qué necesita una protección

El campo de longitud m_FileLen en FPDF_FILEACCESS es un valor sin signo de 32 bits. Su mayor longitud representable es un byte menos que 4 GiB. A TStream reporta su tamaño como un Int64, por lo que un flujo puede describir muchos más bytes de los que el campo puede albergar. En el momento en que el tamaño de un flujo supera ese límite, no hay una forma correcta de indicarle a PDFium la longitud del archivo.

La respuesta incorrecta es asignar el tamaño y permitir que se desborde. Truncar una longitud de 5 GiB a un campo de 32 bits produce un número pequeño con apariencia plausible, y entonces PDFium analizará el archivo creyendo que termina aproximadamente en un gigabyte. El trailer y la tabla de referencia cruzada residen en el final real del archivo, mucho más allá de la longitud truncada, por lo que el análisis falla de una manera que no tiene nada que ver con la causa real. Estaría depurando un error de referencia cruzada en un archivo que es perfectamente válido, sin indicios de que un entero se desbordó dos niveles más arriba.

En su lugar, el adaptador rechaza la entrada. El constructor compara el tamaño del flujo con High(FPDF_DWORD) y lanza un EPdfError en el instante en que el flujo es demasiado grande para describirse. Un error explícito e inmediato nombra el problema real en el punto de construcción. Un truncamiento silencioso lo oculta detrás de un síntoma engañoso que investigaría mucho más tarde. El límite de 4 GiB es una restricción real de esta ruta de carga, y lo correcto es exponerlo abiertamente en lugar de maquillar la situación con operaciones aritméticas que compilan por casualidad.

Las fallas no deben cruzar el límite

Una lectura puede fallar. El flujo puede ser un objeto respaldado por red que agota el tiempo de espera, un identificador de blob que se cerró de manera inesperada o un archivo que se truncó después de abrir el documento. El contrato de PDFium para el callback de lectura es un valor de retorno: distinto de cero para éxito, cero para falla. Es un marco de C y no dispone de mecanismos para capturar o propagar una excepción de Pascal.

Por esta razón, el trampolín envuelve el posicionamiento y la lectura en un bloque try/except que absorbe la excepción y devuelve cero. Si se permitiera que una excepción de Delphi se propagara fuera del callback, desharía el camino de la pila a través de los marcos de pila cdecl de PDFium, los cuales nunca fueron diseñados para ser deshechos por el mecanismo de excepciones de Pascal. El resultado sería un comportamiento indefinido en el mejor de los casos y un bloqueo total en el peor, profundamente dentro del analizador de PDF y sin una pila utilizable. Devolver cero mantiene la falla dentro del acuerdo. PDFium detecta una lectura de bloque fallida, aborta la operación limpiamente y FPDF_LoadCustomDocument reporta que el documento no pudo cargarse, lo que el componente expone como un EPdfError en el lado de Pascal, que es donde corresponde.

Cómo abrir un documento de esta forma

El método de componente que dirige la ruta de transmisión es LoadCustomDocument, declarado como un método distinto en lugar de otra sobrecarga de LoadDocument para que al pasar un TMemoryStream nunca termine accidentalmente en la ruta en búfer. Construye el adaptador, llama a FPDF_LoadCustomDocument y mantiene vivo el adaptador durante la vida útil del documento cargado.

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Hand stream ownership to Pdf: it frees FileStream when the document closes.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium has read only the trailer and catalog so far.
    // Rendering a page pulls just that page's bytes through the callback.
    // ... render or inspect pages here ...
  finally
    Pdf.Free;  // closes the document, which frees the adapter and the stream
  end;
end;

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

Una vez que las páginas se transmiten bajo demanda, la siguiente preocupación es mantener la capacidad de respuesta de las páginas renderizadas mientras el usuario hace zoom y se desplaza, lo que se cubre en nuestra nota sobre almacenamiento en caché de renderizado y rendimiento de zoom. Cuando el documento transmitido es uno que un visor debe mostrar pero no permitir que el usuario lo exporte o altere, las técnicas de la guía de vista previa segura de PDF se complementan naturalmente con esta ruta de carga. Ambos se basan en la carga por transmisión descrita aquí, que se distribuye como parte del PDFium Component para Delphi y C++Builder, junto con las API de renderizado, extracción de texto y anotaciones cubiertas en otras partes de este blog.