Tehnički članak

Chunked zlib na FPC: Zašto FlateDecode treba jedan stream

Pre verzije v3.539.24, Free Pascal build PDF Library for Delphi komprimovao je velike streamove u blokovima od 64 KB i svakom bloku davao sopstveni zlib header i checksum, pa je prilog od 1 MiB postajao šesnaest zlib članova zalepljenih kraj na kraj. FlateDecode iz ISO 32000-1 očekuje tačno jedan zlib stream, a standardni dekoder staje na prvom end-of-stream markeru, što znači da je sve posle prvih 65.536 bajtova tiho nestajalo. Popravka drži jedan zlib state živim kroz svaki blok, i u DeflateStream-u i u InflateStream-u

Bug je postojao samo na FPC strani jedinice, i to samo na chunked streaming putanji, i upravo zato je i preživeo: Delphi grana je uvek bila ispravna, helper-i zasnovani na stringovima su uvek bili ispravni, a mali test payload-ovi nikada nisu dolazili do chunked koda. Bliski je rođak defekata iz teksta o pet FPC bugova pri portovanju koje je Delphi krivao, s tim što ovde Delphi build nije pokrivao ništa. FPC grana je jednostavno bila napisana po pogrešnoj predstavi o tome šta zlib stream uopšte jeste

Zašto drugi PDF čitači skraćuju chunked zlib stream?

Zato što je zlib stream po definiciji iz RFC 1950 jedan kontejner, a ne niz njih, i korektan inflater prvi Adler-32 trailer tretira kao kraj podataka. Format je dvobajtni header, jedan neprekidan RFC 1951 deflate bit stream čiji poslednji blok nosi final-block flag, i četvorobajtna Adler-32 kontrolna suma preko svih nekompresovanih bajtova. ISO 32000-1 §7.4.4 definiše /FlateDecode baš u tim pojmovima. Kada inflate stigne do trailera, vraća Z_STREAM_END i sav preostali ulaz ostavlja nepročitan u avail_in. Iz njegove perspektive sve je u redu, pa ne podiže nikakvu grešku, a bajtovi iza trailera se jednostavno ignorišu. Ulančani članovi su legitimna ideja u gzip-u (RFC 1952 dozvoljava više članova u jednom fajlu), odakle je verovatno došla i ta intuicija, ali zlib nema takvo pravilo, a PDF ga nikada nije ni tražio

Anatomija RFC 1950 zlib člana u FlateDecode streamovima PDFlibPas-a: dvobajtni header, jedan neprekidan deflate bit stream čiji poslednji blok nosi final-block flag, i četvorobajtni Adler-32 trailer na kome inflate vraća Z_STREAM_END i preostali ulaz ostavlja nepročitan u avail_in bez ijedne podignute greške
Korektan inflater prvi Adler-32 trailer tretira kao kraj podataka, pa je granica člana čvrst stop i sve što je pisac zalepio posle njega mrtav je teret koji nijedan čitač neće dekodovati

Stari FPC DeflateStream čitao je izvor po 64 KB i svaki blok predavao ZFPCCompress-u, helper-u koji pokreće svoj deflateInit2, komprimuje sa Z_FINISH i poziva deflateEnd. Svaki blok je zato izlazio kao kompletan, validan, sam po sebi završen zlib stream, a funkcija ih je spajala u jedan AnsiString pre upisa. Rezultat je ličio na Flate podatke, imao je ispravan header i dekodovao se bez greške, ali samo do prvih 65.536 bajtova. Na strani čitanja, InflateStream imao je ogledalo-defekt: pozivao je ZFPCInflate po jednom na svakih 64 KB kompresovanog ulaza, i svaki poziv startovao svež inflateInit2. Drugi blok počinje usred deflate bit streama bez zlib headera, pa ga novi inflater odbija, i sasvim normalan single-member stream od bilo kog drugog proizvođača dekodovan je samo onoliko koliko su ga prva 64 KB kompresovanih bajtova odnela

Defekt chunked pisca u FPC DeflateStream-u PDFlibPas-a: svaki blok od 64 KB prolazi kroz ZFPCCompress kao kompletan zlib član, pa prilog od 1 MiB nosi šesnaest zalepljenih članova, čitač staje na prvom traileru posle 65.536 bajtova, a GetEmbeddedFileContentToStream i dalje javlja uspeh
Šteta je ostala nevidljiva jer je svaki sloj uspeo: rečnik prloga je reklamirao punu /Params /Size vrednost, dekodovanje nije podiglo grešku, a skraćenje je primetio samo neko ko je otvorio prilog
// FPC DeflateStream pre v3.539.24 (pojednostavljeno):
// ZFPCCompress radi deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// pa svaki blok od 64 KB postaje poseban zlib član
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));

Koji pozivi PDF Library for Delphi dolaze do chunked putanje?

Na FPC-u je svaki embedded file od 1 MiB ili više bio loše upisan, a svaki Flate stream izvučen kroz streaming API bio je loše pročitan čim je njegova kompresovana veličina prešla jedan blok. TPDFStream.ReadFromStream odlučuje kako će enkodovati dolazeće podatke. Kada je Deflate uključen i Stream.Size >= 1048576, podaci idu kroz DeflateStream; ispod tog praga čita ceo izvor u memoriju i poziva DeflateStr, single-shot helper koji nikada nije bio pogođen. ASCII85-plus-Flate filter lanac preskače proveru veličine i uvek ide kroz DeflateStream, pa je na toj putanji svaki payload veći od 64 KB već bio podeljen na više članova. Javni ulazni punktovi koji hrane ReadFromStream sa uključenom kompresijom su pisci embedded fajlova:

  • TPDFlib.EmbedFile i TPDFlib.AddEmbeddedFile, koji čitaju fajl sa diska u /EmbeddedFile stream
  • TPDFlib.AddAssociatedFileFromStream i TPDFlib.AddAssociatedFileFromFile, pisci PDF/A-3 associated fajlova koje koristite za e-fakturu u XML-u i druge izvorne podatke
  • Na strani čitanja, TPDFlib.GetEmbeddedFileContentToStream i GetEmbeddedFileContentToFile, koji dekoduju kroz TPDFStream.WriteDecodedToStream a odatle kroz InflateStream

Kvar je bio tih na svakom sloju. Pisac upisuje /Params /Size i MD5 /CheckSum izračunat iz originalnog fajla, pa je rečnik prloga reklamirao punu veličinu dok je stream nosio šesnaest članova. Delphi build iste biblioteke koji je čitao taj fajl uredno je stao na prvom Z_STREAM_END i vratio tačno 65.536 bajtova. GetEmbeddedFileContentToStream je vratio 1, jer javlja da li je dekodovanje podiglo grešku, a ne da li izlaz odgovara /Size. Svako ko je već jurio problem velikih dokumenata kroz spajanje i deljenje gigabajtnih PDF-ova poznaje ovaj obrazac: fajl se otvori, broj stranica je tačan, a šteta se vidi tek kad neko otvori prilog

Jedan deflate state kroz sve blokove

Popravljeni DeflateStream u PDFlibZLib.pas inicijalizuje jedan paszlib.TZStream, svaki blok hrani deflate-u sa Z_NO_FLUSH, i tek na kraju isprazni kompresor sa Z_FINISH dok ne vrati Z_STREAM_END. To daje tačno jedan header, jedan deflate bit stream čiji back-reference mogu da prekoče granice blokova, i jedan Adler-32 preko celog ulaza. FPC grana sada ima istu strukturu koju je Delphi grana oduvek imala. Izlaz se i ispisuje čim se proizvede, umesto da se ceo kompresovani rezultat prvo nagomila u AnsiString, pa pisac više ne pravi drugu punu kopiju kompresovanih podataka u memoriji pre nego što je kopira u cilj

Popravljeni DeflateStream u PDFlibPas-u: jedan paszlib.TZStream inicijalizovan jednom, svaki blok od 64 KB hranjen sa Z_NO_FLUSH i završno pražnjenje sa Z_FINISH, što daje tačno jedan header, jedan neprekidan deflate bit stream čiji back-reference premošćavaju granice blokova i jedan Adler-32 preko celog ulaza
Jedan state menja i način upisa izlaza: kompresovani bajtovi odlaze čim se bafer napuni umesto da se gomilaju u drugu punu kopiju, a PLDeflateLevel sada doseže i velike embedded fajlove na FPC-u
// FPC DeflateStream od v3.539.24 (grane za greške izostavljene)
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);   // isti state, bez prekida člana
        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                                    // jedan trailer za ceo ulaz
    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 dobio simetričan prepis: jedan inflateInit2, unutrašnju petlju koja zove inflate dok se tekući blok ne potroši i izlazni bafer prestane da bude pun, i stop na Z_STREAM_END. Postoji jedna sporedna posledica koju vredi znati. Stari chunked pisac je hard-kodirao nivo 6, a novi poštuje PLDeflateLevel, pa se nivo postavljen kroz TPDFlib.SetCompressionLevel(1..9) sada odnosi i na velike embedded fajlove na FPC-u. To je bitno ako kompresiju već štimujete za arhivski izlaz, kako je opisano u tekstu o smanjenju veličine PDF fajla u Delphi-ju

Kako da proverite da li je Flate stream jedan zlib član?

Dekompresujte ga običnim zlib dekoderom i kada vrati Z_STREAM_END proverite dve stvari: dekodovana dužina jednaka dužini izvora, i avail_in jednak nuli. Preostali ulaz iza end markera je potpis ulančanog streama. Popravka je ovako i verifikovana: 200 KB test podataka, koji prelaze četiri bloka od 64 KB, prošlo je kroz novi DeflateStream i izašlo kao jedan zlib stream od 534 bajta, standardni zlib dekoder povratio je svih 200.000 bajtova bez preostalog ulaza, a ista provera prošla je i na i386 cross-compiled FPC targetu. Rutina ispod je FPC verzija te provere, izgrađena direktno na paszlib-u da ne bi verovala kodu koji testira

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;
    // Jedan član se završava tačno na poslednjem ulaznom bajtu
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Prognite 200.000 bajtova kroz DeflateStream sa podrazumevanim blokom
// od 64 KB i zahtevajte jedan član koji se dekoduje nazad na punu dužinu
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 nivou aplikacije, korisna asercija je ona koju biblioteka ne radi za vas: uporedite ono što izađe iz prloga sa /Params /Size zabeleženim pri ubacivanju. GetEmbeddedFileIntProperty sa tagom 5 vraća tu zabeleženu veličinu, indeksi embedded fajlova kreću od 1, a payload mora imati bar 1 MiB da bi uopšte ušao u streaming putanju. Isti test provucite kroz svaki kompajler uz koji isporučujete, jer je originalni defekt prolazio na Delphi-ju a padalo je samo na FPC-u

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 ili više ide kroz chunked DeflateStream putanju u ReadFromStream-u
    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;

Šta popravka ne menja?

FPC grana zadržava svoje blage pravile dekodovanja, i ne popravljaje fajlove koje su raniji FPC buildovi već upisali. Kada se dostigne MaxOutput, FPC InflateStream skrati na granici i vrati se, dok Delphi grana diže ERangeError, a FPC i dalje prihvata delimično dekodovan izlaz kada inflate javi grešku u podacima, jer neki PDF proizvođači emituju skraćene ili checksum-pokvarene streamove. PDF upisan pre-v3.539.24 FPC builda i dalje sadrži ulančane članove, a popravljeni čitač, kao i svaki drugi, staje na prvom Z_STREAM_END. Ne pokušavajte da izlečite takav fajl dekodovanjem i ponovnim enkodovanjem streama unutar biblioteke, jer time samo učvršćujete skraćenje na 64 KB. Umesto toga ponovo ubacite prilog iz njegovog originalnog izvora. FPC petlja takođe i dalje staje na prvom čitanju koje vrati manje od punog bloka, a to je signal kraja podataka samo za streamove poput TFileStream i TMemoryStream, pa je sopstveni TStream prosleđen AddAssociatedFileFromStream-u najsigurnije prvo prekopirati u TMemoryStream. Delphi grana, helper-i DeflateStr i InflateStr, i svaki stream manji od 1 MiB na običnoj Flate putanji ponašaju se tačno kao do sada

Popravljeni FPC DeflateStream i InflateStream isporučuju se u v3.539.24 PDF Library for Delphi biblioteke, koja iz jednog source tree-a cilja Delphi, C++Builder i Free Pascal, i u kojoj veliki prilog sada treba da se iz FPC builda vrati bajt po bajt, isto kao što se iz Delphi-ja oduvek vraćao