Műszaki cikk

Chunked zlib FPC-n: a FlateDecode egyetlen streamet vár

A v3.539.24 előtt a PDF Library for Delphi Free Pascal változata 64 KB-os darabokban tömörítette a nagy streameket, és minden darabnak saját zlib fejlécet és ellenőrzőösszeget adott, így egy 1 MiB-es melléklet tizenhat, végükön egymáshoz fűzött zlib taggé állt össze. Az ISO 32000-1 FlateDecode-je pontosan egyetlen zlib streamet vár, a szabványos dekóder pedig az első streamvége-jelnél áll meg, ami azt jelenti, hogy az első 65 536 bájt után minden csendben eltűnt. A javítás egyetlen zlib állapotot tart életben az összes darabon át, a DeflateStream-ben és az InflateStream-ben egyaránt

A hiba csak a unit FPC oldalán létezett, és csak a darabolt streaming útvonalon, pontosan ezért maradhatott rejtve: a Delphi ág mindig helyes volt, a string-alapú helperek mindig helyesek voltak, a kis teszt hasznos terhek pedig el sem jutottak a darabolt kódig. Rokonságban áll az öt FPC portolási hibával, amit a Delphi mindeddig elrejtett, azzal a különbséggel, hogy itt a Delphi build nem takart semmit. Az FPC ág egyszerűen egy téves mentális modell alapján íródott arról, mi is az a zlib stream

Miért csonkolják meg a többi PDF-olvasó a darabolt zlib streamet?

Mert az RFC 1950 által definiált zlib stream egyetlen konténer, nem konténerek sorozata, és a szabványos inflater az első Adler-32 trailerhez érést az adat végének veszi. A formátum egy kétbájtos fejléc, egy folyamatos RFC 1951 deflate bitsor, aminek utolsó blokkja hordozza a végső blokk flaget, illetve egy négybájtos Adler-32 ellenőrzőösszeg az összes tömörítetlen bájton. Az ISO 32000-1 §7.4.4-e pontosan így definiálja a /FlateDecode-et. Amikor az inflate eléri a trailert, Z_STREAM_END-et ad vissza, és a maradék bemenetet olvasatlanul hagyja az avail_in-ben. Az ő szemszögéből semmi baj nincs, ezért nem dob hibát, és a trailer utáni bájtokat egyszerűen figyelmen kívül hagyja. Az összefűzött tagok legitim ötlet a gzipben (az RFC 1952 megenged több tagot egy fájlban), valószínűleg innen jött az intuíció, de a zlibnek nincs ilyen szabálya, és a PDF soha nem kért ilyet

Az RFC 1950 zlib tag anatómiája a PDFlibPas FlateDecode streamjeiben: kétbájtos fejléc, egy folyamatos deflate bitsor, aminek utolsó blokkja hordozza a végső blokk flaget, és egy négybájtos Adler-32 trailer, ahol az inflate Z_STREAM_END-et ad vissza, a maradék bemenetet olvasatlanul hagyva az avail_in-ben, hiba dobása nélkül
A szabványos inflater az első Adler-32 trailert az adat végének veszi, így egy taghatár kemény stop, és minden, amit az író utána ráfűzött, halott teher, amit egyetlen olvasó sem dekódol

A régi FPC DeflateStream egyszerre 64 KB-ot olvasott a forrásból, és minden darabot átadott a ZFPCCompress-nek, egy helpernek, ami saját deflateInit2-t futtat, Z_FINISH-szel tömörít, majd meghívja a deflateEnd-et. Így minden darab komplett, érvényes, önlezárt zlib streamként jött ki, és a függvény ezeket egyetlen AnsiString-be fűzte össze, mielőtt kiírta volna. Az eredmény Flate adatnak nézett ki, helyes fejléce volt, és hiba nélkül dekódolódott, de csak az első 65 536 bájtot adta vissza. Az olvasó oldalon az InflateStream a tükörképi hibát hordozta: 64 KB tömörített bemenetenként egyszer hívta a ZFPCInflate-et, és minden hívás friss inflateInit2-vel indult. A második darab egy deflate bitsor közepén kezdődik, zlib fejléc nélkül, így egy új inflater elutasítja, és egy teljesen normális, egytagú stream bármely más gyártótól csak addig dekódolódott, ameddig az első 64 KB tömörített bájtja elvitte

Darabolt író hibája a PDFlibPas FPC DeflateStream-jében: minden 64 KB-os darab komplett zlib tagként megy át a ZFPCCompress-en, így egy 1 MiB-es melléklet tizenhat összefűzött tagot tart, az olvasó az első trailernél áll meg 65 536 bájt után, és a GetEmbeddedFileContentToStream továbbra is sikert jelent
A kár láthatatlan maradt, mert minden réteg sikerrel járt: a mellékletszótár a teljes /Params /Size-t hirdette, a dekódolás nem dobott hibát, és csak az vette észre a csonkolást, aki megnyitotta a mellékletet
// FPC DeflateStream a v3.539.24 előtt (egyszerűsítve):
// a ZFPCCompress deflateInit2 / deflate(Z_FINISH) / deflateEnd sorozatot futtat,
// így minden 64 KB-os darab külön zlib taggé válik
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));

Mely PDF Library for Delphi hívások érték el a darabolt útvonalat?

FPC-n minden 1 MiB-es vagy nagyobb beágyazott fájl rosszul íródott, és minden, a streaming API-n keresztül kinyert Flate stream rosszul olvasódott, amint a tömörített mérete átlépett egy darabot. A TPDFStream.ReadFromStream dönti el, hogyan kódolja a bejövő adatot. Ha a Deflate true és a Stream.Size >= 1048576, akkor a DeflateStream-en keresztül streameli; ez alatt a küszöb alatt az egész forrást memóriába olvassa, és a DeflateStr-t hívja, azt az egyszeri helpert, amit a hiba soha nem érintett. Az ASCII85-plusz-Flate szűrőlánc kihagyja a méretvizsgálatot, és mindig a DeflateStream-en megy át, így ezen az útvonalon minden 64 KB-nál nagyobb hasznos teher már több tagra szakadt szét. A nyilvános belépési pontok, amik bekapcsolt tömörítéssel etetik a ReadFromStream-et, a beágyazottfájl-írók:

  • TPDFlib.EmbedFile és TPDFlib.AddEmbeddedFile, amelyek fájlt olvasnak a lemezről egy /EmbeddedFile streambe
  • TPDFlib.AddAssociatedFileFromStream és TPDFlib.AddAssociatedFileFromFile, a PDF/A-3 társítottfájl-írók, amiket e-számla XML és más forrásadatokhoz használnak
  • Az olvasó oldalon a TPDFlib.GetEmbeddedFileContentToStream és a GetEmbeddedFileContentToFile, amelyek a TPDFStream.WriteDecodedToStream-en, majd onnan az InflateStream-en keresztül dekódolnak

A hiba minden rétegen csendes volt. Az író tárolja a /Params /Size-t és az eredeti fájlból számolt MD5 /CheckSum-t, így a mellékletszótár a teljes méretet hirdette, miközben a stream tizenhat tagot tartott. Ugyanennek a librarynek egy Delphi buildje olvasva azt a fájlt szépen megállt az első Z_STREAM_END-nél, és pontosan 65 536 bájtot adott vissza. A GetEmbeddedFileContentToStream 1-et adott vissza, mert azt jelzi, hogy a dekódolás dobott-e hibát, nem azt, hogy a kimenet egyezik-e a /Size-szal. Aki már üldözött nagy dokumentumos problémát a gigabájtos PDF-ek összefésülése és szétvágása során, az ismeri ezt a mintát: a fájl megnyílik, az oldalszám stimmel, és a kár csak akkor látszik, amikor valaki megnyitja a mellékletet

Egy deflate állapot az összes darabon át

A javított DeflateStream a PDFlibZLib.pas-ban egyetlen paszlib.TZStream-et inicializál, minden darabot Z_NO_FLUSH-szal etet a deflate-nek, és csak a végén csapolja meg a tömörítőt Z_FINISH-szel, amíg az Z_STREAM_END-et nem ad vissza. Ez pontosan egy fejlécet, egy deflate bitsort eredményez, aminek visszahivatkozásai átnyúlhatnak a darabhatárokon, és egy Adler-32-t a teljes bemenetre. Az FPC ág mostantól ugyanazt a struktúrát hordozza, amit a Delphi ág mindig is. Ráadásul a kimenetet akkor írja ki, ahogy keletkezik, ahelyett hogy előbb a teljes tömörített eredményt egy AnsiString-be fűzné össze, így az író már nem épít második teljes másolatot a tömörített adatból a memóriában, mielőtt a célba másolná

Javított DeflateStream a PDFlibPas-ban: egyetlen paszlib.TZStream egyszer inicializálva, minden 64 KB-os darab Z_NO_FLUSH-szal etetve, a végén egy Z_FINISH csapolás, ami pontosan egy fejlécet, egy folyamatos deflate bitsort ad, aminek visszahivatkozásai átlépnek a darabhatárokon, és egy Adler-32-t a teljes bemenetre
Az egyetlen állapot azt is megváltoztatja, hogyan íródik a kimenet: a tömörített bájtok akkor távoznak, ahogy minden buffer megtelik, nem pedig egy második teljes másolatban halmozódnak, és a PLDeflateLevel mostantól az FPC-n is eléri a nagy beágyazott fájlokat
// FPC DeflateStream a v3.539.24-től (hibautak lerövidítve)
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);   // ugyanaz az állapot, nincs taghatár
        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                                    // egy trailer a teljes bemenetre
    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;

Az InflateStream a szimmetrikus átírást kapta: egy inflateInit2, egy belső ciklus, ami addig hívja az inflate-et, amíg az aktuális darab el nem fogy és a kimeneti buffer már nem teli, illetve egy megállás Z_STREAM_END-en. Van egy mellékhatás, amit érdemes ismerni. A régi darabolt író beégetve hordozta a 6-os szintet, az új pedig tiszteletben tartja a PLDeflateLevel-t, így a TPDFlib.SetCompressionLevel(1..9)-ön keresztül beállított szint mostantól az FPC-n is vonatkozik a nagy beágyazott fájlokra. Ez azért számít, ha már most is hangolod a tömörítést archív kimenethez, ahogy azt a PDF-fájlméret csökkentése Delphiben írja

Hogyan ellenőrzöd, hogy egy Flate stream egyetlen zlib tag?

Tömörítsd ki egy sima zlib dekóderrel, és amikor Z_STREAM_END-et ad vissza, ellenőrizz két dolgot: a dekódolt hossz egyenlő-e a forrás hosszával, és az avail_in nulla-e. A végjel után maradó bemenet az összefűzött stream kézjegye. A javítás így bizonyított: 200 KB tesztadat, ami négy 64 KB-os darabra terül szét, átmegy az új DeflateStream-en, és 534 bájtos, egytagú zlib streamként jön ki, egy stock zlib dekóder visszaadja mind a 200 000 bájtot maradék bemenet nélkül, és ugyanez az ellenőrzés átmegy az i386 cross-compiled FPC célon is. Az alábbi rutin annak az ellenőrzésnek az FPC változata, közvetlenül a paszlib-re építve, hogy ne bízzon a tesztelt kódban

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;
    // Egy tag pontosan az utolsó bemeneti bájtnál ér véget
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Nyomj át 200 000 bájtot a DeflateStream-en az alapértelmezett 64 KB-os darabbal,
// és követelj meg egy tagot, ami visszafejtve a teljes hosszt adja
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');

Alkalmazási szinten a hasznos kijelentés az, amit a library nem csinál meg helyetted: hasonlítsd össze, ami egy mellékletből kijön, azzal a /Params /Size-szal, amit a bevitelekor rögzítettek. A GetEmbeddedFileIntProperty 5-ös taggel azt a rögzített méretet adja vissza, a beágyazottfájl-indexek 1-től indulnak, és a hasznos tehernek legalább 1 MiB-nek kell lennie, hogy a streaming útvonalra kerüljön. Futtasd ugyanezt a tesztet minden fordítón, amivel szállítod, hiszen az eredeti hiba a Delphi ágon nem jelentkezett, csak az FPC-n

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 vagy több a darabolt DeflateStream útvonalat veszi a ReadFromStream-ben
    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;

Mit nem változtat meg a javítás?

Az FPC ág megtartja az engedékeny dekódolási szabályait, és nem javítja meg azokat a fájlokat, amiket korábbi FPC build-ek már megírtak. Amikor a MaxOutput határát éri el, az FPC InflateStream a határnál csonkol és visszatér, míg a Delphi ág ERangeError-t dob, és az FPC továbbra is elfogad részben dekódolt kimenetet, amikor az inflate adathibát jelent, mert egyes PDF-gyártók csonkolt vagy ellenőrzőösszeg-sértett streameket bocsátanak ki. Egy v3.539.24 előtti FPC build által írt PDF továbbra is összefűzött tagokat tartalmaz, és a javított olvasó, akárcsak minden más olvasó, az első Z_STREAM_END-nél áll meg. Ne próbáld ilyen fájlt a stream libraryn belüli dekódolásával és újratömörítésével meggyógyítani, mert az csak véglegesíti a 64 KB-os csonkolást. Ehelyett ágyazd be újra a mellékletet az eredeti forrásából. Az FPC ciklus továbbra is az első, teljes darabnál kevesebbet adó olvasásnál ér véget, ami csak a TFileStream és a TMemoryStream fajta streameknél adatvége-jel, így egy egyedi TStream, amit a AddAssociatedFileFromStream-nak adsz át, a legbiztonságosabban úgy viselkedik, ha előbb egy TMemoryStream-be másolod. A Delphi ág, a DeflateStr és InflateStr helperek, és minden 1 MiB-nál kisebb stream a sima Flate útvonalon pontosan úgy viselkedik, mint korábban

A javított FPC DeflateStream és InflateStream a PDF Library for Delphi v3.539.24-ében érkezik, amely egyetlen forrásfáról támogatja a Delphit, a C++Buildert és a Free Pascalt, és amelyben egy nagy melléklet mostantól bájtról bájtra, ugyanúgy tér vissza egy FPC buildből, ahogy mindig is visszatért a Delphiből