Artigo Técnico

Leitura PDF mapeada em memória: janela deslizante

O PDFlibPas consegue abrir um PDF local através de uma vista de memória mapeada, limitada e só de leitura: LoadFromMappedFile e DAOpenMappedFile mantêm exatamente uma janela deslizante sobre o ficheiro, remapeiam-na quando necessário e servem cada fatia de objeto através de leituras com offset absoluto. A biblioteca PDF para Delphi nunca mantém toda a origem em memória, pelo que o uso do espaço de endereçamento permanece estável à medida que o ficheiro cresce. O desenho existe para uma carga de trabalho específica: PDFs de gigabytes em que o parser terminou o carregamento e continua a voltar ao disco, objeto a objeto, fragmento de stream a fragmento de stream

Porque é que as leituras dispersas continuam caras depois de o PDF ser carregado?

Carregar um PDF não termina a sua leitura e, num ficheiro de vários gigabytes, é nessa diferença que o tempo se gasta. Uma tabela de referências cruzadas ou um stream de referências cruzadas (ISO 32000-1 §7.5.4 e §7.5.8) regista apenas onde começa cada objeto indireto. Os bytes chegam mais tarde, quando uma página é renderizada, um programa de fonte é descodificado ou um stream de ficheiro incorporado (ISO 32000-1 §7.11.4) é extraído. Um arquivo de 2 GB com dezenas de milhares de objetos transforma-se em dezenas de milhares de pequenas leituras desordenadas e nenhuma delas é conhecida no momento do carregamento

O percurso que essas leituras usavam era um Seek partilhado seguido de Read num stream posicional, e falha em duas frentes ao mesmo tempo. Cada fragmento paga uma leitura de ficheiro mesmo quando a página já está residente na cache do sistema operativo, e o cursor é estado mutável partilhado, pelo que um ficheiro local e a origem de intervalos de bytes por trás do carregamento progressivo de intervalos PDF com prefetch não podiam executar o mesmo código de parser sem disputarem a posição. O PDFlibPas resolve ambas as coisas ao transformar a leitura por offset absoluto de otimização em contrato

O que garante TPDFReadAtStream?

TPDFReadAtStream garante uma leitura num offset absoluto que não depende do cursor lógico do stream nem o perturba. É um descendente abstrato de TStream com exatamente um método virtual, e ambas as origens independentes do cursor na biblioteca derivam dele: TReadOnlyMappedFileStream para ficheiros locais e TByteRangeStream para origens remotas servidas por intervalos. O leitor de fatias de objetos verifica uma vez se a sua origem é um TPDFReadAtStream e recua para a sequência antiga de seek seguido de read quando não é, pelo que um stream de ficheiro normal ou um stream de memória continua a funcionar sem alterações

type
  // Streams só de leitura cujas leituras absolutas evitam um Seek e Read partilhados
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Acesso só de leitura e com janelas a um único ficheiro 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;

A distinção importa mais do que a assinatura deixa perceber. ReadAt usa o offset que recebe e deixa Position exatamente onde estava, o que permite aos níveis aninhados do parser emitirem leituras sem uma dança de guardar e restaurar à volta de cada chamada. TReadOnlyMappedFileStream continua a implementar Read, Seek e Size como qualquer outro TStream, Seek limita a posição lógica ao ficheiro e Write devolve sempre 0 porque a origem é aberta só para leitura

Abrir um PDF através de uma vista mapeada em Delphi

Dois pontos de entrada explícitos abrem uma origem mapeada e nenhum deles altera o comportamento dos pontos de entrada que já utiliza. LoadFromMappedFile carrega e seleciona um documento; DAOpenMappedFile devolve um handle de Direct Access sobre o mesmo ficheiro, que é o modo adequado quando faz merge e split de PDFs de gigabytes através de Direct Access. LoadFromFile e DAOpenFile mantêm intactas as suas semânticas de partilha de ficheiros, erros e compatibilidade, pelo que nada muda para os chamadores que não optem por esta funcionalidade. Ambos os pontos de entrada mapeados recebem um WindowSize solicitado em bytes e uma bitmask Options, e ambos aceitam 0 para qualquer um dos dois

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 escolhe o valor predefinido de 64 MiB; o mapeamento é obrigatório aqui
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // A extração adiada percorre agora janelas mapeadas em vez de fazer seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

O que impõe realmente PDF_MAPPED_FILE_REQUIRE_MAPPING?

PDF_MAPPED_FILE_REQUIRE_MAPPING transforma um fallback silencioso numa falha imediata e diagnosticável no momento da abertura. Com Options a 0, ambos os pontos de entrada aceitam um fallback para stream de ficheiro só de leitura: se a plataforma não tiver código de mapeamento ou a chamada de mapeamento falhar, o documento continua a abrir e todas as leituras passam por um stream de ficheiro normal. Com o sinalizador definido, o PDFlibPas só aceita a entrada quando a primeira vista tiver sido criada e comunica a recusa através de LastErrorCode 401, em vez de carregar um documento que funciona silenciosamente exatamente como o percurso antigo

No Windows, o stream mapeado abre um segundo handle só de leitura com FILE_SHARE_READ, FILE_SHARE_WRITE e FILE_SHARE_DELETE, além de FILE_FLAG_RANDOM_ACCESS, cria sobre ele um mapeamento PAGE_READONLY e mapeia a primeira janela dentro do construtor. Mapear imediatamente é o objetivo: uma falha de "mapping required" aparece em LoadFromMappedFile, não na primeira leitura preguiçosa de um objeto a meio de um trabalho de renderização. É importante perceber onde a garantia termina. O código de mapeamento só é compilado para alvos Windows e um ficheiro de zero bytes nem sequer tenta ser mapeado, pelo que PDF_MAPPED_FILE_REQUIRE_MAPPING é um pedido que pode legitimamente falhar, não uma promessa portátil. Um WindowSize negativo, ou qualquer bit de Options diferente do único valor documentado, é rejeitado liminarmente com o mesmo erro 401

Uma janela, remapeada à granularidade de alocação

Apenas uma vista é mantida de cada vez, e é isso que torna o uso do espaço de endereçamento independente do tamanho do ficheiro. Um WindowSize de 0 seleciona 64 MiB; um valor inferior à granularidade de alocação do sistema é elevado até ela; um valor superior a 1 GiB é limitado; e o resultado é arredondado para um número inteiro de unidades de granularidade, 65536 bytes no Windows, a menos que GetSystemInfo comunique outro dwAllocationGranularity. Quando uma leitura cai fora da vista atual, o PDFlibPas desmapeia-a, alinha o offset solicitado para baixo até um limite de granularidade e mapeia ali uma janela nova. A janela final é limitada ao tamanho físico do ficheiro, pelo que a vista nunca se estende para além do fim do ficheiro

Uma única leitura pode atravessar qualquer número de janelas: o ciclo copia o que a vista atual consegue fornecer, remapeia e continua, e um pedido que ultrapasse o fim devolve uma contagem curta em vez de falhar. O que o PDFlibPas deliberadamente não faz é entregar um ponteiro para a vista, porque a leitura seguinte entre janelas o invalida e nenhum chamador poderia proteger-se razoavelmente contra isso. Os bytes mapeados são copiados diretamente para buffers de destino pertencentes ao parser, o que elimina o buffer extra de entrada do ficheiro e a troca de posições, mas a biblioteca não promete zero-copy no armazenamento final do parser. O windowing do lado da leitura também combina com o lado da escrita, uma vez que o deslocamento de referências ao nível dos bytes durante um merge rápido de PDF transmite bytes de objetos para fora enquanto a origem mapeada os transmite para dentro. O compromisso do tamanho da janela é o esperado: uma janela menor ocupa menos espaço de endereçamento e remapeia com mais frequência, o que costuma ser a escolha certa dentro de um processo de 32 bits

O que protege o lock e o que comunica GetMappedFileInfo?

Uma única secção crítica cobre a vista mapeada, o cursor do ficheiro de fallback, a posição lógica e as estatísticas, e a separação entre os dois métodos de leitura resulta diretamente daí. ReadAt adquire o lock e chama o leitor interno sem lock; Read adquire o mesmo lock, chama o mesmo leitor interno na posição lógica atual e depois avança-a. Reutilizar a função interna em vez do ReadAt público evita o locking recursivo, e manter o lock durante todo o ciclo de cópia é o que mantém correto um remapeamento de uma única janela sob chamadas concorrentes. Há um detalhe do Free Pascal que convém conhecer antes de fazer o port: a unidade Windows do FPC declara o seu próprio record chamado TCriticalSection, pelo que o campo e a sua construção têm de ser escritos como SyncObjs.TCriticalSection. O Delphi compila tranquilamente a forma não qualificada; o FPC resolve-a para um record sem Create, Enter ou 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 é false sempre que o fallback portátil para stream de ficheiro está ativo e é o único campo que prova que nunca foi estabelecido um mapeamento
  • windowSize é a janela efetiva alinhada, não o valor solicitado, e mappedBytes é menor do que ela na janela final
  • mappedOffset é o início alinhado à alocação da vista mantida, ou -1 quando não há nenhuma vista ativa
  • readCalls conta pedidos de leitura bem-sucedidos dentro do intervalo, bytesRead conta os bytes copiados para os chamadores e remapCount inclui a vista inicial

As regressões direcionadas cobrem leituras absolutas entre janelas, preservação do cursor lógico, leituras curtas no fim, offsets inválidos, escritas rejeitadas, remapeamento entre janelas separadas, extração adiada de um anexo incompressível de 220 KB e estatísticas que se tornam inválidas depois de DACloseFile; as suites headless Win32 e Win64 descobriram 1467 testes cada e passaram-nos todos sem resultados ignorados, falhados, com erro ou com fugas. Se trabalha com PDFs de gigabytes em Delphi ou C++Builder e o profiler continua a apontar para leituras de ficheiro em vez de parsing, vale a pena dedicar uma tarde a medir estes pontos de entrada mapeados, e GetMappedFileInfo dir-lhe-á se obteve realmente um mapeamento. A referência completa da API e um build de avaliação estão na página da biblioteca PDF PDFlibPas para Delphi