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 толеруються, а не відкидаються
// 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, локально в 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 працюють без змін, а повідомлення про помилку досі звітує початковий байтовий зсув заголовка, а не позицію тіла
// 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