Antes de la v3.539.24, el build Free Pascal de PDF Library for Delphi comprimía los streams grandes en bloques de 64 KB y le daba a cada bloque su propia cabecera y checksum zlib, así que un adjunto de 1 MiB se convertía en dieciséis miembros zlib pegados de extremo a extremo. 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 venía después de los primeros 65.536 bytes desaparecía en silencio. El fix mantiene un único estado zlib vivo a través de todos los bloques, tanto en DeflateStream como en InflateStream
El bug solo existía en la parte FPC de la unit, y únicamente en el camino de streaming por bloques, que es exactamente la razón por la que sobrevivió: la rama Delphi siempre fue correcta, los helpers basados en strings siempre fueron correctos, y los payloads pequeños de test nunca llegaban a tocar el código de bloques. Es primo hermano de los defectos de cinco bugs de porting a FPC que Delphi llevaba tiempo tapando, con la diferencia de que aquí el build Delphi no estaba tapando nada. La rama FPC simplemente se había escrito partiendo de 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
El viejo DeflateStream de FPC leía la fuente de 64 KB en 64 KB y le pasaba cada bloque a ZFPCCompress, un helper que ejecuta su propio deflateInit2, comprime con Z_FINISH y llama a deflateEnd. Cada bloque salía por tanto como un stream zlib completo, válido y autoterminado, y la función los concatenaba en un AnsiString antes de escribirlo. El resultado parecía datos Flate, tenía una cabecera correcta y se decodificaba sin error, pero solo se decodificaban los primeros 65.536 bytes. Del lado de la lectura, InflateStream tenía el defecto espejo: llamaba a ZFPCInflate una vez por cada 64 KB de entrada comprimida, y cada llamada arrancaba un inflateInit2 nuevo. El segundo bloque empieza en medio de un bitstream deflate sin cabecera zlib, así que un inflater nuevo lo rechaza, y un stream de un solo miembro perfectamente normal de cualquier otro producer se decodificaba solo hasta donde alcanzaban sus primeros 64 KB de bytes comprimidos
// FPC DeflateStream antes de la v3.539.24 (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 llegaban al camino de bloques?
En FPC, cualquier archivo incrustado 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 pasaba 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 una sola pasada que nunca se vio afectado. 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 se partía en varios miembros. Los puntos de entrada públicos que alimentan ReadFromStream con la compresión activada son los writers de archivos incrustados:
TPDFlib.EmbedFileyTPDFlib.AddEmbeddedFile, que leen un archivo de disco a un stream/EmbeddedFileTPDFlib.AddAssociatedFileFromStreamyTPDFlib.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.GetEmbeddedFileContentToStreamyGetEmbeddedFileContentToFile, que decodifican víaTPDFStream.WriteDecodedToStreamy desde ahí víaInflateStream
El fallo era silencioso en todas las capas. El writer 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 paraba limpiamente en el primer Z_STREAM_END y devolvía exactamente 65.536 bytes. GetEmbeddedFileContentToStream devolvía 1, porque informa de si la decodificación lanzó un error, no de si la salida coincide con /Size. Quien haya perseguido un problema de documentos grandes por fusionar y dividir PDFs de varios gigas conoce este patrón: el archivo abre, el número de páginas es correcto, y el daño solo se ve cuando alguien abre el adjunto
Un solo estado deflate para todos los bloques
El DeflateStream corregido en 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 ahora la misma estructura que la rama Delphi tuvo siempre. Además escribe la salida según se produce, en lugar de concatenar primero todo el resultado comprimido en un AnsiString, así que el writer ya no construye una segunda copia completa de los datos comprimidos en memoria antes de copiarla al destino
// FPC DeflateStream desde la 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 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 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. El viejo writer por bloques tenía el nivel 6 fijo, mientras que el nuevo respeta PLDeflateLevel, así que un nivel fijado con TPDFlib.SetCompressionLevel(1..9) ahora se aplica también a los archivos incrustados grandes en FPC. Eso importa si ya ajusta la compresión para salida de archivo como se describe en reducir el tamaño de un PDF en Delphi
¿Cómo verificar que un stream Flate es un único miembro zlib?
Inflar el stream con un decodificador zlib corriente y comprobar 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. El fix se verificó así: 200 KB de datos de test, que abarcan cuatro bloques de 64 KB, pasaron por el nuevo DeflateStream y salieron como un stream zlib único de 534 bytes, un decodificador zlib de stock recuperó los 200.000 bytes sin entrada restante, y la misma comprobación pasó en el target FPC cross-compilado a i386. La rutina de abajo es la versión FPC de esa comprobación, montada directamente sobre paszlib para no fiarse del 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;
// 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 usted: comparar 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 pasar por el camino de streaming. Ejecute el mismo test en cada compilador con el que distribuye, porque el defecto original pasaba en Delphi y solo fallaba 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;
¿Qué no cambia con el fix?
La rama FPC conserva sus reglas de decodificación indulgentes, y no repara archivos que builds FPC anteriores ya escribieron. Cuando se alcanza MaxOutput, 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 producers de PDF emiten streams truncados o con el checksum roto. Un PDF escrito por un build FPC anterior a la v3.539.24 sigue conteniendo miembros concatenados, y el lector corregido, como cualquier otro lector, se para en el primer Z_STREAM_END. No intente curar semejante archivo decodificando y recodificando el stream dentro de la librería, porque eso solo hace permanente el truncado de 64 KB. Vuelva a incrustar el adjunto desde su fuente original. El bucle FPC además sigue terminando en la primera lectura que devuelve menos que un bloque completo, que solo es señal de fin de datos para streams como TFileStream y TMemoryStream, así que lo más seguro con un TStream propio pasado a AddAssociatedFileFromStream es copiarlo antes a un TMemoryStream. La rama Delphi, los helpers DeflateStr e InflateStr, y todos los streams menores de 1 MiB en el camino Flate normal se comportan exactamente igual que antes
El DeflateStream y el InflateStream FPC corregidos llegan en la v3.539.24 de PDF Library for Delphi, que apunta a Delphi, C++Builder y Free Pascal desde un único árbol de fuentes, y donde un adjunto grande debe volver ahora de un build FPC byte a byte, igual que siempre lo hizo desde Delphi