Artigo Técnico

Fazer Streaming de PDFs Gigantes a Pedido com PDFium em Delphi

Um arquivo digitalizado pode chegar a vários gigabytes num único PDF. Um visualizador que abra um ficheiro desse tipo normalmente pretende mostrar uma página, talvez o índice, talvez uma página para a qual o utilizador saltou a partir de um marcador. Ler o ficheiro inteiro para a memória para renderizar duas páginas é um desperdício em todos os eixos: queima espaço de endereçamento, atrasa o utilizador com uma longa leitura inicial e, num processo Delphi de 32 bits, pode falhar de imediato antes que apareça uma única página. O PDFium foi construído com isto em mente. Pode carregar um documento através de um callback que pede os intervalos de bytes específicos de que necessita, quando necessita, e nunca exige o ficheiro inteiro de uma só vez. Há um limite logo à partida: este canal de streaming descreve o ficheiro com um comprimento de 32 bits, por isso serve um único ficheiro até 4 GiB, o que abrange quase todos os arquivos digitalizados na prática. Um ficheiro para além dessa linha não é o território deste artigo; deverá ser dividido em volumes no momento da digitalização ou aberto através de uma estratégia de acesso direto, e o guarda que impõe o teto honestamente tem uma secção própria abaixo

O componente expõe esse caminho através de um adaptador de stream. Entrega-lhe qualquer TStream, e o PDFium extrai blocos dessa stream a pedido. O ficheiro pode estar no disco, num campo blob de uma base de dados, ou por trás de qualquer outro descendente TStream, e nada dele é copiado para a memória à partida

Como o PDFium pede bytes

A API C do PDFium carrega um documento a partir de um objeto fornecido por quem o chama, descrito pela estrutura FPDF_FILEACCESS. A estrutura tem três partes que importam aqui: um campo de comprimento, um callback de leitura e um parâmetro de utilizador opaco. O ponto de entrada que o consome é FPDF_LoadCustomDocument. Uma vez que o PDFium detém essa estrutura, ele analisa o trailer, localiza a tabela de referências cruzadas e, a partir de então, lê apenas o que uma determinada operação requer. Abrir o documento toca na cauda do ficheiro e numa mão cheia de objetos de catálogo. A renderização da página 400 lê os fluxos de conteúdo e os recursos para essa página e nada mais

Esta é a diferença entre uma carga com buffer e uma carga em streaming. Uma carga com buffer lê o ficheiro de ponta a ponta antes que o PDFium veja o byte zero. Uma carga em streaming inverte a relação: o PDFium conduz as leituras, e os bytes que nunca são tocados nunca são lidos. Para um ficheiro com vários gigabytes visualizado uma página de cada vez, esse é o fosso entre uma carga inutilizável e uma instantânea

O adaptador de stream

O adaptador que faz a ponte entre uma TStream Delphi e a FPDF_FILEACCESS é o TPdfStreamAdapter. O seu construtor recebe a stream e uma flag de propriedade, captura o comprimento da stream uma vez, preenche o registo FPDF_FILEACCESS e liga o callback de leitura. Quando o PDFium faz posteriormente a chamada com um deslocamento (offset) e um tamanho, o adaptador procura a stream nesse deslocamento e copia exatamente esse intervalo 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 liberta a stream. Passe False e o chamador guarda a stream e deve mantê-la viva durante toda a vida útil do documento. Passe True e o adaptador assume o controlo, libertando a stream quando o documento fechar. De qualquer forma, a stream tem de sobreviver a todas as leituras que o PDFium irá executar, porque o PDFium mantém o ponteiro FPDF_FILEACCESS e irá fazer o callback em qualquer altura em que o documento estiver aberto, e não apenas durante a carga 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 transporta um argumento Self oculto que um chamador C desconhece e nunca fornecerá. Portanto, o adaptador declara o callback como uma class function marcada como cdecl; static, que compila para uma função autónoma com o layout do frame C que o PDFium espera e sem nenhum Self implícito

Isso resolve a convenção de chamada, mas levanta uma segunda questão: sem Self, como é que o callback chega à stream específica da qual supostamente vai ler? A resposta é o parâmetro de utilizador opaco. Quando o adaptador constrói o registo, armazena o seu próprio ponteiro de instância em m_Param. O PDFium devolve esse mesmo ponteiro como primeiro argumento de cada callback. A função estática converte-o de volta num TPdfStreamAdapter e despacha a leitura nessa stream da instância. Este é 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 necessita de um guarda

É daqui que vem o limite enunciado na introdução. O campo de comprimento m_FileLen em FPDF_FILEACCESS é um valor não assinado de 32 bits. O seu comprimento máximo representável é um byte a menos de 4 GiB. Uma TStream reporta o seu tamanho como Int64, de modo que uma stream pode descrever muito mais bytes do que o campo consegue reter. A partir do momento em que o tamanho de uma stream excede esse teto, não há maneira honesta de dizer ao PDFium quão longo é o ficheiro

A resposta errada é atribuir o tamanho e deixá-lo dar a volta. Truncar um comprimento de 5 GiB para um campo de 32 bits produz um número pequeno e com aspeto plausível, e o PDFium analisará então o ficheiro acreditando que termina ao fim de cerca de um gigabyte. O trailer e a tabela de referências cruzadas vivem no final real do ficheiro, muito além do comprimento truncado, de modo que a análise falha de uma forma que nada tem a ver com a causa real. Estaria a depurar um erro de referência cruzada num ficheiro que é perfeitamente válido, sem nenhuma indicação de que um número inteiro deu a volta duas camadas acima

O adaptador recusa antes a entrada. O construtor compara o tamanho da stream com High(FPDF_DWORD) e levanta EPdfError no instante em que a stream é demasiado grande para a descrever. Um erro imediato e explícito nomeia o verdadeiro problema no ponto de construção. Um truncamento silencioso oculta-o por trás de um sintoma enganador que iria perseguir muito mais tarde. O limite de 4 GiB é uma limitação genuína desta via de carregamento, e o mais sensato é exibi-lo ruidosamente em vez de o cobrir com aritmética que por acaso até compila. Quando um arquivo cruza verdadeiramente a linha, os remédios prometidos no topo encontram-se fora desta API: dividir a digitalização em ficheiros por volume que fiquem cada um abaixo do limite, ou deixar o documento no disco e servi-lo através de uma conceção de acesso direto construída em deslocamentos de 64 bits em vez de ser através de FPDF_FILEACCESS

As falhas não devem cruzar a fronteira

Uma leitura pode falhar. A stream pode ser um objeto com apoio de rede cujo tempo limite expira, um identificador blob que foi fechado por baixo de si ou um ficheiro 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 o sucesso, zero para a falha. Trata-se de uma frame C e não possui qualquer mecanismo para detetar ou propagar uma exceção de Pascal

É por isso que o trampolim envolve a procura e a leitura num try/except que engole a exceção e devolve zero. Se uma exceção Delphi pudesse propagar-se a partir do callback, ela iria desenrolar-se através das stack frames cdecl do PDFium, que nunca foram construídas para serem desenroladas pela máquina de exceções do Pascal. O resultado é um comportamento indefinido na melhor das hipóteses, ou uma falha grave na pior das hipóteses, nas profundezas do analisador do PDF sem qualquer pilha utilizável. Devolver zero mantém a falha dentro do contrato. O PDFium vê uma falha de leitura do bloco, aborta a operação sem deixar rasto, e o FPDF_LoadCustomDocument reporta que o documento não pôde ser carregado, o que o componente exterioriza como um EPdfError do lado do Pascal onde pertence

Abrir um documento desta forma

O método do componente que aciona o caminho de streaming é o LoadCustomDocument, declarado como um método distinto em vez de outra sobrecarga do LoadDocument, de modo a que a passagem de um TMemoryStream nunca caia acidentalmente no caminho tamponado. Constrói o adaptador, chama 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 uma TMemoryStream, para um fluxo blob a partir de um conjunto de dados de uma base de dados, ou para um descendente TStream personalizado. O carregamento a pedido justifica o seu papel quando o ficheiro é grande e apenas será lida uma parte dele: um visualizador de arquivo, um gerador de miniaturas que avalia algumas páginas, um índice de pesquisa que retira uma página de cada vez. Quando o ficheiro é pequeno ou quando, de qualquer modo, vai lê-lo na íntegra, um carregamento com memória intermédia é mais simples e o mecanismo de fluxo não lhe traz qualquer vantagem. O fator decisivo é a proporção de bytes em que irá efetivamente tocar face aos bytes que o ficheiro contém

Quando o streaming das páginas é efetuado a pedido, a preocupação seguinte é manter a resposta nas páginas renderizadas à medida que o utilizador amplia ou faz o deslocamento das mesmas, aspeto abrangido na nossa nota sobre a cache de renderização e o desempenho da ampliação (zoom). Quando o documento transmitido é aquele que um visualizador deve apresentar, mas não permitir que o utilizador exporte ou modifique, as técnicas no passo a passo de pré-visualização de PDF seguro associam-se de forma natural a esta via de carregamento. Ambas são baseadas na carga via stream aqui descrita, fornecida como parte do Componente PDFium para Delphi e C++Builder, em conjunto com as APIs de renderização, extração de texto e de anotação abrangidas noutras rubricas deste blogue