Artículo técnico

PDF mapeado en memoria en Delphi: ventana móvil

PDFlibPas puede abrir un PDF local mediante una vista de memoria acotada y de solo lectura: LoadFromMappedFile y DAOpenMappedFile mantienen exactamente una ventana deslizante sobre el fichero, la vuelven a mapear cuando hace falta y sirven cada segmento de objeto mediante lecturas con offset absoluto. La biblioteca PDF para Delphi no conserva en memoria toda la fuente, por lo que el uso del espacio de direcciones permanece estable aunque crezca el fichero. El diseño existe para una carga concreta: PDF de gigabytes cuyo parser ha terminado de cargar y sigue volviendo al disco, objeto a objeto y fragmento de stream a fragmento de stream

¿Por qué siguen siendo costosas las lecturas dispersas después de cargar el PDF?

Cargar un PDF no termina de leerlo, y en un fichero de varios gigabytes esa diferencia es donde se va el tiempo. Una tabla de referencias cruzadas o un stream de referencias cruzadas (ISO 32000-1 §7.5.4 y §7.5.8) solo registra dónde empieza cada objeto indirecto. Los bytes llegan después, cuando se renderiza una página, se decodifica un programa de fuente o se extrae un stream de fichero incrustado (ISO 32000-1 §7.11.4). Un archivo de 2 GB con decenas de miles de objetos se convierte en decenas de miles de lecturas pequeñas y desordenadas, y ninguna se conoce en el momento de la carga

La ruta que seguían antes esas lecturas era un Seek compartido seguido de Read sobre un stream posicional, y falla en dos direcciones a la vez. Cada fragmento paga una lectura de fichero aunque la página ya esté residente en la caché del sistema operativo, y el cursor es estado mutable compartido, así que un fichero local y la fuente por rangos que hay detrás de la carga progresiva de PDF por rangos con prefetch no podían ejecutar el mismo código de parser sin pelearse por la posición. PDFlibPas corrige ambos problemas convirtiendo la lectura con offset absoluto de optimización en contrato

¿Qué garantiza TPDFReadAtStream?

TPDFReadAtStream garantiza una lectura en un offset absoluto que no depende del cursor lógico del stream ni lo modifica. Es un descendiente abstracto de TStream con un único método virtual, y las dos fuentes de la biblioteca independientes del cursor derivan de él: TReadOnlyMappedFileStream para ficheros locales y TByteRangeStream para los servidos por rangos remotos. El lector de segmentos de objetos pregunta una vez si su fuente es un TPDFReadAtStream y vuelve a la secuencia antigua de seek y read cuando no lo es, de modo que un file stream normal o un memory stream siguen funcionando sin cambios

type
  // Streams de solo lectura cuyas lecturas absolutas evitan un Seek más Read compartido
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Acceso de solo lectura por ventanas a un único fichero local
  TReadOnlyMappedFileStream = class(TPDFReadAtStream)
  private
    FMemoryMapped: Boolean;
  public
    constructor Create(const FileName: WideString; WindowSize: Int64 = 0);
    function GetStats: TPDFMappedFileStats;
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; override;
    property MemoryMapped: Boolean read FMemoryMapped;
  end;

La diferencia importa más de lo que sugiere la firma. ReadAt utiliza el offset que recibe y deja Position exactamente donde estaba, lo que permite que niveles anidados del parser hagan lecturas sin guardar y restaurar la posición alrededor de cada llamada. TReadOnlyMappedFileStream sigue implementando Read, Seek y Size como cualquier otro TStream, Seek limita la posición lógica al fichero y Write siempre devuelve 0 porque la fuente se abre en modo de solo lectura

Abrir un PDF mediante una vista mapeada en Delphi

Dos puntos de entrada explícitos abren una fuente mapeada, y ninguno cambia el comportamiento de los puntos de entrada que ya utiliza. LoadFromMappedFile carga y selecciona un documento; DAOpenMappedFile devuelve un handle de Direct Access sobre el mismo fichero, que es el modo adecuado al fusionar y dividir PDF de gigabytes mediante Direct Access. LoadFromFile y DAOpenFile conservan intacta su semántica de compartición de ficheros, errores y compatibilidad, así que nada cambia para quienes no opten por la nueva ruta. Ambos puntos de entrada mapeados reciben un WindowSize solicitado en bytes y una máscara Options, y ambos aceptan 0 para cualquiera de los dos

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 selecciona el valor predeterminado de 64 MiB; aquí el mapeo es obligatorio
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // La extracción diferida recorre ahora ventanas mapeadas en lugar de hacer seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

¿Qué obliga a hacer realmente PDF_MAPPED_FILE_REQUIRE_MAPPING?

PDF_MAPPED_FILE_REQUIRE_MAPPING convierte un fallback silencioso en un fallo inmediato y diagnosticable durante la apertura. Con Options a 0, ambos puntos de entrada aceptan un fallback a un file stream de solo lectura: si la plataforma no tiene código de mapeo o falla la llamada de mapeo, el documento sigue abriéndose y cada lectura pasa por un file stream normal. Con el flag activado, PDFlibPas acepta la entrada solo cuando se ha establecido la primera vista, e informa del rechazo mediante LastErrorCode 401 en lugar de cargar un documento que ejecutaría silenciosamente exactamente como la ruta antigua

En Windows, el stream mapeado abre un segundo handle de solo lectura con FILE_SHARE_READ, FILE_SHARE_WRITE y FILE_SHARE_DELETE, además de FILE_FLAG_RANDOM_ACCESS, crea sobre él un mapeo PAGE_READONLY y mapea la primera ventana dentro del constructor. Mapear de forma inmediata es el objetivo: un fallo de «mapeo obligatorio» aparece en LoadFromMappedFile, no durante la primera lectura diferida de un objeto a mitad de un trabajo de renderizado. Conviene dejar claro dónde termina la garantía. El código de mapeo solo se compila para destinos Windows, y un fichero de cero bytes no intenta mapearse, así que PDF_MAPPED_FILE_REQUIRE_MAPPING es una petición que puede fallar legítimamente, no una promesa portable. Un WindowSize negativo, o cualquier bit de Options distinto del único valor documentado, se rechaza directamente con el mismo error 401

Una ventana, remapeada a la granularidad de asignación

Solo se conserva una vista, y eso es lo que mantiene el uso del espacio de direcciones independiente del tamaño del fichero. Un WindowSize de 0 selecciona 64 MiB; un valor inferior a la granularidad de asignación del sistema se eleva hasta ella; un valor superior a 1 GiB se limita; y el resultado se redondea hasta un número entero de unidades de granularidad, 65536 bytes en Windows salvo que GetSystemInfo informe de otro dwAllocationGranularity. Cuando una lectura cae fuera de la vista actual, PDFlibPas la desmapea, alinea el offset solicitado hacia abajo a un límite de granularidad y mapea allí una ventana nueva. La ventana final se limita al tamaño físico del fichero, por lo que la vista nunca se extiende más allá de su final

Una sola lectura puede atravesar cualquier número de ventanas: el bucle copia lo que pueda suministrar la vista actual, remapea y continúa, y una petición que se salga del final devuelve un recuento corto en lugar de fallar. Lo que PDFlibPas no hace deliberadamente es entregarle un puntero a la vista, porque la siguiente lectura que cruce una ventana lo invalidaría y ningún caller podría protegerse razonablemente contra ello. Los bytes mapeados se copian directamente a buffers de destino propiedad del parser, lo que elimina el buffer de entrada de fichero adicional y los cambios de posición, pero la biblioteca no afirma que el almacenamiento final del parser sea zero-copy. El windowing de lectura también compone con el lado de escritura, ya que el desplazamiento de referencias a nivel de byte durante una fusión rápida de PDF transmite los bytes de objeto mientras la fuente mapeada los proporciona. El compromiso del tamaño de ventana es evidente: una ventana menor ocupa menos espacio de direcciones y remapea más a menudo, lo que suele ser la decisión correcta dentro de un proceso de 32 bits

Qué protege el lock y qué informa GetMappedFileInfo

Una única sección crítica cubre la vista mapeada, el cursor del fichero de fallback, la posición lógica y las estadísticas, y la separación entre los dos métodos de lectura sale directamente de ahí. ReadAt toma el lock y llama al lector interno sin lock; Read toma el mismo lock, llama a la misma función interna con la posición lógica actual y después la avanza. Reutilizar la función interna en lugar del ReadAt público evita el locking recursivo, y mantener el lock durante todo el bucle de copia es lo que mantiene correcto un remapeo de una sola ventana bajo llamadas concurrentes. Conviene conocer un detalle de Free Pascal antes de portar: la unidad Windows de FPC declara su propio record llamado TCriticalSection, así que el campo y su construcción deben escribirse como SyncObjs.TCriticalSection. Delphi compila felizmente la forma sin calificar; FPC la resuelve como un record sin Create, Enter ni Leave

var
  Pdf: TPDFlib;
  Handle, PageRef: Integer;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Handle := Pdf.DAOpenMappedFile('archive-2026.pdf', '',
      16 * 1024 * 1024, PDF_MAPPED_FILE_REQUIRE_MAPPING);
    if Handle = 0 then
      Exit;
    try
      PageRef := Pdf.DAFindPage(Handle, 1);
      Writeln(Pdf.DAExtractPageText(Handle, PageRef, 0));

      // {"memoryMapped":true,"fileSize":...,"remapCount":...}
      if Pdf.DAGetMappedFileInfo(Handle, Info) = 1 then
        Writeln(Info);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;
  • memoryMapped es false siempre que esté activo el fallback portable a file stream, y es el único campo que demuestra que nunca se estableció un mapeo
  • windowSize es la ventana efectiva alineada, no el valor solicitado, y mappedBytes es menor que ella en la ventana final
  • mappedOffset es el inicio alineado a la asignación de la vista conservada, o -1 cuando no hay ninguna vista activa
  • readCalls cuenta las peticiones de lectura correctas dentro del rango, bytesRead cuenta los bytes copiados a los callers y remapCount incluye la vista inicial

Las regresiones específicas cubren lecturas absolutas entre ventanas, conservación del cursor lógico, lecturas cortas al final, offsets inválidos, escrituras rechazadas, remapeos entre ventanas separadas, extracción diferida de un adjunto incomprimible de 220 KB y estadísticas que se vuelven inválidas después de DACloseFile; las suites headless Win32 y Win64 descubrieron 1467 pruebas cada una y las superaron todas sin resultados ignorados, fallidos, erróneos ni fugas. Si trabaja con PDF de gigabytes en Delphi o C++Builder y su profiler sigue señalando las lecturas de fichero en lugar del parsing, los puntos de entrada de fichero mapeado merecen una tarde de mediciones, y GetMappedFileInfo le dirá si realmente obtuvo un mapeo. La referencia completa de la API y una compilación de prueba están en la página de la biblioteca PDF para Delphi PDFlibPas