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

Цветные emoji-шрифты в PDF: COLR v1, SVG и битмапы в Delphi

HotPDF рисует цветные emoji в PDF через THotPDF.DrawRegisteredColorGlyph, который читает цветовые данные шрифта, зарегистрированного RegisterUnicodeTTF, и выпускает их как нативную PDF-графику: слои COLR v0 как заполненные контуры глифов, графы отрисовки COLR v1 как клипы, shading и blend mode, SVG-глифы как Form XObject, а битмапы CBDT или sbix как изображения. Всё, что не удаётся отобразить нативно, уходит в событие OnColorGlyphRasterize вместо того, чтобы молча превратиться в чёрную кляксу

Последняя оговорка — вся причина существования этого кода. Встройте emoji-шрифт обычным способом, и вьювер возьмёт контур из glyf или CFF, залитый тем, каким случилось быть текущему цвету заливки. Улыбающееся лицо приходит чёрной кляксой, флаг — прямоугольником, и ни одна ступень конвейера не жалуется

Почему цветной emoji печатается чёрным силуэтом в PDF?

У шрифтовой программы PDF нет понятия цветных глифов. ISO 32000-1 считает глиф фигурой, закрашенной текущим цветом, а цветовые таблицы, добавленные в OpenType позже, — COLR/CPAL, SVG , CBDT/CBLC и sbix — не входят в imaging model PDF, так что ни один вьювер не обязан читать их из встроенного шрифта. Цвет приходится переводить в содержимое страницы в момент генерации, пока производитель ещё держит байты шрифта и знает, какой глиф ему нужен. Перевод у каждого формата свой, и дикие emoji-шрифты используют их все: слоистые векторы, градиентные графы отрисовки, встроенные SVG-документы и PNG-страйки. HotPDF сообщает результат как THPDFOpenTypeColorFormat со значениями otcfNone, otcfCOLRv0, otcfCOLRv1, otcfCBDT, otcfSVG и otcfSBIX и прощупывает шрифт в фиксированном приоритете: сначала COLR, затем SVG, затем CBDT, затем sbix. Векторы бьют битмапы, когда шрифт несёт и то и другое, — именно это вам нужно в документе, который могут увеличить или распечатать

Схема прощупывания цветных глифов в HotPDF: шрифтовая программа PDF красит контуры глифов текущим цветом, поэтому цветовые таблицы OpenType COLR, SVG, CBDT и sbix приходится переводить в содержимое страницы в момент генерации, а HotPDF прощупывает зарегистрированный шрифт в фиксированном приоритете COLR, затем SVG, затем CBDT, затем sbix, сообщая THPDFOpenTypeColorFormat от otcfCOLRv0 до otcfSBIX
Векторы бьют битмапы, когда шрифт несёт и то и другое, — именно это нужно документу, который могут увеличить или распечатать, а глиф без цветового пути остаётся вашему fallback

Один вызов, пять форматов: разрешаем и рисуем цветной глиф

THotPDF.GetRegisteredColorGlyphInfo отвечает, каким путём пойдёт кодовая точка, а DrawRegisteredColorGlyph этим путём её ведёт. Оба ищут кодовую точку в character map шрифта, последним переданного в RegisterUnicodeTTF, так что цветной шрифт на момент вызова обязан быть зарегистрированным Unicode-шрифтом. Рисующая функция возвращает False, когда у глифа нет цветовых данных или ни один путь не смог его отрисовать, и оставляет fallback вам

const
  FormatNames: array[THPDFOpenTypeColorFormat] of string =
    ('none', 'COLR v0', 'COLR v1', 'CBDT', 'SVG', 'sbix');
var
  Pdf: THotPDF;
  Info: THPDFOpenTypeColorGlyphInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'emoji.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\seguiemj.ttf');

    // U+1F600, палитра CPAL 0, bitmap-страйк, ближайший к 300 ppem
    if Pdf.GetRegisteredColorGlyphInfo($1F600, 0, 300, Info) then
      Writeln(Format('GID %d via %s',
        [Info.GlyphID, FormatNames[Info.Format]]));

    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F600,
      72, 144, 'Segoe UI Emoji', 36, 0, 300) then
    begin
      // Цветовых данных нет: откат к монохромному контуру
      Pdf.CurrentPage.SetFont('Segoe UI Emoji', [], 36, DEFAULT_CHARSET);
      Pdf.CurrentPage.TextOut(72, 144, 0, WideString(#$D83D#$DE00));
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Два параметра заслуживают внимания. PaletteIndex выбирает палитру CPAL, так что шрифт с палитрой под тёмный фон переключается, не трогая глиф. TargetPixelsPerEm важен только для битмапных шрифтов; при нуле он берёт Round(FontSize * 96 / 72) — экранное разрешение, почему пример и просит 300 для печатного вывода. Честный предел сидит в сигнатуре: вызов берёт одну кодовую точку и отображает её только через cmap. ZWJ-последовательности, модификаторы тона кожи и флаги из regional indicator — это лигатуры GSUB, так что их сборка — проблема шейпинга из статьи об альтернатах OpenType GSUB, а не то, что эта точка входа делает за вас

COLR v0: слои глифов стопкой с цветами из палитры

COLR v0 — простой случай, и HotPDF рендерит его напрямую: каждый базовый глиф перечисляет глифы-слои с записью цвета CPAL, и каждый слой становится одной обычной операцией показа текста со своей заливкой, сложенной в порядке таблицы. Слой с альфой ниже 255 получает словарь параметров графического состояния с совпадающими /ca и /CA (ISO 32000-1 §8.4.5), и каждый глиф-слой помечается как используемый, чтобы сабсеттер сохранил его контур, хоть к нему и не ведёт ни одна кодовая точка. Одна деталь удивляет: индекс записи палитры 0xFFFF по спецификации OpenType значит «использовать цвет переднего плана текста», и HotPDF разрешает его в чёрный, а не в текущую заливку страницы. Для emoji-шрифтов это редко важно; для иконочных шрифтов, живущих записью переднего плана, чтобы тонировать глиф, проверяйте вывод, прежде чем рассчитывать, что он пойдёт за цветом вашего текста

Как HotPDF превращает граф отрисовки COLR v1 в PDF-операторы?

Тем, что сперва разбирает таблицы отрисовки в плоский ограниченный граф и лишь затем отображает каждый узел в PDF-конструкцию. Глиф COLR v1 — не список слоёв, а направленный ациклический граф записей отрисовки, где узлы могут разделяться через PaintColrLayers и PaintColrGlyph. Парсер ограничивает его 4096 узлами отрисовки, 64 уровнями глубины и 1024 color stop, и ведёт каждый узел как активный или завершённый, так что ссылка назад на активный узел — цикл, который зловредный шрифт может собрать из переиспользования слоёв, — отвергается, а не рекурсируется. Смещения баз — то место, где ошибается первая реализация. Смещения BaseGlyphPaintRecord отсчитываются от начала BaseGlyphList, смещения отрисовки LayerList — от LayerList, а каждый Offset24 внутри таблицы отрисовки — от самой этой таблицы. Разрешить все три от одной базы — и совершенно легальные глифы валятся на проверке границ, что выглядит точь-в-точь как битый шрифт. Когда граф построен, отображение прямое:

  • PaintGlyph ставит контур глифа как клип с режимом рендеринга текста 7 (ISO 32000-1 §9.3.6), затем красит своего ребёнка внутри него
  • Сплошные заливки красят обрезанный прямоугольник; линейные градиенты становятся многостоповыми axial shading, а радиальные — двухцветными radial shading (§8.7.4.5)
  • Sweep-градиенты не имеют PDF-эквивалента, поэтому HotPDF аппроксимирует их 96 клиньями плоских цветов, каждый сэмплится с цветовой линии
  • Трансформации выпускаются как cm, сопряжённые вокруг начала базовой линии глифа, со сдвигами, промасштабированными на FontSize / UnitsPerEm
  • Режимы PaintComposite с 13 по 27 отображаются на разделимые и неразделимые PDF blend mode вроде /Multiply, /Screen и /Luminosity (§11.3.5), выставляемые записью /BM в ExtGState

Граница объявлена явно. Режимы Porter-Duff с 5 по 12 (src_in, xor, plus и остальные) не имеют PDF-аналога среди blend mode, режимы расширения repeat и reflect на линейных и радиальных градиентах не выпускаются, а градиенты, чьи стопы несут разные альфы, не подделываются одной непрозрачностью. Радиальные градиенты с более чем двумя стопами сохраняют только первый и последний цвета. HotPDF сверяет весь граф с этим поддерживаемым подмножеством до записи первого оператора, поэтому неподдерживаемый глиф оставляет страницу нетронутой и переходит к raster fallback, вместо того чтобы бросить полкартинки

Схема конвертации COLR v1 в HotPDF: граф отрисовки разбирается в ограниченный граф с потолком 4096 узлов, 64 уровней глубины и 1024 color stop с отбраковкой циклов, затем PaintGlyph становится клипом режима 7, линейные и радиальные градиенты — axial и radial shading, sweep-градиенты — 96 клиньев, а режимы PaintComposite с 13 по 27 — PDF blend mode
Весь граф сверяется с поддерживаемым подмножеством до записи первого оператора, поэтому неподдерживаемый глиф оставляет страницу нетронутой и переходит к raster fallback, не бросая полкартинки

SVG-глифы и bitmap-страйки

SVG-глифы идут через тот же ограниченный билдер, которым HotPDF пользуется для импортируемых SVG-файлов, и результат регистрируется как Form XObject (§8.10) — ровно как описано в статье о SVG в Form XObject. Документ в таблице SVG может быть сжат gzip; декомпрессия идёт чанками по 8 KB и останавливается, как только развёрнутый размер перевалил бы за 32 MB, вместо того чтобы раздуть и потом проверить, а сам сжатый вход ограничен 8 MB. Профиль ограничен намеренно: скрипты, встроенные изображения, внешние URL, data: URI и нелокальные ссылки отказывают закрыто. Форма масштабируется так, чтобы её длинная сторона равнялась размеру шрифта, и якорится на базовой линии, что отображает систему координат SVG с y вниз на PDF-овскую с y вверх. Учтите: билдер получает весь SVG-документ глифа без выборки элемента glyphNNN, поэтому шрифты, пакующие много глифов в один общий документ, стоит протестировать, прежде чем полагаться на них

Битмапные шрифты — вопрос выбора страйка и размещения. Для CBDT HotPDF берёт размер CBLC, чей вертикальный ppem ближе всех к TargetPixelsPerEm, принимает форматы изображений 17, 18 и 19, а метрики формата 19 читает из подтаблицы индекса CBLC, потому что сам этот формат своих не хранит. Для sbix смещения страйков отсчитываются от таблицы, смещения глифов — от страйка, а запись dupe переиспользует графику другого глифа, храня собственные смещения origin; дать рекурсии затереть внешний origin — значит сдвинуть изображение. Нагрузки PNG и JPEG декодируются внутри, масштабируются на FontSize / PixelsPerEmY, а не растягиваются до размера шрифта, и пишутся с soft mask (§11.6.5.3), когда хоть один пиксель не полностью непрозрачен. Нагрузки sbix TIFF не декодируются и уходят в событие

Что происходит, когда глиф нельзя нарисовать нативно?

HotPDF поднимает OnColorGlyphRasterize и кладёт тот RGBA-битмап, который вернёт ваш хендлер; если ничего не назначено или хендлер оставил Handled ложным, DrawRegisteredColorGlyph возвращает False, и страница остаётся без изменений. Событие срабатывает для графа COLR v1 вне поддерживаемого подмножества, для SVG-документа, отвергнутого безопасным билдером, и для битмапной нагрузки, которую внутренние декодеры не читают. Хендлер получает формат, сырые байты шрифта, извлечённый ассет (SVG-документ, возможно ещё сжатый gzip, или байты битмапа; пусто для COLR v1), идентификатор глифа, палитру и целевой размер в пикселях

Схема raster fallback в HotPDF: OnColorGlyphRasterize срабатывает для графа COLR v1 вне поддерживаемого подмножества, SVG-документа, отвергнутого безопасным билдером, или битмапной нагрузки, которую декодеры не читают, передавая формат, байты шрифта, ассет, GlyphID, PaletteIndex и PixelSize, а вернувшийся RGBA-буфер принимается, только когда его длина ровно Width умножить на Height умножить на 4
Нулевые размеры, неверная длина буфера или переполняющие размерности отвергаются до прикосновения к странице, а без хендлера или с Handled false вызов возвращает False, и страница остаётся без изменений
type
  TEmojiFallback = class
  public
    procedure Rasterize(Sender: TObject;
      Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
      const AssetData: TBytes; GlyphID: Word;
      PaletteIndex, PixelSize: Integer;
      out Width, Height: Integer; out RGBA: TBytes;
      out Handled: Boolean);
  end;

procedure TEmojiFallback.Rasterize(Sender: TObject;
  Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
  const AssetData: TBytes; GlyphID: Word;
  PaletteIndex, PixelSize: Integer;
  out Width, Height: Integer; out RGBA: TBytes;
  out Handled: Boolean);
begin
  Width := 0;
  Height := 0;
  RGBA := nil;
  // RenderWithOwnEngine — ваш растеризатор, а не API HotPDF.
  // Он обязан вернуть ровно Width * Height * 4 байт RGBA.
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

// Подключение
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;

HotPDF валидирует вывод хендлера до прикосновения к странице: нулевые размеры, буфер, чья длина не ровно Width * Height * 4, или размерности, способные переполниться, отвергаются, и вызов возвращает False. Raster fallback — всё равно растр, так что emoji, отрисованный так, теряет векторную резкость; запрашивайте PixelSize, соответствующий вашему разрешению вывода. Поставьте цветовой путь рядом с проверками покрытия при отрисовке из статьи об отслеживании отсутствующих глифов, и конвейер, переваривающий произвольный пользовательский текст, сможет сообщать и о недостающих глифах, и о глифах, потерявших цвет

Рендерер цветных глифов, стек шейпинга OpenType и безопасный SVG-билдер выходят в HotPDF Delphi PDF component для Delphi и C++Builder