PDFlibPas w wersji 3.539.23 dekoduje samodzielne pliki JBIG2 używające organizacji random-access z ITU-T T.88 Annex D.2, gdzie najpierw idą wszystkie nagłówki segmentów, a dane segmentów następują w tej samej kolejności. Natywny dekoder Pascal w PDFlibJBIG2.pas indeksuje offsety nagłówków aż do obowiązkowego nagłówka końca pliku, sprawdza, że numery segmentów rosną i że zadeklarowane długości danych sumują się dokładnie do pozostałych bajtów, a potem dekoduje każde ciało w kolejności nagłówków, bez kopiowania ani przestawiania danych skompresowanych. Przed tym wydaniem ten sam plik dostawał łopatyczny błąd „random-access organisation is not supported” już w chwili odczytu flag nagłówka
Pliki JBIG2 random-access są rzadkie i właśnie dlatego bolą, gdy się trafią. Wychodzą z archiwizacyjnych pipeline'ów i systemów dokumentowego imagingu, które chcą, żeby czytnik zobaczył każdy nagłówek segmentu, a więc każdą zależność stron i słowników, zanim dotknie choćby jednego skompresowanego bajta. Aplikacja Delphi, która hurtowo konwertuje zeskanowane archiwa do PDF, spotyka taki zwykle w środku zadania, po tym jak setki plików sekwencyjnych przeszły bez tarapatów, a dekoder, który staje dęba na poprawnie zbudowanym pliku, jest tylko o włos lepszy od takiego, który renderuje śmieci. Ta sama linia wydań właśnie przed chwilą nauczyła dekoder własnych tabel Huffmana i kanonicznych kodów prefiksowych w JBIG2, więc random access była ostatnią luką organizacyjną w udokumentowanych granicach możliwości dekodera
Czym jest organizacja random-access w JBIG2?
Organizacja random-access to jeden z trzech sposobów, na jakie Annex D w T.88 pozwala rozłożyć te same segmenty: sequential (D.1) przeplata każdy nagłówek z jego danymi, random-access (D.2) stawia wszystkie nagłówki na początku, a wszystkie dane za nimi, a embedded (D.3) to forma bez nagłówka używana wewnątrz innych kontenerów, takich jak PDF. Samodzielny plik .jb2 zaczyna się od ośmiobajtowego identyfikatora 97 4A 42 32 0D 0A 1A 0A, po którym idzie jeden bajt flag, a gdy liczba stron jest znana, czterobajtowa liczba stron. Bit 0 bajta flag wybiera organizację, przy czym 1 znaczy sequential, a 0 znaczy random-access; ustawiony bit 1 znaczy, że liczba stron jest nieznana, a czterobajtowy licznik nie występuje. PDFlibPas czyta to w checkHeader i setFileHeaderFlags, a zarezerwowane bity od 2 do 7 są tolerowane, a nie odrzucane
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1; // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2; // 1 = liczba stron pominięta
noOfPagesKnown := pagesKnown = 0;
// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader; // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
// Strumień PDF: bez nagłówka pliku, organizacja embedded, jedna strona
noOfPagesKnown := True;
randomAccessOrganisation := False;
noOfPages := 1;
end
else
begin
setFileHeaderFlags;
if noOfPagesKnown then
noOfPages := getNoOfPages;
end;
Sam PDF nigdy nie nosi tego układu. Strumień obrazu JBIG2Decode, opisany w ISO 32000-1 §7.4.7, trzyma tylko segmenty stron w organizacji embedded, ze wspólnymi słownikami symboli przeniesionymi do osobnego strumienia JBIG2Globals i bez nagłówka pliku oraz segmentów końca strony i końca pliku. Gdy decodeJBIG2 nie znajduje ośmiobajtowego identyfikatora, zakłada dokładnie to i wymusza dekodowanie sekwencyjne, jednostronicowe. Natywny eksport obrazów JBIG2 idzie w drugą stronę i pakuje segmenty z PDF do samodzielnego pliku o bajcie flag $03, sequential z nieznaną liczbą stron, po którym dopisany jest nagłówek końca pliku. Praca nad random access dotyka więc tylko jednej ścieżki: samodzielnych plików przekazywanych wprost do TPLJBIG2Decoder, zwykle zanim zostaną skonwertowane czy przekompresowane pod PDF, czyli zadania, które po stronie wyjściowej załatwiają backendy enkodera JBIG2 w PDFlibPas
Dlaczego pliku random-access nie da się czytać w kolejności pliku?
Pliku random-access nie da się czytać w kolejności pliku, bo nic w strumieniu bajtów nie zaznacza, gdzie blok nagłówków się kończy, a blok danych się zaczyna — poza samym nagłówkiem segmentu końca pliku. Nagłówki segmentów JBIG2 mają zmienną długość: liczba wskazywanych segmentów może być trzybitową formą krótką albo długą formą z bitmapą retencji, numery wskazywanych segmentów zajmują jeden, dwa lub cztery bajty zależnie od numeru samego segmentu, a pole skojarzenia strony to jeden lub cztery bajty. Naiwny czytnik sekwencyjny parsuje pierwszy nagłówek, czyta jego długość danych, a potem bierze pierwsze bajty drugiego nagłówka za dane tamtego segmentu. Dekoder dowiaduje się, że poszedł zły, dopiero dużo później, i właśnie dlatego stary kod odrzucał tę organizację w całości, zamiast jej próbować
Jak PDFlibPas indeksuje nagłówki segmentów random-access?
PDFlibPas indeksuje nagłówki random-access jednym przeskanowaniem wstępnym, IndexRandomHeaders, które parsuje każdy nagłówek, zapisuje tylko jego offset w bajtach i zatrzymuje się na pierwszym nagłówku końca pliku (typ segmentu 51). Każdy nagłówek jest parsowany w całości i wyrzucany, więc indeks to tablica liczb całkowitych, a nie lista obiektów, a preskan w trakcie akumuluje zadeklarowane długości danych. Gdy skan się kończy, czytnik stoi na pierwszym bajcie danych pierwszego segmentu, a ta pozycja staje się NextBodyOffset
// IndexRandomHeaders, lokalne w TJBIG2StreamDecoder.readSegments
while not reader.isFinished do
begin
Offset := reader.bytePointer;
Header := TSegmentHeader.Create;
try
readSegmentHeader(Header);
if reader.BufferOverrun then
raise EJBIG2DecodeError.CreateFmt(
'JBIG2 truncated random-access header at byte %d', [Offset]);
if (HeaderCount > 0) and
(Cardinal(Header.getSegmentNumber) <= Cardinal(PreviousNumber)) then
raise EJBIG2DecodeError.Create('JBIG2 random-access segment numbers must increase');
PreviousNumber := Header.getSegmentNumber;
Count := Header.getSegmentDataLength;
if Count < 0 then
raise EJBIG2DecodeError.Create('JBIG2 unknown or oversized segment length is not supported');
Inc(TotalLength, Count); // akumulator Int64
HeaderOffsets[HeaderCount] := Offset; // powiększana skokowo
Inc(HeaderCount);
if Header.getSegmentType = JBIG2_END_OF_FILE then
begin
if Count <> 0 then
raise EJBIG2DecodeError.Create('JBIG2 invalid end segment length');
FoundEnd := True;
Break;
end;
finally
Header.Free;
end;
end;
if not FoundEnd then
raise EJBIG2DecodeError.Create('JBIG2 random-access file is missing its end-of-file header');
if TotalLength > Length(reader.Data) - reader.bytePointer then
raise EJBIG2DecodeError.Create('JBIG2 truncated random-access segment data');
if TotalLength < Length(reader.Data) - reader.bytePointer then
raise EJBIG2DecodeError.Create('JBIG2 trailing random-access data');
NextBodyOffset := reader.bytePointer;
Każde sprawdzenie w tej pętli istnieje dlatego, że plik random-access ma mniej redundancji niż sekwencyjny. Numery segmentów muszą ściśle rosnąć, porównywane jako wartości bez znaku, bo dwa nagłówki roszczące sobie ten sam numer czynią niejednoznacznym, które ciało oznacza lista wskazywanych w późniejszym regionie. Pole długości danych czyta handleSegmentDataLength, które mapuje każdą wartość z ustawionym najwyższym bitem, łącznie ze znacznikiem „nieznanej długości” 0xFFFFFFFF, na -1; w układzie random-access nie ma innego sposobu, by znaleźć początek następnego ciała, więc PDFlibPas odrzuca taką długość natychmiast, zamiast skanować w poszukiwaniu znacznika końca. Suma musi zgadzać się z pozostałymi bajtami dokładnie w obie strony, a pojedynczy nadmiarowy bajt po ostatnim ciele kończy się błędem „trailing random-access data”. Ten rygor jest celowy: w tym układzie niezgodność długości znaczy, że każde ciało za punktem błędu jest przesunięte, a dekoder, który machnie ręką na jeden zbłąkany bajt, nie ma jak rozstrzygnąć, czy to nieszkodliwe wypełnienie, czy pierwszy objaw rozjechanych danych
Dlaczego ostatni segment końca strony ginął?
Ostatni segment końca strony ginął, bo pierwsza wersja pętli dekodowania trzymała sekwencyjny test zakończenia, while not reader.isFinished, a w układzie random-access strumień danych kończy się, zanim skończy się indeks nagłówków. Segmenty końca strony (typ 49) i końca pliku niosą zero bajtów danych i normalnie są ostatnimi nagłówkami w pliku. Po skonsumowaniu ciała ostatniego regionu czytnik stoi dokładnie na końcu bufora, więc pętla się kończy, a te segmenty o zerowej długości nigdy nie są rozsyłane do obsługi, zostawiając stronę niedokończoną. Poprawka każe pętli random-access liczyć nagłówki zamiast bajtów. Każda iteracja skacze czytnikiem do następnego zaindeksowanego nagłówka, zeruje bitPointer na 7, bo poprzednie ciało mogło skończyć się w połowie bajta, parsuje ten nagłówek od nowa, potem przesuwa bytePointer na NextBodyOffset i awansuje go za ciało. Istniejące procedury obsługi segmentów, kontrole wskazywanych segmentów i diagnostyka Context działają bez zmian, a komunikat błędu nadal raportuje pierwotny offset bajtowy nagłówka, a nie pozycję ciała
// TJBIG2StreamDecoder.readSegments, pętla główna
if randomAccessOrganisation then
IndexRandomHeaders;
while (randomAccessOrganisation and (HeaderIndex < HeaderCount)) or
((not randomAccessOrganisation) and (not reader.isFinished)) do
begin
if randomAccessOrganisation then
begin
reader.bytePointer := HeaderOffsets[HeaderIndex];
reader.bitPointer := 7; // wyrównanie po częściowym bajcie
Inc(HeaderIndex);
end;
SegmentOffset := reader.bytePointer; // używane w kontekście błędu
readSegmentHeader(segmentHeader);
if randomAccessOrganisation then
reader.bytePointer := NextBodyOffset; // skok do danych tego segmentu
DataLength := segmentHeader.getSegmentDataLength;
DataEnd := reader.bytePointer + DataLength;
NextBodyOffset := DataEnd;
// ... dispatch do istniejącej procedury segmentu, potem seek na DataEnd
end;
Co właściwie dowodzi walidacja random-access?
Walidacja dowodzi, że przetasowane bajty dekodują się do tych samych pikseli co ich sekwencyjne oryginały, i dowodzi, że zniekształcone wejście random-access pada czysto; nie dowodzi pokrycia plików random-access od dowolnych enkoderów. Wspólna regresja Pascal używa 235-bajtowego pliku syntetycznego zbudowanego na fixturze z własną tabelą, który musi zdekodować się do wiersza czarnych pikseli 7 na 1, raz ze znaną liczbą stron, raz z usuniętym polem licznika, a potem podaje dekoderowi każdy ucięty prefiks tego pliku, zdublowany numer segmentu, jeden nadmiarowy bajt i nieznaną długość danych, każdorazowo assertując, że LoadFromByteArray zwraca False i zostawia Width oraz Height na zerze. Przypadek realnego obrazu to obraz typu refinement z własną tabelą o wymiarach 500 na 473, którego segmenty przetasowano do układu random-access z zachowaniem każdego oryginalnego nagłówka i skompresowanego bajta; jego SHA-256 zgadza się co do znaka z recenzowaną sekwencyjną bazą. Ten plik to pochodna wytworzona transformacją organizacji, a nie naturalny dokument random-access znaleziony na wolności, i takiej naturalnej próbki nie było. Zestawy przeszły przy 1 598 testach dla Delphi Win32, 42 dla zestawu obrazowego Delphi Win64, 48 dla FPC Win32 i 46 dla FPC Win64, obok trzech istniejących sekwencyjnych przypadków pikselowych
Wczytywanie pliku .jb2 random-access i jego granice
Kod aplikacji się nie zmienia: TPLJBIG2Decoder.LoadFromByteArray wykrywa nagłówek pliku i organizację sam z siebie, zwraca False przy każdym odrzuconym wejściu z powodem w LastError, a zdekodowaną stronę wystawia przez Width, Height i GetScanline, które zwraca jeden bajt na piksel
uses
SysUtils, Classes, PDFlibJBIG2;
function ReadJb2(const FileName: string): TJBIG2ByteArray;
var
FS: TFileStream;
begin
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, FS.Size);
if Length(Result) > 0 then
FS.ReadBuffer(Result[0], Length(Result));
finally
FS.Free;
end;
end;
function CountBlackPixels(const FileName: string): Integer;
var
Decoder: TPLJBIG2Decoder;
Row: TJBIG2ByteArray;
X, Y: Integer;
begin
Result := 0;
Decoder := TPLJBIG2Decoder.Create;
try
// Samodzielne pliki sekwencyjne i random-access przyjmują to samo wywołanie
if not Decoder.LoadFromByteArray(ReadJb2(FileName)) then
raise Exception.Create('JBIG2 rejected: ' + Decoder.LastError);
for Y := 0 to Decoder.Height - 1 do
if Decoder.GetScanline(Y, Row) then
for X := 0 to Decoder.Width - 1 do
if Row[X] = 1 then
Inc(Result);
finally
Decoder.Free;
end;
end;
Granice wypada powiedzieć wprost. Obsługa random-access to funkcja organizacji pliku, a nie API losowych stron: TPLJBIG2Decoder nadal zwraca bitmapę pierwszej strony i nie ma wywołania, które wyciągnęłoby stronę 7 z pliku o 40 stronach albo dekodowało strony leniwie. Segmenty o nieznanej długości danych są odrzucane w plikach random-access, a istniejące limity długości prefiksów własnych tabel Huffmana i liczby wpisów tabel pozostają bez zmian. Te limity są na tyle wąskie, że aplikacja Delphi może skierować odrzucone przypadki gdzie indziej po LastError, a resztę obrazowego pipeline'u, od ekstrakcji obrazów z PDF po kodowanie JBIG2, opisuje strona produktu biblioteki PDFlibPas dla Delphi