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

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

Глиф-смайлик с улыбкой, записанный в PDF обычным текстовым вызовом со шрифтом Segoe UI Emoji, выходит плоской чёрной силуэтной: форма правильная, ни жёлтого, ни затенения. Подставьте CBDT emoji-шрифт — и тот же вызов может вообще ничего не нарисовать, потому что такой шрифт держит картинки как PNG-файлы и почти не несёт контурных данных. Скопируйте чёрную фигуру из просмотрщика — и в буфере обмена всё ещё U+1F600: символ верен, просто цветовые таблицы никто не читал

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

Потому что у шрифтовой программы PDF нет понятия цветного глифа. ISO 32000-1 трактует каждый глиф как форму, закрашенную текущим цветом заливки, а цветовые таблицы, добавленные в OpenType позже (COLR/CPAL, SVG , CBDT/CBLC и sbix), не входят в модель отображения PDF, так что ни у одного просмотрщика нет причин читать их из встроенного шрифта. Какой бы цвет ни был у emoji, он должен быть превращён в содержимое страницы, пока PDF пишется, в точке, где производитель ещё держит байты шрифта и точно знает, какой глиф ему нужен. Это превращение разное для каждого формата, а настоящие emoji-шрифты используют все четыре: стопку векторных слоёв, графы градиентной заливки, целые SVG-документы и PNG-страйки

HotPDF делает это превращение в THotPDF.DrawRegisteredColorGlyph. Он сообщает, к какому формату разрешался глиф, через 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, так что emoji-шрифт на момент вызова обязан быть зарегистрированным Unicode-шрифтом. Рисующая функция возвращает False, когда у глифа нет цветовых данных или ни один путь не смог его отрисовать, и в этом случае страница остаётся нетронутой

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';
    // Запись палитры 0xFFFF («text foreground») разрешается в этот цвет
    Pdf.ColorGlyphForegroundColor := clNavy;
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\seguiemj.ttf');

    // U+1F600, палитра CPAL 0, битмапный страйк, ближайший к 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, так что шрифт с палитрой под тёмный фон можно переключить, не трогая глиф. Запись палитры 0xFFFF особая в спецификации OpenType: она означает «какой цвет у текста», и HotPDF заполняет её из ColorGlyphForegroundColor и ColorGlyphForegroundAlpha (по умолчанию чёрный и 255). Emoji-шрифты той записью пользуются редко. Её любят иконочные шрифты, и если ваш тинтует глифы через неё, поставьте свойство в цвет текста до рисования

TargetPixelsPerEm важен только для битмапных шрифтов, и его значение по умолчанию — параметр, который стоит переопределять чаще всего. Оставленный нулём он превращается в Round(FontSize * 96 / 72) — экранное разрешение: 48 пикселей для emoji в 36 пунктов. Для шрифта, объявляющего sbix-страйки на 20, 32, 40, 48, 64, 96 и 160 ppem, это значение по умолчанию выберет 48-пиксельный страйк, и 600-dpi принтеру затем придётся увеличить его примерно в шесть раз. Спросите 300 — столько нужно 36 пунктам при 600 dpi, — и тот же вызов возьмёт страйк 160, крупнейший из имеющихся

Сигнатура заодно фиксирует охват. Входит одна кодовая точка и отображается через cmap. Последовательности семейства ZWJ, модификаторы оттенка кожи и флаги из regional indicators — лигатуры GSUB, так что сперва их надо отшейпить в глиф; такая работа разобрана в статье об альтернатах OpenType GSUB

Слои COLR и графы отрисовки как PDF-векторы

COLR v0 — простой формат. Каждый базовый глиф перечисляет стопку слоёв-глифов, каждый со цветом CPAL, и HotPDF заполняет контур каждого слоя в порядке таблицы. Слой, чей цвет CPAL несёт альфу ниже 255, получает собственный /ca, так что полупрозрачные слои блендятся с лежащими под ними ровно как задумал дизайнер шрифта. В обычном документе вся стопка затем захватывается в одну изолированную группу прозрачности (ISO 32000-1 §11.4). Группа — про то, что происходит снаружи глифа: непрозрачность, blend mode или soft mask, заданные вокруг emoji, применяются один раз к готовому глифу, а не повторяются на каждом слое и не удваиваются там, где слои перекрываются

COLR v1 — зверь иной: направленный ациклический граф записей отрисовки с градиентами, трансформациями, вложенными глиф-клипами и композитными режимами, где узлы могут разделяться через PaintColrLayers и PaintColrGlyph. HotPDF сперва разбирает его в ограниченный граф — потолок 4096 узлов отрисовки, 96 уровней глубины и 1024 color stop, — и ведёт каждый узел, пока тот посещается, так что ссылка назад в текущий путь (цикл, который враждебный шрифт может построить из переиспользования слоёв) отвергается, а не раскручивается рекурсией

Смещения — то место, где самописные парсеры COLR v1 обычно ломаются. Смещения BaseGlyphPaintRecord отсчитываются от начала BaseGlyphList, смещения отрисовки LayerList — от LayerList, а каждый Offset24 внутри таблицы отрисовки — от самой этой таблицы. Разрешите все три от одной базы — и совершенно легальные глифы провалят проверки границ, что выглядит в точности как битый шрифт. Когда граф построен, отображение в PDF почти прямое:

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

Градиенты с полупрозрачными стопами прячут ещё одну ловушку. COLR v1 интерполирует цвета, премультиплицированные альфой, тогда как PDF ждёт прямой цвет и применяет альфу сам, так что конвертер, отдающий премультиплицированные значения в PDF, затемняет каждую полупрозрачную полосу дважды. Цвета надо раскрывать обратно после интерполяции и до записи

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

Что происходит с композитными режимами COLR v1 под PDF/A-1 или PDF/X-1a?

Режимы Porter-Duff в PaintComposite (5–12: src_in, dest_out, src_atop, xor, plus и их собратья) не имеют одноимённого PDF blend mode, и очевидное прочтение спецификации PDF — что выразить их нельзя. Это прочтение слишком сильное. Режимы in/out — это soft mask по альфе над одним операндом; atop, xor и plus можно собрать из изолированных форм, групповой альфы, масок люминансности и арифметики блендинга. HotPDF строит их именно так, так что эти глифы остаются векторами

Цена в том, что большинству таких конструкций нужна прозрачность PDF, а PDF/A-1, PDF/X-1a и PDF/X-3 её запрещают. Когда PDFACompliance равен значению PDF/A-1 или PDFXCompliance равен 'X-1a' или 'X-3', HotPDF проверяет глиф до рисования и отправляет случаи, которые нельзя нарисовать без прозрачности, на raster-событие: композиты atop, xor и plus; композиты in/out, чей маскируемый операнд сложнее простой непрозрачной заливки; сплошные заливки с частичной альфой; градиенты с полупрозрачными стопами и слои COLR v0 с альфой ниже 255. Режимы in и out остаются векторами, когда маскируемая сторона — простой сплошной цвет или градиент, опционально завёрнутые в глиф-клип или трансформацию, потому что тому случаю нужна лишь непрозрачная полутоновая маска. То же верно для COLR v0, когда StrictVersionLock прикалывает файл ниже PDF 1.4

Pdf.PDFACompliance := 'B';   // PDF/A-1b: прозрачность запрещена
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF(EmojiFontPath);

if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F308,
  72, 200, EmojiFontName, 36, 0, 300) then
  // Без хендлера OnColorGlyphRasterize полупрозрачный
  // или скомпозитенный глиф заканчивается здесь под PDF/A-1
  LogMissingColorGlyph($1F308);   // ваша собственная процедура логирования

Если архив обязан показывать emoji в цвете, целитесь в PDF/A-2b вместо PDF/A-1b. Часть 2 разрешает прозрачность, все COLR-конструкции остаются векторами, и обработчик отката становится страховочной сеткой, а не главным рендерером для каждого полупрозрачного emoji. PDF/A-1 держите для процессов, требующих его по имени

Одна настройка на стороне читателя тоже относится сюда. HotPDF композитит группы COLR v1 в blend-пространстве линейного света; некоторые рендереры теряют очень тёмные каналы в таких группах, а некоторые игнорируют групповые blend-пространства целиком, поэтому COLRv1DeviceSpaceGroupBlend (по умолчанию False) позволяет группам композититься в обычном пространстве устройства. Компромисс разобран в статье о repeat и reflect градиентах COLR v1; включайте, если тёмные полупрозрачные тинты пропадают в просмотрщике, которым реально пользуются ваши пользователи

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

SVG-глифы идут через тот же безопасный билдер, которым HotPDF пользуется для импортируемых SVG-файлов, и результат регистрируется как Form XObject (§8.10), как описано в статье о SVG в Form XObject. Документ в таблице SVG может быть сжат gzip. Сжатый вход ограничен 8 МБ, декомпрессия идёт чанками по 8 КБ и останавливается, как только развёрнутый размер превысил бы 32 МБ. Скрипты, foreignObject, URL javascript: и стили @import заставляют билдер отвергнуть документ целиком. Форма масштабируется так, чтобы её длинная сторона равнялась размеру шрифта. Что попробовать до продакшена: некоторые шрифты пакуют много глифов в один общий SVG-документ, и билдер получает весь этот документ на каждый глиф

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

Написание обработчика raster fallback

Когда ни один нативный путь не сработал, HotPDF возбуждает OnColorGlyphRasterize и кладёт RGBA-битмап, который вернёт ваш обработчик. Без обработчика, или с Handled, оставшимся False, DrawRegisteredColorGlyph возвращает False, и ничего не рисуется. Обработчик получает формат, байты шрифта, извлечённый ассет (SVG-документ, возможно всё ещё gzipped, или байты битмапа; пусто для COLR), ID глифа, палитру и целевой пиксельный размер

Схема 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. Для вариативного цветного шрифта обработчик получает статический инстанс на зарегистрированных значениях осей; если ему вдруг достался исходный вариативный шрифт, вызов GetRegisteredVariableFontAxes на Sender (приведённом к THotPDF) вернёт координаты для отрисовки. PixelSize — то же значение TargetPixelsPerEm, так что совет про разрешение печати применим и здесь

Конвейер, печатающий произвольный пользовательский текст, должен знать о двух видах потерь: глифы, которых в шрифте никогда не было, и глифы, потерявшие цвет. Отслеживание покрытия на рисовании из статьи об отслеживании недостающих глифов докладывает первое; False от DrawRegisteredColorGlyph — второе

Рендеринг COLR, SVG и битмапных emoji, шейпинг OpenType и безопасный SVG-билдер — часть HotPDF component for Delphi and C++Builder