Tehnični članak

Chunked zlib na FPC: zakaj FlateDecode potrebuje en tok

Pred različico v3.539.24 je gradnja PDF Library for Delphi na Free Pascalu stiskala velike tokove v koščkih po 64 KB in vsakemu koščku dala svojo glavo in nadzorno vsoto zlib, tako da je priloga velikosti 1 MiB postala šestnajst členov zlib, zlepljenih od konca do konca. FlateDecode po ISO 32000-1 pričakuje natanko en tok zlib, standardni dekoder pa se ustavi pri prvem markerju konca toka, kar pomeni, da je vse za prvimi 65.536 bajti tiho izginilo. Popravek ohrani eno stanje zlib živo čez vse koščke, tako v DeflateStream kot v InflateStream

Napaka je obstajala le na strani enote za FPC in le na poti pretakanja po koščkih, kar je točno razlog, zakaj je preživela: veja za Delphi je bila vedno pravilna, pomožniki na nizih prav tako, majhni preskusni paketi pa sploh niso prišli do kode za koščke. Gre za tesno sorodnico napak iz petih napak prenašanja na FPC, ki jih je Delphi skrival, le da tukaj gradnja za Delphi ničesar ni zakrivala. Veja FPC je bila enostavno napisana ob napačni predstavi o tem, kaj je tok zlib

Zakaj tok zlib v koščkih drugi bralniki PDF odrežejo?

Ker je tok zlib, kot ga definira RFC 1950, ena posoda in ne zaporedje posod, izpolnjujoči inflater pa prvi zaključek Adler-32 obravnava kot konec podatkov. Format je dvobajtna glava, en neprekinjen bitni tok deflate po RFC 1951, katerega zadnji blok nosi zastavico zadnjega bloka, in štiribajtna nadzorna vsota Adler-32 nad vsemi nestisnjenimi bajti. ISO 32000-1 §7.4.4 definira /FlateDecode natanko v teh izrazih. Ko inflate pride do zaključka, vrne Z_STREAM_END in morebitni preostali vhod pusti prebran v avail_in. Z njegovega zornega kota ni nič narobe, zato ne sproži nobene napake, bajti za zaključkom pa se preprosto prezrejo. Zlepljeni členi so upravičena ideja v gzipu (RFC 1952 dopušča več členov v eni datoteki), verjetno od tam prihaja tudi intuicija, zlib pa takega pravila nima in PDF ga ni nikoli zahteval

Anatomija člena zlib po RFC 1950 v tokovih FlateDecode PDFlibPas: dvobajtna glava, en neprekinjen bitni tok deflate, katerega zadnji blok nosi zastavico zadnjega bloka, in štiribajtni zaključek Adler-32, kjer inflate vrne Z_STREAM_END in morebitni preostali vhod pusti prebran v avail_in, ne da bi sprožil napako
Izpolnjujoči inflater prvi zaključek Adler-32 obravnava jako konec podatkov, meja med členi je torej trda ustavitev, vse, kar je zapisovalec za njo zlepil, pa je mrtva teža, ki je nihče ne bo dekodiral

Stari DeflateStream na FPC je branil vir po 64 KB naenkrat in vsak košček izročil ZFPCCompress, pomožniku, ki požene svoj deflateInit2, stiska z Z_FINISH in pokliče deflateEnd. Vsak košček je torej prišel ven kot popoln, veljaven, sam zaključen tok zlib, funkcija pa jih je pred zapisom zlepila v en AnsiString. Rezultat je izgledal kot podatki Flate, imel je pravilno glavo in se dekodiral brez napake, a samo do prvih 65.536 bajtov. Na bralni strani je imel InflateStream zrcalno sliko iste napake: ZFPCInflate je poklical enkrat na 64 KB stisnjenega vhoda, vsak klic pa je začel svež inflateInit2. Drugi košček se začne na sredini bitnega toka deflate brez glave zlib, zato ga novi inflater zavrne, in povsem običajen enočlenski tok od katerih koli drugega proizvajalca se je dekodiral le toliko, kolikor je nosil njegovih prvih 64 KB stisnjenih bajtov

Napaka pisalnika v koščkih v FPC DeflateStream PDFlibPas: vsak košček 64 KB gre skozi ZFPCCompress kot popoln člen zlib, zato priloga 1 MiB nosi šestnajst zlepljenih členov, bralnik se ustavi pri prvem zaključku po 65.536 bajtih, GetEmbeddedFileContentToStream pa še vedno sporoča uspeh
Škoda je ostala nevidna, ker je uspela vsaka plast: slovar priloge je oglaševal polno /Params /Size, dekodiranje ni sprožilo napake, okrnitev pa je opazil šele tisti, ki je prilogo odprl
// FPC DeflateStream pred v3.539.24 (poenostavljeno):
// ZFPCCompress izvede deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// zato vsak košček 64 KB postane poseben člen zlib
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));

Kateri klici PDF Library for Delphi so prišli do poti po koščkih?

Na FPC je bila vsaka vdelana datoteka velikosti 1 MiB ali več zapisana narobe, vsak tok Flate, izvlečen skozi API za pretakanje, pa prebran narobe, takoj ko je njegova stisnjena velikost presegla en košček. TPDFStream.ReadFromStream odloča, kako kodirati dohodne podatke. Ko je Deflate resničen in Stream.Size >= 1048576, pretaka skozi DeflateStream; pod tem pragom prebere celoten vir v spomin in pokliče DeflateStr, enokratnega pomožnika, ki ni bil nikoli prizadet. Veriga filtrov ASCII85 plus Flate preskoči preizkus velikosti in vedno gre skozi DeflateStream, zato je bil na tej poti vsak paket, večji od 64 KB, že razdeljen na več členov. Javne vstopne točke, ki napajajo ReadFromStream z vklopljenim stiskanjem, so pisalniki vdelanih datotek:

  • TPDFlib.EmbedFile in TPDFlib.AddEmbeddedFile, ki datoteko z diska prebereta v tok /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream in TPDFlib.AddAssociatedFileFromFile, pisalnika povezanih datotek PDF/A-3 za e-računski XML in druge izvorne podatke
  • Na bralni strani TPDFlib.GetEmbeddedFileContentToStream in GetEmbeddedFileContentToFile, ki dekodirata skozi TPDFStream.WriteDecodedToStream in od tam skozi InflateStream

Odpoved je bila tiha na vsaki plasti. Pisalnik shrani /Params /Size in /CheckSum MD5, izračunan iz izvorne datoteke, zato je slovar priloge oglaševal polno velikost, medtem ko je tok nosil šestnajst členov. Gradnja iste knjižnice za Delphi se je pri branju te datoteke čisto ustavila pri prvem Z_STREAM_END in vrnila natanko 65.536 bajtov. GetEmbeddedFileContentToStream je vrnil 1, ker sporoča, ali je dekodiranje sprožilo napako, in ne, ali se izhod ujema z /Size. Vsakomur, ki je težavo z velikimi dokumenti že lovil skozi združevanje in razdvajanje gigabajtnih PDF-jev, je ta vzorec znan: datoteka se odpre, število strani je prav, škoda pa pride na dan šele, ko nekdo odpre prilogo

Eno stanje deflate čez vse koščke

Popravljeni DeflateStream v PDFlibZLib.pas inicializira en paszlib.TZStream, vsak košček dovaja deflate z Z_NO_FLUSH in šele na koncu izprazni kompresor z Z_FINISH, dokler ta ne vrne Z_STREAM_END. To da natanko eno glavo, en bitni tok deflate, katerega povratne reference lahko segajo čez meje koščkov, in en Adler-32 nad celotnim vhodom. Veja FPC ima zdaj enako zgradbo, kot jo je vselej imela veja Delphi. Izhod tudi zapisuje, ko nastaja, namesto da bi celoten stisnjeni rezultat najprej zlepila v AnsiString, zato pisalnik ne zgradi več druge polne kopije stisnjenih podatkov v spominu, preden jo prekopira na cilj

Popravljeni DeflateStream v PDFlibPas: en paszlib.TZStream, inicializiran enkrat, vsak košček 64 KB doveden z Z_NO_FLUSH in končna izpraznitev z Z_FINISH, kar da natanko eno glavo, en neprekinjen bitni tok deflate, katerega povratne reference segajo čez meje koščkov, in en Adler-32 nad celotnim vhodom
Eno stanje spremeni tudi način zapisovanja izhoda: stisnjeni bajti odidejo, ko se vsak medpomnilnik napolni, namesto da bi se nabrali v drugi polni kopiji, PLDeflateLevel pa zdaj doseže tudi velike vdelane datoteke na FPC
// FPC DeflateStream od v3.539.24 (napačne poti odrezane)
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);   // isto stanje, brez preloma med členi
        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                                    // en zaključek za celoten vhod
    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 je dobil simetričen prepis: en inflateInit2, notranjo zanko, ki kliče inflate, dokler ni trenutni košček porabljen in izhodni medpomnilnik ni več poln, in ustavitev ob Z_STREAM_END. Vredno vedenja je en stranski učinek. Stari pisalnik po koščkih je trdo kodiral stopnjo 6, novi pa upošteva PLDeflateLevel, zato stopnja, nastavljena prek TPDFlib.SetCompressionLevel(1..9), zdaj velja tudi za velike vdelane datoteke na FPC. To je pomembno, če stiskanje za arhivski izhod že uglašujete, kot je opisano v zmanjševanju velikosti datotek PDF v Delphiju

Kako preverite, da je tok Flate en sam člen zlib?

Napihnite ga z navadnim dekoderjem zlib in ob vrnitvi Z_STREAM_END preverite dvoje: dekodirana dolžina je enaka dolžini vira, avail_in pa je nič. Preostali vhod za markerjem konca je podpis zlepljenega toka. Popravek je bil preverjen takole: 200 KB preskusnih podatkov, ki segajo čez štiri koščke 64 KB, je šlo skozi novi DeflateStream in ven kot en sam tok zlib, dolg 534 bajtov, običajni dekoder zlib je vrnil vseh 200.000 bajtov brez preostalega vhoda, isti preizkus pa je uspel tudi na navzkrižno prevedenem cilju FPC za i386. Spodnja rutina je različica tega preizkusa za FPC, zgrajena neposredno na paszlib, zato ne zaupa kodi, ki jo preizkuša

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;
    // En člen se konča točno pri zadnjem vhodnem bajtu
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Spustite 200.000 bajtov skozi DeflateStream s privzetim koščkom 64 KB
// in zahtevajte en člen, ki se dekodira nazaj do polne dolžine
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 ravni aplikacije je uporabna trditev tista, ki je knjižnica ne naredi za vas: primerjajte, kar pride iz priloge, z /Params /Size, zabeleženo ob vnosu. GetEmbeddedFileIntProperty z oznako 5 vrne to zabeleženo velikost, kazala vdelanih datotek tečejo od 1, paket pa mora imeti vsaj 1 MiB, da sploh pride do poti pretakanja. Isti preizkus poženite na vsakem prevajalniku, s katerim knjižnico dobavljate, saj je prvotna napaka na Delphiju šla skozi, odpovedala pa se je le 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 ali več gre po poti DeflateStream po koščkih 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;

Kaj popravek ne spremeni?

Veja FPC obdrži svoja popustljiva pravila dekodiranja in ne popravi datotek, ki so jih že zapisale starejše gradnje FPC. Ko je dosežen MaxOutput, FPC InflateStream odreže na meji in se vrne, medtem ko veja Delphi sproži ERangeError, FPC pa še vedno sprejme delno dekodiran izhod, kadar inflate sporoči napako podatkov, ker nekateri proizvajalci PDF oddajajo okrnjene tokove ali tokove s pokvarjeno nadzorno vsoto. PDF, zapisan z gradnjo FPC pred v3.539.24, še vedno vsebuje zlepljene člene, popravljeni bralnik pa se, kot vsak drug bralnik, ustavi pri prvem Z_STREAM_END. Ne poskušajte takšne datoteke zdraviti z dekodiranjem in ponovnim kodiranjem toka znotraj knjižnice, saj to okrnitev na 64 KB le naredi trajno. Namesto tega prilogo znova vdelajte iz njenega izvirnega vira. FPC zanka se še vedno konča pri prvem branju, ki vrne manj kot poln košček, kar je signal konca podatkov le za tokove, kot sta TFileStream in TMemoryStream, zato je po meri narejen TStream, podan AddAssociatedFileFromStream, najvarneje najprej prekopirati v TMemoryStream. Veja Delphi, pomožnika DeflateStr in InflateStr ter vsi tokovi, manjši od 1 MiB, na običajni poti Flate delujejo natanko kot prej

Popravljeni FPC DeflateStream in InflateStream prihajata z v3.539.24 PDF Library for Delphi, ki cilja na Delphi, C++Builder in Free Pascal iz enega izvornega drevesa, in kjer mora velika priloga zdaj iz gradnje FPC priti nazaj bajt za bajt, na enak način, kot je vedno prihajala iz Delphija