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ł
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
// 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.EmbedFileiTPDFlib.AddEmbeddedFile, które wczytują plik z dysku do strumienia/EmbeddedFileTPDFlib.AddAssociatedFileFromStreamiTPDFlib.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.GetEmbeddedFileContentToStreamiGetEmbeddedFileContentToFile, które dekodują przezTPDFStream.WriteDecodedToStream, a stamtąd przezInflateStream
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
// 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