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
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
// 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.EmbedFileyTPDFlib.AddEmbeddedFile, que leen un archivo del disco hacia un stream/EmbeddedFileTPDFlib.AddAssociatedFileFromStreamyTPDFlib.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.GetEmbeddedFileContentToStreamyGetEmbeddedFileContentToFile, que decodifican víaTPDFStream.WriteDecodedToStreamy de ahí víaInflateStream
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 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