Skip to content

Commit 69d8304

Browse files
matmenclaude
andcommitted
renderText: fallback fonts and color glyphs (COLR v0, CBDT)
- 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
1 parent 0923dda commit 69d8304

13 files changed

Lines changed: 531 additions & 61 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
Rotating a 1920×1080 photo by 30° takes 51 instead of 119 ms (61 instead of 114 ms with alpha). Opaque pixels stay
1818
within 1 of 3.2.1 (0.5-2% of the values differ), what shows of translucent ones (also the faded edges) within
1919
about 2.5 steps. `fast: false` keeps the precise sampling.
20+
- `Image.renderText` takes several fonts by priority (`[text, emoji]`): every character comes from the first font that
21+
has it, the first font sets the line height. Color glyphs keep their colors: COLR version 0 layers with CPAL
22+
palettes (e.g. Twemoji) and CBDT/CBLC bitmaps (e.g. Noto Color Emoji, which now loads at all, it has no outlines).
23+
Invisible characters like joiners and variation selectors take no room, as in browsers. Emoji sequences (skin tones,
24+
flags, families) still show as their parts.
2025

2126
### Performance
2227

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
3333
- [Rendering SVGs](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (shapes, paths, gradients, strokes, clip paths,
3434
masks, CSS; no text, images or filters)
3535
- [Rendering vector fonts](https://imagescript.matmen.dev/classes/Image.html#rendertext) (TrueType and OpenType, with kerning,
36-
outlines and TrueType hinting)
36+
outlines, TrueType hinting, fallback fonts and color emoji)
3737
- [Resizing](https://imagescript.matmen.dev/classes/Image.html#resize) (bilinear, bicubic, area averaging and nearest neighbor)
3838
- Image manipulation functions ([crop](https://imagescript.matmen.dev/classes/Image.html#crop)
3939
, [rotate](https://imagescript.matmen.dev/classes/Image.html#rotate)

‎src/image.ts‎

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2031,45 +2031,58 @@ export class Image {
20312031

20322032
/**
20332033
* Creates a new image containing the rendered text.
2034-
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use
2034+
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use, or several by priority: every character
2035+
* comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color
2036+
* glyphs (COLR version 0 layers and CBDT bitmaps, as in emoji fonts) keep their colors
20352037
* @param size Font size in pixels (per em)
20362038
* @param text Text to render
20372039
* @param options Color, layout and style, see {@link TextOptions}; the image grows by the outline width on every
20382040
* side
20392041
* @return The rendered text
20402042
*/
2041-
static renderText(font: BinaryData, size: number, text: string, options?: TextOptions): Promise<Image>;
2043+
static renderText(font: BinaryData | BinaryData[], size: number, text: string, options?: TextOptions): Promise<Image>;
20422044
/**
20432045
* Creates a new image containing the rendered text.
2044-
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use
2046+
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use, or several by priority: every character
2047+
* comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color
2048+
* glyphs (COLR version 0 layers and CBDT bitmaps, as in emoji fonts) keep their colors
20452049
* @param size Font size in pixels (per em)
20462050
* @param text Text to render
20472051
* @param [color=0xffffffff] Text color to use
20482052
* @param [layout] The text layout to use
20492053
* @param style Outline, see {@link TextStyle}; the image grows by the outline width on every side
20502054
* @return The rendered text
20512055
*/
2052-
static renderText(font: BinaryData, size: number, text: string, color?: Color, layout?: TextLayout, style?: TextStyle): Promise<Image>;
2053-
static async renderText(font: BinaryData, size: number, text: string, colorOrOptions: Color | TextOptions = 0xffffffff, layoutArgument?: TextLayout, styleArgument: TextStyle = {}): Promise<Image> {
2056+
static renderText(font: BinaryData | BinaryData[], size: number, text: string, color?: Color, layout?: TextLayout, style?: TextStyle): Promise<Image>;
2057+
static async renderText(font: BinaryData | BinaryData[], size: number, text: string, colorOrOptions: Color | TextOptions = 0xffffffff, layoutArgument?: TextLayout, styleArgument: TextStyle = {}): Promise<Image> {
20542058
const options: TextOptions = isOptions(colorOrOptions) ? colorOrOptions : { ...styleArgument, color: colorOrOptions, layout: layoutArgument };
20552059
const { color = 0xffffffff, layout = new TextLayout(), strokeWidth = 0, strokeColor = 0x000000ff, hinting = true } = options;
20562060
if (isNaN(strokeWidth) || strokeWidth < 0)
20572061
throw new RangeError('Invalid stroke width');
2062+
const fonts = Array.isArray(font) ? font : [font];
2063+
if (!fonts.length) throw new RangeError('No font given');
20582064

2059-
const rendered = textRenderer.render(view(font), size, text, layout, hinting);
20602065
const [r, g, b, a] = Image.colorToRGBA(color);
2066+
// color glyph layers in the text color take it without its alpha, which applies to the whole color glyph
2067+
const rendered = await textRenderer.render(fonts.map(view), size, text, layout, hinting, (r << 24 | g << 16 | b << 8 | 255) >>> 0);
20612068

20622069
// the outline needs room around the glyphs
20632070
const pad = Math.ceil(strokeWidth);
20642071
const width = rendered.width + 2 * pad, height = rendered.height + 2 * pad;
20652072
const coverage = new Uint8Array(width * height);
20662073
for (let y = 0; y < rendered.height; y++)
20672074
coverage.set(rendered.coverage.subarray(y * rendered.width, (y + 1) * rendered.width), (y + pad) * width + pad);
2075+
let colored: Uint8ClampedArray | null = null;
2076+
if (rendered.color) {
2077+
colored = new Uint8ClampedArray(width * height * 4);
2078+
for (let y = 0; y < rendered.height; y++)
2079+
colored.set(rendered.color.subarray(y * rendered.width * 4, (y + 1) * rendered.width * 4), ((y + pad) * width + pad) * 4);
2080+
}
20682081

20692082
const image = new Image(width, height);
20702083
const bitmap = image.bitmap;
20712084

2072-
if (!strokeWidth) {
2085+
if (!strokeWidth && !colored) {
20732086
for (let i = 0; i < coverage.length; i++) {
20742087
if (!coverage[i]) continue;
20752088
bitmap[i * 4] = r;
@@ -2080,19 +2093,38 @@ export class Image {
20802093
return image;
20812094
}
20822095

2083-
const stroke = outline(coverage, width, height, strokeWidth);
2096+
// the outline goes around the color glyphs as well
2097+
let shape = coverage;
2098+
if (colored && strokeWidth) {
2099+
shape = coverage.slice();
2100+
for (let i = 0; i < shape.length; i++) shape[i] = Math.max(shape[i], colored[i * 4 + 3]);
2101+
}
2102+
const stroke = strokeWidth ? outline(shape, width, height, strokeWidth) : null;
20842103
const [sr, sg, sb, sa] = Image.colorToRGBA(strokeColor);
20852104

2086-
// the glyphs drawn over their outline
2105+
// color glyphs over the text (premultiplied, with the text's alpha), both over their outline
20872106
for (let i = 0; i < coverage.length; i++) {
2088-
const af = coverage[i] / 255 * a / 255, as = stroke[i] * sa / 255;
2089-
const ao = af + as * (1 - af);
2090-
if (ao <= 0) continue;
2091-
2092-
bitmap[i * 4] = (r * af + sr * as * (1 - af)) / ao;
2093-
bitmap[i * 4 + 1] = (g * af + sg * as * (1 - af)) / ao;
2094-
bitmap[i * 4 + 2] = (b * af + sb * as * (1 - af)) / ao;
2095-
bitmap[i * 4 + 3] = ao * 255;
2107+
let af = coverage[i] / 255 * a / 255, pr = r * af, pg = g * af, pb = b * af;
2108+
if (colored && colored[i * 4 + 3]) {
2109+
const ca = colored[i * 4 + 3] / 255 * a / 255, keep = 1 - ca, f = a / 255;
2110+
pr = colored[i * 4] * f + pr * keep;
2111+
pg = colored[i * 4 + 1] * f + pg * keep;
2112+
pb = colored[i * 4 + 2] * f + pb * keep;
2113+
af = ca + af * keep;
2114+
}
2115+
if (stroke) {
2116+
const as = stroke[i] * sa / 255 * (1 - af);
2117+
pr += sr * as;
2118+
pg += sg * as;
2119+
pb += sb * as;
2120+
af += as;
2121+
}
2122+
if (af <= 0) continue;
2123+
2124+
bitmap[i * 4] = pr / af;
2125+
bitmap[i * 4 + 1] = pg / af;
2126+
bitmap[i * 4 + 2] = pb / af;
2127+
bitmap[i * 4 + 3] = af * 255;
20962128
}
20972129

20982130
return image;

‎src/render/color.ts‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// color glyphs: layers of outlines with palette colors (COLR version 0 with CPAL), and bitmaps (CBDT/CBLC, PNG)
2+
3+
import { Font } from './font.ts';
4+
5+
/** an outline glyph drawn in a color, RGBA as 0xrrggbbaa (null: the text color) */
6+
export interface ColorLayer {
7+
glyph: number;
8+
color: number | null;
9+
}
10+
11+
/** a glyph's PNG and where it goes, in pixels of its strike */
12+
export interface BitmapGlyph {
13+
png: Uint8Array;
14+
/** pixels per em of the strike */
15+
ppem: number;
16+
/** size of the bitmap */
17+
width: number;
18+
height: number;
19+
/** offset of the bitmap's left edge from the pen position */
20+
left: number;
21+
/** offset of the bitmap's top edge above the baseline */
22+
top: number;
23+
}
24+
25+
/**
26+
* The layers of the font's color glyphs (COLR version 0 records, with the colors of the first CPAL palette), null if the
27+
* font has none. The lookup returns null for glyphs without layers
28+
*/
29+
export function parseColorLayers(font: Font): ((glyph: number) => ColorLayer[] | null) | null {
30+
const colr = font.tables.COLR?.offset, cpal = font.tables.CPAL?.offset;
31+
if (colr === undefined || cpal === undefined) return null;
32+
33+
const baseCount = font.u16(colr + 2), bases = colr + font.u32(colr + 4), layers = colr + font.u32(colr + 8);
34+
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+
};
43+
44+
const cache = new Map<number, ColorLayer[] | null>();
45+
return glyph => {
46+
let result = cache.get(glyph);
47+
if (result !== undefined) return result;
48+
result = null;
49+
// base glyph records are sorted by glyph
50+
for (let lo = 0, hi = baseCount - 1; lo <= hi;) {
51+
const mid = lo + hi >> 1, record = bases + mid * 6, id = font.u16(record);
52+
if (id < glyph) lo = mid + 1;
53+
else if (id > glyph) hi = mid - 1;
54+
else {
55+
const start = font.u16(record + 2), count = font.u16(record + 4);
56+
result = [];
57+
for (let i = 0; i < count; i++) {
58+
const layer = layers + (start + i) * 4;
59+
result.push({ glyph: font.u16(layer), color: color(font.u16(layer + 2)) });
60+
}
61+
break;
62+
}
63+
}
64+
cache.set(glyph, result);
65+
return result;
66+
};
67+
}
68+
69+
/**
70+
* The font's bitmap glyphs (CBDT/CBLC with PNG images) from the strike closest to `size` (the smallest at least as
71+
* large, else the largest), null if the font has none. The lookup returns null for glyphs without a bitmap
72+
*/
73+
export function parseBitmaps(font: Font, size: number): ((glyph: number) => BitmapGlyph | null) | null {
74+
const cblc = font.tables.CBLC?.offset, cbdt = font.tables.CBDT?.offset;
75+
if (cblc === undefined || cbdt === undefined) return null;
76+
77+
const sizes = font.u32(cblc + 4);
78+
let strike = -1, strikePpem = 0;
79+
for (let i = 0; i < sizes; i++) {
80+
const ppem = font.u8(cblc + 8 + i * 48 + 45);
81+
const better = strike < 0 || (strikePpem < size ? ppem > strikePpem : ppem >= size && ppem < strikePpem);
82+
if (better) { strike = cblc + 8 + i * 48; strikePpem = ppem; }
83+
}
84+
if (strike < 0) return null;
85+
86+
const array = cblc + font.u32(strike), subtables = font.u32(strike + 8);
87+
88+
// where a glyph's data starts in CBDT, its image format, and its metrics if the index holds them (image format 19)
89+
const locate = (glyph: number): { offset: number, format: number, metrics: number } | null => {
90+
for (let i = 0; i < subtables; i++) {
91+
const entry = array + i * 8, firstGlyph = font.u16(entry), lastGlyph = font.u16(entry + 2);
92+
if (glyph < firstGlyph || glyph > lastGlyph) continue;
93+
94+
const header = array + font.u32(entry + 4);
95+
const indexFormat = font.u16(header), format = font.u16(header + 2), data = cbdt + font.u32(header + 4), k = glyph - firstGlyph;
96+
switch (indexFormat) {
97+
case 1: {
98+
const start = font.u32(header + 8 + k * 4), end = font.u32(header + 12 + k * 4);
99+
return end > start ? { offset: data + start, format, metrics: -1 } : null;
100+
}
101+
case 2: return { offset: data + k * font.u32(header + 8), format, metrics: header + 12 };
102+
case 3: {
103+
const start = font.u16(header + 8 + k * 2), end = font.u16(header + 10 + k * 2);
104+
return end > start ? { offset: data + start, format, metrics: -1 } : null;
105+
}
106+
case 4: {
107+
const count = font.u32(header + 8);
108+
for (let j = 0; j < count; j++) {
109+
const pair = header + 12 + j * 4;
110+
if (font.u16(pair) === glyph) return { offset: data + font.u16(pair + 2), format, metrics: -1 };
111+
}
112+
return null;
113+
}
114+
case 5: {
115+
const size = font.u32(header + 8), count = font.u32(header + 20);
116+
for (let j = 0; j < count; j++)
117+
if (font.u16(header + 24 + j * 2) === glyph) return { offset: data + j * size, format, metrics: header + 12 };
118+
return null;
119+
}
120+
default: return null;
121+
}
122+
}
123+
return null;
124+
};
125+
126+
const cache = new Map<number, BitmapGlyph | null>();
127+
return glyph => {
128+
let result = cache.get(glyph);
129+
if (result !== undefined) return result;
130+
result = null;
131+
132+
const location = locate(glyph);
133+
if (location) {
134+
const { format } = location;
135+
let o = location.offset, metrics = location.metrics;
136+
// small metrics (height, width, bearingX, bearingY, advance) and big ones start alike
137+
if (format === 17) { metrics = o; o += 5; }
138+
else if (format === 18) { metrics = o; o += 8; }
139+
if ((format === 17 || format === 18 || format === 19) && metrics >= 0) {
140+
const length = font.u32(o);
141+
result = { png: font.data.subarray(o + 4, o + 4 + length), ppem: strikePpem, width: font.u8(metrics + 1), height: font.u8(metrics), left: font.i8(metrics + 2), top: font.i8(metrics + 3) };
142+
}
143+
}
144+
145+
cache.set(glyph, result);
146+
return result;
147+
};
148+
}

‎src/render/font.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ export class Font {
4242
lineGap: number;
4343
numberOfHMetrics: number;
4444
numGlyphs: number;
45-
outlines: 'glyf' | 'cff';
45+
/** where the glyph outlines come from ('none' for fonts with only bitmap glyphs) */
46+
outlines: 'glyf' | 'cff' | 'none';
4647
cff: CFF | null = null;
4748
cmap: (codepoint: number) => number;
4849
outlineCache: Map<number, PathCommand[]>;
@@ -89,6 +90,7 @@ export class Font {
8990
this.outlines = 'cff';
9091
this.cff = parseCFF(this, this.tables['CFF '].offset);
9192
} else if (this.tables.CFF2) throw new Error('Font: CFF2 outlines are not supported');
93+
else if (this.tables.CBDT && this.tables.CBLC) this.outlines = 'none';
9294
else throw new Error('Font: no supported glyph outlines');
9395

9496
this.cmap = parseCmap(this, this.tables.cmap.offset);
@@ -221,7 +223,7 @@ export class Font {
221223
if (glyph >= this.numGlyphs) glyph = 0;
222224
let path = this.outlineCache.get(glyph);
223225
if (!path) {
224-
path = this.outlines === 'glyf' ? this.glyfOutline(glyph, 0) : this.cffOutline(glyph);
226+
path = this.outlines === 'glyf' ? this.glyfOutline(glyph, 0) : this.outlines === 'cff' ? this.cffOutline(glyph) : [];
225227
this.outlineCache.set(glyph, path);
226228
}
227229
return path;

0 commit comments

Comments
 (0)