Skip to content

Commit f916304

Browse files
matmenclaude
andcommitted
Variable fonts in renderText and renderSVG
renderText: TextStyle.variations picks a variable font's instance by axis names (weight, width, slant, italic, opticalSize) or tags (custom axes like GRAD), clamped to the axes (typos throw a RangeError); the optical size is the font size unless given, like browsers' font-optical-sizing: auto. Hinting uses cvar's control values. renderSVG: like usvg, font-weight, font-stretch (unless 100%) and font-style (ital 1, oblique slnt -12) map to the axes, opsz is the font size with font-optical-sizing: auto, font-variation-settings overrides them; font matching takes a variable font's weight and stretch ranges like several faces (CSS Fonts 4), fallback faces get the span's instance too. resvg's 14 font-variation-settings tests pass and are in tests/svgs (Roboto Flex subset to their characters by scripts/resvg-test-fonts.py). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsU2GAqbnFSNaYDBGD1Km2
1 parent b9f7596 commit f916304

39 files changed

Lines changed: 402 additions & 26 deletions

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
## 3.12.0
44

5+
### New
6+
7+
- Variable fonts: `renderText()` renders the instance `TextStyle.variations` asks for, by axis names (`weight`,
8+
`width`, `slant`, `italic`, `opticalSize`) or tags (like Roboto Flex's `GRAD`), clamped to the axes' ranges; the
9+
optical size is the font size unless given, like browsers' `font-optical-sizing: auto`. `renderSVG()` maps
10+
`font-weight`, `font-stretch` and `font-style` to the axes like browsers and resvg do, with `font-optical-sizing`
11+
and `font-variation-settings`, and matches a variable font's ranges of weights and stretches like several faces.
12+
TrueType outlines (gvar, with the interpolation of untouched points), advances (HVAR, else gvar's phantom points),
13+
metrics (MVAR), GPOS adjustments and anchors (variation indices), feature variations and hinting (cvar) vary;
14+
CFF2 outlines aren't supported yet. Outlines and advances are the same as fontTools' and HarfBuzz's, shaping is
15+
within HarfBuzz's rounding, and all 14 of resvg's `font-variation-settings` tests pass.
16+
517
### Changes
618

719
- AVIFs without an ICC profile whose nclx box (or sequence header) gives other color primaries than sRGB's (Display

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ and encoded as GIF, APNG or WebP, all at once or
5656
P3, Adobe RGB, ...) are converted to sRGB when decoding, so images look the way browsers show them.
5757

5858
- [Text rendering](https://imagescript.matmen.dev/classes/Image.html#rendertext) with TrueType and OpenType fonts:
59-
all scripts, color emoji
59+
all scripts, color emoji, variable fonts
6060
- [SVG rendering](https://imagescript.matmen.dev/classes/Image.html#rendersvg), including text (with fonts you pass
6161
or fonts embedded in the SVG)
6262
- [Resizing](https://imagescript.matmen.dev/classes/Image.html#resize),

‎scripts/resvg-test-fonts.py‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
# Writes tests/fonts/resvg: resvg's test fonts (github.com/linebender/resvg, crates/resvg/tests/fonts, with their
22
# licenses) subset to the characters of the test cases in tests/svgs/resvg and the ones their letters and
3-
# marks compose to (the shaper composes them where a font has them), with all names and layout features. Its small
4-
# subsets (emoji, CFF and sbix) are copied as they are, Roboto Flex (variable fonts aren't supported) left out. Needs
5-
# fontTools:
3+
# marks compose to (the shaper composes them where a font has them), with all names and layout features (and the
4+
# variations of Roboto Flex). Its other small subsets (emoji, CFF and sbix) are copied as they are. Needs fontTools:
65
# RESVG_FONTS=path/to/resvg/crates/resvg/tests/fonts python scripts/resvg-test-fonts.py
7-
import os, shutil, unicodedata
6+
import os, re, shutil, unicodedata
87
from pathlib import Path
98
from fontTools import subset
109
from fontTools.ttLib import TTFont
@@ -19,19 +18,23 @@
1918
content = case.read_text(encoding='utf-8')
2019
if '<text' in content:
2120
used |= set(content)
21+
# (Roboto Flex, variable with many axes, only the characters of the cases that use it)
22+
flex = set()
23+
for case in (root / 'tests' / 'svgs' / 'resvg').rglob('*.svg'):
24+
content = case.read_text(encoding='utf-8')
25+
if 'Roboto Flex' in content:
26+
flex |= set(''.join(re.findall(r'>([^<]*)</text>', content)))
2227
marks = [c for c in used if unicodedata.combining(c)]
2328
composed = {unicodedata.normalize('NFC', b + m) for b in used for m in marks}
2429
characters = sorted(ord(c) for c in used | {c for c in composed if len(c) == 1})
2530

2631
for path in sorted(source.iterdir()):
2732
name = path.name
28-
if name.startswith('RobotoFlex'):
29-
continue
3033
if path.suffix not in ('.ttf', '.otf'):
3134
if 'LICENSE' in name or 'OFL' in name:
3235
shutil.copy(path, target / name)
3336
continue
34-
if '.subset.' in name or name.startswith('CFF-and-SBIX'):
37+
if ('.subset.' in name and not name.startswith('RobotoFlex')) or name.startswith('CFF-and-SBIX'):
3538
shutil.copy(path, target / name)
3639
continue
3740
options = subset.Options()
@@ -44,6 +47,6 @@
4447
options.legacy_kern = True
4548
font = TTFont(path)
4649
subsetter = subset.Subsetter(options)
47-
subsetter.populate(unicodes=characters)
50+
subsetter.populate(unicodes=sorted(ord(c) for c in flex) if name.startswith('RobotoFlex') else characters)
4851
subsetter.subset(font)
4952
font.save(target / name)

‎src/image.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,15 @@ export interface TextStyle {
292292
* `kern`), a number picks an alternate (`salt: 2`). Fonts without a feature ignore it. Default: `{}`
293293
*/
294294
features?: Record<string, boolean | number>;
295+
/**
296+
* The instance of variable fonts, by their axes: `weight` (1-1000, like CSS's `font-weight`: 400 regular, 700
297+
* bold), `width` (percent of the normal width: 75 condensed, 125 expanded), `slant` (degrees, negative leans
298+
* right), `italic` (`true`/`false`), `opticalSize` (the size the glyphs are designed for, in pixels; default: the
299+
* font size, like browsers' `font-optical-sizing: auto`), and other axes by their 4-character tag (like Roboto
300+
* Flex's `GRAD: 50`). Values outside an axis's range are clamped, axes a font doesn't have are ignored, fonts
301+
* without axes aren't affected. Default: `{}` (the fonts' default instances)
302+
*/
303+
variations?: Record<string, number | boolean>;
295304
}
296305

297306
/** Options of {@link Image.cropCircle} */
@@ -2429,7 +2438,8 @@ export class Image {
24292438
static renderText(font: BinaryData | BinaryData[], size: number, text: string, color?: Color, layout?: TextLayout, style?: TextStyle): Promise<Image>;
24302439
static async renderText(font: BinaryData | BinaryData[], size: number, text: string, colorOrOptions: Color | TextOptions = 0xffffffff, layoutArgument?: TextLayout, styleArgument: TextStyle = {}): Promise<Image> {
24312440
const options: TextOptions = isOptions(colorOrOptions) ? colorOrOptions : { ...styleArgument, color: colorOrOptions, layout: layoutArgument };
2432-
const { color = 0xffffffff, layout = new TextLayout(), strokeWidth = 0, strokeColor = 0x000000ff, hinting = true, features = {} } = options;
2441+
const { color = 0xffffffff, layout = new TextLayout(), strokeWidth = 0, strokeColor = 0x000000ff, hinting = true, features = {}, variations = {} } = options;
2442+
const axes = textRenderer.axisValues(variations);
24332443
size = finite(size, 'font size');
24342444
if (finite(strokeWidth, 'stroke width') < 0)
24352445
throw new RangeError('Invalid stroke width');
@@ -2438,7 +2448,7 @@ export class Image {
24382448

24392449
const [r, g, b, a] = Image.colorToRGBA(color);
24402450
// color glyph layers in the text color take it without its alpha, which applies to the whole color glyph
2441-
const rendered = await textRenderer.render(fonts.map(view), size, text, layout, hinting, (r << 24 | g << 16 | b << 8 | 255) >>> 0, featureList(features));
2451+
const rendered = await textRenderer.render(fonts.map(view), size, text, layout, hinting, (r << 24 | g << 16 | b << 8 | 255) >>> 0, featureList(features), axes);
24422452

24432453
// the outline needs room around the glyphs
24442454
const pad = Math.ceil(strokeWidth);

‎src/render/hinting.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ export class Hinter {
214214
const cvt = table('cvt ');
215215
this.cvt = new Int16Array(cvt.length >> 1);
216216
for (let i = 0; i < this.cvt.length; i++) this.cvt[i] = font.i16(font.tables['cvt '].offset + i * 2);
217+
// (a variable font's instance: the values moved by cvar's deltas, rounded to font units like FreeType does)
218+
const deltas = font.cvtDeltas(this.cvt.length);
219+
if (deltas) for (let i = 0; i < this.cvt.length; i++) this.cvt[i] = Math.round(this.cvt[i] + deltas[i]);
217220

218221
const maxp = font.tables.maxp.offset;
219222
const version = font.u32(maxp);

‎src/render/svg-text.ts‎

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export interface FontFace {
4040
style: FontStyle;
4141
/** 1 (ultra-condensed) - 9 (ultra-expanded), 5 is normal */
4242
stretch: number;
43+
/** a variable font's ranges: of weights and stretches (its wght and wdth axes), whether it has italics (ital) and
44+
* obliques (negative slnt); null for other fonts */
45+
ranges: { weight: [number, number] | null, stretch: [number, number] | null, italic: boolean, oblique: boolean } | null;
4346
/** the generic families it fits (serif, sans-serif, monospace, cursive, fantasy) */
4447
generic: Set<string>;
4548
/** metrics in font units */
@@ -122,8 +125,20 @@ export function fontFace(data: Uint8Array, descriptors?: { family?: string, weig
122125
else if ((familyType === 2 && serifStyle >= 2 && serifStyle <= 10) || (familyClass >= 1 && familyClass <= 7 && familyClass !== 6)) generic.add('serif');
123126
else if ((familyType === 2 && serifStyle >= 11 && serifStyle <= 15) || familyClass === 8) generic.add('sans-serif');
124127

128+
// (a variable font's: by its axes)
129+
const axis = (tag: string) => font.axes.find(a => a.tag === tag);
130+
const wght = axis('wght'), wdth = axis('wdth'), ital = axis('ital'), slnt = axis('slnt');
131+
const classes = (min: number, max: number): [number, number] | null => {
132+
const lo = STRETCH_PERCENT.findIndex(p => p >= min), hi = STRETCH_PERCENT.filter(p => p <= max).length - 1;
133+
return lo >= 0 && hi >= lo ? [lo + 1, hi + 1] : null;
134+
};
135+
const ranges = font.axes.length ? {
136+
weight: wght ? [wght.min, wght.max] as [number, number] : null, stretch: wdth ? classes(wdth.min, wdth.max) : null,
137+
italic: !!ital && ital.max >= 1, oblique: !!slnt && slnt.min < 0,
138+
} : null;
139+
125140
const face: FontFace = {
126-
font, families: families.map(name => name.toLowerCase()), weight, style, stretch, generic,
141+
font, families: families.map(name => name.toLowerCase()), weight, style, stretch, ranges, generic,
127142
ascent, descent, xHeight,
128143
underlinePosition: post >= 0 ? font.i16(post + 8) : -Math.trunc(units / 9),
129144
underlineThickness,
@@ -141,12 +156,26 @@ export interface FontQuery {
141156
weight: number;
142157
style: FontStyle;
143158
stretch: number;
159+
/** font-variation-settings: axis values by tag */
160+
variations: Record<string, number>;
161+
/** font-optical-sizing: whether a variable font's optical size is the font size (auto) */
162+
opticalSizing: boolean;
144163
}
145164

165+
// the percentages of the stretches 1-9 (CSS font-stretch keywords)
166+
const STRETCH_PERCENT = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200];
167+
146168
const GENERIC = new Set(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy']);
147169

148-
// the face of a family that matches the query best (CSS Fonts' font matching: stretch, then style, then weight)
149-
function bestMatch(candidates: FontFace[], query: FontQuery): FontFace {
170+
// the face of a family that matches the query best (CSS Fonts' font matching: stretch, then style, then weight; a
171+
// variable font's ranges match what they include, else by their nearest end)
172+
function bestMatch(faces: FontFace[], query: FontQuery): FontFace {
173+
const clamp = (v: number, range: [number, number] | null | undefined, own: number): number => range ? Math.min(Math.max(v, range[0]), range[1]) : own;
174+
const candidates = faces.map(f => !f.ranges ? f : {
175+
...f, face: f, weight: clamp(query.weight, f.ranges.weight, f.weight), stretch: clamp(query.stretch, f.ranges.stretch, f.stretch),
176+
style: query.style === 'italic' && f.ranges.italic ? 'italic' : query.style === 'oblique' && f.ranges.oblique ? 'oblique' : f.style,
177+
});
178+
const original = (f: FontFace): FontFace => (f as FontFace & { face?: FontFace }).face ?? f;
150179
const pick = (values: number[], wanted: number, below: boolean): number => {
151180
if (values.includes(wanted)) return wanted;
152181
const lower = values.filter(v => v < wanted).sort((a, b) => b - a), higher = values.filter(v => v > wanted).sort((a, b) => a - b);
@@ -166,7 +195,32 @@ function bestMatch(candidates: FontFace[], query: FontQuery): FontFace {
166195
const up = weights.filter(v => v > w && v <= 500).sort((a, b) => a - b)[0];
167196
weight = up ?? pick(weights.filter(v => v < w || v > 500), w, true);
168197
} else weight = pick(weights, w, w < 400);
169-
return list.find(f => f.weight === weight) as FontFace;
198+
return original(list.find(f => f.weight === weight) as FontFace);
199+
}
200+
201+
const variedFaces = new WeakMap<FontFace, Map<string, FontFace>>();
202+
203+
/**
204+
* A face at a span's instance of its variable font (like usvg's): wght by the weight, wdth by the stretch, ital for
205+
* italic, slnt -12 for oblique, opsz by the font size (with optical sizing), then font-variation-settings; the face
206+
* itself for other fonts
207+
*/
208+
export function variedFace(face: FontFace, query: FontQuery, fontSize: number): FontFace {
209+
if (!face.font.axes.length) return face;
210+
const values: Record<string, number> = { wght: query.weight };
211+
if (query.stretch !== 5) values.wdth = STRETCH_PERCENT[query.stretch - 1];
212+
if (query.style === 'italic') values.ital = 1;
213+
if (query.style === 'oblique') values.slnt = -12;
214+
if (query.opticalSizing) values.opsz = fontSize;
215+
const font = face.font.withVariations({ ...values, ...query.variations });
216+
if (font === face.font) return face;
217+
let byCoords = variedFaces.get(face);
218+
if (!byCoords) variedFaces.set(face, byCoords = new Map<string, FontFace>());
219+
const key = font.coords.join(',');
220+
let varied = byCoords.get(key);
221+
// (the ascent and descent moved like the font's, by MVAR)
222+
if (!varied) byCoords.set(key, varied = { ...face, font, ascent: face.ascent + font.ascender - face.font.ascender, descent: face.descent + font.descender - face.font.descender });
223+
return varied;
170224
}
171225

172226
/**
@@ -256,6 +310,20 @@ function colorKind(face: FontFace, glyph: number, fontSize: number): ColorKind |
256310
return null;
257311
}
258312

313+
/**
314+
* font-variation-settings: normal, or quoted 4-character tags with a number each (comma separated; invalid ones are
315+
* left out, the last of a tag counts)
316+
*/
317+
export function parseVariationSettings(value: string): Record<string, number> {
318+
const out: Record<string, number> = {};
319+
if (value.trim().toLowerCase() === 'normal') return out;
320+
for (const part of value.split(',')) {
321+
const m = /^\s*(["'])(.{4})\1\s+([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*$/i.exec(part);
322+
if (m) out[m[2]] = Number(m[3]);
323+
}
324+
return out;
325+
}
326+
259327
/** the generic and named families of a font-family value, lowercase */
260328
export function parseFontFamilies(value: string): string[] {
261329
const families: string[] = [];
@@ -418,7 +486,10 @@ function shapeChunk<S>(chunk: ChunkInput<S>, fonts: FontFace[], primary: Map<Spa
418486
if (base) {
419487
const grapheme = cps.slice(start, end);
420488
let face: FontFace = base;
421-
if (!grapheme.every(c => isDefaultIgnorableCodepoint(c) || base.font.glyphIndex(c))) face = fallbackFont(fonts, base, grapheme, new Set([base])) ?? base;
489+
if (!grapheme.every(c => isDefaultIgnorableCodepoint(c) || base.font.glyphIndex(c))) {
490+
const other = fallbackFont(fonts, base, grapheme, new Set([base]));
491+
face = other && span ? variedFace(other, span.font, span.fontSize) : base;
492+
}
422493
faceOf.fill(face, start, end);
423494
if (face !== base) fallback.fill(face, start, end);
424495
}
@@ -902,7 +973,7 @@ export function layOutText<S>(text: TextInput<S>, fonts: FontFace[]): TextOutput
902973
const primary = new Map<SpanInput<S>, FontFace>();
903974
for (const chunk of text.chunks) for (const span of chunk.spans) {
904975
const face = matchFont(fonts, span.font);
905-
if (face) primary.set(span, face);
976+
if (face) primary.set(span, variedFace(face, span.font, span.fontSize));
906977
}
907978

908979
const spans: SpanOutput<S>[] = [];

‎src/render/svg.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { base64 } from '../util/bytes.ts';
1717
import { gunzip } from '../codecs/zlib.ts';
1818
import { applyFilter, Filter, Primitive, Kind, Input, Transfer, ColorMatrix, Pixmap, Light } from './filter.ts';
1919
import { BLEND_MODES, blendPixel } from './blend.ts';
20-
import { FontFace, FontStyle, fontFace, layOutText, parseFontFamilies, pathLength, placeOutline, colorTablesOf, TextInput, ChunkInput, SpanInput, TextOutput, SpanOutput, Baseline } from './svg-text.ts';
20+
import { FontFace, FontStyle, fontFace, layOutText, parseFontFamilies, parseVariationSettings, pathLength, placeOutline, colorTablesOf, TextInput, ChunkInput, SpanInput, TextOutput, SpanOutput, Baseline } from './svg-text.ts';
2121
import { rasterizeGlyph } from './text.ts';
2222
import { renderPaintGlyph } from './colr.ts';
2323
import { decodeSync as decodePNG } from '../codecs/png.ts';
@@ -30,7 +30,7 @@ const INHERITED = new Set(['fill', 'fill-opacity', 'fill-rule', 'stroke', 'strok
3030
'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'color', 'visibility', 'clip-rule', 'font-size', 'marker-start', 'marker-mid',
3131
'marker-end', 'image-rendering', 'color-interpolation-filters', 'paint-order', 'shape-rendering',
3232
'font-family', 'font-weight', 'font-style', 'font-stretch', 'font-variant', 'text-anchor', 'letter-spacing', 'word-spacing', 'dominant-baseline',
33-
'writing-mode', 'kerning', 'font-kerning', 'text-rendering']);
33+
'writing-mode', 'kerning', 'font-kerning', 'text-rendering', 'font-variation-settings', 'font-optical-sizing']);
3434

3535
// elements a switch may choose from
3636
// properties that aren't presentation attributes
@@ -145,6 +145,7 @@ const DEFAULTS: Record<string, string> = {
145145
'stop-color': 'black', 'stop-opacity': '1', 'marker-start': 'none', 'marker-mid': 'none', 'marker-end': 'none',
146146
'image-rendering': 'auto', 'filter': 'none', 'flood-color': 'black', 'flood-opacity': '1', 'color-interpolation-filters': 'linearRGB',
147147
'lighting-color': 'white', 'shape-rendering': 'auto', 'font-weight': '400', 'font-style': 'normal', 'font-stretch': 'normal',
148+
'font-variation-settings': 'normal', 'font-optical-sizing': 'auto',
148149
'font-variant': 'normal', 'text-anchor': 'start', 'letter-spacing': 'normal', 'word-spacing': 'normal', 'dominant-baseline': 'auto',
149150
'writing-mode': 'lr-tb', 'text-rendering': 'auto'
150151
};
@@ -1178,7 +1179,10 @@ class Document {
11781179
const fontStyle = style['font-style'] === 'italic' || style['font-style'] === 'oblique' ? style['font-style'] as FontStyle : 'normal';
11791180
return {
11801181
start, end, paint: style, visible: style.visibility === 'visible',
1181-
font: { families: parseFontFamilies(style['font-family'] ?? ''), weight: Number(style['font-weight']), style: fontStyle, stretch },
1182+
font: {
1183+
families: parseFontFamilies(style['font-family'] ?? ''), weight: Number(style['font-weight']), style: fontStyle, stretch,
1184+
variations: parseVariationSettings(style['font-variation-settings'] ?? ''), opticalSizing: style['font-optical-sizing'] !== 'none',
1185+
},
11821186
fontSize, smallCaps: style['font-variant'] === 'small-caps',
11831187
kerning: !(style.kerning !== undefined && style.kerning !== 'auto' && parseLength(style.kerning, viewport, fontSize, 'x') === 0) && style['font-kerning'] !== 'none',
11841188
letterSpacing: spacing(style['letter-spacing']), wordSpacing: spacing(style['word-spacing']),

0 commit comments

Comments
 (0)