Skip to content

renderText: COLR v1 and sbix color glyphs, robustness against damaged fonts - #79

Merged
matmen merged 4 commits into
masterfrom
colr-v1
Oct 16, 2026
Merged

matmen merged 4 commits into
masterfrom
colr-v1

Conversation

@matmen

@matmen matmen commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

This is step 3 of color emoji: COLR version 1 support, plus sbix. Testing with many fonts and every Unicode emoji turned up robustness fixes for damaged fonts, which are included here too.

COLR version 1 (src/render/colr.ts)

  • Coverage: all 32 paint formats are supported. Variable paints use their default values.
    • Gradients: linear (including p2), two-point conical, and sweep (with the angle bias), each with pad, repeat and reflect.
    • Glyph masks go through the text rasterizer, clipped to the canvas.
    • All 28 composite modes are implemented.
    • The text color is used for palette index 0xFFFF.
  • 4× supersampling: without it, layers that share an edge let the layers below show through (dark seams on Noto's flags).
    • The supersampling factor drops for very large glyphs.
    • The canvas stays within two ems of the em box.
  • Speed: each layer only stores its own box, and a glyph filled with a solid color is written directly as mask × color. A new emoji takes about 5.5 ms; after that it comes from the cache.
  • Preference: COLR v1 is used before bitmaps and v0 layers, as in browsers.

Also in this PR

  • sbix (Apple): PNG glyphs including dupe records. Placement follows the origin offset, and the image size is read from the PNG header.
  • Normalization: combining sequences are composed where the font has the composed character (e + ◌́ → é, as in text from macOS). A character the fonts lack is decomposed where one has its parts, like HarfBuzz does.
  • Empty text: renderText(''), spaces only, or only invisible characters used to throw (already in 3.2.1). It now returns a transparent image 1 px wide.

Robustness against damaged fonts

1,800 fuzzed fonts (truncated, or with random bytes changed): previously one ran out of memory; now every case either renders or throws an error starting with "Font:".

  • Contour end points: a simple glyph whose contour end points don't increase used to spread its ranges over the whole point range: 3,329 contours meant about 100 million path commands. Such glyphs are now empty, as in FreeType. This was already the case in 3.2.1.
  • Composite glyphs: they can no longer contain themselves, and an outline has a budget of 100,000 commands. The hinter has the same cycle protection plus a limit of 65,536 points.
  • Outside the bitmap: segments crossing the edge are split instead of having their endpoints clamped. The part inside keeps its slope, so outlines clipped to the canvas render correctly.
  • Errors and limits: a RangeError from reading font data becomes "Font: damaged or truncated font data", with the original as cause. The line height is at least 1 px, and text larger than 2²⁸ pixels throws.

Testing

  • Shaping against HarfBuzz (uharfbuzz 0.51): all 5,260 sequences from emoji-test.txt (Unicode 18, every qualification level) plus text samples.
    • The emoji fonts produce identical glyph IDs: Noto CBDT, Noto COLRv1, Twemoji Mozilla, and OpenMoji (CBDT and COLRv1).
    • The text fonts (Source Sans, Open Sans, Carbon, Ethnocentric, big.ttf) are identical too. The only earlier difference was composing combining sequences, which this PR adds.
  • Rendering: every fully-qualified emoji was rendered with 7 fonts and formats: Noto CBDT and COLRv1, Twemoji, and OpenMoji in CBDT, sbix, COLRv0 and COLRv1.
    • There were 0 exceptions, and every emoji the font has all characters for produced visible pixels.
    • sbix and CBDT come out pixel-identical. Vector and bitmap versions of the same design differ by about 1–2 steps on average.
  • Combinations: I also checked the following on a contact sheet by eye.
    • Fallback order, FE0E/FE0F, unknown ZWJ sequences, invalid and lone flags, standalone skin-tone modifiers.
    • Keycaps without FE0F, NFD, ligatures, wrapping (word and char, left, center, right), stroke with translucent text.
    • Hinting off, sizes from 8 to 256 px, empty text, newlines.
  • Fuzzing: the 1,800 damaged fonts described above.
  • Repo tests:
    • tests/fonts/sbix.py builds sbix.ttf, a PNG with an origin offset plus a dupe.
    • New tests cover empty text and NFC/NFD.
    • tests/regressions.js covers the damaged-font cases. The damaged Noto subset from the fuzzing is checked in as a fixture; with the old parser it runs out of memory.

Known limits

Hebrew and Arabic (right-to-left, Arabic shaping) and the positioning of combining marks (GPOS mark) are still not supported, as before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ

matmen and others added 4 commits October 16, 2026 20:47
src/render/colr.ts renders the paint graph of COLR version 1 base glyphs
(BaseGlyphList, LayerList, ClipList) into premultiplied float RGBA:
- all 32 paint formats, variable ones with their default values
- linear (with the p2 rotation), two point conical radial and sweep
  gradients (biased angles), with pad, repeat and reflect
- PaintGlyph masks through the text rasterizer, after the transform
- all 28 composite modes (Porter-Duff, separable and HSL blends)
- the text color for palette index 0xffff
Layers carry the box they drew in, so compositing stays within it, and
source over has its own loop. Glyphs render at 2x and are averaged down:
layers sharing an edge would otherwise each be antialiased there and the
ones below would show through (dark seams on Noto's emoji).

COLR version 1 glyphs win over bitmaps and version 0 layers. The CPAL
palette reader is shared with version 0.

Tests: a Noto-COLRv1 subset (OFL) in the emoji tests, and a font built
with fontTools (tests/fonts/colrv1-paints.py) with one glyph per paint
feature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
At 2x, the seams where layers share an edge (Noto's flags) still showed;
at 4x they look like the bitmap version. To pay for it:
- layers hold only their box instead of the whole canvas (at 4x a 64px
  emoji's canvas is about 1 MB per layer)
- a glyph over a solid color, the most common paint, is the mask in that
  color without rendering and masking a filled layer
- gradients write from the color table directly, without a closure and a
  subarray per pixel
A new emoji glyph at 40-78px takes about 5.5 ms (10 ms before these, at
4x); output differs only by rounding (3 values of one pixel at alpha 8).
COLRv1 targets regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
- damaged glyf data: non-increasing contour end points leave the glyph
  empty, composites can't contain themselves and have a command budget,
  the hinter likewise (cycles, at most 65536 points)
- COLR v1 masks rasterize within the canvas only (the rasterizer splits
  segments at the bitmap edges instead of clamping their end points), the
  canvas stays within two ems of the em box and lowers its supersampling
  for very large glyphs
- RangeErrors from reading damaged font data become "Font: damaged or
  truncated font data"; text over 2^28 pixels throws; text without ink
  (empty, spaces) renders a transparent pixel wide instead of throwing
- graphemes are composed (NFC) or decomposed (NFD) where that makes a
  font have them, like HarfBuzz
- sbix color bitmaps (PNG, dupe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
- tests/fonts/sbix.py builds sbix.ttf (a PNG with an origin offset, and
  a 'dupe' of it); the bitmap and its placement are checked
- empty text renders a pixel wide, combining sequences like the composed
  characters
- damaged fonts (truncated, bytes changed, and the damaged Noto-COLRv1
  subset that ran out of memory before) render or throw a font error
  within 3 seconds
- changelog entries for sbix, normalization and the robustness fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
@matmen matmen changed the title renderText: COLR version 1 color glyphs renderText: COLR v1 and sbix color glyphs, robustness against damaged fonts Oct 16, 2026
@matmen
matmen merged commit 0fdc98e into master Oct 16, 2026
13 checks passed
@matmen
matmen deleted the colr-v1 branch October 16, 2026 21:46
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