Artigo Técnico

zlib em blocos no FPC: um único stream para o FlateDecode

Antes da v3.539.24, a build Free Pascal da PDF Library for Delphi comprimia os streams grandes em blocos de 64 KB e dava a cada bloco o seu próprio cabeçalho e checksum zlib, pelo que um anexo de 1 MiB ficava reduzido a dezasseis membros zlib colados ponta a ponta. O FlateDecode da ISO 32000-1 espera exatamente um stream zlib, e um decoder em conformidade para no primeiro marcador de fim de stream, o que significa que tudo o que vinha depois dos primeiros 65.536 bytes desaparecia em silêncio. A correção mantém um único estado zlib vivo ao longo de todos os blocos, tanto no DeflateStream como no InflateStream

O bug só existia do lado FPC da unit, e só no caminho de streaming por blocos, o que é precisamente a razão por que sobreviveu: o ramo Delphi esteve sempre certo, os helpers baseados em strings estiveram sempre certos, e as cargas de teste pequenas nunca chegavam ao código por blocos. É um parente próximo dos defeitos descritos em cinco bugs de porting para FPC que o Delphi andava a esconder, com a diferença de que aqui a build Delphi não estava a cobrir nada. O ramo FPC é que tinha sido escrito a partir de um modelo mental errado do que é um stream zlib

Porque é que um stream zlib em blocos fica truncado nos outros leitores de PDF?

Porque um stream zlib à luz do RFC 1950 é um contentor, e não uma sequência deles, e um inflater em conformidade trata o primeiro trailer Adler-32 como o fim dos dados. O formato é um cabeçalho de dois bytes, um bit stream deflate contínuo cujo último bloco carrega a flag de bloco final, e um checksum Adler-32 de quatro bytes sobre todos os bytes descomprimidos. A ISO 32000-1 §7.4.4 define o /FlateDecode nesses termos exactos. Quando o inflate chega ao trailer devolve Z_STREAM_END e deixa o input restante por ler em avail_in. Do ponto de vista dele nada está errado, por isso não levanta erro nenhum, e os bytes a seguir ao trailer são simplesmente ignorados. Membros concatenados são uma ideia legítima em gzip (o RFC 1952 permite vários membros num só ficheiro), e é provavelmente daí que veio a intuição, mas o zlib não tem regra dessas e o PDF nunca pediu uma

Anatomia de um membro zlib RFC 1950 nos streams FlateDecode do PDFlibPas: um cabeçalho de dois bytes, um bit stream deflate contínuo cujo último bloco carrega a flag de bloco final, e um trailer Adler-32 de quatro bytes onde o inflate devolve Z_STREAM_END, deixando o input restante por ler em avail_in sem erro nenhum
Um inflater em conformidade trata o primeiro trailer Adler-32 como o fim dos dados, por isso uma fronteira de membro é um ponto de paragem definitivo e tudo o que um writer colar depois é peso morto que leitor nenhum vai descodificar

O antigo DeflateStream do FPC lia a origem 64 KB de cada vez e entregava cada bloco ao ZFPCCompress, um helper que corre o seu próprio deflateInit2, comprime com Z_FINISH e chama deflateEnd. Cada bloco saía portanto como um stream zlib completo, válido e auto-terminado, e a função concatenava-os numa única AnsiString antes de a escrever. O resultado parecia dados Flate, tinha um cabeçalho correto e descodificava sem erro, mas descodificava apenas para os primeiros 65.536 bytes. Do lado da leitura, o InflateStream tinha o defeito espelhado: chamava o ZFPCInflate uma vez por cada 64 KB de input comprimido, e cada chamada começava um inflateInit2 novo. O segundo bloco começa a meio de um bit stream deflate sem cabeçalho zlib, por isso um inflater novo rejeita-o, e um stream de membro único perfeitamente normal vindo de qualquer outro produtor era descodificado apenas até onde os primeiros 64 KB de bytes comprimidos o levavam

Defeito do writer em blocos no DeflateStream FPC do PDFlibPas: cada bloco de 64 KB passa pelo ZFPCCompress como um membro zlib completo, por isso um anexo de 1 MiB guarda dezasseis membros colados, um leitor pára no primeiro trailer depois de 65.536 bytes, e o GetEmbeddedFileContentToStream ainda assim reporta sucesso
O dano manteve-se invisível porque todas as camadas tinham sucesso: o dicionário do anexo anunciava o /Params /Size completo, a descodificação não levantava erro nenhum, e só quem abria o anexo é que repara no truncamento
// DeflateStream FPC antes da v3.539.24 (simplificado):
// o ZFPCCompress faz deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// por isso cada bloco de 64 KB vira um membro zlib separado
Repeat
  ReadCount:= Source.Read(Input[1], ChunkSize);
  If (ReadCount> 0) Then
    Compressed:= Compressed+ ZFPCCompress(Copy(Input, 1, ReadCount), 6, False);
Until (ReadCount< ChunkSize);
If (Compressed<> '') Then
  Target.WriteBuffer(PAnsiChar(Compressed)^, Length(Compressed));

Que chamadas da PDF Library for Delphi chegavam ao caminho por blocos?

Em FPC, qualquer ficheiro incorporado de 1 MiB ou mais era escrito mal, e qualquer stream Flate extraído através da API de streaming era lido mal assim que o seu tamanho comprimido passava um bloco. O TPDFStream.ReadFromStream decide como codificar os dados que entram. Quando Deflate é true e Stream.Size >= 1048576, faz passar os dados pelo DeflateStream; abaixo desse limiar lê a origem inteira para memória e chama o DeflateStr, o helper de tiro único que nunca foi afetado. A cadeia de filtros ASCII85 mais Flate salta o teste de tamanho e passa sempre pelo DeflateStream, por isso nesse caminho qualquer carga maior que 64 KB já ia dividida em vários membros. Os pontos de entrada públicos que alimentam o ReadFromStream com a compressão ligada são os writers de ficheiros incorporados:

  • TPDFlib.EmbedFile e TPDFlib.AddEmbeddedFile, que leem um ficheiro do disco para um stream /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream e TPDFlib.AddAssociatedFileFromFile, os writers de associated-file do PDF/A-3 usados para XML de e-fatura e outros dados de origem
  • Do lado da leitura, TPDFlib.GetEmbeddedFileContentToStream e GetEmbeddedFileContentToFile, que descodificam através do TPDFStream.WriteDecodedToStream e daí pelo InflateStream

A falha era silenciosa em todas as camadas. O writer guarda o /Params /Size e um /CheckSum MD5 calculado a partir do ficheiro original, por isso o dicionário do anexo anunciava o tamanho completo enquanto o stream guardava dezasseis membros. Uma build Delphi da mesma biblioteca, ao ler esse ficheiro, parava limparamente no primeiro Z_STREAM_END e devolvia exatamente 65.536 bytes. O GetEmbeddedFileContentToStream devolvia 1, porque reporta se a descodificação levantou um erro, e não se o output corresponde ao /Size. Quem já perseguiu um problema de documentos grandes através da fusão e divisão de PDFs de gigabytes conhece este padrão: o ficheiro abre, a contagem de páginas está certa, e o dano só aparece quando alguém abre o anexo

Um estado deflate para todos os blocos

O DeflateStream corrigido no PDFlibZLib.pas inicializa um único paszlib.TZStream, alimenta cada bloco ao deflate com Z_NO_FLUSH, e só no fim drena o compressor com Z_FINISH até este devolver Z_STREAM_END. Isso produz exatamente um cabeçalho, um bit stream deflate cujas back-references podem atravessar fronteiras de bloco, e um Adler-32 sobre todo o input. O ramo FPC passa a ter a mesma estrutura que o ramo Delphi sempre teve. Passa também a escrever o output à medida que este é produzido, em vez de primeiro concatenar todo o resultado comprimido numa AnsiString, pelo que o writer deixa de construir uma segunda cópia integral dos dados comprimidos em memória antes de os copiar para o destino

DeflateStream corrigido no PDFlibPas: um único paszlib.TZStream inicializado uma vez, cada bloco de 64 KB alimentado com Z_NO_FLUSH e uma drenagem final com Z_FINISH, produzindo exatamente um cabeçalho, um bit stream deflate contínuo cujas back-references atravessam as fronteiras de bloco e um Adler-32 sobre todo o input
Um único estado muda também a forma de escrever o output: os bytes comprimidos saem à medida que cada buffer enche em vez de se acumularem numa segunda cópia integral, e o PLDeflateLevel passa a chegar aos ficheiros incorporados grandes também em FPC
// DeflateStream FPC a partir da v3.539.24 (caminhos de erro cortados)
If (deflateInit2(strm, Level, Z_DEFLATED, 15, 8, Z_DEFAULT_STRATEGY)<> Z_OK) Then
  Exit;
Try
  Repeat
    ReadCount:= Source.Read(Input[0], ChunkSize);
    If (ReadCount> 0) Then
    Begin
      strm.next_in:= Pointer(Input);
      strm.avail_in:= ReadCount;
      While (strm.avail_in> 0) Do
      Begin
        strm.next_out:= Pointer(Output);
        strm.avail_out:= ChunkSize;
        Status:= deflate(strm, Z_NO_FLUSH);   // mesmo estado, sem quebra de membro
        Produced:= ChunkSize- strm.avail_out;
        If (Produced> 0) Then
          Target.WriteBuffer(Output[0], Produced);
        If (Status<> Z_OK) Then
          Break;
      End;
    End;
  Until (ReadCount< ChunkSize);
  Repeat                                    // um trailer para todo o input
    strm.next_out:= Pointer(Output);
    strm.avail_out:= ChunkSize;
    Status:= deflate(strm, Z_FINISH);
    Produced:= ChunkSize- strm.avail_out;
    If (Produced> 0) Then
      Target.WriteBuffer(Output[0], Produced);
  Until (Status= Z_STREAM_END);
Finally
  deflateEnd(strm);
End;

O InflateStream levou a reescrita simétrica: um único inflateInit2, um ciclo interno que continua a chamar inflate até o bloco corrente estar consumido e o buffer de output deixar de estar cheio, e uma paragem em Z_STREAM_END. Há um efeito lateral que convém conhecer. O antigo writer por blocos tinha o nível 6 cravado no código, enquanto o novo respeita o PLDeflateLevel, por isso um nível definido através de TPDFlib.SetCompressionLevel(1..9) passa a aplicar-se também aos ficheiros incorporados grandes em FPC. Isso interessa se já afina a compressão para output de arquivo como descrito em reduzir o tamanho de ficheiros PDF no Delphi

Como verificar que um stream Flate é um único membro zlib?

Descodifique-o com um decoder zlib comum e verifique duas coisas quando ele devolve Z_STREAM_END: o comprimento descodificado é igual ao comprimento da origem, e o avail_in está a zero. Input que sobra depois do marcador de fim é a assinatura de um stream concatenado. A correção foi verificada assim: 200 KB de dados de teste, que atravessam quatro blocos de 64 KB, passaram pelo novo DeflateStream e saíram como um stream zlib único de 534 bytes, um decoder zlib de fábrica recuperou os 200.000 bytes todos sem input restante, e a mesma verificação passou no alvo FPC cross-compilado para i386. A rotina abaixo é a versão FPC dessa verificação, construída directamente sobre o paszlib para não confiar no código sob teste

uses Classes, SysUtils, paszlib, PDFlibZLib;

function IsSingleZlibMember(Packed: TMemoryStream; out Decoded: Int64): Boolean;
var
  strm: TZStream;
  Buf: array[0..65535] of Byte;
  Status: Integer;
begin
  Result:= False;
  Decoded:= 0;
  FillChar(strm, SizeOf(strm), 0);
  if inflateInit2(strm, 15) <> Z_OK then
    Exit;
  try
    strm.next_in:= Packed.Memory;
    strm.avail_in:= Cardinal(Packed.Size);
    repeat
      strm.next_out:= @Buf;
      strm.avail_out:= SizeOf(Buf);
      Status:= inflate(strm, Z_NO_FLUSH);
    until Status <> Z_OK;
    Decoded:= strm.total_out;
    // Um membro termina exatamente no último byte de input
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Passar 200.000 bytes pelo DeflateStream com o bloco de 64 KB predefinido
// e exigir um membro que descodifique de volta para o comprimento completo
Source.Position:= 0;
DeflateStream(Source, Packed);
if not (IsSingleZlibMember(Packed, Decoded) and (Decoded = Source.Size)) then
  raise Exception.Create('DeflateStream produced more than one zlib member');

Ao nível da aplicação, a asserção útil é aquela que a biblioteca não faz por si: compare o que sai de um anexo com o /Params /Size registado quando ele entrou. O GetEmbeddedFileIntProperty com tag 5 devolve esse tamanho registado, os índices de ficheiros incorporados são base 1, e a carga precisa de ter pelo menos 1 MiB para exercitar o caminho de streaming. Corra o mesmo teste em todos os compiladores com que distribui, já que o defeito original passava em Delphi e só falhava em FPC

procedure CheckLargeAttachmentRoundTrip(const PayloadFile, OutFile: string);
var
  PDF: TPDFlib;
  Extracted: TMemoryStream;
  I, Declared: Integer;
begin
  PDF:= TPDFlib.Create;
  try
    PDF.NewDocument;
    PDF.AddStandardFont(4);
    PDF.DrawText(80, 100, 'Large attachment round trip');
    // 1 MiB ou mais segue o caminho DeflateStream por blocos no ReadFromStream
    if PDF.EmbedFile('Payload', PayloadFile, 'application/octet-stream') <> 1 then
      raise Exception.Create('EmbedFile failed');
    if PDF.SaveToFile(OutFile) <> 1 then
      raise Exception.Create('SaveToFile failed');
  finally
    PDF.Free;
  end;

  PDF:= TPDFlib.Create;
  Extracted:= TMemoryStream.Create;
  try
    if PDF.LoadFromFile(OutFile, '') = 0 then
      raise Exception.Create('LoadFromFile failed');
    for I:= 1 to PDF.EmbeddedFileCount do
    begin
      Extracted.Clear;
      if PDF.GetEmbeddedFileContentToStream(I, Extracted) <> 1 then
        raise Exception.CreateFmt('Attachment %d could not be decoded', [I]);
      Declared:= PDF.GetEmbeddedFileIntProperty(I, 5);   // /Params /Size
      if Extracted.Size <> Declared then
        raise Exception.CreateFmt('Attachment %d truncated: %d of %d bytes',
          [I, Extracted.Size, Declared]);
    end;
  finally
    Extracted.Free;
    PDF.Free;
  end;
end;

O que é que a correção não muda?

O ramo FPC mantém as suas regras de descodificação lenientes, e não repara ficheiros que builds FPC anteriores já tivessem escrito. Quando o MaxOutput é atingido, o InflateStream do FPC trunca no limite e devolve, enquanto o ramo Delphi levanta ERangeError, e o FPC continua a aceitar output parcialmente descodificado quando o inflate reporta um erro de dados, porque há produtores de PDF que emitem streams truncados ou com checksum partido. Um PDF escrito por uma build FPC anterior à v3.539.24 ainda contém membros concatenados, e o leitor corrigido, à semelhança de todos os outros leitores, pára no primeiro Z_STREAM_END. Não tente curar um ficheiro desses descodificando e recodificando o stream dentro da biblioteca, porque isso apenas torna permanente o truncamento de 64 KB. Volte a incorporar o anexo a partir da sua origem original. O ciclo do FPC também continua a terminar na primeira leitura que devolve menos que um bloco completo, o que só é sinal de fim de dados para streams como TFileStream e TMemoryStream, por isso um TStream à medida passado ao AddAssociatedFileFromStream fica mais seguro copiado primeiro para um TMemoryStream. O ramo Delphi, os helpers DeflateStr e InflateStr, e todos os streams abaixo de 1 MiB no caminho Flate simples comportam-se exatamente como antes

O DeflateStream e o InflateStream FPC corrigidos saem na v3.539.24 da PDF Library for Delphi, que mira Delphi, C++Builder e Free Pascal a partir de uma única árvore de código, e onde um anexo grande deve agora voltar de uma build FPC byte a byte, da mesma forma que sempre voltou do Delphi