Artykuł techniczny

Pliki JBIG2 random-access: dekodowanie w Delphi

Oto błąd, który ręcznie pisany czytnik JBIG2 robi na pierwszym nietypowym pliku, jaki spotka. Parsuje nagłówek pliku, parsuje pierwszy nagłówek segmentu, czyta czterobajtową długość danych i bierze tyle bajtów jako ciało segmentu. Na większości plików .jb2 to dokładnie dobre. Na pliku, którego bajt flag ma wyzerowany bit 0, te "ciała" to naprawdę drugi, trzeci i czwarty nagłówek segmentu, a dekoder arytmetyczny zamienia je w bitmapę szumu albo ucieka za koniec bufora. Plik nie jest zepsuty. Używa organizacji random-access z ITU-T T.88 Annex D.2, gdzie każdy nagłówek segmentu idzie najpierw, a wszystkie dane segmentów następują potem, w tej samej kolejności

Pliki random-access są rzadkie. Wychodzą z systemów archiwizacyjnych i dokumentowego imagingu, które chcą, żeby czytnik zobaczył każdą stronę i każdą zależność słowników, zanim dotknie skompresowanego bajta. Jeśli zadanie konwersji miesza pliki z takiego źródła ze zwykłymi skanami, jeden plik random-access może siedzieć w środku partii, w której reszta jest sekwencyjna. Dekoder, który je czyta, to ten sam natywny dekoder Pascala, który ogarnia własne tabele Huffmana i kanoniczne kody prefiksowe JBIG2, więc strona kodowania się nie zmienia; różni się tylko kolejność, w jakiej znajduje się segmenty

Trzy układy pliku za jednym bajtem flag

PDF Library for Delphi czyta samodzielne pliki JBIG2 przez TPLJBIG2Decoder w module PDFlibJBIG2, a dekoder wylicza układ z nagłówka pliku sam. T.88 Annex D pozwala ułożyć te same segmenty na trzy sposoby: sequential (D.1) stawia każdy nagłówek bezpośrednio przed jego danymi, random-access (D.2) stawia wszystkie nagłówki najpierw i wszystkie dane za nimi, a embedded (D.3) to forma bez nagłówka używana wewnątrz innych kontenerów. Samodzielny plik zaczyna się od ośmiobajtowego identyfikatora 97 4A 42 32 0D 0A 1A 0A, potem idzie jeden bajt flag, a potem czterobajtowa liczba stron, chyba że flagi mówią, że licznik jest nieznany

Bit 0 tego bajta flag wybiera organizację: 1 znaczy sequential, 0 znaczy random-access. Ustawiony bit 1 znaczy, że liczba stron jest nieznana, a czterobajtowy licznik nie występuje, więc pierwszy nagłówek segmentu zaczyna się na bajcie 9 zamiast na bajcie 13. Bity od 2 do 7 są zarezerwowane; dekoder przyjmuje pliki, które je ustawiają, zamiast je odrzucać, bo zbłąkany zarezerwowany bit nie mówi nic o tym, czy same segmenty są czytelne

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

Sam PDF nigdy nie niesie układu random-access. Strumień obrazu JBIG2Decode, opisany w ISO 32000-1 §7.4.7, trzyma tylko segmenty stron w formie 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 dekoder nie znajduje ośmiobajtowego identyfikatora, traktuje wejście dokładnie jako taki strumień: sequential, jedna strona. W drugą stronę, gdy PDF Library for Delphi eksportuje obraz JBIG2Decode jako natywny plik .jb2, pisze bajt flag $03 (sequential, liczba stron nieznana) i dopisuje nagłówek końca pliku. Random access przychodzi więc wyłącznie z zewnątrz, jako samodzielny plik podany wprost do TPLJBIG2Decoder, zwykle w drodze do konwersji albo rekompresji pod PDF z backendami enkodera JBIG2 w PDFlibPas

Dlaczego 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 zaczyna — poza samym nagłówkiem segmentu końca pliku (typ 51). Nagłówki segmentów JBIG2 nie mają stałego rozmiaru. Liczba wskazywanych segmentów to albo trzybitowa forma krótka, albo forma długa z bitmapą retencji; każdy wskazywany numer segmentu zajmuje jeden, dwa albo cztery bajty, zależnie od tego, jak duży jest numer samego bieżącego segmentu; pole skojarzenia strony to jeden albo cztery bajty. Czytnik musi sparsować każde pole nagłówka, żeby dowiedzieć się, gdzie zaczyna się następny

Sekwencyjny czytnik, który chodzi po pliku po kolei, dostaje pierwszy nagłówek dobrze i wszystko po nim źle. Gorzej: może przez chwilę nie zauważyć. Dekoder ogólnego regionu karmiony bajtami nagłówków produkuje piksele, nie błąd, dopóki jakaś późniejsza kontrola długości albo referencji w końcu nie zadziała. Naszym zdaniem dekoder, który nie indeksuje najpierw nagłówków, powinien odrzucać pliki random-access w całości. Jasna odmowa kosztuje jeden plik w partii; wiarygodnie wyglądająca, ale zła bitmapa może wylądować w archiwum i tam zostać

Indeksowanie każdego nagłówka przed pierwszym ciałem

Gdy bajt flag mówi random-access, PDF Library for Delphi robi jeden przebieg po bloku nagłówków, zanim cokolwiek zdekoduje. Każdy nagłówek jest parsowany w całości i potem wyrzucany; trzymywany jest wyłącznie jego offset bajtowy w pliku. Dane skompresowane nie są nigdy kopiowane do przetasowanego drugiego bufora, co ma znaczenie na dużych skanach archiwalnych, gdzie blok danych może być większością pliku. Przebieg zatrzymuje się na pierwszym nagłówku końca pliku, a w tym momencie pozycja czytania siedzi na pierwszym bajcie danych pierwszego segmentu

Plik random-access ma mniej redundancji niż sekwencyjny, więc preskan jest rygorystyczny. Odrzuca plik, gdy:

  • nagłówek jest urwany przez koniec bufora, raportowany jako JBIG2 truncated random-access header at byte N z przesunięciem, od którego ten nagłówek się zaczął
  • numery segmentów nie rosną ściśle (porównywane jako wartości bez znaku), bo dwa nagłówki roszczące sobie ten sam numer czynią listę wskazywanych późniejszego regionu niejednoznaczną
  • długość danych ma ustawiony najwyższy bit, co obejmuje znacznik "nieznanej długości" 0xFFFFFFFF; w tym układzie nie ma innego sposobu, by znaleźć, gdzie zaczyna się następne ciało, więc dekoder odmawia natychmiast, zamiast skanować w poszukiwaniu znacznika końca
  • nagłówek końca pliku deklaruje jakiekolwiek dane albo w ogóle się nie pojawia (JBIG2 random-access file is missing its end-of-file header)
  • zadeklarowane długości sumują się do więcej niż pozostałe bajty albo do mniej
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

Ostatnia reguła to ta, na której ludzie się potykają. Pojedynczy nadmiarowy bajt po ostatnim ciele zawodzi z JBIG2 trailing random-access data. Obok ścieżki sekwencyjnej, gdzie dekodowanie po prostu zatrzymuje się na segmencie końca pliku, a cokolwiek po nim jest ignorowane, to wygląda ostro. Asymetria ma powód. W układzie sekwencyjnym każdy nagłówek siedzi tuż przy własnych danych, więc śmieci na końcu nie mogły niczego przesunąć. W układzie random-access nagłówki są jedyną mapą bloku danych, a jeśli długości się nie sumują, dekoder nie odróżni nieszkodliwego wypełnienia od bajta zgubionego albo dokleczonego dużo wcześniej, który przesunął każde ciało po nim

Dlaczego pętla licząca bajty gubi ostatni segment końca strony?

Pętla biegnąca "dopóki zostały bajty" kończy się za wcześnie na pliku random-access, bo dane kończą 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 przychodzą ostatnie. Gdy ciało ostatniego regionu zostało skonsumowane, pozycja czytania jest dokładnie na końcu bufora; pętla licząca bajty wychodzi tam, a segment końca strony o zerowej długości nigdy nie dostaje swojej kolejki, co zostawia stronę niedokończoną

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

Dla plików random-access dekoder liczy więc nagłówki, nie bajty. Każdy krok skacze z powrotem do następnego zaindeksowanego nagłówka, wyrównuje do granicy bajtu (ciało kodowane arytmetycznie może skończyć się w połowie bajta), parsuje nagłówek ponownie, potem skacze naprzód tam, gdzie zaczyna się następne ciało, i przechodzi za nie o zadeklarowaną długość. Ciało, którego dekoder skończył wcześniej, jest przeskakiwane do zadeklarowanego końca; takie, które czyta poza niego, jest odrzucane. Stamtąd segment idzie przez te same handlery i kontrole referencji co w pliku sekwencyjnym. Błędy podniesione przy dekodowaniu ciała niosą pierwotną pozycję nagłówka, w formie JBIG2 segment N (type T) at byte B: ..., więc przesunięcie w LastError wskazuje coś, co znajdziesz w edytorze szesnastkowym

Wczytywanie pliku .jb2 random-access w Delphi

Kod aplikacji nie zmienia się wraz z organizacją. TPLJBIG2Decoder.LoadFromByteArray wykrywa nagłówek pliku i układ, zwraca False na każdym odrzuconym wejściu z powodem w LastError i wystawia zdekodowaną stronę przez Width, Height i GetScanline, które wypełnia jeden bajt na piksel, 1 dla czerni

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;

Jeśli chcesz wiedzieć, którego układu używa plik — do logowania albo do segregacji korpusu przed przebiegiem konwersji — przeczytaj bajt flag sam. Biblioteka nie wystawia wykrytej organizacji, a dziewięć bajtów to wszystko, czego potrzebujesz. Helper poniżej to coś do dopisania we własnym kodzie; po prostu odtwarza reguły nagłówka T.88:

type
  TJb2Layout = (jlNoFileHeader, jlSequential, jlRandomAccess);

// Własny helper, nie część PDFlibJBIG2: klasyfikuje bufor po jego nagłówku pliku T.88
function DetectJb2Layout(const Data: TJBIG2ByteArray;
  out PageCountKnown: Boolean): TJb2Layout;
const
  Signature: array[0..7] of Byte = ($97, $4A, $42, $32, $0D, $0A, $1A, $0A);
var
  I: Integer;
begin
  Result := jlNoFileHeader;          // np. strumień embedded wyciągnięty z PDF
  PageCountKnown := False;
  if Length(Data) < 9 then
    Exit;
  for I := 0 to 7 do
    if Data[I] <> Signature[I] then
      Exit;
  PageCountKnown := (Data[8] and 2) = 0;   // bit 1 ustawiony: brak czterobajtowej liczby stron
  if (Data[8] and 1) = 0 then
    Result := jlRandomAccess
  else
    Result := jlSequential;
end;

Zadania seryjne: wypełnione bufory i odrzucone pliki

Reguła danych końcowych to miejsce, gdzie kod seryjny zwykle się gubi, a gdy plik otwierający się gdzie indziej jest odrzucany w ten sposób, spójrz na bufor, zanim oskarżysz plik. .jb2 wyciągnięty z kolumny BLOB w bazie, z odczytu sieciowego o stałym rozmiarze albo z formatu kontenera z wypełnieniem do wyrównania może przyjść z kilkoma zerami po prawdziwym końcu. Plik sekwencyjny macha na to ręką. Plik random-access z tym samym wypełnieniem zawodzi z JBIG2 trailing random-access data, a bufor urwany o kilka bajtów za wcześnie zawodzi jako ucięty. Podawaj dokładnie bajty pliku, wymiarowane z rzeczywistej długości źródła, nigdy z rozmiaru chunka albo pojemności

Odrzucony plik nie zostawia też niczego po sobie. LoadFromByteArray resetuje Width, Height i skanlinie, zanim zacznie dekodować, więc nieudane wywołanie raportuje wymiary zerowe zamiast strony poprzedniego pliku, a ta sama instancja dekodera może prosto przejść do następnego pliku:

procedure DecodeBatch(const Files, Log, Quarantine: TStrings);
var
  Decoder: TPLJBIG2Decoder;
  I: Integer;
begin
  Decoder := TPLJBIG2Decoder.Create;
  try
    for I := 0 to Files.Count - 1 do
      if Decoder.LoadFromByteArray(ReadJb2(Files[I])) then
        Log.Add(Format('%s: %d x %d', [Files[I], Decoder.Width, Decoder.Height]))
      else
      begin
        // Width i Height są tu 0; nic z poprzedniego pliku nie przeżywa
        Log.Add(Files[I] + ': ' + Decoder.LastError);
        if Pos('trailing random-access data', Decoder.LastError) > 0 then
          Quarantine.Add(Files[I]);   // sprawdź, jak bajty były przechowywane, zanim obwinisz plik
      end;
  finally
    Decoder.Free;
  end;
end;

Sprawdzenie tego we własnym potoku nie potrzebuje rzadkich plików próbkowych. Przeorganizuj sekwencyjny .jb2 w układ random-access, zachowując każdy nagłówek i każdy skompresowany bajt, przenieś nagłówki końca strony i końca pliku na koniec bloku nagłówków, a zdekodowane piksele muszą zgadzać się z sekwencyjnym oryginałem dokładnie, i z polem liczby stron, i bez. Potem podaj mu każdy ucięty prefiks, zdublowany numer segmentu, jeden nadmiarowy bajt końcowy i długość 0xFFFFFFFF; każde powinno zwrócić False z Width i Height na zerze

Co zwraca dekoder i czego oczekuje

Random access to tu własność układu pliku. TPLJBIG2Decoder zwraca bitmapę pierwszej strony niezależnie od organizacji pliku, a Width i Height opisują tę stronę. Każdy segment musi deklarować długość danych; T.88 pozwala regionowi ogólnemu typu immediate używać znacznika 0xFFFFFFFF, a dekoder odrzuca ten znacznik w obu układach z unknown or oversized segment length is not supported. Długości prefiksów własnych tablic Huffmana i liczby wpisów tabel są ograniczone tak samo jak w plikach sekwencyjnych

Dekodowanie JBIG2 to jeden etap dłuższej ścieżki obrazowej, która obejmuje też ekstrakcję obrazów z PDF, rekompresję i kodowanie JBIG2; strona produktu PDF Library for Delphi opisuje resztę tego potoku i wspierane formaty obrazów