Technický článek

Chunked zlib na FPC: proč FlateDecode potřebuje jeden stream

Před v3.539.24 komprimovala build PDF Library for Delphi pro Free Pascal velké streamy po 64 KB blocích a každý blok dostal vlastní zlib hlavičku a kontrolní součet, takže příloha o 1 MiB skončila jako šestnáct členů zlib slepených od konce ke konci. ISO 32000-1 FlateDecode očekává právě jeden zlib stream a standardní dekodér se zastaví na prvním markeru konce streamu, takže všechno za prvních 65 536 bajtů potichu zmizelo. Oprava drží jeden zlib stav naživu napříč všemi bloky v obou funkcích DeflateStream i InflateStream

Bug existoval jen na FPC větvi units a jen na chunked streamovací cestě, a přesně proto přežil: verze pro Delphi byla vždy správná, helpery pracující s řetězci byly vždy správné a malé testovací payloady se ke chunked kódu vůbec nedostaly. Je to blízký příbuzný defektů z článku o pěti FPC porting bugách, které Delphi dlouho skrývalo, jenže tady delphi build nic nezakrýval. FPC větev byla prostě napsaná podle špatné představy o tom, co zlib stream vlastně je

Proč se chunked zlib stream v jiných čtečkách PDF usekne?

Protože zlib stream podle RFC 1950 je jeden kontejner, ne sekvence kontejnerů, a korektní inflater bere první Adler-32 trailer jako konec dat. Formát je dvoubajtová hlavička, jeden souvislý RFC 1951 deflate bitový proud, jehož poslední blok nese příznak posledního bloku, a čtyřbajtový Adler-32 kontrolní součet přes všechny nekomprimované bajty. ISO 32000-1 §7.4.4 definuje /FlateDecode přesně v těchto termínech. Když inflate dorazí k traileru, vrátí Z_STREAM_END a zbývající vstup nechá nepřečtený v avail_in. Z jeho pohledu není co zkazit, tak nevyhodí žádnou chybu a bajty za trailerem prostě ignoruje. Řetězení členů je legitimní idea v gzipu (RFC 1952 povoluje v jednom souboru víc členů), odtud ta intuice asi přišla, ale zlib žádné takové pravidlo nemá a PDF o něj nikdy nepožádalo

Anatomie členu zlib podle RFC 1950 ve FlateDecode streamách PDFlibPas: dvoubajtová hlavička, jeden souvislý deflate bitový proud, jehož poslední blok nese příznak posledního bloku, a čtyřbajtový Adler-32 trailer, na kterém inflate vrátí Z_STREAM_END a zbývající vstup nechá nepřečtený v avail_in bez vyvolání chyby
Korektní inflater bere první Adler-32 trailer jako konec dat, takže hranice členu je tvrdá zastávka a všechno, co writer za ni přilepil, je mrtvá váha, kterou žádná čtečka nedekóduje

Starý FPC DeflateStream četl zdroj po 64 KB a každý blok předával ZFPCCompress, helperu, který si pustí vlastní deflateInit2, komprimuje s Z_FINISH a zavolá deflateEnd. Každý blok tedy vylezl jako kompletní, validní, sám sebe ukončující zlib stream a funkce je před zápisem slepila do jednoho AnsiStringu. Výsledek vypadal jako Flate data, měl správnou hlavičku a dekódoval se bez chyby, jen se dekódoval na prvních 65 536 bajtů. Na čtení měla InflateStream zrcadlově obrácenou vadu: volala ZFPCInflate jednou za každých 64 KB komprimovaného vstupu a každé volání startovalo čerstvé inflateInit2. Druhý blok ale začíná uprostřed deflate bitového proudu bez zlib hlavičky, takže nový inflater ho odmítne, a úplně normální single-member stream od jiného producenta se dekódoval jen do dálky, kam dosáhly jeho první 64 KB komprimovaných bajtů

Vada chunked writeru ve FPC DeflateStream PDFlibPas: každých 64 KB jde přes ZFPCCompress jako kompletní člen zlib, takže příloha o 1 MiB drží šestnáct slepených členů, čtečka se zastaví na prvním traileru po 65 536 bajtech a GetEmbeddedFileContentToStream pořád hlásí úspěch
Škoda zůstala neviditelná, protože uspěla každá vrstva: slovník přílohy hlásil plné /Params /Size, dekódování nevyhodilo chybu a useknutí si všiml jen ten, kdo přílohu otevřel
// FPC DeflateStream před v3.539.24 (zjednodušeno):
// ZFPCCompress dělá deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// takže každých 64 KB se stane samostatným členem 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));

Která volání PDF Library for Delphi se dostala na chunked cestu?

Na FPC se každý embedded file od 1 MiB výš zapsal špatně a každý Flate stream extrahovaný streamovacím API se četl špatně, jakmile jeho komprimovaná velikost přesáhla jeden blok. TPDFStream.ReadFromStream rozhoduje, jak příchozí data zakóduje. Když Deflate je true a Stream.Size >= 1048576, streamuje je přes DeflateStream; pod tímto prahem načte celý zdroj do paměti a zavolá DeflateStr, single-shot helper, kterého se vada nikdy nedotkla. Řetězec filtrů ASCII85 plus Flate test velikosti vynechává a jde vždy přes DeflateStream, takže na té cestě byl jakýkoli payload větší než 64 KB už rozdělený na několik členů. Veřejné vstupní body, které volají ReadFromStream s zapnutou komprimací, jsou writery embedded files:

  • TPDFlib.EmbedFile a TPDFlib.AddEmbeddedFile, které čtou soubor z disku do streamu /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream a TPDFlib.AddAssociatedFileFromFile, writery PDF/A-3 associated files pro XML e-faktur a další zdrojová data
  • Na čtení pak TPDFlib.GetEmbeddedFileContentToStream a GetEmbeddedFileContentToFile, které dekódují přes TPDFStream.WriteDecodedToStream a odtud přes InflateStream

Selhání bylo na každé vrstvě tiché. Writer ukládá /Params /Size a MD5 /CheckSum spočítaný z originálního souboru, takže slovník přílohy hlásil plnou velikost, zatímco stream držel šestnáct členů. Delphi build téže knihovny se při čtení takového souboru čistě zastavil na prvním Z_STREAM_END a vrátil přesně 65 536 bajtů. GetEmbeddedFileContentToStream vrátilo 1, protože hlásí, zda dekódování vyhodilo chybu, ne jestli výstup odpovídá /Size. Kdo někdy pronásledoval problém velkého dokumentu skrz slučování a dělení gigabajtových PDF, zná tenhle vzorec: soubor se otevře, počet stránek sedí a škoda se ukáže, až někdo otevře přílohu

Jeden deflate stav napříč všemi bloky

Opravený DeflateStream v PDFlibZLib.pas zinicializuje jeden paszlib.TZStream, podstrká každý blok funkci deflate s Z_NO_FLUSH a teprve na konci vyprázdní kompresor přes Z_FINISH, dokud nevrátí Z_STREAM_END. Výsledkem je přesně jedna hlavička, jeden deflate bitový proud, jehož back-references sahají přes hranice bloků, a jedno Adler-32 přes celý vstup. FPC větev teď má tutéž strukturu, kterou verze pro Delphi měla vždycky. Výstup navíc zapisuje průběžně, jak se vyrábí, místo aby nejprve slepil celý komprimovaný výsledek do AnsiStringu, takže writer už si v paměti nestaví druhou plnou kopii komprimovaných dat, než je zkopíruje do cíle

Opravený DeflateStream v PDFlibPas: jeden paszlib.TZStream zinicializovaný jednou, každých 64 KB podstrčených se Z_NO_FLUSH a závěrečné vyprázdnění přes Z_FINISH, což dá přesně jednu hlavičku, jeden souvislý deflate bitový proud, jehož back-references překračují hranice bloků, a jedno Adler-32 přes celý vstup
Jeden stav mění i způsob zápisu výstupu: komprimované bajty odcházejí, jakmile se buffer naplní, místo aby se hromadily v druhé plné kopii, a PLDeflateLevel teď zasáhne i velké embedded files na FPC
// FPC DeflateStream od v3.539.24 (chybové cesty vypuštěny)
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);   // tentýž stav, žádný zlom mezi členy
        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 trailer pro 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ý přepis: jedno inflateInit2, vnitřní smyčku, která volá inflate, dokud se aktuální blok nespotřebuje a výstupní buffer už není plný, a zastávku na Z_STREAM_END. Existuje ale jeden vedlejší efekt, který stojí za pozornost. Starý chunked writer měl level 6 natvrdo, zatímco nový respektuje PLDeflateLevel, takže level nastavený přes TPDFlib.SetCompressionLevel(1..9) se teď vztahuje i na velké embedded files na FPC. To je relevantní, pokud už si ladíte komprimaci pro archivní výstup, jak popisuje snižování velikosti PDF souboru v Delphi

Jak ověříte, že Flate stream je jediný člen zlib?

Nafoukněte ho obyčejným zlib dekodérem a když vrátí Z_STREAM_END, zkontrolujte dvě věci: dekódovaná délka se rovná délce zdroje a avail_in je nula. Zbytkový vstup za koncovým markerem je podpis zřetězeného streamu. Oprava se ověřila takhle: 200 KB testovacích dat, která pokrývají čtyři 64 KB bloky, prošla novým DeflateStream a vylezla jako jediný zlib stream o 534 bajtech, stock zlib dekodér vrátil všech 200 000 bajtů bez zbývajícího vstupu a tatáž kontrola prošla i na cross-kompilovaném i386 FPC targetu. Rutina níže je FPC verze té kontroly, postavená přímo na paszlib, aby nevěřila 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čí přesně na posledním vstupním bajtu
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Protáhněte 200 000 bajtů DeflateStreamem s defaultním 64 KB blokem
// a vyžádejte jeden člen, který se dekóduje zpět na plnou délku
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 aplikace je užitečná ta aserce, kterou za vás knihovna nedělá: porovnejte, co z přílohy vyleze, s /Params /Size zaznamenaným při vkládání. GetEmbeddedFileIntProperty s tagem 5 vrátí tu zaznamenanou velikost, indexy embedded files jsou 1-based a payload musí mít aspoň 1 MiB, aby se streamovací cesta vůbec spustila. Stejný test pusťte na každém kompilátoru, se kterým knihovnu dodáváte, protože původní vada prošla na Delphi a selhala jen 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 víc jde cestou chunked 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;

Co oprava nemění?

FPC větev si nechává shovívavá dekódovací pravidla a nenapravuje soubory, které už dřívější FPC buildy zapsaly. Když se dosáhne MaxOutput, FPC InflateStream se usekne na limitu a vrátí se, zatímco verze pro Delphi vyhodí ERangeError, a FPC pořád akceptuje částečně dekódovaný výstup, když inflate hlásí chybu dat, protože někteří PDF producenti vypouštějí useknuté streamy nebo streamy s rozbitým kontrolním součtem. PDF zapsané FPC buildem starším než v3.539.24 pořád obsahuje zřetězené členy a opravená čtečka se, jako každá jiná, zastaví na prvním Z_STREAM_END. Nezkoušejte takový soubor léčit dekódováním a rekódováním streamu uvnitř knihovny, protože tím jen useknutí na 64 KB učiníte trvalým. Vložte přílohu znovu z jejího původního zdroje. FPC smyčka taky pořád končí na prvním čtení, které vrátí méně než celý blok, což je signál konce dat jen pro streamy jako TFileStream a TMemoryStream, takže vlastní TStream předaný AddAssociatedFileFromStream je nejbezpečnější nejdřív zkopírovat do TMemoryStreamu. Delphi větev, helpery DeflateStr a InflateStr a každý stream menší než 1 MiB na čisté Flate cestě se chovají přesně jako dřív

Opravené FPC DeflateStream a InflateStream vycházejí ve v3.539.24 PDF Library for Delphi, která cílí na Delphi, C++Builder a Free Pascal z jednoho zdrojového stromu a kde by velká příloha měla teď z FPC buildu vylezt bajt za bajt, stejně jako z Delphi vycházela vždycky