Skip to content

Commit 2aafe8e

Browse files
matmenclaude
andcommitted
renderText: GSUB shaping, emoji sequences, lazy fallbacks, glyph cache
- src/render/gsub.ts applies the default features (ccmp, rlig, liga, clig, calt): single, multiple, ligature, contextual and chained contextual substitutions (formats 1-3) and extension lookups, with lookup flags from GDEF and nested lookups. A memo per lookup skips glyphs no subtable starts at. - Paragraphs are shaped: graphemes (extending characters, joined sequences, regional indicator pairs) go to the first font that has all of their visible characters, preferring color glyphs before FE0F and others before FE0E; each font's run is substituted. Invisible characters that weren't substituted show nothing. - Fallback fonts load when the text first needs them; glyphs, including decoded and scaled emoji bitmaps, are kept per font, size and hinting across calls (a few sizes per font). - Tests: skin tones, flags, keycaps and families are single glyphs, ❤️ picks the emoji font's red heart; emoji targets regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent 69d8304 commit 2aafe8e

8 files changed

Lines changed: 553 additions & 78 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,14 @@
2020
- `Image.renderText` takes several fonts by priority (`[text, emoji]`): every character comes from the first font that
2121
has it, the first font sets the line height. Color glyphs keep their colors: COLR version 0 layers with CPAL
2222
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.
23+
Invisible characters like joiners and variation selectors take no room, as in browsers.
24+
- `Image.renderText` applies the fonts' glyph substitutions (GSUB) that browsers apply by default (`ccmp`, `rlig`,
25+
`liga`, `clig`, `calt`): emoji sequences (skin tones, flags, keycaps, families and other joined ones) become one
26+
glyph, and text fonts use their ligatures (fi, ffi, ...) and contextual alternates. Emoji sequences come from one
27+
font, the first that has all of their characters, and the emoji variation selector picks a color glyph (❤️ in an
28+
emoji font rather than the text font's ❤).
29+
- Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are
30+
kept per font and size across calls.
2531

2632
### Performance
2733

‎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, TrueType hinting, fallback fonts and color emoji)
36+
ligatures, 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/render/font.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ function parsePairPos(font: Font, table: number): PairLookup | null {
598598
}
599599

600600
// returns the coverage index of a glyph, -1 if it isn't covered
601-
function parseCoverage(font: Font, table: number): (glyph: number) => number {
601+
export function parseCoverage(font: Font, table: number): (glyph: number) => number {
602602
const format = font.u16(table), count = font.u16(table + 2);
603603

604604
return glyph => {
@@ -621,7 +621,7 @@ function parseCoverage(font: Font, table: number): (glyph: number) => number {
621621
};
622622
}
623623

624-
function parseClassDef(font: Font, table: number): (glyph: number) => number {
624+
export function parseClassDef(font: Font, table: number): (glyph: number) => number {
625625
const format = font.u16(table);
626626

627627
if (format === 1) {

‎src/render/gsub.ts‎

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

‎src/render/text.ts‎

Lines changed: 170 additions & 71 deletions
Large diffs are not rendered by default.

‎tests/font.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,11 @@ const panic = message => {
155155
}
156156

157157
{
158-
// color glyphs from fallback fonts: CBDT bitmaps (Noto Color Emoji) and COLR layers (Twemoji)
158+
// color glyphs from fallback fonts: CBDT bitmaps (Noto Color Emoji) and COLR layers (Twemoji), emoji sequences
159159
const sans = await fs.readFile('./tests/fonts/sourcesans3 regular.otf');
160160
for (const [file, target] of [['noto color emoji subset.ttf', 'emoji-cbdt'], ['twemoji mozilla subset.ttf', 'emoji-colr']]) {
161161
const emoji = await fs.readFile(`./tests/fonts/${file}`);
162-
const image = await Image.renderText([sans, emoji], 48, 'Hi 😀🔥 👍', {color: 0x000000ff});
162+
const image = await Image.renderText([sans, emoji], 48, 'Hi 😀🔥 👍🏽 🇩🇪 1️⃣ ❤️ 👨‍👩‍👧', {color: 0x000000ff});
163163
const encoded = await image.encode(1, {creationTime: 0, software: ''});
164164
if (process.env.OVERWRITE_TEST)
165165
await fs.writeFile(`./tests/targets/${target}.png`, encoded);
@@ -174,6 +174,17 @@ const panic = message => {
174174
const plain = await Image.renderText([sans, emoji], 48, '😀😀'), joined = await Image.renderText([sans, emoji], 48, '😀\u200d\ufe0f😀');
175175
if (plain.width !== joined.width) panic(`${file}: invisible characters should take no room (${plain.width} vs ${joined.width})`);
176176

177+
// sequences are one glyph (GSUB ligatures): skin tones, flags, keycaps, joined ones
178+
const one = (await Image.renderText(emoji, 48, '👍')).width;
179+
for (const sequence of ['👍🏽', '🇩🇪', '1️⃣', '👨‍👩‍👧'])
180+
if ((await Image.renderText([sans, emoji], 48, sequence)).width > one * 1.2) panic(`${file}: ${sequence} should be a single glyph`);
181+
182+
// the emoji variation selector picks the color glyph over the text font's heart
183+
const heart = await Image.renderText([sans, emoji], 48, '❤️', {color: 0x000000ff});
184+
let red = 0;
185+
for (let i = 0; i < heart.bitmap.length; i += 4) if (heart.bitmap[i + 3] > 128 && heart.bitmap[i] > 150 && heart.bitmap[i + 1] < 100) red++;
186+
if (red < 100) panic(`${file}: ❤️ should be the red emoji`);
187+
177188
// characters the emoji font doesn't have come from the next font (the first one still sets the line height)
178189
const fallback = await Image.renderText([emoji, sans], 48, 'Hi'), direct = await Image.renderText(sans, 48, 'Hi');
179190
const ink = image => image.bitmap.reduce((sum, v, i) => (i & 3) === 3 ? sum + v : sum, 0);

‎tests/targets/emoji-cbdt.png‎

9.69 KB
Loading

‎tests/targets/emoji-colr.png‎

5.42 KB
Loading

0 commit comments

Comments
 (0)