HotPDF Component рисует repeat- и reflect-градиенты COLRv1 как векторные PDF shading вместо растеризации глифа. Геометрия растягивается, чтобы покрыть целое число циклов цветовой линии, каждый цикл получает собственный axial или radial shading с выключенным Extend, цвета интерполируются в linear-light CalRGB, а композитные режимы Porter-Duff in/out выражаются luminosity soft mask
Когда мы разбирали, как HotPDF рисует цветные шрифты COLR v1 и SVG emoji в Delphi, режимы расширения repeat и reflect числились среди того, что нативный путь не выпускал, и такие глифы уходили в raster fallback. Более новые версии HotPDF рисуют их векторами. Дойти до этого удалось через несколько ложных стартов, большинство из которых выросли из правдоподобного на вид предположения о PDF, оказавшегося неверным минимум в одном широко используемом вьювере. Этот пост проходит по тем предположениям, потому что любой, кто отображает градиенты CSS, SVG или OpenType на PDF, встретит те же
Почему вьювер PDF не может повторить градиент за вас?
У PDF shading нет режима repeat или reflect, поэтому продюсер обязан разложить каждый цикл сам. ISO 32000-1 §8.7.4.5.3 и §8.7.4.5.4 определяют /Extend для axial и radial shading, и всё, что он делает, — продолжает цвет на t0 или t1 за конец оси. Это COLRv1 EXTEND_PAD, и ничего больше. OpenType COLR v1 определяет ещё EXTEND_REPEAT, где цветовая линия начинается заново всякий раз, когда параметр проходит целое, и EXTEND_REFLECT, где каждый второй цикл идёт назад
Первая попытка HotPDF пыталась заставить функцию делать периодическую работу. Она регистрировала одну sampled-функцию Type 0 (§7.10.2) с широким доменом, примерно [-12.73, 12.73] на 25 циклов, заполняла её сложенными сэмплами и включала Extend. Внутри первого цикла минимальный тестовый файл рендерился правильно; снаружи MuPDF красил одним усреднённым цветом, и Poppler был не лучше. Оба вьювера вычисляют функцию вдоль параметра shading и дописывают цвет за осью, и ни один не возвращается запрашивать периодическую функцию в расширенной области. Широкий домен был ещё и неверным прочтением спецификации: параметр shading t передаётся функции напрямую, и по §7.10.1 вход вне домена функции обрезается, так что у функции, чей домен не совпадает с /Domain shading (по умолчанию [0 1]), читается лишь часть сэмплов
Работающий дизайн состоит из трёх частей, все сверены против рендеров MuPDF и Poppler. Геометрия shading физически покрывает целое число N циклов, так что ни один пиксель не зависит от Extend. Sampled-функция держит домен по умолчанию [0 1], совпадающий с тем, что вьювер реально передаёт. И сама последовательность сэмплов сложена по циклам, по 256 интервалов на цикл, так что функция несёт repeat- или reflect-данные цвета, не полагаясь ни на какую возможность вьювера
Складывание цветовой линии в сэмплы Type 0
Складывание означает выборку цветовой линии раз за цикл и зеркалирование последовательности на нечётных циклах для reflect-режима. Хелпер ниже — обычный Delphi, а не API HotPDF, и он делает две вещи, которые наивная версия делает неверно. Он интерполирует в linear light, а не по сохранённым sRGB-байтам, и разрешает жёсткие стопы (два стопа на одном смещении) в цвет дальней стороны — это семантика OpenType и CSS
uses
System.SysUtils, System.Math;
type
TColorStop = record
Offset: Single; // 0..1 внутри одного цикла, отсортировано по возрастанию
R, G, B: Byte; // sRGB-байты в стиле CPAL
end;
function SRGBToLinear(V: Byte): Double;
var
C: Double;
begin
C := V / 255;
if C <= 0.04045 then
Result := C / 12.92
else
Result := Power((C + 0.055) / 1.055, 2.4);
end;
function LinearToSRGB(L: Double): Byte;
var
C: Double;
begin
L := EnsureRange(L, 0, 1);
if L <= 0.0031308 then
C := L * 12.92
else
C := 1.055 * Power(L, 1 / 2.4) - 0.055;
Result := Round(C * 255);
end;
// Один цикл цветовой линии как 8-битные RGB-тройки. Reverse = True
// зеркалит цикл — то, что reflect-режим делает на нечётных циклах
function SampleCycle(const Stops: array of TColorStop; Count: Integer;
Reverse: Boolean): TBytes;
var
I, S: Integer;
T, F, Span: Double;
begin
SetLength(Result, Count * 3);
for I := 0 to Count - 1 do
begin
T := I / (Count - 1);
if Reverse then
T := 1 - T;
S := 0;
// >= берёт дальнюю сторону жёсткого стопа
while (S < High(Stops) - 1) and (T >= Stops[S + 1].Offset) do
Inc(S);
Span := Stops[S + 1].Offset - Stops[S].Offset;
if Span > 0 then
F := EnsureRange((T - Stops[S].Offset) / Span, 0, 1)
else
F := 1;
Result[I * 3] := LinearToSRGB(SRGBToLinear(Stops[S].R) +
(SRGBToLinear(Stops[S + 1].R) - SRGBToLinear(Stops[S].R)) * F);
Result[I * 3 + 1] := LinearToSRGB(SRGBToLinear(Stops[S].G) +
(SRGBToLinear(Stops[S + 1].G) - SRGBToLinear(Stops[S].G)) * F);
Result[I * 3 + 2] := LinearToSRGB(SRGBToLinear(Stops[S].B) +
(SRGBToLinear(Stops[S + 1].B) - SRGBToLinear(Stops[S].B)) * F);
end;
end;
COLRv1-рендерер внутри HotPDF идёт дальше этого наброска. Он пишет big-endian 16-битные линейные сэмплы в функцию CalRGB и хранит альфу отдельным 16-битным серым сэмплом, тогда как публичные хелперы, показанные далее, принимают 8-битные тройки DeviceRGB — потому набросок и конвертирует обратно в sRGB-байты. О теории того, что читатель делает с этими сэмплами, статья о sampled-функциях Type 0 как 3D-цветовых LUT подробно рассказывает модель интерполяции
Почему каждому циклу нужен собственный shading?
Каждому циклу нужен собственный shading, потому что читатели не вычисляют sampled-функцию точно; они предвычисляют таблицу цветов, а таблица интерполирует через заворот от последнего цвета одного цикла к первому цвету следующего. MuPDF хранит одномерную функцию shading как таблицу цветов на 256 записей. Упакуйте 11 циклов в одну функцию на 2816 сэмплов — и каждый сохранённый сэмпл может быть точным, пока MuPDF всё ещё показывает 12–14 уровней ошибки канала, потому что 256 записей таблицы размазаны по всему диапазону, а жёсткий repeat-шов сглаживается в рампу. Poppler и аналитическая интерполяция того же файла были оба корректны, так что это предел точности читателя, а не ошибка файла, и добавление сэмплов не помогает
Фикс в том, чтобы держать каждую таблицу читателя внутри одного цикла. HotPDF выпускает периодические линейные градиенты как один независимый axial shading на исходный цикл с обоими флагами Extend ложными, так что цветовой скачок конец-в-начало ложится на границу между двумя shading вместо внутренностей одной таблицы. Для reflect нечётность берётся по исходному знаковому номеру цикла, включая отрицательные циклы до старта цветовой линии, а направление shading меняется местами вместо пересчёта сэмплов. Все циклы рисуются в одну linear-light изолированную поверхность, и альфа краски применяется один раз в конце, так что полупрозрачный градиент не удваивает свою непрозрачность вдоль швов
Публичные методы THotPDF.RegisterSampledAxialGradient и THotPDF.RegisterSampledRadialGradient выставляют строительный блок sampled-shading для вашего собственного рисования. Оба после геометрии принимают (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer), требуют PDF 1.3, бросают исключение, если Samples не содержит ровно SampleCount * 3 байт, и возвращают имя паттерна для THPDFPage.SetFillPattern. Они выставляют Extend на обоих концах и записывают Cycles как пользовательский ключ паттерна /Cycles, который вьюверы игнорируют. Чтобы соблюсти правило один-цикл-на-shading с их помощью, регистрируйте по одному паттерну на цикл и режьте его прямоугольником полосы
// Repeat- или reflect-градиент по горизонтали из одного shading на цикл.
// Координаты паттерна в пространстве PDF по умолчанию; горизонтальная
// ось освобождает пример от соглашения о Y сверху вниз на странице
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
SamplesPerCycle = 257; // 256 интервалов на цикл
var
Cycle, CycleCount: Integer;
X0, X1: Single;
Samples: TBytes;
PatternName: AnsiString;
begin
CycleCount := Ceil(Width / CycleLength);
for Cycle := 0 to CycleCount - 1 do
begin
X0 := X + Cycle * CycleLength;
X1 := X0 + CycleLength;
Samples := SampleCycle(Stops, SamplesPerCycle, Reflect and Odd(Cycle));
// Домен [0, 1]: параметр shading доходит до функции без изменений
PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
Samples, SamplesPerCycle, 1);
if PatternName = '' then
Exit; // версия документа заперта ниже PDF 1.3
Pdf.CurrentPage.SetFillPattern(PatternName);
// Полоса режет shading ровно на один цикл, поэтому Extend никогда не виден
Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
Pdf.CurrentPage.Fill;
end;
end;
Очень длинные диапазоны требуют другого трюка, потому что один shading на цикл означает один ресурс на цикл. Для периодических линейных COLRv1-градиентов HotPDF переключается на схему удвоения: листовая форма держит примитив repeat на один цикл или reflect на два, и каждый уровень Form XObject рисует предыдущий уровень дважды, сдвинутым на свою ширину. Число форм растёт логарифмически с диапазоном, а вьювер всё равно красит каждый видимый цикл, поэтому покрытие после выравнивания фазы ограничено 65536 циклических единиц. Ранняя версия, клавшая примитив в tiling pattern Type 1, была выброшена, потому что читатели подгоняют шаг тайлов под сетку устройства, и Poppler уплывал из фазы на цикле, который не был целым числом пикселей
Решение диапазона параметра для семейств окружностей radial
Радиальный градиент покрывает глиф, только когда диапазон циклов вычислен для правильного семейства окружностей, а радиальные краски COLRv1 распадаются на три семейства с разной математикой. При D = c1 - c0 и dr = r1 - r0 строго вложенное семейство имеет |dr| > |D|, невложенный конус — |D|² > dr², а касательное семейство — |D| = |dr|. HotPDF меряет параметр от точки нулевого радиуса исходного семейства и держит центр и радиус каждой выпущенной окружности привязанными к тому же исходному параметру, так что перемапленные отрицательные радиусы никогда не обжимаются по отдельности
- Вложенные семейства пересекают fill box монотонно, поэтому диапазон параметра берётся от четырёх углов плюс бисекция первого круга, коснувшегося бокса. До 64 циклов выбираются в один shading по 256 сэмплов на цикл, с потолком 4096 сэмплов; более глубокие диапазоны переключаются на per-cycle срезы
- Конусы и полосы равных радиусов используют отдельные короткие radial shading на исходных границах циклов, каждый со 257 сэмплами и выключенным
Extend, композитные в исходном порядке параметров внутри изолированной non-knockout группы, чтобы наибольший валидный параметр побеждал там, где окружности перекрываются - Касательные семейства сводят уравнение окружности к линейному. Относительно общей точки касания корень равен
q = |W|² / (2·(W·V)), а покрытая область — открытая полуплоскость перед этой точкой
Касательный случай имеет настоящую сингулярность. Когда касательная линия пересекает fill box, число циклов, нужное, чтобы добраться до пикселей возле этой линии, неограниченно, поэтому никакое конечное число shading её не покроет. HotPDF тратит бюджет в 64 цикла от точки касания и оставляет оставшуюся тонкую полосу прозрачной. Когда все четыре угла бокса сидят за точкой касания, краска прозрачна целиком, и остальной глиф всё равно рисуется. Конечный конусный путь принимает до 4096 требуемых циклов, с покрытием, ограниченным 1024 срезами, и всем, что за потолком, оставленным некрашеным; глиф на 1024 среза весит примерно 1,8 МБ
Цвет в linear light и ловушка двух знаков после запятой
COLRv1 интерполирует и композитит в linear light, поэтому HotPDF декодирует каждый байт CPAL обратной передаточной функцией sRGB и красит сплошные заливки, shading и внутренние группы в одном пространстве CalRGB с белым D65, primaries sRGB и /Gamma [1 1 1] (§8.6.5.3). Только готовая корневая поверхность конвертируется в blend-пространство вызывающего. Интерполяция сохранённых sRGB-байтов вместо этого даёт заметно более тёмные середины — классическая гамма-ошибка, которую каждый градиентный движок делает один раз
Переход на linear light вскрыл старый баг форматтера. Тёмное значение канала CPAL 12 становится около 0,0036765 в linear light, а общий форматтер чисел за scn держал только два знака после запятой, поэтому компонент записывался как 0 ещё до того, как вьювер трогал файл. Симптом выглядел в точности как квантование читателя, и первым инстинктом было обвинить 8-битные групповые буферы MuPDF. Компоненты заливки и обводки теперь идут через форматтер десятичных чисел без экспоненты, который сохраняет малые значения и игнорирует локаль процесса. Урок обобщается: когда пропадает тёмный цвет, сдампите content stream и проверьте записанный операнд, прежде чем копать вьювер
Вьювер всё ещё имеет значение после того фикса. MuPDF 1.28.2 теряет околонулевые линейные каналы, когда композитит изолированную группу прозрачности, несущую явное blend-цветовое пространство, а Poppler 26.07.0 Splash игнорирует групповые blend-пространства вовсе. Опциональное свойство THotPDF.COLRv1DeviceSpaceGroupBlend (по умолчанию False) выпускает группы COLRv1 без blend-цветового пространства, так что они композитятся в унаследованном пространстве устройства, пока цвета содержимого, shading и функции остаются linear-light CalRGB. Плата в том, что перекрывающиеся полупрозрачные слои тогда блендятся в пространстве устройства в тех вьюверах, которые групповые цветовые пространства всё же уважают
Какие композитные режимы COLRv1 PDF выражает точно?
HotPDF рендерит композитные режимы COLRv1 с 5 по 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) нативно, а режимы с 9 по 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) уходят в OnColorGlyphRasterize. Режимы in и out — произведения одной поверхности на альфу другой, что в точности вычисляет soft mask (§11.6.5). Выживающий операнд захватывается как обычная форма; маскируемый операнд захватывается альфа-проходом как плоское содержимое внутри одной изолированной группы DeviceGray и используется как маска /Luminosity с /BC [0]. Режимы out пишут 1 − α для каждой альфы и кладут непрозрачную белую подложку под содержимое маски, поэтому выживший остаётся видимым там, где другой операнд никогда не красил
Остальным четырём нужно премультиплицированное сложение двух поверхностей. В модели source-over в PDF фоновый член всегда ослаблен на (1 − α) источника, а примитива, который складывает две поверхности или масштабирует фон только на покрытие источника, нет, поэтому atop, xor и plus не записать векторами. Ещё одно ограничение пришло из голых тестовых PDF: оба вьювера неверно считают группы soft mask, чьё содержимое вкладывает форму через Do, поэтому маскируемый операнд обязан быть плоским. Сплошные цвета, pad-градиенты и обёртка PaintGlyph вокруг них проходят; слои, трансформации и периодические градиенты, производящие внутренние формы, отправляют глиф в fallback
var
Pdf: THotPDF;
Fallback: TEmojiFallback; // класс-хендлер с сигнатурой события ниже
begin
Pdf := THotPDF.Create(nil);
Fallback := TEmojiFallback.Create;
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'periodic-icons.pdf';
// Опционально: композитинг групп в пространстве устройства для MuPDF и Poppler
Pdf.COLRv1DeviceSpaceGroupBlend := True;
// Принимает глифы atop/xor/plus и всё прочее вне подмножества:
// (Sender; Format; const FontBytes, AssetData: TBytes; GlyphID: Word;
// PaletteIndex, PixelSize: Integer; out Width, Height: Integer;
// out RGBA: TBytes; out Handled: Boolean)
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('Fonts\GradientIcons.ttf');
if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $E001,
72, 144, 'Gradient Icons', 48) then
Writeln('U+E001 was neither drawn natively nor rasterized');
Pdf.EndDoc;
finally
Fallback.Free;
Pdf.Free;
end;
end;
Где векторный путь всё ещё недотягивает
Векторный вывод — не то же самое, что идентичные пиксели в каждом вьювере, и приёмочные записи HotPDF говорят об этом прямо. Сэмплы в одном-двух шагах от границы цикла сглаживаются интерполяцией читателя, поэтому пиксельные проверки берут пробы вдали от швов, а потоки функций верифицируются декодированием. Интерполяция раздельно хранимых прямых сэмплов RGB и альфы — аппроксимация возле конечных точек с низкой альфой и жёстких стопов; измеренная ошибка — второй порядок при 256 сэмплах на цикл, но она не нулевая. Poppler Splash берёт пробы у левого края пикселя, а не в его центре, что проявляется малыми расхождениями на самых крутых рампах
Бюджеты — тоже настоящие пределы. Вложенные радиальные семейства отказывают за пределами диапазонов в 4096 циклов, конусы и касательные семейства — за пределами 4096 требуемых циклов, а касательная полоса возле сингулярной линии остаётся пустой. Переменная альфа стопов требует прозрачности PDF 1.4, поэтому не включается под PDF/A-1, PDF/X-1a или PDF/X-3. Если глиф выпадает за любой из этих пределов, DrawRegisteredColorGlyph отказывает всему графу до записи первого оператора — то же поведение всё-или-ничего, что и раньше
FAQ
Растеризует ли HotPDF repeat- и reflect-градиенты COLRv1?
Нет. Более новые версии HotPDF выпускают их как векторные axial и radial shading, покрывающие целые циклы. Только графы вне поддерживаемого подмножества, вроде композитов atop, xor и plus, уходят в OnColorGlyphRasterize
Почему бы не взять одну sampled-функцию на все циклы?
Читатели предвычисляют таблицу цветов из функции, а таблица MuPDF имеет 256 записей. Размазанная по многим циклам, она интерполирует через каждый repeat-шов. Один shading на цикл держит каждую таблицу внутри единственного цикла
Когда включать COLRv1DeviceSpaceGroupBlend?
Включайте, когда околочёрные каналы в глифах COLRv1 обнуляются в MuPDF. Оставляйте выключенным, когда перекрывающиеся полупрозрачные слои обязаны блендиться в linear light во вьюверах, уважающих групповые цветовые пространства
Нативные COLRv1-градиенты, цветные emoji, sampled shading и группы прозрачности — всё часть HotPDF Component for Delphi and C++Builder, который генерирует PDF-документы прямо из кода Delphi и C++Builder