Prima della v3.539.24, la build Free Pascal di PDF Library for Delphi comprimeva gli stream grandi a chunk da 64 KB e dava a ogni chunk il suo header e il suo checksum zlib, così un allegato da 1 MiB diventava sedici membri zlib incollati uno dietro l'altro. FlateDecode secondo ISO 32000-1 si aspetta esattamente uno stream zlib, e un decoder standard si ferma al primo marker di fine stream, il che significa che tutto ciò che veniva dopo i primi 65.536 byte spariva in silenzio. La correzione tiene vivo un solo stato zlib attraverso tutti i chunk, sia in DeflateStream sia in InflateStream
Il bug esisteva solo sul lato FPC della unit, e solo sul percorso streaming chunked, che è esattamente il motivo per cui è sopravvissuto: il ramo Delphi era sempre stato corretto, gli helper a stringa erano sempre stati corretti, e i payload di test piccoli non arrivavano mai al codice chunked. È un stretto parente dei difetti raccontati in cinque bug di porting FPC che Delphi nascondeva, solo che qui la build Delphi non copriva nulla. Il ramo FPC era stato semplicemente scritto contro un modello mentale sbagliato di che cosa sia uno stream zlib
Perché gli altri viewer PDF troncano uno stream zlib chunked?
Perché uno stream zlib come definito da RFC 1950 è un contenitore unico, non una sequenza di contenitori, e un inflater conforme considera il primo trailer Adler-32 la fine dei dati. Il formato è un header di due byte, un bit stream deflate RFC 1951 continuo il cui ultimo blocco porta il flag final-block, e un checksum Adler-32 di quattro byte su tutti i byte non compressi. ISO 32000-1 §7.4.4 definisce /FlateDecode esattamente in questi termini. Quando inflate raggiunge il trailer restituisce Z_STREAM_END e lascia eventuale input residuo non letto in avail_in. Dal suo punto di vista non c'è nulla di sbagliato, quindi non alza alcun errore, e i byte dopo il trailer vengono semplicemente ignorati. I membri concatenati sono un'idea legittima in gzip (RFC 1952 consente più membri in un file), ed è probabile che l'intuizione venisse da lì, ma zlib non ha una regola del genere e il PDF non l'ha mai chiesta
Il vecchio DeflateStream FPC leggeva la sorgente 64 KB alla volta e passava ogni chunk a ZFPCCompress, un helper che esegue il suo deflateInit2, comprime con Z_FINISH e chiama deflateEnd. Ogni chunk usciva quindi come stream zlib completo, valido e auto-terminato, e la funzione li concatenava in un unico AnsiString prima di scriverlo. Il risultato sembrava dati Flate, aveva un header corretto e si decodificava senza errori, ma si decodificava solo fino ai primi 65.536 byte. Sul lato lettura, InflateStream aveva il difetto speculare: chiamava ZFPCInflate una volta per ogni 64 KB di input compresso, e ogni chiamata partiva con un inflateInit2 nuovo. Il secondo chunk inizia in mezzo a un bit stream deflate senza header zlib, quindi un inflater nuovo lo rifiuta, e uno stream perfettamente normale a membro singolo prodotto da qualunque altro producer veniva decodificato solo fino a dove lo portavano i primi 64 KB di byte compressi
// FPC DeflateStream prima della v3.539.24 (semplificato):
// ZFPCCompress esegue deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// quindi ogni chunk da 64 KB diventa un membro zlib separato
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));
Quali chiamate di PDF Library for Delphi finivano sul percorso chunked?
Su FPC, qualunque file embedded da 1 MiB in su veniva scritto male, e qualunque stream Flate estratto attraverso la streaming API veniva letto male appena la sua dimensione compressa superava un chunk. TPDFStream.ReadFromStream decide come codificare i dati in entrata. Quando Deflate è true e Stream.Size >= 1048576, passa per DeflateStream in streaming; sotto quella soglia legge l'intera sorgente in memoria e chiama DeflateStr, l'helper one-shot che non è mai stato toccato dal problema. La catena di filtri ASCII85 più Flate salta il test sulla dimensione e passa sempre per DeflateStream, quindi su quel percorso qualunque payload oltre 64 KB veniva già spezzato in più membri. I punti d'ingresso pubblici che alimentano ReadFromStream con la compressione attiva sono i writer di file embedded:
TPDFlib.EmbedFileeTPDFlib.AddEmbeddedFile, che leggono un file dal disco dentro uno stream/EmbeddedFileTPDFlib.AddAssociatedFileFromStreameTPDFlib.AddAssociatedFileFromFile, i writer di associated-file per PDF/A-3 usati per l'XML delle fatture elettroniche e gli altri dati sorgente- Sul lato lettura,
TPDFlib.GetEmbeddedFileContentToStreameGetEmbeddedFileContentToFile, che decodificano attraversoTPDFStream.WriteDecodedToStreame da lì attraversoInflateStream
Il fallimento era silenzioso a ogni layer. Il writer memorizza /Params /Size e un /CheckSum MD5 calcolato dal file originale, così il dizionario dell'allegato dichiarava la dimensione completa mentre lo stream conteneva sedici membri. Una build Delphi della stessa libreria che leggeva quel file si fermava pulitamente al primo Z_STREAM_END e restituiva esattamente 65.536 byte. GetEmbeddedFileContentToStream restituiva 1, perché riporta se la decodifica ha alzato un errore, non se l'output corrisponde a /Size. Chi ha già rincorso un problema da documento grande attraverso merge e split di PDF da gigabyte riconosce lo schema: il file si apre, il conteggio pagine è giusto, e il danno si vede solo quando qualcuno apre l'allegato
Un solo stato deflate attraverso tutti i chunk
Il DeflateStream corretto in PDFlibZLib.pas inizializza un solo paszlib.TZStream, alimenta ogni chunk a deflate con Z_NO_FLUSH, e solo alla fine svuota il compressore con Z_FINISH finché non restituisce Z_STREAM_END. Questo produce esattamente un header, un bit stream deflate i cui back-reference possono raggiungere oltre i confini dei chunk, e un Adler-32 sull'intero input. Il ramo FPC ora ha la stessa struttura che il ramo Delphi ha sempre avuto. Scrive anche l'output mentre viene prodotto, invece di concatenare prima l'intero risultato compresso in un AnsiString, così il writer non costruisce più una seconda copia completa dei dati compressi in memoria prima di copiarla sul target
// FPC DeflateStream dalla v3.539.24 (percorsi di errore omessi)
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); // stesso stato, nessuna rottura di membro
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 per l'intero input
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 ha ricevuto la riscrittura speculare: un solo inflateInit2, un ciclo interno che continua a chiamare inflate finché il chunk corrente non è consumato e il buffer di output non è più pieno, e uno stop su Z_STREAM_END. C'è un effetto collaterale che vale la pena conoscere. Il vecchio writer chunked aveva il livello 6 hardcoded, mentre il nuovo rispetta PLDeflateLevel, quindi un livello impostato con TPDFlib.SetCompressionLevel(1..9) ora vale anche per i file embedded grandi su FPC. Conta se già tarate la compressione per output di archivio come descritto in ridurre la dimensione dei PDF in Delphi
Come verificare che uno stream Flate sia un solo membro zlib?
Decodificalo con un decoder zlib qualunque e controlla due cose quando restituisce Z_STREAM_END: che la lunghezza decodificata eguagli quella della sorgente, e che avail_in sia zero. Input residuo dopo il marker di fine è la firma di uno stream concatenato. La correzione è stata verificata così: 200 KB di dati di test, che attraversano quattro chunk da 64 KB, sono passati dal nuovo DeflateStream ed è uscito uno stream zlib singolo da 534 byte, un decoder zlib di serie ha recuperato tutti i 200.000 byte senza input residuo, e lo stesso controllo è passato sul target FPC cross-compilato i386. La routine qui sotto è la versione FPC di quel controllo, costruita direttamente su paszlib così non si fida del codice sotto test
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 membro termina esattamente all'ultimo byte di input
Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
finally
inflateEnd(strm);
end;
end;
// Passa 200.000 byte dentro DeflateStream con il chunk predefinito di 64 KB
// ed esigi un membro che si decodi di nuovo sulla lunghezza 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 livello di applicazione, l'asserzione utile è quella che la libreria non fa per te: confronta ciò che esce da un allegato con il /Params /Size registrato quando è entrato. GetEmbeddedFileIntProperty con tag 5 restituisce quella dimensione registrata, gli indici dei file embedded partono da 1, e il payload deve essere almeno 1 MiB per imboccare il percorso streaming. Esegui lo stesso test su ogni compilatore con cui spedisci, dato che il difetto originale passava su Delphi e falliva solo su 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 più imbocca il percorso chunked di DeflateStream in 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;
Cosa non cambia con la correzione?
Il ramo FPC conserva le sue regole di decodifica permissive, e non ripara i file che build FPC precedenti hanno già scritto. Quando viene raggiunto MaxOutput, InflateStream su FPC tronca al limite e restituisce, mentre il ramo Delphi alza ERangeError, e FPC accetta ancora output parzialmente decodificato quando inflate segnala un errore sui dati, perché alcuni producer PDF emettono stream troncati o con checksum rotti. Un PDF scritto da una build FPC precedente alla v3.539.24 contiene ancora membri concatenati, e il reader corretto, come ogni altro reader, si ferma al primo Z_STREAM_END. Non provare a guarire un file simile decodificando e ricodificando lo stream dentro la libreria, perché non fai che rendere permanente il troncamento a 64 KB. Ri-embedda l'allegato dalla sua sorgente originale. Il loop FPC si ferma comunque alla prima lettura che restituisce meno di un chunk intero, che è un segnale di fine dati solo per stream come TFileStream e TMemoryStream, quindi uno TStream personalizzato passato a AddAssociatedFileFromStream è più sicuro se copiato prima in un TMemoryStream. Il ramo Delphi, gli helper DeflateStr e InflateStr, e ogni stream sotto 1 MiB sul percorso Flate semplice si comportano esattamente come prima
Il DeflateStream e l'InflateStream FPC corretti sono nella v3.539.24 di PDF Library for Delphi, che copre Delphi, C++Builder e Free Pascal da un unico albero sorgente, e dove un allegato grande ora dovrebbe tornare da una build FPC byte per byte, esattamente come è sempre tornato da Delphi