Articolo tecnico

Chunked zlib su FPC: FlateDecode vuole un solo stream

Un writer FlateDecode chunked su Free Pascal deve tenere vivo un solo stato zlib attraverso tutti i chunk. La scorciatoia allettante, comprimere ogni chunk da 64 KB per conto suo, dà a ogni chunk il suo header e il suo checksum zlib, così un allegato da 1 MiB diventa 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 viene dopo i primi 65.536 byte sparisce in silenzio. PDF Library for Delphi tiene vivo un solo stato zlib attraverso tutti i chunk, sia in DeflateStream sia in InflateStream

La trappola esiste solo sul lato FPC di una unit cross-compilatore, e solo sul percorso streaming chunked, che è esattamente il motivo per cui è facile perderla: il ramo Delphi e gli helper a stringa producono ciascuno un singolo stream zlib, e i payload di test piccoli non arrivano mai al codice chunked. È un stretto parente delle trappole raccontate in cinque insidie di porting FPC che Delphi tende a nascondere, solo che qui Delphi non sta coprendo nulla. La scorciatoia poggia semplicemente su 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 DeflateStream FPC naive legge la sorgente 64 KB alla volta e passa ogni chunk a ZFPCCompress, un helper che esegue il suo deflateInit2, comprime con Z_FINISH e chiama deflateEnd. Ogni chunk esce quindi come stream zlib completo, valido e auto-terminato, e la funzione li concatena in un unico AnsiString prima di scriverlo. Il risultato sembra dati Flate, ha un header corretto e si decodifica senza errori, ma si decodifica solo fino ai primi 65.536 byte. Sul lato lettura c'è la trappola speculare: chiamare ZFPCInflate una volta per ogni 64 KB di input compresso fa partire un inflateInit2 nuovo ogni volta. 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 viene decodificato solo fino a dove lo portano i primi 64 KB di byte compressi

Trappola del writer chunked su FPC: quando ogni chunk da 64 KB passa da ZFPCCompress come membro zlib completo, 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 resta invisibile perché ogni layer ha successo: il dizionario dell'allegato dichiara il /Params /Size completo, la decodifica non alza errori e solo chi apre l'allegato nota il troncamento
// DeflateStream FPC chunked naive (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 arrivano al percorso chunked?

Su FPC, il percorso chunked scrive ogni file embedded da 1 MiB in su, e legge ogni stream Flate estratto attraverso la streaming API appena la sua dimensione compressa supera 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 comprime tutto in una sola chiamata. 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 copre già più chunk. 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

Sbagliare questo fallisce in silenzio a ogni layer. Il writer memorizza /Params /Size e un /CheckSum MD5 calcolato dal file originale, così il dizionario dell'allegato dichiara la dimensione completa anche quando lo stream contiene sedici membri. Qualunque reader conforme, build Delphi della libreria compresa, si ferma pulitamente al primo Z_STREAM_END e restituisce esattamente 65.536 byte. GetEmbeddedFileContentToStream restituisce 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 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 ha la stessa struttura del ramo Delphi. Scrive anche l'output mentre viene prodotto, invece di concatenare prima l'intero risultato compresso in un AnsiString, così il writer non costruisce mai una seconda copia completa dei dati compressi in memoria prima di copiarla sul target

DeflateStream a stato unico 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 arriva anche ai file embedded grandi su FPC
// FPC DeflateStream con un solo stato zlib (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 segue lo stesso schema: 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. Poiché il writer chiama deflateInit2 direttamente invece di passare per un helper a livello fisso, rispetta PLDeflateLevel, quindi un livello impostato con TPDFlib.SetCompressionLevel(1..9) 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 di serie 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 routine qui sotto è la versione FPC di quel controllo, costruita direttamente su paszlib così non si fida del codice sotto test, seguita da una prova che spinge 200.000 byte, quattro chunk da 64 KB, dentro 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 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 questa classe di errori passa su Delphi e fallisce 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;

Casi limite sul ramo FPC

Il ramo FPC usa regole di decodifica permissive. 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 che contiene già membri concatenati, qualunque cosa lo abbia scritto, si ferma al primo Z_STREAM_END in ogni reader. 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 tratta una lettura che restituisce meno di un chunk intero come fine dei dati, il che vale per stream come TFileStream e TMemoryStream, quindi uno TStream personalizzato passato a AddAssociatedFileFromStream è più sicuro se copiato prima in un TMemoryStream

Il DeflateStream e l'InflateStream FPC a stato unico sono in PDF Library for Delphi, che copre Delphi, C++Builder e Free Pascal da un unico albero sorgente, così un allegato grande torna da una build FPC byte per byte, esattamente come da Delphi