Przed wersją v3.539.24 budowa Free Pascal biblioteki PDF Library for Delphi kompresowała duże strumienie w kawałkach po 64 KB i dopisywała każdemu kawałkowi własny nagłówek oraz sumę kontrolną zlib, więc załącznik o rozmiarze 1 MiB stawał 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ło. Poprawka utrzymuje jeden stan zlib przy życiu przez wszystkie kawałki, zarówno w DeflateStream, jak i w InflateStream
Błąd istniał wyłącznie po stronie FPC tej jednostki i tylko na strumieniowej ścieżce kawałkowania, i dokładnie dlatego przetrwał: gałąź Delphi zawsze działała poprawnie, helpery oparte na łańcuchach zawsze działały poprawnie, a małe testowe dane nigdy w ogóle nie dochodziły do kodu kawałkowania. To bliski kuzyn defektów opisanych w artykule o pięciu błędach portowania na FPC, które Delphi skutecznie maskowało, z tą różnicą, że tym razem budowa Delphi nikogo nie okrywała. Gałąź FPC została po prostu napisana w oparciu o błędny model 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ł
Stary DeflateStream po stronie FPC czytał źródło porcjami po 64 KB i przekazywał 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ła je w jeden AnsiString przed zapisem. Wynik wyglądał jak dane Flate, miał poprawny nagłówek i dekodował się bez błędu, ale dekodował się tylko do pierwszych 65 536 bajtów. Po stronie odczytu InflateStream miał lustrzany defekt: wołał ZFPCInflate raz na każde 64 KB skompresowanego wejścia, a każde wywołanie zaczynało 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 był dekodowany tylko tak daleko, jak poniosły go pierwsze 64 KB skompresowanych bajtów
// FPC DeflateStream przed v3.539.24 (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 trafiały na ścieżkę kawałkowania?
Na FPC każdy osadzony plik od 1 MiB wzwyż był zapisywany błędnie, a każdy strumień Flate wyciągnięty przez strumieniowe API był błędnie odczytywany, gdy tylko jego rozmiar po kompresji przekraczał jeden kawałek. TPDFStream.ReadFromStream decyduje, jak kodować nadchodzące dane. Gdy Deflate jest włączone, a Stream.Size ma co najmniej 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órego problem nigdy nie dotyczył. Ł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 był już rozcinany na kilka członó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
Awaria była cicha na każdej warstwie. Zapisująca strona przechowuje /Params /Size oraz MD5 /CheckSum liczony z oryginalnego pliku, więc słownik załącznika deklarował pełny rozmiar, podczas gdy strumień trzymał szesnaście członów. Budowa Delphi tej samej biblioteki, czytając taki plik, zatrzymywała się czysto na pierwszym Z_STREAM_END i zwracała dokładnie 65 536 bajtów. GetEmbeddedFileContentToStream zwracało 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
Naprawiony 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 teraz strukturę, którą gałąź Delphi miała zawsze. Zapisuje też wynik na bieżąco, w miarę powstawania, zamiast najpierw sklejać cały skompresowany rezultat w AnsiString, więc zapis nie buduje już drugiej pełnej kopii skompresowanych danych w pamięci, zanim skopiuje je do celu
// FPC DeflateStream od v3.539.24 (ś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 dostała symetryczną przebudowę: jeden inflateInit2, wewnętrzną pętlę wołającą 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ć. Stary kawałkujący zapis miał na sztywno poziom 6, a nowy respektuje PLDeflateLevel, więc poziom ustawiony przez TPDFlib.SetCompressionLevel(1..9) obowiązuje teraz 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. Poprawkę zweryfikowano właśnie tak: 200 KB danych testowych, obejmujących cztery kawałki po 64 KB, przeszło przez nowy DeflateStream i wyszło jako 534-bajtowy pojedynczy strumień zlib, fabryczny dekoder zlib odzyskał wszystkie 200 000 bajtów bez resztki wejścia, a ten sam test przeszedł na skrośkompilowanym celu FPC i386. Poniższa procedura to wersja FPC tego sprawdzenia, zbudowana wprost na paszlib, żeby nie ufać testowanemu kodowi
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 oryginalny defekt na Delphi przechodził, a wysypywał 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;
Czego poprawka nie zmienia?
Gałąź FPC zachowuje swoje pobłażliwe reguły dekodowania i nie naprawia plików, które wcześniejsze budowy FPC już zapisały. 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 zapisany przez budowę FPC sprzed v3.539.24 nadal zawiera sklejone człony, a poprawiony czytnik, jak każdy inny, zatrzymuje się na pierwszym Z_STREAM_END. 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 kończy się też nadal na pierwszym odczycie krótszym niż pełny kawałek, co jest sygnałem końca danych tylko dla strumieni takich jak TFileStream i TMemoryStream, więc własny TStream przekazany do AddAssociatedFileFromStream najbezpieczniej najpierw skopiować do TMemoryStream. Gałąź Delphi, helpery DeflateStr i InflateStr oraz każdy strumień mniejszy niż 1 MiB na zwykłej ścieżce Flate zachowują się dokładnie jak wcześniej
Naprawione DeflateStream i InflateStream po stronie FPC trafiają do wydania v3.539.24 PDF Library for Delphi, celującej w Delphi, C++Buildera i Free Pascal z jednego drzewa źródeł, i od tej wersji duży załącznik powinien z budowy FPC wracać bajt w bajt, tak samo, jak zawsze wracał z Delphi