Skip to content

Commit 0fdc98e

Browse files
matmenclaude
andcommitted
renderText: tests for damaged fonts, sbix, empty text and normalization
- tests/fonts/sbix.py builds sbix.ttf (a PNG with an origin offset, and a 'dupe' of it); the bitmap and its placement are checked - empty text renders a pixel wide, combining sequences like the composed characters - damaged fonts (truncated, bytes changed, and the damaged Noto-COLRv1 subset that ran out of memory before) render or throw a font error within 3 seconds - changelog entries for sbix, normalization and the robustness fixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent a193c86 commit 0fdc98e

6 files changed

Lines changed: 96 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
about 2.5 steps. `fast: false` keeps the precise sampling.
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
22-
palettes (e.g. Twemoji) and CBDT/CBLC bitmaps (e.g. Noto Color Emoji, which now loads at all, it has no outlines).
22+
palettes (e.g. Twemoji), CBDT/CBLC bitmaps (e.g. Noto Color Emoji, which now loads at all, it has no outlines) and
23+
sbix bitmaps (Apple's format).
2324
Invisible characters like joiners and variation selectors take no room, as in browsers.
2425
- `Image.renderText` applies the fonts' glyph substitutions (GSUB) that browsers apply by default (`ccmp`, `rlig`,
2526
`liga`, `clig`, `calt`): emoji sequences (skin tones, flags, keycaps, families and other joined ones) become one
@@ -30,6 +31,8 @@
3031
gradients (pad, repeat, reflect), glyph outlines as masks, transforms and all composite modes; variable paints with
3132
their default values. They are rendered at four times the size and scaled down, so layers sharing an edge don't let the
3233
ones below show through. Fonts with both prefer them over their other color glyphs, like browsers.
34+
- `Image.renderText` composes combining sequences (e + ◌́ to é, as in text from macOS) where the font has the composed
35+
character, and decomposes characters the fonts lack where one has their parts, like HarfBuzz.
3336
- Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are
3437
kept per font and size across calls.
3538

@@ -45,6 +48,14 @@
4548
glyphs (opaque text renders as before).
4649
- The outline of `Image.renderText` follows the glyphs' antialiased edges instead of their pixels, so it is smooth
4750
rather than stepped, and it reaches exactly `strokeWidth` beyond the edge.
51+
- `Image.renderText` threw for text without anything to show (empty, only spaces), it now renders a transparent pixel
52+
wide.
53+
- Damaged fonts no longer take unbounded memory or time: glyphs with contour end points that don't increase render
54+
empty (as in FreeType), composite glyphs can't contain themselves (also when hinting), and outlines have a size
55+
limit. Reading past the end of damaged or truncated font data throws "Font: damaged or truncated font data" instead
56+
of a RangeError, and text larger than 2^28 pixels throws.
57+
- Glyph outlines reaching far outside their bitmap rasterize correctly (the parts outside used to change the slope of
58+
the edges inside).
4859

4960
## 3.2.1
5061

‎tests/font.js‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
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 (subsets of Noto Color Emoji and Noto-COLRv1, SIL OFL, see OFL-NotoColorEmoji.md)
7-
colrv1 paints.ttf is built by colrv1-paints.py
6+
https://github.com/googlefonts/noto-emoji (subsets of Noto Color Emoji and Noto-COLRv1, SIL OFL, see OFL-NotoColorEmoji.md;
7+
damaged noto colrv1 subset.ttf is a damaged copy for tests/regressions.js)
8+
colrv1 paints.ttf is built by colrv1-paints.py, sbix.ttf by sbix.py
89
https://github.com/mozilla/twemoji-colr (subset, CC-BY 4.0, see CC-BY-Twemoji.md)
910
*/
1011

@@ -216,4 +217,26 @@ const panic = message => {
216217
const [fr, fg, fb, fa] = foreground.getRGBAAt(Math.round(foreground.width * 0.12), foreground.height >> 1);
217218
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})`);
218219
}
220+
221+
{
222+
// sbix bitmaps: the PNG placed by its origin offset (8, -4 at 64 ppem), and a 'dupe' of it
223+
const font = await fs.readFile('./tests/fonts/sbix.ttf');
224+
const square = await Image.renderText(font, 64, '\ue000'), dupe = await Image.renderText(font, 64, '\ue001');
225+
if (!Buffer.from(square.bitmap).equals(Buffer.from(dupe.bitmap))) panic('sbix: a dupe should render as the glyph it refers to');
226+
// the baseline is at the ascender (0.9 em), the bitmap's bottom 4 pixels below it, its left edge at 8
227+
const [r, g, b] = square.getRGBAAt(8 + 24, Math.ceil(0.9 * 64) + 4 - 24), [cr, , cb] = square.getRGBAAt(8 + 4, Math.ceil(0.9 * 64) + 4 - 44);
228+
if (!(r > 200 && g < 50 && b < 50 && cb > 200 && cr < 50)) panic(`sbix: the bitmap should be placed by its origin offset (${r},${g},${b} / ${cr},${cb})`);
229+
if (square.getRGBAAt(8 - 2, 30)[3] !== 0) panic('sbix: nothing left of the bitmap');
230+
}
231+
232+
{
233+
// text without anything to show renders a transparent pixel wide, it doesn't throw
234+
const font = await fs.readFile('./tests/fonts/sourcesans3 regular.otf');
235+
for (const text of ['', ' ', '\u200b\ufe0f'])
236+
if ((await Image.renderText(font, 32, text)).width !== 1) panic(`empty text: ${JSON.stringify(text)} should render a pixel wide`);
237+
238+
// combining sequences are composed where the font has the composed character
239+
const composed = await Image.renderText(font, 32, 'Café Müller'), decomposed = await Image.renderText(font, 32, 'Cafe\u0301 Mu\u0308ller');
240+
if (!Buffer.from(composed.bitmap).equals(Buffer.from(decomposed.bitmap))) panic('combining sequences should render like the composed characters');
241+
}
219242
})();
24.2 KB
Binary file not shown.

‎tests/fonts/sbix.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# builds "sbix.ttf" (python sbix.py sbix.ttf, needs fontTools): an sbix test font, U+E000 a red square PNG with a blue
2+
# corner, U+E001 the same bitmap through a 'dupe' record, one strike at 64 ppem
3+
import sys, struct, zlib
4+
from fontTools.fontBuilder import FontBuilder
5+
from fontTools.pens.ttGlyphPen import TTGlyphPen
6+
from fontTools.ttLib import newTable
7+
from fontTools.ttLib.tables.sbixStrike import Strike
8+
from fontTools.ttLib.tables.sbixGlyph import Glyph as SbixGlyph
9+
10+
def png(width, height, pixel):
11+
rows = b''.join(b'\0' + b''.join(bytes(pixel(x, y)) for x in range(width)) for y in range(height))
12+
chunk = lambda kind, data: struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
13+
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b'')
14+
15+
names = ['.notdef', 'square', 'dupe']
16+
fb = FontBuilder(1000, isTTF=True)
17+
fb.setupGlyphOrder(names)
18+
fb.setupCharacterMap({0xE000: 'square', 0xE001: 'dupe'})
19+
fb.setupGlyf({n: TTGlyphPen(None).glyph() for n in names})
20+
fb.setupHorizontalMetrics({n: (1000, 0) for n in names})
21+
fb.setupHorizontalHeader(ascent=900, descent=-100)
22+
fb.setupNameTable({'familyName': 'sbix Test', 'styleName': 'Regular'})
23+
fb.setupOS2(); fb.setupPost()
24+
image = png(48, 48, lambda x, y: (0, 0, 255, 255) if x < 12 and y < 12 else (255, 0, 0, 255))
25+
strike = Strike(ppem=64, resolution=72)
26+
strike.glyphs['square'] = SbixGlyph(glyphName='square', graphicType='png ', originOffsetX=8, originOffsetY=-4, imageData=image)
27+
strike.glyphs['dupe'] = SbixGlyph(glyphName='dupe', graphicType='dupe', referenceGlyphName='square')
28+
sbix = newTable('sbix'); sbix.version = 1; sbix.flags = 1; sbix.strikes = {64: strike}
29+
fb.font['sbix'] = sbix
30+
fb.save(sys.argv[1])

‎tests/fonts/sbix.ttf‎

840 Bytes
Binary file not shown.

‎tests/regressions.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,4 +532,33 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y));
532532
}
533533
assert(crc32(data) === zlib.crc32(data), 'crc32: wrong for a long buffer');
534534
}
535+
536+
// damaged fonts (truncated, bytes changed) render or throw a font error, quickly: no runaway memory or time (contours
537+
// with decreasing end points, composite glyphs containing themselves, huge COLR clip boxes)
538+
{
539+
const fs = require('fs');
540+
let seed = 7;
541+
const random = () => (seed = Math.imul(seed, 1103515245) + 12345 >>> 0) / 2 ** 32;
542+
// (a damaged copy of the Noto-COLRv1 subset with 3329 contours of decreasing end points in a glyph ran out of memory)
543+
const start = Date.now();
544+
await Image.renderText(fs.readFileSync('./tests/fonts/damaged noto colrv1 subset.ttf'), 40, '😀🔥👍❤️').catch(err => assert(/^Font: /.test(err.message), err.message));
545+
assert(Date.now() - start < 3000, 'the damaged Noto-COLRv1 subset took too long');
546+
for (const file of ['sourcesans3 regular.otf', 'opensans bold.ttf', 'noto colrv1 subset.ttf', 'noto color emoji subset.ttf']) {
547+
const original = fs.readFileSync(`./tests/fonts/${file}`);
548+
for (let k = 0; k < 30; k++) {
549+
let data = original.subarray(0, Math.floor(random() * original.length));
550+
if (k >= 10) {
551+
data = Buffer.from(original);
552+
for (let n = 0; n < 1 + random() * 20; n++) data[Math.floor(random() * data.length)] = random() * 256;
553+
}
554+
const start = Date.now();
555+
try {
556+
await Image.renderText(data, 40, 'Hi office 😀🔥 👍🏽 🇩🇪 1️⃣ e\u0301');
557+
} catch (err) {
558+
assert(/^Font: /.test(err.message), `${file} #${k}: unexpected error for a damaged font: ${err.message}`);
559+
}
560+
assert(Date.now() - start < 3000, `${file} #${k}: a damaged font took ${Date.now() - start} ms`);
561+
}
562+
}
563+
}
535564
})();

0 commit comments

Comments
 (0)