Artigo Técnico

Chunked zlib no FPC: FlateDecode precisa de um único stream

Antes da v3.539.24, a build Free Pascal da PDF Library for Delphi comprimia streams grandes em blocos de 64 KB e dava a cada bloco seu próprio header e checksum de zlib, então um anexo de 1 MiB virava dezesseis membros zlib colados de ponta a ponta. O FlateDecode da ISO 32000-1 espera exatamente um stream zlib, e um decoder padrão para no primeiro marcador de fim de stream, o que significa que tudo 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 quanto no InflateStream

O bug só existia do lado FPC da unit, e só no caminho de streaming em blocos, e é exatamente por isso que ele sobreviveu: o branch Delphi sempre esteve correto, os helpers baseados em string sempre estiveram corretos, e payloads de teste pequenos nunca chegavam ao código em blocos. É primo próximo dos defeitos de cinco bugs de port para FPC que o Delphi vinha escondendo, com a diferença de que aqui a build Delphi não estava cobrindo nada. O branch FPC simplesmente tinha sido escrito a partir de um modelo mental errado do que um stream zlib é

Por que um stream zlib em blocos é truncado por outros leitores de PDF?

Porque um stream zlib como definido na RFC 1950 é um container só, 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 header de dois bytes, um bit stream deflate contínuo da RFC 1951 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 exatamente nesses termos. Quando o inflate chega ao trailer ele retorna Z_STREAM_END e deixa qualquer input restante sem ler em avail_in. Do ponto de vista dele nada está errado, então ele não levanta erro nenhum, e os bytes depois do trailer são simplesmente ignorados. Membros concatenados são uma ideia legítima no gzip (a RFC 1952 permite vários membros num arquivo), e provavelmente foi daí que veio a intuição, mas o zlib não tem regra assim e o PDF nunca pediu uma

Anatomia de um membro zlib da RFC 1950 nos streams FlateDecode do PDFlibPas: um header 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 retorna Z_STREAM_END, deixando input restante sem ler em avail_in sem levantar erro nenhum
Um inflater em conformidade trata o primeiro trailer Adler-32 como o fim dos dados, então a fronteira de um membro é um ponto de parada duro, e tudo que o writer colou depois disso é peso morto que nenhum leitor vai decodificar

O antigo DeflateStream do FPC lia a fonte 64 KB por vez e entregava cada bloco ao ZFPCCompress, um helper que roda o próprio deflateInit2, comprime com Z_FINISH e chama deflateEnd. Cada bloco saía então como um stream zlib completo, válido e auto-terminado, e a função concatenava tudo num único AnsiString antes de escrever. O resultado parecia dado Flate, tinha header correto e decodificava sem erro, mas decodificava apenas os primeiros 65.536 bytes. Do lado da leitura, o InflateStream tinha o defeito espelhado: ele chamava o ZFPCInflate uma vez por 64 KB de input comprimido, e cada chamada iniciava um inflateInit2 novo. O segundo bloco começa no meio de um bit stream deflate, sem header de zlib, então um inflater novo o rejeita, e um stream de membro único perfeitamente normal vindo de qualquer outro produtor era decodificado só até onde os primeiros 64 KB de bytes comprimidos o levavam

Defeito do writer em blocos no DeflateStream do FPC do PDFlibPas: cada bloco de 64 KB passa pelo ZFPCCompress como um membro zlib completo, então um anexo de 1 MiB contém dezesseis membros colados, o leitor para no primeiro trailer depois de 65.536 bytes, e o GetEmbeddedFileContentToStream ainda reporta sucesso
O dano ficou invisível porque toda camada teve sucesso: o dicionário do anexo anunciava o /Params /Size completo, a decodificação não levantava erro nenhum, e só quem abria o anexo percebia o truncamento
// DeflateStream do FPC antes da v3.539.24 (simplificado):
// o ZFPCCompress faz deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// então 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));

Quais chamadas da PDF Library for Delphi chegavam ao caminho em blocos?

No FPC, qualquer embedded file de 1 MiB ou mais era escrito errado, e qualquer stream Flate extraído pela API de streaming era lido errado assim que o tamanho comprimido passava de um bloco. O TPDFStream.ReadFromStream decide como codificar os dados que chegam. Quando Deflate é true e Stream.Size >= 1048576, ele faz streaming pelo DeflateStream; abaixo desse limiar ele lê a fonte inteira na memória e chama o DeflateStr, o helper single-shot que nunca foi afetado. A cadeia de filtros ASCII85 mais Flate pula o teste de tamanho e sempre passa pelo DeflateStream, então nesse caminho qualquer payload maior que 64 KB já saía dividido em vários membros. Os pontos de entrada públicos que alimentam o ReadFromStream com compressão ligada são os writers de embedded file:

  • TPDFlib.EmbedFile e TPDFlib.AddEmbeddedFile, que leem um arquivo 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-invoice e outros dados-fonte
  • Do lado da leitura, TPDFlib.GetEmbeddedFileContentToStream e GetEmbeddedFileContentToFile, que decodificam via TPDFStream.WriteDecodedToStream e daí via InflateStream

A falha foi silenciosa em todas as camadas. O writer armazena o /Params /Size e um /CheckSum MD5 calculado a partir do arquivo original, então o dicionário do anexo anunciava o tamanho completo enquanto o stream guardava dezesseis membros. Uma build Delphi da mesma biblioteca lendo esse arquivo parava limpatamente no primeiro Z_STREAM_END e devolvia exatamente 65.536 bytes. O GetEmbeddedFileContentToStream retornava 1, porque ele reporta se a decodificação levantou erro, não se o output casa com o /Size. Quem já perseguiu um problema de documento grande em merge e split de PDFs gigabyte conhece esse padrão: o arquivo abre, a contagem de páginas está certa, e o dano só aparece quando alguém abre o anexo

Um estado deflate ao longo de todos os blocos

O DeflateStream corrigido em PDFlibZLib.pas inicializa um único paszlib.TZStream, alimenta cada bloco no deflate com Z_NO_FLUSH e só no final drena o compressor com Z_FINISH até ele retornar Z_STREAM_END. Isso produz exatamente um header, um bit stream deflate cujas back-references podem atravessar fronteiras de bloco, e um Adler-32 sobre o input inteiro. O branch FPC agora tem a mesma estrutura que o branch Delphi sempre teve. Ele também escreve o output à medida que é produzido, em vez de concatenar primeiro o resultado comprimido inteiro num AnsiString, então o writer não monta mais uma segunda cópia completa dos dados comprimidos na memória antes de copiá-los 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 header, um bit stream deflate contínuo cujas back-references cruzam fronteiras de bloco e um Adler-32 sobre o input inteiro
Um único estado também muda como o output é escrito: os bytes comprimidos saem conforme cada buffer enche, em vez de se acumularem numa segunda cópia completa, e o PLDeflateLevel agora também alcança embedded files grandes no FPC
// DeflateStream do FPC a partir da v3.539.24 (caminhos de erro removidos)
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 o input inteiro
    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 ganhou a reescrita simétrica: um inflateInit2, um loop interno que segue chamando inflate até o bloco atual ser consumido e o buffer de output não estar mais cheio, e uma parada em Z_STREAM_END. Há um efeito colateral que vale conhecer. O antigo writer em blocos tinha o level 6 hard-coded, enquanto o novo honra o PLDeflateLevel, então um level definido via TPDFlib.SetCompressionLevel(1..9) agora vale também para embedded files grandes no FPC. Isso importa se você já ajusta a compressão para output de arquivamento, como descrito em reduzir o tamanho de arquivo PDF no Delphi

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

Infla com um decoder zlib comum e confere duas coisas quando ele retorna Z_STREAM_END: o tamanho decodificado é igual ao tamanho da fonte, e o avail_in está zerado. Input sobrando 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 prateleira recuperou os 200.000 bytes inteiros sem input restante, e a mesma checagem passou no target FPC cross-compilado para i386. A rotina abaixo é a versão FPC dessa checagem, construída direto 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;

// Empurra 200.000 bytes pelo DeflateStream com o chunk padrão de 64 KB
// e exige um único membro que decodifique de volta ao tamanho 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');

No nível da aplicação, a asserção útil é justamente aquela que a biblioteca não faz por você: compare o que sai de um anexo com o /Params /Size registrado na entrada. O GetEmbeddedFileIntProperty com tag 5 devolve esse tamanho registrado, os índices de embedded file são 1-based, e o payload precisa ter pelo menos 1 MiB para exercitar o caminho de streaming. Rode o mesmo teste em cada compilador com que você distribui, já que o defeito original passava no Delphi e falhava só no 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 do DeflateStream em 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 a correção não muda?

O branch FPC mantém suas regras de decodificação tolerantes, e ele não repara arquivos que builds FPC anteriores já escreveram. Quando o MaxOutput é atingido, o InflateStream do FPC trunca no limite e retorna, enquanto o branch Delphi levanta ERangeError, e o FPC ainda aceita output parcialmente decodificado quando o inflate reporta erro de dados, porque alguns produtores de PDF emitem streams truncados ou com checksum quebrado. Um PDF escrito por uma build FPC anterior à v3.539.24 ainda contém membros concatenados, e o leitor corrigido, como todo outro leitor, para no primeiro Z_STREAM_END. Não tente curar um arquivo desses decodificando e recodificando o stream dentro da biblioteca, porque isso só torna o truncamento de 64 KB permanente. Re-embeba o anexo a partir da fonte original. O loop do FPC também continua terminando na primeira leitura que devolve menos que um bloco cheio, o que só é sinal de fim de dados para streams como TFileStream e TMemoryStream, então um TStream customizado passado ao AddAssociatedFileFromStream fica mais seguro copiado para um TMemoryStream primeiro. O branch Delphi, os helpers DeflateStr e InflateStr, e todo stream menor que 1 MiB no caminho Flate puro se comportam exatamente como antes

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