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

Совместное использование словарей символов JBIG2 между страницами в Delphi

Пятидесятистраничный отсканированный договор повторяет один и тот же алфавит на каждой странице, но кодировщик JBIG2, строящий один словарь символов на изображение, заново обучает этот алфавит пятьдесят отдельных раз. HotPDF, нативный компонент PDF для Delphi и C++Builder, вместо этого может накапливать один общий словарь символов на протяжении всего документа и продвигать его в единый поток уровня документа /JBIG2Globals, так что собственный поток JBIG2 каждой страницы просто ссылается на идентификаторы символов вместо хранения собственной копии алфавита

Эта статья намеренно узка по охвату и рассматривает только то, как HotPDF строит это совместное использование между страницами внутри себя, — основы JBIG2, сравнение с CCITT и компромиссы между Lossless и LossyLevel уже описаны в сопутствующей статье о нативном бинарном сжатии JBIG2 в Delphi, которую эта статья предполагает уже прочитанной

Почему постраничное сжатие JBIG2 всё ещё повторяет одни и те же затраты?

Ответ в том, что ничто не переносит состояние между вызовами. Каждый раз, когда кодировщик HotPDF строит словарь символов для одного изображения, этот словарь ограничен рамками одного вызова AddImage: проход сопоставления форм начинается с нуля, каждый глиф на странице классифицируется как новый, а получившиеся растровые изображения арифметически кодируются и сохраняются заново. Скормите тому же кодировщику пятьдесят страниц, набранных одним и тем же шрифтом, и он с готовностью повторит весь этот проход обучения пятьдесят раз, потому что с его точки зрения каждая страница — не связанное с другими изображение, которое просто похоже на них. Постраничный UseSymbolDictionary уже значительно превосходит плоское кодирование обычной области на одной странице, но он упирается в потолок значительно раньше того предела, который оставляет на столе настоящий многостраничный скан

Как HotPDF делит один словарь символов между страницами?

Включите AccumulateGlobalsAcrossPages в THPDFJBIG2Options, и HotPDF будет держать один словарь символов живым в памяти на протяжении всего документа вместо того, чтобы отбрасывать его после каждого изображения. Глифы каждой последующей страницы проверяются на соответствие этому текущему словарю прежде, чем что-либо кодируется заново: форма, которая уже существует, переиспользуется по своему идентификатору символа, и только форма, которую никто раньше не видел, добавляется и кодируется в словарь. Сравнение переиспользует ту же логику допуска, что LossyLevel применяет на одной странице, — слегка зашумлённый скан той же буквы всё равно считается совпадением, — так что накопитель не раздувается незаметно до одной записи словаря на каждый пиксельный вариант одного и того же глифа. Извлечение происходит первым и питает это сравнение: HotPDF обходит растровое изображение каждой страницы и извлекает связанные формы через заливку по чёрным пикселям — та же идея, что и обводка чернильных пятен вручную, — и именно эти извлечённые формы, а не сырые пиксельные блоки, сравниваются с текущим словарём

Как общий словарь размещается внутри потока /JBIG2Globals

Накопленный словарь записывается как один сегмент словаря символов внутри потока /JBIG2Globals, хранимый под фиксированным номером сегмента, так что каждая страница может указывать на одну и ту же цель. Внутри организации встроенного JBIG2, которую определяет ISO 32000-1 §7.4.7, сегмент текстовой области может назвать другой сегмент своим источником символов через поле ссылки на сегмент в заголовке сегмента, и именно на этот механизм опирается HotPDF: поток globals несёт один большой словарь символов, а собственный поток JBIG2 каждой страницы сжимается до сегмента информации о странице плюс сегмент текстовой области, чей список ссылок указывает обратно на сегмент globals. То, что раньше было самодостаточным битовым потоком на страницу, становится коротким списком позиций и идентификаторов символов, и каждая страница, построенная таким образом, ссылается на идентичный косвенный объект /JBIG2Globals, а не на его копию. Собственное регрессионное покрытие HotPDF проверяет именно это: закодировать короткий документ, где на каждой странице своя раскладка глифов, перезагрузить его и подсчитать, сколько различных ссылок на объект /JBIG2Globals встречается в файле, — один документ, одна ссылка на объект, сколько бы страниц ни внесло символы в него

Включение накопления словаря символов между страницами

Переключатель находится в той же записи параметров, описанной в сопутствующей статье, и требует четыре настройки, согласованные друг с другом, прежде чем накопление действительно включится

var
  Pdf: THotPDF;
  Bmp: TBitmap;
  PageIdx, ImgIdx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.JBIG2Options.Lossless := True;
    Pdf.JBIG2Options.UseSymbolDictionary := True;
    Pdf.JBIG2Options.UseGlobalSegments := True;
    Pdf.JBIG2Options.AccumulateGlobalsAcrossPages := True;  // opt-in, default False
    Pdf.JBIG2Options.UseExternalEncoder := False;            // accumulation needs the native path
    Pdf.JBIG2Options.UseNativeArithmeticFallback := True;
    Pdf.BeginDoc;
    for PageIdx := 0 to ScannedPages.Count - 1 do
    begin
      if PageIdx > 0 then
        Pdf.AddPage;
      Bmp := ScannedPages[PageIdx];             // 1-bit TBitmap for this page
      ImgIdx := Pdf.AddImage(Bmp, icJBIG2);
      Pdf.CurrentPage.ShowImage(ImgIdx, 0, 0, Bmp.Width, Bmp.Height, 0);
    end;
    Pdf.EndDoc;                                  // the shared /JBIG2Globals stream is finalized here
  finally
    Pdf.Free;
  end;
end;

Это сочетание — не необязательное украшение. Стык внешнего кодировщика, описанный в статье о бинарном сжатии, — тот, что вы регистрируете через RegisterJBIG2EncoderBackend ради коэффициентов сжатия промышленного уровня, — построен вокруг покадрового кодирования по одному изображению, и собственные демонстрационные примеры и регрессионные тесты накопления HotPDF всегда сочетают AccumulateGlobalsAcrossPages с UseExternalEncoder := False. Воспринимайте это как жёсткое требование, а не как рекомендацию: совместное использование между страницами — функция нативного кодировщика, и зарегистрированный внешний бэкенд попросту не является частью пути, который строит общий словарь

Насколько на самом деле уменьшается многостраничный скан?

Честный ответ начинается с того, что сперва не сдвинуло стрелку. Более ранний релиз добавил кэш потоков /JBIG2Globals, адресуемый по содержимому, — поиск по ключу в виде 64-битного хеша FNV-1a от байтов потока, так что два изображения, чьи данные globals оказались побайтово идентичными, могли делить один объект PDF. При измерении на реальном выводе этот кэш почти не помогал, потому что существующее в HotPDF обнаружение дубликатов целых изображений уже схлопывало побайтово идентичные изображения ещё до того, как кэш вообще получал шанс сработать. Урок оказался в том, что дедупликация на уровне потоков окупается только тогда, когда два по-настоящему разных изображения страниц всё же могут делить один растущий словарь, — а именно это и даёт истинное накопление между страницами

Для этого более сложного случая собственная инженерная оценка HotPDF ставит дополнительную экономию примерно на 30–60 процентов меньше того, чего достигает одна лишь дедупликация на уровне потоков, для типичного многостраничного скана, набранного одним повторяющимся шрифтом, — диапазон меняется в зависимости от того, насколько велика доля визуального словаря документа, реально повторяющаяся, поскольку страница, полная уникальных диаграмм, не даёт словарю ничего переиспользовать. Воспринимайте это как целевой ориентир дизайна, а не как гарантию для какого-либо конкретного входного файла, и измеряйте собственные документы, а не доверяйте единственному числу. Демонстрационный пример JBIG2Benchmark, поставляемый вместе с HotPDF, существует именно для этой цели: он кодирует один и тот же многостраничный скан четырьмя разными способами и печатает итоговый размер файла для каждой конфигурации, так что сравнение выполняется на вашей собственной смеси сканов, а не на синтетической

procedure RunScenario(const Title: string; AccumulateGlobals: Boolean);
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.JBIG2Options.Lossless := True;
    Pdf.JBIG2Options.UseSymbolDictionary := True;
    Pdf.JBIG2Options.UseGlobalSegments := True;
    Pdf.JBIG2Options.AccumulateGlobalsAcrossPages := AccumulateGlobals;
    Pdf.JBIG2Options.UseExternalEncoder := not AccumulateGlobals;
    // ... encode the same three-page scan here, then compare file sizes.
  finally
    Pdf.Free;
  end;
end;

begin
  RunScenario('Per-image lossless baseline', False);
  RunScenario('Cross-page accumulated globals', True);
end.

Где накопление между страницами упирается в свои пределы

Накопленный словарь ограничен 4096 символами — тем же потолком, который уже применяет нативный покадровый кодировщик на одной странице. Пересеките этот предел в середине документа, и HotPDF не выбросит исключение и не прервёт выполнение: накопитель откажет новому глифу, и страница, которая его внесла, автоматически откатится к независимому покадровому кодированию, так что документ всё равно получится корректным — вы просто перестанете получать экономию между страницами для тех страниц, что вытолкнули за потолок. Второй предохранитель следит за общим размером, а не за числом символов: как только суммарная ширина символов накопленного словаря пересекает 131071 пиксель, HotPDF автоматически сбрасывает текущий пакет на диск и начинает новую группу globals, вместо того чтобы позволить одной структуре в памяти расти без ограничений. Ни один из этих пределов не требует какого-либо кода с вашей стороны, поскольку оба являются автоматическими откатами, а не исключениями, которые нужно перехватывать

Соответствие PDF/A — единственная настройка, которая полностью отключает весь механизм, а не просто ограничивает его. HotPDF незаметно подменяет JBIG2 на CCITT Group 4 в момент, когда PDFACompliance не пуст, на каждой странице, независимо от AccumulateGlobalsAcrossPages или чего-либо ещё в JBIG2Options, — это осознанный выбор в пользу соответствия спецификации, а не ошибка, но это означает, что архивный профиль и совместное использование символов между страницами сегодня взаимно исключают друг друга. Какую бы конфигурацию вы ни выбрали, декодируйте то, что записали, прежде чем доверять этому: загрузите файл обратно через LoadFromFile и извлеките каждую страницу через ExtractLoadedImage, который разрешает общие globals за вас точно так же, как это сделал бы любой соответствующий спецификации читатель, и сравните результат с исходными растровыми изображениями

var
  Loaded: THotPDF;
  PageBmp: TBitmap;
  PageIdx: Integer;
begin
  Loaded := THotPDF.Create(nil);
  try
    Loaded.LoadFromFile('scanned-contract.pdf');
    for PageIdx := 0 to Loaded.PagesCount - 1 do
    begin
      PageBmp := Loaded.ExtractLoadedImage(PageIdx);   // resolves the shared globals for you
      try
        // Compare PageBmp against the source bitmap for this page.
      finally
        PageBmp.Free;
      end;
    end;
  finally
    Loaded.Free;
  end;
end;

Совместное использование словаря между страницами затрагивает только бинарную (bilevel) сторону изображений документа. Если тот же конвейер также выпускает сгенерированные текстовые страницы наряду со сканами — титульные листы, страницы указателя, слой текста OCR, — потоки объектов и потоки перекрёстных ссылок атакуют другую половину бюджета размера файла, сжимая структуру документа, которую добавляют эти страницы. Общие globals JBIG2 между страницами поставляются как часть компонента HotPDF для Delphi и C++Builder, наряду с постраничными параметрами JBIG2 и остальным конвейером сжатия