Teknisk artikkel

Chunked zlib på FPC: Hvorfor FlateDecode trenger én strøm

Før v3.539.24 komprimerte Free Pascal-bygget av PDF Library for Delphi store strømmer i 64 KB-biter og ga hver bit sin egen zlib-header og sjekksum, så et vedlegg på 1 MiB ble seksten zlib-medlemmer limt ende til ende. ISO 32000-1 FlateDecode forventer nøyaktig én zlib-strøm, og en standarddekoder stopper ved den første ende-av-strøm-markøren, noe som betyr at alt etter de første 65 536 bytene forsvant i stillhet. Fiksen holder én zlib-tilstand i live på tvers av alle biter i både DeflateStream og InflateStream

Feilen fantes bare på FPC-siden av uniten, og bare på den chunkede strømmingsstien, som er nøyaktig grunnen til at den overlevde: Delphi-grenen var alltid riktig, de strengbaserte hjelperne var alltid riktige, og små testdata nådde aldri frem til den chunkede koden i det hele tatt. Den er en nær slektning av feilene i fem FPC-porteringsfeil som Delphi hadde skjult, bare med den forskjell at Delphi-bygget her ikke dekket over noe. FPC-grenen var rett og slett skrevet ut fra en feil mental modell av hva en zlib-strøm er

Hvorfor blir en chunked zlib-strøm avkortet av andre PDF-lesere?

Fordi en zlib-strøm slik RFC 1950 definerer den, er én beholder, ikke en rekke av dem, og en regelrett inflater behandler den første Adler-32-traileren som slutten på dataene. Formatet er en header på to byte, én sammenhengende RFC 1951 deflate-bitstrøm der siste blokk bærer final-block-flagget, og en Adler-32-sjekksum på fire byte over alle ukomprimerte byte. ISO 32000-1 §7.4.4 definerer /FlateDecode i nøyaktig disse termene. Når inflate når traileren, returnerer den Z_STREAM_END og lar eventuelt gjenværende input ligge ulest i avail_in. Sett fra dens ståsted er ingenting galt, så den reiser ingen feil, og bytene etter traileren ignoreres rett og slett. Sammenkjedede medlemmer er en legitim idé i gzip (RFC 1952 tillater flere medlemmer i én fil), som sikkert er der intuisjonen kom fra, men zlib har ingen slik regel, og PDF har aldri bedt om én

Anatomien til et RFC 1950 zlib-medlem i PDFlibPas FlateDecode-strømmer: en header på to byte, én sammenhengende deflate-bitstrøm der siste blokk bærer final-block-flagget, og en Adler-32-trailer på fire byte der inflate returnerer Z_STREAM_END og lar gjenværende input ligge ulest i avail_in uten å reise feil
En regelrett inflater behandler den første Adler-32-traileren som slutten på dataene, så en medlemsgrense er et hardt stopp, og alt skriveren limer på etterpå er ballast ingen leser vil dekode

Den gamle FPC-DeflateStream leste kilden 64 KB om gangen og ga hver bit videre til ZFPCCompress, en hjelper som kjører sin egen deflateInit2, komprimerer med Z_FINISH og kaller deflateEnd. Hver bit kom dermed ut som en komplett, gyldig, selvbegrenset zlib-strøm, og funksjonen kjedet dem sammen til én AnsiString før den ble skrevet ut. Resultatet så ut som Flate-data, hadde en korrekt header og dekodet uten feil, men det dekodet til de første 65 536 bytene, ikke mer. På lesesiden hadde InflateStream speilbildefeilen: den kalte ZFPCInflate én gang per 64 KB komprimert input, og hvert kall startet en fersk inflateInit2. Den andre biten begynner midt i en deflate-bitstrøm uten zlib-header, så en ny inflater avviser den, og en helt normal enkeltmedlemsstrøm fra enhver annen produsent ble dekodet bare så langt som de første 64 KB komprimerte byte bar den

Chunked-skriverfeilen i FPC DeflateStream i PDFlibPas: hver 64 KB-bit går gjennom ZFPCCompress som et komplett zlib-medlem, så et vedlegg på 1 MiB holder seksten limte medlemmer, en leser stopper ved den første traileren etter 65 536 byte, og GetEmbeddedFileContentToStream rapporterer fortsatt suksess
Skaden forble usynlig fordi alle lag lyktes: vedleggsordboken annonserte full /Params /Size, dekoding reiste ingen feil, og bare noen som åpnet vedlegget la merke til avkortingen
// FPC DeflateStream før v3.539.24 (forenklet):
// ZFPCCompress kjører deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// så hver 64 KB-bit blir et eget zlib-medlem
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-kall nådde den chunkede stien?

På FPC ble ethvert innebygget filvedlegg på 1 MiB eller mer skrevet feil, og enhver Flate-strøm hentet ut gjennom strømmings-API-et ble lest feil så snart den komprimerte størrelsen passerte én bit. TPDFStream.ReadFromStream avgjør hvordan innkommende data kodes. Når Deflate er sann og Stream.Size >= 1048576, strømmer den gjennom DeflateStream; under den terskelen leser den hele kilden inn i minnet og kaller DeflateStr, enkeltskudd-hjelperen som aldri ble berørt. Filterkjeden ASCII85 pluss Flate hopper over størrelsestesten og går alltid gjennom DeflateStream, så på den stien var allerede all nyttelast over 64 KB delt opp i flere medlemmer. De offentlige inngangspunktene som mater ReadFromStream med komprimering slått på, er skriverne for innebygde filer:

  • TPDFlib.EmbedFile og TPDFlib.AddEmbeddedFile, som leser en fil fra disk inn i en /EmbeddedFile-strøm
  • TPDFlib.AddAssociatedFileFromStream og TPDFlib.AddAssociatedFileFromFile, PDF/A-3-skriverne for tilknyttede filer som brukes til e-faktura-XML og andre kildedata
  • På lesesiden TPDFlib.GetEmbeddedFileContentToStream og GetEmbeddedFileContentToFile, som dekoder gjennom TPDFStream.WriteDecodedToStream og derfra gjennom InflateStream

Feilen var stille i hvert lag. Skriveren lagrer /Params /Size og en MD5-/CheckSum beregnet fra originalfilen, så vedleggsordboken annonserte full størrelse mens strømmen holdt seksten medlemmer. Et Delphi-bygg av samme bibliotek som leste den filen, stoppet pent ved den første Z_STREAM_END og returnerte nøyaktig 65 536 byte. GetEmbeddedFileContentToStream returnerte 1, fordi den rapporterer om dekoding reiste en feil, ikke om resultatet matcher /Size. Alle som har jaget et stort dokumentproblem gjennom sammenslåing og splitting av gigabyte-PDF-er, kjenner dette mønsteret: filen åpner seg, sideantallet stemmer, og skaden viser seg først når noen åpner vedlegget

Én deflate-tilstand på tvers av alle biter

Den reparerte DeflateStream i PDFlibZLib.pas initialiserer én paszlib.TZStream, mater hver bit til deflate med Z_NO_FLUSH, og først på slutten tapper den kompressoren med Z_FINISH til den returnerer Z_STREAM_END. Det gir nøyaktig én header, én deflate-bitstrøm der back-referansene kan nå over bitgrensene, og én Adler-32 over hele inputen. FPC-grenen har nå samme struktur som Delphi-grenen alltid har hatt. Den skriver også ut data etter hvert som de produseres, i stedet for å først kjede sammen hele det komprimerte resultatet i en AnsiString, så skriveren ikke lenger bygger en andre full kopi av de komprimerte dataene i minnet før den kopierer dem til målet

Reparert DeflateStream i PDFlibPas: én paszlib.TZStream initialisert én gang, hver 64 KB-bit matet med Z_NO_FLUSH og en endelig Z_FINISH-tapping, som gir nøyaktig én header, én sammenhengende deflate-bitstrøm der back-referansene krysser bitgrensene, og én Adler-32 over hele inputen
Én tilstand endrer også hvordan resultatet skrives: komprimerte byte slippes ut etter hvert som hver buffer fylles, i stedet for å samle seg i en andre full kopi, og PLDeflateLevel når nå store innebygde filer på FPC også
// FPC DeflateStream fra v3.539.24 (feilstier kuttet)
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, ingen medlemsbrudd
        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 inputen
    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 fikk den symmetriske omskrivingen: én inflateInit2, en indre løkke som fortsetter å kalle inflate til gjeldende bit er konsumert og utdatabufferen ikke lenger er full, og et stopp på Z_STREAM_END. Det er én bieffekt verdt å vite om. Den gamle chunkede skriveren hardkodet nivå 6, mens den nye respekterer PLDeflateLevel, så et nivå satt gjennom TPDFlib.SetCompressionLevel(1..9) gjelder nå for store innebygde filer på FPC også. Det betyr noe hvis du allerede tuner komprimering for arkivutdata, som beskrevet i å redusere PDF-filstørrelse i Delphi

Hvordan verifiserer du at en Flate-strøm er ett enkelt zlib-medlem?

Inflater den med en helt vanlig zlib-dekoder og sjekk to ting når den returnerer Z_STREAM_END: den dekodede lengden er lik kildelengden, og avail_in er null. Input som ligger igjen etter slutmarkøren er signaturen på en sammenkjedet strøm. Fiksen ble verifisert slik: 200 KB testdata, som spenner over fire 64 KB-biter, gikk gjennom den nye DeflateStream og kom ut som en enkelt zlib-strøm på 534 byte, en standard zlib-dekoder hentet tilbake alle 200 000 bytene uten gjenværende input, og samme sjekk gikk bra på det krysskompilerte i386-målet for FPC. Rutinen nedenfor er FPC-versjonen av den sjekken, bygget direkte på paszlib slik at den ikke stoler på koden som testes

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;
    // Ett medlem slutter nøyaktig ved den siste inputbyten
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Press 200 000 byte gjennom DeflateStream med standard 64 KB-bit
// og krev ett medlem som dekoder tilbake til full lengde
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å applikasjonsnivå er den nyttige påstanden den biblioteket ikke gjør for deg: sammenlign det som kommer ut av et vedlegg med /Params /Size registrert da det gikk inn. GetEmbeddedFileIntProperty med tag 5 returnerer den registrerte størrelsen, indeksene til innebygde filer er 1-baserte, og nyttelasten må være minst 1 MiB for å utløse strømmingsstien. Kjør samme test på hver kompilator du leverer med, siden den opprinnelige feilen gikk bra på Delphi og feilet bare 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 mer tar den chunkede DeflateStream-stien 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;

Hva endrer ikke fiksen?

FPC-grenen beholder sine milde dekodingsregler, og den reparerer ikke filer som tidligere FPC-bygg allerede har skrevet. Når MaxOutput er nådd, avkorter FPC-InflateStream ved grensen og returnerer, mens Delphi-grenen reiser ERangeError, og FPC godtar fortsatt delvis dekodet resultat når inflate rapporterer en datafeil, fordi noen PDF-produsenter sender ut avkortete strømmer eller strømmer med ødelagt sjekksum. En PDF skrevet av et FPC-bygg før v3.539.24 inneholder fortsatt sammenkjedede medlemmer, og den korrigerte leseren stopper, som enhver annen leser, ved den første Z_STREAM_END. Ikke prøv å lege en slik fil ved å dekode og re-enkode strømmen inne i biblioteket, for det gjør bare 64 KB-avkortingen permanent. Legg heller ved vedlegget på nytt fra originalkilden sin. FPC-løkken slutter også fortsatt ved den første lesingen som returnerer mindre enn en full bit, som bare er et slutt-på-data-signal for strømmer som TFileStream og TMemoryStream, så en egendefinert TStream sendt til AddAssociatedFileFromStream er tryggest kopiert inn i en TMemoryStream først. Delphi-grenen, hjelperne DeflateStr og InflateStr, og alle strømmer under 1 MiB på den rene Flate-stien oppfører seg nøyaktig som før

Den korrigerte FPC-DeflateStream og InflateStream leveres i v3.539.24 av PDF Library for Delphi, som retter seg mot Delphi, C++Builder og Free Pascal fra ett kildekode-tre, og der skal nå et stort vedlegg komme tilbake fra et FPC-bygg byte for byte, på samme måte som det alltid har gjort fra Delphi