Техническа статия

JBIG2 файлове с произволен достъп: декодиране в Delphi

PDFlibPas v3.539.23 декодира самостоятелни JBIG2 файлове, ползващи random-access организацията от ITU-T T.88 Annex D.2, при която всеки segment header идва първо, а данните на сегментите следват в същия ред. Нативният Pascal decoder в PDFlibJBIG2.pas индексира отместванията на header-ите до задължителния end-of-file header, проверява, че номерата на сегментите растат и че обявените дължини на данните се събират точно до останалите байтове, и после декодира всяко тяло в реда на header-ите, без да копира или пренарежда компресираните данни. Преди този release същият файл вдигаше плоска грешка „random-access организацията не се поддържа" още при четенето на header флаговете

Random-access JBIG2 файлове са рядкост, което е точно причината да боли, когато все пак се появят. Излизат от архивни pipeline-и и document-imaging системи, които искат четецът да види всеки segment header, а оттам всяка страница и dictionary зависимост, преди да е пипнал един-единствен компресиран байт. Delphi приложение, което пакетно конвертира сканирани архиви в PDF, обикновено се сблъсква с такъв по средата на работа, след стотици sequential файлове, минали чисто, а decoder, който умира на коректен файл, е само мъничко по-добър от един, който рендира боклук. Същата release линия току-що беше научила decoder-а на JBIG2 custom Huffman таблици и канонични prefix кодове, така че random access беше последната организационна дупка в документираните граници на възможностите на decoder-а

Какво е JBIG2 random-access организацията?

Random-access организацията е един от трите начина, по които T.88 Annex D позволява същите сегменти да се подредят: sequential (D.1) преплита всеки header с данните му, random-access (D.2) слага всички header-и най-отпред и цялата след тях, а embedded (D.3) е формата без header, ползвана в други контейнери като PDF. Самостоятелен .jb2 файл започва с осембайтовия идентификатор 97 4A 42 32 0D 0A 1A 0A, следван от един flags байт и, когато броят страници е известен, четирибайтов брой страници. Бит 0 на flags байта избира организацията, като 1 значи sequential, а 0 — random-access; вдигнат бит 1 значи, че броят страници е неизвестен и четирибайтовото число липсва. PDFlibPas ги чете в checkHeader и setFileHeaderFlags, а резервираните битове 2 до 7 се толерират, вместо да се отхвърлят

JBIG2 файлови организации в PDFlibPas: flags байтът, който setFileHeaderFlags чете, избира sequential D.1 с преплети header-и, random-access D.2 с всички header-и пред блока с данни или embedded D.3 — формата без header, която JBIG2Decode stream ползва с речници в JBIG2Globals
Трите подредби носят същите сегменти, но само random access кара четеца да види всяка страница и dictionary зависимост, преди да е пипнал компресиран байт, което е причината архивните pipeline-и да го поискат
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = броят страници е пропуснат
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // PDF stream: без file header, embedded организация, една страница
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

PDF сам по себе си никога не носи тази подредба. JBIG2Decode image stream, описан в ISO 32000-1 §7.4.7, държи само page сегментите в embedded организация, като споделените symbol dictionary-та са изнесени в отделен JBIG2Globals stream, без file header, без end-of-page или end-of-file сегменти. Когато decodeJBIG2 не намери осембайтовия идентификатор, допуска точно това и налага sequential, едностранично декодиране. Нативният JBIG2 image export върви в обратната посока и опакова PDF сегментите в самостоятелен файл с flags байт $03 — sequential с неизвестен брой страници — следван от приложен end-of-file header. Така че работата по random access пипа само един път: самостоятелни файлове, подадени директно на TPLJBIG2Decoder, обикновено преди да бъдат конвертирани или прекодирани за PDF — работата, която JBIG2 encoder backend-ите в PDFlibPas вършат на изходната страна

Защо random-access файл не може да се чете в реда на файла?

Random-access файл не може да се чете в реда на файла, защото нищо в байтовия поток не отбелязва къде свършва блокът с header-и и започва блокът с данни — освен самия end-of-file segment header. JBIG2 segment header-ите са с променлива дължина: броят referred-to сегменти може да е трибитова кратка форма или дълга форма с retention bitmap, referred-to номерата заемат един, два или четири байта според собствения номер на сегмента, а полето за page association е с един или четири байта. Наивен sequential четец парсва първия header, чете дължината на данните му и после приема първите байтове на втория header за данни на този сегмент. Decoder-ът не може да разбере, че е сбъркал, чак много по-късно — затова старият код отказваше организацията изцяло, вместо да се опитва

Как PDFlibPas индексира random-access segment header-ите?

PDFlibPas индексира random-access header-ите в едно pre-scan обхождане, IndexRandomHeaders, което парсва всеки header, записва само байтовото му отместване и спира на първия end-of-file header (segment тип 51). Всеки header се парсва изцяло и се изхвърля, така че индексът е масив от числа, а не списък от обекти, а pre-scan-ът натрупва обявените дължини на данните в движение. Когато сканирането свърши, четецът стои на първия байт от данните на първия сегмент, а тази позиция става NextBodyOffset

IndexRandomHeaders pre-scan в PDFlibPas: всеки segment header се парсва и изхвърля, като се пази само байтовото му отместване, номерата на сегментите трябва строго да растат, неизвестната дължина 0xFFFFFFFF се отхвърля, сканирането спира на type 51 end-of-file header, а обявените дължини трябва да равняват останалите байтове точно
Строгостта е нарочна: в подредба, където header-ите дават единствената карта на данните, един излишен байт значи, че всяко следващо тяло може да е изместено, така че decoder, който го търпява, не може да отличи padding от разместване
// IndexRandomHeaders, локално в 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 акумулатор
    HeaderOffsets[HeaderCount] := Offset;      // расте на парчета
    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;

Всяка проверка в този цикъл съществува, защото random-access файл има по-малко резервираност от sequential. Номерата на сегментите трябва строго да растат, сравнявани като unsigned, защото два header-а с един и същ номер правят неясно кое тяло значи referred-to списъкът на по-късен регион. Полето за дължина на данните се чете от handleSegmentDataLength, който мапва всяка стойност с вдигнат горен бит, включително маркера за „неизвестна дължина" 0xFFFFFFFF, на -1; при random-access подредба няма друг начин да се намери къде започва следващото тяло, затова PDFlibPas отхвърля тази дължина веднага, вместо да сканира за краен маркер. Общото трябва да съвпада с останалите байтове и в двете посоки, а един-единствен излишен байт след последното тяло пада с „trailing random-access data". Тази строгост е нарочна: при тази подредба разминаване в дължината значи, че всяко тяло след точката на грешката е изместено, а decoder, който да се отърва от един излишен байт, няма как да знае дали той е безобиден padding или първият симптом на разместени данни

Защо последният end-of-page сегмент изчезваше?

Последният end-of-page сегмент изчезваше, защото първата версия на decode цикъла пазеше sequential теста за край, while not reader.isFinished, а при random-access подредба потокът с данни свършва, преди да свърши индексът с header-ите. End-of-page (тип 49) и end-of-file сегментите носят нула байта данни и обикновено са последните header-и във файла. След като последното региона тяло е изядено, четецът стои точно на края на буфера, цикълът излиза и тези сегменти с нулева дължина никога не се изпращат, оставяйки страницата недовършена. Поправката кара random-access цикъла да брой header-и вместо байтове. Всяка итерация скача с четеца на следващия индексиран header, нулира bitPointer на 7, защото предишното тяло може да е свършило по средата на байт, препарсва този header, после мести bytePointer на NextBodyOffset и го напредва отвъд тялото. Съществуващите segment handler-и, проверките на referred-to сегменти и Context диагностиката работят непроменени, а съобщение за грешка и нататък докладва първоначалното байтово отместване на header-а, не позицията на тялото

Random-access decode цикъл в PDFlibPas: всяка итерация търси HeaderOffsets на текущия header, нулира bitPointer на 7, за да оправи опашки от частичен байт, скача на NextBodyOffset за тялото и брои header-и вместо байтове, така че сегментите end-of-page с нулева дължина се изпращат, преди цикълът да свърши
Понеже end-of-page и end-of-file сегментите носят нула байта данни, потокът с данни свършва преди индекса с header-ите, и само цикъл, който брои header-и, може да даде на тези последни сегменти техния ред
// TJBIG2StreamDecoder.readSegments, главен цикъл
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;                    // подравняване след частичен байт
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // ползва се в error контекста
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // скачане към данните на сегмента
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... изпращане към съществуващия segment handler, после придвижване до DataEnd
end;

Какво всъщност доказва random-access валидацията?

Валидацията доказва, че пренаредените байтове декодират до същите пиксели като своите sequential оригинали, и че зле оформен random-access вход се проваля чисто; тя не доказва покритие на random-access файлове от произволни encoder-и. Споделената Pascal регресия ползва синтетичен файл от 235 байта, изграден върху fixture с custom таблица, който трябва да декодира до ред от 7 на 1 черни пиксела, както с известен брой страници, така и с премахнато полето за брой, и после подава на decoder-а всеки съкратен префикс на файла, дублиран номер на сегмент, един излишен байт накрая и неизвестна дължина на данните, като всеки път изисква LoadFromByteArray да върне False и да остави Width и Height на нула. Случаят с истинско изображение е refinement image 500 на 473 с custom таблица, чиито сегменти са пренаредени в random-access подредба с всеки оригинален header и всеки компресиран байт запазени; неговият SHA-256 съвпада точно с прегледания sequential baseline. Този файл е производен, получен чрез организационна трансформация, а не естествен random-access документ, хванат в дивото, и такава естествена проба не е имало. Suites-ите минаха с 1 598 теста за Delphi Win32, 42 за Delphi Win64 image suite, 48 за FPC Win32 и 46 за FPC Win64, редом с трите съществуващи sequential пикселни случая

Зареждане на random-access .jb2 файл и границите му

Кодът на приложението не се мени: TPLJBIG2Decoder.LoadFromByteArray сам разпознава file header-а и организацията, връща False на всеки отхвърлен вход с причината в LastError, и излага декодираната страница чрез Width, Height и GetScanline, който връща по един байт на пиксел

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
    // Sequential и random-access самостоятелни файлове минават през същото извикване
    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;

Границите си струва да се кажат направо. Поддръжката на random access е функция за файлова организация, а не random page API: TPLJBIG2Decoder и нататък връща bitmap-а на първата страница, а няма извикване, което да вземе страница 7 от файл с 40 страници или да декодира страниците лениво. Сегментите с неизвестна дължина на данните се отхвърлят в random-access файлове, а съществуващите ограничения върху custom Huffman prefix дължини и брой записи в таблици са непроменени. Тези ограничения са достатъчно тесни, че Delphi приложение може да пренасочи отхвърлените случаи другаде по LastError, а останалата част от image pipeline-а, от извличане на PDF изображения до JBIG2 кодиране, е покрита на продуктовата страница на PDFlibPas Delphi PDF library