Artículo técnico

Zlib fragmentado en FPC: FlateDecode exige un solo stream

Antes de la v3.539.24, el build de PDF Library for Delphi para Free Pascal comprimía los streams grandes en fragmentos de 64 KB y le daba a cada fragmento su propio header y checksum de zlib, así que un adjunto de 1 MiB terminaba siendo dieciséis miembros zlib pegados de punta a punta. FlateDecode de ISO 32000-1 espera exactamente un stream de zlib, y un decoder estándar se detiene en el primer marcador de fin de stream, o sea que todo lo que venía después de los primeros 65,536 bytes desaparecía en silencio. El fix mantiene un único estado de zlib vivo a través de todos los fragmentos, tanto en DeflateStream como en InflateStream

El bug solo existía del lado FPC de la unidad, y solo en el camino de streaming por fragmentos, que es exactamente por qué sobrevivió: la rama Delphi siempre estuvo bien, los helpers basados en strings siempre estuvieron bien, y los payloads chicos de prueba jamás llegaban a tocar el código por fragmentos. Es primo directo de los defectos de cinco bugs de porting a FPC que Delphi había estado ocultando, solo que aquí el build Delphi no estaba tapando nada. La rama FPC simplemente se había escrito sobre un modelo mental equivocado de lo que es un stream de zlib

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

Porque un stream de zlib según lo define RFC 1950 es un contenedor, no una secuencia de ellos, y un inflater que cumple el estándar toma el primer trailer de Adler-32 como el fin de los datos. El formato es un header de dos bytes, un bit stream deflate de RFC 1951 continuo 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 malo, así que no levanta ningún error, y los bytes después del trailer simplemente se ignoran. Los miembros concatenados son una idea legítima en gzip (RFC 1952 permite varios miembros en un archivo), de ahí probablemente salió la intuición, pero zlib no tiene ninguna regla así y PDF nunca pidió una

Anatomía del miembro zlib de RFC 1950 en los streams FlateDecode de PDFlibPas: un header de dos bytes, un bit stream 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 cualquier entrada restante sin leer en avail_in sin levantar error
Un inflater conforme toma el primer trailer Adler-32 como el fin de los datos, así que un límite de miembro es una parada dura y todo lo que el escritor pegó después es peso muerto que ningún lector va a decodificar

El viejo DeflateStream de FPC leía la fuente en bloques de 64 KB y le entregaba cada fragmento a ZFPCCompress, un helper que corre su propio deflateInit2, comprime con Z_FINISH y llama a deflateEnd. Cada fragmento salía entonces como un stream de zlib completo, válido y auto-terminado, y la función los concatenaba en un solo AnsiString antes de escribirlo. El resultado parecía data Flate, tenía un header correcto y decodificaba sin error, pero solo decodificaba hasta los primeros 65,536 bytes. Del lado de la lectura, InflateStream tenía el defecto espejado: llamaba a ZFPCInflate una vez por cada 64 KB de entrada comprimida, y cada llamada arrancaba un inflateInit2 nuevo. El segundo fragmento empieza en el medio de un bit stream deflate sin header de zlib, así que un inflater nuevo lo rechaza, y un stream perfectamente normal de un solo miembro venido de cualquier otro productor se decodificaba solo hasta donde alcanzaban sus primeros 64 KB de bytes comprimidos

Defecto del escritor por fragmentos en el DeflateStream de FPC de PDFlibPas: cada fragmento de 64 KB pasa por ZFPCCompress como un miembro zlib completo, así que 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 reportando éxito
El daño quedó invisible porque todas las capas tuvieron éxito: el diccionario del adjunto anunciaba el /Params /Size completo, la decodificación no levantó error, y solo quien abriera el adjunto notaba el truncamiento
// DeflateStream de FPC antes de v3.539.24 (simplificado):
// ZFPCCompress hace deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// así que cada fragmento de 64 KB se vuelve un miembro 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));

¿Qué llamadas de PDF Library for Delphi llegaban al camino por fragmentos?

En FPC, cualquier embedded file de 1 MiB o más se escribía mal, y cualquier stream Flate extraído por la API de streaming se leía mal en cuanto su tamaño comprimido superaba un fragmento. TPDFStream.ReadFromStream decide cómo codificar los datos de entrada. Cuando Deflate es true y Stream.Size >= 1048576, hace streaming vía DeflateStream; por debajo de ese umbral lee toda la fuente a memoria y llama a DeflateStr, el helper de una sola pasada que nunca se vio afectado. La cadena de filtros ASCII85 más Flate se salta el test de tamaño y siempre pasa por DeflateStream, así que en ese camino cualquier payload de más de 64 KB ya quedaba partido en varios miembros. Los puntos de entrada públicos que alimentan a ReadFromStream con compresión activada son los escritores de embedded files:

  • TPDFlib.EmbedFile y TPDFlib.AddEmbeddedFile, que leen un archivo del disco hacia un stream /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream y TPDFlib.AddAssociatedFileFromFile, los escritores de associated files de PDF/A-3 usados para XML de facturación electrónica y otros datos fuente
  • Del lado de la lectura, TPDFlib.GetEmbeddedFileContentToStream y GetEmbeddedFileContentToFile, que decodifican vía TPDFStream.WriteDecodedToStream y de ahí vía InflateStream

El fallo fue silencioso en todas las capas. El escritor guarda /Params /Size y un /CheckSum MD5 calculado del archivo original, así que el diccionario del adjunto anunciaba el tamaño completo mientras el stream guardaba dieciséis miembros. Un build Delphi de la misma librería leyendo ese archivo se detenía limpiamente en el primer Z_STREAM_END y devolvía exactamente 65,536 bytes. GetEmbeddedFileContentToStream devolvía 1, porque reporta si la decodificación levantó un error, no si la salida coincide con /Size. Quien haya perseguido un problema de documentos grandes a través de unir y partir PDFs de gigabytes conoce este patrón: el archivo abre, el conteo de páginas da bien, y el daño solo aparece cuando alguien abre el adjunto

Un estado deflate para todos los fragmentos

El DeflateStream corregido en PDFlibZLib.pas inicializa un paszlib.TZStream, le pasa cada fragmento 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 un header, un bit stream deflate cuyas back-references pueden cruzar los límites entre fragmentos, y un Adler-32 sobre toda la entrada. La rama FPC ahora tiene la misma estructura que la rama Delphi siempre tuvo. Además escribe la salida a medida que se produce, en lugar de concatenar primero todo el resultado comprimido en un AnsiString, así que el escritor ya no arma una segunda copia completa de los datos comprimidos en memoria antes de copiarla al destino

DeflateStream corregido en PDFlibPas: un paszlib.TZStream inicializado una vez, cada fragmento de 64 KB alimentado con Z_NO_FLUSH y un drenaje final con Z_FINISH, produciendo exactamente un header, un bit stream deflate continuo cuyas back-references cruzan los límites entre fragmentos y un Adler-32 sobre toda la entrada
Un único estado también cambia cómo se escribe la salida: los bytes comprimidos salen a medida que cada buffer se llena en vez de acumularse en una segunda copia completa, y PLDeflateLevel ahora también alcanza a los embedded files grandes en FPC
// DeflateStream de FPC desde v3.539.24 (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 solo 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 recibió la reescritura simétrica: un inflateInit2, un bucle interno que sigue llamando a inflate hasta consumir el fragmento actual y que el buffer de salida deje de estar lleno, y una parada en Z_STREAM_END. Hay un efecto secundario que conviene conocer. El viejo escritor por fragmentos tenía hardcodeado el nivel 6, mientras que el nuevo respeta PLDeflateLevel, así que un nivel fijado vía TPDFlib.SetCompressionLevel(1..9) ahora también aplica a los embedded files grandes en FPC. Eso importa si usted ya ajusta la compresión para documentos de archivo como se describe en reducir el tamaño de archivos PDF en Delphi

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

Infléelo con un decoder zlib de los de siempre y verifique dos cosas cuando devuelva Z_STREAM_END: que la longitud decodificada iguale la longitud de la fuente, y que avail_in sea cero. Entrada sobrante después del marcador de fin es la firma de un stream concatenado. El fix se verificó así: 200 KB de datos de prueba, que abarcan cuatro fragmentos de 64 KB, pasaron por el nuevo DeflateStream y salieron como un stream zlib único de 534 bytes, un decoder zlib de serie recuperó los 200,000 bytes completos sin entrada restante, y el mismo chequeo pasó en el target FPC cross-compilado a i386. La rutina de abajo es la versión FPC de ese chequeo, construida directamente sobre paszlib para no confiar en el código bajo prueba

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;

// Empuje 200,000 bytes por DeflateStream con el fragmento de 64 KB por defecto
// y exija un solo miembro que decodifique de vuelta 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 usted: compare lo que sale de un adjunto con el /Params /Size registrado cuando entró. GetEmbeddedFileIntProperty con el tag 5 devuelve ese tamaño registrado, los índices de embedded files son base 1, y el payload necesita al menos 1 MiB para ejercitar el camino de streaming. Corra el mismo test en cada compilador con el que distribuye, dado que el defecto original pasaba en Delphi y fallaba 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 de DeflateStream por fragmentos 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;

¿Qué no cambia con el fix?

La rama FPC conserva sus reglas de decodificación permisivas, y no repara archivos que builds FPC anteriores ya escribieron. Cuando se alcanza MaxOutput, el InflateStream de FPC trunca en el límite y devuelve, mientras que la rama Delphi levanta ERangeError, y FPC sigue aceptando salida parcialmente decodificada cuando inflate reporta un error de datos, porque algunos productores de PDF emiten streams truncados o con checksum roto. Un PDF escrito por un build FPC anterior a v3.539.24 todavía contiene miembros concatenados, y el lector corregido, igual que cualquier otro lector, se detiene en el primer Z_STREAM_END. No intente curar un archivo así decodificando y re-codificando el stream dentro de la librería, porque eso solo vuelve permanente el truncamiento de 64 KB. Re-inserte el adjunto desde su fuente original mejor. El bucle FPC además sigue terminando en la primera lectura que devuelve menos que un fragmento completo, lo cual solo es señal de fin de datos para streams como TFileStream y TMemoryStream, así que un TStream custom pasado a AddAssociatedFileFromStream es más seguro copiarlo primero a un TMemoryStream. La rama Delphi, los helpers DeflateStr e InflateStr, y todo stream de menos de 1 MiB en el camino Flate pelado se comportan exactamente igual que antes

El DeflateStream y el InflateStream de FPC corregidos llegan en la v3.539.24 de PDF Library for Delphi, que apunta a Delphi, C++Builder y Free Pascal desde un solo árbol de fuentes, y donde un adjunto grande ahora debería volver de un build FPC byte por byte, de la misma manera en que siempre volvió de Delphi