Technisch artikel

Chunked zlib op FPC: waarom FlateDecode één stream wil

Een chunked FlateDecode-writer onder Free Pascal moet één zlib-state in leven houden over alle chunks heen. De verleidelijke shortcut, elke chunk van 64 KB afzonderlijk comprimeren, geeft elke chunk zijn eigen zlib-header en checksum, zodat een bijlage van 1 MiB uit zestien achter elkaar geplakte zlib-members bestaat. ISO 32000-1 verwacht bij FlateDecode precies één zlib-stream, en een standaarddecoder stopt bij de eerste end-of-stream-marker, wat betekent dat alles na de eerste 65.536 bytes geruisloos verdwijnt. PDF Library for Delphi houdt één zlib-state in leven over alle chunks heen, zowel in DeflateStream als in InflateStream

De valkuil bestaat alleen in het FPC-deel van een cross-compiler-unit, en alleen op het chunked streaming-pad, en dat is precies waarom hij zo makkelijk te missen is: de Delphi-tak en de stringgebaseerde helpers produceren elk een enkele zlib-stream, en kleine testpayloads bereiken de chunked code helemaal niet. Hij is een naaste verwant van de valkuilen uit vijf FPC-portagevalkuilen die Delphi vaak verborgen houdt, met dit verschil dat Delphi hier niets wegstopt. De shortcut berust simpelweg op een verkeerd beeld van wat een zlib-stream eigenlijk is

Waarom kappen andere PDF-readers een chunked zlib-stream af?

Omdat een zlib-stream zoals RFC 1950 die definieert één container is, geen reeks containers, en een conforme inflater de eerste Adler-32-trailer als het einde van de data behandelt. Het formaat bestaat uit een header van twee bytes, één doorlopende RFC 1951-deflate-bitstream waarvan het laatste blok de final-block-flag draagt, en een Adler-32-checksum van vier bytes over alle ongecomprimeerde bytes. ISO 32000-1 §7.4.4 definieert /FlateDecode precies in die termen. Zodra inflate de trailer bereikt geeft hij Z_STREAM_END terug en laat hij resterende invoer ongelezen in avail_in staan. Vanuit zijn perspectief is er niets aan de hand, dus hij geeft geen foutmelding, en de bytes na de trailer worden simpelweg genegeerd. Aaneengeregen members zijn een legitiem idee in gzip (RFC 1952 staat meerdere members in één bestand toe), en daar komt de intuïtie waarschijnlijk vandaan, maar zlib heeft zo'n regel niet en PDF heeft er nooit om gevraagd

Anatomie van een RFC 1950-zlib-member in PDFlibPas FlateDecode-streams: een header van twee bytes, één doorlopende deflate-bitstream waarvan het laatste blok de final-block-flag draagt, en een Adler-32-trailer van vier bytes waar inflate Z_STREAM_END teruggeeft en resterende invoer ongelezen in avail_in laat staan zonder foutmelding
Een conforme inflater behandelt de eerste Adler-32-trailer als het einde van de data, dus een membergrens is een hard stoppunt en alles wat de schrijver erachter plakte is ballast die geen enkele reader decodeert

De naïeve FPC-DeflateStream leest de bron per 64 KB en geeft elke chunk door aan ZFPCCompress, een helper die zijn eigen deflateInit2 draait, met Z_FINISH comprimeert en deflateEnd aanroept. Elke chunk komt er dus uit als een complete, geldige, zichzelf afsluitende zlib-stream, en de functie plakt ze aan elkaar tot één AnsiString voordat die weggeschreven wordt. Het resultaat lijkt op Flate-data, heeft een correcte header en decodeert zonder foutmelding, maar decodeert wel tot precies de eerste 65.536 bytes. Aan de leeszijkant wacht het spiegelbeeld van dezelfde valkuil: ZFPCInflate aanroepen per 64 KB gecomprimeerde invoer start telkens een verse inflateInit2. De tweede chunk begint midden in een deflate-bitstream zonder zlib-header, dus een nieuwe inflater wijst hem af, en een volstrekt normale single-member-stream van een willekeurige andere producent wordt alleen zo ver gedecodeerd als zijn eerste 64 KB gecomprimeerde bytes reiken

Valkuil van de chunked writer op FPC: gaat elke chunk van 64 KB als complete zlib-member door ZFPCCompress, dan bevat een bijlage van 1 MiB zestien aaneengeplakte members, stopt een reader bij de eerste trailer na 65.536 bytes en meldt GetEmbeddedFileContentToStream desondanks succes
De schade blijft onzichtbaar omdat elke laag slaagt: de bijlage-dictionary adverteert de volledige /Params /Size, het decoderen geeft geen foutmelding, en pas iemand die de bijlage opent valt de afkapping op
// Naïeve chunked FPC-DeflateStream (vereenvoudigd):
// ZFPCCompress doet deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// dus elke chunk van 64 KB wordt een apart 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));

Welke aanroepen in PDF Library for Delphi bereiken het chunked pad?

Op FPC schrijft het chunked pad elk embedded file van 1 MiB of groter, en leest het elke via de streaming-API opgehaalde Flate-stream zodra zijn gecomprimeerde grootte één chunk passeert. TPDFStream.ReadFromStream bepaalt hoe inkomende data gecodeerd wordt. Is Deflate waar en geldt Stream.Size >= 1048576, dan streamt hij via DeflateStream; onder die drempel leest hij de hele bron in het geheugen en roept hij DeflateStr aan, de single-shot-helper die alles in één aanroep comprimeert. De filterketen ASCII85-plus-Flate slaat de sizetest over en gaat altijd via DeflateStream, dus op dat pad beslaat elke payload groter dan 64 KB al meerdere chunks. De publieke toegangspunten die ReadFromStream voeden met compressie ingeschakeld zijn de embedded-file-writers:

  • TPDFlib.EmbedFile en TPDFlib.AddEmbeddedFile, die een bestand van schijf inlezen in een /EmbeddedFile-stream
  • TPDFlib.AddAssociatedFileFromStream en TPDFlib.AddAssociatedFileFromFile, de PDF/A-3-associated-file-writers voor e-factuur-XML en andere brongegevens
  • Aan de leeszijkant TPDFlib.GetEmbeddedFileContentToStream en GetEmbeddedFileContentToFile, die decoderen via TPDFStream.WriteDecodedToStream en daarna via InflateStream

Wie dit verkeerd doet, ziet op elke laag niets mis gaan. De writer bewaart /Params /Size en een MD5-/CheckSum berekend uit het originele bestand, dus de bijlage-dictionary adverteert de volledige grootte terwijl de stream zestien members bevat. Elke conformerende reader, ook een Delphi-build van de library, stopt netjes bij de eerste Z_STREAM_END en geeft precies 65.536 bytes terug. GetEmbeddedFileContentToStream geeft 1 terug, want hij meldt of het decoderen een fout opleverde, niet of de uitvoer met /Size overeenkomt. Wie ooit een groot-documentenprobleem najoeg langs het samenvoegen en splitsen van gigabyte-PDF’s kent dit patroon: het bestand opent, het paginanaantal klopt, en de schade komt pas boven water wanneer iemand de bijlage opent

Eén deflate-state over alle chunks heen

De DeflateStream in PDFlibZLib.pas initialiseert één paszlib.TZStream, voedt elke chunk aan deflate met Z_NO_FLUSH, en spoelt aan het einde de compressor pas leeg met Z_FINISH tot die Z_STREAM_END teruggeeft. Dat levert precies één header op, één deflate-bitstream waarvan de back-references over chunkgrenzen heen kunnen reiken, en één Adler-32 over de hele invoer. De FPC-tak heeft dezelfde structuur als de Delphi-tak. Hij schrijft de uitvoer bovendien weg naarmate die ontstaat, in plaats van eerst het hele gecomprimeerde resultaat in een AnsiString aan elkaar te plakken, dus de writer bouwt nooit een tweede volledige kopie van de gecomprimeerde data in het geheugen voordat die naar het doel gekopieerd wordt

DeflateStream met één state in PDFlibPas: één paszlib.TZStream één keer geïnitialiseerd, elke chunk van 64 KB gevoed met Z_NO_FLUSH en een afsluitende Z_FINISH-drain, wat precies één header oplevert, één doorlopende deflate-bitstream waarvan de back-references chunkgrenzen oversteken en één Adler-32 over de hele invoer
Eén state verandert ook hoe de uitvoer weggeschreven wordt: gecomprimeerde bytes vertrekken zodra elke buffer vol zit in plaats van zich op te hopen in een tweede volledige kopie, en PLDeflateLevel bereikt ook grote embedded files op FPC
// FPC-DeflateStream met één zlib-state (foutpaden weggelaten)
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);   // zelfde state, geen member-breuk
        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                                    // één trailer voor de hele invoer
    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 volgt hetzelfde patroon: één inflateInit2, een binnenlus die inflate blijft aanroepen tot de huidige chunk verbruikt is en de uitvoerbuffer niet langer vol zit, en een stop op Z_STREAM_END. Er is één bijwerking die u goed moet weten. Omdat de writer deflateInit2 zelf aanroept in plaats van via een helper met vast level, volgt hij PLDeflateLevel, dus een level dat via TPDFlib.SetCompressionLevel(1..9) gezet is geldt op FPC ook voor grote embedded files. Dat is relevant als u compressie al afstemt op archiefuitvoer, zoals beschreven in het verkleinen van PDF-bestanden in Delphi

Hoe verifieert u dat een Flate-stream één enkele zlib-member is?

Inflateer hem met een gewone zlib-decoder en controleer twee dingen op het moment dat die Z_STREAM_END teruggeeft: de gedecodeerde lengte is gelijk aan de bronnengte, en avail_in is nul. Invoer die na de eindmarker overblijft is de signatuur van een aaneengeregen stream. De routine hieronder is de FPC-versie van die controle, rechtstreeks op paszlib gebouwd zodat hij de code die getest wordt niet vertrouwt, gevolgd door een probe die 200.000 bytes, vier chunks van 64 KB, door DeflateStream drukt

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;
    // Eén member eindigt precies op de laatste invoerbyte
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Duw 200.000 bytes door DeflateStream met de standaard chunk van 64 KB
// en eis één member die terug decodeert tot de volledige lengte
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');

Op applicatieniveau is de nuttige assertie degene die de library niet voor u maakt: vergelijk wat eruit komt van een bijlage met de /Params /Size die bij het erin stoppen is vastgelegd. GetEmbeddedFileIntProperty met tag 5 geeft die vastgelegde grootte terug, embedded-file-indexen zijn 1-based, en de payload moet minstens 1 MiB zijn om het streamingpad te raken. Draai dezelfde test op elke compiler waar u mee uitlevert, want deze klasse fouten gaat op Delphi goed en mislukt alleen op 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 MiB of meer neemt het chunked DeflateStream-pad in 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;

Randgevallen in de FPC-tak

De FPC-tak hanteert soepele decoderingsregels. Als MaxOutput bereikt is, kapt FPC-InflateStream op de grens af en keert terug, terwijl de Delphi-tak een ERangeError gooit, en FPC accepteert nog steeds gedeeltelijk gedecodeerde uitvoer wanneer inflate een datafout meldt, omdat sommige PDF-producenten afgekapte of checksum-gebroken streams uitzenden. Een PDF die al aaneengeregen members bevat, van welke schrijver dan ook, stopt in elke reader bij de eerste Z_STREAM_END. Probeer zo’n bestand niet te genezen door de stream binnen de library te decoderen en opnieuw te encoderen, want dat maakt de afkapping van 64 KB alleen maar permanent. Embed de bijlage opnieuw vanaf zijn oorspronkelijke bron. De FPC-lus behandelt een leesactie die minder dan een volle chunk teruggeeft als het einde van de data, wat klopt voor streams als TFileStream en TMemoryStream, dus een eigen TStream die aan AddAssociatedFileFromStream wordt meegegeven kopieert u het veiligst eerst naar een TMemoryStream

De FPC-DeflateStream en InflateStream met één state zitten in PDF Library for Delphi, die Delphi, C++Builder en Free Pascal vanuit één source tree bedient, dus een grote bijlage komt uit een FPC-build byte voor byte terug, precies zoals uit Delphi