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

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

Вот ошибка, которую рукописный читатель JBIG2 делает на первом необычном файле. Он разбирает заголовок файла, разбирает заголовок первого сегмента, читает четырехбайтовую длину данных и принимает столько байтов за тело сегмента. На большинстве .jb2-файлов это ровно правильно. На файле, у которого бит 0 байта флагов сброшен, те «телесные» байты — на самом деле второй, третий и четвертый заголовки сегментов, и арифметический декодер превращает их в битмап из шума или убегает за конец буфера. Файл не сломан. Он использует организацию произвольного доступа из ITU-T T.88 Annex D.2, где сначала идут все заголовки сегментов, а потом все данные сегментов, в том же порядке

Файлы произвольного доступа редки. Они выходят из архивных и документно-имиджевых систем, которые хотят, чтобы читатель увидел каждую страницу и каждую словарную зависимость, прежде чем тронуть сжатый байт. Если задание конвертации мешает файлы из такого источника с обычными сканами, один файл произвольного доступа может сидеть посреди батча, иначе полностью последовательного. Декодер, читающий их, — тот же нативный Pascal-декодер, что обрабатывает пользовательские таблицы Хаффмана JBIG2 и канонические префиксные коды, так что кодирующая сторона не изменилась; отличается только порядок, в котором находятся сегменты

Три раскладки файла за одним байтом флагов

PDF Library for Delphi читает автономные JBIG2-файлы через TPLJBIG2Decoder в модуле PDFlibJBIG2, и декодер сам вычисляет раскладку из заголовка файла. T.88 Annex D позволяет раскладывать одни и те же сегменты тремя способами: последовательная (D.1) кладет каждый заголовок прямо перед его данными, произвольный доступ (D.2) кладет все заголовки впереди и все данные после них, а встроенная (D.3) — беззаголовочная форма, используемая внутри других контейнеров. Автономный файл начинается с восьмибайтового идентификатора 97 4A 42 32 0D 0A 1A 0A, затем один байт флагов, затем четырехбайтовое число страниц, если флаги не говорят, что счетчик неизвестен

Бит 0 того байта флагов выбирает организацию: 1 значит последовательная, 0 значит произвольный доступ. Бит 1 выставлен — число страниц неизвестно и четырехбайтовый счетчик отсутствует, так что первый заголовок сегмента стартует с байта 9 вместо байта 13. Биты 2–7 зарезервированы; декодер принимает файлы, которые их выставляют, вместо отвержения, потому что блуждающий зарезервированный бит ничего не говорит о том, читаемы ли сами сегменты

Организации JBIG2-файлов в PDFlibPas: байт флагов, который читает setFileHeaderFlags, выбирает последовательную D.1 с перемежающимися заголовками, произвольный доступ D.2 с каждым заголовком перед блоком данных или встроенную D.3 — беззаголовочную форму, которую использует поток JBIG2Decode со словарями в JBIG2Globals
Три раскладки несут одни и те же сегменты, но только произвольный доступ дает читателю увидеть каждую страницу и словарную зависимость до первого сжатого байта — именно этого просили архивные конвейеры

Сам PDF никогда не несет раскладку произвольного доступа. Поток изображения JBIG2Decode, описанный в ISO 32000-1 §7.4.7, держит только сегменты страниц во встроенной форме, с общими словарями символов, вынесенными в отдельный поток JBIG2Globals, и без заголовка файла, end-of-page или end-of-file сегментов. Когда декодер не находит восьмибайтовый идентификатор, он трактует вход как поток ровно такого сорта: последовательный, одна страница. В другую сторону, когда PDF Library for Delphi экспортирует изображение JBIG2Decode как нативный .jb2-файл, она пишет байт флагов $03 (последовательная, число страниц неизвестно) и дописывает заголовок end-of-file сегмента. Так что произвольный доступ приходит только снаружи — как автономный файл, отданный TPLJBIG2Decoder напрямую, обычно по пути конвертации или пережатия для PDF с помощью бэкендов JBIG2-энкодера в PDFlibPas

Почему нельзя читать файл произвольного доступа в порядке файла?

Потому что ничто в байтовом потоке не отмечает, где кончается блок заголовков и начинается блок данных, кроме самого заголовка end-of-file сегмента (тип 51). Заголовки сегментов JBIG2 не имеют фиксированного размера. Число ссылаемых сегментов — либо трехбитная короткая форма, либо длинная форма с битмапом удержания вслед за ней; каждый ссылаемый номер сегмента занимает один, два или четыре байта в зависимости от того, насколько велик собственный номер текущего сегмента; поле ассоциации страницы — один байт или четыре. Читатель обязан разобрать каждое поле заголовка, просто чтобы узнать, где начинается следующий

Последовательный читатель, идущий по файлу по порядку, берет первый заголовок правильно и все после него — неправильно. Хуже: он может не заметить какое-то время, потому что декодер generic region, накормленный байтами заголовков, производит пиксели, а не ошибку, пока какая-нибудь поздняя проверка длины или ссылок наконец не сработает. Наш взгляд: декодер, который не индексирует заголовки сначала, должен отказывать файлам произвольного доступа outright. Ясное отвержение стоит одного файла в батче; правдоподобный, но неверный битмап может попасть в архив и остаться там

Индексация каждого заголовка до первого тела

Когда байт флагов говорит «произвольный доступ», PDF Library for Delphi делает один проход по блоку заголовков, прежде чем декодировать что-либо. Каждый заголовок разбирается полностью и затем выбрасывается; единственное, что сохраняется, — его байтовое смещение в файле. Сжатые данные никогда не копируются во второй перемешанный буфер, что имеет значение на больших архивных сканах, где блок данных может быть большей частью файла. Проход останавливается на первом заголовке end-of-file, и в этот момент позиция чтения сидит на первом байте данных первого сегмента

У файла произвольного доступа меньше избыточности, чем у последовательного, поэтому предсканирование строгое. Оно отвергает файл, когда:

  • заголовок обрезан концом буфера — репортится как JBIG2 truncated random-access header at byte N со смещением, где тот заголовок начался
  • номера сегментов не строго возрастают (сравниваются как беззнаковые), потому что два заголовка, заявляющие один номер, делают список ссылаемых позднего региона двусмысленным
  • длина данных несет выставленный старший бит, включая маркер «неизвестной длины» 0xFFFFFFFF; в этой раскладке нет другого способа найти, где начинается следующее тело, так что декодер отказывает ей сразу вместо сканирования вперед в поисках концевого маркера
  • заголовок end-of-file заявляет хоть какие-то данные или вообще не появляется (JBIG2 random-access file is missing its end-of-file header)
  • заявленные длины в сумме дают больше оставшихся байтов или меньше
Предсканирование IndexRandomHeaders в PDFlibPas: каждый заголовок сегмента разбирается и отбрасывается, хранится только его байтовое смещение, номера сегментов обязаны строго возрастать, неизвестная длина 0xFFFFFFFF отвергается, скан останавливается на заголовке end-of-file типа 51, а заявленные длины обязаны равняться оставшимся байтам в точности
Строгость намеренная: в раскладке, где заголовки — единственная карта данных, один блуждающий байт значит, что каждое позднее тело может быть сдвинуто, и декодер, терпящий это, не отличит паддинг от смещения

Последнее правило — то, о которое все спотыкаются. Один лишний байт после финального тела отказывает с JBIG2 trailing random-access data. Рядом с последовательным путем это выглядит жестко, где декодирование просто останавливается на сегменте end-of-file, а все после него игнорируется. Асимметрия имеет причину. В последовательной раскладке каждый заголовок сидит рядом со своими данными, так что мусор в конце не мог ничего сдвинуть. В раскладке произвольного доступа заголовки — единственная карта блока данных, и если длины не сходятся, декодер не отличит безобидный паддинг от байта, потерянного или добавленного намного раньше, сдвинувшего все тела после него

Почему цикл со счетчиком байтов теряет последний end-of-page сегмент?

Цикл «пока есть байты» кончается слишком рано на файле произвольного доступа, потому что данные кончаются раньше индекса заголовков. Сегменты end-of-page (тип 49) и end-of-file несут ноль байтов данных и обычно приходят последними. Когда финальное тело региона потреблено, позиция чтения — ровно в конце буфера; цикл со счетчиком байтов выходит там, и нулевой end-of-page сегмент никогда не диспетчеризуется, что оставляет страницу незавершенной

Цикл декодирования произвольного доступа в PDFlibPas: каждая итерация прыгает на HeaderOffsets текущего заголовка, сбрасывает bitPointer в 7, отменяя межбайтовые хвосты, прыгает на NextBodyOffset для тела и считает заголовки вместо байтов, так что нулевые end-of-page сегменты диспетчеризуются до конца цикла
Поскольку сегменты end-of-page и end-of-file несут ноль байтов данных, поток данных кончается раньше индекса заголовков, и только цикл, считающий заголовки, дает финальным сегментам их ход

Так что для файлов произвольного доступа декодер считает заголовки, а не байты. Каждый шаг прыгает назад к следующему индексированному заголовку, выравнивается на границу байта (арифметически закодированное тело может кончиться посреди байта), разбирает заголовок снова, затем прыгает вперед к месту, где начинается следующее тело, и продвигается за него на заявленную длину. Тело, чей декодер закончил раньше, пропускается до заявленного конца; читающее дальше — отвергается. Оттуда сегмент идет через те же обработчики и проверки ссылок, что в последовательном файле. Ошибки, поднятые при декодировании тела, несут исходную позицию заголовка в форме JBIG2 segment N (type T) at byte B: ..., так что смещение в LastError указывает на то, что можно найти в hex-редакторе

Загрузка файла .jb2 произвольного доступа в Delphi

Код приложения не меняется вместе с организацией. TPLJBIG2Decoder.LoadFromByteArray распознает заголовок файла и раскладку, возвращает False на любом отвергнутом входе с причиной в LastError и выставляет декодированную страницу через Width, Height и GetScanline, который заполняет один байт на пиксель с 1 для черного

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;

Если хотите знать, какую раскладку использует файл — для журнала или для сортировки корпуса перед прогоном конвертации, — прочитайте байт флагов сами. Библиотека не выставляет распознанную организацию наружу, а девяти байтов достаточно. Хелпер ниже — то, что стоит добавить в собственный код; он просто пересказывает правила заголовка T.88:

type
  TJb2Layout = (jlNoFileHeader, jlSequential, jlRandomAccess);

// Ваш собственный помощник, не часть PDFlibJBIG2: классифицирует буфер по его файловому заголовку T.88
function DetectJb2Layout(const Data: TJBIG2ByteArray;
  out PageCountKnown: Boolean): TJb2Layout;
const
  Signature: array[0..7] of Byte = ($97, $4A, $42, $32, $0D, $0A, $1A, $0A);
var
  I: Integer;
begin
  Result := jlNoFileHeader;          // например, встроенный поток, вынутый из PDF
  PageCountKnown := False;
  if Length(Data) < 9 then
    Exit;
  for I := 0 to 7 do
    if Data[I] <> Signature[I] then
      Exit;
  PageCountKnown := (Data[8] and 2) = 0;   // бит 1 выставлен: четырехбайтового числа страниц нет
  if (Data[8] and 1) = 0 then
    Result := jlRandomAccess
  else
    Result := jlSequential;
end;

Батч-задания: паддированные буферы и отвергнутые файлы

Правило хвостовых данных — то место, где батч-код обычно идет не так, и когда файл, открывающийся в другом месте, отвергается этим способом, смотрите на буфер раньше, чем на файл. .jb2, вынутый из BLOB-колонки базы данных, из фиксированного по размеру сетевого чтения или из формата-контейнера с выравнивающим паддингом, может прийти с несколькими нулевыми байтами после реального конца. Последовательный файл на это плюет. Файл произвольного доступа с тем же паддингом отказывает с JBIG2 trailing random-access data, как и буфер, обрезанный на несколько байтов, который отказывает как усеченный. Передавайте ровно байты файла, размером из реальной длины источника, никогда из размера чанка или емкости

Отвергнутый файл также не оставляет ничего позади. LoadFromByteArray сбрасывает Width, Height и сканлайны до начала декодирования, так что провалившийся вызов репортит нулевые размеры вместо страницы предыдущего файла, и тот же экземпляр декодера может сразу идти к следующему файлу:

procedure DecodeBatch(const Files, Log, Quarantine: TStrings);
var
  Decoder: TPLJBIG2Decoder;
  I: Integer;
begin
  Decoder := TPLJBIG2Decoder.Create;
  try
    for I := 0 to Files.Count - 1 do
      if Decoder.LoadFromByteArray(ReadJb2(Files[I])) then
        Log.Add(Format('%s: %d x %d', [Files[I], Decoder.Width, Decoder.Height]))
      else
      begin
        // Width и Height здесь 0; от предыдущего файла не выживает ничего
        Log.Add(Files[I] + ': ' + Decoder.LastError);
        if Pos('trailing random-access data', Decoder.LastError) > 0 then
          Quarantine.Add(Files[I]);   // проверьте, как байты хранились, прежде чем винить файл
      end;
  finally
    Decoder.Free;
  end;
end;

Проверить это в собственном конвейере можно без редких образцов. Переорганизуйте последовательный .jb2 в раскладку произвольного доступа, сохранив каждый заголовок и каждый сжатый байт, передвиньте заголовки end-of-page и end-of-file в конец блока заголовков — и декодированные пиксели обязаны совпасть с последовательным оригиналом в точности, как с полем числа страниц, так и без него. Затем скормите ему каждый усеченный префикс, дублированный номер сегмента, один лишний хвостовой байт и длину 0xFFFFFFFF; каждый должен вернуть False с Width и Height на нуле

Что декодер возвращает и чего он ожидает

Произвольный доступ здесь — свойство раскладки файла. TPLJBIG2Decoder возвращает битмап первой страницы, какую бы организацию файл ни использовал, а Width и Height описывают ту страницу. Каждый сегмент обязан заявлять длину своих данных; T.88 позволяет immediate generic region использовать маркер 0xFFFFFFFF, и декодер отвергает тот маркер в обеих раскладках с unknown or oversized segment length is not supported. Длины префиксов пользовательского Хаффмана и счетчики записей таблиц ограничены так же, как в последовательных файлах

Декодирование JBIG2 — одна стадия более длинного пути изображений, покрывающего еще извлечение изображений PDF, пережатие и JBIG2-кодирование; страница продукта PDF Library for Delphi покрывает остальное того конвейера и поддерживаемые форматы изображений