Artykuł techniczny

Pliki JBIG2 random-access: dekodowanie w Delphi

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

Organizacje plików JBIG2 w PDFlibPas: bajt flag czytany przez setFileHeaderFlags wybiera sequential D.1 z przeplecionymi nagłówkami, random-access D.2 z każdym nagłówkiem przed blokiem danych albo embedded D.3, formę bez nagłówka, której strumień JBIG2Decode używa ze słownikami w JBIG2Globals
Trzy układy niosą te same segmenty, ale tylko random access każe czytnikowi zobaczyć każdą zależność stron i słowników, zanim dotknie skompresowanego bajta, dlatego prosili o nią pipeline'y archiwizacyjne
// 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

Preskan IndexRandomHeaders w PDFlibPas: każdy nagłówek segmentu jest parsowany i wyrzucany, a trzymywany jest tylko jego offset w bajtach, numery segmentów muszą ściśle rosnąć, nieznana długość 0xFFFFFFFF jest odrzucana, skan zatrzymuje się na nagłówku końca pliku typu 51, a zadeklarowane długości muszą dokładnie równać się pozostałym bajtom
Rygor jest celowy: w układzie, w którym nagłówki dają jedyną mapę danych, jeden zbłąkany bajt znaczy, że każde późniejsze ciało może być przesunięte, więc dekoder, który go toleruje, nie odróżni wypełnienia od rozjechania
// 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

Pętla dekodowania random-access w PDFlibPas: każda iteracja skacze do HeaderOffsets bieżącego nagłówka, zeruje bitPointer na 7, by cofnąć końcówki urwane w połowie bajta, przeskakuje na NextBodyOffset po ciało i liczy nagłówki zamiast bajtów, więc segmenty końca strony o zerowej długości dostają swoją kolej przed końcem pętli
Ponieważ segmenty końca strony i końca pliku niosą zero bajtów danych, strumień danych kończy się, zanim skończy się indeks nagłówków, i tylko pętla licząca nagłówki da tym ostatnim segmentom ich kolej
// 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