Технічна стаття

Кольорові emoji в PDF: COLR v1, SVG і бітмапи в Delphi

HotPDF малює кольорові emoji в PDF через THotPDF.DrawRegisteredColorGlyph, який читає кольорові дані шрифту, зареєстрованого через RegisterUnicodeTTF, і випромінює їх як нативну PDF-графіку: шари COLR v0 як залиті контури гліфів, paint-графи COLR v1 як кліпи, шейдінги та blend modes, SVG-гліфи як Form XObject-и, а бітмапи CBDT чи sbix — як зображення. Усе, що не мапиться нативно, іде в подію OnColorGlyphRasterize замість того, щоб тихо перетворитися на чорну фігуру

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

Чому кольоровий emoji друкується чорним силуетом у PDF?

Шрифтова програма PDF не має поняття про кольорові гліфи. ISO 32000-1 трактує гліф як фігуру, залиту поточним кольором, а кольорові таблиці, які OpenType додав пізніше, а саме COLR/CPAL, SVG , CBDT/CBLC і sbix, не є частиною PDF-моделі зображення, тож жоден переглядач не зобов'язаний читати їх із вбудованого шрифту. Колір мусить бути перекладений у вміст сторінки в момент генерації, поки продюсер ще тримає байти шрифту і знає, який гліф йому потрібен. Той переклад різниться за форматом, і emoji-шрифти в реальних даних використовують усі: нашаровані вектори, градієнтні paint-графи, вбудовані 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 ним малює. Обидва шукають кодову точку в таблиці символів шрифту, останнього переданого в 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 strike, найближчий до 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
      // Немає кольорових даних: fallback на монохромний контур
      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-лігатури, тож їхнє складання — проблема шейпінгу того роду, що розібрана в статті про GSUB-альтернати OpenType, а не те, що ця точка входу робить за вас

COLR v0: нашаровані шари гліфів з палітровими кольорами

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

Як HotPDF перетворює paint-граф COLR v1 на PDF-оператори?

Спершу paint-таблиці розбираються в плаский обмежений граф, і лише потім кожен вузол мапиться на PDF-конструкцію. Гліф COLR v1 — не список шарів, а орієнтований ациклічний граф paint-записів, де вузли можуть ділитися через PaintColrLayers і PaintColrGlyph. Парсер обмежує його 4096 paint-вузлами, 64 рівнями глибини і 1024 колірними стопами і трекає кожен вузол як активний чи завершений, щоб посилання назад на активний вузол — цикл, який зловмисний шрифт може збудувати з повторного використання шарів, — відкидалося, а не рекурсувалося. Основи зсувів — місце, де перша реалізація помиляється. Зсуви BaseGlyphPaintRecord відносні до початку BaseGlyphList, paint-зсуви LayerList відносні до LayerList, а кожен Offset24 усередині paint-таблиці відносний до самої тієї paint-таблиці. Розв'язати всі три від однієї основи — і цілком легальні гліфи провалюють перевірку меж, що виглядає рівно як пошкоджений шрифт. Щойно граф збудовано, відображення пряме:

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

Межа явна. Режими Porter-Duff з 5 по 12 (src_in, xor, plus та решта) не мають PDF-аналога серед blend modes, режими розширення repeat і reflect на лінійних та радіальних градієнтах не випромінюються, а градієнти, чиї стопи несуть різні значення альфи, не фейкаться однією прозорістю. Радіальні градієнти з більш ніж двома стопами тримають лише перший і останній кольори. HotPDF звіряє весь граф із цією підтримуваною підмножиною, перш ніж написати хоч один оператор, тож непідтримуваний гліф лишає сторінку недоторканою і переходить до raster-фолбеку замість того, щоб лишити позаду половину малюнка

Діаграма конвертації COLR v1 у HotPDF: paint-граф розбирається в обмежений граф із стелею 4096 вузлів, 64 рівнів глибини і 1024 колірних стопів із відкиданням циклів, потім PaintGlyph стає кліпом режиму 7, лінійні та радіальні градієнти стають осьовими і радіальними шейдінгами, sweep-градієнти — 96 клинами, а режими PaintComposite з 13 по 27 — PDF blend modes
Весь граф звіряється з підтримуваною підмножиною до того, як написано перший оператор, тож непідтримуваний гліф лишає сторінку недоторканою і переходить до raster-фолбеку замість половини малюнка

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

SVG-гліфи йдуть крізь той самий обмежений білдер, який HotPDF використовує для імпортованих SVG-файлів, а результат реєструється як Form XObject (§8.10), рівно як описано в статті про SVG у Form XObject. Документ у таблиці SVG може бути стиснутий gzip; декомпресія йде шматками по 8 КБ і зупиняється щойно розгорнутий розмір мав би пройти 32 МБ, замість надувати спершу і перевіряти потім, а сам стиснутий вхід обмежений 8 МБ. Профіль обмежувальний навмисно: скрипти, вбудовані зображення, зовнішні URL, URI data: і нелокальні посилання падають закрито. Форма масштабується так, щоб її довший бік дорівнював розміру шрифту, і якориться на базовій лінії, що відображає систему координат SVG з y вниз на PDF-івську з y вгору. Майте на увазі: білдер отримує весь SVG-документ гліфа, без вибору елемента glyphNNN, тож шрифти, які пакують багато гліфів в один спільний документ, варто протестувати, перш ніж покладатися на них

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

Що буває, коли гліф неможливо намалювати нативно?

HotPDF піднімає OnColorGlyphRasterize і розміщує той RGBA-бітмап, який поверне ваш обробник; якщо нічого не присвоєно або обробник лишає Handled хибним, DrawRegisteredColorGlyph повертає False, і сторінка лишається без змін. Подія спрацьовує на графі COLR v1 поза підтримуваною підмножиною, на SVG-документі, який безпечний білдер відмовив, і на bitmap payload-і, який внутрішні декодери не читають. Обробник отримує формат, сирі байти шрифту, витягнутий asset (SVG-документ, можливо досі в gzip, або байти бітмапа; порожньо для COLR v1), glyph ID, палітру і цільовий розмір у пікселях

Діаграма raster-фолбеку HotPDF: OnColorGlyphRasterize спрацьовує на графі COLR v1 поза підтримуваною підмножиною, на SVG-документі, який відмовив безпечний білдер, чи на bitmap payload-і, який декодери не читають, передаючи формат, байти шрифту, asset, GlyphID, PaletteIndex і PixelSize, а повернений RGBA-буфер приймається лише коли його довжина — рівно Width помножити на Height помножити на 4
Нульові розміри, неправильна довжина буфера чи переповнені розмірності відкидаються до того, як сторінку торкнуто, а без обробника чи з хибним Handled виклик повертає 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-фолбек — все одно растр, тож emoji, відрендерений так, втрачає векторну різкість; просіть PixelSize, що відповідає вашій роздільності виводу. Поєднайте кольоровий шлях із перевірками покриття під час малювання з статті про трекінг відсутніх гліфів, і конвеєр, що обробляє довільний користувацький текст, зможе звітувати і відсутні гліфи, і гліфи, що втратили колір

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