Articol tehnic

zlib chunked pe FPC: FlateDecode cere un singur stream

Înainte de v3.539.24, build-ul Free Pascal al PDF Library for Delphi comprima stream-urile mari în chunk-uri de 64 KB și dădea fiecărui chunk propriul header și checksum zlib, astfel încât un atașament de 1 MiB devenea șaisprezece membri zlib lipiți cap la cap. FlateDecode din ISO 32000-1 așteaptă exact un singur stream zlib, iar un decoder standard se oprește la primul marker de sfârșit de stream, ceea ce înseamnă că tot ce venea după primii 65.536 de octeți dispărea în tăcere. Reparația ține o singură stare zlib vie peste toate chunk-urile, atât în DeflateStream, cât și în InflateStream

Bug-ul exista doar pe partea de FPC a unit-ului și doar pe calea de streaming chunked, ceea ce explică exact de ce a supraviețuit: ramura Delphi a fost mereu corectă, helper-ele pe bază de string au fost mereu corecte, iar payload-urile mici de test nu ajungeau niciodată până la codul chunked. Este o rudă apropiată a defectelor din cinci bug-uri de portare FPC pe care Delphi le-a ascuns, cu deosebirea că aici build-ul Delphi nu acoperea nimic. Ramura FPC fusese pur și simplu scrisă pe baza unui model mental greșit despre ce este un stream zlib

De ce trunchiază alte cititoare PDF un stream zlib chunked?

Pentru că un stream zlib așa cum îl definește RFC 1950 este un singur container, nu o secvență de containere, iar un inflater conformant tratează primul trailer Adler-32 drept sfârșitul datelor. Formatul este un header de doi octeți, un bit stream RFC 1951 deflate continuu al cărui ultim bloc poartă flag-ul final-block, și un checksum Adler-32 de patru octeți peste toți octeții necomprimați. §7.4.4 din ISO 32000-1 definește /FlateDecode exact în acești termeni. Când inflate ajunge la trailer, întoarce Z_STREAM_END și lasă orice input rămas necitit în avail_in. Din punctul ei de vedere nimic nu este în neregulă, deci nu ridică nicio eroare, iar octeții de după trailer sunt pur și simplu ignorați. Membrii concatenați sunt o idee legitimă în gzip (RFC 1952 permite mai mulți membri într-un fișier), de acolo vine probabil intuiția, dar zlib nu are o asemenea regulă și PDF nu a cerut niciodată una

Anatomia unui membru zlib RFC 1950 în stream-urile FlateDecode din PDFlibPas: un header de doi octeți, un bit stream deflate continuu al cărui ultim bloc poartă flag-ul final-block și un trailer Adler-32 de patru octeți la care inflate întoarce Z_STREAM_END, lăsând input-ul rămas necitit în avail_in fără nicio eroare ridicată
Un inflater conformant tratează primul trailer Adler-32 drept sfârșitul datelor, deci o graniță de membru este o oprire dură, iar tot ce a lipit un scriitor după ea este sarcină inutilă pe care niciun cititor nu o va decoda

Vechiul DeflateStream de pe FPC citea sursa câte 64 KB și împingea fiecare chunk spre ZFPCCompress, un helper care își rulează propriul deflateInit2, comprimă cu Z_FINISH și apelează deflateEnd. Fiecare chunk ieșea deci ca un stream zlib complet, valid, auto-terminat, iar funcția îi concatena într-un singur AnsiString înainte de a-l scrie. Rezultatul arăta a date Flate, avea un header corect și se decoda fără eroare, dar se decoda doar până la primii 65.536 de octeți. Pe partea de citire, InflateStream avea defectul în imagine inversă: apela ZFPCInflate o dată la fiecare 64 KB de input comprimat, iar fiecare apel pornea un inflateInit2 nou. Al doilea chunk începe în mijlocul unui bit stream deflate, fără header zlib, deci un inflater nou îl respinge, iar un stream perfect normal cu un singur membru, produs de oricine altcineva, era decodat doar până unde îl duceau primii săi 64 KB de octeți comprimați

Defectul scriitorului chunked din FPC DeflateStream din PDFlibPas: fiecare chunk de 64 KB trece prin ZFPCCompress ca un membru zlib complet, deci un atașament de 1 MiB conține șaisprezece membri lipiți, cititorul se oprește la primul trailer după 65.536 de octeți, iar GetEmbeddedFileContentToStream raportează în continuare succes
Paguba a rămas invizibilă pentru că fiecare strat a reușit: dicționarul atașamentului anunța întregul /Params /Size, decodarea nu ridica nicio eroare, iar doar cine deschidea atașamentul observa trunchierea
// FPC DeflateStream înainte de v3.539.24 (simplificat):
// ZFPCCompress face deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// deci fiecare chunk de 64 KB devine un membru zlib separat
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));

Ce apeluri din PDF Library for Delphi ajungeau pe calea chunked?

Pe FPC, orice fișier încorporat de 1 MiB sau mai mult era scris greșit, iar orice stream Flate extras prin API-ul de streaming era citit greșit imediat ce dimensiunea sa comprimată trecea de un chunk. TPDFStream.ReadFromStream decide cum să encodeze datele primite. Când Deflate este true și Stream.Size >= 1048576, le trece prin DeflateStream; sub acel prag citește întreaga sursă în memorie și apelează DeflateStr, helper-ul single-shot care n-a fost niciodată afectat. Lanțul de filtre ASCII85 plus Flate sare peste testul de dimensiune și trece mereu prin DeflateStream, deci pe calea aceea orice payload mai mare de 64 KB era deja împărțit în mai mulți membri. Punctele de intrare publice care hrănesc ReadFromStream cu compresia pornită sunt scriitorii de fișiere încorporate:

  • TPDFlib.EmbedFile și TPDFlib.AddEmbeddedFile, care citesc un fișier de pe disc într-un stream /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream și TPDFlib.AddAssociatedFileFromFile, scriitorii de associated-file PDF/A-3 folosiți pentru XML de e-factură și alte date sursă
  • Pe partea de citire, TPDFlib.GetEmbeddedFileContentToStream și GetEmbeddedFileContentToFile, care decod prin TPDFStream.WriteDecodedToStream și de acolo prin InflateStream

Eșecul a fost tăcut la fiecare strat. Scriitorul stochează /Params /Size și un /CheckSum MD5 calculat din fișierul original, deci dicționarul atașamentului anunța dimensiunea completă în timp ce stream-ul ținea șaisprezece membri. Un build Delphi al aceleiași biblioteci care citea acel fișier se oprea curat la primul Z_STREAM_END și întorcea exact 65.536 de octeți. GetEmbeddedFileContentToStream întorcea 1, pentru că raportează dacă decodarea a ridicat o eroare, nu dacă rezultatul se potrivește cu /Size. Oricine a urmărit o problemă de document mare prin îmbinarea și divizarea de PDF-uri de ordinul gigabyte-ului cunoaște tiparul acesta: fișierul se deschide, numărul de pagini e corect, iar paguba se vede doar când cineva deschide atașamentul

O singură stare deflate peste toate chunk-urile

DeflateStream-ul reparat din PDFlibZLib.pas inițializează un singur paszlib.TZStream, îi dă fiecare chunk spre deflate cu Z_NO_FLUSH și abia la sfârșit golește compresorul cu Z_FINISH până când acesta întoarce Z_STREAM_END. Asta produce exact un header, un bit stream deflate ale cărui back-references pot sări peste granițele dintre chunk-uri și un singur Adler-32 peste tot input-ul. Ramura FPC are acum aceeași structură pe care ramura Delphi a avut-o dintotdeauna. Scrie și rezultatul pe măsură ce e produs, în loc să concateneze mai întâi tot rezultatul comprimat într-un AnsiString, deci scriitorul nu își mai construiește o a doua copie completă a datelor comprimate în memorie înainte de a o copia spre destinație

DeflateStream reparat în PDFlibPas: un singur paszlib.TZStream inițializat o dată, fiecare chunk de 64 KB dat cu Z_NO_FLUSH și o golire finală cu Z_FINISH, rezultând exact un header, un bit stream deflate continuu ale cărui back-references traversează granițele dintre chunk-uri și un singur Adler-32 peste tot input-ul
O singură stare schimbă și felul în care se scrie rezultatul: octeții comprimați pleacă pe măsură ce fiecare buffer se umple, în loc să se acumuleze într-o a doua copie completă, iar PLDeflateLevel ajunge acum și la fișierele încorporate mari pe FPC
// FPC DeflateStream din v3.539.24 (căile de eroare tăiate)
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);   // aceeași stare, fără rupere de membru
        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                                    // un singur trailer pentru tot input-ul
    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 a primit rescrierea simetrică: un singur inflateInit2, o buclă interioară care continuă să apeleze inflate până când chunk-ul curent e consumat și buffer-ul de rezultat nu mai e plin, și o oprire la Z_STREAM_END. Există un efect secundar pe care merită să îl știți. Vechiul scriitor chunked hardcoda nivelul 6, în timp ce cel nou respectă PLDeflateLevel, deci un nivel setat prin TPDFlib.SetCompressionLevel(1..9) se aplică acum și fișierelor încorporate mari pe FPC. Contează dacă vă reglați deja compresia pentru output de arhivă, așa cum descrie reducerea dimensiunii fișierelor PDF în Delphi

Cum verificați că un stream Flate este un singur membru zlib?

Îl inflați cu un decoder zlib obișnuit și verificați două lucruri când întoarce Z_STREAM_END: lungimea decodată este egală cu lungimea sursei și avail_in este zero. Input-ul rămas după markerul de sfârșit este semnătura unui stream concatenat. Reparația a fost verificată exact așa: 200 KB de date de test, care se întind pe patru chunk-uri de 64 KB, au trecut prin noul DeflateStream și au ieșit ca un stream zlib de 534 de octeți cu un singur membru, un decoder zlib de stoc a recuperat toți cei 200.000 de octeți fără input rămas, iar aceeași verificare a trecut și pe ținta FPC cross-compilată i386. Rutina de mai jos este versiunea FPC a acestei verificări, construită direct pe paszlib, ca să nu aibă încredere în codul testat

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;
    // Un singur membru se termină exact la ultimul octet de input
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Trecem 200.000 de octeți prin DeflateStream cu chunk-ul implicit de 64 KB
// și cerem un singur membru care se decodează înapoi la lungimea completă
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');

La nivel de aplicație, aserțiunea utilă este cea pe care biblioteca nu o face pentru dumneavoastră: comparați ce iese dintr-un atașament cu /Params /Size înregistrat la intrare. GetEmbeddedFileIntProperty cu tag-ul 5 întoarce dimensiunea înregistrată, indicii fișierelor încorporate pornesc de la 1, iar payload-ul trebuie să aibă cel puțin 1 MiB ca să exercite calea de streaming. Rulați același test pe fiecare compilator cu care livrați, pentru că defectul original trecea pe Delphi și eșua doar pe 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 sau mai mult ia calea DeflateStream chunked din 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;

Ce nu schimbă reparația?

Ramura FPC își păstrează regulile de decodare îngăduitoare și nu repară fișierele pe care build-urile FPC anterioare le scrisese deja. Când se atinge MaxOutput, InflateStream de pe FPC trunchiază la limită și se întoarce, în timp ce ramura Delphi ridică ERangeError, iar FPC acceptă în continuare output decodat parțial când inflate raportează o eroare de date, pentru că unii producători PDF emit stream-uri trunchiate sau cu checksum stricat. Un PDF scris de un build FPC anterior lui v3.539.24 conține în continuare membri concatenați, iar cititorul corectat, ca orice alt cititor, se oprește la primul Z_STREAM_END. Nu încercați să vindecați un asemenea fișier decodând și recodând stream-ul în interiorul bibliotecii, pentru că așa faceți trunchierea de 64 KB permanentă. Re-încorporați atașamentul din sursa lui originală. Bucla FPC se mai termină și acum la prima citire care întoarce mai puțin decât un chunk întreg, ceea ce este semnal de sfârșit de date doar pentru stream-uri precum TFileStream și TMemoryStream, deci un TStream custom pasat spre AddAssociatedFileFromStream e cel mai sigur copiat întâi într-un TMemoryStream. Ramura Delphi, helper-ele DeflateStr și InflateStr și orice stream sub 1 MiB pe calea Flate simplă se comportă exact ca înainte

DeflateStream-ul și InflateStream-ul FPC corectate se livrează în v3.539.24 din PDF Library for Delphi, care țintește Delphi, C++Builder și Free Pascal dintr-un singur arbore de cod, iar un atașament mare ar trebui acum să revină dintr-un build FPC octet cu octet, exact cum a revenit dintotdeauna din Delphi