Vor v3.539.24 komprimierte der Free-Pascal-Build der PDF Library for Delphi große Streams in 64-KB-Blöcken und verpasste jedem Block seinen eigenen zlib-Header und seine eigene Checksumme, sodass aus einem 1-MiB-Anhang sechzehn aneinandergeklebte zlib-Member wurden. ISO 32000-1 FlateDecode erwartet exakt einen zlib-Stream, und ein standardkonformer Decoder hält am ersten Ende-des-Streams-Marker an, was bedeutete, dass alles nach den ersten 65.536 Bytes stillschweigend verschwand. Der Fix hält einen einzigen zlib-State über alle Blöcke hinweg am Leben, sowohl in DeflateStream als auch in InflateStream
Der Bug existierte nur auf der FPC-Seite der Unit, und nur auf dem Chunked-Streaming-Pfad – genau deshalb hat er überlebt: Der Delphi-Zweig war stets korrekt, die stringbasierten Helfer waren stets korrekt, und kleine Test-Payloads erreichten den Chunked-Code überhaupt nie. Er ist ein enger Verwandter der Defekte in fünf FPC-Portierungs-Bugs, die Delphi versteckt hatte, nur dass hier der Delphi-Build nichts überdeckt hat. Der FPC-Zweig war schlicht gegen ein falsches mentales Modell davon geschrieben, was ein zlib-Stream ist
Warum werden chunked zlib-Streams von anderen PDF-Readern abgeschnitten?
Weil ein zlib-Stream im Sinne von RFC 1950 ein einzelner Container ist und keine Sequenz von ihnen, und ein konformer Inflater den ersten Adler-32-Trailer als Ende der Daten behandelt. Das Format besteht aus einem Zwei-Byte-Header, einem durchgehenden Deflate-Bitstrom nach RFC 1951, dessen letzter Block das Final-Block-Flag trägt, und einer Vier-Byte-Adler-32-Prüfsumme über alle unkomprimierten Bytes. ISO 32000-1 §7.4.4 definiert /FlateDecode genau in diesen Begriffen. Erreicht inflate den Trailer, liefert es Z_STREAM_END zurück und lässt verbleibenden Input ungelesen in avail_in. Aus seiner Sicht ist nichts falsch, also wirft es keinen Fehler, und die Bytes nach dem Trailer werden einfach ignoriert. Aneinandergehängte Member sind in gzip eine legitime Idee (RFC 1952 erlaubt mehrere Member in einer Datei), daher stammt vermutlich die Intuition, aber zlib kennt keine solche Regel, und PDF hat nie danach gefragt
Das alte FPC-DeflateStream las die Quelle häppchenweise in 64 KB und übergab jeden Block an ZFPCCompress, einen Helfer, der sein eigenes deflateInit2 ausführt, mit Z_FINISH komprimiert und deflateEnd aufruft. Jeder Block kam also als kompletter, gültiger, selbst terminierter zlib-Stream heraus, und die Funktion hängte sie aneinandergereiht in einen AnsiString, bevor sie ihn wegschrieb. Das Ergebnis sah wie Flate-Daten aus, hatte einen korrekten Header und dekodierte ohne Fehler – aber eben nur die ersten 65.536 Bytes. Auf der Leseseite hatte InflateStream den spiegelbildlichen Defekt: Es rief ZFPCInflate einmal pro 64 KB komprimierten Inputs auf, und jeder Aufruf startete ein frisches inflateInit2. Der zweite Block beginnt mitten in einem Deflate-Bitstrom ohne zlib-Header, also weist ein neuer Inflater ihn zurück, und ein völlig normaler Single-Member-Stream von jedem anderen Producer wurde nur so weit dekodiert, wie seine ersten 64 KB komprimierte Bytes reichten
// FPC DeflateStream vor v3.539.24 (vereinfacht):
// ZFPCCompress macht deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// jeder 64-KB-Block wird also ein separates zlib-Member
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));
Welche Aufrufe der PDF Library for Delphi landeten auf dem Chunked-Pfad?
Auf FPC wurde jede eingebettete Datei ab 1 MiB falsch geschrieben, und jeder Flate-Stream, der über die Streaming-API extrahiert wurde, wurde falsch gelesen, sobald seine komprimierte Größe einen Block überschritt. TPDFStream.ReadFromStream entscheidet, wie eingehende Daten kodiert werden. Ist Deflate wahr und Stream.Size >= 1048576, streamt es durch DeflateStream; unterhalb dieser Schwelle liest es die gesamte Quelle in den Speicher und ruft DeflateStr auf, den Einmalschuss-Helfer, der nie betroffen war. Die Filterkette ASCII85 plus Flate überspringt den Größentest und geht immer durch DeflateStream, auf diesem Pfad war also jede Payload über 64 KB bereits in mehrere Member zerlegt. Die öffentlichen Einstiegspunkte, die ReadFromStream mit eingeschalteter Komprimierung füttern, sind die Embedded-File-Writer:
TPDFlib.EmbedFileundTPDFlib.AddEmbeddedFile, die eine Datei von der Platte in einen/EmbeddedFile-Stream lesenTPDFlib.AddAssociatedFileFromStreamundTPDFlib.AddAssociatedFileFromFile, die PDF/A-3-Writer für zugeordnete Dateien, im Einsatz für E-Rechnungs-XML und andere Quelldaten- Auf der Leseseite
TPDFlib.GetEmbeddedFileContentToStreamundGetEmbeddedFileContentToFile, die überTPDFStream.WriteDecodedToStreamund von dort überInflateStreamdekodieren
Das Versagen war auf jeder Ebene leise. Der Writer speichert /Params /Size und eine aus der Originaldatei berechnete MD5-/CheckSum, das Anhang-Dictionary bewarb also die volle Größe, während der Stream sechzehn Member enthielt. Ein Delphi-Build derselben Bibliothek, der diese Datei liest, hielt sauber am ersten Z_STREAM_END an und lieferte exakt 65.536 Bytes zurück. GetEmbeddedFileContentToStream gab 1 zurück, denn es meldet, ob das Dekodieren einen Fehler geworfen hat, nicht ob die Ausgabe zu /Size passt. Wer schon einmal einem Large-Document-Problem hinterhergejagt ist, etwa beim Zusammenführen und Splitten von Gigabyte-PDFs, kennt dieses Muster: Die Datei öffnet, die Seitenzahl stimmt, und der Schaden zeigt sich erst, wenn jemand den Anhang öffnet
Ein Deflate-State über alle Blöcke hinweg
Das reparierte DeflateStream in PDFlibZLib.pas initialisiert einen einzigen paszlib.TZStream, füttert jeden Block mit Z_NO_FLUSH an deflate und leert den Kompressor erst am Ende mit Z_FINISH, bis er Z_STREAM_END zurückliefert. Das ergibt exakt einen Header, einen Deflate-Bitstrom, dessen Rückreferenzen Blockgrenzen überschreiten können, und eine Adler-32-Prüfsumme über die gesamte Eingabe. Der FPC-Zweig hat jetzt dieselbe Struktur, die der Delphi-Zweig immer hatte. Er schreibt die Ausgabe auch, sobald sie entsteht, statt das gesamte komprimierte Ergebnis erst in einen AnsiString zu konkatenieren, sodass der Writer keine zweite vollständige Kopie der komprimierten Daten im Speicher anlegt, bevor er sie ans Ziel kopiert
// FPC DeflateStream ab v3.539.24 (Fehlerpfade gekürzt)
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); // derselbe State, kein Member-Bruch
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 // ein Trailer für die gesamte Eingabe
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 bekam den symmetrischen Umbau: ein inflateInit2, eine innere Schleife, die inflate weiter aufruft, bis der aktuelle Block verbraucht ist und der Output-Buffer nicht mehr voll ist, und ein Stopp bei Z_STREAM_END. Ein Nebeneffekt ist gut zu wissen. Der alte Chunked-Writer hatte Level 6 fest verdrahtet, der neue ehrt PLDeflateLevel, sodass ein über TPDFlib.SetCompressionLevel(1..9) gesetzter Level jetzt auch auf FPC bei großen Embedded Files greift. Das ist relevant, wenn Sie die Komprimierung für Archivausgaben bereits tunen, wie in Reduzierung der PDF-Dateigröße in Delphi beschrieben
Wie prüfen Sie, dass ein Flate-Stream ein einzelnes zlib-Member ist?
Dekodieren Sie ihn mit einem schlichten zlib-Decoder und prüfen Sie zwei Dinge, wenn er Z_STREAM_END zurückliefert: Die dekodierte Länge entspricht der Quelllänge, und avail_in ist null. Übrig gebliebener Input nach dem Ende-Marker ist die Signatur eines konkatenierten Streams. Der Fix wurde genau so verifiziert: 200 KB Testdaten, die vier 64-KB-Blöcke überspannen, liefen durch das neue DeflateStream und kamen als 534-Byte-Single-zlib-Stream heraus, ein standardmäßiger zlib-Decoder holte alle 200.000 Bytes ohne verbleibenden Input zurück, und derselbe Check bestand auf dem i386-cross-kompilierten FPC-Target. Die Routine unten ist die FPC-Version dieses Checks, direkt auf paszlib aufgebaut, damit sie dem getesteten Code nicht vertraut
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;
// Ein Member endet exakt am letzten Input-Byte
Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
finally
inflateEnd(strm);
end;
end;
// 200.000 Bytes mit dem Standard-64-KB-Block durch DeflateStream schieben
// und ein Member verlangen, das zur vollen Länge zurückdekodiert
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');
Auf Anwendungsebene ist die nützliche Assertion die, die die Bibliothek nicht für Sie übernimmt: Vergleichen Sie, was aus einem Anhang herauskommt, mit der /Params /Size, die beim Einbetten notiert wurde. GetEmbeddedFileIntProperty mit Tag 5 liefert diese notierte Größe, Embedded-File-Indizes sind 1-basiert, und die Payload muss mindestens 1 MiB haben, um den Streaming-Pfad zu treffen. Führen Sie denselben Test auf jedem Compiler aus, mit dem Sie ausliefern, denn der ursprüngliche Defekt bestand auf Delphi und schlug nur auf FPC fehl
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');
// Ab 1 MiB nimmt ReadFromStream den Chunked-DeflateStream-Pfad
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;
Was ändert der Fix nicht?
Der FPC-Zweig behält seine großzügigen Dekodierregeln, und er repariert keine Dateien, die frühere FPC-Builds bereits geschrieben haben. Wenn MaxOutput erreicht ist, schneidet FPC InflateStream am Limit ab und kehrt zurück, während der Delphi-Zweig einen ERangeError wirft, und FPC akzeptiert weiterhin teilweise dekodierte Ausgabe, wenn inflate einen Datenfehler meldet, weil manche PDF-Producer abgeschnittene oder checksummenbeschädigte Streams ausgeben. Ein PDF aus einem FPC-Build vor v3.539.24 enthält weiterhin konkatenierte Member, und der korrigierte Reader hält wie jeder andere Reader am ersten Z_STREAM_END an. Versuchen Sie nicht, so eine Datei zu heilen, indem Sie den Stream innerhalb der Bibliothek dekodieren und neu kodieren – das zementiert die 64-KB-Abschneidung nur. Betten Sie den Anhang stattdessen aus seiner Originalquelle neu ein. Die FPC-Schleife endet außerdem weiterhin beim ersten Read, der weniger als einen vollen Block liefert, was nur bei Streams wie TFileStream und TMemoryStream ein Ende-der-Daten-Signal ist; ein eigener TStream, der an AddAssociatedFileFromStream geht, ist am sichersten zuerst in einen TMemoryStream kopiert. Der Delphi-Zweig, die Helfer DeflateStr und InflateStr und jeder Stream unter 1 MiB auf dem reinen Flate-Pfad verhalten sich exakt wie vorher
Das korrigierte FPC-DeflateStream und InflateStream erscheint in v3.539.24 der PDF Library for Delphi, die Delphi, C++Builder und Free Pascal aus einem Quellbaum bedient, und ein großer Anhang sollte aus einem FPC-Build jetzt byte für byte zurückkommen, so wie er es aus Delphi immer tat