diff --git a/CHANGELOG.md b/CHANGELOG.md index edd7a3e..9032e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,13 +19,20 @@ 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 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 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. @@ -41,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/src/render/color.ts b/src/render/color.ts index 2420835..16086c6 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 => { @@ -67,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 new file mode 100644 index 0000000..efc1eb8 --- /dev/null +++ b/src/render/colr.ts @@ -0,0 +1,567 @@ +// 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; +} + +/** 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; + 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, 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], + 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), 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[], 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); + + // 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; + // 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) { + 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 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[], 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); + + 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) 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 = 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: + return gradient(p, paint, format, m, canvas, region, foreground); + + // 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, { 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; + 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)]; + 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 layer; + } + + // 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); + if (!backdrop && !source) return null; + const box = (backdrop ?? source as Layer).box; + return composite(font.u8(paint + 4), source ?? blank(box), backdrop ?? blank(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]; +} + +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; + } + + const x0 = font.i16(paint + 4), y0 = font.i16(paint + 6); + // 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; + 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) { + const t = c / (2 * b); + return b && r0 + t * dr >= 0 ? t : NaN; + } + const discriminant = b * b - a * c; + 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 : NaN; + }; + } 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 = 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) +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: 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 = 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; + 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 out; + } + + const blend = BLEND[mode], nonSeparable = NON_SEPARABLE[mode]; + 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) { + 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 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++) d[o + c] = Math.min(1, src[c] * fa + d[o + c] * fb); + } + return out; +} 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 192d4c7..0d4252d 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; } @@ -82,11 +84,13 @@ 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>(); 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 +120,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 +133,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 }; @@ -167,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, '')]; @@ -196,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; @@ -222,7 +249,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; } @@ -238,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.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) - 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); @@ -263,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[] = []; @@ -373,6 +414,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, clip) => rasterizeGlyph(path, 1, 0, clip)); +} + function premultiply(rgba: Uint8ClampedArray): void { for (let i = 0; i < rgba.length; i += 4) { const a = rgba[i + 3] / 255; @@ -497,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; @@ -513,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) @@ -579,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; diff --git a/tests/font.js b/tests/font.js index d404af4..0f5cc1d 100644 --- a/tests/font.js +++ b/tests/font.js @@ -3,7 +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 (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; + 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) */ @@ -155,9 +157,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 +198,45 @@ 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})`); + } + + { + // 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/colrv1 paints.ttf b/tests/fonts/colrv1 paints.ttf new file mode 100644 index 0000000..e705672 Binary files /dev/null and b/tests/fonts/colrv1 paints.ttf differ 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/damaged noto colrv1 subset.ttf b/tests/fonts/damaged noto colrv1 subset.ttf new file mode 100644 index 0000000..e4e358c Binary files /dev/null and b/tests/fonts/damaged noto colrv1 subset.ttf differ diff --git a/tests/fonts/noto colrv1 subset.ttf b/tests/fonts/noto colrv1 subset.ttf new file mode 100644 index 0000000..891421b Binary files /dev/null and b/tests/fonts/noto colrv1 subset.ttf differ 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 0000000..e769738 Binary files /dev/null and b/tests/fonts/sbix.ttf differ diff --git a/tests/regressions.js b/tests/regressions.js index f0b5660..e0922b6 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -532,4 +532,33 @@ const rgba = (image, x, y) => 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`); + } + } + } })(); diff --git a/tests/targets/colrv1-paints.png b/tests/targets/colrv1-paints.png new file mode 100644 index 0000000..cec3077 Binary files /dev/null and b/tests/targets/colrv1-paints.png differ diff --git a/tests/targets/emoji-colrv1.png b/tests/targets/emoji-colrv1.png new file mode 100644 index 0000000..580a7d7 Binary files /dev/null and b/tests/targets/emoji-colrv1.png differ