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
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
// 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.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
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 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