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

Функции PDF типов 2/3/4 в Delphi: экспоненциальные, сшивающие, PostScript

HotPDF, нативный компонент PDF на VCL для Delphi и C++Builder, вычисляет три типа функций PDF, построенных на формулах, а не на сеточных выборках: экспоненциальную интерполяцию типа 2, сшивание типа 3 и калькуляторные функции PostScript типа 4, соответствующие ISO 32000-1 §7.10.3, §7.10.4 и §7.10.5. Тип 2 смешивает два выходных вектора вдоль кривой, тип 3 объединяет в цепочку несколько подфункций на одной входной области определения, а тип 4 выполняет ограниченную программу на PostScript, способную ветвиться, сравнивать и вычислять почти всё, что нужно потоку содержимого из своих входных данных. Ошибитесь чуть-чуть в любом из трёх — и сбой никогда не заявит о себе как об ошибке: он проявится как градиент с мёртвой плоской полосой, плашечный цвет, отрисовывающийся чистым чёрным, или калькуляторная функция, дающая расхождение ровно на единицу именно на тех входных значениях, которые набор тестов случайно не попробовал

Эти три соседствуют с четвёртым типом, типом 0, который хранит выборочную сетку вместо формулы и описан отдельно в сопутствующей статье о таблицах цветового поиска типа 0. Оба семейства решают одну и ту же задачу — отображение входа в выход, — но тип 0 представляет собой данные, вычисленные один раз и запечённые в файл, тогда как типы 2, 3 и 4 — это код, который читающая программа вычисляет при каждом вызове. Все четыре типа разделяют одну точку диспетчеризации в рендерере HotPDF, ключом для которой служит запись /FunctionType словаря функции, так что затенению, преобразованию оттенка или плашечной функции полутонов никогда не нужно знать, какой из четырёх типов она получила, прежде чем запросить цвет

В HotPDF для Delphi одна общая точка диспетчеризации /FunctionType направляет каждый вызов затенения, tint-преобразования или spot-функции вычислителю Type 0, 2, 3 или 4
Одна диспетчеризация по записи /FunctionType выбирает среди сэмпловых и формульных вычислителей

Как работает экспоненциальная функция PDF типа 2?

Функция PDF типа 2 вычисляет одну формулу — y = C0 + x^N × (C1 − C0), применяемую покомпонентно, — где x является единственным входом функции, нормализованным относительно её /Domain перед выполнением формулы (ISO 32000-1 §7.10.3). /C0 и /C1 — это выходные векторы на двух концах этого диапазона, по одному числу на каждый выходной компонент, а /N — показатель степени, задающий форму кривой между ними: N = 1 даёт прямой линейный переход, лежащий в основе большинства градиентных остановок и преобразований дуплекса, N выше 1 притягивает кривую к C0, а N между 0 и 1 подталкивает её к C1. RegisterExponentialFunction строит этот словарь из пяти аргументов и возвращает объект функции, готовый к подключению к затенению, плашечной функции полутонов или куда угодно ещё, где спецификация допускает ключ /Function

Соотношение количества компонентов между C0 и C1 важно дважды: один раз при создании функции типа 2, и ещё раз всякий раз, когда HotPDF приходится отрисовывать чужую функцию, которую он не создавал. На стороне создания RegisterExponentialFunction проверяет C0 и C1 друг относительно друга и выбрасывает исключение при их несовпадении, так что вызов, дошедший до BeginDoc, уже даёт самосогласованный объект функции. Однако на стороне рендеринга оценщику приходится доверять любым массивам /C0 и /C1, реально объявленным в исходном файле, — скажем, файле из типографии, открытом для предпросмотра, или подписанном документе, показываемом пользователю обратно, — а версии до 2.376.0 считывали эти массивы в буфер, рассчитанный на четыре компонента, случай CMYK. Экспоненциальный оттенок DeviceGray или DeviceRGB с одно- или трёхэлементными /C0 и /C1 при таком чтении молча проваливался, и оба массива оставались нулевыми, так что оттенок отрисовывался сплошным чёрным вместо задуманного цвета. Версия 2.376.0 изменила размер буфера чтения под реально объявленное количество выходов функции вместо фиксированного буфера — именно тот тип ошибки, который вскрывает только тестовый случай не-CMYK, поскольку существующий набор тестов повсюду использовал CMYK, где четыре-в-четыре всегда помещалось

var
  EaseIn: THPDFDictionaryObject;
begin
  // Type 2: один вход, N > 1 смещает ramp к C0 (кривая ease-in)
  EaseIn := Pdf.RegisterExponentialFunction(
    [0,1],           // Domain: single input, clamped to [0,1]
    [0, 0, 0],       // C0: output at x = 0
    [0.8, 0, 0],     // C1: output at x = 1
    3,               // N: exponent, 1 = linear, > 1 eases toward C0
    []);             // Range omitted: по умолчанию зажимается в [0,1] для каждого выхода
end;

Сшивание типа 3: объединение подфункций через массив Bounds

Функция PDF типа 3 сшивает k подфункций в одно кусочное отображение по /Domain единственного входа, и работу здесь обеспечивают два массива — /Bounds и /Encode (ISO 32000-1 §7.10.4). /Bounds содержит k − 1 внутренних точек разбиения, разрезающих /Domain на k последовательных интервалов; оценщик выбирает первый интервал, верхняя граница которого превышает вход, либо последний интервал, если вход достиг последней границы, и передаёт управление подфункции этого интервала. Затем /Encode заново отображает вход из его позиции внутри этого интервала на тот входной диапазон, который ожидает сама выбранная подфункция, — обычно [0, 1], если подфункция представляет собой ещё один экспоненциальный сегмент, — прежде чем вычисление продолжится на уровень глубже, уже в собственных /Domain и /Range этой подфункции

Стежковая функция Type 3 в HotPDF для Delphi режет входную область в точке разбиения /Bounds и перемэппит каждый интервал через /Encode перед вычислением её экспоненциальной подфункции
Вход попадает в один интервал через /Bounds, а /Encode пересштабирует его на собственный входной диапазон той подфункции

Оценщик сшивания в HotPDF раньше умел обрабатывать только ровно две подфункции, а его считыватель /Bounds требовал полного восьмиэлементного массива, так что единственная точка разбиения, реально нужная двухсегментному градиенту — одно число в /Bounds, — всегда не проходила разбор, и функция ничего не возвращала. /Encode вообще не применялся. Версия 2.376.0 переписала выбор как общий поиск по k подфункциям, описанный спецификацией, и стала считывать /Bounds по его реальной объявленной длине, так что трёх-, четырёх- или пятиостановочный градиент, сшитый из соответствующего числа экспоненциальных сегментов, теперь разрешается так же, как двухсегментный якобы всегда и разрешался. Пример ниже строит двухсегментный переход чёрный-через-красный-к-белому — форму, к которой осевое или радиальное затенение обращается всякий раз, когда одна экспоненциальная кривая не может перенести все цветовые остановки, которые требует дизайн

var
  ToRed, ToWhite, Ramp: THPDFDictionaryObject;
begin
  // Два линейных сегмента: black->red на [0, 0.5], red->white на [0.5, 1]
  ToRed   := Pdf.RegisterExponentialFunction([0,1], [0, 0, 0],   [0.8, 0, 0], 1, []);
  ToWhite := Pdf.RegisterExponentialFunction([0,1], [0.8, 0, 0], [1, 1, 1],   1, []);

  Ramp := Pdf.RegisterStitchingFunction(
    [0,1],                  // Domain: собственный диапазон входа сшитой функции
    [ToRed, ToWhite],       // Functions: k = 2 sub-functions
    [0.5],                  // Bounds: k - 1 = 1 split point
    [0,1, 0,1],             // Encode: 2 числа на каждую sub-function
    []);                    // Range omitted: наследуется от каждой sub-function
end;

Что может калькуляторная функция PostScript типа 4, чего не могут типы 2 и 3?

Функция PDF типа 4 выполняет настоящую, хотя и намеренно ограниченную, программу: калькулятор PostScript, который помещает свои входные данные в стек операндов, выполняет арифметические операторы, операторы сравнения, манипуляции со стеком и логические операторы, а также условные конструкции if/ifelse, и оставляет свои выходы в стеке по завершении (ISO 32000-1 §7.10.5, таблица 42). Здесь нет ни конструкции цикла, ни хранения именованных переменных — только стек, что делает соответствующую спецификации программу лёгкой для понимания, — но в рамках этого ограниченного набора операторов тип 4 может выразить то, чего не могут типы 2 и 3, например настоящую формулу смешивания нескольких красок для разделения DeviceN или плашечную функцию полутонов с условным порогом. Оценщик HotPDF, HPDFEvalPostScriptCalculator, один раз токенизирует программу — числа, операторы и блоки процедур { }, — а затем обходит стек операндов на 100 элементов (глубина, требуемая ISO 32000-1 §7.10.5) под жёстким потолком в 50 000 вычисленных операторов как защитным барьером против патологических или написанных вручную программ

Оператор roll: направление легко перепутать

roll — оператор, который с наибольшей вероятностью окажется перепутан по направлению с первой попытки, потому что и порядок его аргументов, и направление вращения противоположны тому, как их описывает естественный язык. n j roll снимает со стека счётчик n и величину вращения j, затем циклически сдвигает верхние n записей стека на j позиций, оборачивая элементы, выпадающие с одного конца, обратно на другой; канонический пример прямо из спецификации — a b c 3 1 roll, дающий c a b: верхний элемент перемещается в низ группы, а не наоборот, и каждый другой элемент сдвигается вверх на одну позицию, освобождая место. Оценщик HotPDF вычисляет новую позицию элемента стека i как (i + j) mod n, что в точности соответствует этому примеру, но это цикл в две строки, который так же легко написать с перевёрнутым направлением вращения, а зеркальный roll всё равно даёт правдоподобно выглядящий цвет — просто не тот цвет, который запросил автор файла

Калькулятор PostScript в Delphi от HotPDF применяет roll как новый индекс (i + j) mod n, циклически сдвигая стек операндов из трёх элементов от a b c к c a b
Один шаг roll переносит верхний элемент в низ группы — направление, которое многие первые попытки переворачивают
const
  Prog = '{ 3 1 roll }';   // (a b c) -> (c a b): третий вход перемещается в начало
var
  Reorder: THPDFStreamObject;
begin
  // Type 4: 3 входа, 3 выхода, без дополнительного ограничения кроме Domain/Range
  Reorder := Pdf.RegisterPostScriptFunction(
    [0,1, 0,1, 0,1],   // Domain: 2 числа на каждый вход
    [0,1, 0,1, 0,1],   // Range: 2 числа на каждый выход (обязательно для Type 4)
    Prog);
end;

round — не то же самое, что Round в Delphi: округление вверх от .5 против банковского округления

Оператор PostScript round всегда разрешает ничью на .5 в сторону большего целого числа, а встроенная функция Delphi Round — нет: она округляет по правилу «половина к чётному», банковское округление, которое чередует направление округления .5, чтобы повторяющееся округление не накапливало смещение. Оба совпадают почти везде и расходятся именно на той границе, которая здесь важна, — Round(0.5) в Delphi возвращает 0, а Round(2.5) возвращает 2, тогда как round из спецификации PDF хочет получить 1 и 3 для тех же входов, — так что это несовпадение прячется при обычном тестировании, а затем проявляется как стабильное расхождение на единицу везде, где промежуточная арифметика калькуляторной программы попадает точно на полуцелое число. ISO 32000-1 §7.10.5, таблица 42, прямо указывает, что round продвигает дробное .5 к большему целому, поэтому HotPDF реализует этот оператор как Floor(x + 0.5) вместо вызова Round из Delphi, и любой код, который переписывает заново или вручную выборочно проверяет арифметику программы типа 4, нуждается в той же замене

function PostScriptRound(const X: Double): Double;
begin
  // ISO 32000-1 7.10.5 Table 42: округление .5 в сторону большего
  // integer. Delphi's Round() использует банковское округление и расходится со спецификацией
  // Round(0.5) = 0, Round(2.5) = 2 — оба на единицу меньше значения спецификации
  Result := Floor(X + 0.5);
end;

Проверка при регистрации выявляет неисправную калькуляторную программу заранее

Некорректно построенную программу типа 4 дёшево поймать на этапе создания и дорого поймать где-либо ещё, поэтому RegisterPostScriptFunction не просто сохраняет исходный текст: он один раз пробно вычисляет программу в средней точке объявленного /Domain, прежде чем объект функции вообще будет записан в документ. Несбалансированные блоки { }, нераспознанный оператор, опустошение стека или количество выходов, не совпадающее с /Range, — всё это приводит к сбою этого пробного прогона и немедленному выбросу исключения, причём стек вызовов указывает на сам вызов RegisterPostScriptFunction, а не на артефакт рендеринга, обнаруженный при контроле качества уже отгруженного файла. Пробный прогон в средней точке не доказывает, что программа корректна на всей области /Domain, — условное ветвление, которое ведёт себя неправильно только вблизи одного края входного диапазона, всё ещё может проскользнуть мимо единственной точки выборки, — но он отсекает целый класс программ, структурно сломанных, а не просто неверных в одном частном случае

Где градиенты и плашечные цвета применяют эти функции на практике

Типы 2, 3 и 4 редко появляются в реальном PDF изолированно; они возникают везде, где спецификация допускает ключ /Function, а два самых частых потребителя — затенения и преобразования оттенка плашечных цветов. Оператор sh осевого или радиального градиента (ISO 32000-1 §8.7.4.5) вычисляет свою /Function один раз для каждой позиции вдоль оси градиента, что в точности тот многоостановочный случай, для которого существует сшивание типа 3. Преобразование оттенка цветового пространства Separation или DeviceN — ещё одно частое пристанище для этих трёх типов, и именно здесь тип 4 оправдывает своё существование: одна плашечная краска обычно сводится к кривой типа 2 или типа 0, но смесь DeviceN из нескольких красок с реальным поведением треппинга и наложения часто нуждается в условной логике, которую может выразить только калькулятор PostScript, — случай, описанный в статье о рендеринге плашечных цветов Separation и DeviceN. RegisterSeparationFunc — парный вызов на стороне создания: он принимает имя красителя, альтернативное цветовое пространство и любой объект, возвращённый семейством функций Register*Function, и подключает это преобразование оттенка к ресурсу цветового пространства Separation, который остальная часть страницы может выбрать через scn/SCN

Вместе выборочные сетки типа 0 и эти три формульно-управляемых типа покрывают каждую /Function, которую может объявить PDF, и выбор нужного типа — это в основном вопрос того, что у вас уже есть: таблица поиска, вычисленная где-то ещё, становится типом 0, смешение с двумя конечными точками становится типом 2, несколько смешений, объединённых цепочкой по области определения, становятся типом 3, а всё, что содержит настоящую условную логику, становится типом 4. RegisterExponentialFunction, RegisterStitchingFunction и RegisterPostScriptFunction входят в стандартный компонент HotPDF для Delphi и C++Builder, наряду с остальным его API функций и затенений по ISO 32000-1