Articolo tecnico

Chunked zlib su FPC: FlateDecode vuole un solo stream

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

Anatomia di un membro zlib RFC 1950 negli stream FlateDecode di PDFlibPas: un header di due byte, un bit stream deflate continuo il cui ultimo blocco porta il flag final-block, e un trailer Adler-32 di quattro byte dove inflate restituisce Z_STREAM_END lasciando input residuo non letto in avail_in senza alzare alcun errore
Un inflater conforme considera il primo trailer Adler-32 la fine dei dati, quindi un confine di membro è uno stop duro e tutto ciò che un writer ha incollato dopo è peso morto che nessun reader decoderà

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

Difetto del writer chunked nel DeflateStream FPC di PDFlibPas: ogni chunk da 64 KB passa da ZFPCCompress come membro zlib completo, così un allegato da 1 MiB contiene sedici membri incollati, un reader si ferma al primo trailer dopo 65.536 byte e GetEmbeddedFileContentToStream segnala comunque successo
Il danno restava invisibile perché ogni layer aveva successo: il dizionario dell'allegato dichiarava il /Params /Size completo, la decodifica non alzava errori e solo chi apriva l'allegato notava il troncamento
// 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.EmbedFile e TPDFlib.AddEmbeddedFile, che leggono un file dal disco dentro uno stream /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream e TPDFlib.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.GetEmbeddedFileContentToStream e GetEmbeddedFileContentToFile, che decodificano attraverso TPDFStream.WriteDecodedToStream e da lì attraverso InflateStream

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

DeflateStream corretto in PDFlibPas: un paszlib.TZStream inizializzato una volta sola, ogni chunk da 64 KB alimentato con Z_NO_FLUSH e uno svuotamento finale con Z_FINISH, con esattamente un header, un bit stream deflate continuo i cui back-reference attraversano i confini dei chunk e un Adler-32 sull'intero input
Uno stato unico cambia anche come viene scritto l'output: i byte compressi escono man mano che ogni buffer si riempie invece di accumularsi in una seconda copia completa, e PLDeflateLevel ora arriva anche ai file embedded grandi su FPC
// 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