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

Indexed-палитры PDF в Delphi: базовые пространства, циклы

/Indexed таблица поиска хранит компоненты своего базового цветового пространства, а не RGB-тройки. База DeviceGray упаковывает один байт на запись, DeviceCMYK — четыре, Lab — три, требующие масштабирования диапазона, а ICCBased — сколько объявит /N профиля. HotPDF нормализует все их на этапе парсинга в публичную RGB-палитру, которую выставляет THPDFColorSpace

Это предложение — весь класс багов. Почти каждый рендерер палитр начинает жизнь с предположения о трёхбайтовом шаге, потому что подавляющее большинство реальных /Indexed пространств сидит на /DeviceRGB, и предположение работает годами. Потом приходит отсканированный счёт с [/Indexed /DeviceCMYK 15 <...>], читатель идёт по строке поиска по три байта, и каждый индекс после нулевого попадает в середину чужого цвета. Вывод не пустой и не падает. Это правдоподобно выглядящий мусор, что куда хуже, потому что никто не заводит баг против цветов, которые просто выглядят не так

Почему Indexed-палитра выходит неправильной?

Шаг неправильный, потому что шаг — не константа. ISO 32000-1 §8.6.6.3 определяет таблицу поиска как m × (hival + 1) байт, где m — число компонентов в базовом пространстве, и ничего не говорит о том, что база — RGB. Тот же раздел напрямую запрещает только две базы: сам /Indexed и /Pattern. Всё остальное — игра, включая все четыре семейства на CIE из §8.6.5.2 по §8.6.5.5

Диаграмма Indexed-таблицы поиска PDF в HotPDF, где шаг записи берётся из базового цветового пространства: 8-байтовая поисковая таблица DeviceCMYK, разрезанная неверным 3-байтовым RGB-обходом, даёт мусорные записи, корректная группировка по 4 байта даёт две записи CMYK, нормализованные в RGB-тройки, а таблица перечисляет компоненты на запись для DeviceGray, DeviceRGB, DeviceCMYK, Lab и ICCBased
HotPDF сначала разрешает базовое цветовое пространство и спрашивает у него число компонентов, так что CMYK-поиск идёт по четыре байта на запись перед нормализацией в RGB

Так что настоящая работа — не чтение байт, а сначала разрешение базы и вопрос, какой ширины запись. ResolveIndexedPalette в HotPDF делает ровно это: разрешает базу в THPDFColorSpace, берёт ComponentCount из разрешённой записи и сразу отклоняет массив, если база вернулась как csfIndexed, csfPattern или csfUnsupported. Только потом он считает байтовый бюджет как PaletteCount * Count, что для легального максимума hival 255 и четырёхкомпонентной базы упирается в 1024 байта. Две детали в этом расчёте — места, где вас будет прощупывать враждебный файл. HotPDF зажимает hival в 0..255 перед использованием, по лимиту §8.6.6.3, так что таблица поиска, заявляющая hival 100000, не превращается в гигабайтное выделение; и он усекает бюджет до того, что данные поиска реально содержат, а затем прекращает заполнять палитру на первой записи, чьи байты вылезают за конец. Короткая таблица даёт короткую палитру, а не чтение за буфером

Нормализация четырёх разных базовых пространств в одну RGB-таблицу

HotPDF конвертирует палитры в RGB во время парсинга, а не в момент отрисовки, и это решение ведёт остальной дизайн. Публичное поле THPDFColorSpace.Palette — это TBytes из RGB-троек длиной 3 * PaletteCount, и оно такой формы с тех пор, как CIE-базы вообще не поддерживались. Переписывать раскладку записи, чтобы нести нативные компоненты базы, сломало бы каждого потребителя поля. Конвертация на парсинге сохраняет раскладку нетронутой и, как бонус, прогоняет записи Gray, CMYK, Lab, CalGray, CalRGB и ICCBased через тот же путь HPDFResolveColor, который рендерер уже использует для прямых цветовых операторов

Шаг масштабирования — то место, где базовые пространства перестают быть взаимозаменяемыми. Байт поиска всегда 0..255, но что этот байт значит, зависит от диапазона компонентов, который объявляет база. Устройственные и калиброванные компоненты отображаются на 0..1. Lab — нет: компонент L* идёт 0..100, а a* и b* идут по /Range, который объявляет пространство. HotPDF тянет эти границы из полей AMin, AMax, BMin и BMax записи THPDFCIEParams, которые Lab-парсер заполняет из записи словаря /Range и по умолчанию ставит −108..101, когда записи нет, так что не хардкодьте симметричный диапазон по памяти. ICCBased-базы добавляют третий источник истины: число компонентов приходит из /N потока профиля, выставляется как ICCN, и поток ICCBased может сам нести массив /Range, который HotPDF читает через HPDFReadNumericArrayUpTo в ту же таблицу границ на компонент. Трёхкомпонентная ICC-палитра и трёхкомпонентная Lab-палитра поэтому идут по строке поиска одинаковыми шагами и совершенно разной арифметикой — ровно тот результат, который фиксированный RGB-шаг дать не может

var
  CS: THPDFColorSpace;
  Entry: Integer;
  R, G, B: Byte;
begin
  // ColorSpaceObj — запись /ColorSpace из image XObject или словаря
  // ресурсов; это может быть имя, массив или косвенная ссылка
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if CS.Family <> csfIndexed then
    Exit;

  // Palette уже в RGB, чем бы ни была база
  for Entry := 0 to CS.PaletteCount - 1 do
  begin
    if (Entry * 3 + 2) >= Length(CS.Palette) then
      Break;                       // короткая таблица поиска, чисто останавливаемся
    R := CS.Palette[Entry * 3];
    G := CS.Palette[Entry * 3 + 1];
    B := CS.Palette[Entry * 3 + 2];
    UseColor(Entry, R, G, B);
  end;
end;

Когда таблица поиска живёт в потоке

ISO 32000-1 §8.6.6.3 позволяет таблице поиска быть строкой или потоком, и кусается потоковая форма. Поток может нести /FlateDecode, может быть косвенным объектом, разделяемым с несвязанными частями файла, и его TStream может быть позиционирован где угодно в момент, когда ваш парсер до него добирается. HotPDF обрабатывает сжатый случай делегированием колбэку декодированных байт, зарегистрированному через HPDFSetColorStreamReader, — тому же колбэку, которым пользуется движок Type 0 сэмплированной функции

Когда ридер не зарегистрирован, фолбэк намеренно узкий, а не best-effort. HotPDF проверяет словарь потока на /Filter и отклоняет палитру целиком, если тот есть, вместо того чтобы скормить сжатые байты в поиск и получить уверенно неверную таблицу. Без фильтра он читает сырые байты, ограниченные байтовым бюджетом, посчитанным из hival и числа компонентов базы. Вся потоковая ветвь сидит внутри try..finally, который восстанавливает исходный Stream.Position на каждом пути выхода, включая отказы, потому что объект разделяемый и следующий потребитель не скажет спасибо за сдвинутый курсор. LookupStreamUsesDecodedReaderAndRestoresPosition в регрессионном наборе закрепляет обе половины этого поведения

Диаграмма решений для Indexed-таблицы поиска HotPDF, хранящейся как PDF-поток: зарегистрированный колбэк HPDFSetColorStreamReader декодирует фильтрованные байты, поток без ридера с записью /Filter отклоняется сразу, а поток без фильтра читается сырым до байтового бюджета, пока try..finally восстанавливает Stream.Position на каждом выходе
Отказ от фильтрованного потока лучше, чем скармливать сжатые байты в поиск, а восстановление исходной позиции потока держит разделяемый объект в безопасности для следующего потребителя
// Зарегистрируйте оба колбэка до разрешения любого цветового пространства,
// иначе косвенные базы и сжатые потоки поиска будут просто отклонены
HPDFSetColorSpaceResolver(MyResolveIndirectObject);
HPDFSetColorStreamReader(MyDecodedStreamReader);
try
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if (CS.Family = csfIndexed) and (Length(CS.Palette) = 0) then
    LogWarning('lookup table unreadable: filtered stream or no reader');
finally
  HPDFSetColorStreamReader(nil);
  HPDFSetColorSpaceResolver(nil);
end;

Что останавливает Indexed-цветовое пространство от бесконечной рекурсии?

Отклонение /Indexed как прямой базы необходимо и совершенно недостаточно. Интересная атака идёт сбоку. Возьмите валидный [/Indexed /Separation ...] и вспомните, что §8.6.6.4 позволяет Separation называть альтернативное пространство, а §8.6.6.5 позволяет DeviceN делать то же. Сделайте эту альтернативу косвенной ссылкой обратно на исходный Indexed-массив. Ничто в цепочке не нарушает правило прямой базы ни на одном отдельном хопе: база — Separation, альтернатива Separation — массив, и массиву просто случается быть тем, с которого вы начали. Разрешайте наивно — и парсер ходит по циклу, пока стек не сдастся. Машинерия tint-transform, на которую опираются эти пространства, — отдельная тема, покрытая в статье про рендеринг плашечных цветов Separation и DeviceN; здесь они важны только как звено цикла

HotPDF закрывает это в единственной точке входа, через которую уже проходят все пути. HPDFResolveColorSpace оборачивает внутренний резолвер счётчиком глубины, ограниченным HPDFColorSpaceMaxDepth, который равен 64. Пересеките границу — и гард ставит флаг, а важен именно флаг: при разматывании любой уровень вложенности, видевший его установленным, отбрасывает свою частично разрешённую запись и возвращает csfUnsupported, так что отклоняется весь граф, а не половина, и оба счётчика сбрасываются, когда возвращается самый внешний вызов. Эти счётчики объявлены как threadvar, а не юнит-глобалы, и это не случайная опрятность: GResolveObj и GReadStream живут в thread-local storage по той же причине, потому что страницы рендерятся параллельно. Общий глобал позволил бы одному потоку, глубоко застрявшему во вложенной DeviceN-цепочке, сработать на лимите для другого потока, парсящего совершенно обычный [/Indexed /DeviceRGB 255 <...>], давая недетерминированный, зависящий от нагрузки сбой — наименее отлаживаемый из всех. Шестьдесят четыре — комфортный запас для легального контента, который редко вложен глубже четырёх-пяти уровней, так что относитесь к csfUnsupported от структурно валидно выглядящего массива как к поводу залогировать: на практике это цикл или глубина, которую не порождает ни один реальный производитель. IndirectAlternateCycleIsUnsupported, параметризованный по Separation и DeviceN, и ExcessivePatternDepthIsUnsupported покрывают оба случая

Диаграмма цикла и гард для цветовых пространств HotPDF: Indexed-массив с базой Separation, чья альтернатива — косвенная ссылка обратно на тот же массив, разрешаемый HPDFResolveColorSpace со счётчиком глубины, ограниченным HPDFColorSpaceMaxDepth на 64, после чего каждый уровень разматывания отбрасывает свою запись и возвращает csfUnsupported
Ничто на отдельном хопе не нарушает правило прямой базы, поэтому HotPDF ограничивает глубину разрешения 64 уровнями и отклоняет весь граф, а не удерживает его половину
function TryResolveImageSpace(Obj: THPDFObject;
  out CS: THPDFColorSpace): Boolean;
begin
  CS := HPDFResolveColorSpace(Obj);
  // csfUnsupported здесь покрывает действительно неизвестное семейство,
  // цикл через косвенную альтернативу и глубину свыше 64 уровней
  Result := CS.Family <> csfUnsupported;
  if not Result then
    CS := HPDFDefaultColorSpace;   // DeviceGray, исходное пространство PDF
end;

Одна граница API, которую стоит задокументировать для ваших вызывающих

HotPDF применяет действующую конфигурацию ICC-конвертации в момент парсинга, когда строит Indexed-палитру, и более поздние вызовы HPDFSetColorSpaceRenderingIntent не перегенерируют уже разрешённую палитру. Эта функция обходит ICCBased и альтернативные трансформы записи, которую вы ей даёте, и перестраивает эти пайплайны; сплющенные RGB-байты в Palette — артефакт парсинга и остаются как были. Это следствие ранней нормализации, а не недосмотр, и это стоит вписать в вашу собственную документацию API, а не оставлять клиенту для обнаружения. Если ваше приложение позволяет пользователю переключать rendering intent посреди сессии, разрешайте цветовые пространства заново после переключения; если intent ставится один раз при настройке пайплайна — а это подавляюще частый случай, — делать ничего не надо. Тот же порядок применим к пруфинговым профилям, установленным через HPDFSetICCColorPipeline: настраивайте их до начала разрешения, а не после

Зеркальный баг на стороне записи

Всё выше — про чтение. Симметричный дефект на стороне записи проще выпустить и куда легче пропустить. Когда HotPDF строит /Indexed массив из 8-битного битмапа Windows, он вызывает GetPaletteEntries, и этот API заполняет ровно столько записей, сколько реально держит палитра, и возвращает это число. Исходный код проверял результат на = 0 и затем итерировал фиксированные 256 записей по стековому массиву. Для 16-цветного битмапа это писало 240 записей неинициализированной стековой памяти прямо в строку поиска PDF, который вы потом отдаёте клиенту, с захардкоженным hival 255, так что каждый читатель послушно трактовал остаток как настоящие цветовые данные. Это баг корректности и баг раскрытия информации в одной одежде. Фикс неприглядный и полный: обнулите массив перед вызовом, затем уважайте возвращённое число и для границы цикла, и для hival. Если вы где-то в своём коде генерируете /Indexed изображения, идите и проверьте сейчас свой hival

Две соседние вещи дорисовывают картину: Indexed-палитры и Type 0 сэмплированные функции делят ридер декодированных потоков и ту же дисциплину зажима, о чём — в статье про цветовые таблицы поиска функции Type 0, а правила фильтров, управляющие потоками поиска, — те же, что управляют данными изображений в извлечении загруженных изображений через decode-фильтры. Резолвер цветовых пространств, описанный здесь, поставляется как часть HotPDF Delphi Component для Delphi и C++Builder, с полной справкой по THPDFColorSpace на странице продукта