Technischer Artikel

Chunked zlib auf FPC: Warum FlateDecode einen Stream braucht

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

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 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

Chunked-Writer-Defekt im FPC-DeflateStream von PDFlibPas: Jeder 64-KB-Block läuft als komplettes zlib-Member durch ZFPCCompress, ein 1-MiB-Anhang enthält also sechzehn verklebte Member, ein Reader hält am ersten Trailer nach 65.536 Bytes an, und GetEmbeddedFileContentToStream meldet trotzdem Erfolg
Der Schaden blieb unsichtbar, weil jede Ebene Erfolg meldete: Das Anhang-Dictionary bewarb die volle /Params /Size, das Dekodieren warf keinen Fehler, und nur wer den Anhang öffnete, bemerkte die Abschneidung
// 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.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

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

Repariertes 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 auf FPC jetzt ebenfalls
// 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