Article technique

zlib par blocs sur FPC : FlateDecode exige un seul flux

Avant la v3.539.24, la build Free Pascal de PDF Library for Delphi compressait les gros flux par blocs de 64 Ko en donnant à chaque bloc son propre en-tête et sa propre somme de contrôle zlib, si bien qu'une pièce jointe de 1 Mio devenait seize membres zlib collés bout à bout. FlateDecode de l'ISO 32000-1 attend exactement un flux zlib, et un décodeur standard s'arrête au premier marqueur de fin de flux, ce qui veut dire que tout ce qui suivait les premiers 65 536 octets disparaissait silencieusement. Le correctif maintient un seul état zlib vivant à travers tous les blocs, dans DeflateStream comme dans InflateStream

Le bug n'existait que du côté FPC de l'unité, et uniquement sur le chemin de streaming par blocs, ce qui explique exactement pourquoi il a survécu : la branche Delphi était toujours correcte, les helpers à base de chaînes étaient toujours corrects, et de petites charges de test n'atteignaient jamais le code par blocs. C'est un cousin proche des défauts de cinq bugs de portage FPC que Delphi cachait, sauf qu'ici la build Delphi ne couvrait rien du tout. La branche FPC avait simplement été écrite 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

L'ancien DeflateStream FPC lisait la source 64 Ko à la fois et remettait chaque bloc à ZFPCCompress, un helper qui lance son propre deflateInit2, compresse avec Z_FINISH et appelle deflateEnd. Chaque bloc sortait donc sous forme de flux zlib complet, valide et auto-terminal, et la fonction les concaténait dans un AnsiString avant de l'écrire. Le résultat ressemblait à des données Flate, avait un en-tête correct et se décodait sans erreur, mais ne décodait que les premiers 65 536 octets. Côté lecture, InflateStream avait le défaut en miroir : il appelait ZFPCInflate une fois par 64 Ko d'entrée compressée, et chaque appel repartait d'un inflateInit2 tout neuf. Le second bloc commence au milieu d'un flux de bits deflate sans en-tête zlib, donc un inflater neuf le rejette, et un flux parfaitement normal à membre unique produit par n'importe quel autre producteur n'était décodé que jusqu'où portaient ses premiers 64 Ko d'octets compressés

Défaut d'écriture par blocs dans le DeflateStream FPC de PDFlibPas : chaque bloc de 64 Ko passe par ZFPCCompress comme un membre zlib complet, si bien qu'une pièce jointe de 1 Mio contient seize membres collés, qu'un lecteur s'arrête au premier trailer après 65 536 octets, et que GetEmbeddedFileContentToStream signale quand même le succès
Les dégâts restaient invisibles parce que chaque couche réussissait : le dictionnaire de pièce jointe annonçait le /Params /Size complet, le décodage ne levait aucune erreur, et seul celui qui ouvrait la pièce jointe remarquait la troncature
// FPC DeflateStream avant v3.539.24 (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 atteignaient le chemin par blocs ?

Sur FPC, tout fichier embarqué de 1 Mio ou plus était mal écrit, et tout flux Flate extrait via l'API de streaming était mal lu dès que sa taille compressée dépassait un bloc. TPDFStream.ReadFromStream décide de la façon d'encoder les données entrantes. Quand Deflate vaut true et que Stream.Size >= 1048576, il passe par DeflateStream en streaming ; en dessous de ce seuil, il lit toute la source en mémoire et appelle DeflateStr, le helper en une seule passe qui n'a jamais été touché. La chaîne de filtres ASCII85 plus Flate saute le test de taille et passe toujours par DeflateStream, donc sur ce chemin toute charge de plus de 64 Ko partait déjà en plusieurs membres. Les points d'entrée publics qui alimentent ReadFromStream avec la compression activée sont les écrivains de fichiers embarqués :

  • 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

L'échec était silencieux à chaque couche. L'écrivain stocke /Params /Size et un /CheckSum MD5 calculé sur le fichier d'origine, donc le dictionnaire de pièce jointe annonçait la taille complète pendant que le flux contenait seize membres. Une build Delphi de la même bibliothèque lisant ce fichier s'arrêtait proprement au premier Z_STREAM_END et renvoyait exactement 65 536 octets. GetEmbeddedFileContentToStream renvoyait 1, parce qu'il indique si le décodage a levé une erreur, pas si la sortie correspond à /Size. Quiconque a traqué un problème de gros document à travers la fusion et le découpage de PDF de plusieurs gigaoctets connaît ce schéma : le fichier s'ouvre, le nombre de pages est bon, et les dégâts n'apparaissent que quand quelqu'un ouvre la pièce jointe

Un seul état deflate à travers tous les blocs

Le DeflateStream corrigé dans PDFlibZLib.pas initialise un unique paszlib.TZStream, alimente chaque bloc dans deflate avec Z_NO_FLUSH, et seulement à la fin draine le compresseur avec Z_FINISH jusqu'à obtenir Z_STREAM_END. Cela produit exactement un en-tête, un flux de bits deflate dont les références arrière peuvent franchir les frontières de blocs, et un Adler-32 sur toute l'entrée. La branche FPC a désormais la même structure que la branche Delphi a toujours eue. Elle écrit aussi la sortie au fur et à mesure de sa production, au lieu de concaténer d'abord tout le résultat compressé dans un AnsiString, si bien que l'écrivain ne construit plus une seconde copie complète des données compressées en mémoire avant de les copier vers la cible

DeflateStream corrigé dans PDFlibPas : un unique paszlib.TZStream initialisé une fois, chaque bloc de 64 Ko alimenté avec Z_NO_FLUSH puis un drain final en Z_FINISH, produisant exactement un en-tête, un flux de bits deflate continu dont les références arrière franchissent 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 désormais aussi les gros fichiers embarqués sur FPC
// FPC DeflateStream à partir de la v3.539.24 (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 a eu la réécriture symétrique : un 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. Il y a un effet de bord à connaître. L'ancien écrivain par blocs figeait le niveau 6, alors que le nouveau honore PLDeflateLevel, donc un niveau réglé via TPDFlib.SetCompressionLevel(1..9) s'applique désormais aussi aux gros fichiers embarqués sur FPC. Cela compte si vous calibrez déjà la compression pour de la sortie d'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 quelconque et vérifiez deux choses quand il renvoie Z_STREAM_END : la longueur décodée égale la longueur de la source, et avail_in vaut zéro. Une entrée restante après le marqueur de fin est la signature d'un flux concaténé. Le correctif a été vérifié ainsi : 200 Ko de données de test, qui couvrent quatre blocs de 64 Ko, sont passés par le nouveau DeflateStream et sont ressortis en un unique flux zlib de 534 octets, un décodeur zlib standard a récupéré les 200 000 octets sans entrée restante, et la même vérification est passée sur la cible FPC compilée en croix pour i386. La routine ci-dessous est la version FPC de cette vérification, bâtie directement sur paszlib pour ne pas faire confiance au code 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 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 renvoie cette taille enregistrée, les index de fichiers embarqués sont à base 1, et la charge doit faire au moins 1 Mio pour emprunter le chemin de streaming. Exécutez le même test sur chaque compilateur que vous livrez, puisque le défaut d'origine passait sur Delphi et n'échouait 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;

Que ne change pas le correctif ?

La branche FPC garde ses règles de décodage permissives, et elle ne répare pas les fichiers déjà écrits par des builds FPC antérieures. Quand MaxOutput est atteint, le InflateStream FPC tronque à la limite et rend la main, tandis que la branche Delphi lève ERangeError, et FPC accepte toujours une sortie partiellement décodée quand inflate signale une erreur de données, parce que certains producteurs de PDF émettent des flux tronqués ou à somme de contrôle cassée. Un PDF écrit par une build FPC antérieure à la v3.539.24 contient encore des membres concaténés, et le lecteur corrigé, comme tout autre lecteur, s'arrête au premier Z_STREAM_END. N'essayez pas de réparer un tel fichier en décodant puis réencodant le flux dans la bibliothèque, puisque cela se contente de rendre la troncature à 64 Ko définitive. Réembarquez plutôt la pièce jointe depuis sa source d'origine. La boucle FPC se termine aussi toujours à la première lecture rendant moins qu'un bloc complet, ce qui n'est un signal de fin de données que pour des flux comme TFileStream et TMemoryStream, donc un TStream personnalisé passé à AddAssociatedFileFromStream a intérêt à être copié d'abord dans un TMemoryStream. La branche Delphi, les helpers DeflateStr et InflateStr, et tout flux de moins de 1 Mio sur le chemin Flate simple se comportent exactement comme avant

Le DeflateStream et le InflateStream FPC corrigés sont livrés dans la v3.539.24 de PDF Library for Delphi, qui cible Delphi, C++Builder et Free Pascal depuis un arbre de sources unique, et où une grosse pièce jointe doit désormais ressortir d'une build FPC octet pour octet, comme elle l'a toujours fait depuis Delphi