Технічна стаття

JBIG2 random-access файли: декодування в Delphi

PDFlibPas версії 3.539.23 декодує самостійні JBIG2-файли з random-access-організацією з ITU-T T.88 Annex D.2, де всі заголовки сегментів ідуть першими, а дані сегментів — у тому самому порядку слідом. Нативний Pascal-декодер у PDFlibJBIG2.pas індексує зсуви заголовків аж до обов'язкового end-of-file-заголовка, перевіряє, що номери сегментів зростають, а оголошені довжини даних у сумі дають рівно байти, що лишилися, і тоді декодує кожне тіло в порядку заголовків, не копіюючи і не переставляючи стиснуті дані. До цього релізу той самий файл видавав пласку помилку "random-access organisation is not supported" щойно читалися прапорці заголовка

Random-access JBIG2-файли рідкісні — саме тому вони і б'ють по болю, коли все ж трапляються. Вони виходять з архівних конвеєрів і систем document imaging, які хочуть, щоб читач побачив кожен заголовок сегмента, а отже, кожну сторінку і кожну залежність від словника, ще до того, як торкнутися хоч одного стиснутого байта. Delphi-застосунок, що пакетно конвертує відскановані архіви в PDF, зазвичай зустрічає такий посеред роботи, після сотень послідовних файлів, що пройшли чисто, а декодер, який стає стовпом на коректному файлі, лише трохи кращий за той, що рендерить сміття. Та сама лінія релізів щойно навчила декодер кастомних таблиць JBIG2 Huffman і канонічних префіксних кодів, тож random access лишався останньою прогалиною серед організацій у задокументованих обмеженнях декодера

Що таке random-access-організація JBIG2?

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

Організації JBIG2-файлів у PDFlibPas: байт прапорців, який читає setFileHeaderFlags, вибирає послідовну D.1 з чергуванням заголовків, random-access D.2 із усіма заголовками перед блоком даних або вбудовану D.3 — беззаголовкову форму, яку використовує потік JBIG2Decode зі словниками в JBIG2Globals
Усі три розкладки несуть ті самі сегменти, але лише random access дає читачеві побачити кожну сторінку і кожну залежність від словника ще до першого стиснутого байта — саме цього просили архівні конвеєри
// 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-потік: без файлового заголовка, вбудована організація, одна сторінка
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

Сам PDF ніколи не несе цю розкладку. Потік зображення JBIG2Decode, описаний в ISO 32000-1 §7.4.7, тримає лише сегменти сторінки у вбудованій організації, спільні symbol dictionaries переїхали в окремий потік JBIG2Globals, а файлового заголовка і сегментів end-of-page чи end-of-file немає. Коли decodeJBIG2 не знаходить восьмибайтовий ідентифікатор, він припускає рівно це і форсує послідовне односторінкове декодування. Нативний експорт JBIG2-зображень іде у зворотний бік і загортає PDF-сегменти в самостійний файл, чиїй байт прапорців — $03, послідовний із невідомою кількістю сторінок, за яким додається end-of-file-заголовок. Тож random-access-робота торкається лише одного шляху: самостійні файли, передані напряму в TPLJBIG2Decoder, зазвичай ще до конвертації чи перекодування для PDF — цю роботу на вихідному боці роблять бекенди JBIG2-енкодера в PDFlibPas

Чому random-access-файл неможливо прочитати у файловому порядку?

Random-access-файл неможливо читати у файловому порядку, бо ніщо в байтовому потоці не позначає, де блок заголовків закінчується і починається блок даних, — крім самого заголовка сегмента end-of-file. Заголовки сегментів JBIG2 мають змінну довжину: кількість referred-to-сегментів буває трибітовою короткою формою або довгою формою з retention-бітмапою, номери referred-to-сегментів займають один, два чи чотири байти залежно від власного номера сегмента, а поле page association — один чи чотири байти. Наївний послідовний читач розбирає перший заголовок, читає довжину його даних, а потім приймає перші байти другого заголовка за дані того сегмента. Декодер не може зрозуміти, що зібився, аж до значно пізніше — саме тому старий код відмовляв цю організацію одразу, замість того щоб пробувати

Як PDFlibPas індексує random-access-заголовки сегментів?

PDFlibPas індексує random-access-заголовки одним передсканом IndexRandomHeaders, який розбирає кожен заголовок, записує лише його байтовий зсув і зупиняється на першому end-of-file-заголовку (тип сегмента 51). Кожен заголовок розбирається повністю і викидається, тож індекс — це масив цілих чисел, а не список об'єктів, а передскан у міру ходу накопичує оголошені довжини даних. Коли скан завершується, читач стоїть на першому байті даних першого сегмента, і ця позиція стає NextBodyOffset

Передскан IndexRandomHeaders у PDFlibPas: кожен заголовок сегмента розбирається і викидається, а зберігається лише його байтовий зсув, номери сегментів мусять строго зростати, невідома довжина 0xFFFFFFFF відкидається, скан зупиняється на end-of-file-заголовку типу 51, а оголошені довжини мусять дорівнювати байтам, що лишилися, точно
Суворість навмисна: у розкладці, де заголовки — єдина мапа даних, один зайвий байт означає, що всі наступні тіла можуть бути зсунуті, тож декодер, який це толерує, не відрізнить паддінг від розсинхрону
// 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-файл має менше надлишковості, ніж послідовний. Номери сегментів мусять строго зростати — порівнюються як беззнакові, — бо два заголовки з одним номером роблять неоднозначним, яке тіло має на увазі referred-to-список пізнішого регіону. Поле довжини даних читає handleSegmentDataLength, який відображає будь-яке значення зі встановленим старшим бітом, зокрема маркер "невідомої довжини" 0xFFFFFFFF, у -1; у random-access-розкладці іншого способу знайти, де починається наступне тіло, немає, тож PDFlibPas відкидає таку довжину одразу, замість шукати кінцевий маркер. Сума мусить точно збігатися з байтами, що лишилися, в обидва боки, і один зайвий байт після останнього тіла падає з "trailing random-access data". Ця суворість навмисна: у такій розкладці розбіжність довжини означає, що всі тіла після точки помилки зсунуті, а декодер, який відмахується від одного зайвого байта, не може знати, чи то нешкідливий паддінг, чи перший симптом розсинхрону даних

Чому губився останній сегмент end-of-page?

Останній сегмент end-of-page губився, бо перша версія циклу декодування залишила послідовну умову завершення while not reader.isFinished, а в random-access-розкладці потік даних скінчується раніше, ніж індекс заголовків. Сегменти end-of-page (тип 49) і end-of-file несуть нуль байтів даних, і зазвичай вони — останні заголовки у файлі. Після того як останнє тіло регіону спожито, читач стоїть рівно в кінці буфера, тож цикл виходить, і ті нульові сегменти ніколи не диспетчеризуються, лишаючи сторінку недобудованою. Виправлення змушує random-access-цикл рахувати заголовки замість байтів. Кожна ітерація стрибає з читачем до наступного проіндексованого заголовка, скидає bitPointer у 7, бо попереднє тіло могло закінчитися посеред байта, перерозбирає той заголовок, потім переводить bytePointer на NextBodyOffset і просуває його повз тіло. Наявні обробники сегментів, перевірки referred-to-сегментів і діагностика Context працюють без змін, а повідомлення про помилку досі звітує початковий байтовий зсув заголовка, а не позицію тіла

Random-access-цикл декодування в PDFlibPas: кожна ітерація стрибає до HeaderOffsets поточного заголовка, скидає bitPointer у 7, щоб прибрати хвости посеред байта, перескакує на NextBodyOffset за тілом і рахує заголовки замість байтів, тож нульові сегменти end-of-page диспетчеризуються до завершення циклу
Бо сегменти end-of-page і end-of-file несуть нуль байтів даних, потік даних скінчується раніше за індекс заголовків, і лише цикл, що рахує заголовки, може дати тим фінальним сегментам їхню чергу
// 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;         // використовується в контексті помилки
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // стрибок до даних цього сегмента
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... диспетчеризація наявному обробнику сегмента, потім перехід на DataEnd
end;

Що насправді доводить random-access-валідація?

Валідація доводить, що переорганізовані байти декодуються в ті самі пікселі, що й їхні послідовні оригінали, і що некоректний random-access-вхід падає чисто; вона не доводить покриття random-access-файлів від довільних енкодерів. Спільний Pascal-регрес використовує синтетичний файл на 235 байтів, збудований на фікстурі з кастомною таблицею, який мусить декодуватися в рядок чорних пікселів 7 на 1, — і з відомою кількістю сторінок, і з прибраним полем лічильника, — а потім згодовує декодеру кожен обрізаний префікс того файлу, дублікат номера сегмента, один зайвий байт і невідому довжину даних, щоразу перевіряючи, що LoadFromByteArray повертає False і лишає Width і Height нулями. Реальний кейс із зображенням — це refinement-зображення з кастомною таблицею 500 на 473, чиї сегменти було переорганізовано в random-access-розкладку зі збереженням кожного оригінального заголовка і кожного стиснутого байта; його SHA-256 точно збігається з переглянутим послідовним baseline. Той файл — похідне, зроблене перетворенням організації, а не природний random-access-документ, знайдений у реальних даних, і такого природного зразка не знайшлося. Набори пройшли: 1 598 тестів на Delphi Win32, 42 на наборі зображень Delphi Win64, 48 на FPC Win32 і 46 на FPC Win64 — плюс три наявні послідовні піксельні кейси

Завантаження random-access .jb2-файлу та його обмеження

Код застосунку не змінюється: TPLJBIG2Decoder.LoadFromByteArray сам виявляє файловий заголовок і організацію, повертає 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
    // Послідовні та 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 — це фіча організації файлу, а не API довільних сторінок: TPLJBIG2Decoder досі повертає бітмапу першої сторінки, і немає виклику, щоб вибрати сторінку 7 із файлу на 40 сторінок чи декодувати сторінки ледачо. Сегменти з невідомою довжиною даних відкидаються в random-access-файлах, а наявні обмеження на довжини префіксів кастомних таблиць Huffman і на кількість записів таблиць не змінилися. Ці обмеження вузькі настільки, що Delphi-застосунок може маршрутизувати відкинуті кейси в інше місце за LastError, а решта зображеннєвого конвеєра — від добування PDF-зображень до кодування JBIG2 — описана на сторінці продукту PDFlibPas Delphi PDF library