Ein Chunked-FlateDecode-Writer auf Free Pascal muss einen einzigen zlib-State über alle Blöcke hinweg am Leben halten. Der verlockende Kurzweg, jeden 64-KB-Block für sich zu komprimieren, verpasst jedem Block seinen eigenen zlib-Header und seine eigene Checksumme, sodass aus einem 1-MiB-Anhang sechzehn aneinandergeklebte zlib-Member werden. ISO 32000-1 FlateDecode erwartet exakt einen zlib-Stream, und ein standardkonformer Decoder hält am ersten Ende-des-Streams-Marker an, womit alles nach den ersten 65.536 Bytes stillschweigend verschwindet. Die PDF Library for Delphi hält in DeflateStream und InflateStream durchgehend einen zlib-State über alle Blöcke hinweg am Leben
Die Falle existiert nur auf der FPC-Seite einer Cross-Compiler-Unit und nur auf dem Chunked-Streaming-Pfad – genau deshalb ist sie so leicht zu übersehen: Der Delphi-Zweig und die stringbasierten Helfer liefern jeweils einen einzelnen zlib-Stream, und kleine Test-Payloads erreichen den Chunked-Code überhaupt nie. Sie ist ein enger Verwandter der Fallen in fünf FPC-Portierungs-Fallen, die Delphi gerne versteckt, nur dass hier Delphi nichts überdeckt. Der Kurzweg beruht schlicht auf einem falschen mentalen Modell davon, 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 naive FPC-DeflateStream liest die Quelle häppchenweise in 64 KB und übergibt jeden Block an ZFPCCompress, einen Helfer, der sein eigenes deflateInit2 ausführt, mit Z_FINISH komprimiert und deflateEnd aufruft. Jeder Block kommt also als kompletter, gültiger, selbst terminierter zlib-Stream heraus, und die Funktion reiht sie in einen AnsiString ein, bevor sie ihn wegschreibt. Das Ergebnis sieht wie Flate-Daten aus, hat einen korrekten Header und dekodiert ohne Fehler – aber eben nur die ersten 65.536 Bytes. Auf der Leseseite wartet die spiegelbildliche Falle: Ein ZFPCInflate-Aufruf pro 64 KB komprimierten Inputs startet jedes Mal 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 einem anderen Producer wird nur so weit dekodiert, wie seine ersten 64 KB komprimierte Bytes reichen
// Naives chunked FPC DeflateStream (vereinfacht):
// ZFPCCompress macht deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// jeder 64-KB-Block wird so 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 landen auf dem Chunked-Pfad?
Auf FPC schreibt der Chunked-Pfad jede eingebettete Datei ab 1 MiB, und er liest jeden Flate-Stream, der über die Streaming-API extrahiert wird, sobald seine komprimierte Größe einen Block überschreitet. 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 alles in einem Rutsch komprimiert. Die Filterkette aus ASCII85 plus Flate überspringt den Größentest und geht immer durch DeflateStream, auf diesem Pfad spannt also jede Payload über 64 KB bereits mehrere Blöcke. 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
Wer diesen Kurzweg geht, scheitert auf jeder Ebene leise. Der Writer speichert /Params /Size und eine aus der Originaldatei berechnete MD5-/CheckSum, das Anhang-Dictionary bewirbt also die volle Größe, während der Stream sechzehn Member enthält. Jeder standardkonforme Reader, auch ein Delphi-Build der Bibliothek, hält sauber am ersten Z_STREAM_END an und liefert exakt 65.536 Bytes zurück. GetEmbeddedFileContentToStream gibt 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 DeflateStream in PDFlibZLib.pas initialisiert genau einen 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 dieselbe Struktur wie der Delphi-Zweig. Er schreibt die Ausgabe auch, sobald sie entsteht, statt das gesamte komprimierte Ergebnis erst in einen AnsiString einzureihen, sodass der Writer nie eine zweite vollständige Kopie der komprimierten Daten im Speicher anlegt, bevor er sie ans Ziel kopiert
// FPC DeflateStream mit einem zlib-State (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 folgt demselben Muster: 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. Weil der Writer deflateInit2 selbst aufruft, statt über einen Helfer mit festem Level zu gehen, ehrt er PLDeflateLevel, sodass ein über TPDFlib.SetCompressionLevel(1..9) gesetzter Level 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. Die Routine unten ist die FPC-Version dieses Checks, direkt auf paszlib aufgebaut, damit sie dem getesteten Code nicht vertraut – gefolgt von einem Probe-Lauf, der 200.000 Bytes, vier 64-KB-Blöcke, durch DeflateStream schickt
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 diese Fehlerklasse besteht den Test auf Delphi und schlägt 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;
Randfälle im FPC-Zweig
Der FPC-Zweig arbeitet mit großzügigen Dekodierregeln. 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, das bereits konkatenierte Member enthält, gleichgültig wer es geschrieben hat, hält in jedem 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 behandelt einen Read, der weniger als einen vollen Block liefert, als Ende der Daten, was für Streams wie TFileStream und TMemoryStream stimmt; ein eigener TStream, der an AddAssociatedFileFromStream geht, ist am sichersten zuerst in einen TMemoryStream kopiert
Single-State-DeflateStream und InflateStream für FPC gehören zur PDF Library for Delphi, die Delphi, C++Builder und Free Pascal aus einem Quellbaum bedient, sodass ein großer Anhang aus einem FPC-Build byte für byte zurückkommt, genau wie aus Delphi