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
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ů
// 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.EmbedFileaTPDFlib.AddEmbeddedFile, které čtou soubor z disku do streamu/EmbeddedFileTPDFlib.AddAssociatedFileFromStreamaTPDFlib.AddAssociatedFileFromFile, writery PDF/A-3 associated files pro XML e-faktur a další zdrojová data- Na čtení pak
TPDFlib.GetEmbeddedFileContentToStreamaGetEmbeddedFileContentToFile, které dekódují přesTPDFStream.WriteDecodedToStreama odtud přesInflateStream
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
// 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