Odborný článok

JBIG2 súbory s náhodným prístupom: ich dekódovanie v Delphi

PDFlibPas verzia 3.539.23 dekóduje samostatné JBIG2 súbory používajúce random-access organizáciu z ITU-T T.88 Annex D.2, kde najprv príde každá hlavička segmentu a dáta segmentov nasledujú v rovnakom poradí. Natívny Pascal dekodér v PDFlibJBIG2.pas indexuje offsety hlavičiek až po povinnú hlavičku end-of-file, kontroluje, že čísla segmentov rastú a že deklarované dĺžky dát sa sčítajú presne na bajty, ktoré zostávajú, a potom dekóduje každé telo v poradí hlavičiek bez kopírovania či preskladania komprimovaných dát. Pred týmto vydaním rovnaký súbor hneď po prečítaní flagov hlavičky vyhodil holú chybu „random-access organisation is not supported"

JBIG2 súbory s náhodným prístupom sú zriedkavé, a presne preto bolia, keď sa predsa len objavia. Vychádzajú z archivačných pipeline a document-imaging systémov, ktoré chcú, aby si čítač pozrel každú hlavičku segmentu, a teda každú závislosť strán a slovníkov, ešte predtým, než sa dotkne jediného komprimovaného bajtu. Delphi aplikácia, ktorá dávkovo konvertuje naskenované archívy do PDF, sa s niečím takým zvyčajne stretne v polovici jobu, keď stovky sekvenčných súborov prešli čisto, a dekodér, ktorý na korektne poskladanom súbore zastane na mieste, je len o chlp lepší než ten, čo vykreslí odpad. Rovnaká vývojová línia práve doučila dekodér vlastné Huffmanove tabuľky JBIG2 a kanonické prefixové kódy, takže random access bola posledná organizačná medzera, ktorá zostávala v zdokumentovaných limitoch dekodéra

Čo je random-access organizácia JBIG2?

Random-access organizácia je jeden z troch spôsobov, akými T.88 Annex D dovoľuje rozložiť tie isté segmenty: sekvenčná (D.1) prepletie každú hlavičku s jej dátami, random-access (D.2) dá všetky hlavičky dopredu a všetky dáta potom a embedded (D.3) je bezhlavičková forma používaná vo vnútri iných kontajnerov, ako je PDF. Samostatný súbor .jb2 začína osembajtovým identifikátorom 97 4A 42 32 0D 0A 1A 0A, nasleduje jeden flags bajt a, ak je počet strán známy, štyrbajtový počet strán. Bit 0 flags bajtu vyberá organizáciu, 1 znamená sekvenčnú a 0 random-access; nastavený bit 1 znamená, že počet strán je neznámy a štyrbajtový počet chýba. PDFlibPas toto číta v checkHeader a setFileHeaderFlags a rezervované bity 2 až 7 toleruje namiesto toho, aby ich odmietol

Organizácie JBIG2 súborov v PDFlibPas: flags bajt, ktorý číta setFileHeaderFlags, vyberie sekvenčnú D.1 s prepletenými hlavičkami, random-access D.2 s každou hlavičkou pred blokom dát alebo embedded D.3, bezhlavičkovú formu, ktorú stream JBIG2Decode používa so slovníkmi v JBIG2Globals
Tri rozloženia nesú tie isté segmenty, ale len random access nechá čítača vidieť každú závislosť strán a slovníkov predtým, než sa dotkne komprimovaného bajtu, a preto ho archivačné pipeline žiadali
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = počet strán vynechaný
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // PDF stream: žiadna hlavička súboru, embedded organizácia, jedna strana
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

Samotné PDF nikdy nenesie toto rozloženie. Image stream JBIG2Decode, opísaný v ISO 32000-1 §7.4.7, drží len stránkové segmenty v embedded organizácii, so zdieľanými symbol dictionaries presunutými do samostatného streamu JBIG2Globals a bez hlavičky súboru a bez segmentov end-of-page či end-of-file. Keď decodeJBIG2 osembajtový identifikátor nenájde, presne toto predpokladá a vynúti si sekvenčné dekódovanie jednej strany. Natívny export JBIG2 obrázkov ide opačným smerom a zabalí PDF segmenty do samostatného súboru, ktorého flags bajt je $03, sekvenčný s neznámym počtom strán, nasledovaným prilepenou hlavičkou end-of-file. Random-access práca sa preto dotýka len jednej cesty: samostatných súborov odovzdaných priamo do TPLJBIG2Decoder, typicky predtým, než sa konvertujú či prekomprimujú pre PDF, čo je job, ktorý na výstupnej strane odchytávajú JBIG2 encoder backendy v PDFlibPas

Prečo sa random-access súbor nedá čítať v poradí súboru?

Random-access súbor sa nedá čítať v poradí súboru preto, lebo nič v bajtovom prúde neoznačuje, kde blok hlavičiek končí a kde začína blok dát, okrem samej hlavičky segmentu end-of-file. Hlavičky segmentov JBIG2 majú premennú dĺžku: počet referred-to segmentov môže byť trojbitová krátka forma alebo dlhá forma s retention bitmapou, čísla referred-to segmentov zaberajú jeden, dva alebo štyri bajty podľa vlastného čísla segmentu a pole page association má jeden alebo štyri bajty. Naivný sekvenčný čítač naparsuje prvú hlavičku, prečíta jej dĺžku dát a potom za dáta tohto segmentu vezme prvé bajty druhej hlavičky. Dekodér sa veľmi neskoro dozvie, že sa pokazil, a presne preto starý kód organizáciu rovno odmietol, namiesto aby sa ju pokúšal zvládnuť

Ako PDFlibPas indexuje random-access hlavičky segmentov?

PDFlibPas indexuje random-access hlavičky jedným pre-skenom IndexRandomHeaders, ktorý naparsuje každú hlavičku, zapamätá si len jej bajtový offset a zastane na prvej hlavičke end-of-file (segment typu 51). Každá hlavička sa plne parsuje a zahodí, takže index je pole celých čísel, nie zoznam objektov, a pre-sken priebežne sčítava deklarované dĺžky dát. Keď sken skončí, čítačka stojí na prvom bajte dát prvého segmentu a táto pozícia sa stane NextBodyOffset

Pre-sken IndexRandomHeaders v PDFlibPas: každá hlavička segmentu sa naparsuje a zahodí a ponechá sa len jej bajtový offset, čísla segmentov musia prísne rásť, neznáma dĺžka 0xFFFFFFFF sa zamietne, sken zastane na hlavičke end-of-file typu 51 a deklarované dĺžky sa musia presne rovnať zostávajúcim bajtom
Prísnosť je zámerná: v rozložení, kde hlavičky dávajú jedinú mapu dát, jeden záujmiteľný bajt znamená, že každé neskoršie telo môže byť posunuté, takže dekodér, ktorý ho premlčí, nerozozná neškodný padding od prvého príznaku posunutých dát
// IndexRandomHeaders, lokálne v 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);                   // Int64 akumulátor
    HeaderOffsets[HeaderCount] := Offset;      // rastie po blokoch
    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ždá kontrola v tej slučke existuje preto, lebo random-access súbor má menej redundancie než sekvenčný. Čísla segmentov musia prísne rásť, porovnávané ako neznamienkové hodnoty, pretože ak si dve hlavičky nárokujú rovnaké číslo, nejednoznačné je, ktoré telo myslí referred-to zoznam neskoršej oblasti. Pole dĺžky dát číta handleSegmentDataLength, ktorá každú hodnotu s nastaveným horným bitom, vrátane značky neznámej dĺžky 0xFFFFFFFF, mapuje na -1; v random-access rozložení neexistuje iný spôsob, ako nájsť začiatok ďalšieho tela, takže PDFlibPas tú dĺžku odmietne hneď, namiesto hľadania koncovej značky. Súčet sa musí v oboch smeroch presne rovnať zostávajúcim bajtom a jediný extra bajt za posledným telom zlyhá s „trailing random-access data". Tá prísnosť je zámerná: v tomto rozložení znamená nezhoda dĺžok, že každé telo za miestom chyby je posunuté, a dekodér, čo premlčí jeden záujmiteľný bajt, nemá ako zistiť, či je to neškodný padding alebo prvý príznak posunutých dát

Prečo sa posledný segment end-of-page stratil?

Posledný segment end-of-page sa stratil preto, že prvá verzia dekódovacej slučky si nechala sekvenčný test ukončenia while not reader.isFinished a v random-access rozložení dátový prúd dôjde skôr než index hlavičiek. Segmenty end-of-page (typ 49) a end-of-file nesú nula bajtov dát a normálne sú poslednými hlavičkami v súbore. Po spotrebovaní posledného tela oblasti stojí čítačka presne na konci bufferu, takže slučka skončí a tieto nulodĺžkové segmenty sa nikdy neodohrajú, pričom strana ostane nedokončená. Oprava prinúti random-access slučku počítať hlavičky namiesto bajtov. Každá iterácia skočí s čítačkou na ďalšej indexovanej hlavičke, vynuluje bitPointer na 7, lebo predchádzajúce telo mohlo skončiť uprostred bajtu, znovu naparsuje tú hlavičku, potom posunie bytePointer na NextBodyOffset a posúva ho cez telo. Existujúce handlery segmentov, kontroly referred-to segmentov a diagnostika Context bežia nezmenené a chybové hlásenie stále uvádza pôvodný bajtový offset hlavičky, nie pozíciu tela

Dekódovacia slučka random-access v PDFlibPas: každá iterácia skočí na HeaderOffsets aktuálnej hlavičky, vynuluje bitPointer na 7, aby zrušila chvosty uprostred bajtu, preskočí na NextBodyOffset kvôli telu a počíta hlavičky namiesto bajtov, takže nulodĺžkové segmenty end-of-page sa odohrajú, kým slučka skončí
Segmenty end-of-page a end-of-file nesú nula bajtov dát, takže dátový prúd dôjde skôr než index hlavičiek a len slučka počítajúca hlavičky dá tým posledným segmentom ich šancu
// TJBIG2StreamDecoder.readSegments, main loop
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;                    // opätovné zarovnanie po čiastočnom bajte
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // používa sa v chybovom kontexte
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // skok na dáta tohto segmentu
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... odovzdaj existujúcemu handleru segmentu, potom skoč na DataEnd
end;

Čo random-access validácia vlastne dokazuje?

Validácia dokazuje, že preskladané bajty sa dekódujú na tie isté pixely ako ich sekvenčné originály, a že deformovaný random-access vstup zlyhá čisto; nedokazuje pokrytie random-access súborov od ľubovoľných encoderov. Zdieľaná Pascal regresia používa 235-bajtový syntetický súbor postavený na custom-table fixture, ktorý sa musí dekódovať na rad 7 krát 1 čiernych pixelov, tak so známym počtom strán, ako s odobratým poľom počtu, a potom kŕmi dekodér každým skráteným prefixom tohto súboru, duplicitným číslom segmentu, jedným prebytočným bajtom a neznámou dĺžkou dát, pričom pri každom tvrdí, že LoadFromByteArray vráti False a Width aj Height nechá na nule. Prípad reálneho obrázka je 500 krát 473 custom-table refinement obrázok, ktorého segmenty sa preskladali do random-access rozloženia so zachovaním každej pôvodnej hlavičky a každého komprimovaného bajtu; jeho SHA-256 sa presne zhoduje s prekontrolovaným sekvenčným baselineom. Ten súbor je derivát vyrobený organizačnou transformáciou, nie prirodzený random-access dokument nájdený vo voľnej prírode, a takáto prirodzená vzorka nebola k dispozícii. Sady prešli s 1 598 testami pre Delphi Win32, 42 pre Delphi Win64 image sadu, 48 pre FPC Win32 a 46 pre FPC Win64, popri troch existujúcich sekvenčných pixelových prípadoch

Načítanie random-access súboru .jb2 a jeho limity

Aplikačný kód sa nemení: TPLJBIG2Decoder.LoadFromByteArray si hlavičku súboru a organizáciu zistí sám, na akomkoľvek zamietnutom vstupe vráti False s dôvodom v LastError a dekódovanú stranu vystaví cez Width, Height a GetScanline, ktorý vracia jeden bajt na pixel

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
    // Sekvenčné aj random-access samostatné súbory berie to isté volanie
    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;

Hranice stoja za to povedať nahlas. Podpora random access je funkcia organizácie súboru, nie API náhodných strán: TPLJBIG2Decoder stále vracia bitmapu prvej strany a neexistuje volanie, ktoré by vytiahlo stránku 7 zo 40-stranového súboru alebo dekódovalo strany lenivo. Segmenty s neznámou dĺžkou dát sa v random-access súboroch zamietajú a existujúce limity na dĺžky vlastných Huffmanových prefixov a počty položiek tabuliek ostávajú nezmenené. Tie limity sú dosť úzke na to, aby Delphi aplikácia vedela zamietnuté prípady poslať inam cez LastError, a zvyšok image pipeline, od extrakcie PDF obrázkov po JBIG2 kódovanie, je pokrytý na stránke produktu PDFlibPas Delphi PDF library