Teknisk artikel

Chunked zlib på FPC: Hvorfor FlateDecode kræver én stream

Før v3.539.24 komprimerede Free Pascal-bygningen af PDF Library for Delphi store streams i 64 KB-chunks og gav hver chunk sit eget zlib-header og checksum, så en vedhæftet fil på 1 MiB blev til seksten zlib-members sat sammen ende mod ende. ISO 32000-1 FlateDecode forventer præcis én zlib-stream, og en standard-decoder stopper ved den første end-of-stream-marker, hvilket betyder, at alt efter de første 65.536 bytes lydløst forsvandt. Fixet holder én zlib-tilstand i live på tværs af alle chunks i både DeflateStream og InflateStream

Fejlen fandtes kun på unitens FPC-side, og kun på den chunkede streamingsti, hvilket præcis er grunden til, at den overlevede: Delphi-grenen var altid korrekt, de strengbaserede helpers var altid korrekte, og små test-payloads nåede aldrig frem til den chunkede kode overhovedet. Den er tæt beslægtet med fejlene i fem FPC-porteringsfejl, som Delphi havde skjult, blot var Delphi-bygningen her ikke med til at dække over noget. FPC-grenen var simpelthen skrevet ud fra en forkert mental model af, hvad en zlib-stream er

Hvorfor bliver en chunked zlib-stream afkortet af andre PDF-readers?

For en zlib-stream som defineret i RFC 1950 er én container, ikke en sekvens af dem, og en korrekt inflater behandler den første Adler-32-trailer som enden på dataene. Formatet er en header på to bytes, én sammenhængende RFC 1951 deflate-bitstream, hvis sidste blok bærer final-block-flaget, og en Adler-32-checksum på fire bytes over alle de ukomprimerede bytes. ISO 32000-1 §7.4.4 definerer /FlateDecode i netop de termer. Når inflate når traileren, returnerer den Z_STREAM_END og efterlader eventuelt resterende input ulæst i avail_in. Set fra dens synsvinkel er der intet galt, så den rejser ingen fejl, og bytesene efter traileren ignoreres simpelthen. Members sat sammen i kæde er en legitim idé i gzip (RFC 1952 tillader flere members i én fil), og dér kommer nok intuitionen fra, men zlib har ingen sådan regel, og PDF har aldrig bedt om en

Anatomien af et RFC 1950 zlib-member i PDFlibPas FlateDecode-streams: en header på to bytes, én sammenhængende deflate-bitstream, hvis sidste blok bærer final-block-flaget, og en Adler-32-trailer på fire bytes, hvor inflate returnerer Z_STREAM_END og efterlader resterende input ulæst i avail_in uden at rejse fejl
En korrekt inflater behandler den første Adler-32-trailer som enden på dataene, så en member-grænse er et hårdt stop, og alt, hvad en writer har limet på bagefter, er dødvægt, ingen reader gider dekode

Den gamle FPC-DeflateStream læste kilden 64 KB ad gangen og gav hver chunk videre til ZFPCCompress, en helper, der kører sin egen deflateInit2, komprimerer med Z_FINISH og kalder deflateEnd. Hver chunk kom derfor ud som en komplet, gyldig, selvterminerende zlib-stream, og funktionen satte dem sammen til én AnsiString, før den skrev den ud. Resultatet lignede Flate-data, havde en korrekt header og dekodede uden fejl, men det dekodede til de første 65.536 bytes og ikke mere. På læsesiden havde InflateStream spejlbilledet af fejlen: den kaldte ZFPCInflate én gang pr. 64 KB komprimeret input, og hvert kald startede en frisk inflateInit2. Den anden chunk begynder midt i en deflate-bitstream uden zlib-header, så en ny inflater afviser den, og en helt normal single-member-stream fra enhver anden producent blev kun dekodet, så langt dens første 64 KB komprimerede bytes rakte

Chunked-writer-defekten i FPC DeflateStream i PDFlibPas: hver 64 KB-chunk går gennem ZFPCCompress som et komplet zlib-member, så en vedhæftet fil på 1 MiB indeholder seksten limede members, en reader stopper ved den første trailer efter 65.536 bytes, og GetEmbeddedFileContentToStream rapporterer stadig succes
Skaden forblev usynlig, fordi alle lag havde succes: attachment-dictionaryen annoncerede den fulde /Params /Size, dekodningen rejste ingen fejl, og først da nogen åbnede den vedhæftede fil, blev afkortningen opdaget
// FPC DeflateStream før v3.539.24 (forenklet):
// ZFPCCompress kører deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// så hver 64 KB-chunk bliver et separat 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));

Hvilke PDF Library for Delphi-kald ramte den chunkede sti?

På FPC blev enhver embedded fil på 1 MiB eller mere skrevet forkert, og enhver Flate-stream, der blev pakket ud gennem streaming-API'en, blev læst forkert, så snart dens komprimerede størrelse overskred én chunk. TPDFStream.ReadFromStream beslutter, hvordan indkommende data encodes. Når Deflate er true og Stream.Size >= 1048576, streamer den gennem DeflateStream; under den tærskel læser den hele kilden ind i hukommelsen og kalder DeflateStr, single-shot-helpers, der aldrig blev berørt. ASCII85-plus-Flate-filterkæden springer størrelsestjekket over og går altid gennem DeflateStream, så på den sti var enhver payload større end 64 KB allerede delt op i flere members. De offentlige entry points, der føder ReadFromStream med komprimering slået til, er embedded-file-writerne:

  • TPDFlib.EmbedFile og TPDFlib.AddEmbeddedFile, som læser en fil fra disk ind i en /EmbeddedFile-stream
  • TPDFlib.AddAssociatedFileFromStream og TPDFlib.AddAssociatedFileFromFile, PDF/A-3 associated-file-writerne, der bruges til e-faktura-XML og andre kildedata
  • På læsesiden TPDFlib.GetEmbeddedFileContentToStream og GetEmbeddedFileContentToFile, som decoder gennem TPDFStream.WriteDecodedToStream og derfra gennem InflateStream

Fejlen var stille i hvert lag. Writeren gemmer /Params /Size og en MD5-/CheckSum beregnet ud fra den oprindelige fil, så attachment-dictionaryen annoncerede den fulde størrelse, mens streamen indeholdt seksten members. En Delphi-bygning af samme bibliotek, der læste filen, stoppede rent ved den første Z_STREAM_END og returnerede præcis 65.536 bytes. GetEmbeddedFileContentToStream returnerede 1, fordi den rapporterer, om dekodningen rejste en fejl, ikke om outputtet matcher /Size. Enhver, der har jaget et stor-dokument-problem gennem merging og splitting af gigabyte-PDF'er, kender mønstret: filen åbner, sideantallet stemmer, og skaden viser sig først, når nogen åbner den vedhæftede fil

Én deflate-tilstand på tværs af alle chunks

Den rettede DeflateStream i PDFlibZLib.pas initialiserer én paszlib.TZStream, føder hver chunk til deflate med Z_NO_FLUSH og tapper først til sidst kompressoren med Z_FINISH, indtil den returnerer Z_STREAM_END. Det giver præcis én header, én deflate-bitstream, hvis back-references kan nå på tværs af chunk-grænser, og én Adler-32 over hele inputtet. FPC-grenen har nu samme struktur, som Delphi-grenen altid har haft. Den skriver også output ud, efterhånden som det produceres, i stedet for først at samle hele det komprimerede resultat i en AnsiString, så writeren ikke længere bygger en fuld kopi af de komprimerede data i hukommelsen, før den kopierer den til target

Rettede DeflateStream i PDFlibPas: én paszlib.TZStream initialiseret én gang, hver 64 KB-chunk født med Z_NO_FLUSH og en endelig Z_FINISH-drain, hvilket giver præcis én header, én sammenhængende deflate-bitstream, hvis back-references krydser chunk-grænser, og én Adler-32 over hele inputtet
Én tilstand ændrer også, hvordan output skrives: komprimerede bytes forlader kompressoren, efterhånden som hver buffer fyldes, i stedet for at samle sig i en anden fuld kopi, og PLDeflateLevel rammer nu også store embeddede filer på FPC
// FPC DeflateStream fra v3.539.24 (fejlstier trimmet)
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);   // samme tilstand, intet member-skift
        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 for hele inputtet
    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 fik den symmetriske omskrivning: én inflateInit2, en indre løkke, der bliver ved med at kalde inflate, til den aktuelle chunk er konsumeret og output-bufferen ikke længere er fuld, og et stop ved Z_STREAM_END. Der er én bivirkning, du skal kende. Den gamle chunkede writer hardcodede level 6, mens den nye respekterer PLDeflateLevel, så et level sat gennem TPDFlib.SetCompressionLevel(1..9) gælder nu også for store embeddede filer på FPC. Det betyder noget, hvis du allerede tuner komprimering til arkival-output som beskrevet i at reducere PDF-filstørrelsen i Delphi

Hvordan verificerer du, at en Flate-stream er ét enkelt zlib-member?

Inflat den med en almindelig zlib-decoder og tjek to ting, når den returnerer Z_STREAM_END: den dekodede længde svarer til kildens længde, og avail_in er nul. Input tilbage efter end-markeren er signaturen på en stream sat sammen af flere members. Fixet blev verificeret sådan: 200 KB testdata, der spænder over fire 64 KB-chunks, gik gennem den nye DeflateStream og kom ud som én enkelt zlib-stream på 534 bytes, en standard zlib-decoder gengav alle 200.000 bytes uden resterende input, og samme tjek bestod på FPC-targetet cross-kompileret til i386. Rutinen nedenfor er FPC-versionen af det tjek, bygget direkte på paszlib, så den ikke stoler på koden under test

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;
    // Ét member slutter præcis ved den sidste input-byte
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Push 200.000 bytes gennem DeflateStream med standard 64 KB-chunken
// og kræv ét member, der dekoder tilbage til den fulde længde
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');

På applikationsniveau er den nyttige assertion den, biblioteket ikke laver for dig: sammenlign, hvad der kommer ud af en vedhæftet fil, med den /Params /Size, der blev registreret, da den kom ind. GetEmbeddedFileIntProperty med tag 5 returnerer den registrerede størrelse, embedded-file-indekser er 1-baserede, og payloaden skal være mindst 1 MiB for at ramme streamingstien. Kør den samme test på hver compiler, du sender med, da den oprindelige fejl bestod på Delphi og kun fejlede på 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 eller mere tager den chunkede DeflateStream-sti i 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;

Hvad ændrer fixet ikke?

FPC-grenen beholder sine tolerante dekoderingsregler, og den reparerer ikke filer, som tidligere FPC-builds allerede har skrevet. Når MaxOutput nås, afkorter FPC-InflateStream ved grænsen og returnerer, mens Delphi-grenen rejser ERangeError, og FPC accepterer stadig delvist dekodet output, når inflate rapporterer en datafejl, fordi nogle PDF-producenter udsender afkortede eller checksum-brudte streams. En PDF skrevet af en FPC-build før v3.539.24 indeholder stadig members sat sammen i kæde, og den rettede reader stopper, som enhver anden reader, ved den første Z_STREAM_END. Forsøg ikke at hele sådan en fil ved at dekode og re-encode streamen inde i biblioteket, for det gør kun 64 KB-afkortningen permanent. Embed i stedet den vedhæftede fil igen fra dens oprindelige kilde. FPC-løkken slutter stadig ved den første læsning, der returnerer mindre end en fuld chunk, hvilket kun er et end-of-data-signal for streams som TFileStream og TMemoryStream, så en custom TStream, der sendes til AddAssociatedFileFromStream, er sikrest kopieret ind i en TMemoryStream først. Delphi-grenen, DeflateStr- og InflateStr-helpersne og enhver stream under 1 MiB på den rene Flate-sti opfører sig præcis som før

Den rettede FPC-DeflateStream og InflateStream følger med i v3.539.24 af PDF Library for Delphi, som targeter Delphi, C++Builder og Free Pascal fra ét source tree, og hvor en stor vedhæftet fil nu skulle komme tilbage fra en FPC-build byte for byte, på samme måde som den altid har gjort fra Delphi