Artículo técnico

zlib por bloques en FPC: por qué FlateDecode exige un stream

Un writer de FlateDecode por bloques sobre Free Pascal tiene que mantener un único estado zlib vivo a través de todos los bloques. El atajo tentador, comprimir cada bloque de 64 KB por su cuenta, da a cada bloque su propia cabecera y checksum zlib, así que un adjunto de 1 MiB se convierte en dieciséis miembros zlib pegados de extremo a extremo. El FlateDecode de ISO 32000-1 espera exactamente un stream zlib, y un decodificador estándar se detiene en el primer marcador de fin de stream, lo que significa que todo lo que va después de los primeros 65.536 bytes desaparece en silencio. PDF Library for Delphi mantiene un único estado zlib vivo en todos los bloques, tanto en DeflateStream como en InflateStream

La trampa solo existe en la parte FPC de una unit multiplataforma, y únicamente en el camino de streaming por bloques, que es exactamente la razón por la que resulta fácil pasarla por alto: la rama Delphi y los helpers basados en strings producen cada uno un único stream zlib, y los payloads de test pequeños nunca llegan a tocar el código de bloques. Es primo hermano de las trampas de cinco peligros de portado a FPC que Delphi suele esconder, solo que aquí Delphi no está tapando nada. El atajo simplemente se apoya en un modelo mental equivocado de lo que es un stream zlib

¿Por qué otros lectores de PDF truncan un stream zlib por bloques?

Porque un stream zlib según lo define el RFC 1950 es un contenedor, no una secuencia de ellos, y un inflater conforme trata el primer trailer Adler-32 como el final de los datos. El formato son dos bytes de cabecera, un bitstream deflate continuo del RFC 1951 cuyo último bloque lleva el flag de bloque final, y un checksum Adler-32 de cuatro bytes sobre todos los bytes sin comprimir. ISO 32000-1 §7.4.4 define /FlateDecode exactamente en esos términos. Cuando inflate llega al trailer devuelve Z_STREAM_END y deja cualquier entrada restante sin leer en avail_in. Desde su punto de vista no pasa nada raro, así que no lanza ningún error, y los bytes posteriores al trailer simplemente se ignoran. Los miembros concatenados son una idea legítima en gzip (el RFC 1952 permite varios miembros en un archivo), y probablemente de ahí viene la intuición, pero zlib no tiene ninguna regla así y PDF nunca pidió una

Anatomía del miembro zlib del RFC 1950 en streams FlateDecode de PDFlibPas: dos bytes de cabecera, un bitstream deflate continuo cuyo último bloque lleva el flag de bloque final, y un trailer Adler-32 de cuatro bytes donde inflate devuelve Z_STREAM_END, dejando la entrada restante sin leer en avail_in sin lanzar ningún error
Un inflater conforme trata el primer trailer Adler-32 como el final de los datos, así que un borde de miembro es un tope duro y todo lo que un writer pegue detrás es peso muerto que ningún lector va a decodificar

El DeflateStream ingenuo de FPC lee la fuente en tandas de 64 KB y le pasa cada bloque a ZFPCCompress, un helper que ejecuta su propio deflateInit2, comprime con Z_FINISH y llama a deflateEnd. Cada bloque sale por tanto como un stream zlib completo, válido y autoterminado, y la función los concatena en un AnsiString antes de escribirlo. El resultado parece datos Flate, tiene una cabecera correcta y se decodifica sin error, pero solo se decodifican los primeros 65.536 bytes. El lado de lectura tiene la trampa espejo: llamar a ZFPCInflate una vez por cada 64 KB de entrada comprimida arranca un inflateInit2 nuevo cada vez. El segundo bloque empieza en medio de un bitstream deflate sin cabecera zlib, así que un inflador nuevo lo rechaza, y un stream normal de un solo miembro producido por cualquiera se decodifica solo hasta donde lleguen sus primeros 64 KB de bytes comprimidos

Trampa del writer por bloques en FPC: cuando cada bloque de 64 KB pasa por ZFPCCompress como un miembro zlib completo, un adjunto de 1 MiB guarda dieciséis miembros pegados, un lector se detiene en el primer trailer tras 65.536 bytes, y GetEmbeddedFileContentToStream sigue informando de éxito
El daño queda invisible porque todas las capas tienen éxito: el diccionario del adjunto anuncia el /Params /Size completo, la decodificación no da error, y solo quien abre el adjunto se percata del truncamiento
// DeflateStream FPC por bloques ingenuo (simplificado):
// ZFPCCompress hace deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// así que cada bloque de 64 KB se convierte en un miembro zlib aparte
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));

¿Qué llamadas de PDF Library for Delphi llegan al camino de bloques?

En FPC, el camino de bloques escribe todo archivo incrustado de 1 MiB o más, y lee todo stream Flate extraído por la API de streaming en cuanto su tamaño comprimido pasa de un bloque. TPDFStream.ReadFromStream decide cómo codificar los datos de entrada. Cuando Deflate es true y Stream.Size >= 1048576 transmite por DeflateStream; por debajo de ese umbral lee la fuente entera en memoria y llama a DeflateStr, el helper de un solo disparo que comprime todo en una llamada. La cadena de filtros ASCII85 más Flate se salta el test de tamaño y pasa siempre por DeflateStream, así que en ese camino cualquier payload de más de 64 KB ya atraviesa varios bloques. Los puntos de entrada públicos que alimentan ReadFromStream con la compresión activada son los writers de archivos incrustados:

  • TPDFlib.EmbedFile y TPDFlib.AddEmbeddedFile, que leen un archivo de disco a un stream /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream y TPDFlib.AddAssociatedFileFromFile, los writers de associated-file de PDF/A-3 que se usan para el XML de factura electrónica y otros datos fuente
  • Del lado de la lectura, TPDFlib.GetEmbeddedFileContentToStream y GetEmbeddedFileContentToFile, que decodifican vía TPDFStream.WriteDecodedToStream y desde ahí vía InflateStream

Equivocarse con esto falla en silencio en todas las capas. El writer guarda /Params /Size y un /CheckSum MD5 calculado del archivo original, así que el diccionario del adjunto anuncia el tamaño completo aunque el stream guarde dieciséis miembros. Cualquier lector conforme, incluido un build Delphi de la librería, se detiene limpiamente en el primer Z_STREAM_END y devuelve exactamente 65.536 bytes. GetEmbeddedFileContentToStream devuelve 1, porque informa de si la decodificación dio error, no de si la salida coincide con /Size. Quien haya perseguido un problema de documentos grandes por los fusiones y divisiones de PDFs de varios gigas conoce este patrón: el archivo abre, el conteo de páginas es correcto, y el daño solo aparece cuando alguien abre el adjunto

Un solo estado deflate para todos los bloques

El DeflateStream de PDFlibZLib.pas inicializa un paszlib.TZStream, alimenta cada bloque a deflate con Z_NO_FLUSH, y solo al final drena el compresor con Z_FINISH hasta que devuelve Z_STREAM_END. Eso produce exactamente una cabecera, un bitstream deflate cuyas back-references pueden cruzar los bordes de bloque, y un Adler-32 sobre toda la entrada. La rama FPC tiene la misma estructura que la rama Delphi. Además escribe la salida según se produce, en lugar de concatenar antes todo el resultado comprimido en un AnsiString, así que el writer nunca construye una segunda copia completa de los datos comprimidos en memoria antes de copiarlos al destino

DeflateStream de estado único en PDFlibPas: un paszlib.TZStream inicializado una vez, cada bloque de 64 KB alimentado con Z_NO_FLUSH y un drenaje final con Z_FINISH, lo que produce exactamente una cabecera, un bitstream deflate continuo cuyas back-references cruzan los bordes de bloque y un Adler-32 sobre toda la entrada
Un único estado cambia también cómo se escribe la salida: los bytes comprimidos salen según se llena cada buffer en lugar de acumularse en una segunda copia completa, y PLDeflateLevel llega también a los archivos incrustados grandes en FPC
// DeflateStream FPC con un único estado zlib (caminos de error recortados)
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);   // mismo estado, sin corte de miembro
        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                                    // un trailer para toda la entrada
    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;

InflateStream sigue el mismo patrón: un inflateInit2, un bucle interior que sigue llamando a inflate hasta que el bloque actual se consume y el buffer de salida deja de estar lleno, y una parada en Z_STREAM_END. Hay un efecto secundario que conviene conocer. Como el writer llama a deflateInit2 por sí mismo en lugar de pasar por un helper de nivel fijo, honra PLDeflateLevel, así que un nivel fijado mediante TPDFlib.SetCompressionLevel(1..9) también se aplica a los archivos incrustados grandes en FPC. Eso importa si ya ajustas la compresión para salida de archivo como se describe en reducir el tamaño de archivo PDF en Delphi

¿Cómo verificar que un stream Flate es un único miembro zlib?

Pásalo por un decodificador zlib corriente y comprueba dos cosas cuando devuelva Z_STREAM_END: que la longitud decodificada es igual a la de la fuente, y que avail_in vale cero. Entrada sobrante después del marcador de fin es la firma de un stream concatenado. La rutina de abajo es la versión FPC de esa comprobación, construida directamente sobre paszlib para que no confíe en el código bajo test, seguida de una sonda que empuja 200.000 bytes, cuatro bloques de 64 KB, por DeflateStream

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;
    // Un miembro termina exactamente en el último byte de entrada
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Pasar 200.000 bytes por DeflateStream con el bloque por defecto de 64 KB
// y exigir un único miembro que se decodifique a la longitud completa
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');

A nivel de aplicación, la aserción útil es la que la librería no hace por ti: compara lo que sale de un adjunto con el /Params /Size registrado cuando entró. GetEmbeddedFileIntProperty con tag 5 devuelve ese tamaño registrado, los índices de archivos incrustados son 1-based, y el payload necesita al menos 1 MiB para ejercitar el camino de streaming. Ejecuta el mismo test en cada compilador con el que distribuyas, ya que esta clase de equivocación pasa en Delphi y falla solo en 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 o más toma el camino DeflateStream por bloques en 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;

Casos especiales en la rama FPC

La rama FPC usa reglas de decodificación indulgentes. Cuando se alcanza MaxOutput, el InflateStream de FPC trunca en el límite y devuelve, mientras que la rama Delphi lanza ERangeError, y FPC sigue aceptando salida parcialmente decodificada cuando inflate informa de un error de datos, porque algunos productores de PDF emiten streams truncados o con el checksum roto. Un PDF que ya contiene miembros concatenados, lo que sea que lo escribiera, se detiene en el primer Z_STREAM_END en todos los lectores. No intentes sanar semejante archivo decodificando y recodificando el stream dentro de la librería, porque eso solo hace permanente el truncamiento de 64 KB. Vuelve a incrustar el adjunto desde su fuente original. El bucle FPC trata una lectura que devuelve menos de un bloque completo como el fin de los datos, lo que se cumple en streams como TFileStream y TMemoryStream, así que un TStream propio que pases a AddAssociatedFileFromStream es más seguro copiarlo antes a un TMemoryStream

El DeflateStream y el InflateStream FPC de estado único llegan en PDF Library for Delphi, que apunta a Delphi, C++Builder y Free Pascal desde un único árbol de fuentes, así que un adjunto grande vuelve de un build FPC byte a byte, igual que lo hace de Delphi