Technical Article

Streaming de PDFs gigantes sob demanda com PDFium no Delphi

Um arquivo digitalizado pode alcançar vários gigabytes em um único PDF. Um visualizador que abre esse tipo de arquivo geralmente deseja exibir uma página, talvez o sumário ou uma página para a qual o usuário saltou a partir de um indicador. Ler o arquivo inteiro na memória para renderizar duas páginas é um desperdício sob qualquer perspectiva: consome espaço de endereçamento, atrasa o usuário com uma leitura inicial longa e, em um processo Delphi de 32 bits, pode falhar completamente antes que uma única página seja exibida. O PDFium foi construído com isso em mente. Ele pode carregar um documento por meio de um callback que solicita as faixas de bytes específicas que ele precisa, no momento em que precisa, e nunca exige o arquivo completo de uma só vez.

O componente expõe esse caminho por meio de um adaptador de stream. Você entrega qualquer TStream a ele, e o PDFium extrai blocos desse stream sob demanda. O arquivo pode residir no disco, em um campo blob do banco de dados ou atrás de qualquer outro descendente do TStream, e nada disso é copiado previamente para a memória.

Como o PDFium solicita bytes

A API C do PDFium carrega um documento a partir de um objeto fornecido pelo chamador, descrito pela estrutura FPDF_FILEACCESS. A estrutura possui três partes importantes aqui: um campo de comprimento, um callback de leitura e um parâmetro de usuário opaco. O ponto de entrada que a consome é o FPDF_LoadCustomDocument. Uma vez que o PDFium obtém essa estrutura, ele analisa o trailer, localiza a tabela de referência cruzada e, a partir daí, lê apenas o que uma determinada operação exige. A abertura do documento acessa o final do arquivo e alguns objetos de catálogo. A renderização da página 400 lê os fluxos de conteúdo e recursos para essa página e nada mais.

Essa é a diferença entre um carregamento em buffer e um carregamento em streaming. Um carregamento em buffer lê o arquivo de ponta a ponta antes que o PDFium acesse o byte zero. Um carregamento em streaming inverte essa relação: o PDFium gerencia as leituras, e os bytes que nunca são acessados nunca são lidos. Para um arquivo de vários gigabytes visualizado página por página, essa é a diferença entre um carregamento inviável e um instantâneo.

O adaptador de stream

O adaptador que conecta um TStream do Delphi à estrutura FPDF_FILEACCESS é o TPdfStreamAdapter. Seu construtor recebe o stream e uma flag de propriedade, captura o comprimento do stream uma vez, preenche o registro FPDF_FILEACCESS e configura o callback de leitura. Quando o PDFium posteriormente realiza a chamada de retorno com um deslocamento e um tamanho, o adaptador move (seeks) o stream para esse deslocamento e copia exatamente essa faixa para o buffer fornecido pelo PDFium.

// 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;

A flag de propriedade decide quem libera o stream. Passe False e o chamador mantém o stream e deve mantê-lo ativo por toda a vida útil do documento. Passe True e o adaptador assume a responsabilidade, liberando o stream quando o documento for fechado. De qualquer forma, o stream deve sobreviver a cada leitura que o PDFium realizar, porque o PDFium mantém o ponteiro FPDF_FILEACCESS e fará chamadas de retorno a qualquer momento enquanto o documento estiver aberto, não apenas durante o carregamento inicial.

Por que o callback é uma função estática

O callback de leitura que o PDFium armazena em m_GetBlock é um ponteiro de função C simples com a convenção de chamada cdecl. Um método Delphi não pode ser usado diretamente, porque um método carrega um argumento oculto Self que um chamador C desconhece e nunca fornecerá. O adaptador, portanto, declara o callback como uma class function marcada como cdecl; static, a qual é compilada para uma função livre com o layout de frame C esperado pelo PDFium e sem Self implícito.

Isso resolve a convenção de chamada, mas levanta uma segunda questão: sem Self, como o callback alcança o stream específico do qual deve ler? A resposta é o parâmetro de usuário opaco. Quando o adaptador constrói o registro, ele armazena seu próprio ponteiro de instância em m_Param. O PDFium entrega esse mesmo ponteiro de volta como o primeiro argumento de cada callback. A função estática o converte de volta para um TPdfStreamAdapter e despacha a leitura contra o stream dessa instância. Esse é o trampolim padrão para passar o contexto do objeto através de uma fronteira C que não tem noção 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;

O teto de 4 GiB e por que ele precisa de uma proteção

O campo de comprimento m_FileLen no FPDF_FILEACCESS é um valor não assinado de 32 bits. Seu maior comprimento representável é um byte a menos que 4 GiB. Um TStream relata seu tamanho como um Int64, portanto um stream pode descrever muito mais bytes do que o campo pode conter. No momento em que o tamanho de um stream excede esse teto, não há uma forma honesta de informar ao PDFium qual é o comprimento do arquivo.

A resposta errada é atribuir o tamanho e deixar que ele estoure (wrap). Truncar um comprimento de 5 GiB para um campo de 32 bits fornece um número pequeno e de aparência plausível, e o PDFium analisará o arquivo acreditando que ele termina em cerca de um gigabyte. O trailer e a tabela de referência cruzada residem no final real do arquivo, muito além do comprimento truncado, de modo que a análise falha de uma maneira que não tem relação com a causa real. Você ficaria depurando um erro de referência cruzada em um arquivo que é perfeitamente válido, sem qualquer pista de que um inteiro estourou duas camadas acima.

Em vez disso, o adaptador recusa a entrada. O construtor compara o tamanho do stream com High(FPDF_DWORD) e gera um EPdfError no instante em que o stream é grande demais para ser descrito. Um erro explícito e imediato aponta o problema real no ponto de construção. Um truncamento silencioso esconde-o atrás de um sintoma enganoso que você investigaria muito mais tarde. O limite de 4 GiB é uma restrição genuína desse caminho de carregamento, e o correto é exibi-lo claramente, em vez de mascará-lo com aritmética que apenas compila por acaso.

As falhas não devem cruzar a fronteira

Uma leitura pode falhar. O stream pode ser um objeto baseado em rede que expira, um handle de blob que foi fechado por baixo de você ou um arquivo que foi truncado após a abertura do documento. O contrato do PDFium para o callback de leitura é um valor de retorno: diferente de zero para sucesso, zero para falha. É um frame C e não possui mecanismo para capturar ou propagar uma exceção Pascal.

É por isso que o trampolim envolve a busca e a leitura em um bloco try/except que absorve a exceção e retorna zero. Se uma exceção do Delphi pudesse se propagar para fora do callback, ela percorreria os frames de pilha cdecl do PDFium, que nunca foram projetados para serem desfeitos pelo mecanismo de exceções do Pascal. O resultado é um comportamento indefinido na melhor das hipóteses e uma falha grave na pior, profundamente dentro do analisador de PDF, sem uma pilha útil de execução. Retornar zero mantém a falha dentro do contrato. O PDFium detecta uma falha de leitura de bloco, aborta a operação de forma limpa, e o FPDF_LoadCustomDocument relata que o documento não pôde ser carregado, o que o componente exibe como um EPdfError no lado Pascal, onde pertence.

Abrindo um documento desta forma

O método do componente que direciona o caminho de streaming é o LoadCustomDocument, declarado como um método distinto e não como outra sobrecarga de LoadDocument, de modo que passar um TMemoryStream nunca caia acidentalmente no caminho com buffer. Ele constrói o adaptador, chama o FPDF_LoadCustomDocument e mantém o adaptador ativo durante a vida útil do documento carregado.

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;

A mesma chamada funciona para um TMemoryStream, um fluxo de blob de um dataset de banco de dados ou um descendente customizado do TStream. O carregamento sob demanda mostra seu valor quando o arquivo é grande e apenas parte dele será lida: um visualizador de arquivos, um gerador de miniaturas que amostra algumas páginas ou um índice de busca que extrai uma página por vez. Quando o arquivo é pequeno ou você lerá tudo de qualquer maneira, um carregamento em buffer é mais simples e o mecanismo de streaming não traz vantagens. O fator decisivo é a proporção de bytes que você realmente acessará em relação aos bytes que o arquivo contém.

Uma vez que as páginas são transmitidas sob demanda, a próxima preocupação é manter a resposta das páginas renderizadas à medida que o usuário aplica zoom e navega, o que é abordado em nossa nota sobre cache de renderização e desempenho de zoom. Quando o documento transmitido deve ser exibido por um visualizador, mas sem permitir que o usuário o exporte ou altere, as técnicas descritas no tutorial de visualização segura de PDF alinham-se naturalmente com este caminho de carregamento. Ambos baseiam-se no carregamento em streaming descrito aqui, fornecido como parte do Componente PDFium para Delphi e C++Builder junto com as APIs de renderização, extração de texto e anotações descritas em outras partes deste blog.