Skip to content

Commit 6003df5

Browse files
matmenclaude
andcommitted
renderText: COLR version 1 color glyphs
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
1 parent 3d28ae4 commit 6003df5

10 files changed

Lines changed: 674 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
glyph, and text fonts use their ligatures (fi, ffi, ...) and contextual alternates. Emoji sequences come from one
2727
font, the first that has all of their characters, and the emoji variation selector picks a color glyph (❤️ in an
2828
emoji font rather than the text font's ❤).
29+
- `Image.renderText` renders COLR version 1 color glyphs (e.g. Noto-COLRv1): solid colors, linear, radial and sweep
30+
gradients (pad, repeat, reflect), glyph outlines as masks, transforms and all composite modes; variable paints with
31+
their default values. They are rendered at twice the size and scaled down, so layers sharing an edge don't let the
32+
ones below show through. Fonts with both prefer them over their other color glyphs, like browsers.
2933
- Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are
3034
kept per font and size across calls.
3135

‎src/render/color.ts‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ export interface BitmapGlyph {
2222
top: number;
2323
}
2424

25+
/**
26+
* The colors of the font's first CPAL palette as RGBA (0xrrggbbaa), null for the text color (index 0xffff) and
27+
* indices outside the palette
28+
*/
29+
export function parsePalette(font: Font, cpal: number): (index: number) => number | null {
30+
const entries = font.u16(cpal + 2), records = cpal + font.u32(cpal + 8), first = font.u16(cpal + 12);
31+
return index => {
32+
if (index === 0xffff || index >= entries) return null;
33+
const o = records + (first + index) * 4; // blue, green, red, alpha
34+
return (font.u8(o + 2) << 24 | font.u8(o + 1) << 16 | font.u8(o) << 8 | font.u8(o + 3)) >>> 0;
35+
};
36+
}
37+
2538
/**
2639
* The layers of the font's color glyphs (COLR version 0 records, with the colors of the first CPAL palette), null if the
2740
* font has none. The lookup returns null for glyphs without layers
@@ -32,14 +45,7 @@ export function parseColorLayers(font: Font): ((glyph: number) => ColorLayer[] |
3245

3346
const baseCount = font.u16(colr + 2), bases = colr + font.u32(colr + 4), layers = colr + font.u32(colr + 8);
3447
if (!baseCount) return null;
35-
36-
// the first palette
37-
const entries = font.u16(cpal + 2), records = cpal + font.u32(cpal + 8), first = font.u16(cpal + 12);
38-
const color = (index: number): number | null => {
39-
if (index === 0xffff || index >= entries) return null;
40-
const o = records + (first + index) * 4; // blue, green, red, alpha
41-
return (font.u8(o + 2) << 24 | font.u8(o + 1) << 16 | font.u8(o) << 8 | font.u8(o + 3)) >>> 0;
42-
};
48+
const color = parsePalette(font, cpal);
4349

4450
const cache = new Map<number, ColorLayer[] | null>();
4551
return glyph => {

‎src/render/colr.ts‎

Lines changed: 543 additions & 0 deletions
Large diffs are not rendered by default.

‎src/render/text.ts‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Font, PathCommand } from './font.ts';
77
import { Hinter } from './hinting.ts';
88
import { ColorLayer, BitmapGlyph, parseColorLayers, parseBitmaps } from './color.ts';
99
import { GlyphBuffer, parseGSUB } from './gsub.ts';
10+
import { PaintGlyphs, parsePaintGlyphs, paintExtent, renderPaintGlyph } from './colr.ts';
1011
import { decode as decodePNG } from '../codecs/png.ts';
1112
import { resample, TRIANGLE } from '../util/resample.ts';
1213

@@ -46,6 +47,7 @@ interface SizedFont {
4647
hinter: Hinter | null;
4748
layers: ((glyph: number) => ColorLayer[] | null) | null;
4849
bitmaps: ((glyph: number) => BitmapGlyph | null) | null;
50+
paints: PaintGlyphs | null;
4951
gsub: ((buffer: GlyphBuffer) => void) | null;
5052
glyphs: Map<number, Glyph>;
5153
}
@@ -62,8 +64,8 @@ interface Glyph {
6264
ink: { left: number, right: number } | null;
6365
/** bitmaps per subpixel offset */
6466
bitmaps: Array<GlyphBitmap | null>;
65-
/** the layers or the bitmap of color glyphs */
66-
color: { layers: ColorLayer[] } | { bitmap: BitmapGlyph } | null;
67+
/** the paint graph (COLR version 1), the layers (version 0) or the bitmap of color glyphs */
68+
color: { paint: true } | { layers: ColorLayer[] } | { bitmap: BitmapGlyph } | null;
6769
/** color bitmaps by subpixel offset and text color (bitmap glyphs only have the one for offset 0) */
6870
colors: Map<string, ColorBitmap | null>;
6971
}
@@ -86,7 +88,7 @@ const SUBPIXELS = 4;
8688
// parsed fonts are cached per memory region, so new views of the same font data (e.g. from node Buffers) hit the cache
8789
const fonts = new WeakMap<ArrayBufferLike, Map<string, Font>>();
8890
const hinters = new WeakMap<Font, Hinter>();
89-
const substitutions = new WeakMap<Font, { gsub: ((buffer: GlyphBuffer) => void) | null, layers: ((glyph: number) => ColorLayer[] | null) | null }>();
91+
const substitutions = new WeakMap<Font, { gsub: ((buffer: GlyphBuffer) => void) | null, layers: ((glyph: number) => ColorLayer[] | null) | null, paints: PaintGlyphs | null }>();
9092
// sizes (and hinting) per font, a few of them
9193
const sizes = new WeakMap<Font, Map<string, SizedFont>>();
9294
const SIZES = 16;
@@ -116,10 +118,10 @@ function sizedFont(data: Uint8Array, size: number, hinting: boolean): SizedFont
116118
if (!hinter.hasInstructions) hinter = null;
117119
}
118120
let shared = substitutions.get(font);
119-
if (!shared) substitutions.set(font, shared = { gsub: parseGSUB(font), layers: parseColorLayers(font) });
121+
if (!shared) substitutions.set(font, shared = { gsub: parseGSUB(font), layers: parseColorLayers(font), paints: parsePaintGlyphs(font) });
120122

121123
if (bySize.size >= SIZES) bySize.clear();
122-
bySize.set(key, sized = { font, scale: size / font.unitsPerEm, hinter, layers: shared.layers, bitmaps: parseBitmaps(font, size), gsub: shared.gsub, glyphs: new Map() });
124+
bySize.set(key, sized = { font, scale: size / font.unitsPerEm, hinter, layers: shared.layers, bitmaps: parseBitmaps(font, size), paints: shared.paints, gsub: shared.gsub, glyphs: new Map() });
123125
return sized;
124126
}
125127

@@ -129,10 +131,16 @@ function glyphOf(f: SizedFont, index: number, size: number): Glyph {
129131
if (g) return g;
130132

131133
const { font, scale, hinter } = f;
132-
const bitmap = f.bitmaps?.(index) ?? null, layers = bitmap ? null : f.layers?.(index) ?? null;
134+
// renderers that support COLR version 1 prefer it over the other color glyphs
135+
const paint = f.paints !== null && f.paints.paint(index) !== null;
136+
const bitmap = paint ? null : f.bitmaps?.(index) ?? null, layers = paint || bitmap ? null : f.layers?.(index) ?? null;
133137
const advance = font.advance(index) * scale;
134138
g = { index, font: f, path: [], scale, advance: hinter ? Math.round(advance) : advance, ink: null, bitmaps: [], color: null, colors: new Map() };
135-
if (bitmap) {
139+
if (paint) {
140+
g.color = { paint: true };
141+
const extent = paintExtent(font, f.paints as PaintGlyphs, index, glyph => font.outline(glyph));
142+
g.ink = extent && { left: extent.left * scale, right: extent.right * scale };
143+
} else if (bitmap) {
136144
const s = size / bitmap.ppem;
137145
g.color = { bitmap };
138146
g.ink = { left: bitmap.left * s, right: (bitmap.left + bitmap.width) * s };
@@ -222,7 +230,8 @@ export async function render(fonts: Uint8Array[], size: number, text: string, la
222230
let bitmap = g.colors.get(key);
223231
if (bitmap === undefined) g.colors.set(key, bitmap = 'bitmap' in g.color
224232
? await scaledBitmap(g.color.bitmap, size / g.color.bitmap.ppem)
225-
: layeredGlyph(g, g.color.layers, subpixel / SUBPIXELS, foreground));
233+
: 'layers' in g.color ? layeredGlyph(g, g.color.layers, subpixel / SUBPIXELS, foreground)
234+
: paintedGlyph(g, subpixel / SUBPIXELS, foreground));
226235
if (bitmap) blitColor(color ??= new Uint8ClampedArray(width * height * 4), width, height, bitmap, pixel + bitmap.left, baseline - bitmap.top);
227236
continue;
228237
}
@@ -246,7 +255,7 @@ function shape(paragraph: string, count: number, font: (i: number) => SizedFont,
246255
const has = (f: SizedFont, codepoint: number): boolean => f.font.glyphIndex(codepoint) !== 0;
247256
const isColor = (f: SizedFont, codepoint: number): boolean => {
248257
const index = f.font.glyphIndex(codepoint);
249-
return !!(f.bitmaps?.(index) || f.layers?.(index));
258+
return !!(f.paints && f.paints.paint(index) !== null || f.bitmaps?.(index) || f.layers?.(index));
250259
};
251260

252261
// the font of a grapheme (later fonts are only loaded when the earlier ones miss a character)
@@ -373,6 +382,12 @@ function layeredGlyph(g: Glyph, layers: ColorLayer[], offset: number, foreground
373382
return { data, width, height, left, top };
374383
}
375384

385+
// a COLR version 1 glyph, its outlines rasterized like the others
386+
function paintedGlyph(g: Glyph, offset: number, foreground: number): ColorBitmap | null {
387+
const { font } = g.font;
388+
return renderPaintGlyph(font, g.font.paints as PaintGlyphs, g.index, g.scale, offset, foreground, glyph => font.outline(glyph), path => rasterizeGlyph(path, 1));
389+
}
390+
376391
function premultiply(rgba: Uint8ClampedArray): void {
377392
for (let i = 0; i < rgba.length; i += 4) {
378393
const a = rgba[i + 3] / 255;

‎tests/font.js‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
https://www.1001fonts.com/carbon-font.html
44
https://www.1001fonts.com/ethnocentric-font.html
55
https://github.com/adobe-fonts/source-sans (SIL OFL, see OFL-SourceSans3.md)
6-
https://github.com/googlefonts/noto-emoji (subset, SIL OFL, see OFL-NotoColorEmoji.md)
6+
https://github.com/googlefonts/noto-emoji (subsets of Noto Color Emoji and Noto-COLRv1, SIL OFL, see OFL-NotoColorEmoji.md)
7+
colrv1 paints.ttf is built by colrv1-paints.py
78
https://github.com/mozilla/twemoji-colr (subset, CC-BY 4.0, see CC-BY-Twemoji.md)
89
*/
910

@@ -155,9 +156,10 @@ const panic = message => {
155156
}
156157

157158
{
158-
// color glyphs from fallback fonts: CBDT bitmaps (Noto Color Emoji) and COLR layers (Twemoji), emoji sequences
159+
// color glyphs from fallback fonts: CBDT bitmaps (Noto Color Emoji), COLR layers (Twemoji) and paints (Noto-COLRv1),
160+
// emoji sequences
159161
const sans = await fs.readFile('./tests/fonts/sourcesans3 regular.otf');
160-
for (const [file, target] of [['noto color emoji subset.ttf', 'emoji-cbdt'], ['twemoji mozilla subset.ttf', 'emoji-colr']]) {
162+
for (const [file, target] of [['noto color emoji subset.ttf', 'emoji-cbdt'], ['twemoji mozilla subset.ttf', 'emoji-colr'], ['noto colrv1 subset.ttf', 'emoji-colrv1']]) {
161163
const emoji = await fs.readFile(`./tests/fonts/${file}`);
162164
const image = await Image.renderText([sans, emoji], 48, 'Hi 😀🔥 👍🏽 🇩🇪 1️⃣ ❤️ 👨‍👩‍👧', {color: 0x000000ff});
163165
const encoded = await image.encode(1, {creationTime: 0, software: ''});
@@ -195,4 +197,23 @@ const panic = message => {
195197
const bitmapOnly = await Image.renderText(await fs.readFile('./tests/fonts/noto color emoji subset.ttf'), 32, '🔥');
196198
if (bitmapOnly.width < 20) panic('bitmap font should render on its own');
197199
}
200+
201+
{
202+
// every COLR version 1 paint feature: gradients, transforms, composite modes, the text color
203+
const font = await fs.readFile('./tests/fonts/colrv1 paints.ttf');
204+
const glyphs = Array.from({length: 17}, (_, k) => String.fromCodePoint(0xe000 + k)).join('');
205+
const image = await Image.renderText(font, 48, glyphs, {color: 0x00aaaaff});
206+
const encoded = await image.encode(1, {creationTime: 0, software: ''});
207+
if (process.env.OVERWRITE_TEST)
208+
await fs.writeFile('./tests/targets/colrv1-paints.png', encoded);
209+
if (!await samePixels(await fs.readFile('./tests/targets/colrv1-paints.png'), encoded)) panic('colrv1-paints doesn\'t match');
210+
211+
// the linear gradient runs from red (left) to blue (right), the last glyph's square is in the text color
212+
const linear = await Image.renderText(font, 100, '\ue000');
213+
const [lr, , lb] = linear.getRGBAAt(Math.round(linear.width * 0.15), linear.height >> 1), [rr, , rb] = linear.getRGBAAt(Math.round(linear.width * 0.85), linear.height >> 1);
214+
if (!(lr > 150 && lb < 100 && rb > 150 && rr < 100)) panic(`colrv1: linear gradient should go from red to blue (${lr},${lb} -> ${rr},${rb})`);
215+
const foreground = await Image.renderText(font, 100, '\ue010', {color: 0x00aaaaff});
216+
const [fr, fg, fb, fa] = foreground.getRGBAAt(Math.round(foreground.width * 0.12), foreground.height >> 1);
217+
if (fr > 2 || Math.abs(fg - 0xaa) > 2 || Math.abs(fb - 0xaa) > 2 || fa < 253) panic(`colrv1: layers in the text color should take it (${fr},${fg},${fb},${fa})`);
218+
}
198219
})();

‎tests/fonts/colrv1 paints.ttf‎

1.63 KB
Binary file not shown.

‎tests/fonts/colrv1-paints.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# builds "colrv1 paints.ttf" (python colrv1-paints.py "colrv1 paints.ttf", needs fontTools): a COLRv1 test font with one
2+
# glyph per paint feature, on U+E000 onwards
3+
import sys
4+
from fontTools.fontBuilder import FontBuilder
5+
from fontTools.pens.ttGlyphPen import TTGlyphPen
6+
from fontTools.colorLib.builder import buildCOLR, buildCPAL
7+
from fontTools.ttLib.tables.otTables import PaintFormat as F, ExtendMode, CompositeMode
8+
9+
UPM = 1000
10+
def rect(x0, y0, x1, y1):
11+
pen = TTGlyphPen(None)
12+
pen.moveTo((x0, y0)); pen.lineTo((x0, y1)); pen.lineTo((x1, y1)); pen.lineTo((x1, y0)); pen.closePath()
13+
return pen.glyph()
14+
def circle(cx, cy, r):
15+
import math
16+
pen = TTGlyphPen(None)
17+
n = 16
18+
pts = [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)]
19+
pen.moveTo(pts[0])
20+
for k in range(1, n): pen.lineTo(pts[k])
21+
pen.closePath()
22+
return pen.glyph()
23+
24+
names = ['.notdef', 'square', 'disc', 'bar']
25+
outlines = {'.notdef': rect(100, 0, 900, 800), 'square': rect(100, 0, 900, 800), 'disc': circle(500, 400, 350), 'bar': rect(450, 0, 550, 800)}
26+
features = ['linear', 'linear_p2', 'radial', 'sweep', 'repeat', 'reflect', 'rotate', 'skew', 'transform', 'scale_center', 'colrglyph', 'var_solid', 'multiply', 'src_in', 'xor', 'hue', 'foreground']
27+
for f in features: names.append(f); outlines[f] = rect(0, 0, 0, 0) if False else TTGlyphPen(None).glyph()
28+
29+
palette = [(1, 0, 0, 1), (0, 0.6, 0, 1), (0, 0, 1, 1), (1, 0.8, 0, 1), (0.5, 0, 0.5, 0.6)]
30+
def line(stops, extend=ExtendMode.PAD):
31+
return {'Extend': extend, 'ColorStop': [{'StopOffset': o, 'PaletteIndex': i, 'Alpha': 1.0} for o, i in stops]}
32+
rgb = [(0, 0), (0.5, 1), (1, 2)]
33+
glyph = lambda name, paint: {'Format': F.PaintGlyph, 'Glyph': name, 'Paint': paint}
34+
solid = lambda i, a=1.0: {'Format': F.PaintSolid, 'PaletteIndex': i, 'Alpha': a}
35+
paints = {
36+
'linear': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line(rgb), 'x0': 100, 'y0': 0, 'x1': 900, 'y1': 0, 'x2': 100, 'y2': 800}),
37+
'linear_p2': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line(rgb), 'x0': 100, 'y0': 0, 'x1': 900, 'y1': 0, 'x2': 900, 'y2': 800}),
38+
'radial': glyph('square', {'Format': F.PaintRadialGradient, 'ColorLine': line(rgb), 'x0': 500, 'y0': 400, 'r0': 50, 'x1': 500, 'y1': 400, 'r1': 400}),
39+
'sweep': glyph('disc', {'Format': F.PaintSweepGradient, 'ColorLine': line([(0, 0), (0.33, 1), (0.66, 2), (1, 0)]), 'centerX': 500, 'centerY': 400, 'startAngle': 0, 'endAngle': 360}),
40+
'repeat': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line([(0, 0), (1, 3)], ExtendMode.REPEAT), 'x0': 400, 'y0': 0, 'x1': 600, 'y1': 0, 'x2': 400, 'y2': 800}),
41+
'reflect': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line([(0, 0), (1, 3)], ExtendMode.REFLECT), 'x0': 400, 'y0': 0, 'x1': 600, 'y1': 0, 'x2': 400, 'y2': 800}),
42+
'rotate': {'Format': F.PaintRotateAroundCenter, 'angle': 30, 'centerX': 500, 'centerY': 400, 'Paint': glyph('bar', solid(2))},
43+
'skew': {'Format': F.PaintSkewAroundCenter, 'xSkewAngle': 20, 'ySkewAngle': 0, 'centerX': 500, 'centerY': 400, 'Paint': glyph('square', solid(1))},
44+
'transform': {'Format': F.PaintTransform, 'Transform': {'xx': 0.5, 'yx': 0, 'xy': 0, 'yy': 0.5, 'dx': 250, 'dy': 200}, 'Paint': glyph('square', solid(0))},
45+
'scale_center': {'Format': F.PaintScaleAroundCenter, 'scaleX': 0.5, 'scaleY': 1, 'centerX': 500, 'centerY': 400, 'Paint': glyph('disc', solid(3))},
46+
'colrglyph': {'Format': F.PaintColrLayers, 'Layers': [glyph('square', solid(3)), {'Format': F.PaintColrGlyph, 'Glyph': 'rotate'}]},
47+
'var_solid': glyph('disc', {'Format': F.PaintVarSolid, 'PaletteIndex': 0, 'Alpha': 0.5, 'VarIndexBase': 0xFFFFFFFF}),
48+
'multiply': {'Format': F.PaintComposite, 'CompositeMode': CompositeMode.MULTIPLY, 'SourcePaint': glyph('disc', solid(3)), 'BackdropPaint': glyph('square', solid(2))},
49+
'src_in': {'Format': F.PaintComposite, 'CompositeMode': CompositeMode.SRC_IN, 'SourcePaint': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line(rgb), 'x0': 100, 'y0': 0, 'x1': 900, 'y1': 0, 'x2': 100, 'y2': 800}), 'BackdropPaint': glyph('disc', solid(0))},
50+
'xor': {'Format': F.PaintComposite, 'CompositeMode': CompositeMode.XOR, 'SourcePaint': glyph('disc', solid(0)), 'BackdropPaint': glyph('square', solid(2))},
51+
'hue': {'Format': F.PaintComposite, 'CompositeMode': CompositeMode.HSL_HUE, 'SourcePaint': glyph('disc', solid(1)), 'BackdropPaint': glyph('square', solid(0))},
52+
'foreground': {'Format': F.PaintColrLayers, 'Layers': [glyph('square', solid(0xFFFF)), glyph('disc', solid(4))]},
53+
}
54+
fb = FontBuilder(UPM, isTTF=True)
55+
fb.setupGlyphOrder(names)
56+
fb.setupCharacterMap({0xE000 + k: f for k, f in enumerate(features)})
57+
fb.setupGlyf(outlines)
58+
fb.setupHorizontalMetrics({n: (1000, 0) for n in names})
59+
fb.setupHorizontalHeader(ascent=900, descent=-100)
60+
fb.setupNameTable({'familyName': 'COLRv1 Test', 'styleName': 'Regular'})
61+
fb.setupOS2(); fb.setupPost()
62+
fb.font['COLR'] = buildCOLR(paints, glyphMap=fb.font.getReverseGlyphMap(), clipBoxes={f: (0, -100, 1000, 900) for f in features})
63+
fb.font['CPAL'] = buildCPAL([palette])
64+
fb.save(sys.argv[1])
65+
print('features', features)

‎tests/fonts/noto colrv1 subset.ttf‎

24.2 KB
Binary file not shown.

‎tests/targets/colrv1-paints.png‎

13 KB
Loading

‎tests/targets/emoji-colrv1.png‎

18.8 KB
Loading

0 commit comments

Comments
 (0)