Skip to content

renderText: fallback fonts and color emoji (COLR v0, CBDT) - #75

Merged
matmen merged 1 commit into
masterfrom
color-emoji
Oct 16, 2026
Merged

matmen merged 1 commit into
masterfrom
color-emoji

Conversation

@matmen

@matmen matmen commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Image.renderText now takes several fonts by priority and draws color glyphs, so emoji fonts can sit next to a text font:

const image = await Image.renderText([sourceSans, notoColorEmoji], 48, 'Hi 😀🔥 👍', { color: 0x000000ff });

This is the first of three steps. Emoji sequences (skin tones, flags, families, keycaps) need GSUB ligatures, which come next. COLR v1 comes after that.

Fallback fonts

  • Every character comes from the first font that has a glyph for it.
  • The first font sets the line height and baseline.
  • Kerning applies between glyphs of the same font.
  • Each glyph is placed on whole pixels only if its own font is hinted. Before this, the first font decided for every glyph.
  • A single font as before renders unchanged; all existing targets pass.

Color glyphs (src/render/color.ts)

  • COLR version 0 with CPAL (Twemoji, Segoe UI Emoji): each layer outline goes through the existing rasterizer and is filled with its palette color, from the first palette. Layers marked with the text color take the text's color.
  • CBDT/CBLC (Noto Color Emoji): index formats 1–5, image formats 17–19. The PNG comes from the strike closest to the size (the smallest one at least as large) and is resampled to the font size.
  • Fonts that contain only bitmaps, like Noto Color Emoji, can now be loaded at all. Before this they failed with "no supported glyph outlines".
  • Color glyphs render into a premultiplied RGBA layer above the text. They take the alpha of the text color, and a stroke goes around them too.
  • render() is now async, because PNG decoding is.

Invisible characters

Default ignorable code points take no room, even when a font has glyphs for them. This covers ZWJ, the variation selectors FE0E/FE0F and tag characters. HarfBuzz hides them the same way. Twemoji, for example, has wide empty glyphs for ZWJ and FE0F, which left gaps.

Tests

  • Subsets of Noto Color Emoji (73 kB, OFL) and Twemoji Mozilla (15 kB, CC-BY 4.0) are added under tests/fonts, with license notices.
  • New targets emoji-cbdt.png and emoji-colr.png.
  • New checks:
    • emoji keep their colors;
    • ZWJ and FE0F take no room;
    • characters fall back to the next font;
    • a bitmap-only font renders on its own.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ

- renderText takes a font or an array of fonts by priority: every
  character comes from the first font that has a glyph for it, the first
  font sets the line height. Kerning applies within a font, whole pixel
  placement follows each glyph's own font's hinting.
- src/render/color.ts parses COLR version 0 layers with the first CPAL
  palette, and CBDT/CBLC bitmaps (index formats 1-5, image formats 17-19)
  from the strike closest to the size. Fonts with only bitmaps load.
- Color glyphs render into a premultiplied RGBA layer over the text:
  layers through the rasterizer, PNGs decoded and resampled. The text
  color's alpha applies to them, layers in the text color take its color;
  outlines go around them too.
- Default ignorable code points (joiners, variation selectors, tags) take
  no room, as HarfBuzz hides them.
- Tests with subsets of Noto Color Emoji (OFL) and Twemoji Mozilla
  (CC-BY 4.0), license notices in tests/fonts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
@matmen
matmen merged commit 69d8304 into master Oct 16, 2026
13 checks passed
@matmen
matmen deleted the color-emoji branch October 16, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant