Technisch artikel

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

Vóór v3.539.24 comprimeerde de Free Pascal-build van PDF Library for Delphi grote streams in chunks van 64 KB en kreeg elke chunk zijn eigen zlib-header en checksum, zodat een bijlage van 1 MiB uit zestien achter elkaar geplakte zlib-members bestond. 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 verdween. De fix houdt één zlib-state in leven over alle chunks heen, zowel in DeflateStream als in InflateStream

De bug bestond alleen in het FPC-deel van de unit, en alleen op het chunked streaming-pad, en dat is precies waarom hij kon overleven: de Delphi-tak was altijd correct, de stringgebaseerde helpers waren altijd correct, en kleine testpayloads bereikten de chunked code helemaal niet. Hij is een naaste verwant van de defecten uit vijf FPC-portagebugs die Delphi altijd had verborgen, met dit verschil dat de Delphi-build hier niets wegstopte. De FPC-tak was simpelweg geschreven vanuit 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 oude FPC-DeflateStream las de bron per 64 KB en gaf elke chunk door aan ZFPCCompress, een helper die zijn eigen deflateInit2 draait, met Z_FINISH comprimeert en deflateEnd aanroept. Elke chunk kwam er dus uit als een complete, geldige, zichzelf afsluitende zlib-stream, en de functie plakte ze aan elkaar tot één AnsiString voordat die weggeschreven werd. Het resultaat leek op Flate-data, had een correcte header en decodeerde zonder foutmelding, maar decodeerde wel tot precies de eerste 65.536 bytes. Aan de leeszijkant had InflateStream het spiegelbeeldige defect: hij riep ZFPCInflate aan per 64 KB gecomprimeerde invoer, en elke aanroep startte 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 werd alleen zo ver gedecodeerd als zijn eerste 64 KB gecomprimeerde bytes reikten

Defect van de chunked writer in de FPC-DeflateStream van PDFlibPas: elke chunk van 64 KB gaat als complete zlib-member door ZFPCCompress, zodat een bijlage van 1 MiB zestien aaneengeplakte members bevat, een reader stopt bij de eerste trailer na 65.536 bytes en GetEmbeddedFileContentToStream meldt desondanks succes
De schade bleef onzichtbaar omdat elke laag slaagde: de bijlage-dictionary adverteerde de volledige /Params /Size, het decoderen gaf geen foutmelding, en pas iemand die de bijlage opende viel de afkapping op
// FPC-DeflateStream vóór v3.539.24 (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 bereikten het chunked pad?

Op FPC werd elk embedded file van 1 MiB of groter verkeerd weggeschreven, en werd elke via de streaming-API opgehaalde Flate-stream verkeerd gelezen zodra zijn gecomprimeerde grootte één chunk passeerde. 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 nooit last had. De filterketen ASCII85-plus-Flate slaat de sizetest over en gaat altijd via DeflateStream, dus op dat pad werd elke payload groter dan 64 KB al in meerdere members gesplitst. 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

De mislukking was in elke laag geruisloos. De writer bewaart /Params /Size en een MD5-/CheckSum berekend uit het originele bestand, dus de bijlage-dictionary adverteerde de volledige grootte terwijl de stream zestien members bevatte. Een Delphi-build van dezelfde library die dat bestand las stopte netjes bij de eerste Z_STREAM_END en gaf precies 65.536 bytes terug. GetEmbeddedFileContentToStream gaf 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 herstelde 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 nu dezelfde structuur die de Delphi-tak altijd al had. 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 geen tweede volledige kopie van de gecomprimeerde data meer in het geheugen voordat die naar het doel gekopieerd wordt

Herstelde DeflateStream 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 nu ook grote embedded files op FPC
// FPC-DeflateStream vanaf v3.539.24 (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 kreeg de symmetrische herschrijving: éé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 moet kennen. De oude chunked writer hardcodeerde level 6, terwijl de nieuwe PLDeflateLevel volgt, dus een level dat via TPDFlib.SetCompressionLevel(1..9) gezet is geldt nu ook voor grote embedded files op FPC. 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 fix is zo geverifieerd: 200 KB testdata, verspreid over vier chunks van 64 KB, ging door de nieuwe DeflateStream en kwam eruit als één zlib-stream van 534 bytes, een standaard zlib-decoder haalde alle 200.000 bytes terug zonder resterende invoer, en dezelfde controle slaagde op de i386-crossgecompileerde FPC-doelomgeving. De routine hieronder is de FPC-versie van die controle, rechtstreeks op paszlib gebouwd zodat hij de code die getest wordt niet vertrouwt

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 het oorspronkelijke defect bleef op Delphi onzichtbaar en trad alleen op FPC op

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;

Wat verandert de fix niet?

De FPC-tak behoudt zijn soepele decoderingsregels, en hij herstelt geen bestanden die eerdere FPC-builds al geschreven hebben. 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 geschreven door een FPC-build ouder dan v3.539.24 bevat nog steeds aaneengeregen members, en de gecorrigeerde reader stopt, net als elke andere 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 eindigt bovendien nog steeds op de eerste leesactie die minder dan een volle chunk teruggeeft, wat alleen voor streams als TFileStream en TMemoryStream een end-of-data-signaal is, dus een eigen TStream die aan AddAssociatedFileFromStream wordt meegegeven kopieert u het veiligst eerst naar een TMemoryStream. De Delphi-tak, de helpers DeflateStr en InflateStr, en elke stream kleiner dan 1 MiB op het gewone Flate-pad gedragen zich precies zoals voorheen

De gecorrigeerde FPC-DeflateStream en InflateStream zitten in v3.539.24 van PDF Library for Delphi, die Delphi, C++Builder en Free Pascal vanuit één source tree bedient, en waar een grote bijlage nu byte voor byte terug moet komen uit een FPC-build, op dezelfde manier als hij dat altijd al uit Delphi deed