Artículo técnico

Zlib fragmentado en FPC: FlateDecode exige un solo stream

Un escritor FlateDecode por fragmentos en Free Pascal tiene que mantener un solo estado de zlib vivo a través de todos los fragmentos. El atajo tentador, comprimir cada fragmento de 64 KB por su cuenta, le da a cada fragmento su propio header y checksum de zlib, así que un adjunto de 1 MiB se convierte en 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 viene después de los primeros 65,536 bytes desaparece en silencio. PDF Library for Delphi mantiene un único estado de zlib vivo a través de todos los fragmentos, tanto en DeflateStream como en InflateStream

La trampa solo existe del lado FPC de una unidad cross-compilador, y solo en el camino de streaming por fragmentos, que es exactamente por qué es fácil pasarla por alto: la rama Delphi y los helpers basados en strings producen cada uno un stream de zlib único, y los payloads chicos de prueba jamás llegan a tocar el código por fragmentos. Es primo directo de las trampas de cinco trampas de porting a FPC que Delphi suele esconder, solo que aquí Delphi no está tapando nada. El atajo simplemente descansa 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 DeflateStream de FPC ingenuo lee la fuente de a 64 KB por vez y le entrega cada fragmento a ZFPCCompress, un helper que corre su propio deflateInit2, comprime con Z_FINISH y llama a deflateEnd. Cada fragmento sale entonces como un stream de zlib completo, válido y auto-terminado, y la función los concatena en un solo AnsiString antes de escribirlo. El resultado parece data Flate, tiene un header correcto y decodifica sin error, pero solo decodifica hasta los primeros 65,536 bytes. Del lado de la lectura hay una trampa espejada: llamar a ZFPCInflate una vez por cada 64 KB de entrada comprimida arranca un inflateInit2 nuevo en cada llamada. 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 decodifica solo hasta donde alcanzan sus primeros 64 KB de bytes comprimidos

Trampa del escritor por fragmentos en FPC: cuando cada fragmento 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 reportando é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 levanta error, y solo quien abre el adjunto nota el truncamiento
// DeflateStream de FPC ingenuo (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 llegan al camino por fragmentos?

En FPC, el camino por fragmentos escribe cada embedded file de 1 MiB o más, y lee cada stream Flate extraído por la API de streaming en cuanto su tamaño comprimido supera 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 comprime todo en una llamada. 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 abarca varios fragmentos. 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

Equivocarse en esto falla en silencio en todas las capas. El escritor 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 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 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 tiene la misma estructura que la rama Delphi. 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 nunca arma una segunda copia completa de los datos comprimidos en memoria antes de copiarla al destino

DeflateStream de estado único 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 solo 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 también alcanza a los embedded files grandes en FPC
// DeflateStream de FPC con un solo estado de 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 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 sigue el mismo patrón: un inflateInit2, un bucle interno que sigue llamando a inflate hasta consumir el fragmento actual y hasta que el buffer de salida deje de estar lleno, y una parada en Z_STREAM_END. Hay un efecto secundario que conviene conocer. Como el escritor llama a deflateInit2 por su cuenta en lugar de pasar por un helper de nivel fijo, respeta PLDeflateLevel, así que un nivel fijado vía TPDFlib.SetCompressionLevel(1..9) 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. 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, seguida de una prueba que empuja 200,000 bytes, cuatro fragmentos de 64 KB, a través de 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;

// 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, ya que esta clase de error 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 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;

Casos límite en la rama FPC

La rama FPC usa reglas de decodificación permisivas. 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 que ya contiene miembros concatenados, lo que sea que lo haya escrito, se detiene en el primer Z_STREAM_END en cualquier lector. 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 trata una lectura que devuelve menos que un fragmento completo como el fin de los datos, lo cual se cumple para streams como TFileStream y TMemoryStream, así que un TStream custom pasado a AddAssociatedFileFromStream es más seguro copiarlo primero a un TMemoryStream

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