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