Odborný článok

Chunked zlib na FPC: FlateDecode potrebuje jeden stream

Pred verziou v3.539.24 komprimovalo Free Pascal zostavenie PDF Library for Delphi veľké streamy v 64 KB blokoch a každý blok dostal vlastnú zlib hlavičku a kontrolný súčet, takže príloha s veľkosťou 1 MiB sa premenila na šestnásť zlib členov nalepených jeden za druhým. FlateDecode podľa ISO 32000-1 očakáva presne jeden zlib stream a štandardný dekodér sa zastaví na prvej značke konca streamu, takže všetko za prvými 65 536 bajtmi potichu zmizlo. Oprava drží jeden zlib stav nažive cez každý blok v oboch DeflateStream aj InflateStream

Chyba existovala len na strane FPC tejto jednotky a len na chunked streaming ceste, a presne preto prežila: Delphi vetva bola vždy správna, helpery postavené na reťazcoch boli vždy správne a malé testovacie dátové sady sa ku chunked kódu nikdy nedostali. Je to blízky príbuzný defektov opísaných v článku o piatich FPC portovacích chybách, ktoré Delphi roky krylo, s tým rozdielom, že tu Delphi zostavenie nič nepokrývalo. FPC vetva bola jednoducho napísaná podľa nesprávnej predstavy o tom, čo zlib stream vlastne je

Prečo iné PDF prehliadače skrátia chunked zlib stream?

Pretože zlib stream podľa definície v RFC 1950 je jeden kontajner, nie postupnosť kontajnerov, a korektný inflater považuje prvý trailér Adler-32 za koniec dát. Formát je dvojbajtová hlavička, jeden súvislý deflate bitový stream podľa RFC 1951, ktorého posledný blok nesie príznak final-block, a štyrbajtový kontrolný súčet Adler-32 cez všetky nekomprimované bajty. ISO 32000-1 §7.4.4 definuje /FlateDecode presne v týchto termínoch. Keď inflate dorazí k trailéru, vráti Z_STREAM_END a prípadný zostávajúci vstup nechá neprečítaný v avail_in. Z jeho pohľadu nie je nič pokazené, takže nevyhodí žiadnu chybu a bajty za trailérom jednoducho ignoruje. Spájané členy sú v gzipu legitimná idea, RFC 1952 dovoľuje viacero členov v jednom súbore, a odtiaľ tá intuícia asi prišla, ale zlib žiadne takéto pravidlo nemá a PDF oň nikdy nežiadalo

Anatómia zlib člena RFC 1950 vo FlateDecode streamoch PDFlibPas: dvojbajtová hlavička, jeden súvislý deflate bitový stream, ktorého posledný blok nesie príznak final-block, a štyrbajtový trailér Adler-32, na ktorom inflate vráti Z_STREAM_END a zostávajúci vstup nechá neprečítaný v avail_in bez vyhodenia chyby
Korektný inflater považuje prvý trailér Adler-32 za koniec dát, takže hranica člena je tvrdá zastávka a všetko, čo pisateľ po nej nalepil, je mŕtva váha, ktorú žiadny prehliadač nedekóduje

Starý FPC DeflateStream čítal zdroj po 64 KB a každý blok odovzdával helperu ZFPCCompress, ktorý si spustí vlastný deflateInit2, komprimuje s Z_FINISH a zavolá deflateEnd. Každý blok teda vyšiel ako kompletný, platný, sebaukončujúci zlib stream a funkcia ich spájala do jedného AnsiString ešte pred zápisom. Výsledok vyzeral ako Flate dáta, mal správnu hlavičku a dekódoval sa bez chyby, ale dekódoval sa len do prvých 65 536 bajtov. Na čítacej strane mal InflateStream zrkadlový defekt: volal ZFPCInflate raz na každých 64 KB komprimovaného vstupu a každé volanie štartovalo čerstvý inflateInit2. Druhý blok začína v strede deflate bitového streamu bez zlib hlavičky, takže nový inflater ho odmietne, a úplne bežný jednoclenný stream od ktoréhokoľvek iného producenta sa dekódoval len po prvých 64 KB komprimovaných bajtov

Defekt chunked pisateľa vo FPC DeflateStreame PDFlibPas: každý 64 KB blok prejde cez ZFPCCompress ako kompletný zlib člen, takže príloha o 1 MiB nesie šestnásť nalepených členov, prehliadač sa zastaví na prvom trailéri po 65 536 bajtoch a GetEmbeddedFileContentToStream aj naďalej hlási úspech
Poškodenie zostalo neviditeľné, pretože uspela každá vrstva: slovník prílohy deklaroval plné /Params /Size, dekódovanie nevyhodilo chybu a skrátenie si všimol len ten, kto si prílohu otvoril
// FPC DeflateStream pred v3.539.24 (zjednodušené):
// ZFPCCompress robí deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// takže každý 64 KB blok sa stane samostatným zlib členom
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));

Ktoré volania PDF Library for Delphi sa dostali na chunked cestu?

Na FPC sa každý vložený súbor od 1 MiB nahor zapisoval zle a každý Flate stream ťahaný cez streaming API sa čítal zle, akonáhle jeho komprimovaná veľkosť prekročila jeden blok. TPDFStream.ReadFromStream rozhoduje, ako kódovať prichádzajúce dáta. Keď je Deflate true a Stream.Size >= 1048576, tečie cez DeflateStream; pod touto hranicou načíta celý zdroj do pamäte a zavolá DeflateStr, jednorazový helper, ktorého sa chyba nikdy nedotkla. Reťazec filtrov ASCII85 plus Flate test veľkosti preskočí a ide vždy cez DeflateStream, takže na tejto ceste sa každá dátová sada väčšia než 64 KB už delila na viacero členov. Verejné vstupné body, ktoré kŕmia ReadFromStream so zapnutou kompresiou, sú pisateľia vložených súborov:

  • TPDFlib.EmbedFile a TPDFlib.AddEmbeddedFile, ktoré načítajú súbor z disku do streamu /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream a TPDFlib.AddAssociatedFileFromFile, pisateľia asociovaných súborov PDF/A-3, ktorí sa používajú pre XML e-faktúr a iné zdrojové dáta
  • Na čítacej strane TPDFlib.GetEmbeddedFileContentToStream a GetEmbeddedFileContentToFile, ktoré dekódujú cez TPDFStream.WriteDecodedToStream a odtiaľ cez InflateStream

Zlyhanie bolo v každej vrstve tiché. Pisateľ ukladá /Params /Size a MD5 /CheckSum počítaný z pôvodného súboru, takže slovník prílohy deklaroval plnú veľkosť, kým stream držal šestnásť členov. Delphi zostavenie tej istej knižnice sa pri čítaní takéhoto súboru poriadne zastavilo na prvom Z_STREAM_END a vrátilo presne 65 536 bajtov. GetEmbeddedFileContentToStream vrátilo 1, lebo hlási, či dekódovanie vyhodilo chybu, nie či výstup sedí s /Size. Ktokoľvek, kto už prenasledoval problém veľkých dokumentov cez spájanie a delenie gigabajtových PDF, pozná tento vzor: súbor sa otvorí, počet strán sedí a poškodenie sa ukáže, až keď si niekto otvorí prílohu

Jeden deflate stav cez všetky bloky

Opravený DeflateStream v PDFlibZLib.pas inicializuje jeden paszlib.TZStream, kŕmi každý blok do deflate s Z_NO_FLUSH a až na konci vypúšťa kompresor s Z_FINISH, kým nevráti Z_STREAM_END. To dáva presne jednu hlavičku, jeden deflate bitový stream, ktorého back-references môžu siahnuť cez hranice blokov, a jedno Adler-32 cez celý vstup. FPC vetva má teraz tú istú štruktúru, ktorú mala Delphi vetva vždy. Výstup navyše zapisuje hneď, ako vzniká, namiesto toho, aby najprv spojil celý komprimovaný výsledok do AnsiString, takže pisateľ si už pred kopírovaním do cieľa nebuduje druhú plnú kópiu komprimovaných dát v pamäti

Opravený DeflateStream v PDFlibPas: jeden paszlib.TZStream inicializovaný raz, každý 64 KB blok kŕmený so Z_NO_FLUSH a záverečné vypustenie so Z_FINISH, čo dáva presne jednu hlavičku, jeden súvislý deflate bitový stream, ktorého back-references prekračujú hranice blokov, a jedno Adler-32 cez celý vstup
Jeden stav mení aj spôsob zápisu výstupu: komprimované bajty odchádzajú, ako sa plní každý buffer, namiesto hromadenia v druhej plnej kópii, a PLDeflateLevel teraz zasahuje aj veľké vložené súbory na FPC
// FPC DeflateStream od v3.539.24 (chybové cesty vystrihnuté)
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);   // ten istý stav, žiadne lomenie člena
        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                                    // jeden trailér pre celý vstup
    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 dostal symetrický prepis: jedno inflateInit2, vnútornú slučku, ktorá volá inflate donekonečna, kým sa nespotrebuje aktuálny blok a výstupný buffer už nie je plný, a zastavenie na Z_STREAM_END. Jeden vedľajší efekt stojí za poznanie. Starý chunked pisateľ mal natvrdo level 6, nový rešpektuje PLDeflateLevel, takže level nastavený cez TPDFlib.SetCompressionLevel(1..9) sa teraz na FPC uplatní aj pri veľkých vložených súboroch. To má význam, keď už ladíte kompresiu pre archívny výstup, ako je popísané v článku o znižovaní veľkosti PDF súborov v Delphi

Ako overiť, že Flate stream je jeden zlib člen?

Nafúkajte ho obyčajným zlib dekodérom a po vrátení Z_STREAM_END skontrolujte dve veci: dekódovaná dĺžka sa rovná dĺžke zdroja a avail_in je nula. Zostávajúci vstup za značkou konca je podpis spájaného streamu. Oprava sa overila presne takto: 200 KB testovacích dát, čo sú štyri 64 KB bloky, prešlo novým DeflateStream a vyšiel z nich jediný zlib stream o 534 bajtoch, štandardný zlib dekodér vrátil všetkých 200 000 bajtov bez zostatku na vstupe a tá istá kontrola prešla aj na i386 cross-kompilovanom FPC cieli. Nižšia rutina je FPC verzia tejto kontroly, postavená priamo na paszlib, aby neverila testovanému kódu

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;
    // Jeden člen končí presne na poslednom vstupnom bajte
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Preženite 200 000 bajtov cez DeflateStream s predvoleným 64 KB blokom
// a vyžadujte jeden člen, ktorý sa dekóduje späť na celú dĺžku
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');

Na úrovni aplikácie je užitočný ten assertion, ktorý za vás knižnica nerobí: porovnajte, čo z prílohy vychádza, s /Params /Size zaznamenaným pri vkladaní. GetEmbeddedFileIntProperty s tagom 5 vráti túto zaznamenanú veľkosť, indexy vložených súborov sa číslujú od 1 a dátová sada musí mať aspoň 1 MiB, aby sa streaming cesta vôbec spustila. Rovnaký test spustite na každom kompilátore, s ktorým dodávate, keďže pôvodný defekt na Delphi prešiel a zlyhal len na 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 a viac berie chunked cestu DeflateStream v 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;

Čo oprava nemení?

FPC vetva si necháva zhovievavé dekódovacie pravidlá a neremontuje súbory, ktoré už napísali staršie FPC zostavenia. Keď sa dosiahne MaxOutput, FPC InflateStream skráti výstup na hranici a vráti sa, zatiaľ čo Delphi vetva vyhodí ERangeError, a FPC stále prijíma čiastočne dekódovaný výstup, keď inflate hlási chybu dát, lebo niektorí PDF producenti vypúšťajú skrátené alebo kontrolným súčtom pokazené streamy. PDF napísané FPC zostavením starším než v3.539.24 stále obsahuje spájané členy a opravený čítač sa, ako každý iný, zastaví na prvom Z_STREAM_END. Takýto súbor sa nepokúšajte vyliečiť dekódovaním a opätovným zakódovaním streamu vnútri knižnice, tým sa len 64 KB skrátenie zabetónuje. Namiesto toho vložte prílohu znovu z jej pôvodného zdroja. FPC slučka tiež stále končí na prvom čítaní, ktoré vráti menej než plný blok, čo je signál konca dát len pre streamy typu TFileStream a TMemoryStream, takže vlastný TStream odovzdaný do AddAssociatedFileFromStream je najbezpečnejšie najprv skopírovať do TMemoryStream. Delphi vetva, helpery DeflateStr a InflateStr a každý stream menší než 1 MiB na čistej Flate ceste sa správajú presne ako doteraz

Opravený FPC DeflateStream a InflateStream prichádza vo v3.539.24 PDF Library for Delphi, ktorá cieli na Delphi, C++Builder aj Free Pascal z jedného zdrojového stromu, a kde by veľká príloha mala z FPC zostavenia vyjsť teraz bajt za bajtom, rovnako ako z Delphi vždy vychádzala