From 6003df507558a8efb0695293ec5d99ee0da8e931 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 20:47:23 +0000 Subject: [PATCH 1/4] renderText: COLR version 1 color glyphs src/render/colr.ts renders the paint graph of COLR version 1 base glyphs (BaseGlyphList, LayerList, ClipList) into premultiplied float RGBA: - all 32 paint formats, variable ones with their default values - linear (with the p2 rotation), two point conical radial and sweep gradients (biased angles), with pad, repeat and reflect - PaintGlyph masks through the text rasterizer, after the transform - all 28 composite modes (Porter-Duff, separable and HSL blends) - the text color for palette index 0xffff Layers carry the box they drew in, so compositing stays within it, and source over has its own loop. Glyphs render at 2x and are averaged down: layers sharing an edge would otherwise each be antialiased there and the ones below would show through (dark seams on Noto's emoji). COLR version 1 glyphs win over bitmaps and version 0 layers. The CPAL palette reader is shared with version 0. Tests: a Noto-COLRv1 subset (OFL) in the emoji tests, and a font built with fontTools (tests/fonts/colrv1-paints.py) with one glyph per paint feature. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- CHANGELOG.md | 4 + src/render/color.ts | 22 +- src/render/colr.ts | 543 +++++++++++++++++++++++++++++ src/render/text.ts | 33 +- tests/font.js | 27 +- tests/fonts/colrv1 paints.ttf | Bin 0 -> 1664 bytes tests/fonts/colrv1-paints.py | 65 ++++ tests/fonts/noto colrv1 subset.ttf | Bin 0 -> 24760 bytes tests/targets/colrv1-paints.png | Bin 0 -> 13269 bytes tests/targets/emoji-colrv1.png | Bin 0 -> 19244 bytes 10 files changed, 674 insertions(+), 20 deletions(-) create mode 100644 src/render/colr.ts create mode 100644 tests/fonts/colrv1 paints.ttf create mode 100644 tests/fonts/colrv1-paints.py create mode 100644 tests/fonts/noto colrv1 subset.ttf create mode 100644 tests/targets/colrv1-paints.png create mode 100644 tests/targets/emoji-colrv1.png diff --git a/CHANGELOG.md b/CHANGELOG.md index edd7a3e..adc26ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ glyph, and text fonts use their ligatures (fi, ffi, ...) and contextual alternates. Emoji sequences come from one font, the first that has all of their characters, and the emoji variation selector picks a color glyph (❀️ in an emoji font rather than the text font's ❀). +- `Image.renderText` renders COLR version 1 color glyphs (e.g. Noto-COLRv1): solid colors, linear, radial and sweep + gradients (pad, repeat, reflect), glyph outlines as masks, transforms and all composite modes; variable paints with + their default values. They are rendered at twice the size and scaled down, so layers sharing an edge don't let the + ones below show through. Fonts with both prefer them over their other color glyphs, like browsers. - Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are kept per font and size across calls. diff --git a/src/render/color.ts b/src/render/color.ts index 2420835..9898dd9 100644 --- a/src/render/color.ts +++ b/src/render/color.ts @@ -22,6 +22,19 @@ export interface BitmapGlyph { top: number; } +/** + * The colors of the font's first CPAL palette as RGBA (0xrrggbbaa), null for the text color (index 0xffff) and + * indices outside the palette + */ +export function parsePalette(font: Font, cpal: number): (index: number) => number | null { + const entries = font.u16(cpal + 2), records = cpal + font.u32(cpal + 8), first = font.u16(cpal + 12); + return index => { + if (index === 0xffff || index >= entries) return null; + const o = records + (first + index) * 4; // blue, green, red, alpha + return (font.u8(o + 2) << 24 | font.u8(o + 1) << 16 | font.u8(o) << 8 | font.u8(o + 3)) >>> 0; + }; +} + /** * The layers of the font's color glyphs (COLR version 0 records, with the colors of the first CPAL palette), null if the * font has none. The lookup returns null for glyphs without layers @@ -32,14 +45,7 @@ export function parseColorLayers(font: Font): ((glyph: number) => ColorLayer[] | const baseCount = font.u16(colr + 2), bases = colr + font.u32(colr + 4), layers = colr + font.u32(colr + 8); if (!baseCount) return null; - - // the first palette - const entries = font.u16(cpal + 2), records = cpal + font.u32(cpal + 8), first = font.u16(cpal + 12); - const color = (index: number): number | null => { - if (index === 0xffff || index >= entries) return null; - const o = records + (first + index) * 4; // blue, green, red, alpha - return (font.u8(o + 2) << 24 | font.u8(o + 1) << 16 | font.u8(o) << 8 | font.u8(o + 3)) >>> 0; - }; + const color = parsePalette(font, cpal); const cache = new Map(); return glyph => { diff --git a/src/render/colr.ts b/src/render/colr.ts new file mode 100644 index 0000000..1f8b3cd --- /dev/null +++ b/src/render/colr.ts @@ -0,0 +1,543 @@ +// COLR version 1 color glyphs: a graph of paints (solid colors, linear, radial and sweep gradients, glyph outlines as +// masks, affine transforms and Porter-Duff/blend compositing), rendered into premultiplied RGBA. Variable paints +// render with their default values + +import { Font, PathCommand } from './font.ts'; +import { parsePalette } from './color.ts'; + +/** an affine transform [a, b, c, d, e, f]: x' = a x + c y + e, y' = b x + d y + f */ +type Matrix = [number, number, number, number, number, number]; + +/** bounds as [xMin, yMin, xMax, yMax] */ +type Box = [number, number, number, number]; + +/** an outline rasterized at pixel coordinates (y up), placed like the glyph bitmaps of the text renderer */ +export interface Coverage { + data: Uint8Array; + width: number; + height: number; + left: number; + top: number; +} + +/** premultiplied RGBA (0-1) of a canvas whose pixel (0, 0) has its top left corner at (left, top), y up */ +interface Canvas { + left: number; + top: number; + width: number; + height: number; +} + +/** a font's COLR version 1 glyphs */ +export interface PaintGlyphs { + /** the paint (an offset into the font) of a base glyph, null if it has none */ + paint: (glyph: number) => number | null; + /** the glyph's clip box in font units, null if it has none */ + clip: (glyph: number) => Box | null; +} + +const MAX_DEPTH = 64; +// samples per pixel axis +const SAMPLES = 2; + +const multiply = (m: Matrix, n: Matrix): Matrix => [ + m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], + m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3], + m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5] +]; + +const invert = (m: Matrix): Matrix | null => { + const det = m[0] * m[3] - m[1] * m[2]; + if (!det) return null; + return [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det, (m[2] * m[5] - m[3] * m[4]) / det, (m[1] * m[4] - m[0] * m[5]) / det]; +}; + +const around = (cx: number, cy: number, m: Matrix): Matrix => multiply(multiply([1, 0, 0, 1, cx, cy], m), [1, 0, 0, 1, -cx, -cy]); + +/** + * The font's COLR version 1 base glyphs, null if it has none + */ +export function parsePaintGlyphs(font: Font): PaintGlyphs | null { + const colr = font.tables.COLR?.offset; + if (colr === undefined || font.u16(colr) < 1) return null; + const baseList = font.u32(colr + 14) ? colr + font.u32(colr + 14) : 0; + if (!baseList) return null; + const clipList = font.u32(colr + 22) ? colr + font.u32(colr + 22) : 0; + const count = font.u32(baseList), clips = clipList ? font.u32(clipList + 1) : 0; + + return { + paint: glyph => { + // records sorted by glyph + for (let lo = 0, hi = count - 1; lo <= hi;) { + const mid = lo + hi >> 1, record = baseList + 4 + mid * 6, id = font.u16(record); + if (id < glyph) lo = mid + 1; + else if (id > glyph) hi = mid - 1; + else return baseList + font.u32(record + 2); + } + return null; + }, + clip: glyph => { + for (let lo = 0, hi = clips - 1; lo <= hi;) { + const mid = lo + hi >> 1, record = clipList + 5 + mid * 7; + if (font.u16(record + 2) < glyph) lo = mid + 1; + else if (font.u16(record) > glyph) hi = mid - 1; + else { + const box = clipList + (font.u8(record + 4) << 16 | font.u16(record + 5)); + return [font.i16(box + 1), font.i16(box + 3), font.i16(box + 5), font.i16(box + 7)]; + } + } + return null; + } + }; +} + +// reads the paint graph of a font +class Paints { + font: Font; + layerList: number; + palette: (index: number) => number | null; + glyphs: PaintGlyphs; + + constructor(font: Font, glyphs: PaintGlyphs) { + this.font = font; + this.glyphs = glyphs; + const colr = font.tables.COLR.offset; + this.layerList = font.u32(colr + 18) ? colr + font.u32(colr + 18) : 0; + const cpal = font.tables.CPAL?.offset; + this.palette = cpal === undefined ? () => null : parsePalette(font, cpal); + } + + offset24(o: number): number { + return this.font.u8(o) << 16 | this.font.u16(o + 1); + } + + child(paint: number, at = 1): number { + return paint + this.offset24(paint + at); + } + + f2dot14(o: number): number { + return this.font.i16(o) / 16384; + } + + layer(index: number): number { + return this.layerList + this.font.u32(this.layerList + 4 + index * 4); + } + + /** the transform a transforming paint applies (formats 12-31), with its child */ + transform(paint: number, format: number): Matrix { + const font = this.font, p = paint; + const f = (o: number): number => this.f2dot14(p + o); + const degrees = (o: number): number => f(o) * Math.PI; + switch (format) { + case 12: case 13: { + const t = this.child(p, 4), fixed = (k: number): number => font.i32(t + k * 4) / 65536; + return [fixed(0), fixed(1), fixed(2), fixed(3), fixed(4), fixed(5)]; + } + case 14: case 15: return [1, 0, 0, 1, font.i16(p + 4), font.i16(p + 6)]; + case 16: case 17: return [f(4), 0, 0, f(6), 0, 0]; + case 18: case 19: return around(font.i16(p + 8), font.i16(p + 10), [f(4), 0, 0, f(6), 0, 0]); + case 20: case 21: return [f(4), 0, 0, f(4), 0, 0]; + case 22: case 23: return around(font.i16(p + 6), font.i16(p + 8), [f(4), 0, 0, f(4), 0, 0]); + case 24: case 25: case 26: case 27: { + const a = degrees(4), cos = Math.cos(a), sin = Math.sin(a), m: Matrix = [cos, sin, -sin, cos, 0, 0]; + return format >= 26 ? around(font.i16(p + 6), font.i16(p + 8), m) : m; + } + default: { + // skew (28-31) + const m: Matrix = [1, Math.tan(degrees(6)), Math.tan(-degrees(4)), 1, 0, 0]; + return format >= 30 ? around(font.i16(p + 8), font.i16(p + 10), m) : m; + } + } + } + + /** bounds in pixels of what a paint draws (the outlines it masks with), null if unbounded or empty */ + bounds(paint: number, m: Matrix, outline: (glyph: number) => PathCommand[], depth = 0): Box | null { + if (depth > MAX_DEPTH) return null; + const font = this.font, format = font.u8(paint); + const union = (a: Box | null, b: Box | null): Box | null => !a ? b : !b ? a : [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])]; + switch (format) { + case 1: { + let box: Box | null = null; + const first = font.u32(paint + 2); + for (let i = 0, n = font.u8(paint + 1); i < n; i++) box = union(box, this.bounds(this.layer(first + i), m, outline, depth + 1)); + return box; + } + case 10: { + let box: Box | null = null; + for (const command of outline(font.u16(paint + 4))) + for (let i = 1; i < command.length; i += 2) { + const x = command[i] as number, y = command[i + 1] as number; + const px = m[0] * x + m[2] * y + m[4], py = m[1] * x + m[3] * y + m[5]; + box = union(box, [px, py, px, py]); + } + return box; + } + case 11: { + const base = this.glyphs.paint(font.u16(paint + 1)); + return base === null ? null : this.bounds(base, m, outline, depth + 1); + } + case 32: return union(this.bounds(this.child(paint), m, outline, depth + 1), this.bounds(this.child(paint, 5), m, outline, depth + 1)); + default: + if (format >= 12 && format <= 31) return this.bounds(this.child(paint), multiply(m, this.transform(paint, format)), outline, depth + 1); + return null; + } + } +} + +const paintsOf = new WeakMap(); +const paintsFor = (font: Font, glyphs: PaintGlyphs): Paints => { + let paints = paintsOf.get(glyphs); + if (!paints) paintsOf.set(glyphs, paints = new Paints(font, glyphs)); + return paints; +}; + +/** + * The horizontal extent of a COLR version 1 glyph in font units (its clip box, else what its outlines cover), null if + * it draws nothing + */ +export function paintExtent(font: Font, glyphs: PaintGlyphs, glyph: number, outline: (glyph: number) => PathCommand[]): { left: number, right: number } | null { + const clip = glyphs.clip(glyph); + if (clip) return { left: clip[0], right: clip[2] }; + const root = glyphs.paint(glyph); + const box = root === null ? null : paintsFor(font, glyphs).bounds(root, [1, 0, 0, 1, 0, 0], outline); + return box && box[2] > box[0] ? { left: box[0], right: box[2] } : null; +} + +/** + * Renders a COLR version 1 glyph into premultiplied RGBA + * @param scale pixels per font unit + * @param offset horizontal subpixel offset + * @param foreground the text color (RGBA, 0xrrggbbaa) for paints in it + * @param outline the outline of a glyph (font units) + * @param rasterize coverage of an outline at pixel coordinates (y up) + * @return the pixels and their placement relative to the pen position on the baseline (top measured upwards) + */ +export function renderPaintGlyph(font: Font, glyphs: PaintGlyphs, glyph: number, scale: number, offset: number, foreground: number, + outline: (glyph: number) => PathCommand[], rasterize: (path: PathCommand[]) => Coverage | null): { data: Uint8ClampedArray, width: number, height: number, left: number, top: number } | null { + const root = glyphs.paint(glyph); + if (root === null) return null; + const p = paintsFor(font, glyphs); + + // font units to pixels (y up) + const base: Matrix = [scale, 0, 0, scale, offset, 0]; + const clip = glyphs.clip(glyph); + const box = clip ? [clip[0] * scale + offset, clip[1] * scale, clip[2] * scale + offset, clip[3] * scale] as Box : p.bounds(root, base, outline); + if (!box) return null; + const left = Math.floor(box[0]), top = Math.ceil(box[3]); + const width = Math.ceil(box[2]) - left, height = top - Math.floor(box[1]); + if (width <= 0 || height <= 0 || width * height * SAMPLES * SAMPLES > 1 << 24) return null; + + // rendered at SAMPLES times the size and averaged down: layers sharing an edge would otherwise each be antialiased + // there, and the ones below would show through + const S = SAMPLES, canvas: Canvas = { left: left * S, top: top * S, width: width * S, height: height * S }; + const layer = render(p, root, [scale * S, 0, 0, scale * S, offset * S, 0], canvas, [0, 0, canvas.width, canvas.height], foreground, outline, rasterize, 0); + const sum = new Float32Array(width * height * 4); + if (layer) + for (let y = 0; y < canvas.height; y++) + for (let x = 0; x < canvas.width; x++) { + const i = (y * canvas.width + x) * 4, o = ((y / S | 0) * width + (x / S | 0)) * 4; + sum[o] += layer.data[i]; + sum[o + 1] += layer.data[i + 1]; + sum[o + 2] += layer.data[i + 2]; + sum[o + 3] += layer.data[i + 3]; + } + const data = new Uint8ClampedArray(width * height * 4), share = 255 / (S * S); + for (let i = 0; i < data.length; i++) data[i] = sum[i] * share; + return { data, width, height, left, top }; +} + +/** a rendered paint: premultiplied RGBA (0-1) of the whole canvas, transparent outside of the box it drew in */ +interface Layer { + data: Float32Array; + box: Box; +} + +const unionBox = (a: Box, b: Box): Box => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])]; + +// renders a paint into a new layer, only within the region [x0, y0, x1, y1) of the canvas, null if it draws nothing +// there +function render(p: Paints, paint: number, m: Matrix, canvas: Canvas, region: Box, foreground: number, + outline: (glyph: number) => PathCommand[], rasterize: (path: PathCommand[]) => Coverage | null, depth: number): Layer | null { + if (depth > MAX_DEPTH || region[0] >= region[2] || region[1] >= region[3]) return null; + const font = p.font, format = font.u8(paint); + const next = (child: number, matrix: Matrix, r: Box): Layer | null => render(p, child, matrix, canvas, r, foreground, outline, rasterize, depth + 1); + + switch (format) { + // layers, drawn over each other + case 1: { + let out: Layer | null = null; + const first = font.u32(paint + 2); + for (let i = 0, n = font.u8(paint + 1); i < n; i++) { + const layer = next(p.layer(first + i), m, region); + if (!layer) continue; + if (!out) out = layer; + else { + composite(3, layer.data, out.data, canvas.width, layer.box); + out.box = unionBox(out.box, layer.box); + } + } + return out; + } + + case 2: case 3: { + const color = colorOf(p, font.u16(paint + 1), p.f2dot14(paint + 3), foreground); + const out = new Float32Array(canvas.width * canvas.height * 4); + fill(out, canvas.width, region, () => color); + return { data: out, box: region }; + } + + case 4: case 5: case 6: case 7: case 8: case 9: + return gradient(p, paint, format, m, canvas, region, foreground); + + // the child paint within a glyph's outline + case 10: { + const path = outline(font.u16(paint + 4)).map(command => transformCommand(command, m)); + const mask = rasterize(path); + if (!mask) return null; + // the mask's pixels on the canvas + const mx = mask.left - canvas.left, my = canvas.top - mask.top; + const inner: Box = [Math.max(region[0], mx), Math.max(region[1], my), Math.min(region[2], mx + mask.width), Math.min(region[3], my + mask.height)]; + const child = next(p.child(paint), m, inner); + if (!child) return null; + const layer = child.data; + for (let y = inner[1]; y < inner[3]; y++) + for (let x = inner[0]; x < inner[2]; x++) { + const c = mask.data[(y - my) * mask.width + x - mx] / 255, o = (y * canvas.width + x) * 4; + layer[o] *= c; + layer[o + 1] *= c; + layer[o + 2] *= c; + layer[o + 3] *= c; + } + return { data: layer, box: inner }; + } + + // another base glyph's paint + case 11: { + const base = p.glyphs.paint(font.u16(paint + 1)); + return base === null ? null : next(base, m, region); + } + + // the source composited over (or with) the backdrop + case 32: { + const backdrop = next(p.child(paint, 5), m, region); + const source = next(p.child(paint), m, region); + const mode = font.u8(paint + 4); + if (!backdrop && !source) return null; + // outside both boxes, every mode leaves the result transparent + const box = backdrop && source ? unionBox(backdrop.box, source.box) : (backdrop ?? source as Layer).box; + const out = backdrop?.data ?? new Float32Array(canvas.width * canvas.height * 4); + composite(mode, source?.data ?? new Float32Array(out.length), out, canvas.width, box); + return { data: out, box }; + } + + default: + if (format >= 12 && format <= 31) return next(p.child(paint), multiply(m, p.transform(paint, format)), region); + return null; + } +} + +function transformCommand(command: PathCommand, m: Matrix): PathCommand { + const out: PathCommand = [command[0]]; + for (let i = 1; i < command.length; i += 2) { + const x = command[i] as number, y = command[i + 1] as number; + out.push(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]); + } + return out; +} + +// a palette color with an alpha factor, premultiplied (0-1) +function colorOf(p: Paints, index: number, alpha: number, foreground: number): [number, number, number, number] { + const rgba = p.palette(index) ?? foreground; + const a = (rgba & 255) / 255 * Math.min(1, Math.max(0, alpha)); + return [(rgba >>> 24) / 255 * a, (rgba >>> 16 & 255) / 255 * a, (rgba >>> 8 & 255) / 255 * a, a]; +} + +// sets every pixel of the region to the color at its center +function fill(out: Float32Array, width: number, region: Box, color: (x: number, y: number) => ArrayLike | null): void { + for (let y = region[1]; y < region[3]; y++) + for (let x = region[0]; x < region[2]; x++) { + const c = color(x, y); + if (!c) continue; + const o = (y * width + x) * 4; + out[o] = c[0]; + out[o + 1] = c[1]; + out[o + 2] = c[2]; + out[o + 3] = c[3]; + } +} + +const LUT = 256; + +// linear (4, 5), radial (6, 7) and sweep (8, 9) gradients: each pixel center mapped back into the paint's space +function gradient(p: Paints, paint: number, format: number, m: Matrix, canvas: Canvas, region: Box, foreground: number): Layer | null { + const font = p.font, inverse = invert(m); + if (!inverse) return null; + + // the color line as a lookup table over its stops' range + const line = p.child(paint), extend = font.u8(line), count = font.u16(line + 1), stride = format & 1 ? 10 : 6; + if (!count) return null; + const stops = Array.from({ length: count }, (_, i) => { + const o = line + 3 + i * stride; + return { offset: p.f2dot14(o), color: colorOf(p, font.u16(o + 2), p.f2dot14(o + 4), foreground) }; + }).sort((a, b) => a.offset - b.offset); + const start = stops[0].offset, end = stops[stops.length - 1].offset, span = end - start; + const table = new Float32Array(LUT * 4); + for (let i = 0; i < LUT; i++) { + const t = start + span * i / (LUT - 1); + let k = 0; + while (k < stops.length - 2 && stops[k + 1].offset < t) k++; + const a = stops[k], b = stops[Math.min(k + 1, stops.length - 1)]; + const f = b.offset > a.offset ? Math.min(1, Math.max(0, (t - a.offset) / (b.offset - a.offset))) : t >= b.offset ? 1 : 0; + for (let c = 0; c < 4; c++) table[i * 4 + c] = a.color[c] + (b.color[c] - a.color[c]) * f; + } + // a position on the color line, extended past its stops + const lookup = (t: number): Float32Array => { + let u = span > 0 ? (t - start) / span : t >= start ? 1 : 0; + if (extend === 1) u -= Math.floor(u); + else if (extend === 2) { + u = Math.abs(u) % 2; + if (u > 1) u = 2 - u; + } + const i = Math.round(Math.min(1, Math.max(0, u)) * (LUT - 1)) * 4; + return table.subarray(i, i + 4); + }; + + const x0 = font.i16(paint + 4), y0 = font.i16(paint + 6); + let position: (x: number, y: number) => number | null; + if (format <= 5) { + // p1 projected onto the perpendicular of p0-p2 through p0 (p2 rotates the gradient) + let dx = font.i16(paint + 8) - x0, dy = font.i16(paint + 10) - y0; + const nx = font.i16(paint + 12) - x0, ny = font.i16(paint + 14) - y0, perp = nx * nx + ny * ny; + if (perp) { + const k = (dx * -ny + dy * nx) / perp; + dx = -ny * k; + dy = nx * k; + } + const length = dx * dx + dy * dy; + if (!length) return null; + position = (x, y) => ((x - x0) * dx + (y - y0) * dy) / length; + } else if (format <= 7) { + // two point conical: the largest t whose circle (with a radius not below 0) goes through the point + const r0 = font.u16(paint + 8), cx = font.i16(paint + 10) - x0, cy = font.i16(paint + 12) - y0, dr = font.u16(paint + 14) - r0; + const a = cx * cx + cy * cy - dr * dr; + position = (x, y) => { + const px = x - x0, py = y - y0; + const b = px * cx + py * cy + r0 * dr, c = px * px + py * py - r0 * r0; + if (Math.abs(a) < 1e-9) { + if (!b) return null; + const t = c / (2 * b); + return r0 + t * dr >= 0 ? t : null; + } + const discriminant = b * b - a * c; + if (discriminant < 0) return null; + const root = Math.sqrt(discriminant), t1 = (b + root) / a, t2 = (b - root) / a; + const high = Math.max(t1, t2), low = Math.min(t1, t2); + return r0 + high * dr >= 0 ? high : r0 + low * dr >= 0 ? low : null; + }; + } else { + // counterclockwise angles, the stored ones are biased by 180 degrees + const startAngle = (p.f2dot14(paint + 8) + 1) * 180, endAngle = (p.f2dot14(paint + 10) + 1) * 180; + position = (x, y) => { + let angle = Math.atan2(y - y0, x - x0) * 180 / Math.PI; + if (angle < 0) angle += 360; + return endAngle === startAngle ? (angle >= startAngle ? 1 : 0) : (angle - startAngle) / (endAngle - startAngle); + }; + } + + const out = new Float32Array(canvas.width * canvas.height * 4); + fill(out, canvas.width, region, (px, py) => { + // the pixel center, back in the paint's space + const x = canvas.left + px + 0.5, y = canvas.top - py - 0.5; + const t = position(inverse[0] * x + inverse[2] * y + inverse[4], inverse[1] * x + inverse[3] * y + inverse[5]); + return t === null ? null : lookup(t); + }); + return { data: out, box: region }; +} + +// separable blend functions of unpremultiplied backdrop and source channels (W3C Compositing and Blending) +const softLight = (b: number, s: number): number => { + if (s <= 0.5) return b - (1 - 2 * s) * b * (1 - b); + const d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : Math.sqrt(b); + return b + (2 * s - 1) * (d - b); +}; +const hardLight = (b: number, s: number): number => s <= 0.5 ? b * 2 * s : 1 - (1 - b) * (1 - (2 * s - 1)); +const BLEND: Record number> = { + 13: (b, s) => b + s - b * s, + 14: (b, s) => hardLight(s, b), + 15: Math.min, + 16: Math.max, + 17: (b, s) => b === 0 ? 0 : s >= 1 ? 1 : Math.min(1, b / (1 - s)), + 18: (b, s) => b >= 1 ? 1 : s <= 0 ? 0 : 1 - Math.min(1, (1 - b) / s), + 19: hardLight, + 20: softLight, + 21: (b, s) => Math.abs(b - s), + 22: (b, s) => b + s - 2 * b * s, + 23: (b, s) => b * s +}; + +// non-separable blend modes (24-27): hue, saturation, color and luminosity, of unpremultiplied RGB +const lum = (c: number[]): number => 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]; +const clipColor = (c: number[]): number[] => { + const l = lum(c), n = Math.min(...c), x = Math.max(...c); + return c.map(v => n < 0 ? l + (v - l) * l / (l - n) : x > 1 ? l + (v - l) * (1 - l) / (x - l) : v); +}; +const setLum = (c: number[], l: number): number[] => clipColor(c.map(v => v + l - lum(c))); +const sat = (c: number[]): number => Math.max(...c) - Math.min(...c); +const setSat = (c: number[], s: number): number[] => { + const max = Math.max(...c), min = Math.min(...c); + return c.map(v => max > min ? (v - min) * s / (max - min) : 0); +}; +const NON_SEPARABLE: Record number[]> = { + 24: (b, s) => setLum(setSat(s, sat(b)), lum(b)), + 25: (b, s) => setLum(setSat(b, sat(s)), lum(b)), + 26: (b, s) => setLum(s, lum(b)), + 27: (b, s) => setLum(b, lum(s)) +}; + +// composites the source onto the backdrop (in place) within the region, both premultiplied +function composite(mode: number, source: Float32Array, backdrop: Float32Array, width: number, region: Box): void { + // source over, the common case + if (mode === 3) { + for (let y = region[1]; y < region[3]; y++) + for (let o = (y * width + region[0]) * 4, end = (y * width + region[2]) * 4; o < end; o += 4) { + const keep = 1 - source[o + 3]; + if (keep === 1) continue; + backdrop[o] = source[o] + backdrop[o] * keep; + backdrop[o + 1] = source[o + 1] + backdrop[o + 1] * keep; + backdrop[o + 2] = source[o + 2] + backdrop[o + 2] * keep; + backdrop[o + 3] = source[o + 3] + backdrop[o + 3] * keep; + } + return; + } + const blend = BLEND[mode], nonSeparable = NON_SEPARABLE[mode]; + for (let y = region[1]; y < region[3]; y++) + for (let x = region[0]; x < region[2]; x++) { + const o = (y * width + x) * 4, as = source[o + 3], ab = backdrop[o + 3]; + // Porter-Duff: result = source * fa + backdrop * fb + let fa = 0, fb = 0; + switch (mode) { + case 0: fa = 0; fb = 0; break; + case 1: fa = 1; fb = 0; break; + case 2: fa = 0; fb = 1; break; + case 4: fa = 1 - ab; fb = 1; break; + case 5: fa = ab; fb = 0; break; + case 6: fa = 0; fb = as; break; + case 7: fa = 1 - ab; fb = 0; break; + case 8: fa = 0; fb = 1 - as; break; + case 9: fa = ab; fb = 1 - as; break; + case 10: fa = 1 - ab; fb = as; break; + case 11: fa = 1 - ab; fb = 1 - as; break; + case 12: fa = 1; fb = 1; break; + default: { + // source over, with the blended color where both are + if (!blend && !nonSeparable || !as || !ab) { fa = 1; fb = 1 - as; break; } + const s = [source[o] / as, source[o + 1] / as, source[o + 2] / as], b = [backdrop[o] / ab, backdrop[o + 1] / ab, backdrop[o + 2] / ab]; + const mixed = nonSeparable ? nonSeparable(b, s) : [blend(b[0], s[0]), blend(b[1], s[1]), blend(b[2], s[2])]; + for (let c = 0; c < 3; c++) backdrop[o + c] = source[o + c] * (1 - ab) + backdrop[o + c] * (1 - as) + as * ab * mixed[c]; + backdrop[o + 3] = as + ab - as * ab; + continue; + } + } + for (let c = 0; c < 4; c++) backdrop[o + c] = Math.min(1, source[o + c] * fa + backdrop[o + c] * fb); + } +} diff --git a/src/render/text.ts b/src/render/text.ts index 192d4c7..1a90f5f 100644 --- a/src/render/text.ts +++ b/src/render/text.ts @@ -7,6 +7,7 @@ import { Font, PathCommand } from './font.ts'; import { Hinter } from './hinting.ts'; import { ColorLayer, BitmapGlyph, parseColorLayers, parseBitmaps } from './color.ts'; import { GlyphBuffer, parseGSUB } from './gsub.ts'; +import { PaintGlyphs, parsePaintGlyphs, paintExtent, renderPaintGlyph } from './colr.ts'; import { decode as decodePNG } from '../codecs/png.ts'; import { resample, TRIANGLE } from '../util/resample.ts'; @@ -46,6 +47,7 @@ interface SizedFont { hinter: Hinter | null; layers: ((glyph: number) => ColorLayer[] | null) | null; bitmaps: ((glyph: number) => BitmapGlyph | null) | null; + paints: PaintGlyphs | null; gsub: ((buffer: GlyphBuffer) => void) | null; glyphs: Map; } @@ -62,8 +64,8 @@ interface Glyph { ink: { left: number, right: number } | null; /** bitmaps per subpixel offset */ bitmaps: Array; - /** the layers or the bitmap of color glyphs */ - color: { layers: ColorLayer[] } | { bitmap: BitmapGlyph } | null; + /** the paint graph (COLR version 1), the layers (version 0) or the bitmap of color glyphs */ + color: { paint: true } | { layers: ColorLayer[] } | { bitmap: BitmapGlyph } | null; /** color bitmaps by subpixel offset and text color (bitmap glyphs only have the one for offset 0) */ colors: Map; } @@ -86,7 +88,7 @@ const SUBPIXELS = 4; // parsed fonts are cached per memory region, so new views of the same font data (e.g. from node Buffers) hit the cache const fonts = new WeakMap>(); const hinters = new WeakMap(); -const substitutions = new WeakMap void) | null, layers: ((glyph: number) => ColorLayer[] | null) | null }>(); +const substitutions = new WeakMap void) | null, layers: ((glyph: number) => ColorLayer[] | null) | null, paints: PaintGlyphs | null }>(); // sizes (and hinting) per font, a few of them const sizes = new WeakMap>(); const SIZES = 16; @@ -116,10 +118,10 @@ function sizedFont(data: Uint8Array, size: number, hinting: boolean): SizedFont if (!hinter.hasInstructions) hinter = null; } let shared = substitutions.get(font); - if (!shared) substitutions.set(font, shared = { gsub: parseGSUB(font), layers: parseColorLayers(font) }); + if (!shared) substitutions.set(font, shared = { gsub: parseGSUB(font), layers: parseColorLayers(font), paints: parsePaintGlyphs(font) }); if (bySize.size >= SIZES) bySize.clear(); - bySize.set(key, sized = { font, scale: size / font.unitsPerEm, hinter, layers: shared.layers, bitmaps: parseBitmaps(font, size), gsub: shared.gsub, glyphs: new Map() }); + bySize.set(key, sized = { font, scale: size / font.unitsPerEm, hinter, layers: shared.layers, bitmaps: parseBitmaps(font, size), paints: shared.paints, gsub: shared.gsub, glyphs: new Map() }); return sized; } @@ -129,10 +131,16 @@ function glyphOf(f: SizedFont, index: number, size: number): Glyph { if (g) return g; const { font, scale, hinter } = f; - const bitmap = f.bitmaps?.(index) ?? null, layers = bitmap ? null : f.layers?.(index) ?? null; + // renderers that support COLR version 1 prefer it over the other color glyphs + const paint = f.paints !== null && f.paints.paint(index) !== null; + const bitmap = paint ? null : f.bitmaps?.(index) ?? null, layers = paint || bitmap ? null : f.layers?.(index) ?? null; const advance = font.advance(index) * scale; g = { index, font: f, path: [], scale, advance: hinter ? Math.round(advance) : advance, ink: null, bitmaps: [], color: null, colors: new Map() }; - if (bitmap) { + if (paint) { + g.color = { paint: true }; + const extent = paintExtent(font, f.paints as PaintGlyphs, index, glyph => font.outline(glyph)); + g.ink = extent && { left: extent.left * scale, right: extent.right * scale }; + } else if (bitmap) { const s = size / bitmap.ppem; g.color = { bitmap }; g.ink = { left: bitmap.left * s, right: (bitmap.left + bitmap.width) * s }; @@ -222,7 +230,8 @@ export async function render(fonts: Uint8Array[], size: number, text: string, la let bitmap = g.colors.get(key); if (bitmap === undefined) g.colors.set(key, bitmap = 'bitmap' in g.color ? await scaledBitmap(g.color.bitmap, size / g.color.bitmap.ppem) - : layeredGlyph(g, g.color.layers, subpixel / SUBPIXELS, foreground)); + : 'layers' in g.color ? layeredGlyph(g, g.color.layers, subpixel / SUBPIXELS, foreground) + : paintedGlyph(g, subpixel / SUBPIXELS, foreground)); if (bitmap) blitColor(color ??= new Uint8ClampedArray(width * height * 4), width, height, bitmap, pixel + bitmap.left, baseline - bitmap.top); continue; } @@ -246,7 +255,7 @@ function shape(paragraph: string, count: number, font: (i: number) => SizedFont, const has = (f: SizedFont, codepoint: number): boolean => f.font.glyphIndex(codepoint) !== 0; const isColor = (f: SizedFont, codepoint: number): boolean => { const index = f.font.glyphIndex(codepoint); - return !!(f.bitmaps?.(index) || f.layers?.(index)); + return !!(f.paints && f.paints.paint(index) !== null || f.bitmaps?.(index) || f.layers?.(index)); }; // the font of a grapheme (later fonts are only loaded when the earlier ones miss a character) @@ -373,6 +382,12 @@ function layeredGlyph(g: Glyph, layers: ColorLayer[], offset: number, foreground return { data, width, height, left, top }; } +// a COLR version 1 glyph, its outlines rasterized like the others +function paintedGlyph(g: Glyph, offset: number, foreground: number): ColorBitmap | null { + const { font } = g.font; + return renderPaintGlyph(font, g.font.paints as PaintGlyphs, g.index, g.scale, offset, foreground, glyph => font.outline(glyph), path => rasterizeGlyph(path, 1)); +} + function premultiply(rgba: Uint8ClampedArray): void { for (let i = 0; i < rgba.length; i += 4) { const a = rgba[i + 3] / 255; diff --git a/tests/font.js b/tests/font.js index d404af4..d472dcd 100644 --- a/tests/font.js +++ b/tests/font.js @@ -3,7 +3,8 @@ https://www.1001fonts.com/carbon-font.html https://www.1001fonts.com/ethnocentric-font.html https://github.com/adobe-fonts/source-sans (SIL OFL, see OFL-SourceSans3.md) - https://github.com/googlefonts/noto-emoji (subset, SIL OFL, see OFL-NotoColorEmoji.md) + https://github.com/googlefonts/noto-emoji (subsets of Noto Color Emoji and Noto-COLRv1, SIL OFL, see OFL-NotoColorEmoji.md) + colrv1 paints.ttf is built by colrv1-paints.py https://github.com/mozilla/twemoji-colr (subset, CC-BY 4.0, see CC-BY-Twemoji.md) */ @@ -155,9 +156,10 @@ const panic = message => { } { - // color glyphs from fallback fonts: CBDT bitmaps (Noto Color Emoji) and COLR layers (Twemoji), emoji sequences + // color glyphs from fallback fonts: CBDT bitmaps (Noto Color Emoji), COLR layers (Twemoji) and paints (Noto-COLRv1), + // emoji sequences const sans = await fs.readFile('./tests/fonts/sourcesans3 regular.otf'); - for (const [file, target] of [['noto color emoji subset.ttf', 'emoji-cbdt'], ['twemoji mozilla subset.ttf', 'emoji-colr']]) { + for (const [file, target] of [['noto color emoji subset.ttf', 'emoji-cbdt'], ['twemoji mozilla subset.ttf', 'emoji-colr'], ['noto colrv1 subset.ttf', 'emoji-colrv1']]) { const emoji = await fs.readFile(`./tests/fonts/${file}`); const image = await Image.renderText([sans, emoji], 48, 'Hi πŸ˜€πŸ”₯ πŸ‘πŸ½ πŸ‡©πŸ‡ͺ 1️⃣ ❀️ πŸ‘¨β€πŸ‘©β€πŸ‘§', {color: 0x000000ff}); const encoded = await image.encode(1, {creationTime: 0, software: ''}); @@ -195,4 +197,23 @@ const panic = message => { const bitmapOnly = await Image.renderText(await fs.readFile('./tests/fonts/noto color emoji subset.ttf'), 32, 'πŸ”₯'); if (bitmapOnly.width < 20) panic('bitmap font should render on its own'); } + + { + // every COLR version 1 paint feature: gradients, transforms, composite modes, the text color + const font = await fs.readFile('./tests/fonts/colrv1 paints.ttf'); + const glyphs = Array.from({length: 17}, (_, k) => String.fromCodePoint(0xe000 + k)).join(''); + const image = await Image.renderText(font, 48, glyphs, {color: 0x00aaaaff}); + const encoded = await image.encode(1, {creationTime: 0, software: ''}); + if (process.env.OVERWRITE_TEST) + await fs.writeFile('./tests/targets/colrv1-paints.png', encoded); + if (!await samePixels(await fs.readFile('./tests/targets/colrv1-paints.png'), encoded)) panic('colrv1-paints doesn\'t match'); + + // the linear gradient runs from red (left) to blue (right), the last glyph's square is in the text color + const linear = await Image.renderText(font, 100, '\ue000'); + const [lr, , lb] = linear.getRGBAAt(Math.round(linear.width * 0.15), linear.height >> 1), [rr, , rb] = linear.getRGBAAt(Math.round(linear.width * 0.85), linear.height >> 1); + if (!(lr > 150 && lb < 100 && rb > 150 && rr < 100)) panic(`colrv1: linear gradient should go from red to blue (${lr},${lb} -> ${rr},${rb})`); + const foreground = await Image.renderText(font, 100, '\ue010', {color: 0x00aaaaff}); + const [fr, fg, fb, fa] = foreground.getRGBAAt(Math.round(foreground.width * 0.12), foreground.height >> 1); + 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})`); + } })(); diff --git a/tests/fonts/colrv1 paints.ttf b/tests/fonts/colrv1 paints.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e7056722bc6d86ee966fefb35109d63ac1399b96 GIT binary patch literal 1664 zcma)6PiP!v6#u>XHk0gTlk7H)l{T756s@gNlazWe(v=8$Seml!K}ume?0(6v&i={F zZW<4jMPl(Jd#V(A@t_D&@hT`ty|mDyc;1r+w4$I`q%!`#nO!#pBhK)B^WN{h_r34^ z8wh|jI#9^1uH_4vbDNmz zmB@cf{^W-D*80=;p7@ZqC&|B8(Qf(qpT4?J`YzY03IY4$#2M-v2TxXNt!=eS3i1iJ zXT3(rwcfT!TVsZ=x!X+~!U4*Ul6UHEO<$P*^(^IIlJ}a8pfxf3(LA7x{5zOfxMqL+ zVzD^2@;f9x{rmeX4=6$OEmM0(Ib;)c@(gPMP}(dGy=59Yiytm4=((FdL?r3FWU^ATcS{u zgp7zSqcSE*Ny)gRWkM!p3cpB3vT{JC?O?O*`Z`gr1|{38*0t*=<4>`Utx= zWv!dGubbL!jrw}s)1{W}H(G8>CxSQi%n@%ucd>M>*-=i*IU|8mKvVVL2Oo% zTdrRW8eX+*2Y#tot*h;ZuPSYwqLJS48|^x4)z8>4parIn6|!C(LdY`DkayYLz#Fi+ zcbH+|8qA!2gCzG|q^L7tsvwW?kHQ+WqabIPbI66L>X$e_kOt3#T#4rTb4-)I!~TT) zzyoaaYmnn{7qNvosWD2yjQqw4TQa+JBh>sR(uV&UIbMO61c{kBNDjwdf|ZNloVi(a zFfu#<*6VcERgT0pjg-`WO4h?lhk;b;Q{$3n-h_Ib7Z{!8c`@=CgpuEb$xert89W`} zPtvzxf_}!~wW262Bab6tYdaiA4kR#k2v{TBg=IKq{l#GfgVcaC5YISIBgK$&j4h(a zScFNM9D@}bGKyI6C@>vCmSrpkQksednsb>fvK*M>THtzapu`-FGx{i(Rv7lJc~FAZ lR1})hFikqVp?1{{u@R!CYuE!sLtHl-Lfj3*4m#I?zX8!<>J9(^ literal 0 HcmV?d00001 diff --git a/tests/fonts/colrv1-paints.py b/tests/fonts/colrv1-paints.py new file mode 100644 index 0000000..4e5a56c --- /dev/null +++ b/tests/fonts/colrv1-paints.py @@ -0,0 +1,65 @@ +# builds "colrv1 paints.ttf" (python colrv1-paints.py "colrv1 paints.ttf", needs fontTools): a COLRv1 test font with one +# glyph per paint feature, on U+E000 onwards +import sys +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.colorLib.builder import buildCOLR, buildCPAL +from fontTools.ttLib.tables.otTables import PaintFormat as F, ExtendMode, CompositeMode + +UPM = 1000 +def rect(x0, y0, x1, y1): + pen = TTGlyphPen(None) + pen.moveTo((x0, y0)); pen.lineTo((x0, y1)); pen.lineTo((x1, y1)); pen.lineTo((x1, y0)); pen.closePath() + return pen.glyph() +def circle(cx, cy, r): + import math + pen = TTGlyphPen(None) + n = 16 + pts = [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)] + pen.moveTo(pts[0]) + for k in range(1, n): pen.lineTo(pts[k]) + pen.closePath() + return pen.glyph() + +names = ['.notdef', 'square', 'disc', 'bar'] +outlines = {'.notdef': rect(100, 0, 900, 800), 'square': rect(100, 0, 900, 800), 'disc': circle(500, 400, 350), 'bar': rect(450, 0, 550, 800)} +features = ['linear', 'linear_p2', 'radial', 'sweep', 'repeat', 'reflect', 'rotate', 'skew', 'transform', 'scale_center', 'colrglyph', 'var_solid', 'multiply', 'src_in', 'xor', 'hue', 'foreground'] +for f in features: names.append(f); outlines[f] = rect(0, 0, 0, 0) if False else TTGlyphPen(None).glyph() + +palette = [(1, 0, 0, 1), (0, 0.6, 0, 1), (0, 0, 1, 1), (1, 0.8, 0, 1), (0.5, 0, 0.5, 0.6)] +def line(stops, extend=ExtendMode.PAD): + return {'Extend': extend, 'ColorStop': [{'StopOffset': o, 'PaletteIndex': i, 'Alpha': 1.0} for o, i in stops]} +rgb = [(0, 0), (0.5, 1), (1, 2)] +glyph = lambda name, paint: {'Format': F.PaintGlyph, 'Glyph': name, 'Paint': paint} +solid = lambda i, a=1.0: {'Format': F.PaintSolid, 'PaletteIndex': i, 'Alpha': a} +paints = { + 'linear': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line(rgb), 'x0': 100, 'y0': 0, 'x1': 900, 'y1': 0, 'x2': 100, 'y2': 800}), + 'linear_p2': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line(rgb), 'x0': 100, 'y0': 0, 'x1': 900, 'y1': 0, 'x2': 900, 'y2': 800}), + 'radial': glyph('square', {'Format': F.PaintRadialGradient, 'ColorLine': line(rgb), 'x0': 500, 'y0': 400, 'r0': 50, 'x1': 500, 'y1': 400, 'r1': 400}), + 'sweep': glyph('disc', {'Format': F.PaintSweepGradient, 'ColorLine': line([(0, 0), (0.33, 1), (0.66, 2), (1, 0)]), 'centerX': 500, 'centerY': 400, 'startAngle': 0, 'endAngle': 360}), + 'repeat': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line([(0, 0), (1, 3)], ExtendMode.REPEAT), 'x0': 400, 'y0': 0, 'x1': 600, 'y1': 0, 'x2': 400, 'y2': 800}), + 'reflect': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line([(0, 0), (1, 3)], ExtendMode.REFLECT), 'x0': 400, 'y0': 0, 'x1': 600, 'y1': 0, 'x2': 400, 'y2': 800}), + 'rotate': {'Format': F.PaintRotateAroundCenter, 'angle': 30, 'centerX': 500, 'centerY': 400, 'Paint': glyph('bar', solid(2))}, + 'skew': {'Format': F.PaintSkewAroundCenter, 'xSkewAngle': 20, 'ySkewAngle': 0, 'centerX': 500, 'centerY': 400, 'Paint': glyph('square', solid(1))}, + 'transform': {'Format': F.PaintTransform, 'Transform': {'xx': 0.5, 'yx': 0, 'xy': 0, 'yy': 0.5, 'dx': 250, 'dy': 200}, 'Paint': glyph('square', solid(0))}, + 'scale_center': {'Format': F.PaintScaleAroundCenter, 'scaleX': 0.5, 'scaleY': 1, 'centerX': 500, 'centerY': 400, 'Paint': glyph('disc', solid(3))}, + 'colrglyph': {'Format': F.PaintColrLayers, 'Layers': [glyph('square', solid(3)), {'Format': F.PaintColrGlyph, 'Glyph': 'rotate'}]}, + 'var_solid': glyph('disc', {'Format': F.PaintVarSolid, 'PaletteIndex': 0, 'Alpha': 0.5, 'VarIndexBase': 0xFFFFFFFF}), + 'multiply': {'Format': F.PaintComposite, 'CompositeMode': CompositeMode.MULTIPLY, 'SourcePaint': glyph('disc', solid(3)), 'BackdropPaint': glyph('square', solid(2))}, + 'src_in': {'Format': F.PaintComposite, 'CompositeMode': CompositeMode.SRC_IN, 'SourcePaint': glyph('square', {'Format': F.PaintLinearGradient, 'ColorLine': line(rgb), 'x0': 100, 'y0': 0, 'x1': 900, 'y1': 0, 'x2': 100, 'y2': 800}), 'BackdropPaint': glyph('disc', solid(0))}, + 'xor': {'Format': F.PaintComposite, 'CompositeMode': CompositeMode.XOR, 'SourcePaint': glyph('disc', solid(0)), 'BackdropPaint': glyph('square', solid(2))}, + 'hue': {'Format': F.PaintComposite, 'CompositeMode': CompositeMode.HSL_HUE, 'SourcePaint': glyph('disc', solid(1)), 'BackdropPaint': glyph('square', solid(0))}, + 'foreground': {'Format': F.PaintColrLayers, 'Layers': [glyph('square', solid(0xFFFF)), glyph('disc', solid(4))]}, +} +fb = FontBuilder(UPM, isTTF=True) +fb.setupGlyphOrder(names) +fb.setupCharacterMap({0xE000 + k: f for k, f in enumerate(features)}) +fb.setupGlyf(outlines) +fb.setupHorizontalMetrics({n: (1000, 0) for n in names}) +fb.setupHorizontalHeader(ascent=900, descent=-100) +fb.setupNameTable({'familyName': 'COLRv1 Test', 'styleName': 'Regular'}) +fb.setupOS2(); fb.setupPost() +fb.font['COLR'] = buildCOLR(paints, glyphMap=fb.font.getReverseGlyphMap(), clipBoxes={f: (0, -100, 1000, 900) for f in features}) +fb.font['CPAL'] = buildCPAL([palette]) +fb.save(sys.argv[1]) +print('features', features) diff --git a/tests/fonts/noto colrv1 subset.ttf b/tests/fonts/noto colrv1 subset.ttf new file mode 100644 index 0000000000000000000000000000000000000000..891421b186a3780b59f1a37ed7a82a5ee317146f GIT binary patch literal 24760 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
  • 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 literal 0 HcmV?d00001 diff --git a/tests/targets/colrv1-paints.png b/tests/targets/colrv1-paints.png new file mode 100644 index 0000000000000000000000000000000000000000..cde42a2a822992c71ba58bc7e5f797999de49ae3 GIT binary patch literal 13269 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 b;D}aL{Rg!?8i}ucIskbY73oSz)6f48EfJ>> literal 0 HcmV?d00001 diff --git a/tests/targets/emoji-colrv1.png b/tests/targets/emoji-colrv1.png new file mode 100644 index 0000000000000000000000000000000000000000..c7d0078511d7fb01587ab2f06d41812eea84e84f GIT binary patch literal 19244 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 literal 0 HcmV?d00001 From 7352d1ab378a5a7ac8065eed906e10caf625b4e3 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 20:50:36 +0000 Subject: [PATCH 2/4] COLR v1: render at 4x, layers only as large as what they draw At 2x, the seams where layers share an edge (Noto's flags) still showed; at 4x they look like the bitmap version. To pay for it: - layers hold only their box instead of the whole canvas (at 4x a 64px emoji's canvas is about 1 MB per layer) - a glyph over a solid color, the most common paint, is the mask in that color without rendering and masking a filled layer - gradients write from the color table directly, without a closure and a subarray per pixel A new emoji glyph at 40-78px takes about 5.5 ms (10 ms before these, at 4x); output differs only by rounding (3 values of one pixel at alpha 8). COLRv1 targets regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- CHANGELOG.md | 2 +- src/render/colr.ts | 191 +++++++++++++++++--------------- tests/targets/colrv1-paints.png | Bin 13269 -> 13242 bytes tests/targets/emoji-colrv1.png | Bin 19244 -> 19184 bytes 4 files changed, 102 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adc26ff..07381f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ emoji font rather than the text font's ❀). - `Image.renderText` renders COLR version 1 color glyphs (e.g. Noto-COLRv1): solid colors, linear, radial and sweep gradients (pad, repeat, reflect), glyph outlines as masks, transforms and all composite modes; variable paints with - their default values. They are rendered at twice the size and scaled down, so layers sharing an edge don't let the + their default values. They are rendered at four times the size and scaled down, so layers sharing an edge don't let the ones below show through. Fonts with both prefer them over their other color glyphs, like browsers. - Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are kept per font and size across calls. diff --git a/src/render/colr.ts b/src/render/colr.ts index 1f8b3cd..72ebbe2 100644 --- a/src/render/colr.ts +++ b/src/render/colr.ts @@ -38,7 +38,7 @@ export interface PaintGlyphs { const MAX_DEPTH = 64; // samples per pixel axis -const SAMPLES = 2; +const SAMPLES = 4; const multiply = (m: Matrix, n: Matrix): Matrix => [ m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], @@ -232,33 +232,37 @@ export function renderPaintGlyph(font: Font, glyphs: PaintGlyphs, glyph: number, const S = SAMPLES, canvas: Canvas = { left: left * S, top: top * S, width: width * S, height: height * S }; const layer = render(p, root, [scale * S, 0, 0, scale * S, offset * S, 0], canvas, [0, 0, canvas.width, canvas.height], foreground, outline, rasterize, 0); const sum = new Float32Array(width * height * 4); - if (layer) - for (let y = 0; y < canvas.height; y++) - for (let x = 0; x < canvas.width; x++) { - const i = (y * canvas.width + x) * 4, o = ((y / S | 0) * width + (x / S | 0)) * 4; + if (layer) { + const [x0, y0, x1, y1] = layer.box, stride = x1 - x0; + for (let y = y0; y < y1; y++) + for (let x = x0, i = (y - y0) * stride * 4; x < x1; x++, i += 4) { + const o = ((y / S | 0) * width + (x / S | 0)) * 4; sum[o] += layer.data[i]; sum[o + 1] += layer.data[i + 1]; sum[o + 2] += layer.data[i + 2]; sum[o + 3] += layer.data[i + 3]; } + } const data = new Uint8ClampedArray(width * height * 4), share = 255 / (S * S); for (let i = 0; i < data.length; i++) data[i] = sum[i] * share; return { data, width, height, left, top }; } -/** a rendered paint: premultiplied RGBA (0-1) of the whole canvas, transparent outside of the box it drew in */ +/** a rendered paint: premultiplied RGBA (0-1) of a box [x0, y0, x1, y1) of the canvas, transparent outside of it */ interface Layer { data: Float32Array; box: Box; } +const blank = (box: Box): Layer => ({ data: new Float32Array((box[2] - box[0]) * (box[3] - box[1]) * 4), box }); const unionBox = (a: Box, b: Box): Box => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])]; +const empty = (box: Box): boolean => box[0] >= box[2] || box[1] >= box[3]; // renders a paint into a new layer, only within the region [x0, y0, x1, y1) of the canvas, null if it draws nothing // there function render(p: Paints, paint: number, m: Matrix, canvas: Canvas, region: Box, foreground: number, outline: (glyph: number) => PathCommand[], rasterize: (path: PathCommand[]) => Coverage | null, depth: number): Layer | null { - if (depth > MAX_DEPTH || region[0] >= region[2] || region[1] >= region[3]) return null; + if (depth > MAX_DEPTH || empty(region)) return null; const font = p.font, format = font.u8(paint); const next = (child: number, matrix: Matrix, r: Box): Layer | null => render(p, child, matrix, canvas, r, foreground, outline, rasterize, depth + 1); @@ -269,21 +273,16 @@ function render(p: Paints, paint: number, m: Matrix, canvas: Canvas, region: Box const first = font.u32(paint + 2); for (let i = 0, n = font.u8(paint + 1); i < n; i++) { const layer = next(p.layer(first + i), m, region); - if (!layer) continue; - if (!out) out = layer; - else { - composite(3, layer.data, out.data, canvas.width, layer.box); - out.box = unionBox(out.box, layer.box); - } + if (layer) out = out ? composite(3, layer, out) : layer; } return out; } case 2: case 3: { const color = colorOf(p, font.u16(paint + 1), p.f2dot14(paint + 3), foreground); - const out = new Float32Array(canvas.width * canvas.height * 4); - fill(out, canvas.width, region, () => color); - return { data: out, box: region }; + const out = blank(region); + for (let o = 0; o < out.data.length; o += 4) out.data.set(color, o); + return out; } case 4: case 5: case 6: case 7: case 8: case 9: @@ -297,18 +296,30 @@ function render(p: Paints, paint: number, m: Matrix, canvas: Canvas, region: Box // the mask's pixels on the canvas const mx = mask.left - canvas.left, my = canvas.top - mask.top; const inner: Box = [Math.max(region[0], mx), Math.max(region[1], my), Math.min(region[2], mx + mask.width), Math.min(region[3], my + mask.height)]; - const child = next(p.child(paint), m, inner); - if (!child) return null; - const layer = child.data; - for (let y = inner[1]; y < inner[3]; y++) - for (let x = inner[0]; x < inner[2]; x++) { - const c = mask.data[(y - my) * mask.width + x - mx] / 255, o = (y * canvas.width + x) * 4; - layer[o] *= c; - layer[o + 1] *= c; - layer[o + 2] *= c; - layer[o + 3] *= c; + if (empty(inner)) return null; + + // a solid color (the common case) is the mask in that color, anything else is masked + const child = p.child(paint), childFormat = font.u8(child); + const solid = childFormat === 2 || childFormat === 3 ? colorOf(p, font.u16(child + 1), p.f2dot14(child + 3), foreground) : null; + const layer = solid ? blank(inner) : next(child, m, inner); + if (!layer) return null; + const [x0, y0, x1, y1] = layer.box, data = layer.data; + for (let y = y0, o = 0; y < y1; y++) + for (let x = x0, k = (y - my) * mask.width + x0 - mx; x < x1; x++, k++, o += 4) { + const c = mask.data[k] / 255; + if (solid) { + data[o] = solid[0] * c; + data[o + 1] = solid[1] * c; + data[o + 2] = solid[2] * c; + data[o + 3] = solid[3] * c; + } else { + data[o] *= c; + data[o + 1] *= c; + data[o + 2] *= c; + data[o + 3] *= c; + } } - return { data: layer, box: inner }; + return layer; } // another base glyph's paint @@ -321,13 +332,9 @@ function render(p: Paints, paint: number, m: Matrix, canvas: Canvas, region: Box case 32: { const backdrop = next(p.child(paint, 5), m, region); const source = next(p.child(paint), m, region); - const mode = font.u8(paint + 4); if (!backdrop && !source) return null; - // outside both boxes, every mode leaves the result transparent - const box = backdrop && source ? unionBox(backdrop.box, source.box) : (backdrop ?? source as Layer).box; - const out = backdrop?.data ?? new Float32Array(canvas.width * canvas.height * 4); - composite(mode, source?.data ?? new Float32Array(out.length), out, canvas.width, box); - return { data: out, box }; + const box = (backdrop ?? source as Layer).box; + return composite(font.u8(paint + 4), source ?? blank(box), backdrop ?? blank(box)); } default: @@ -352,20 +359,6 @@ function colorOf(p: Paints, index: number, alpha: number, foreground: number): [ return [(rgba >>> 24) / 255 * a, (rgba >>> 16 & 255) / 255 * a, (rgba >>> 8 & 255) / 255 * a, a]; } -// sets every pixel of the region to the color at its center -function fill(out: Float32Array, width: number, region: Box, color: (x: number, y: number) => ArrayLike | null): void { - for (let y = region[1]; y < region[3]; y++) - for (let x = region[0]; x < region[2]; x++) { - const c = color(x, y); - if (!c) continue; - const o = (y * width + x) * 4; - out[o] = c[0]; - out[o + 1] = c[1]; - out[o + 2] = c[2]; - out[o + 3] = c[3]; - } -} - const LUT = 256; // linear (4, 5), radial (6, 7) and sweep (8, 9) gradients: each pixel center mapped back into the paint's space @@ -390,20 +383,10 @@ function gradient(p: Paints, paint: number, format: number, m: Matrix, canvas: C const f = b.offset > a.offset ? Math.min(1, Math.max(0, (t - a.offset) / (b.offset - a.offset))) : t >= b.offset ? 1 : 0; for (let c = 0; c < 4; c++) table[i * 4 + c] = a.color[c] + (b.color[c] - a.color[c]) * f; } - // a position on the color line, extended past its stops - const lookup = (t: number): Float32Array => { - let u = span > 0 ? (t - start) / span : t >= start ? 1 : 0; - if (extend === 1) u -= Math.floor(u); - else if (extend === 2) { - u = Math.abs(u) % 2; - if (u > 1) u = 2 - u; - } - const i = Math.round(Math.min(1, Math.max(0, u)) * (LUT - 1)) * 4; - return table.subarray(i, i + 4); - }; const x0 = font.i16(paint + 4), y0 = font.i16(paint + 6); - let position: (x: number, y: number) => number | null; + // the position on the color line (NaN where the gradient is undefined) + let position: (x: number, y: number) => number; if (format <= 5) { // p1 projected onto the perpendicular of p0-p2 through p0 (p2 rotates the gradient) let dx = font.i16(paint + 8) - x0, dy = font.i16(paint + 10) - y0; @@ -424,15 +407,14 @@ function gradient(p: Paints, paint: number, format: number, m: Matrix, canvas: C const px = x - x0, py = y - y0; const b = px * cx + py * cy + r0 * dr, c = px * px + py * py - r0 * r0; if (Math.abs(a) < 1e-9) { - if (!b) return null; const t = c / (2 * b); - return r0 + t * dr >= 0 ? t : null; + return b && r0 + t * dr >= 0 ? t : NaN; } const discriminant = b * b - a * c; - if (discriminant < 0) return null; + if (discriminant < 0) return NaN; const root = Math.sqrt(discriminant), t1 = (b + root) / a, t2 = (b - root) / a; const high = Math.max(t1, t2), low = Math.min(t1, t2); - return r0 + high * dr >= 0 ? high : r0 + low * dr >= 0 ? low : null; + return r0 + high * dr >= 0 ? high : r0 + low * dr >= 0 ? low : NaN; }; } else { // counterclockwise angles, the stored ones are biased by 180 degrees @@ -444,14 +426,27 @@ function gradient(p: Paints, paint: number, format: number, m: Matrix, canvas: C }; } - const out = new Float32Array(canvas.width * canvas.height * 4); - fill(out, canvas.width, region, (px, py) => { - // the pixel center, back in the paint's space - const x = canvas.left + px + 0.5, y = canvas.top - py - 0.5; - const t = position(inverse[0] * x + inverse[2] * y + inverse[4], inverse[1] * x + inverse[3] * y + inverse[5]); - return t === null ? null : lookup(t); - }); - return { data: out, box: region }; + const out = blank(region), data = out.data; + for (let py = region[1], o = 0; py < region[3]; py++) + for (let px = region[0]; px < region[2]; px++, o += 4) { + // the pixel center, back in the paint's space + const x = canvas.left + px + 0.5, y = canvas.top - py - 0.5; + const t = position(inverse[0] * x + inverse[2] * y + inverse[4], inverse[1] * x + inverse[3] * y + inverse[5]); + if (t !== t) continue; + // the color line extended past its stops + let u = span > 0 ? (t - start) / span : t >= start ? 1 : 0; + if (extend === 1) u -= Math.floor(u); + else if (extend === 2) { + u = Math.abs(u) % 2; + if (u > 1) u = 2 - u; + } + const i = Math.round(Math.min(1, Math.max(0, u)) * (LUT - 1)) * 4; + data[o] = table[i]; + data[o + 1] = table[i + 1]; + data[o + 2] = table[i + 2]; + data[o + 3] = table[i + 3]; + } + return out; } // separable blend functions of unpremultiplied backdrop and source channels (W3C Compositing and Blending) @@ -494,25 +489,40 @@ const NON_SEPARABLE: Record number[]> = { 27: (b, s) => setLum(b, lum(s)) }; -// composites the source onto the backdrop (in place) within the region, both premultiplied -function composite(mode: number, source: Float32Array, backdrop: Float32Array, width: number, region: Box): void { - // source over, the common case +// composites the source onto the backdrop: the result covers both boxes (in the backdrop's layer where it does) +function composite(mode: number, source: Layer, backdrop: Layer): Layer { + const box = unionBox(source.box, backdrop.box); + let out = backdrop; + if (box[0] < backdrop.box[0] || box[1] < backdrop.box[1] || box[2] > backdrop.box[2] || box[3] > backdrop.box[3]) { + out = blank(box); + const [x0, y0, x1] = backdrop.box, w = x1 - x0, ow = box[2] - box[0]; + for (let y = y0; y < backdrop.box[3]; y++) out.data.set(backdrop.data.subarray((y - y0) * w * 4, (y - y0 + 1) * w * 4), ((y - box[1]) * ow + x0 - box[0]) * 4); + } + const d = out.data, s = source.data, ow = box[2] - box[0]; + const [sx0, sy0, sx1, sy1] = source.box, sw = sx1 - sx0; + + // source over, the common case: only where the source is if (mode === 3) { - for (let y = region[1]; y < region[3]; y++) - for (let o = (y * width + region[0]) * 4, end = (y * width + region[2]) * 4; o < end; o += 4) { - const keep = 1 - source[o + 3]; + for (let y = sy0; y < sy1; y++) + for (let x = sx0, i = (y - sy0) * sw * 4, o = ((y - box[1]) * ow + sx0 - box[0]) * 4; x < sx1; x++, i += 4, o += 4) { + const keep = 1 - s[i + 3]; if (keep === 1) continue; - backdrop[o] = source[o] + backdrop[o] * keep; - backdrop[o + 1] = source[o + 1] + backdrop[o + 1] * keep; - backdrop[o + 2] = source[o + 2] + backdrop[o + 2] * keep; - backdrop[o + 3] = source[o + 3] + backdrop[o + 3] * keep; + d[o] = s[i] + d[o] * keep; + d[o + 1] = s[i + 1] + d[o + 1] * keep; + d[o + 2] = s[i + 2] + d[o + 2] * keep; + d[o + 3] = s[i + 3] + d[o + 3] * keep; } - return; + return out; } + const blend = BLEND[mode], nonSeparable = NON_SEPARABLE[mode]; - for (let y = region[1]; y < region[3]; y++) - for (let x = region[0]; x < region[2]; x++) { - const o = (y * width + x) * 4, as = source[o + 3], ab = backdrop[o + 3]; + const src = new Float32Array(4); + for (let y = box[1]; y < box[3]; y++) + for (let x = box[0], o = (y - box[1]) * ow * 4; x < box[2]; x++, o += 4) { + // the source pixel, transparent outside of its box + if (x >= sx0 && x < sx1 && y >= sy0 && y < sy1) src.set(s.subarray(((y - sy0) * sw + x - sx0) * 4, ((y - sy0) * sw + x - sx0) * 4 + 4)); + else src.fill(0); + const as = src[3], ab = d[o + 3]; // Porter-Duff: result = source * fa + backdrop * fb let fa = 0, fb = 0; switch (mode) { @@ -531,13 +541,14 @@ function composite(mode: number, source: Float32Array, backdrop: Float32Array, w default: { // source over, with the blended color where both are if (!blend && !nonSeparable || !as || !ab) { fa = 1; fb = 1 - as; break; } - const s = [source[o] / as, source[o + 1] / as, source[o + 2] / as], b = [backdrop[o] / ab, backdrop[o + 1] / ab, backdrop[o + 2] / ab]; - const mixed = nonSeparable ? nonSeparable(b, s) : [blend(b[0], s[0]), blend(b[1], s[1]), blend(b[2], s[2])]; - for (let c = 0; c < 3; c++) backdrop[o + c] = source[o + c] * (1 - ab) + backdrop[o + c] * (1 - as) + as * ab * mixed[c]; - backdrop[o + 3] = as + ab - as * ab; + const sc = [src[0] / as, src[1] / as, src[2] / as], b = [d[o] / ab, d[o + 1] / ab, d[o + 2] / ab]; + const mixed = nonSeparable ? nonSeparable(b, sc) : [blend(b[0], sc[0]), blend(b[1], sc[1]), blend(b[2], sc[2])]; + for (let c = 0; c < 3; c++) d[o + c] = src[c] * (1 - ab) + d[o + c] * (1 - as) + as * ab * mixed[c]; + d[o + 3] = as + ab - as * ab; continue; } } - for (let c = 0; c < 4; c++) backdrop[o + c] = Math.min(1, source[o + c] * fa + backdrop[o + c] * fb); + for (let c = 0; c < 4; c++) d[o + c] = Math.min(1, src[c] * fa + d[o + c] * fb); } + return out; } diff --git a/tests/targets/colrv1-paints.png b/tests/targets/colrv1-paints.png index cde42a2a822992c71ba58bc7e5f797999de49ae3..cec3077f65967ebc62adc40d129254df038ae626 100644 GIT binary patch literal 13242 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 F{|m48pr8N% literal 13269 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 b;D}aL{Rg!?8i}ucIskbY73oSz)6f48EfJ>> diff --git a/tests/targets/emoji-colrv1.png b/tests/targets/emoji-colrv1.png index c7d0078511d7fb01587ab2f06d41812eea84e84f..580a7d7d0a3a8333649573d7a0842f045c505797 100644 GIT binary patch literal 19184 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 FV1gUm?j!&J literal 19244 zcmV*DKy1H>P)WdKxYbu1t-F(67|ZXhu^H!vVDFgh?WIxsLGM@>|+x4u&V07|?`L_t(| z0qtD}d>qBq|IhATKQ*hlcN^Q-G&dZY!Nzn5Kq+vcz<~k>3LGeKpum9w2MQb@4irE^;X5dBpum9w2MQc0 zaG<~e!GQuOD0~P74iq?0;6Q-`1r8KAAUIF}1%(fxz<~k>3LGeKpum9w2LuNSprG&} zw8;VgjH%TCe;(iw4z`OitOu|<6taC-`N0RiHZvC@3miBAIq>}_|2D9)$n!G*Co#Z# zpf!PWL;(IlQ*-k@zr5z+PYyKt4$}#*@oF{HtgQL8;r59FIcePgc&ntTt#gu zfPxOZQU22KlAtCZ$7MDF62?jlqcq0)NDM`s!2`}ijI$OFv@p)Man9FhQMN{bwAA(W z&$o7>N30C{=-~y zSicj_Iq2#Q&gF8!?RFy=3=%n4E?BVOPcvrB_^<#9+Ic&n7lsbiB-^Es*{MO9O<@ZC z0gxG(#ONl3Muyy|7#!7%16<=1nTqpd2Zt8p2+SX~mT|lu3*&Dkmu*o}hIxa@59+k&e(sprda3Z7qdtUD(EifJD z{Ad6LP+&5|dZ!OC_}s{|xs2~>HfbiaOA>4h%*FtTuGG~dW3nKM5EzgOpq~akkYDBC zacLZ4e4L_aw*>w=YB`T;zuD8s{yOTqj{P{SboQj*>r@{IL~uO===Yd#+-xTpIA`a& zW$*j`8B4{ z1Gt&ID4qDC(h@ja&h+5aXNFdte`L$1}NYX^fDX(i%H;7Z89S4>Hdm#XUo$gZz{fg6P>NmDMARQ41HZxh^5JsyRL zD-h?{9neTKTQ+PE{ls9yCfX&*3XS0vg@?II`c1PA~|2#B-E zfiP$gys=l&-_6A6lSP9kmb%JHy(sVF9e&q^C!YP&`EUQj64mRx)2C1OYMORbO-;?H zy1Ke*Ns@*uic-oLqnQzP%yCWAS~N{-jz*)kvMkpW6%{oW6&2NZJf1I>ELrlo@#Xhu z+lCAoGOV$&@%wMR_138X#^tMWS~AGyV$mpqz5wXHzulg?`W^uU4KpVW=A18-fDtKe zmSxDrCE%I{&DR2?=~$3}wVzvpL+02Q68aa6bN0Zhi4%*CTDkJhl(LKm|BT6(0CS}b2C&mZ+JCRRe1?3fPybau9jzpA1ll3SvaJj1PBBu5IO@i=v$=(CMYlf z0=n74V+70OuEiD>#(Pa{ zx@@0^f{1+fDb$AgRy6c+P=6tzHmoaVYQRrcO`5dfs1+-owI=q@n0hk6|7=~iCoo_i zdnlMu57il7z7%RxqcyAW(|m4_SH@PK3XKa@g9?9Cdv(KUBPWb{GyhAr*#H6kHX0in z5sSrI(b!u9TK)KYE1}&*m;agJb0JGl!zC%qErU5Fu~a}xgvliX`ZQA|1qnTZR)b`6 zo@4?60tW&NkfeZq3#7HI0bJo=k}Q-Kxx}hi5dRK5GxB=RWg8#wuJ(4RTqW*G&9CcC z==u|h>h z-Id{R_`9Ed_SpcF1{(Zn&@(R+448K_or!FT|)Za^y&d)9D--jYj)NB9ThJ z-#;)CiA<0r>6jfmc8u0EO^ifDpGlKRV^{A~o+KqEsiHVF9!E<Vtszf!H+We%ItPHCr*J>ES93pU@(^(j>C^caM1!G9Zf_So^mHcL}h4+33)1k zncsdgb?V!PFI_4CUEq|7{dk=J#k#yLS5?Dt=mbM-YZN3<Zo+6i^Bh##TBdv1~!l zs)kYF`chi1xXvgcZ#PmZOm`$1XliOo1QRe=ym&D_{`h0DgiujYfoapG;qb%TU4==M zk;(JfXP@Ey_ut39efv;cT#T_}$Ku2jPs}R0$W(?ktaVGQvHQng1%R$@pJ4``8Sx!1 z!4eI#BBFxRAJuPK?k3 z)k~Y&8Dt(%AM-skTrIk6^Y8W#`?J@o$?7Ow(vbu{f+6C!E%XVDu4~wf(7z(&XsUDTmgWt!#L;XQb+0YrP{!9I2@E$rEB~4?KD+y^(!+$iWR(A zHL3H#+H^obqVe7Y2&j#LaJbtg1XPbOV#axLFl7wRBPX>21Sa1JFwl&iXIVS%>y{Am z>EeA3c;oRn^>+d^YjOEerjG%lIA*iKap?F|KwwnF<89Bt%Nz_15r}03$`aT3)jOt5 zn>T3j;s%q}eB9GP0qG7{0l|U=3oviqJnY!9qg8biCr-q@_uh*`4k7zTxw|b}w&1qg zZo{%=%UWe3{f4{lx(jEY-SJINy$uxn!I)W6N3inC>ns3!TD3S20lI_$zhf?$nG%8o z2%s_A0tkekx+N1Vkl*a4%)6B)v&$EXclrRzNQOm|X^In)0-j&Yt(gepSG1X=xc9kH;M}Ak$?IuqLhfxVHr^(!qiN$Kq#^{4sIPNdh2S zX(NC7l}@WQeR^C+pG~^Vd;y=o*GLFVntJNapR_+U#kh8XwJ_U=kzjU*HAwe!j`;4~t;5qoO6|$z3YEj& z7>|Ajz%SC0b?MOxAW4a^p&^Mmo_+RNF+gYgO{`qG66c+F9-e;sX-t|lDJMGCu3d|{ zbLXZ24{OHy`g&Y<-E{(lTzl=c)~I&OJw6fLO}h!|Jh+5doSJ6E6kKe zE*v-kvU;@9LgPKOv3ww;AsR^z&NvuAK|^z(-+=xk2*~UL{&3#K+nH<1J(Q#a`5Z)Z zt=Sydb4|bB(wV<;67Fdl!og79NGUIJW6Xdm46E*gmlv-_INE!)(W%jlvvU&@p)WgS z+t6WIdBI~*MAoz~=q<7_xD_V05>jERiW{M6)z*4QN}voRJ4mUH$6|=A{#aLPOpR*w zn*Qsnqy!Ea$>2$+K(}Uqq|Crr2qQeMat9CNg_fs_jw`<0*IHx8QfGI2=got} zzKH({OmHc!Ivp5@?TC__o_!t!}(sXJ1b3f4J~jv?cx0` zCCEx(AtD0@%pWok4R_tpjLUArA^j^cW?&_%N|HP5?6Qn7dM_xbKYsEcFpM`DBze7H z&VJ5_u|W*hJZu?-Q&OZpkbXhXuk%@ExPa6+tSLyBz*H<0;Upc{vm4Qk8$b$; zxj2WccU&Z_B^TG&5uc44XCJ?IZQizxn7azX;jn2TP}D_(vKgn>p8Y?~?w@?}N!)Ua zbuQgHJxLyR?b_8kB1_2q_ut=TP>`kQZV!GH04dlde=Ptho8;AbiX*pjnH`eYWsoi* zFad!Po&*GB!7vRBhylJedAfq3zUx3=gJ*_gx+a*-@qS zI6jsKiyi}fb6eNab0I(4SamOC^(39_WJjs5hY%g6J^>*eNk~QXHewd zo{OhX+P0(qh^k7mnUdM<+1m(7#;VM*h0xx}0d9^W0Ai8OjUTckVOVt;#tx{$kg76B z7U@H8Y;z`>95x(k4bKJ$Vp}&Oy8erd%7xbaRzGbYbjl22m7~m&rqxMvqqGnWq%Ab{)%2w-3+9-4rF!o~7IYbk+At1d0jXV4}<&?N=Bg)b8f7;r!b z28L||T9+rNK)`@SCCG8-`SrhXoLv6B$bZf9d-5K=wBiR~>TTvah_z1>@W9k3ASMdQ zh^9*jzO;!?stEqPDIDr6rzM z@?3SNifd1l*{MQ%NhVWln-6jXpXhhVwGJC5>Q`2TF$1eGx?cqxX$GzGL0eCjp)o#K zGHsGjx0gZFKDLDb0hy@ixFco2+Av3vUG$tO1rPv;eA@LDRAG+ZS~3%M7V-uPqR}Ym zvbxQiHw&O(4QtO&gE#5#po4f*GNzw7JxvAJgiazAt%teU;qPQ zpMd;HKp=p@#IshaLT$7?o*jVkx12fczHlgd>`_N#7l>@%Q7c+jR_1y60BqtxOELkB zmZm0XD%svh{r0mlrP$@bIa3cssV6f)%$Bb=hs#{5u(m#Zrmp#Xh;HcoL5wAa#lzN+ zAb(t;)nTiPtLS5W?hZc>?0 z+GCsXzi^uww!iPx`Q$p#?QdFht-m|k(@|Z5BDeD^1qg`uoFSA@o+i~O z{ZzfgYl&^zknF<}p&j$In#p%8d%LOiJr2oavc$Xn(6&K?NHE%ZJ`f-{da?)KyQ%`k9!VJQq_H+tN}&I~NSl>5WidO0WVg$T zV!I5;A1DZ(KXj@jX|F+2-AvMw3t@Dy$Yrhp64PD)(-ff;DF6Y%7+u(b5t`eS;50Ma~q9ZCNuE zBEE7Q&@_%6^-b7T+k~%;83LvC(s{3Lvj!HBEU17-!q5VKNFFapY7pPo`_Dd0v${<4 zn3O)RJ6^!x!Gke!qi7E4WUkgT1k5=2q=^CPi}hOJ=-SwMIOqzb9v8J zo3S>HbL^~d#)jQ>*tEAE;h4U=;HcpP;B{uUk)N@kUJxh)HP&A}k)-acfi%o36#sPN zE4_fwHb{u4E&s@pyrGTcd5ik~_rH(pufIO0@@UE6fBxrxvZmd-bt@JvS|qF$(o(=P z&peYgtr)wz5zvW; zrgXr1wc1OUD2Cy4j~_s_NvNGg*=} zrYIprmTM%NvQ|DyT|H+`{!aJn3^zsrmh6D*e$+W0xHpcqq2^`Q5le8sh zo=DgBFOSe91W0QnTfVd63j8ESIRLwi2xP>HXRXj@f1`x6w@wFL0rPc~rdzD9(i z9bZ$F9e4%^V-8P6R^rs^F&l_xD45=AmYY{dJ^Ioka8U7P@|8@M0?I*K1n%2k{}B}Ksyd-v^4-h z%H5O_FktJBDB^LB=0FTKMZ&(ODWZNGIwJDop?YXfi40GWP8rMXiX`BM6*?zC7gby zWX^^_0L>x@$Phj23~-VqTaN@sVsWh7xvxj+>ZwYKr!U?dd~ZsC1BrDq9u6V$$wwIv zJ?bgCa;+yqsKDYRNqmx)knZ)8oq(#UDm?MT6B!RiDk&+!-FM#&0ub5UqmMo+zyQS) zKzQJR2k_KWPk|&6@4fe4t8D%H^}}y}`&;bXxf36J@PUXY14)-%c3I-NRVE{#w}FCe zbapA6F+QHj5bRj=>l zGwz18KoDsR#6*umdIdH*Rq=3i!N|!xTBKmOTYv!q1Y+qx1(V}K1|GS}ekhoFoE~oi zf;66)EjeNWaMr21lIF%OfEv@!jnD{W(AfvF&_Q6Dw#LkIAm492ZOSkL$B5GFoGGBwF6My&jUp(5J+WXeGe1-?psxI@s2?K!+-kz>|a$Q zde`ZXy_rO$)`B|er&UGMTqsEe$fb6hMqnG^ZRIiJ$O!Hc#+58luZ!v>nJANXx|@D$eFcBlI6#WRz8P4_fa9$t#cN1E zx%azPN0M-K%TBIroFEg(WRPgioH=;))mK3~0a{vGU>=A>;(Y=JZMi8^roiLzfChGQ zZ@u*v2q^sd&ws{;AAX2;-+dRgwY4DW!1(dwg+zcP2{+$-bD~(944-n!DT(K7o`VAv zaEuaD2@=JpfC58ApO74w`Ub$DK{kD$&d|#Y9-Nq1XXC2~A3P{~x*>yY7Fvid3kWd5 zf>An?N$HRPgEFZrpy2ik2b~E?9jk)@O_vl9IAEHvBdRFc#Uw$1kPsMPKuAwaLX7*E z(ZJcK8qA|!eYZs?f@HWPAyD;gm|V{5QMLvJJ$ifOis_{rwlqCGWN;Pw_v^q;Cga=j zha%Wh=R+_O6U$Jv6h&ttkZTS^P*Glt^73Mo6ng~V@OYevhNF&}T{XAWND7@wH!GhF zenggwi#Qj?Kr(#ZiK_A)XDyF@hUUp<)-&R)X^4=nJ|`Me@N|g-jI&q4cnT=!Qh_~~ z*>9Z2XjbV|dKMIz^^) zp=tZ>&~Z+T9N_@z7Fb((|$oZSV?)Nfm!U?;pO}#bY9tz@f%53K)-14 zPXL1o{yiEa)Z+ypFoA%1H-Qv2qM{r)=ZqvEpfEZ&ozhYdZua6ruRujhT+KM02!Eq; zq$5(foz2gmsZlGB^XcEYC-`t&(~1wDkWEnDc5RKmFb4N6fyZeVU?3Qd!Rd9Qs=5zK zOTFS%ru8?z^c{K5l$OG} zV_RY-9vp08F+^Xr!S+jIs&K$GpMO7&d@X`*m*uuHp(r33&sUSEY+d>@u9)qz1} zg@W5|rrBbuI5->eRL)O-(iivKSA%t5=(fOz4YlKrTdU#m&^~@WCsm*PY*S0q<48&% z-Z*^Qg+#S3HC6jh{;vWqWeY#r=ASY7Er6YzbEaO9usNFgRVWNk>zk;%mpC$9L}wH{ z_Sj>f1CnxeG?V}4H@}H1uDBvAI3Ux>Kl|Cw+O=#*0D-n6((%d!IC6ElNDA)EwXoir zjxpvDqpGobkd00n_pHwAk?^xS`=VxF9O1BrK?Ci=)_^rld;Y3Q1&{u60Cw(*2@(iU zn4lv?8j!W10|l}cISaBTrAi8PWkW-U;8rk{2{2#<1tvHk`VJif^sR19 zsQ?U?QN#K>WH=E(${u}LR_`J8Ne*^N=;D6wxL(}zWa~nEN66l~#vd!P#Kw$ne-KZn zm^NfO?Mea=nDOp54o))Dn0LjlAQsg~;WmFRZF`nku=TZ>VdlLvaoO@%!>xX*t0T|p zX*1qWYv%qib>z#oO8|*+ahyay2u3-bhKT)Fg?$1A}R-J ztE;-srhx<6nPt+zYF_Cy8EA!Uu-m1S1EbPYW_^}q%o5uS6OsZ_Ff^qF!Y;xXp?6_| z04p#s>6%8HA2K+aK~sAA6;j&d?~wct^6Rk|Wq3Qd8?vxJL!Y5H9*I4TMQ+k0N zd?t$0sY-t~i_8{I@g0pzPv4Q}T+&jS^mJxB3iqya!2QYe?|kPw)~JmBu8(#Zi|uuRdF@oYt##RYHM6JFH2TOpWw(JrJ6tFjlHCDAufSYJ zNRbX$!GH-6XrLc77$~pE-0hN{!Pd<>7%*u~GKoYB{~^_KmyMQ8=vZaL1_Iea;F?Qu zu$#Wrx%)vfOYiXQ*k1Rz*W+X(NA&9yK?DdQ(Kvj;2#Vb1ZvWiZy$R_@BT?AwO1JE( z&i1*PjcxrZ&6u_a-%R<^!`gaoD~>407M~uTq_*J87Y7)ZZsAO$qyKvK*g;slE}TRJ z5<{Ta$>-&Z582A0-|W**KbNWOZauXy--{Ery-_4%ip8MikYUz1>y6@uYL-#fK9}S12vyMG_jBeMW6E&C$ zMeDcKVbowUZLI@?SdK<*S{hpDSnY5J?#yK(4yE4tm}dQDA@*c#Z? zrjfFs)sPXLj<|Yt*G#^UGU7k}@eh3WyWee-yj(!R4gj6mYff8?dxhg1K4GPodW<5tkfVjkL-7u$|4hGC;6BLk=+=$Wo`O@6-OEVcU zd=%hv0%2X5vehyO@tn#ufq^a|&`*gXJMdg{M^%;9Dqk{-?1}q}w_h|l8jc(ji^ht$ z#`{XLd>Ft-MIy0k0+AKveNj=>zU9hnw9?i?aoU+8P^ab1^Ap=EY@rgNbr=iW841o{q+)r|-yf zN753Jl-|5jh%P+jodX`1I?o@H&u6;f%eN;5;xt7vP-Ge; z%UY2U@zF=^pB9}9C}^*qULVgGwu0kmTH`gW^@|R~ocI2Fkq`e|;J~@xxB=l{0H1xd z7=M3l1-KT*@L_fg8|uKYA$ANIVh8O&s3@1=a?vQ)wu{C%>g(gEt5>mOM-*GP=_Zah zZj68_ML2xYaX9+46VSK+U_AH4gLv$z&vEP370KRBNC)&adX2W6Fa*cX>$o8}S(5sI z#l#jwVY-+GWpNpUwFv?;-HA#42Lg6d%OEKO4dQ8Cw#@;IKMYv4Ocy4YqmG%(dKVHK z4UE7Ap(CP01qivj&BFnQu=G}Sh^LxIo>&);~GtFIPDj3|%K=L1Ou zDl01yjYjd>Yp;Ry2&i8gGiD5C&6BTRl%`KGiW0SE{fsL(V<+wOYI zm6a7A+wi45aT23TvIJR?#r8i^0(82au%%fc>q64)eu>Bz;adfoXe*Is+Aur^5j51n5?TNo!znj~x% zEMB}AH{N(7_UuVr?H~(?4?g&y00jg-R;*Yd`Y$>)kxM(WQP|-)h%dh_nOW*qsrXDv7=U#FRCQh49QJSO_rvraV&Y~U-;)sPhrJ} z3o&evqEB=Z^cl3qIWz(VoRfl2$DOQ2#EhJl3}|L9mJW>Z+zJS+ek(vIE(4e>c$L&9 zBqAw;0SfGHV8+?NKb}i=?UaO^wLKyWEn8?{31A?QLfhIeMp5~?Iou z2yE`&(;%b))qPS=QuGwLQQeP}HrsbJBXGH0WZ{sMI`ZPo*FuGfx99|1M*+^M`eh$x z003)k)=Xx&HH29H1f zIH=sNxPt-|NL+bGQ&sS|ah$JAR)q}{792QYoD(m<-VCm3VvweRx~#ksla85*smGm+ zNne`*n#q&1+3Z5xPa^-$xaP)stX%vyR($XlmVdAS4Ry)0$iFtxZ497>MkI|aq@W+? zP(yw|vVGARJ5pTMf)ZI6q=8rf1B;ZvI{0VxQv@;1P!LRv^27lH7*O1hF~H>M!1A|s z)|fp#-P0}t1Y9f~n2L|Q(kRT#)fx8YoJZqqc*0&p&be|=xO-1MeEv|z3__P6V@#Gn zG0gGyA%2$ZnSyDC;y41Rya;fgXaND4xZhrw5ieH&ayj5U(x}nQrvdSOKx_{X-wVWR zbia5ee+{5o42PQ8s1&Q8*#LWAz%d$d9s+od)bEM*t>cB&g9J0W-$T7YZP<3X+Z7we zxj5#c`zy^-11%Tq3M7{eY!Z9t&}Az~Nxs*)Lk~SvNCe2RkqHQhNE+nHC{bIEBn0NE zhh{pmZ207pPvVX{?f^**-hSKYf6W9PydM;xzf6ijs7#=r{Pd3);>BxbJ z6I>WN$SzJsH2K$9UyJ|#;{|a^vW(%Q4#kiWhhXsVF&Hv@GzJbGg`$#DxIIO1xyd#X z4V?ORvv4SgP|%N-rUvZXwh`MmugCT+>#=piT5S5_(~RprE~kW3Pbkts0QH2XK7q=i z`U9ktr-n84Ws|;++-*$Vrirr^NK!zva%1?-3IftVLzsGOTIwJ}N|ebUFhPW+z^SJI zwR?aqpC^Wa}_~?_(DDpUC(BM{!Ho8!>rGl{1ir~ ze^&zv9d=+ER!d8(`2m$q-X@8Hd66277m!N;xeSm-fmpSb;qjkpp;%Y(~%+nARhR?kkpUrf!=6<24tx5+B(Ai|p^eHW!qDWf?FTM2A zwz4MT=!8T95L>p;`J+0ZpgSoacGVpepn#afnDloZ=eIL5dr3;2G%!&IBWB|aw_a0$ z@BL)A7^l*iN>YZ6pRX2IdYn&k+tsap;I@_=6E_-O~iG+X1&z*U2&!8rvu)5!KSzLV5=gm&cp8ZHe9)aN0;N zV>;_V+mSG9KGSdBshke@B@aNCaP8&G7H|BVjZzA?9^!y|(bAobGbf#8;}R(zCY5J; zsQ#u1hiT4OVyO4naps5*S9VQ?-i@@=P6Ki-SJI{)oopBk=5B(9b{~)lV-pmZW#y{q z*jDon8c?7z{zo~E+jvxCEG~hz$kP%6DZ!H%dDtL3=AKiEzr51a7QZsak)^?4z?U&D zm%u|tI57LxOWX*w1mG=B)icOccXLBC zS{hrLY&P447eDydO_|bkCM@OkW#U15Vb=JL_`54DF@ef9ug)-~kf%!=D0^$gN4}Xy zpUt@T290-JDy##U>98Y`5|E>v21Pb@@~972cfFn-^R{T2&1gLJx zN^K;+;JcyxC~3OZOeQ9EM;IMUkVm(#wi%zU*@{5}E8(>VS7Dx z*qc#Wg}~H{S{LB zOdLrMvp#3?X9`O%J5!oWVI9mzU?&Cew8w)&d|o+F^wyF^fs?0R0M1|KT*zbdT7G(A zq*zFL1oY|13=yX!pFC>uvd-<$N-w+{pJ~~EPD~^qV7ik}J{hA&j}~Vgn34oDF8xl* z$A=wu7)UpPb{kN7GIT`S7z>~vom=faYwQjl(`F&UBxWaR0TUBd(m7;eYZ$|w*H_`k z57l6eajIc^MRp~g^a$qtpc>WXiWneiQcYhtZBqj&7y5!w13oZWx|_{2H+*%jMv2AW z1jB=^L4Ywpo6>@Qqk)qCq}>P{`z_$LyMRbDuy-Rc@))Dn)seYAiWq=EXzfuU1PTZo zD8Q6+fi>>~bc!PVaw(gMAIxlG`G7zf?~H-0&M`yJl^nIZQi_qcva$p-rt1slySD5~ zlt_k)X!&65uu5^tVq;4Pflw6Nch*7P+n6{BO_n9NoOXD!T?fKh_&sIi9^O^J)?mf-{&wrP`5u7pg z5)S@CcbY<`(~x)gRk7MCF~nRFKYh%yWs7^f!JVv-bcN^`WpboPFm>uw;U}X-fBkFQ zcFL@#if_8S8$syEuij^3`r0xX8I83^xa96BG=l zoo|XB?x_S$z8P5gA~2LbTAMEJPwD-MvuTBx(gIpWIQ$e~$t%euX41r600M9p&zmA$ zjz0?eMUv`UUK};YbY$zmG-cf63!&KUK#5lu4U$De`rc?eq}`FUSZ3z6-t9I8jdhI( zhaz+UP5wXuee9z1FglP-{fITJv;L0c>quH+l9}GT8%@eXpJ;sr1%D?v;9juwW#5UD zHA!YKbce)%=1e>NnlNK5F+}Y$P93v!Ro7JRJJHV0W+^Kx69$W_tE=(ii!Tb@0FoTg zR>eE-yc28JuEnRHewwlPLk>Aa>^=yELc-V)nJhm3_~V6*0?I#R$PjGW)V0-xL@Kf4v#czS4wPEZOt)^%Uc>u~RUq zdT^W8?&5T8ksPTiE-OYTpaa5aOoiQ+y4%20PQrRj*jBWbN=x0UaJSU#{e zKxb_05R>5t?!D;k<*x)zntTCcY(58)r-5~`NyIB!G6<_25@TVHq|G{H>8kg-SZv<1 zD@yxAD$VNY6vgS&rwa>)ue|b#uuX8n2`AwG`|rp6`SbC}Bah&P7hXt66N-zAanVH= z;RiqXfjI4uz{8twzKL6Jy%ndPda4L}@4fdxCXd^4xzN{s3)-sT0E}~AzH?X0ANya< zLj3QN%$SgNJ0vkq(LW(MAddvhIkN=EPVwN0=Ng36V0?K$+&cMWxRmxSdn%HI;|Gq! zgo^&Sd&!&F>2E^cDn%$W&OFAe*Jl6%G0qA=z=0S?bZ;%<{uUA~&#SyX{z2Z@9Kcg3 z#T{ViCkRLxoDFc`t=0!~(Nod73NghT&5bRDiQbk-z-&id)Q z%QO#++w->Y9;Y^&RVaiXBdT_k<(RGIR}&o$LYa_7k&ZambN%0&1 zOuDg(%wbmmhmFHw18fRO+j<@__(S?mqTarIuyIda2i{ZrNyD@&s24l$f~Efro-}EO z#-!H)}!YAoGTcT;m;wHn}G2XjG!3@(5Hq=8%#c-St9f*?{s9Frd$i%pgEJny5LTP#1wv$L3fjx_m568aXh?3nr19~1M2bhRPgP<#X=pWxjaA*qLVenx0((PcsP#m_FYi@egXt(cs z0Wok;wKxpPS~Y z+CIgRS0lC1e07rA+++(i`W=m&gE^GA(=MwHyG^sXJgTjvT2p+AGC0)2rn@DzpQluF zYJI@MivCTA@0}WG00>gA5Fo*jEI5rj?GTm?350C?uE^jeT`HMLCJ6?8N>K>#(j3sG z8G?5jP`4d>Lwoom|M#Bun3bRT_U~(Kg0BNcX7XLXSsnp`-F0<{=5WA`nP-3Ai{4tY z&Od$fG|23q048;Wmb$Qph6sm^;r(K-de*Q-E4xh}s3UZ>mG+opjsaaAainW--g)O? z^XAQ2v(aHm=bUp6UVZgdv17s9wU9MKCn647P+;(imtE`oX2auHr$s~e*~%*<$!iC5 zNZ>Mo0bRF1bZp}60P5U~nUpaOCU{QI)^XDG(>)yw2%wo5Z|&VC;Wg&}}p6C6FUTnu$q- zm5&qXB_?{&FK>^X2jN5FzK{l%)aM^#ULB4X+q42_k>rM%&auQ-d&XAzwG&0_G9v(&#?x#XW|}-}sLr zsHJfmAQSMbU~)~ikCS3Bo7-QA!6>pVhJyP5C(q@w#g~tQEbXCXh*P-{*R&Ibyx>(| z@=QQ+rAq9p(;n1Am$5BI-Nfk|+gF5>#Kq8zU=*K_4?*-eWCLoSG49>jl=0fkFO@G* zv5Om5TXt45a_U~f(7SCmUj>^Si%r!!9I#qn~O7E3Gy^OQBkJan5Y-oe;0y%3f zijc}A5N@soFUrxv6k|w4Br)V)0^Fs*8+iQC43z<76DBDxxIDj1FO#qV_#IrGt^EZ4 zws5W=PI1BPzc)t#Vj(lU^xKUY?8y^Z3z^npZ0e!2VZNOfdro6V2mv0CpGu}X#WbRd z^~9%o9+(|V34-3)53%s}K^PJqQk88=<6Qln`JEk>$p!ZH*qzv?uA|zeY(uf8{Ow89Vmuf4mlM4oofbj z6fq7crqZ@cHBkb?(LT9$3L&)a5&0LyGi@x)?!(BB=6BmEsDty5)qH6T-1#r{LTM22 zMnVPn4GAb!?H60noSvB(xrVDC|H`Pys2!w-G){hz&u4lv-HAIhwVt3qL7$y(vR~I&aIT-l9o{`Kza=TV zE5JYUfmuYXxPp~s17yRGFA=O3041`gv5KV4kx|+bU>Ol_R1vX*CcuTb_1 z39q<#`&>g`6FB*u7YGw!=_{U}LJ)>KId8J3FUoMkTq>pMO@`rgo{;wrpofEeOH zqAOgBs-~XKP>vja731)UGw}!Ssl)b(A`4t{y=58SHK1(7V;ckOM9Bz zo}inFYPcUZJK83j&{#n0wae^CrirciMqg++Og3;()ot)5Mpp_vICDSPew{tF?VD(m z6ZP}PTT;36(dlnLM@R90E}AskgoT~!7S|C>X-oQ{jHt9e9>8QdoeF3uJPVj!Pu_~} zalG^Q)RQ&Z?c}aniBC3Z6GpNLk6sp$b^G+JOdXOS?mF-K9sJ#9Scn%N*~@F+GiBm> zQ;|@rE>N$Ao7%^n9FN%&zQQe)7;EykU^tOKTE;&dA^`$PO*!EYRzXb(0IjEyv?ljr z{II$C1XGkSlfwm$h|dq_#`f?yU(>v{U+T&}7|lsd)eE_np2_MOAry_>ULO9gY1CEj8(H*t;OWX~n$0e;3Pgnd#0#5H$3A=6^W=6x2rsbpNf zt8~=o?ZIV0`&d6i{P1JnqSL|u`SqjjjCTnR3Xc(nx0l-Qjb*$2cqm>cIVPf5!>6S= zR4k)Q;y2Hq_+6l~|8+`+Er)rV)Wy8cTe{D{@x#n6HC4&ygiVTn+jzV6#m23Yxv8Yf z3kW(YkQ8^2jTfF9dKYwhF$b|N6s{%KnP-tRUycVo`qR3e{;~rruxGIDC+Y8gY zx)F}#eD%PzP+tnQ6_)04Q^UgNi>;7jBew*xJ=fIC#2igoNTUgunb65J)vuL2-MHHa zo3dPaN1VhVn`q1|MtK~coXxd{uK;Ao^^~1vmm0&1@x3YuSiEpa0b|u&N)>HCokTZi zwzE>o4(%+OEY&(@o#nkVfm`masmW2idMW9JPKk$Hu3MT=l3uMykZM-4t&eB;^SCmK_TM24x?3RhK@?|q{W!_T>(5t3=3hfMJxwO5XCcEb^o=j(fc31WIB<`?&3Z?3pY%{kx_K=Awa4}<8z^o~q0CybfcLY$-& z%2`9I$x)ze&?ap6Ih2?gLRKt>KTh?-Og4r&#c!C*uB5Amzs>+kEwM@qto+Arng&e6 t8Tdp1GVkCH|G}R${so=>&s*{tC}vur>7Kk6@|?c{=;;{UtJKCs{||cnCt3gi From 45dea179e8642f2e31ad5d7e56837d33191613b7 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 21:08:23 +0000 Subject: [PATCH 3/4] renderText: robustness against damaged fonts, normalization, sbix (WIP) - damaged glyf data: non-increasing contour end points leave the glyph empty, composites can't contain themselves and have a command budget, the hinter likewise (cycles, at most 65536 points) - COLR v1 masks rasterize within the canvas only (the rasterizer splits segments at the bitmap edges instead of clamping their end points), the canvas stays within two ems of the em box and lowers its supersampling for very large glyphs - RangeErrors from reading damaged font data become "Font: damaged or truncated font data"; text over 2^28 pixels throws; text without ink (empty, spaces) renders a transparent pixel wide instead of throwing - graphemes are composed (NFC) or decomposed (NFD) where that makes a font have them, like HarfBuzz - sbix color bitmaps (PNG, dupe) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- src/render/color.ts | 63 +++++++++++++++++++++++++----- src/render/colr.ts | 37 ++++++++++++------ src/render/font.ts | 43 +++++++++++++++----- src/render/hinting.ts | 13 +++++++ src/render/text.ts | 91 +++++++++++++++++++++++++++++++++---------- 5 files changed, 195 insertions(+), 52 deletions(-) diff --git a/src/render/color.ts b/src/render/color.ts index 9898dd9..16086c6 100644 --- a/src/render/color.ts +++ b/src/render/color.ts @@ -73,21 +73,64 @@ export function parseColorLayers(font: Font): ((glyph: number) => ColorLayer[] | } /** - * The font's bitmap glyphs (CBDT/CBLC with PNG images) from the strike closest to `size` (the smallest at least as - * large, else the largest), null if the font has none. The lookup returns null for glyphs without a bitmap + * The font's bitmap glyphs (CBDT/CBLC or sbix, with PNG images) from the strike closest to `size` (the smallest at least + * as large, else the largest), null if the font has none. The lookup returns null for glyphs without a bitmap */ export function parseBitmaps(font: Font, size: number): ((glyph: number) => BitmapGlyph | null) | null { + return parseCBDT(font, size) ?? parseSbix(font, size); +} + +// the index of the strike closest to the size (the smallest at least as large, else the largest), -1 if none +function closestStrike(ppems: number[], size: number): number { + let best = -1; + for (const [i, ppem] of ppems.entries()) { + const current = best < 0 ? 0 : ppems[best]; + if (best < 0 || (current < size ? ppem > current : ppem >= size && ppem < current)) best = i; + } + return best; +} + +// Apple's sbix: per strike, glyph data offsets; per glyph, the origin offset, a graphic type and the data +function parseSbix(font: Font, size: number): ((glyph: number) => BitmapGlyph | null) | null { + const sbix = font.tables.sbix?.offset; + if (sbix === undefined) return null; + const strikes = Array.from({ length: font.u32(sbix + 4) }, (_, i) => sbix + font.u32(sbix + 8 + i * 4)); + const index = closestStrike(strikes.map(strike => font.u16(strike)), size); + if (index < 0) return null; + const strike = strikes[index], ppem = font.u16(strike); + + const cache = new Map(); + const lookup = (glyph: number, depth: number): BitmapGlyph | null => { + if (glyph >= font.numGlyphs || depth > 4) return null; + const start = strike + font.u32(strike + 4 + glyph * 4), end = strike + font.u32(strike + 8 + glyph * 4); + if (end - start < 8) return null; + const type = font.u32(start + 4); + // 'dupe': the data of another glyph + if (type === 0x64757065) return lookup(font.u16(start + 8), depth + 1); + if (type !== 0x706e6720) return null; // 'png ' + const png = font.data.subarray(start + 8, end); + // the size from the PNG header (the IHDR chunk follows the signature) + if (png.length < 24) return null; + const header = new DataView(png.buffer, png.byteOffset + 16, 8), height = header.getUint32(4); + // the origin offset places the bitmap's bottom left corner + return { png, ppem, width: header.getUint32(0), height, left: font.i16(start), top: font.i16(start + 2) + height }; + }; + return glyph => { + let result = cache.get(glyph); + if (result === undefined) cache.set(glyph, result = lookup(glyph, 0)); + return result; + }; +} + +// CBDT/CBLC (Google's color bitmaps) +function parseCBDT(font: Font, size: number): ((glyph: number) => BitmapGlyph | null) | null { const cblc = font.tables.CBLC?.offset, cbdt = font.tables.CBDT?.offset; if (cblc === undefined || cbdt === undefined) return null; - const sizes = font.u32(cblc + 4); - let strike = -1, strikePpem = 0; - for (let i = 0; i < sizes; i++) { - const ppem = font.u8(cblc + 8 + i * 48 + 45); - const better = strike < 0 || (strikePpem < size ? ppem > strikePpem : ppem >= size && ppem < strikePpem); - if (better) { strike = cblc + 8 + i * 48; strikePpem = ppem; } - } - if (strike < 0) return null; + const ppems = Array.from({ length: font.u32(cblc + 4) }, (_, i) => font.u8(cblc + 8 + i * 48 + 45)); + const index = closestStrike(ppems, size); + if (index < 0) return null; + const strike = cblc + 8 + index * 48, strikePpem = ppems[index]; const array = cblc + font.u32(strike), subtables = font.u32(strike + 8); diff --git a/src/render/colr.ts b/src/render/colr.ts index 72ebbe2..efc1eb8 100644 --- a/src/render/colr.ts +++ b/src/render/colr.ts @@ -20,6 +20,14 @@ export interface Coverage { top: number; } +/** an area to rasterize within, in pixels (y up) */ +export interface Clip { + left: number; + top: number; + right: number; + bottom: number; +} + /** premultiplied RGBA (0-1) of a canvas whose pixel (0, 0) has its top left corner at (left, top), y up */ interface Canvas { left: number; @@ -37,8 +45,9 @@ export interface PaintGlyphs { } const MAX_DEPTH = 64; -// samples per pixel axis +// samples per pixel axis, fewer where the canvas would get larger than MAX_PIXELS (16 MB per layer) const SAMPLES = 4; +const MAX_PIXELS = 1 << 20; const multiply = (m: Matrix, n: Matrix): Matrix => [ m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], @@ -209,11 +218,11 @@ export function paintExtent(font: Font, glyphs: PaintGlyphs, glyph: number, outl * @param offset horizontal subpixel offset * @param foreground the text color (RGBA, 0xrrggbbaa) for paints in it * @param outline the outline of a glyph (font units) - * @param rasterize coverage of an outline at pixel coordinates (y up) + * @param rasterize coverage of an outline at pixel coordinates (y up), within a clip * @return the pixels and their placement relative to the pen position on the baseline (top measured upwards) */ export function renderPaintGlyph(font: Font, glyphs: PaintGlyphs, glyph: number, scale: number, offset: number, foreground: number, - outline: (glyph: number) => PathCommand[], rasterize: (path: PathCommand[]) => Coverage | null): { data: Uint8ClampedArray, width: number, height: number, left: number, top: number } | null { + outline: (glyph: number) => PathCommand[], rasterize: (path: PathCommand[], clip: Clip) => Coverage | null): { data: Uint8ClampedArray, width: number, height: number, left: number, top: number } | null { const root = glyphs.paint(glyph); if (root === null) return null; const p = paintsFor(font, glyphs); @@ -223,13 +232,16 @@ export function renderPaintGlyph(font: Font, glyphs: PaintGlyphs, glyph: number, const clip = glyphs.clip(glyph); const box = clip ? [clip[0] * scale + offset, clip[1] * scale, clip[2] * scale + offset, clip[3] * scale] as Box : p.bounds(root, base, outline); if (!box) return null; - const left = Math.floor(box[0]), top = Math.ceil(box[3]); - const width = Math.ceil(box[2]) - left, height = top - Math.floor(box[1]); - if (width <= 0 || height <= 0 || width * height * SAMPLES * SAMPLES > 1 << 24) return null; - - // rendered at SAMPLES times the size and averaged down: layers sharing an edge would otherwise each be antialiased - // there, and the ones below would show through - const S = SAMPLES, canvas: Canvas = { left: left * S, top: top * S, width: width * S, height: height * S }; + // within two ems around the em box (damaged fonts can have huge clip boxes and transforms) + const em = font.unitsPerEm * scale; + const left = Math.floor(Math.max(box[0], -2 * em)), top = Math.ceil(Math.min(box[3], 3 * em)); + const width = Math.ceil(Math.min(box[2], 3 * em)) - left, height = top - Math.floor(Math.max(box[1], -3 * em)); + if (!(width > 0 && height > 0) || width * height > MAX_PIXELS) return null; + + // rendered at up to SAMPLES times the size and averaged down: layers sharing an edge would otherwise each be + // antialiased there, and the ones below would show through + const S = Math.max(1, Math.min(SAMPLES, Math.floor(Math.sqrt(MAX_PIXELS / (width * height))))); + const canvas: Canvas = { left: left * S, top: top * S, width: width * S, height: height * S }; const layer = render(p, root, [scale * S, 0, 0, scale * S, offset * S, 0], canvas, [0, 0, canvas.width, canvas.height], foreground, outline, rasterize, 0); const sum = new Float32Array(width * height * 4); if (layer) { @@ -261,7 +273,7 @@ const empty = (box: Box): boolean => box[0] >= box[2] || box[1] >= box[3]; // renders a paint into a new layer, only within the region [x0, y0, x1, y1) of the canvas, null if it draws nothing // there function render(p: Paints, paint: number, m: Matrix, canvas: Canvas, region: Box, foreground: number, - outline: (glyph: number) => PathCommand[], rasterize: (path: PathCommand[]) => Coverage | null, depth: number): Layer | null { + outline: (glyph: number) => PathCommand[], rasterize: (path: PathCommand[], clip: Clip) => Coverage | null, depth: number): Layer | null { if (depth > MAX_DEPTH || empty(region)) return null; const font = p.font, format = font.u8(paint); const next = (child: number, matrix: Matrix, r: Box): Layer | null => render(p, child, matrix, canvas, r, foreground, outline, rasterize, depth + 1); @@ -290,8 +302,9 @@ function render(p: Paints, paint: number, m: Matrix, canvas: Canvas, region: Box // the child paint within a glyph's outline case 10: { + // rasterized within the region only const path = outline(font.u16(paint + 4)).map(command => transformCommand(command, m)); - const mask = rasterize(path); + const mask = rasterize(path, { left: canvas.left + region[0], right: canvas.left + region[2], top: canvas.top - region[1], bottom: canvas.top - region[3] }); if (!mask) return null; // the mask's pixels on the canvas const mx = mask.left - canvas.left, my = canvas.top - mask.top; diff --git a/src/render/font.ts b/src/render/font.ts index 6475941..49a18ff 100644 --- a/src/render/font.ts +++ b/src/render/font.ts @@ -28,6 +28,9 @@ interface CFF { type Dict = Record; +// the most path commands of a glyph outline +const MAX_COMMANDS = 100000; + /** * Parses TrueType (glyf) and OpenType (CFF) fonts, including the first font of a collection */ @@ -90,7 +93,7 @@ export class Font { this.outlines = 'cff'; this.cff = parseCFF(this, this.tables['CFF '].offset); } else if (this.tables.CFF2) throw new Error('Font: CFF2 outlines are not supported'); - else if (this.tables.CBDT && this.tables.CBLC) this.outlines = 'none'; + else if (this.tables.CBDT && this.tables.CBLC || this.tables.sbix) this.outlines = 'none'; else throw new Error('Font: no supported glyph outlines'); this.cmap = parseCmap(this, this.tables.cmap.offset); @@ -180,8 +183,13 @@ export class Font { return { xs: [], ys: [], on: [], ends: [], instructions, xMin, components }; } + // contours end at increasing points (see simpleGlyph) const ends: number[] = []; - for (let i = 0; i < contourCount; i++) ends.push(this.u16(offset + 10 + i * 2)); + for (let i = 0; i < contourCount; i++) { + const end = this.u16(offset + 10 + i * 2); + if (i && end <= ends[i - 1]) return null; + ends.push(end); + } const pointCount = contourCount ? ends[contourCount - 1] + 1 : 0; let p = offset + 10 + contourCount * 2; @@ -223,14 +231,18 @@ export class Font { if (glyph >= this.numGlyphs) glyph = 0; let path = this.outlineCache.get(glyph); if (!path) { - path = this.outlines === 'glyf' ? this.glyfOutline(glyph, 0) : this.outlines === 'cff' ? this.cffOutline(glyph) : []; + path = this.outlines === 'glyf' ? this.glyfOutline(glyph, { expanding: new Set(), budget: MAX_COMMANDS }) : this.outlines === 'cff' ? this.cffOutline(glyph) : []; this.outlineCache.set(glyph, path); } return path; } - glyfOutline(glyph: number, depth: number): PathCommand[] { - if (depth > 8) return []; + /** + * @param expanding the composite glyphs being expanded (a glyph doesn't contain itself), and how many commands are + * left (damaged composites can multiply each other's components endlessly) + */ + glyfOutline(glyph: number, expanding: { expanding: Set, budget: number }): PathCommand[] { + if (expanding.expanding.has(glyph) || expanding.expanding.size > 8 || expanding.budget <= 0) return []; const loca = this.tables.loca.offset; const start = this.indexToLocFormat ? this.u32(loca + glyph * 4) : this.u16(loca + glyph * 2) * 2; @@ -240,12 +252,25 @@ export class Font { const offset = this.tables.glyf.offset + start; const contours = this.i16(offset); - return contours >= 0 ? this.simpleGlyph(offset, contours) : this.compositeGlyph(offset, depth); + if (contours >= 0) { + const path = this.simpleGlyph(offset, contours); + expanding.budget -= path.length; + return expanding.budget >= 0 ? path : []; + } + expanding.expanding.add(glyph); + const path = this.compositeGlyph(offset, expanding); + expanding.expanding.delete(glyph); + return path; } simpleGlyph(offset: number, contourCount: number): PathCommand[] { + // contours end at increasing points, damaged glyphs (as FreeType rejects them) are left empty const endPoints = []; - for (let i = 0; i < contourCount; i++) endPoints.push(this.u16(offset + 10 + i * 2)); + for (let i = 0; i < contourCount; i++) { + const end = this.u16(offset + 10 + i * 2); + if (i && end <= endPoints[i - 1]) return []; + endPoints.push(end); + } const pointCount = contourCount ? endPoints[contourCount - 1] + 1 : 0; let p = offset + 10 + contourCount * 2; @@ -287,7 +312,7 @@ export class Font { return path; } - compositeGlyph(offset: number, depth: number): PathCommand[] { + compositeGlyph(offset: number, expanding: { expanding: Set, budget: number }): PathCommand[] { const path: PathCommand[] = []; let p = offset + 10; @@ -327,7 +352,7 @@ export class Font { p += 8; } - for (const command of this.glyfOutline(glyph, depth + 1)) { + for (const command of this.glyfOutline(glyph, expanding)) { const transformed: PathCommand = [command[0]]; for (let i = 1; i < command.length; i += 2) { const x = command[i] as number, y = command[i + 1] as number; diff --git a/src/render/hinting.ts b/src/render/hinting.ts index d624c89..6bad354 100644 --- a/src/render/hinting.ts +++ b/src/render/hinting.ts @@ -120,6 +120,9 @@ const MAX_INSTRUCTIONS = 1_000_000; /** * Executes TrueType instructions for one font */ +// the most points of a glyph with its components +const MAX_POINTS = 65536; + export class Hinter { font: Font; fpgm: Uint8Array; @@ -131,6 +134,9 @@ export class Hinter { sizes = new Map(); /** 26.6 units per font unit (16.16 fixed point) of the glyph being loaded */ private scaleFixed = 0; + /** the composite glyphs being loaded, and the points loaded so far (damaged composites can contain each other) */ + private loading = new Set(); + private points = 0; constructor(font: Font) { this.font = font; @@ -201,6 +207,8 @@ export class Hinter { if (state.broken) return null; try { + this.loading.clear(); + this.points = 0; const outline = this.load(glyph, state, 0); if (!outline) return { path: [], advance: Math.round(this.font.advance(glyph) * state.scale / 64) }; const { zone, count } = outline; @@ -232,9 +240,12 @@ export class Hinter { */ private load(glyph: number, size: SizeState, depth: number): { zone: Zone, count: number } | null { if (depth > 8) throw new HintError('composite glyphs nested too deep'); + if (this.loading.has(glyph)) throw new HintError('composite glyph containing itself'); const font = this.font; const data: GlyfPoints | null = font.glyfPoints(glyph); if (!data) return null; + this.points += data.xs.length; + if (this.points > MAX_POINTS) throw new HintError('too many points'); const scale = size.scale, scaleFixed = this.scaleFixed = size.scaleFixed; const lsb = font.leftSideBearing(glyph), advance = font.advance(glyph); @@ -248,6 +259,7 @@ export class Hinter { const xsAll: number[] = [], ysAll: number[] = [], on: number[] = [], ends: number[] = []; let useMetrics: { zone: Zone, count: number } | null = null; + this.loading.add(glyph); for (const component of data.components) { const part = this.load(component.glyph, size, depth + 1); if (!part) continue; @@ -281,6 +293,7 @@ export class Hinter { if (component.useMetrics) useMetrics = part; } + this.loading.delete(glyph); count = total; zone = makeZone(count + 4); zone.ends = ends; diff --git a/src/render/text.ts b/src/render/text.ts index 1a90f5f..0d4252d 100644 --- a/src/render/text.ts +++ b/src/render/text.ts @@ -84,6 +84,8 @@ interface Line { // glyphs are rasterized at quarter pixel offsets const SUBPIXELS = 4; +// the largest glyph bitmap (2^26 pixels, 8192 pixels square) +const MAX_GLYPH_PIXELS = 1 << 26; // parsed fonts are cached per memory region, so new views of the same font data (e.g. from node Buffers) hit the cache const fonts = new WeakMap>(); @@ -175,11 +177,26 @@ function glyphOf(f: SizedFont, index: number, size: number): Glyph { * @return the coverage of the outline glyphs, and the premultiplied RGBA pixels of color glyphs (null without any) */ export async function render(fonts: Uint8Array[], size: number, text: string, layout: Layout, hinting = false, foreground = 0xffffffff): Promise<{ width: number, height: number, coverage: Uint8Array, color: Uint8ClampedArray | null }> { + // reading past the end of damaged or truncated font data throws a RangeError + try { + return await layOut(fonts, size, text, layout, hinting, foreground); + } catch (err) { + if (err instanceof RangeError && !(err instanceof TooLarge)) throw new Error('Font: damaged or truncated font data', { cause: err }); + throw err; + } +} + +// rendered text larger than 2^28 pixels (16384 square) +const MAX_PIXELS = 1 << 28; +class TooLarge extends RangeError {} + +async function layOut(fonts: Uint8Array[], size: number, text: string, layout: Layout, hinting: boolean, foreground: number): Promise<{ width: number, height: number, coverage: Uint8Array, color: Uint8ClampedArray | null }> { const sized: SizedFont[] = []; const font = (i: number): SizedFont => sized[i] ??= sizedFont(fonts[i], size, hinting); const primary = font(0); const ascent = Math.ceil(primary.font.ascender * primary.scale); - const lineHeight = Math.ceil((primary.font.ascender - primary.font.descender + primary.font.lineGap) * primary.scale); + // (at least a pixel, damaged fonts can have any metrics) + const lineHeight = Math.max(1, Math.ceil((primary.font.ascender - primary.font.descender + primary.font.lineGap) * primary.scale)); const invisible: Glyph = { index: 0, font: primary, path: [], scale: primary.scale, advance: 0, ink: null, bitmaps: [], color: null, colors: new Map() }; const paragraphs = layout.wrapHardBreaks ? text.split(/\r\n|\r|\n/) : [text.replace(/\r\n|\r|\n/g, '')]; @@ -204,8 +221,10 @@ export async function render(fonts: Uint8Array[], size: number, text: string, la } } - const width = Math.ceil(Math.max(0, ...visible.map(line => line.extent))); + // text without ink (empty, spaces) still makes an image, a transparent pixel wide + const width = Math.max(1, Math.ceil(Math.max(0, ...visible.map(line => line.extent)))); const height = visible.length * lineHeight; + if (!(width * height <= MAX_PIXELS)) throw new TooLarge(`Text too large to render (${width}x${height} pixels)`); const coverage = new Uint8Array(width * height); let color: Uint8ClampedArray | null = null; @@ -247,21 +266,22 @@ export async function render(fonts: Uint8Array[], size: number, text: string, la /** * Turns a paragraph into glyphs: graphemes go to the first font that has all of their (visible) characters, - * preferring color glyphs before an emoji variation selector and others before a text one; each run of a font is - * mapped to glyphs and substituted. Invisible characters that weren't substituted show nothing + * preferring color glyphs before an emoji variation selector and others before a text one (composed or decomposed + * where that makes a font have them); each run of a font is mapped to glyphs and substituted. Invisible characters that + * weren't substituted show nothing */ function shape(paragraph: string, count: number, font: (i: number) => SizedFont, size: number, invisible: Glyph): ShapedGlyph[] { - const codepoints = Array.from(paragraph, char => char.codePointAt(0) as number); + const characters = Array.from(paragraph, char => char.codePointAt(0) as number); const has = (f: SizedFont, codepoint: number): boolean => f.font.glyphIndex(codepoint) !== 0; const isColor = (f: SizedFont, codepoint: number): boolean => { const index = f.font.glyphIndex(codepoint); return !!(f.paints && f.paints.paint(index) !== null || f.bitmaps?.(index) || f.layers?.(index)); }; - // the font of a grapheme (later fonts are only loaded when the earlier ones miss a character) - const choose = (start: number, end: number): number => { - const needed = codepoints.slice(start, end).filter(c => !defaultIgnorable(c)); - const variation = codepoints.slice(start, end).find(c => c === 0xfe0f || c === 0xfe0e); + // the font of a grapheme (later fonts are only loaded when the earlier ones miss a character), -1 if none has all + const choose = (grapheme: number[]): number => { + const needed = grapheme.filter(c => !defaultIgnorable(c)); + const variation = grapheme.find(c => c === 0xfe0f || c === 0xfe0e); let first = -1; for (let i = 0; i < count; i++) { const f = font(i); @@ -272,11 +292,23 @@ function shape(paragraph: string, count: number, font: (i: number) => SizedFont, return first; }; - // fonts per character: whole graphemes where a font has all of them, else character by character - const fontOf = new Int32Array(codepoints.length); - for (const [start, end] of graphemes(codepoints)) { - const chosen = choose(start, end); - for (let k = start; k < end; k++) fontOf[k] = chosen >= 0 ? chosen : Math.max(0, choose(k, k + 1)); + // the characters to shape and their fonts: whole graphemes where a font has all of them, else character by + // character. Like HarfBuzz, combining sequences are composed where a font has the composed character, and + // characters no font has are decomposed where one has their parts + const codepoints: number[] = [], fontOf: number[] = []; + const normalized = (grapheme: number[], form: string): number[] => Array.from(String.fromCodePoint(...grapheme).normalize(form), char => char.codePointAt(0) as number); + for (const [start, end] of graphemes(characters)) { + const grapheme = characters.slice(start, end); + let form = grapheme, chosen = -1; + for (const candidate of grapheme.length > 1 ? [normalized(grapheme, 'NFC'), grapheme] : [grapheme, normalized(grapheme, 'NFD')]) + if ((chosen = choose(candidate)) >= 0) { + form = candidate; + break; + } + for (const c of form) { + codepoints.push(c); + fontOf.push(chosen >= 0 ? chosen : Math.max(0, choose([c]))); + } } const shaped: ShapedGlyph[] = []; @@ -385,7 +417,7 @@ function layeredGlyph(g: Glyph, layers: ColorLayer[], offset: number, foreground // a COLR version 1 glyph, its outlines rasterized like the others function paintedGlyph(g: Glyph, offset: number, foreground: number): ColorBitmap | null { const { font } = g.font; - return renderPaintGlyph(font, g.font.paints as PaintGlyphs, g.index, g.scale, offset, foreground, glyph => font.outline(glyph), path => rasterizeGlyph(path, 1)); + return renderPaintGlyph(font, g.font.paints as PaintGlyphs, g.index, g.scale, offset, foreground, glyph => font.outline(glyph), (path, clip) => rasterizeGlyph(path, 1, 0, clip)); } function premultiply(rgba: Uint8ClampedArray): void { @@ -512,10 +544,11 @@ function blitColor(target: Uint8ClampedArray, width: number, height: number, bit * @param {Array} path outline in font units (y up) * @param {number} scale pixels per font unit * @param {number} [offset=0] horizontal subpixel offset + * @param clip the area (pixels, y up) to rasterize the outline within, if not all of it * @return {{data: Uint8Array, width: number, height: number, left: number, top: number}|null} * left/top position the bitmap relative to the pen position on the baseline (top is measured upwards) */ -export function rasterizeGlyph(path: PathCommand[], scale: number, offset = 0): GlyphBitmap | null { +export function rasterizeGlyph(path: PathCommand[], scale: number, offset = 0, clip?: { left: number, top: number, right: number, bottom: number }): GlyphBitmap | null { if (!path.length) return null; let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; @@ -528,10 +561,18 @@ export function rasterizeGlyph(path: PathCommand[], scale: number, offset = 0): if (y > ymax) ymax = y; } - const left = Math.floor(xmin), right = Math.ceil(xmax); - const bottom = Math.floor(ymin), top = Math.ceil(ymax); + // only the part within the clip: the rasterizer puts what lies left of the bitmap into its first column, what lies + // right of it evens out at the row's end, rows above and below are left out + let left = Math.floor(xmin), right = Math.ceil(xmax), bottom = Math.floor(ymin), top = Math.ceil(ymax); + if (clip) { + left = Math.max(left, clip.left); + right = Math.min(right, clip.right); + bottom = Math.max(bottom, clip.bottom); + top = Math.min(top, clip.top); + } const width = right - left, height = top - bottom; - if (width <= 0 || height <= 0) return null; + // (damaged fonts can have coordinates that aren't numbers, or are far out) + if (!(width > 0 && height > 0) || width * height > MAX_GLYPH_PIXELS) return null; const raster = new Rasterizer(width, height); // flip y and move the bitmap origin to (left, top) @@ -594,8 +635,16 @@ export class Rasterizer { line(x0: number, y0: number, x1: number, y1: number): void { if (y0 === y1) return; - // outlines lie within the bitmap, but subdividing curves can land a hair outside (e.g. -1e-17 for points on the - // left edge, common with hinting); such segments would be dropped (their row index is negative) + // segments crossing the left or right edge are split there, so the parts outside run along the edge: what lies + // left of the bitmap covers its rows from the first column, what lies right of it evens out at the row's end. + // (Outlines clipped to a part, and subdivided curves landing a hair outside, e.g. -1e-17 on the left edge) + for (const edge of [0, this.width]) + if ((x0 - edge) * (x1 - edge) < 0) { + const y = y0 + (y1 - y0) * (edge - x0) / (x1 - x0); + this.line(x0, y0, edge, y); + this.line(edge, y, x1, y1); + return; + } x0 = x0 < 0 ? 0 : x0 > this.width ? this.width : x0; x1 = x1 < 0 ? 0 : x1 > this.width ? this.width : x1; From 9c6f1e2b860ad0ef9bd6b06ee29237c042a96abd Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 21:45:39 +0000 Subject: [PATCH 4/4] 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 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- CHANGELOG.md | 13 ++++++++- tests/font.js | 27 +++++++++++++++++-- tests/fonts/damaged noto colrv1 subset.ttf | Bin 0 -> 24760 bytes tests/fonts/sbix.py | 30 +++++++++++++++++++++ tests/fonts/sbix.ttf | Bin 0 -> 840 bytes tests/regressions.js | 29 ++++++++++++++++++++ 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 tests/fonts/damaged noto colrv1 subset.ttf create mode 100644 tests/fonts/sbix.py create mode 100644 tests/fonts/sbix.ttf diff --git a/CHANGELOG.md b/CHANGELOG.md index 07381f4..9032e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,8 @@ about 2.5 steps. `fast: false` keeps the precise sampling. - `Image.renderText` takes several fonts by priority (`[text, emoji]`): every character comes from the first font that has it, the first font sets the line height. Color glyphs keep their colors: COLR version 0 layers with CPAL - palettes (e.g. Twemoji) and CBDT/CBLC bitmaps (e.g. Noto Color Emoji, which now loads at all, it has no outlines). + palettes (e.g. Twemoji), CBDT/CBLC bitmaps (e.g. Noto Color Emoji, which now loads at all, it has no outlines) and + sbix bitmaps (Apple's format). Invisible characters like joiners and variation selectors take no room, as in browsers. - `Image.renderText` applies the fonts' glyph substitutions (GSUB) that browsers apply by default (`ccmp`, `rlig`, `liga`, `clig`, `calt`): emoji sequences (skin tones, flags, keycaps, families and other joined ones) become one @@ -30,6 +31,8 @@ gradients (pad, repeat, reflect), glyph outlines as masks, transforms and all composite modes; variable paints with their default values. They are rendered at four times the size and scaled down, so layers sharing an edge don't let the ones below show through. Fonts with both prefer them over their other color glyphs, like browsers. +- `Image.renderText` composes combining sequences (e + β—ŒΜ to Γ©, as in text from macOS) where the font has the composed + character, and decomposes characters the fonts lack where one has their parts, like HarfBuzz. - Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are kept per font and size across calls. @@ -45,6 +48,14 @@ glyphs (opaque text renders as before). - The outline of `Image.renderText` follows the glyphs' antialiased edges instead of their pixels, so it is smooth rather than stepped, and it reaches exactly `strokeWidth` beyond the edge. +- `Image.renderText` threw for text without anything to show (empty, only spaces), it now renders a transparent pixel + wide. +- Damaged fonts no longer take unbounded memory or time: glyphs with contour end points that don't increase render + empty (as in FreeType), composite glyphs can't contain themselves (also when hinting), and outlines have a size + limit. Reading past the end of damaged or truncated font data throws "Font: damaged or truncated font data" instead + of a RangeError, and text larger than 2^28 pixels throws. +- Glyph outlines reaching far outside their bitmap rasterize correctly (the parts outside used to change the slope of + the edges inside). ## 3.2.1 diff --git a/tests/font.js b/tests/font.js index d472dcd..0f5cc1d 100644 --- a/tests/font.js +++ b/tests/font.js @@ -3,8 +3,9 @@ https://www.1001fonts.com/carbon-font.html https://www.1001fonts.com/ethnocentric-font.html https://github.com/adobe-fonts/source-sans (SIL OFL, see OFL-SourceSans3.md) - https://github.com/googlefonts/noto-emoji (subsets of Noto Color Emoji and Noto-COLRv1, SIL OFL, see OFL-NotoColorEmoji.md) - colrv1 paints.ttf is built by colrv1-paints.py + https://github.com/googlefonts/noto-emoji (subsets of Noto Color Emoji and Noto-COLRv1, SIL OFL, see OFL-NotoColorEmoji.md; + damaged noto colrv1 subset.ttf is a damaged copy for tests/regressions.js) + colrv1 paints.ttf is built by colrv1-paints.py, sbix.ttf by sbix.py https://github.com/mozilla/twemoji-colr (subset, CC-BY 4.0, see CC-BY-Twemoji.md) */ @@ -216,4 +217,26 @@ const panic = message => { const [fr, fg, fb, fa] = foreground.getRGBAAt(Math.round(foreground.width * 0.12), foreground.height >> 1); 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})`); } + + { + // sbix bitmaps: the PNG placed by its origin offset (8, -4 at 64 ppem), and a 'dupe' of it + const font = await fs.readFile('./tests/fonts/sbix.ttf'); + const square = await Image.renderText(font, 64, '\ue000'), dupe = await Image.renderText(font, 64, '\ue001'); + if (!Buffer.from(square.bitmap).equals(Buffer.from(dupe.bitmap))) panic('sbix: a dupe should render as the glyph it refers to'); + // the baseline is at the ascender (0.9 em), the bitmap's bottom 4 pixels below it, its left edge at 8 + 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); + 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})`); + if (square.getRGBAAt(8 - 2, 30)[3] !== 0) panic('sbix: nothing left of the bitmap'); + } + + { + // text without anything to show renders a transparent pixel wide, it doesn't throw + const font = await fs.readFile('./tests/fonts/sourcesans3 regular.otf'); + for (const text of ['', ' ', '\u200b\ufe0f']) + if ((await Image.renderText(font, 32, text)).width !== 1) panic(`empty text: ${JSON.stringify(text)} should render a pixel wide`); + + // combining sequences are composed where the font has the composed character + const composed = await Image.renderText(font, 32, 'CafΓ© MΓΌller'), decomposed = await Image.renderText(font, 32, 'Cafe\u0301 Mu\u0308ller'); + if (!Buffer.from(composed.bitmap).equals(Buffer.from(decomposed.bitmap))) panic('combining sequences should render like the composed characters'); + } })(); diff --git a/tests/fonts/damaged noto colrv1 subset.ttf b/tests/fonts/damaged noto colrv1 subset.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e4e358c8fec81b753b3795319bb0d59dfa3f3d8a GIT binary patch literal 24760 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 z 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 IpCf_(KXWSw3;+NC literal 0 HcmV?d00001 diff --git a/tests/fonts/sbix.py b/tests/fonts/sbix.py new file mode 100644 index 0000000..e52f145 --- /dev/null +++ b/tests/fonts/sbix.py @@ -0,0 +1,30 @@ +# builds "sbix.ttf" (python sbix.py sbix.ttf, needs fontTools): an sbix test font, U+E000 a red square PNG with a blue +# corner, U+E001 the same bitmap through a 'dupe' record, one strike at 64 ppem +import sys, struct, zlib +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.ttLib import newTable +from fontTools.ttLib.tables.sbixStrike import Strike +from fontTools.ttLib.tables.sbixGlyph import Glyph as SbixGlyph + +def png(width, height, pixel): + rows = b''.join(b'\0' + b''.join(bytes(pixel(x, y)) for x in range(width)) for y in range(height)) + chunk = lambda kind, data: struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data)) + 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'') + +names = ['.notdef', 'square', 'dupe'] +fb = FontBuilder(1000, isTTF=True) +fb.setupGlyphOrder(names) +fb.setupCharacterMap({0xE000: 'square', 0xE001: 'dupe'}) +fb.setupGlyf({n: TTGlyphPen(None).glyph() for n in names}) +fb.setupHorizontalMetrics({n: (1000, 0) for n in names}) +fb.setupHorizontalHeader(ascent=900, descent=-100) +fb.setupNameTable({'familyName': 'sbix Test', 'styleName': 'Regular'}) +fb.setupOS2(); fb.setupPost() +image = png(48, 48, lambda x, y: (0, 0, 255, 255) if x < 12 and y < 12 else (255, 0, 0, 255)) +strike = Strike(ppem=64, resolution=72) +strike.glyphs['square'] = SbixGlyph(glyphName='square', graphicType='png ', originOffsetX=8, originOffsetY=-4, imageData=image) +strike.glyphs['dupe'] = SbixGlyph(glyphName='dupe', graphicType='dupe', referenceGlyphName='square') +sbix = newTable('sbix'); sbix.version = 1; sbix.flags = 1; sbix.strikes = {64: strike} +fb.font['sbix'] = sbix +fb.save(sys.argv[1]) diff --git a/tests/fonts/sbix.ttf b/tests/fonts/sbix.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e76973884b327c353106fe99434ecd314f9cff81 GIT binary patch literal 840 zcma)3O=}ZT6g@L<8q?a;AX$j3Q3O}jqNS87R3*{|p_Uj>M6i=~ngo(&n#{h2 zPQ*opQnC?T3EjAI(H~GL6hRk)<3@rYZWOf$e9H3$ebTbzc@E>SL)VF{s04 Image.colorToRGBA(image.getPixelAt(x, y)); } assert(crc32(data) === zlib.crc32(data), 'crc32: wrong for a long buffer'); } + + // damaged fonts (truncated, bytes changed) render or throw a font error, quickly: no runaway memory or time (contours + // with decreasing end points, composite glyphs containing themselves, huge COLR clip boxes) + { + const fs = require('fs'); + let seed = 7; + const random = () => (seed = Math.imul(seed, 1103515245) + 12345 >>> 0) / 2 ** 32; + // (a damaged copy of the Noto-COLRv1 subset with 3329 contours of decreasing end points in a glyph ran out of memory) + const start = Date.now(); + await Image.renderText(fs.readFileSync('./tests/fonts/damaged noto colrv1 subset.ttf'), 40, 'πŸ˜€πŸ”₯πŸ‘β€οΈ').catch(err => assert(/^Font: /.test(err.message), err.message)); + assert(Date.now() - start < 3000, 'the damaged Noto-COLRv1 subset took too long'); + for (const file of ['sourcesans3 regular.otf', 'opensans bold.ttf', 'noto colrv1 subset.ttf', 'noto color emoji subset.ttf']) { + const original = fs.readFileSync(`./tests/fonts/${file}`); + for (let k = 0; k < 30; k++) { + let data = original.subarray(0, Math.floor(random() * original.length)); + if (k >= 10) { + data = Buffer.from(original); + for (let n = 0; n < 1 + random() * 20; n++) data[Math.floor(random() * data.length)] = random() * 256; + } + const start = Date.now(); + try { + await Image.renderText(data, 40, 'Hi office πŸ˜€πŸ”₯ πŸ‘πŸ½ πŸ‡©πŸ‡ͺ 1️⃣ e\u0301'); + } catch (err) { + assert(/^Font: /.test(err.message), `${file} #${k}: unexpected error for a damaged font: ${err.message}`); + } + assert(Date.now() - start < 3000, `${file} #${k}: a damaged font took ${Date.now() - start} ms`); + } + } + } })();