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

JBIG2-файлы с произвольным доступом: декодируем их в Delphi

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 терпит, а не отвергает

Организации JBIG2-файлов в PDFlibPas: байт флагов, который читает setFileHeaderFlags, выбирает последовательную D.1 с перемежаемыми заголовками, произвольный доступ D.2, где все заголовки стоят перед блоком данных, или встроенную D.3 — беззаголовочную форму, которую использует поток JBIG2Decode со словарями в JBIG2Globals
Все три раскладки несут одни и те же сегменты, но только произвольный доступ заставляет ридер увидеть все зависимости страниц и словарей до прикосновения к сжатому байту — ради этого его и просили архивные конвейеры
// 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 в 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;

Каждая проверка в этом цикле существует потому, что у файла с произвольным доступом избыточности меньше, чем у последовательного. Номера сегментов должны строго возрастать при сравнении как беззнаковые, ведь два заголовка с одним номером делают неоднозначным, какое тело имеет в виду список ссылаемых сегментов более позднего региона. Поле длины данных читает 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 работают без изменений, и сообщение об ошибке по-прежнему сообщает исходное байтовое смещение заголовка, а не позицию тела

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