Article technique

zlib par blocs sur FPC : FlateDecode exige un seul flux

Un encodeur FlateDecode par blocs sur Free Pascal doit conserver un seul état zlib d'un bloc au suivant. Le raccourci tentant, compresser chaque bloc de 64 Ko indépendamment, donne à chaque bloc son propre en-tête zlib et son propre checksum, si bien qu'une pièce jointe de 1 Mio devient seize membres zlib collés bout à bout. ISO 32000-1 attend exactement un seul flux zlib derrière FlateDecode, et un décodeur standard s'arrête au premier marqueur de fin de flux, ce qui fait disparaître silencieusement tout ce qui suit les premiers 65 536 octets. PDF Library for Delphi conserve un seul état zlib d'un bloc au suivant, dans DeflateStream comme dans InflateStream

Le piège n'existe que du côté FPC d'une unité multi-compilateurs, et seulement sur le chemin du streaming par blocs, c'est précisément pourquoi il passe inaperçu : la branche Delphi et les helpers basés sur chaînes produisent chacun un flux zlib unique, et de petits payloads de test n'atteignent jamais le code par blocs. C'est un proche cousin des pièges des cinq bugs de portage FPC que Delphi cache souvent, à cette différence que Delphi ne masque rien ici. Le raccourci repose simplement sur un modèle mental erroné de ce qu'est un flux zlib

Pourquoi les autres lecteurs PDF tronquent-ils un flux zlib par blocs ?

Parce qu'un flux zlib au sens du RFC 1950 est un conteneur unique, pas une séquence de conteneurs, et qu'un inflater conforme traite le premier trailer Adler-32 comme la fin des données. Le format, c'est un en-tête de deux octets, un flux de bits deflate RFC 1951 continu dont le dernier bloc porte le drapeau final-block, et une somme de contrôle Adler-32 de quatre octets calculée sur tous les octets décompressés. ISO 32000-1 §7.4.4 définit /FlateDecode exactement en ces termes. Quand inflate atteint le trailer, il renvoie Z_STREAM_END et laisse dans avail_in toute entrée restante non lue. De son point de vue rien ne va pas, donc il ne lève pas d'erreur, et les octets après le trailer sont simplement ignorés. Les membres concaténés sont une idée légitime en gzip (le RFC 1952 autorise plusieurs membres dans un fichier), et c'est probablement de là que venait l'intuition, mais zlib n'a pas de telle règle et le PDF ne l'a jamais demandée

Anatomie d'un membre zlib RFC 1950 dans les flux FlateDecode de PDFlibPas : un en-tête de deux octets, un flux de bits deflate continu dont le dernier bloc porte le drapeau final-block, et un trailer Adler-32 de quatre octets où inflate renvoie Z_STREAM_END, laissant dans avail_in toute entrée restante non lue sans lever d'erreur
Un inflater conforme traite le premier trailer Adler-32 comme la fin des données, donc une frontière de membre est un arrêt sec, et tout ce qu'un écrivain a collé après est du poids mort qu'aucun lecteur ne décodera

Le DeflateStream FPC naïf lit la source par blocs de 64 Ko et confie chaque bloc à ZFPCCompress, un helper qui appelle son propre deflateInit2, compresse avec Z_FINISH, puis appelle deflateEnd. Chaque bloc produit donc un flux zlib complet, valide et auto-terminé, et la fonction concatène le tout dans une seule AnsiString avant d'écrire. Le résultat ressemble à des données Flate, a un en-tête correct et se décode sans erreur, mais il ne décode que les premiers 65 536 octets. La lecture cache le piège symétrique : appeler ZFPCInflate une fois par bloc de 64 Ko d'entrée compressée relance un inflateInit2 neuf à chaque fois. Le second bloc commence au milieu d'un bit stream deflate sans en-tête zlib, un nouvel inflater le refuse donc, et un flux parfaitement normal à membre unique produit par n'importe quel autre producteur n'est décodé que jusqu'où portent ses premiers 64 Ko d'octets compressés

Piège de l'encodeur par blocs sur FPC : quand chaque bloc de 64 Ko passe dans ZFPCCompress comme membre zlib complet, une pièce jointe de 1 Mio contient seize membres collés, un lecteur s'arrête au premier trailer après 65 536 octets, et GetEmbeddedFileContentToStream signale toujours un succès
Le dégât reste invisible parce que chaque couche réussit : le dictionnaire de pièce jointe annonce la totalité de /Params /Size, le décodage ne lève aucune erreur, et seul celui qui ouvre la pièce jointe remarque la troncature
// FPC DeflateStream naïf par blocs (simplifié) :
// ZFPCCompress enchaîne deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// donc chaque bloc de 64 Ko devient un membre zlib séparé
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));

Quels appels de PDF Library for Delphi empruntent le chemin par blocs ?

Sur FPC, le chemin par blocs écrit toute pièce jointe de 1 Mio ou plus, et lit tout flux Flate extrait via l'API de streaming dès que sa taille compressée dépasse un bloc. TPDFStream.ReadFromStream décide comment encoder les données entrantes. Quand Deflate est à true et que Stream.Size >= 1048576, il passe par DeflateStream en streaming ; en dessous de ce seuil il charge toute la source en mémoire et appelle DeflateStr, le helper one-shot qui compresse tout en un seul appel. La chaîne de filtres ASCII85 plus Flate saute le test de taille et passe toujours par DeflateStream, si bien que sur ce chemin tout payload de plus de 64 Ko chevauche déjà plusieurs blocs. Les points d'entrée publics qui alimentent ReadFromStream avec compression activée sont les écrivains de pièces jointes :

  • TPDFlib.EmbedFile et TPDFlib.AddEmbeddedFile, qui lisent un fichier depuis le disque vers un flux /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream et TPDFlib.AddAssociatedFileFromFile, les écrivains de fichiers associés PDF/A-3 utilisés pour le XML de facture électronique et d'autres données sources
  • Côté lecture, TPDFlib.GetEmbeddedFileContentToStream et GetEmbeddedFileContentToFile, qui décodent via TPDFStream.WriteDecodedToStream puis via InflateStream

Se tromper ici échoue silencieusement à chaque couche. L'écriture stocke /Params /Size et un /CheckSum MD5 calculé sur le fichier d'origine, si bien que le dictionnaire de pièce jointe annonce la taille complète même quand le flux contient seize membres. Tout lecteur conforme, build Delphi de la bibliothèque compris, s'arrête proprement au premier Z_STREAM_END et rend exactement 65 536 octets. GetEmbeddedFileContentToStream renvoie 1, parce qu'il signale si le décodage a levé une erreur, pas si la sortie correspond à /Size. Quiconque a traqué un problème de gros document en passant par la fusion et la découpe de PDF de plusieurs gigaoctets connaît ce schéma : le fichier s'ouvre, le nombre de pages est bon, et le dégât n'apparaît que lorsque quelqu'un ouvre la pièce jointe

Un seul état deflate à travers tous les blocs

Le DeflateStream de PDFlibZLib.pas initialise un seul paszlib.TZStream, alimente chaque bloc vers deflate avec Z_NO_FLUSH, et ne draine le compresseur avec Z_FINISH, jusqu'à obtenir Z_STREAM_END, qu'à la fin. Résultat : exactement un en-tête, un bit stream deflate dont les références arrière peuvent traverser les frontières de blocs, et un Adler-32 sur toute l'entrée. La branche FPC a la même structure que la branche Delphi. Elle écrit aussi la sortie au fil de sa production, au lieu de concaténer d'abord tout le résultat compressé dans une AnsiString, si bien que le writer ne construit jamais une seconde copie complète des données compressées en mémoire avant de la copier vers la cible

DeflateStream à état unique dans PDFlibPas : un seul paszlib.TZStream initialisé une fois, chaque bloc de 64 Ko passé avec Z_NO_FLUSH et un drainage final en Z_FINISH, d'où exactement un en-tête, un bit stream deflate continu dont les références arrière traversent les frontières de blocs et un Adler-32 sur toute l'entrée
Un seul état change aussi la façon d'écrire la sortie : les octets compressés partent dès que chaque tampon se remplit au lieu de s'accumuler dans une seconde copie complète, et PLDeflateLevel atteint aussi les grosses pièces jointes sur FPC
// FPC DeflateStream avec un seul état zlib (chemins d'erreur retirés)
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);   // même état, aucune rupture de membre
        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 seul trailer pour toute l'entrée
    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 suit le même schéma : un seul inflateInit2, une boucle interne qui rappelle inflate jusqu'à ce que le bloc courant soit consommé et que le tampon de sortie ne soit plus plein, et un arrêt sur Z_STREAM_END. Un effet de bord mérite d'être connu. Comme l'écriture appelle elle-même deflateInit2 au lieu de passer par un helper à niveau figé, elle honore PLDeflateLevel, donc un niveau fixé via TPDFlib.SetCompressionLevel(1..9) vaut aussi pour les grosses pièces jointes sur FPC. Cela compte si vous réglez déjà la compression pour de l'archivage, comme décrit dans la réduction de la taille des PDF en Delphi

Comment vérifier qu'un flux Flate est un seul membre zlib ?

Décompressez-le avec un décodeur zlib ordinaire et vérifiez deux choses au retour de Z_STREAM_END : la longueur décodée égale la longueur source, et avail_in vaut zéro. De l'entrée restante après le marqueur de fin, c'est la signature d'un flux concaténé. La routine ci-dessous est la version FPC de ce contrôle, construite directement sur paszlib pour ne pas faire confiance au code testé, suivie d'une sonde qui pousse 200 000 octets, quatre blocs de 64 Ko, dans 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 membre se termine exactement au dernier octet d'entrée
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Poussez 200 000 octets dans DeflateStream avec le bloc de 64 Ko par défaut
// et exigez un seul membre qui redécode à la longueur complète
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');

Au niveau applicatif, l'assertion utile est celle que la bibliothèque ne fait pas pour vous : comparez ce qui sort d'une pièce jointe avec le /Params /Size enregistré à l'entrée. GetEmbeddedFileIntProperty avec le tag 5 rend cette taille enregistrée, les index de pièces jointes sont à base 1, et le payload doit faire au moins 1 Mio pour emprunter le chemin streaming. Exécutez le même test sur chaque compilateur que vous livrez, puisque cette classe d'erreur passe sur Delphi et n'échoue que sur 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 Mio ou plus emprunte le chemin DeflateStream par blocs dans 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;

Cas particuliers de la branche FPC

La branche FPC applique des règles de décodage tolérantes. Quand MaxOutput est atteint, InflateStream FPC tronque à la limite et rend la main, tandis que la branche Delphi lève ERangeError, et FPC accepte encore une sortie partiellement décodée quand inflate signale une erreur de données, car certains producteurs de PDF émettent des flux tronqués ou au checksum cassé. Un PDF qui contient déjà des membres concaténés, quel qu'en soit l'auteur, s'arrête au premier Z_STREAM_END dans tous les lecteurs. N'essayez pas de réparer un tel fichier en décodant puis en réencodant le flux dans la bibliothèque : cela ne rend que la troncature à 64 Ko définitive. Réintégrez plutôt la pièce jointe depuis sa source d'origine. La boucle FPC traite une lecture qui rend moins qu'un bloc complet comme la fin des données, ce qui vaut pour des flux comme TFileStream et TMemoryStream, aussi un TStream personnalisé passé à AddAssociatedFileFromStream a tout intérêt à être copié d'abord dans un TMemoryStream

Le DeflateStream et le InflateStream FPC à état unique sont livrés dans PDF Library for Delphi, qui cible Delphi, C++Builder et Free Pascal depuis un seul arbre source, si bien qu'une grosse pièce jointe revient d'un build FPC octet pour octet, exactement comme depuis Delphi