Technischer Artikel

Chunked zlib auf FPC: Warum FlateDecode einen Stream braucht

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

RFC-1950-zlib-Member-Anatomie in PDFlibPas-FlateDecode-Streams: ein Zwei-Byte-Header, ein durchgehender Deflate-Bitstrom, dessen letzter Block das Final-Block-Flag trägt, und ein Vier-Byte-Adler-32-Trailer, an dem inflate Z_STREAM_END zurückliefert, verbleibenden Input ungelesen in avail_in lässt und keinen Fehler wirft
Ein konformer Inflater behandelt den ersten Adler-32-Trailer als Ende der Daten, eine Member-Grenze ist also ein harter Stopp, und alles, was ein Writer dahinter anklebt, ist totes Gewicht, das kein Reader dekodiert

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

Chunked-Writer-Falle auf FPC: Läuft jeder 64-KB-Block als komplettes zlib-Member durch ZFPCCompress, enthält ein 1-MiB-Anhang sechzehn verklebte Member, ein Reader hält am ersten Trailer nach 65.536 Bytes an, und GetEmbeddedFileContentToStream meldet trotzdem Erfolg
Der Schaden bleibt unsichtbar, weil jede Ebene Erfolg meldet: Das Anhang-Dictionary bewirbt die volle /Params /Size, das Dekodieren wirft keinen Fehler, und nur wer den Anhang öffnet, bemerkt die Abschneidung
// 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.EmbedFile und TPDFlib.AddEmbeddedFile, die eine Datei von der Platte in einen /EmbeddedFile-Stream lesen
  • TPDFlib.AddAssociatedFileFromStream und TPDFlib.AddAssociatedFileFromFile, die PDF/A-3-Writer für zugeordnete Dateien, im Einsatz für E-Rechnungs-XML und andere Quelldaten
  • Auf der Leseseite TPDFlib.GetEmbeddedFileContentToStream und GetEmbeddedFileContentToFile, die über TPDFStream.WriteDecodedToStream und von dort über InflateStream dekodieren

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

Single-State-DeflateStream in PDFlibPas: Ein einmal initialisierter paszlib.TZStream, jeder 64-KB-Block mit Z_NO_FLUSH gefüttert und ein abschließender Z_FINISH-Abfluss, Ergebnis exakt ein Header, ein durchgehender Deflate-Bitstrom, dessen Rückreferenzen Blockgrenzen überschreiten, und eine Adler-32 über die gesamte Eingabe
Ein State ändert auch, wie die Ausgabe geschrieben wird: Komprimierte Bytes fließen ab, sobald jeder Buffer voll ist, statt sich in einer zweiten Vollkopie zu sammeln, und PLDeflateLevel erreicht große Embedded Files auch auf FPC
// 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