Artykuł techniczny

Chunked zlib na FPC: FlateDecode wymaga jednego strumienia

Kawałkujący zapis FlateDecode na Free Pascal musi utrzymywać jeden stan zlib przy życiu przez wszystkie kawałki. Kusząca droga na skróty, czyli kompresowanie każdego kawałka 64 KB osobno, daje każdemu kawałkowi własny nagłówek i sumę kontrolną zlib, więc załącznik o rozmiarze 1 MiB staje się szesnastoma członami zlib sklejonymi końcem do końca. FlateDecode z ISO 32000-1 oczekuje dokładnie jednego strumienia zlib, a standardowy dekoder zatrzymuje się na pierwszym znaczniku końca strumienia, co oznacza, że wszystko za pierwszymi 65 536 bajtami po prostu znika. PDF Library for Delphi utrzymuje jeden stan zlib przy życiu przez wszystkie kawałki, zarówno w DeflateStream, jak i w InflateStream

Ta pułapka istnieje wyłącznie po stronie FPC jednostki współdzielonej przez kompilatory i tylko na strumieniowej ścieżce kawałkowania, i dokładnie dlatego łatwo ją przeoczyć: gałąź Delphi i helpery oparte na łańcuchach produkują każda pojedynczy strumień zlib, a małe testowe dane nigdy w ogóle nie dochodzą do kodu kawałkowania. To bliski kuzyn pułapek opisanych w artykule o pięciu pułapkach portowania na FPC, które Delphi zwykle maskuje, z tą różnicą, że tym razem Delphi nikogo nie okrywa. Droga na skróty po prostu wyrasta z błędnego modelu tego, czym w ogóle jest strumień zlib

Dlaczego inne czytniki PDF ucinają strumień zlib podzielony na kawałki?

Bo strumień zlib w definicji RFC 1950 to jeden kontener, a nie ich sekwencja, i zgodny ze standardem inflater traktuje pierwszą stopkę Adler-32 jako koniec danych. Format to dwubajtowy nagłówek, jeden ciągły strumień bitowy deflate wg RFC 1951, którego ostatni blok niesie flagę bloku końcowego, oraz czterobajtowa suma kontrolna Adler-32 liczona po wszystkich zdekompresowanych bajtach. ISO 32000-1 §7.4.4 definiuje /FlateDecode dokładnie w tych kategoriach. Gdy inflate dociera do stopki, zwraca Z_STREAM_END i zostawia ewentualne pozostałe dane wejściowe nieprzeczytane w avail_in. Z jego punktu widzenia nic się nie stało, więc nie zgłasza błędu, a bajty za stopką są po prostu ignorowane. Sklejanie członów to legalny pomysł w gzip (RFC 1952 dopuszcza kilka członów w jednym pliku) i stąd zapewne wziąła się ta intuicja, ale zlib nie ma takiej reguły, a PDF nigdy o nią nie prosił

Anatomia członu zlib wg RFC 1950 w strumieniach FlateDecode biblioteki PDFlibPas: dwubajtowy nagłówek, jeden ciągły strumień bitowy deflate, którego ostatni blok niesie flagę bloku końcowego, oraz czterobajtowa stopka Adler-32, na której inflate zwraca Z_STREAM_END, zostawiając pozostałe dane wejściowe nieprzeczytane w avail_in i nie zgłaszając żadnego błędu
Zgodny ze standardem inflater traktuje pierwszą stopkę Adler-32 jako koniec danych, więc granica członu to twarde stop, a wszystko, co producent dokleił za nią, to martwy ciężar, którego żaden czytnik nie zdekoduje

Naiwny DeflateStream po stronie FPC czyta źródło porcjami po 64 KB i przekazuje każdy kawałek do ZFPCCompress, helpera, który uruchamia własny deflateInit2, kompresuje z Z_FINISH i woła deflateEnd. Każdy kawałek wychodzi więc jako kompletny, poprawny, samodzielnie zakończony strumień zlib, a funkcja skleja je w jeden AnsiString przed zapisem. Wynik wygląda jak dane Flate, ma poprawny nagłówek i dekoduje się bez błędu, ale dekoduje się tylko do pierwszych 65 536 bajtów. Po stronie odczytu czeka lustrzana pułapka: wywołanie ZFPCInflate raz na każde 64 KB skompresowanego wejścia startuje za każdym razem od świeżego inflateInit2. Drugi kawałek zaczyna się w środku strumienia bitowego deflate, bez nagłówka zlib, więc nowy inflater go odrzuca, a całkowicie normalny jedno-członowy strumień od innego producenta jest dekodowany tylko tak daleko, jak poniosą go jego pierwsze 64 KB skompresowanych bajtów

Pułapka kawałkującego zapisu na FPC: gdy każdy kawałek 64 KB przechodzi przez ZFPCCompress jako kompletny człon zlib, załącznik 1 MiB mieści szesnaście sklejonych członów, czytnik zatrzymuje się na pierwszej stopce po 65 536 bajtach, a GetEmbeddedFileContentToStream nadal zgłasza sukces
Uszkodzenie pozostaje niewidoczne, bo każda warstwa kończy się sukcesem: słownik załącznika deklaruje pełne /Params /Size, dekodowanie nie zgłasza błędu, a ucięcie zauważa dopiero ktoś, kto otworzy załącznik
// Naiwny kawałkujący FPC DeflateStream (w wersji uproszczonej):
// ZFPCCompress robi deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// więc każdy kawałek 64 KB staje się osobnym członem 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));

Które wywołania PDF Library for Delphi trafiają na ścieżkę kawałkowania?

Na FPC ścieżka kawałkowania zapisuje każdy osadzony plik od 1 MiB wzwyż, a czyta każdy strumień Flate wyciągnięty przez strumieniowe API, gdy tylko jego rozmiar po kompresji przekroczy jeden kawałek. TPDFStream.ReadFromStream decyduje, jak kodować nadchodzące dane. Gdy Deflate jest włączone, a Stream.Size >= 1048576, dane płyną przez DeflateStream; poniżej tego progu całe źródło trafia do pamięci i wołany jest DeflateStr, jednoshotowy helper, który kompresuje wszystko w jednym wywołaniu. Łańcuch filtrów ASCII85 plus Flate pomija test rozmiaru i zawsze idzie przez DeflateStream, więc na tej ścieżce każdy ładunek większy niż 64 KB obejmuje już kilka kawałków. Publiczne punkty wejścia zasilające ReadFromStream z włączoną kompresją to funkcje zapisu plików osadzonych:

  • TPDFlib.EmbedFile i TPDFlib.AddEmbeddedFile, które wczytują plik z dysku do strumienia /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream i TPDFlib.AddAssociatedFileFromFile, funkcje zapisu plików powiązanych PDF/A-3 używane dla XML faktur elektronicznych i innych danych źródłowych
  • Po stronie odczytu TPDFlib.GetEmbeddedFileContentToStream i GetEmbeddedFileContentToFile, które dekodują przez TPDFStream.WriteDecodedToStream, a stamtąd przez InflateStream

Gdy zrobimy to źle, awaria jest cicha na każdej warstwie. Zapisująca strona przechowuje /Params /Size oraz MD5 /CheckSum liczony z oryginalnego pliku, więc słownik załącznika deklaruje pełny rozmiar nawet wtedy, gdy strumień trzyma szesnaście członów. Każdy zgodny ze standardem czytnik, łącznie z budową Delphi tej biblioteki, zatrzymuje się czysto na pierwszym Z_STREAM_END i zwraca dokładnie 65 536 bajtów. GetEmbeddedFileContentToStream zwraca 1, bo raportuje, czy dekodowanie zgłosiło błąd, a nie to, czy wynik zgadza się z /Size. Każdy, kto gonił problem dużego dokumentu przez łączenie i dzielenie gigabajtowych PDF-ów, zna ten wzorzec: plik się otwiera, liczba stron się zgadza, a uszkodzenie wychodzi na jaw dopiero, gdy ktoś otwiera załącznik

Jeden stan deflate na wszystkie kawałki

DeflateStream w PDFlibZLib.pas inicjalizuje jeden paszlib.TZStream, podaje każdy kawałek do deflate z Z_NO_FLUSH, a dopiero na końcu opróżnia kompresor przez Z_FINISH, aż ten zwróci Z_STREAM_END. Daje to dokładnie jeden nagłówek, jeden strumień bitowy deflate, którego odwołania wsteczne mogą sięgać przez granice kawałków, i jedną sumę Adler-32 z całego wejścia. Gałąź FPC ma tę samą strukturę co gałąź Delphi. Zapisuje też wynik na bieżąco, w miarę powstawania, zamiast najpierw sklejać cały skompresowany rezultat w AnsiString, więc zapis nigdy nie buduje drugiej pełnej kopii skompresowanych danych w pamięci, zanim skopiuje je do celu

DeflateStream z jednym stanem w PDFlibPas: jeden paszlib.TZStream inicjalizowany raz, każdy kawałek 64 KB podawany z Z_NO_FLUSH i końcowe opróżnienie przez Z_FINISH, co daje dokładnie jeden nagłówek, jeden ciągły strumień bitowy deflate z odwołaniami wstecznymi przekraczającymi granice kawałków i jedną sumę Adler-32 z całego wejścia
Jeden stan zmienia też sposób zapisu wyniku: skompresowane bajty wychodzą, gdy tylko zapełni się bufor, zamiast kumulować się w drugiej pełnej kopii, a PLDeflateLevel dociera także do dużych plików osadzonych na FPC
// FPC DeflateStream z jednym stanem zlib (ścieżki błędów wycięte)
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 sam stan, bez przerywania członu
        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                                    // jedna stopka dla całego wejścia
    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 działa według tego samego wzorca: jeden inflateInit2, wewnętrzna pętla wołająca inflate, dopóki bieżący kawałek nie zostanie skonsumowany, a bufor wyjściowy przestanie być pełny, i stop na Z_STREAM_END. Jest jeden efekt uboczny, o którym warto wiedzieć. Ponieważ zapis woła deflateInit2 samodzielnie, zamiast przechodzić przez helper ze sztywnym poziomem, respektuje PLDeflateLevel, więc poziom ustawiony przez TPDFlib.SetCompressionLevel(1..9) obowiązuje na FPC także przy dużych plikach osadzonych. To ma znaczenie, jeśli już dostrajasz kompresję pod archiwizację, jak opisano w artykule o zmniejszaniu rozmiaru plików PDF w Delphi

Jak sprawdzić, czy strumień Flate to pojedynczy człon zlib?

Zdekompresuj go zwykłym dekoderem zlib i sprawdź dwie rzeczy w chwili, gdy zwróci Z_STREAM_END: długość zdekodowana równa się długości źródła, a avail_in wynosi zero. Pozostałość wejścia za znacznikiem końca to znak rozpoznawczy strumienia sklejanego. Poniższa procedura to wersja FPC tego sprawdzenia, zbudowana wprost na paszlib, żeby nie ufać testowanemu kodowi, a zaraz za nią idzie sonda, która przepycha 200 000 bajtów, cztery kawałki po 64 KB, przez DeflateStream

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 człon kończy się dokładnie na ostatnim bajcie wejścia
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Przepchnij 200 000 bajtów przez DeflateStream z domyślnym kawałkiem 64 KB
// i wymagaj jednego członu, który dekoduje się z powrotem do pełnej długości
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 poziomie aplikacji przydatna asercja jest taka, której biblioteka za ciebie nie robi: porównaj to, co wychodzi z załącznika, z zapisanym /Params /Size z chwili, gdy trafił do środka. GetEmbeddedFileIntProperty z tagiem 5 zwraca tę zapisaną wielkość, indeksy plików osadzonych liczą się od 1, a ładunek musi mieć co najmniej 1 MiB, żeby w ogóle ruszyć ścieżkę strumieniowania. Uruchom ten sam test na każdym kompilatorze, z którym wysyłasz produkt, bo ta klasa błędów na Delphi przechodzi, a wysypuje się tylko 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 lub więcej wybiera kawałkowaną ścieżkę DeflateStream w 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;

Przypadki brzegowe gałęzi FPC

Gałąź FPC stosuje pobłażliwe reguły dekodowania. Gdy osiągnięte zostaje MaxOutput, InflateStream na FPC ucina na limicie i wraca, podczas gdy gałąź Delphi rzuca ERangeError, a FPC wciąż akceptuje częściowo zdekodowany wynik, gdy inflate zgłasza błąd danych, bo część producentów PDF emituje strumienie ucięte albo z zepsutą sumą kontrolną. PDF, który już zawiera sklejone człony, zatrzymuje się na pierwszym Z_STREAM_END w każdym czytniku, cokolwiek go napisało. Nie próbuj leczyć takiego pliku przez dekodowanie i ponowne kodowanie strumienia wewnątrz biblioteki, bo to tylko utrwala ucięcie do 64 KB. Osadź załącznik ponownie z jego oryginalnego źródła. Pętla FPC traktuje odczyt krótszy niż pełny kawałek jako koniec danych, co obowiązuje dla strumieni takich jak TFileStream i TMemoryStream, więc własny TStream przekazany do AddAssociatedFileFromStream najbezpieczniej najpierw skopiować do TMemoryStream

DeflateStream i InflateStream po stronie FPC z jednym stanem zlib trafiają do PDF Library for Delphi, która celuje w Delphi, C++Buildera i Free Pascal z jednego drzewa źródeł, więc duży załącznik wraca z budowy FPC bajt w bajt, dokładnie tak, jak wraca z Delphi