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
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
// 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.EmbedFileetTPDFlib.AddEmbeddedFile, qui lisent un fichier depuis le disque vers un flux/EmbeddedFileTPDFlib.AddAssociatedFileFromStreametTPDFlib.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.GetEmbeddedFileContentToStreametGetEmbeddedFileContentToFile, qui décodent viaTPDFStream.WriteDecodedToStreampuis viaInflateStream
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
// 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