PDFlibPas версии 3.539.23 декодирует автономные JBIG2-файлы, использующие организацию с произвольным доступом из ITU-T T.88 Annex D.2, где сначала идут все заголовки сегментов, а данные сегментов следуют в том же порядке. Нативный Pascal-декодер в PDFlibJBIG2.pas индексирует смещения заголовков вплоть до обязательного end-of-file-заголовка, проверяет, что номера сегментов возрастают, а заявленные длины данных в сумме дают ровно оставшиеся байты, и затем декодирует каждое тело в порядке заголовков, не копируя и не переставляя сжатые данные. До этого релиза тот же файл выдавал плоскую ошибку «random-access organisation is not supported» сразу при чтении флагов заголовка
JBIG2-файлы с произвольным доступом редки, и именно поэтому они так бьют, когда всё же попадаются. Они выходят из архивных конвейеров и систем документной визуализации, которые хотят, чтобы ридер увидел каждый заголовок сегмента, а значит, все зависимости страниц и словарей, не притронувшись ни к одному сжатому байту. Delphi-приложение, пакетно конвертирующее скан-архивы в PDF, обычно встречает такой файл посреди задания, после того как сотни последовательных файлов прошли чисто, и декодер, намертво останавливающийся на корректном файле, лишь немногим лучше того, что рендерит мусор. Та же линейка релизов как раз научила декодер пользовательским таблицам Huffman и каноническим префиксным кодам JBIG2, так что произвольный доступ оставался последним пробелом по организациям в задокументированных пределах возможностей декодера
Что такое организация JBIG2 с произвольным доступом?
Организация с произвольным доступом — один из трёх способов разложить те же сегменты, разрешённых Annex D стандарта T.88: последовательная (D.1) перемежает каждый заголовок его данными, произвольный доступ (D.2) кладёт все заголовки вперёд, а все данные — после, и встроенная (D.3) — беззаголовочная форма, используемая внутри других контейнеров вроде PDF. Автономный файл .jb2 начинается с восьмибайтового идентификатора 97 4A 42 32 0D 0A 1A 0A, за которым идут один байт флагов и, когда счётчик страниц известен, четырёхбайтовый счётчик страниц. Бит 0 байта флагов выбирает организацию: 1 — последовательная, 0 — произвольный доступ; установленный бит 1 означает, что число страниц неизвестно и четырёхбайтового счётчика нет. PDFlibPas читает это в checkHeader и setFileHeaderFlags, а зарезервированные биты со 2 по 7 терпит, а не отвергает
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1; // 0 = произвольный доступ (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, держит только сегменты страницы во встроенной организации, общие словари символов вынесены в отдельный поток JBIG2Globals, а заголовков файла, end-of-page и end-of-file сегментов нет вовсе. Когда decodeJBIG2 не находит восьмибайтовый идентификатор, она предполагает ровно это и принуждает последовательное одностраничное декодирование. Нативный экспорт изображений JBIG2 идёт в обратную сторону и заворачивает PDF-сегменты в автономный файл, чей байт флагов равен $03 — последовательная организация с неизвестным числом страниц, — за которым добавлен end-of-file-заголовок. Так что работа над произвольным доступом трогает ровно один путь: автономные файлы, отданные TPLJBIG2Decoder напрямую, обычно до их конвертации или пережатия под PDF — эту работу на выходе выполняют бэкенды JBIG2-кодировщика в PDFlibPas
Почему файл с произвольным доступом нельзя читать в порядке файла?
Файл с произвольным доступом нельзя читать в порядке файла, потому что ничто в потоке байтов не помечает, где кончается блок заголовков и начинается блок данных, — кроме самого end-of-file-заголовка сегмента. Заголовки сегментов JBIG2 имеют переменную длину: счётчик ссылаемых сегментов бывает трёхбитной короткой формой или длинной формой с битовой картой удержания, номера ссылаемых сегментов занимают один, два или четыре байта в зависимости от собственного номера сегмента, а поле ассоциации со страницей — один или четыре байта. Наивный последовательный ридер разберёт первый заголовок, прочитает длину его данных и затем примет первые байты второго заголовка за данные того сегмента. Декодер не сможет понять, что сбился, ещё очень нескоро — вот почему старый код отвергал эту организацию целиком, вместо того чтобы пытаться
Как PDFlibPas индексирует заголовки сегментов при произвольном доступе?
PDFlibPas индексирует заголовки произвольного доступа одним пре-сканом 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;
Каждая проверка в этом цикле существует потому, что у файла с произвольным доступом избыточности меньше, чем у последовательного. Номера сегментов должны строго возрастать при сравнении как беззнаковые, ведь два заголовка с одним номером делают неоднозначным, какое тело имеет в виду список ссылаемых сегментов более позднего региона. Поле длины данных читает handleSegmentDataLength, отображающий любое значение со взведённым старшим битом, включая маркер «неизвестная длина» 0xFFFFFFFF, в -1; в раскладке произвольного доступа другого способа найти начало следующего тела нет, поэтому PDFlibPas отвергает такую длину немедленно вместо поиска маркера конца. Сумма обязана совпасть с оставшимися байтами точно в обе стороны, и единственный лишний байт после последнего тела валит файл с «trailing random-access data». Эта строгость намеренная: здесь несовпадение длины значит, что каждое тело после точки ошибки сдвинуто, и у декодера, отмахнувшегося от одного лишнего байта, нет способа узнать, безобидная ли это набивка или первый симптом рассинхронизированных данных
Почему последний end-of-page сегмент пропадал?
Последний end-of-page сегмент пропадал, потому что первая версия цикла декодирования сохранила последовательный тест завершения while not reader.isFinished, а в раскладке произвольного доступа поток данных кончается раньше индекса заголовков. Сегменты end-of-page (тип 49) и end-of-file не несут ни байта данных, и обычно они — последние заголовки в файле. После поглощения тела последнего региона ридер стоит ровно на конце буфера, цикл выходит, и эти сегменты нулевой длины никогда не диспетчеризуются, оставляя страницу незавершённой. Исправление делает цикл произвольного доступа считающим заголовки вместо байтов. Каждая итерация прыгает ридером к следующему индексированному заголовку, сбрасывает bitPointer в 7, потому что предыдущее тело могло кончиться посреди байта, заново разбирает тот заголовок, затем переносит bytePointer в NextBodyOffset и продвигает его за тело. Существующие обработчики сегментов, проверки ссылаемых сегментов и диагностика 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;
// ... диспетчеризация существующему обработчику сегмента, затем seek на DataEnd
end;
Что на самом деле доказывает валидация произвольного доступа?
Валидация доказывает, что переставленные байты декодируются в те же пиксели, что и их последовательные оригиналы, и что кривой вход произвольного доступа отказывает чисто; она не доказывает покрытие файлов произвольного доступа от произвольных кодировщиков. Общий Pascal-регресс использует синтетический файл на 235 байт, построенный на фикстуре с пользовательской таблицей, который обязан декодироваться в ряд из 7 на 1 чёрных пикселей — и с известным счётчиком страниц, и с удалённым полем счётчика, — а затем скармливает декодеру каждый обрезанный префикс этого файла, дублированный номер сегмента, один хвостовой байт и неизвестную длину данных, всякий раз утверждая, что LoadFromByteArray возвращает False, а Width и Height остаются нулями. Случай с реальным изображением — это refinement-изображение 500 на 473 с пользовательской таблицей, чьи сегменты переставлены в раскладку произвольного доступа с сохранением каждого исходного заголовка и сжатого байта; его SHA-256 совпадает с просмотренным последовательным бейслайном точно. Тот файл — производная, полученная трансформацией организации, а не естественный документ с произвольным доступом, найденный в дикой природе, и такого естественного образца не нашлось. Наборы прошли: 1 598 тестов на Delphi Win32, 42 в наборе изображений Delphi Win64, 48 на FPC Win32 и 46 на FPC Win64, плюс три существующих последовательных пиксельных случая
Загрузка .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
// Автономные файлы — и последовательные, и с произвольным доступом — идут через тот же вызов
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;
Границы стоит назвать прямо. Поддержка произвольного доступа — это фича организации файла, а не API произвольных страниц: TPLJBIG2Decoder по-прежнему возвращает битмап первой страницы, и вызова, чтобы вытащить страницу 7 из файла на 40 страниц или декодировать страницы лениво, нет. Сегменты с неизвестной длиной данных в файлах произвольного доступа отвергаются, а существующие пределы на длины префиксов пользовательских таблиц Huffman и счётчики записей таблиц не изменились. Пределы достаточно узки, чтобы Delphi-приложение могло маршрутизировать отвергнутые случаи в другое место по LastError, а остальной конвейер изображений, от извлечения изображений PDF до кодирования JBIG2, описан на странице продукта PDFlibPas Delphi PDF library