Глиф-смайлик с улыбкой, записанный в 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. Шрифт, несущий и векторы, и битмапы, поэтому идёт векторным путём. Для всего, что будет зумиться или печататься, это правильный порядок
Разрешение и отрисовка одной кодовой точки
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 под 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 глифа, палитру и целевой пиксельный размер
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