Технічна стаття

Сітки halftone JBIG2 у PDFlibPas: HSKIP і від'ємні зміщення

PDFlibPas полагодив дві незалежні вади у своєму нативному декодері halftone-регіонів JBIG2: у v3.539.37 маска пропуску HSKIP індексується як HSKIP[ng, mg], як її визначає ITU-T T.88 §6.6.5.1, а в v3.539.38 сітки, що сягають від'ємних координат — через від'ємний HGX чи HGY або через ротацію, — розміщуються зі справжнім floor-зсувом. До тих релізів постраждалі halftone-регіони виходили перемазаними чи зсунутими, без жодної помилки. Обидва баги ховалися за тестовими даними, що випадково були симетричними чи невід'ємними, а другий вмикає властивість Delphi й Free Pascal, яка кусає далеко за межами JBIG2: shr над знаковим цілим — це логічний зсув, а не арифметичний >>, який припускає стандарт

Halftone-регіони — найрідкісніший тип регіонів JBIG2, тож декодер може обробити тисячі відсканованих документів, перш ніж зустріне растрову фотографію, закодовану таким. Коли зустріне — відмова огидна: файл розбирається, довжини сегментів сумуються, сторінка має правильний розмір, а регіон — сміття

Що насправді декодує halftone-регіон JBIG2?

Halftone-регіон JBIG2 — це сітка маленьких бітмапів, узятих зі словника патернів, і справжня робота декодера — обчислити індекс для кожної клітинки сітки та піксельну позицію, куда та клітинка падає. Словник патернів тримає HNUMPATS патернів розміром HPW × HPH пікселів. Сегмент halftone-регіону описує сітку з HGW колонок на HGH рядків і напівтонове зображення того ж розміру, закодоване як бітплощини з кодуванням Gray. Кожна бітплощина декодується процедурою generic region над бітмапом HGW × HGH, старшою площиною першою, а разом площини дають кожній клітинці її індекс патерну

Розміщення клітинок користується фіксовано-крапковою арифметикою з 8-бітовим дробом. Початок сітки HGX, HGY — пара 32-бітових значень, а вектор сітки HRX, HRY описує крок між сусідніми клітинками, що дозволяє повернену сітку. Для рядка сітки mg і колонки сітки ng T.88 §6.6.5 обчислює піксельну позицію так:

  • x = (HGX + mg × HRY + ng × HRX) >> 8
  • y = (HGY + mg × HRX − ng × HRY) >> 8

Маска пропуску входить через необов'язковий прапорець HENABLESKIP. Коли прапорець установлено, §6.6.5.1 будує бітмап HSKIP розміром HGW × HGH і ставить HSKIP[ng, mg] у 1 для кожної клітинки, чий патерн лежить повністю поза регіоном: x + HPW <= 0, x >= HBW, y + HPH <= 0 чи y >= HBH. Бітплощини напівтонового зображення тоді декодуються з тією маскою як skip-бітмап generic region, тож арифметичний декодер ні читає, ні оновлює контекст для пропущеної клітинки. Декодер і енкодер мусять збігатися в кожному біті HSKIP, інакше два арифметичні кодери розсинхронізуються

Чому транспонована маска HSKIP ламала лише неквадратні сітки?

Маска пропуску записувалася з переплутаними координатами, і лише неквадратна сітка це викривала, бо квадратна сітка тримає кожну переплутану координату всередині маски. PDFlibPas зберігає бітмапи з піксельним доступником (column, row), а код, що будував маску, передавав (mg, ng) — спершу рядок. Декодер бітплощин напівтонового зображення читає маску правильно як (ng, mg). Цикл розміщення патернів читав її назад у переплутаному порядку будівника, тож обоє сходилися, і рев'ю одного лише циклу розміщення пропустило б це. Пастка іменування погіршила справу: у циклі розміщення змінна на ім'я col ітерує рядки сітки, а Row — колонки сітки

Візьміть сітку 5 × 3 із патернів 4 × 4 на регіоні 16 × 8, яку v3.539.37 тримає як свій регресійний випадок. З HRX = 1024 і HRY = 0 колонка сітки 4 падає на x = 16, а рядок сітки 2 — на y = 8, обидва поза регіоном. Коректна маска позначає сім клітинок: усю колонку 4 і весь рядок 2. Переплутані записи намагалися поставити пікселі в рядкові індекси 3 і 4 маски заввишки лише три рядки, і сеттер бітмапа тихо проігнорував ті записи поза межами. Вижила колонка 2, рядки 0–2. Декодер отже пропустив дві клітинки, які енкодер закодував, і декодував шість клітинок, які енкодер пропустив

Маски пропуску halftone JBIG2 у PDFlibPas для сітки 5 на 3, де коректна HSKIP[ng, mg] позначає колонку 4 і рядок 2 як пропущені, тоді як транспоновані записи в рядки 3 і 4 трирядкової маски тихо загубилися, і вижила лише колонка 2, розсинхронізувавши арифметичні кодери
Лише неквадратна сітка викриває транспоновану маску, і її наслідок — розсинхрон кодерів — перемазує регіон замість підняття помилки

Арифметичний декодер не падає, коли таке стається. Він декодує зайві пікселі з бітів, що належать пізнішим клітинкам, його контексти читають неправильних сусідів, і кожен індекс патерну після першої розбіжності — шум, саме тому симптомом була перемазана область, а не кілька зсунутих клітинок. На квадратній сітці той самий баг часто невидимий: жодна переплутана координата не виходить із маски, а коли клітинки поза регіоном симетричні щодо діагоналі — скажімо, сітка нависає над правим і нижнім краями на однакову кількість клітинок, — транспонована маска біт за бітом збігається з коректною. HENABLESKIP ще й необов'язковий, мусить бути 0, коли напівтонове зображення кодоване MMR, і рідко встановлюється енкодерами, тож у бага було дуже мало шляхів виринанути. З v3.539.37 будівник пише HSKIP[ng, mg], а цикл розміщення читає той самий порядок

Чому від'ємні зміщення halftone-сітки ламаються в трьох шарах?

Halftone-сітка, що стартує лівіше чи вище свого регіону, ламала PDFlibPas у трьох окремих місцях, і кожна вада ховала наступну. T.88 дозволяє таку геометрію навмисно. Енкодер, який вирівнює свій растровий екран по сторінці, а не по регіону, чи користується поверненою сіткою, природно породжує від'ємні кути клітинок, які регіон обрізає. v3.539.38 полагодив усі три шари разом, бо полагодження якогось одного лише змінювало симптом

Шар 1: знакове поле, прочитане як беззнакове

T.88 §7.4.5.1.2 визначає HGX і HGY як знакові 32-бітові значення, але декодер читав їх тим самим 32-бітовим хелпером, що й беззнакові поля, а той хелпер притискував кожен від'ємний результат до 0. Сітку, що мала стартувати з HGX = -900, тихо пересунули на початок регіону. У регресійному випадку v3.539.38 вся картина виходила на два рядки завнизько. Притиск пояснює ще й те, чому інші дві вади дожили так довго: з початком, примусово невід'ємним, від'ємна координата могла з'явитися лише через повернену сітку з HRY > 0, де y = HGY + mg × HRX − ng × HRY падає нижче нуля для пізніх колонок сітки

Шар 2: shr — це не >> 8

T.88 пише >> 8 і має на увазі арифметичний зсув, який округлює до мінус нескінченності. Декодер переклав це як shr 8. У Delphi та Free Pascal shr над знаковим цілим — логічний зсув: знаковий біт засувається як нуль. Для Integer, що тримає -512, shr 8 дає 16777214 замість -2. Патерн, який мав бути намальованим на y = -2 і обрізаним до нижньої половини, летів на 16 мільйонів рядків униз і скидався як поза регіоном. Нічого не впало; верхній рядок halftone просто зник

Шар 3: порівняння фіксованої крапки замість пікселів

Тест пропуску порівнював значення фіксованої крапки, а не піксельні позиції, а ці дві речі не еквівалентні, щойно дріб ненульовий. Початковий код обійшов логічний зсув, тестуючи xx + HPW × 256 <= 0 на незсунутому значенні — гаданий еквівалент тесту T.88. З HGX = -900 і 4-піксельним патерном це дає -900 + 1024 = 124, що додатнє, тож клітинка не пропускається. Стандарт спершу зсуває: floor(-900 / 256) = -4, а -4 + 4 = 0 задовольняє x + HPW <= 0, тож клітинка лежить повністю поза межами і мусить бути пропущеною. Енкодер пропустив її, декодер декодував, і напівтонове зображення дрейфувало точно як у випадку з транспонованою маскою

Вади halftone JBIG2 у PDFlibPas для сітки з від'ємним HGX: знакове поле, прочитане беззнаковим хелпером і притиснуте до нуля; зсув праворуч T.88, перекладений як логічний shr, що відправив патерн на 16 мільйонів рядків униз; і тест пропуску на значеннях фіксованої крапки, що зберіг клітинку, яку енкодер пропустив
Кожна вада ховала наступну, саме тому v3.539.38 полагодив усі три шари разом в одному спільному хелпері HalftoneGridPixel, яким користуються будівник маски та цикл розміщення

Регресійний випадок з v3.539.38 користується сіткою 4 × 3 з патернів 4 × 4 при HGX = -900, HGY = -512, HRX = 1024 на регіоні 12 × 10. Колонки сітки падають на x = -4, 0, 4 і 8, тож колонка 0 повністю поза межами і належить до HSKIP; рядки сітки падають на y = -2, 2 і 6, тож рядок 0 мусить бути обрізаним до нижніх двох піксельних рядків, а не скинутим. Полагодження шарів по одному відтворює стос:

Полагоджені вадиДекодований регіон
Жодної (до v3.539.38)Сітка притягнута до початку координат, вся картина на два рядки завнизько
Лише знакове читання HGX / HGYПершого рядка сітки немає, решта перемазана дрейфом skip-тесту
Знакове читання, floor-зсув і skip-тест у піксельному просторіІдентичний, піксель за пікселем, сторінці, обчисленій з T.88 §6.6.5, і двом незалежним референсним декодерам

Виправлення — один хелпер, HalftoneGridPixel, спільний для будівника маски пропуску та циклу розміщення. Він акумулює координату в Int64, тож великий добуток mg × HRX не може переповнитися, ділить на 256 з округленням до мінус нескінченності і притискає до ±MaxInt div 2, тож зіпсована сітка не може переповнити пізнішу бітмапову арифметику. Тест пропуску тепер порівнює ті піксельні значення з HPW, HPH, HBW і HBH, точно як це формулює §6.6.5.1

Як написати арифметичний зсув праворуч у Delphi?

У Delphi немає оператора арифметичного зсуву, тож коректний знаковий зсув праворуч доводиться писати як ділення з округленням донизу, а звичайний div — не таке ділення. div обрізає до нуля. Для невід'ємних значень обрізання і floor сходяться, і сходяться вони також для від'ємних значень, точно кратних дільнику, — саме тому -512 div 256 = -2 виглядає добре в швидкому тесті. Розходяться вони всюди інакше: -900 div 256 — це -3, тоді як floor — це -4, а -1 div 256 — це 0, тоді як floor — це -1. Координата JBIG2 з ненульовим дробом — це точно той випадок, де div дає неправильний піксель

На компіляторах Delphi Win32 і Win64 змінна Integer із -512, зсунута праворуч на 8, дає 16777214, а Int64 із -512 дає 72057594037927934. Free Pascal також визначає shr як логічний зсув і постачає SarLongint і SarInt64 у своєму юніті System для арифметичної версії, але тих функцій у Delphi немає, тож код, спільний для обох компіляторів, потребує власного хелпера:

// Ділення з округленням донизу: дає floor за будь-якого знаку A і B.
// B не має бути 0, а FloorDiv(Low(Integer), -1) переповнюється так само, як div
function FloorDiv(A, B: Integer): Integer;
begin
  Result := A div B;
  if (A mod B <> 0) and ((A < 0) <> (B < 0)) then
    Dec(Result);
end;

// Арифметичний зсув праворуч (це C- і T.88-івський ">>" над знаковими значеннями).
// Для від'ємного Value not Value = -Value - 1 невід'ємне, тож
// логічний shr там безпечний, а зовнішній not мапить результат назад
function SarInt32(Value: Integer; Shift: Integer): Integer;  // Shift 0..31
begin
  if Value >= 0 then
    Result := Value shr Shift
  else
    Result := not ((not Value) shr Shift);
end;

function SarInt64(Value: Int64; Shift: Integer): Int64;      // Shift 0..63
begin
  if Value >= 0 then
    Result := Value shr Shift
  else
    Result := not ((not Value) shr Shift);
end;

Трюк із not ніколи не зсуває від'ємне число, тож не залежить від того, як компілятор поводиться зі знаковим бітом, і ніколи не переповнюється, зокрема для Low(Integer). Обидва хелпери збігалися з Int64-референсом floor на кількох мільйонах значень, на кожному зсуві від 0 до 31 і на краях Low(Integer) та High(Integer) на Delphi Win32, Delphi Win64 і Free Pascal x86_64. Санітарна перевірка, варта місця в будь-якому юніт-тесті, що торкається координат:

var
  V: Integer;
begin
  V := -900;
  Writeln(V shr 8);           // 16777212  логічний зсув, старий баг
  Writeln(V div 256);         // -3        обрізання до нуля
  Writeln(FloorDiv(V, 256));  // -4        те, що T.88 має на увазі під >> 8
  Writeln(SarInt32(V, 8));    // -4
end;
Числова пряма PDFlibPas для координати -900, зсунутої праворуч на 8: shr дає 16777212, div обрізає до -3, тоді як FloorDiv і SarInt32 обидва приземляються на floor-значення -4, яке ITU-T T.88 має на увазі під зсувом, а важить це лише коли дріб фіксованої крапки ненульовий
Обрізання і floor сходяться лише на точних кратних, тож -512 div 256 проходить швидкий тест, а -900 div 256 обирає неправильний піксель

Math.Floor(V / 256) теж повертає -4, але його обхід через Double втрачає точність для Int64 значень понад 253, тож цілочисельна геометрія має лишатися в цілих

Які виклики PDFlibPas запускають halftone-декодер?

Halftone-декодер JBIG2 запускається, коли PDFlibPas рендерить сторінку вбудованим рендерером, бо рендерингу потрібні пікселі. RenderPageToFile і RenderPageToStream обидва доходять до нього через JBIG2Decode-потоки зображень сторінки, тож повторний рендеринг halftone-сторінки — прямий спосіб пересвідчитися, що v3.539.38 змінює ваш вихід. Той самий декодер обслуговує інші типи регіонів JBIG2, описані в власних таблицях Huffman у JBIG2 чистого Pascal-декодера та декодуванні JBIG2-файлів з довільним доступом у Delphi, а відрендерений бітмап живить конверсії, як-от рендеринг PDF-сторінок у 1-бітовий monochrome

uses
  SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  Page: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('scanned-halftone.pdf', '') <> 1 then
      raise Exception.CreateFmt('Load failed, error %d', [Lib.LastErrorCode]);
    for Page := 1 to Lib.PageCount do
      // Рендеринг декодує кожен регіон JBIG2, включно з halftone
      if Lib.RenderPageToFile(150, Page, PDF_RENDER_PNG,
        Format('page-%.3d.png', [Page])) <> 1 then
        Writeln('Page ', Page, ' was not rendered');
  finally
    Lib.Free;
  end;
end.

Екстракція зображень зазвичай іде іншим шляхом. GetPageImageList повертає JBIG2-зображення в нативній формі, а SaveImageListItemDataToFile чи GetImageListItemDataToString віддає вам самостійний JBIG2-файл, зібраний із байтів потоку: заголовок файлу, дані JBIG2Globals і сегмент end-of-file навколо даних сторінки. Властивість 400 у GetImageListItemIntProperty звітує 6 для такого елемента. На тому шляху нічого не декодується, тож витягнутий .jb2, що виглядає правильним в іншому переглядачі, тоді як відрендерена сторінка показує шум, був типовою ознакою цих двох halftone-багів:

var
  ListID, I: Integer;
begin
  Lib.SelectPage(1);
  ListID := Lib.GetPageImageList(0);
  if ListID = 0 then
    Exit;
  try
    for I := 1 to Lib.GetImageListCount(ListID) do
      if Lib.GetImageListItemIntProperty(ListID, I, 400) = 6 then  // standalone JBIG2
        Lib.SaveImageListItemDataToFile(ListID, I, 0,
          Format('page1-image%d.jb2', [I]));
  finally
    Lib.ReleaseImageList(ListID);
  end;
end;

Коли маски чи конверсія кольору змушують відрендерений фолбек, елемент повертається як декодований бітмап, і halftone-декодер справді запускається. Більше про списки зображень — в екстракції тексту, зображень і шрифтів із PDF у Delphi

Шпаргалка: правила halftone-сітки JBIG2

  • Індексуйте маску пропуску як HSKIP[ng, mg], спершу колонка сітки, і читайте в тому самому порядку всюди, де розміщуються клітинки (T.88 §6.6.5.1, виправлено в PDFlibPas v3.539.37)
  • Тестуйте будь-який halftone- чи сітковий код неквадратною сіткою та асиметричним набором клітинок поза регіоном, бо квадратна сітка може повністю сховати транспонований індекс
  • Читайте HGX і HGY як знакові 32-бітові значення (T.88 §7.4.5.1.2), ніколи не через беззнаковий хелпер, що притискує від'ємні
  • Перекладайте >> 8 зі стандарту як ділення floor на 256, не як shr 8 і не як div 256
  • Ганяйте тест пропуску на зсунутих піксельних позиціях; форма фіксованої крапки розходиться щоразу, коли дріб ненульовий, як показує HGX = -900 із 4-піксельним патерном
  • Акумулюйте координати сітки в Int64 і притискайте перед передаванням у бітмаповий код, тож зіпсована сітка не може переповнити
  • Оновіться до v3.539.38 чи новішої, якщо ваші документи містять halftone-регіони з HENABLESKIP, від'ємними початками сітки чи поверненими сітками

PDFlibPas рендерить, витягує і редагує PDF-документи з Delphi та C++Builder із нативним Pascal-декодером JBIG2, який тепер поводиться з масками пропуску halftone, від'ємними початками сітки і поверненими сітками так, як визначає T.88. Можливості, видання і пробне завантаження — на PDFlibPas Delphi PDF library