Skip to content

Commit cf0bae2

Browse files
matmenclaude
andcommitted
Fonts: accept WOFF everywhere
The Font constructor decompresses WOFF (1) fonts itself (fromWOFF moved from svg.ts to font.ts), so renderText(), TextLayout and SVGOptions.fonts take them like @font-face in SVGs already did. WOFF2 is refused with a clear message: it needs Brotli, which Chrome's DecompressionStream doesn't have yet, and we'd rather wait than bundle a JS decoder with its 122 kB dictionary. Checked: carbon phyber, Open Sans (TrueType), Source Sans 3 (CFF), Noto COLRv1 emoji and Noto Naskh Arabic as WOFF (fontTools) render the same pixels as the originals, also through SVGOptions.fonts and @font-face. tests/font.js renders the Arabic subset both ways and checks the WOFF2 error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsU2GAqbnFSNaYDBGD1Km2
1 parent c357b2f commit cf0bae2

7 files changed

Lines changed: 68 additions & 49 deletions

File tree

‎CHANGELOG.md‎

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

33
## 3.13.0
44

5+
### New
6+
7+
- WOFF fonts work everywhere fonts do: `renderText()`, `TextLayout`, and SVG rendering's `fonts` option (before, only
8+
fonts an SVG embeds with `@font-face` could be WOFF). Font packages like Fontsource ship only WOFF and WOFF2, so
9+
their fonts can be used directly. WOFF2 fonts are refused with a clear error for now: they need Brotli, which
10+
Chrome's `DecompressionStream` doesn't offer yet.
11+
512
### Changes
613

714
- PNG and APNG encoding (`encode()`, `encodePNG()`, `encodeAPNG()`, `apngEncoder()`) default to compression level 4

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ and encoded as GIF, APNG or WebP, all at once or
6060
[frame by frame](https://imagescript.dev/classes/Animation.html#webpencoder). Embedded color profiles (Display
6161
P3, Adobe RGB, ...) are converted to sRGB when decoding, so images look the way browsers show them.
6262

63-
- [Text rendering](https://imagescript.dev/classes/Image.html#rendertext) with TrueType and OpenType fonts:
63+
- [Text rendering](https://imagescript.dev/classes/Image.html#rendertext) with TrueType, OpenType and WOFF fonts:
6464
all scripts, color emoji, variable fonts
6565
- [SVG rendering](https://imagescript.dev/classes/Image.html#rendersvg), including text (with fonts you pass
6666
or fonts embedded in the SVG)

‎src/image.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,8 @@ export interface SVGOptions {
180180
* and `font-stretch` match best among them, by the names, weights and styles in the fonts (like browsers match
181181
* installed fonts). Generic families (`serif`, `sans-serif`, `monospace`, ...) are the first font that fits them,
182182
* other families and text without one get the first font's family; characters a face doesn't have come from the
183-
* other fonts. Fonts the SVG embeds with `@font-face` (in data: URIs, also WOFF) are used too, before these for
184-
* their family names. Without any fonts text isn't rendered. Default: `[]`
183+
* other fonts (TrueType, OpenType or WOFF). Fonts the SVG embeds with `@font-face` (in data: URIs) are used too,
184+
* before these for their family names. Without any fonts text isn't rendered. Default: `[]`
185185
*/
186186
fonts?: BinaryData[];
187187
}
@@ -2411,10 +2411,11 @@ export class Image {
24112411

24122412
/**
24132413
* Creates a new image containing the rendered text.
2414-
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use, or several by priority: every character
2415-
* comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color
2416-
* glyphs (COLR layers and paints, CBDT and sbix bitmaps, as in emoji fonts) keep their colors. Text in any script
2417-
* is shaped like browsers do (right to left and bidirectional text, Arabic joining, Indic conjuncts, marks)
2414+
* @param font TrueType (ttf/ttc), OpenType (otf) or WOFF font buffer to use, or several by priority: every
2415+
* character comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height.
2416+
* Color glyphs (COLR layers and paints, CBDT and sbix bitmaps, as in emoji fonts) keep their colors. Text in any
2417+
* script is shaped like browsers do (right to left and bidirectional text, Arabic joining, Indic conjuncts,
2418+
* marks)
24182419
* @param size Font size in pixels (per em)
24192420
* @param text Text to render
24202421
* @param options Color, layout and style, see {@link TextOptions}; the image grows by the outline width on every
@@ -2424,10 +2425,11 @@ export class Image {
24242425
static renderText(font: BinaryData | BinaryData[], size: number, text: string, options?: TextOptions): Promise<Image>;
24252426
/**
24262427
* Creates a new image containing the rendered text.
2427-
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use, or several by priority: every character
2428-
* comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color
2429-
* glyphs (COLR layers and paints, CBDT and sbix bitmaps, as in emoji fonts) keep their colors. Text in any script
2430-
* is shaped like browsers do (right to left and bidirectional text, Arabic joining, Indic conjuncts, marks)
2428+
* @param font TrueType (ttf/ttc), OpenType (otf) or WOFF font buffer to use, or several by priority: every
2429+
* character comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height.
2430+
* Color glyphs (COLR layers and paints, CBDT and sbix bitmaps, as in emoji fonts) keep their colors. Text in any
2431+
* script is shaped like browsers do (right to left and bidirectional text, Arabic joining, Indic conjuncts,
2432+
* marks)
24312433
* @param size Font size in pixels (per em)
24322434
* @param text Text to render
24332435
* @param [color=0xffffffff] Text color to use

‎src/render/font.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { decompress } from '../codecs/zlib.ts';
12
import { type Axis, Gvar, ItemVariationStore, cvarDeltas, deltaSetIndexMap, normalize, parseAvar, parseFvar } from './variations.ts';
23

34

@@ -77,13 +78,16 @@ export class Font {
7778
private instances: Map<string, Font> | null = null;
7879

7980
constructor(data: Uint8Array) {
81+
// 'wOFF': the TrueType or OpenType font it compresses; 'wOF2' (Brotli) isn't everywhere yet
82+
const woff = data.length >= 4 ? (data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3]) >>> 0 : 0;
83+
if (woff === 0x774f4646) data = fromWOFF(data);
84+
else if (woff === 0x774f4632) throw new Error('Font: WOFF2 fonts are not supported, only WOFF');
8085
this.data = data;
8186
this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);
8287

8388
let offset = 0;
8489
const signature = this.u32(0);
8590
if (signature === 0x74746366) offset = this.u32(12); // 'ttcf', use the first font
86-
else if (signature === 0x774f4646 || signature === 0x774f4632) throw new Error('Font: WOFF fonts are not supported');
8791

8892
const version = this.u32(offset);
8993
if (version !== 0x00010000 && version !== 0x4f54544f && version !== 0x74727565) throw new Error('Font: invalid font file');
@@ -1102,3 +1106,30 @@ function cffCharString(cff: CFF, glyph: number): PathCommand[] {
11021106
return path;
11031107
}
11041108

1109+
// a WOFF (1) font as the TrueType or OpenType font it compresses
1110+
function fromWOFF(data: Uint8Array): Uint8Array {
1111+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1112+
const count = view.getUint16(12);
1113+
const tables: Array<{ tag: number, data: Uint8Array, checksum: number }> = [];
1114+
for (let i = 0; i < count; i++) {
1115+
const e = 44 + i * 20, offset = view.getUint32(e + 4), compressed = view.getUint32(e + 8), length = view.getUint32(e + 12);
1116+
const raw = data.subarray(offset, offset + compressed);
1117+
tables.push({ tag: view.getUint32(e), checksum: view.getUint32(e + 16), data: compressed < length ? decompress(raw, length).subarray(0, length) : raw });
1118+
}
1119+
const header = 12 + count * 16;
1120+
let size = header;
1121+
for (const t of tables) size += t.data.length + 3 & ~3;
1122+
const out = new Uint8Array(size), o = new DataView(out.buffer);
1123+
o.setUint32(0, view.getUint32(4));
1124+
o.setUint16(4, count);
1125+
let position = header;
1126+
tables.forEach((t, i) => {
1127+
o.setUint32(12 + i * 16, t.tag);
1128+
o.setUint32(16 + i * 16, t.checksum);
1129+
o.setUint32(20 + i * 16, position);
1130+
o.setUint32(24 + i * 16, t.data.length);
1131+
out.set(t.data, position);
1132+
position += t.data.length + 3 & ~3;
1133+
});
1134+
return out;
1135+
}

‎src/render/svg.ts‎

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { FontFace, FontStyle, fontFace, layOutText, parseFontFamilies, parseVari
2121
import { rasterizeGlyph } from './text.ts';
2222
import { renderPaintGlyph } from './colr.ts';
2323
import { decodeSync as decodePNG } from '../codecs/png.ts';
24-
import { decompress } from '../codecs/zlib.ts';
2524

2625
const UNSUPPORTED = new Set(['filter', 'pattern', 'marker', 'foreignObject', 'script', 'style', 'title', 'desc', 'metadata',
2726
'defs', 'clipPath', 'mask', 'linearGradient', 'radialGradient', 'stop', 'symbol', 'font', 'font-face', 'animate', 'animateTransform', 'animateMotion', 'set']);
@@ -3633,13 +3632,7 @@ function embeddedFonts(css: string): FontFace[] {
36333632
if (family) for (const [, , url] of (d.src ?? '').matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/g)) {
36343633
const uri = dataURI(url);
36353634
if (!uri) continue;
3636-
let data: Uint8Array | null = uri.data;
3637-
try {
3638-
if (data[0] === 0x77 && data[1] === 0x4f && data[2] === 0x46 && data[3] === 0x46) data = fromWOFF(data);
3639-
} catch {
3640-
data = null;
3641-
}
3642-
const face = data && fontFace(data, { family, weight, style });
3635+
const face = fontFace(uri.data, { family, weight, style });
36433636
if (face) {
36443637
faces.push(face);
36453638
break;
@@ -3651,34 +3644,6 @@ function embeddedFonts(css: string): FontFace[] {
36513644
return faces;
36523645
}
36533646

3654-
// a WOFF (1) font as the TrueType or OpenType font it compresses
3655-
function fromWOFF(data: Uint8Array): Uint8Array {
3656-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
3657-
const count = view.getUint16(12);
3658-
const tables: Array<{ tag: number, data: Uint8Array, checksum: number }> = [];
3659-
for (let i = 0; i < count; i++) {
3660-
const e = 44 + i * 20, offset = view.getUint32(e + 4), compressed = view.getUint32(e + 8), length = view.getUint32(e + 12);
3661-
const raw = data.subarray(offset, offset + compressed);
3662-
tables.push({ tag: view.getUint32(e), checksum: view.getUint32(e + 16), data: compressed < length ? decompress(raw, length).subarray(0, length) : raw });
3663-
}
3664-
const header = 12 + count * 16;
3665-
let size = header;
3666-
for (const t of tables) size += t.data.length + 3 & ~3;
3667-
const out = new Uint8Array(size), o = new DataView(out.buffer);
3668-
o.setUint32(0, view.getUint32(4));
3669-
o.setUint16(4, count);
3670-
let position = header;
3671-
tables.forEach((t, i) => {
3672-
o.setUint32(12 + i * 16, t.tag);
3673-
o.setUint32(16 + i * 16, t.checksum);
3674-
o.setUint32(20 + i * 16, position);
3675-
o.setUint32(24 + i * 16, t.data.length);
3676-
out.set(t.data, position);
3677-
position += t.data.length + 3 & ~3;
3678-
});
3679-
return out;
3680-
}
3681-
36823647
// an element's transform around its transform-origin
36833648
function transformOf(style: Style, viewport: Viewport, fallback?: string): Matrix {
36843649
return originTransform(style.transform as string, style['transform-origin'] ?? fallback, viewport, parseNumber(style['font-size'], 16));

‎tests/font.js‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
https://www.1001fonts.com/ethnocentric-font.html
55
https://github.com/adobe-fonts/source-sans (SIL OFL, see OFL-SourceSans3.md)
66
https://notofonts.github.io (subsets of Noto Sans Hebrew, Noto Naskh Arabic and Noto Sans Devanagari, SIL OFL, see
7-
../shaping/OFL-Noto.md)
7+
../shaping/OFL-Noto.md; noto naskh arabic subset.woff is the .ttf as WOFF, by fontTools)
88
https://github.com/googlefonts/noto-emoji (subsets of Noto Color Emoji and Noto-COLRv1, SIL OFL, see OFL-NotoColorEmoji.md;
99
damaged noto colrv1 subset.ttf is a damaged copy for tests/regressions.js)
1010
colrv1 paints.ttf is built by colrv1-paints.py, sbix.ttf by sbix.py, hinting instructions.ttf by scripts/hinting-fixtures.py
@@ -21,6 +21,20 @@ const panic = message => {
2121
};
2222

2323
(async () => {
24+
// WOFF fonts render like the fonts they compress; WOFF2 ones are refused with a clear message
25+
{
26+
const text = 'مرحبا بالعالم';
27+
const ttf = await Image.renderText(await fs.readFile('./tests/fonts/noto naskh arabic subset.ttf'), 48, text);
28+
const woff = await Image.renderText(await fs.readFile('./tests/fonts/noto naskh arabic subset.woff'), 48, text);
29+
if (!Buffer.from(ttf.bitmap).equals(Buffer.from(woff.bitmap))) panic('WOFF font doesn\'t render like its TTF');
30+
31+
const woff2 = new Uint8Array(64);
32+
woff2.set([0x77, 0x4f, 0x46, 0x32]);
33+
await Image.renderText(woff2, 48, text).then(() => panic('WOFF2 font was accepted'), err => {
34+
if (!/WOFF2/.test(err.message)) panic(`unexpected WOFF2 error: ${err.message}`);
35+
});
36+
}
37+
2438
{
2539
const font = await Image.renderText(await fs.readFile('./tests/fonts/carbon phyber.ttf'), 128, 'ThE qUiCk');
2640
const encoded = await font.encode(1, {creationTime: 0, software: ''});
7.61 KB
Binary file not shown.

0 commit comments

Comments
 (0)