diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea6d2d2..6672e8b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,14 @@ - `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). - Invisible characters like joiners and variation selectors take no room, as in browsers. Emoji sequences (skin tones, - flags, families) still show as their parts. + 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 ❤). +- 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. ### Performance diff --git a/README.md b/README.md index df8c0d4e..799403c2 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too) - [Rendering SVGs](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (shapes, paths, gradients, strokes, clip paths, masks, CSS; no text, images or filters) - [Rendering vector fonts](https://imagescript.matmen.dev/classes/Image.html#rendertext) (TrueType and OpenType, with kerning, - outlines, TrueType hinting, fallback fonts and color emoji) + ligatures, outlines, TrueType hinting, fallback fonts and color emoji) - [Resizing](https://imagescript.matmen.dev/classes/Image.html#resize) (bilinear, bicubic, area averaging and nearest neighbor) - Image manipulation functions ([crop](https://imagescript.matmen.dev/classes/Image.html#crop) , [rotate](https://imagescript.matmen.dev/classes/Image.html#rotate) diff --git a/src/render/font.ts b/src/render/font.ts index 617b0d63..6475941f 100644 --- a/src/render/font.ts +++ b/src/render/font.ts @@ -598,7 +598,7 @@ function parsePairPos(font: Font, table: number): PairLookup | null { } // returns the coverage index of a glyph, -1 if it isn't covered -function parseCoverage(font: Font, table: number): (glyph: number) => number { +export function parseCoverage(font: Font, table: number): (glyph: number) => number { const format = font.u16(table), count = font.u16(table + 2); return glyph => { @@ -621,7 +621,7 @@ function parseCoverage(font: Font, table: number): (glyph: number) => number { }; } -function parseClassDef(font: Font, table: number): (glyph: number) => number { +export function parseClassDef(font: Font, table: number): (glyph: number) => number { const format = font.u16(table); if (format === 1) { diff --git a/src/render/gsub.ts b/src/render/gsub.ts new file mode 100644 index 00000000..a9f7b5dd --- /dev/null +++ b/src/render/gsub.ts @@ -0,0 +1,359 @@ +// glyph substitution (GSUB) with the features browsers apply by default: composition (emoji sequences, flags, +// keycaps), ligatures and contextual alternates. Script and language systems aren't told apart, every record of these +// features applies (like the kerning of GPOS) + +import { Font, parseCoverage, parseClassDef } from './font.ts'; + +const FEATURES = new Set(['ccmp', 'rlig', 'liga', 'clig', 'calt']); + +/** glyphs being shaped, with the index of the character each one came from (a ligature: its first) */ +export interface GlyphBuffer { + glyphs: number[]; + clusters: number[]; +} + +// a subtable applied at index i: the index to continue at, -1 if it doesn't apply there +type Subtable = (buffer: GlyphBuffer, i: number) => number; + +interface Lookup { + subtables: Subtable[]; + /** whether the lookup flags skip the glyph */ + skip: (glyph: number) => boolean; + /** whether a subtable can start at the glyph */ + starts: (glyph: number) => boolean; +} + +// a glyph test in a context: a glyph id, a class or a coverage +type Matcher = (glyph: number) => boolean; + +/** + * The font's substitutions as a function that rewrites a glyph buffer, null if the font has none + */ +export function parseGSUB(font: Font): ((buffer: GlyphBuffer) => void) | null { + const gsub = font.tables.GSUB?.offset; + if (gsub === undefined) return null; + const featureList = gsub + font.u16(gsub + 6), lookupList = gsub + font.u16(gsub + 8); + const lookupCount = font.u16(lookupList); + + const indices = new Set(); + for (let i = 0, count = font.u16(featureList); i < count; i++) { + const record = featureList + 2 + i * 6; + const tag = String.fromCharCode(font.u8(record), font.u8(record + 1), font.u8(record + 2), font.u8(record + 3)); + if (!FEATURES.has(tag)) continue; + const feature = featureList + font.u16(record + 4); + for (let j = 0, n = font.u16(feature + 2); j < n; j++) { + const index = font.u16(feature + 4 + j * 2); + if (index < lookupCount) indices.add(index); + } + } + if (!indices.size) return null; + + // glyph classes (1 base, 2 ligature, 3 mark) and mark attachment classes for the lookup flags + const gdef = font.tables.GDEF?.offset; + const glyphClass = gdef !== undefined && font.u16(gdef + 4) ? parseClassDef(font, gdef + font.u16(gdef + 4)) : () => 0; + const markClass = gdef !== undefined && font.u16(gdef + 10) ? parseClassDef(font, gdef + font.u16(gdef + 10)) : () => 0; + + // lookups are parsed when first used, contextual ones can call any lookup + const lookups = new Map(); + const lookup = (index: number): Lookup | null => { + if (index >= lookupCount) return null; + let l = lookups.get(index); + if (!l) { + const offset = lookupList + font.u16(lookupList + 2 + index * 2); + const type = font.u16(offset), flag = font.u16(offset + 2); + const skip = skipper(flag, glyphClass, markClass); + // a glyph no subtable covers first is passed over without trying them (memoized per glyph: 0 unknown, + // 1 no, 2 yes) + const covers: Matcher[] = [], memo = new Uint8Array(font.numGlyphs); + const starts = (glyph: number): boolean => { + if (glyph >= memo.length) return covers.some(c => c(glyph)); + if (!memo[glyph]) memo[glyph] = covers.some(c => c(glyph)) ? 2 : 1; + return memo[glyph] === 2; + }; + l = { subtables: [], skip, starts }; + lookups.set(index, l); + for (let i = 0, count = font.u16(offset + 4); i < count; i++) { + const subtableOffset = offset + font.u16(offset + 6 + i * 2); + const subtable = parseSubtable(font, type, subtableOffset, l, applyNested); + if (!subtable) continue; + l.subtables.push(subtable); + covers.push(firstCoverage(font, type, subtableOffset)); + } + } + return l; + }; + + // a lookup called from a context applies once, at the given index + function applyNested(buffer: GlyphBuffer, index: number, i: number): void { + const l = lookup(index); + if (!l || i >= buffer.glyphs.length || l.skip(buffer.glyphs[i]) || !l.starts(buffer.glyphs[i])) return; + for (const subtable of l.subtables) if (subtable(buffer, i) >= 0) return; + } + + const order = [...indices].sort((a, b) => a - b); + return buffer => { + // every lookup goes over the whole buffer, in the order of the lookup list + for (const index of order) { + const l = lookup(index); + if (!l) continue; + for (let i = 0; i < buffer.glyphs.length;) { + let next = -1; + const glyph = buffer.glyphs[i]; + if (l.starts(glyph) && !l.skip(glyph)) for (const subtable of l.subtables) if ((next = subtable(buffer, i)) >= 0) break; + i = next >= 0 ? next : i + 1; + } + } + }; +} + +// which glyphs a lookup's flags skip: bases, ligatures, marks (all or those of another attachment class) +function skipper(flag: number, glyphClass: (glyph: number) => number, markClass: (glyph: number) => number): (glyph: number) => boolean { + const attachment = flag >> 8; + if (!(flag & 0xe) && !attachment) return () => false; + return glyph => { + const c = glyphClass(glyph); + if (c === 1) return (flag & 2) !== 0; + if (c === 2) return (flag & 4) !== 0; + if (c === 3) return (flag & 8) !== 0 || attachment !== 0 && markClass(glyph) !== attachment; + return false; + }; +} + +// the next / previous index the lookup doesn't skip, -1 at the end +function next(buffer: GlyphBuffer, i: number, l: Lookup): number { + for (i++; i < buffer.glyphs.length; i++) if (!l.skip(buffer.glyphs[i])) return i; + return -1; +} + +function previous(buffer: GlyphBuffer, i: number, l: Lookup): number { + for (i--; i >= 0; i--) if (!l.skip(buffer.glyphs[i])) return i; + return -1; +} + +// the glyphs a subtable can start at: its coverage, or the first input coverage of format 3 contexts +function firstCoverage(font: Font, type: number, offset: number): Matcher { + if (type === 7) return firstCoverage(font, font.u16(offset + 2), offset + font.u32(offset + 4)); + let coverage = offset + font.u16(offset + 2); + if (type === 5 && font.u16(offset) === 3) coverage = offset + font.u16(offset + 6); + else if (type === 6 && font.u16(offset) === 3) coverage = offset + font.u16(offset + 6 + font.u16(offset + 2) * 2); + const covered = parseCoverage(font, coverage); + return glyph => covered(glyph) >= 0; +} + +function parseSubtable(font: Font, type: number, offset: number, l: Lookup, nested: (buffer: GlyphBuffer, lookup: number, i: number) => void): Subtable | null { + const format = font.u16(offset); + switch (type) { + // single: a glyph for another + case 1: { + const coverage = parseCoverage(font, offset + font.u16(offset + 2)); + if (format === 1) { + const delta = font.i16(offset + 4); + return (buffer, i) => { + if (coverage(buffer.glyphs[i]) < 0) return -1; + buffer.glyphs[i] = buffer.glyphs[i] + delta & 0xffff; + return i + 1; + }; + } + return (buffer, i) => { + const k = coverage(buffer.glyphs[i]); + if (k < 0) return -1; + buffer.glyphs[i] = font.u16(offset + 6 + k * 2); + return i + 1; + }; + } + + // multiple: a glyph for a sequence (empty ones delete it) + case 2: { + const coverage = parseCoverage(font, offset + font.u16(offset + 2)); + return (buffer, i) => { + const k = coverage(buffer.glyphs[i]); + if (k < 0) return -1; + const sequence = offset + font.u16(offset + 6 + k * 2), count = font.u16(sequence); + const glyphs = Array.from({ length: count }, (_, j) => font.u16(sequence + 2 + j * 2)); + buffer.glyphs.splice(i, 1, ...glyphs); + buffer.clusters.splice(i, 1, ...glyphs.map(() => buffer.clusters[i])); + return i + count; + }; + } + + // ligature: a sequence for a glyph, the first ligature of the set that matches + case 4: { + const coverage = parseCoverage(font, offset + font.u16(offset + 2)); + return (buffer, i) => { + const k = coverage(buffer.glyphs[i]); + if (k < 0) return -1; + const set = offset + font.u16(offset + 6 + k * 2); + for (let j = 0, count = font.u16(set); j < count; j++) { + const ligature = set + font.u16(set + 2 + j * 2), components = font.u16(ligature + 2); + const positions = [i]; + for (let c = 1, p = i; c < components; c++) { + p = next(buffer, p, l); + if (p < 0 || buffer.glyphs[p] !== font.u16(ligature + 2 + c * 2)) break; + positions.push(p); + } + if (positions.length !== components) continue; + + buffer.glyphs[i] = font.u16(ligature); + for (let c = positions.length - 1; c > 0; c--) { + buffer.glyphs.splice(positions[c], 1); + buffer.clusters.splice(positions[c], 1); + } + return i + 1; + } + return -1; + }; + } + + // contextual and chained contextual: lookups applied to a matched sequence + case 5: + case 6: + return parseContext(font, type === 6, offset, l, nested); + + // extension: a subtable of another type behind a 32-bit offset + case 7: + return parseSubtable(font, font.u16(offset + 2), offset + font.u32(offset + 4), l, nested); + + default: + return null; + } +} + +interface Rule { + backtrack: Matcher[]; + input: Matcher[]; + lookahead: Matcher[]; + /** (sequence index, lookup index) pairs */ + records: number[]; +} + +function parseContext(font: Font, chained: boolean, offset: number, l: Lookup, nested: (buffer: GlyphBuffer, lookup: number, i: number) => void): Subtable | null { + const format = font.u16(offset); + const records = (o: number, count: number): number[] => Array.from({ length: count * 2 }, (_, j) => font.u16(o + j * 2)); + const glyphs = (o: number, count: number): Matcher[] => Array.from({ length: count }, (_, j) => { + const id = font.u16(o + j * 2); + return (glyph: number) => glyph === id; + }); + + // rules of format 1 (glyphs) and 2 (classes) sets: [backtrack,] input after the first, [lookahead,] records + const rule = (o: number, matchers: (o: number, count: number, part: number) => Matcher[]): Rule => { + if (!chained) { + const inputCount = font.u16(o), recordCount = font.u16(o + 2); + return { backtrack: [], input: matchers(o + 4, inputCount - 1, 1), lookahead: [], records: records(o + 4 + (inputCount - 1) * 2, recordCount) }; + } + const backtrackCount = font.u16(o); + const backtrack = matchers(o + 2, backtrackCount, 0); + let p = o + 2 + backtrackCount * 2; + const inputCount = font.u16(p); + const input = matchers(p + 2, inputCount - 1, 1); + p += 2 + (inputCount - 1) * 2; + const lookaheadCount = font.u16(p); + const lookahead = matchers(p + 2, lookaheadCount, 2); + p += 2 + lookaheadCount * 2; + return { backtrack, input, lookahead, records: records(p + 2, font.u16(p)) }; + }; + const ruleSet = (set: number, matchers: (o: number, count: number, part: number) => Matcher[]): Rule[] => + Array.from({ length: font.u16(set) }, (_, j) => rule(set + font.u16(set + 2 + j * 2), matchers)); + + if (format === 1 || format === 2) { + const coverage = parseCoverage(font, offset + font.u16(offset + 2)); + let classes: Array<(glyph: number) => number> = []; + let setCount: number, sets: number; + if (format === 1) { + setCount = font.u16(offset + 4); + sets = offset + 6; + } else if (!chained) { + const classDef = parseClassDef(font, offset + font.u16(offset + 4)); + classes = [classDef, classDef, classDef]; + setCount = font.u16(offset + 6); + sets = offset + 8; + } else { + classes = [0, 1, 2].map(k => parseClassDef(font, offset + font.u16(offset + 4 + k * 2))); + setCount = font.u16(offset + 10); + sets = offset + 12; + } + // format 2 rules hold classes of the backtrack (0), input (1) or lookahead (2) class definition + const matchers = format === 1 ? glyphs : (o: number, count: number, part: number): Matcher[] => Array.from({ length: count }, (_, j) => { + const value = font.u16(o + j * 2), classOf = classes[part]; + return (glyph: number) => classOf(glyph) === value; + }); + + const cache = new Map(); + return (buffer, i) => { + const glyph = buffer.glyphs[i]; + if (coverage(glyph) < 0) return -1; + // sets by coverage index (format 1) or by the input class of the first glyph (format 2) + const k = format === 1 ? coverage(glyph) : classes[1](glyph); + if (k >= setCount || !font.u16(sets + k * 2)) return -1; + let rules = cache.get(k); + if (!rules) cache.set(k, rules = ruleSet(offset + font.u16(sets + k * 2), matchers)); + for (const r of rules) { + const end = applyRule(buffer, i, r, l, nested); + if (end >= 0) return end; + } + return -1; + }; + } + + if (format === 3) { + const coverages = (o: number, count: number): Matcher[] => Array.from({ length: count }, (_, j) => { + const coverage = parseCoverage(font, offset + font.u16(o + j * 2)); + return (glyph: number) => coverage(glyph) >= 0; + }); + let r: Rule; + if (!chained) { + const inputCount = font.u16(offset + 2), recordCount = font.u16(offset + 4); + const input = coverages(offset + 6, inputCount); + r = { backtrack: [], input: input.slice(1), lookahead: [], records: records(offset + 6 + inputCount * 2, recordCount) }; + const first = input[0]; + return (buffer, i) => first(buffer.glyphs[i]) ? applyRule(buffer, i, r, l, nested) : -1; + } + const backtrackCount = font.u16(offset + 2); + const backtrack = coverages(offset + 4, backtrackCount); + let p = offset + 4 + backtrackCount * 2; + const inputCount = font.u16(p); + const input = coverages(p + 2, inputCount); + p += 2 + inputCount * 2; + const lookaheadCount = font.u16(p); + const lookahead = coverages(p + 2, lookaheadCount); + p += 2 + lookaheadCount * 2; + r = { backtrack, input: input.slice(1), lookahead, records: records(p + 2, font.u16(p)) }; + const first = input[0]; + return (buffer, i) => first(buffer.glyphs[i]) ? applyRule(buffer, i, r, l, nested) : -1; + } + + return null; +} + +// matches a rule at i (its first input glyph already matched) and applies its lookups: the index after the input +// sequence, -1 if the rule doesn't match +function applyRule(buffer: GlyphBuffer, i: number, r: Rule, l: Lookup, nested: (buffer: GlyphBuffer, lookup: number, i: number) => void): number { + const positions = [i]; + for (let k = 0, p = i; k < r.input.length; k++) { + p = next(buffer, p, l); + if (p < 0 || !r.input[k](buffer.glyphs[p])) return -1; + positions.push(p); + } + for (let k = 0, p = i; k < r.backtrack.length; k++) { + p = previous(buffer, p, l); + if (p < 0 || !r.backtrack[k](buffer.glyphs[p])) return -1; + } + for (let k = 0, p = positions[positions.length - 1]; k < r.lookahead.length; k++) { + p = next(buffer, p, l); + if (p < 0 || !r.lookahead[k](buffer.glyphs[p])) return -1; + } + + // the lookups can change the length, the positions after the changed one move along + let end = positions[positions.length - 1] + 1; + for (let k = 0; k < r.records.length; k += 2) { + const sequenceIndex = r.records[k]; + if (sequenceIndex >= positions.length) continue; + const at = positions[sequenceIndex], before = buffer.glyphs.length; + nested(buffer, r.records[k + 1], at); + const delta = buffer.glyphs.length - before; + if (delta) { + for (let j = sequenceIndex + 1; j < positions.length; j++) positions[j] += delta; + end += delta; + } + } + return end; +} diff --git a/src/render/text.ts b/src/render/text.ts index 2fa60e8b..192d4c7e 100644 --- a/src/render/text.ts +++ b/src/render/text.ts @@ -6,6 +6,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 { decode as decodePNG } from '../codecs/png.ts'; import { resample, TRIANGLE } from '../util/resample.ts'; @@ -37,7 +38,7 @@ interface ColorBitmap { top: number; } -/** a font with what's derived from it for one size */ +/** a font with what's derived from it for one size, and its glyphs (kept across calls) */ interface SizedFont { font: Font; /** pixels per font unit */ @@ -45,6 +46,8 @@ interface SizedFont { hinter: Hinter | null; layers: ((glyph: number) => ColorLayer[] | null) | null; bitmaps: ((glyph: number) => BitmapGlyph | null) | null; + gsub: ((buffer: GlyphBuffer) => void) | null; + glyphs: Map; } interface Glyph { @@ -61,8 +64,14 @@ interface Glyph { bitmaps: Array; /** the layers or the bitmap of color glyphs */ color: { layers: ColorLayer[] } | { bitmap: BitmapGlyph } | null; - /** color bitmaps per subpixel offset (bitmap glyphs only have the one for offset 0) */ - colors: Array; + /** color bitmaps by subpixel offset and text color (bitmap glyphs only have the one for offset 0) */ + colors: Map; +} + +/** a glyph of a shaped paragraph, and whether it is a space (a line can break after it) */ +interface ShapedGlyph { + glyph: Glyph; + space: boolean; } interface Line { @@ -77,6 +86,10 @@ 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 }>(); +// sizes (and hinting) per font, a few of them +const sizes = new WeakMap>(); +const SIZES = 16; function loadFont(data: Uint8Array): Font { let regions = fonts.get(data.buffer); @@ -88,12 +101,63 @@ function loadFont(data: Uint8Array): Font { return font; } +function sizedFont(data: Uint8Array, size: number, hinting: boolean): SizedFont { + const font = loadFont(data); + let bySize = sizes.get(font); + if (!bySize) sizes.set(font, bySize = new Map()); + const key = `${size}:${hinting}`; + let sized = bySize.get(key); + if (sized) return sized; + + let hinter: Hinter | null = null; + if (hinting && font.outlines === 'glyf') { + hinter = hinters.get(font) ?? null; + if (!hinter) hinters.set(font, hinter = new Hinter(font)); + if (!hinter.hasInstructions) hinter = null; + } + let shared = substitutions.get(font); + if (!shared) substitutions.set(font, shared = { gsub: parseGSUB(font), layers: parseColorLayers(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() }); + return sized; +} + +// a glyph of a font, with its outline or color glyph data (hinted glyphs advance by whole pixels) +function glyphOf(f: SizedFont, index: number, size: number): Glyph { + let g = f.glyphs.get(index); + if (g) return g; + + const { font, scale, hinter } = f; + const bitmap = f.bitmaps?.(index) ?? null, layers = 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) { + const s = size / bitmap.ppem; + g.color = { bitmap }; + g.ink = { left: bitmap.left * s, right: (bitmap.left + bitmap.width) * s }; + } else if (layers) { + g.color = { layers }; + g.ink = inkExtent(layers.flatMap(layer => font.outline(layer.glyph)), scale); + } else { + const hinted = hinter && hinter.glyph(index, size); + if (hinted) Object.assign(g, { path: hinted.path, scale: 1, advance: hinted.advance, ink: inkExtent(hinted.path, 1) }); + else { + g.path = font.outline(index); + g.ink = inkExtent(g.path, scale); + } + } + f.glyphs.set(index, g); + return g; +} + /** * Lays out and rasterizes text - * Lines start at ceil(ascender) and advance by ceil(ascender - descender + lineGap) of the first font; every character - * comes from the first font that has a glyph for it. Glyphs are placed with subpixel precision using their advance - * widths and the font's kerning; the canvas spans from the origin to the furthest glyph edge - * @param fonts TrueType/OpenType fonts, by priority + * Lines start at ceil(ascender) and advance by ceil(ascender - descender + lineGap) of the first font. Every grapheme + * (a character with its joined, modifying and combining ones, like an emoji sequence) comes from the first font that + * has all of it; runs of a font are shaped with its substitutions (GSUB). Glyphs are placed with subpixel precision + * using their advance widths and the font's kerning; the canvas spans from the origin to the furthest glyph edge + * @param fonts TrueType/OpenType fonts, by priority (later ones are only loaded when the text needs them) * @param size font size in pixels per em * @param text * @param layout @@ -103,64 +167,23 @@ function loadFont(data: Uint8Array): Font { * @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 }> { - const sized: SizedFont[] = fonts.map(data => { - const font = loadFont(data); - let hinter: Hinter | null = null; - if (hinting && font.outlines === 'glyf') { - hinter = hinters.get(font) ?? null; - if (!hinter) hinters.set(font, hinter = new Hinter(font)); - if (!hinter.hasInstructions) hinter = null; - } - return { font, scale: size / font.unitsPerEm, hinter, layers: parseColorLayers(font), bitmaps: parseBitmaps(font, size) }; - }); - const primary = sized[0]; + 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); - const glyphs = new Map(); - const make = (f: SizedFont, index: number): Glyph => { - const { font, scale, hinter } = f; - const bitmap = f.bitmaps?.(index) ?? null, layers = bitmap ? null : f.layers?.(index) ?? null; - // hinted glyphs go on whole pixels (it only makes sense for hinted outlines) - const advance = font.advance(index) * scale; - const g: Glyph = { index, font: f, path: [], scale, advance: hinter ? Math.round(advance) : advance, ink: null, bitmaps: [], color: null, colors: [] }; - if (bitmap) { - const s = size / bitmap.ppem; - g.color = { bitmap }; - g.ink = { left: bitmap.left * s, right: (bitmap.left + bitmap.width) * s }; - } else if (layers) { - g.color = { layers }; - g.ink = inkExtent(layers.flatMap(layer => font.outline(layer.glyph)), scale); - } else { - const hinted = hinter && hinter.glyph(index, size); - if (hinted) Object.assign(g, { path: hinted.path, scale: 1, advance: hinted.advance, ink: inkExtent(hinted.path, 1) }); - else { - g.path = font.outline(index); - g.ink = inkExtent(g.path, scale); - } - } - return g; - }; - const glyph = (codepoint: number): Glyph => { - let g = glyphs.get(codepoint); - if (!g) { - const f = sized.find(f => f.font.glyphIndex(codepoint)); - // invisible characters (joiners, variation selectors, ...) show nothing even where fonts have glyphs for - // them (as HarfBuzz does), characters no font has show the first one's .notdef - if (defaultIgnorable(codepoint)) g = { index: 0, font: primary, path: [], scale: primary.scale, advance: 0, ink: null, bitmaps: [], color: null, colors: [] }; - else g = f ? make(f, f.font.glyphIndex(codepoint)) : make(primary, 0); - glyphs.set(codepoint, g); - } - return g; - }; + 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, '')]; + const shaped = paragraphs.map(paragraph => shape(paragraph, fonts.length, font, size, invisible)); const kern = (left: Glyph, right: Glyph): number => { - const kerning = left.font === right.font && left.font.font.kerning; + const kerning = left.font === right.font && left !== invisible && right !== invisible && left.font.font.kerning; if (!kerning) return 0; const k = kerning(left.index, right.index) * left.font.scale; return left.font.hinter ? Math.round(k) : k; }; - const lines = wrap(text, layout, glyph, kern); + const lines = wrap(shaped, layout, kern); // lines that fit into maxHeight, chosen by the vertical alignment let visible = lines; @@ -195,10 +218,11 @@ export async function render(fonts: Uint8Array[], size: number, text: string, la if (g.color) { // bitmaps are placed on whole pixels if ('bitmap' in g.color) subpixel = 0; - if (g.colors[subpixel] === undefined) g.colors[subpixel] = 'bitmap' in g.color + const key = 'bitmap' in g.color ? '0' : `${subpixel}:${foreground}`; + 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); - const bitmap = g.colors[subpixel]; + : layeredGlyph(g, g.color.layers, subpixel / SUBPIXELS, foreground)); if (bitmap) blitColor(color ??= new Uint8ClampedArray(width * height * 4), width, height, bitmap, pixel + bitmap.left, baseline - bitmap.top); continue; } @@ -212,14 +236,94 @@ export async function render(fonts: Uint8Array[], size: number, text: string, la return { width, height, coverage, color }; } +/** + * 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 + */ +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 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)); + }; + + // 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); + let first = -1; + for (let i = 0; i < count; i++) { + const f = font(i); + if (!needed.every(c => has(f, c))) continue; + if (first < 0) first = i; + if (!variation || !needed.length || (variation === 0xfe0f) === isColor(f, needed[0])) return i; + } + 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)); + } + + const shaped: ShapedGlyph[] = []; + for (let start = 0; start < codepoints.length;) { + let end = start + 1; + while (end < codepoints.length && fontOf[end] === fontOf[start]) end++; + const f = font(fontOf[start]); + + // invisible characters the font has no glyph for are left out of the run + const buffer: GlyphBuffer = { glyphs: [], clusters: [] }; + for (let k = start; k < end; k++) { + const index = f.font.glyphIndex(codepoints[k]); + if (!index && defaultIgnorable(codepoints[k])) continue; + buffer.glyphs.push(index); + buffer.clusters.push(k); + } + f.gsub?.(buffer); + + for (let j = 0; j < buffer.glyphs.length; j++) { + const codepoint = codepoints[buffer.clusters[j]], index = buffer.glyphs[j]; + const hidden = defaultIgnorable(codepoint) && index === f.font.glyphIndex(codepoint); + shaped.push({ glyph: hidden ? invisible : glyphOf(f, index, size), space: codepoint === 0x20 || codepoint === 0x09 }); + } + start = end; + } + return shaped; +} + +// characters that extend the grapheme before them: combining marks, variation selectors, emoji modifiers, the keycap, +// tags and joiners (which also join the next character) +const EXTENDING = [0x300, 0x36f, 0x1ab0, 0x1aff, 0x1dc0, 0x1dff, 0x200d, 0x200d, 0x20d0, 0x20ff, 0xfe00, 0xfe0f, 0xfe20, 0xfe2f, + 0x1f3fb, 0x1f3ff, 0xe0020, 0xe007f, 0xe0100, 0xe01ef]; +const inRanges = (ranges: number[], codepoint: number): boolean => { + for (let i = 0; i < ranges.length; i += 2) if (codepoint >= ranges[i] && codepoint <= ranges[i + 1]) return true; + return false; +}; +const regionalIndicator = (codepoint: number): boolean => codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff; + +// grapheme ranges (simplified UAX #29 for emoji): extensions, joined sequences and pairs of regional indicators +function graphemes(codepoints: number[]): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + for (let start = 0; start < codepoints.length;) { + let end = start + 1; + if (regionalIndicator(codepoints[start]) && end < codepoints.length && regionalIndicator(codepoints[end])) end++; + while (end < codepoints.length && (inRanges(EXTENDING, codepoints[end]) || codepoints[end - 1] === 0x200d)) end++; + ranges.push([start, end]); + start = end; + } + return ranges; +} + // default ignorable code points (Unicode's DerivedCoreProperties), which render as nothing const IGNORABLE = [0xad, 0xad, 0x34f, 0x34f, 0x61c, 0x61c, 0x115f, 0x1160, 0x17b4, 0x17b5, 0x180b, 0x180f, 0x200b, 0x200f, 0x202a, 0x202e, 0x2060, 0x206f, 0x3164, 0x3164, 0xfe00, 0xfe0f, 0xfeff, 0xfeff, 0xffa0, 0xffa0, 0xfff0, 0xfff8, 0x1bca0, 0x1bca3, 0x1d173, 0x1d17a, 0xe0000, 0xe0fff]; -function defaultIgnorable(codepoint: number): boolean { - for (let i = 0; i < IGNORABLE.length; i += 2) if (codepoint >= IGNORABLE[i] && codepoint <= IGNORABLE[i + 1]) return true; - return false; -} +const defaultIgnorable = (codepoint: number): boolean => inRanges(IGNORABLE, codepoint); // a bitmap glyph's PNG decoded and scaled from its strike to the font size, premultiplied async function scaledBitmap(glyph: BitmapGlyph, scale: number): Promise { @@ -289,10 +393,9 @@ function inkExtent(path: PathCommand[], scale: number): Glyph['ink'] { return right > left ? { left, right } : null; } -// breaks text into lines of positioned glyphs -function wrap(text: string, layout: Layout, glyph: (codepoint: number) => Glyph, kern: (left: Glyph, right: Glyph) => number): Line[] { +// breaks shaped paragraphs into lines of positioned glyphs +function wrap(paragraphs: ShapedGlyph[][], layout: Layout, kern: (left: Glyph, right: Glyph) => number): Line[] { const lines: Line[] = []; - const paragraphs = layout.wrapHardBreaks ? text.split(/\r\n|\r|\n/) : [text.replace(/\r\n|\r|\n/g, '')]; // right edge of a glyph placed at x (glyphs without ink don't extend the line) const edge = (x: number, g: Glyph): number => g.ink ? x + g.ink.right : 0; @@ -320,11 +423,7 @@ function wrap(text: string, layout: Layout, glyph: (codepoint: number) => Glyph, breakAt = -1; }; - for (const char of paragraph) { - const codepoint = char.codePointAt(0) as number; - const g = glyph(codepoint); - const space = codepoint === 0x20 || codepoint === 0x09; - + for (const { glyph: g, space } of paragraph) { if (!space && line.glyphs.length && edge(next(g), g) > layout.maxWidth) { if (layout.wrapStyle === 'word' && breakAt > 0) { // move the current word to the next line diff --git a/tests/font.js b/tests/font.js index eb37a289..d404af46 100644 --- a/tests/font.js +++ b/tests/font.js @@ -155,11 +155,11 @@ const panic = message => { } { - // color glyphs from fallback fonts: CBDT bitmaps (Noto Color Emoji) and COLR layers (Twemoji) + // color glyphs from fallback fonts: CBDT bitmaps (Noto Color Emoji) and COLR layers (Twemoji), 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']]) { const emoji = await fs.readFile(`./tests/fonts/${file}`); - const image = await Image.renderText([sans, emoji], 48, 'Hi 😀🔥 👍', {color: 0x000000ff}); + const image = await Image.renderText([sans, emoji], 48, 'Hi 😀🔥 👍🏽 🇩🇪 1️⃣ ❤️ 👨‍👩‍👧', {color: 0x000000ff}); const encoded = await image.encode(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile(`./tests/targets/${target}.png`, encoded); @@ -174,6 +174,17 @@ const panic = message => { const plain = await Image.renderText([sans, emoji], 48, '😀😀'), joined = await Image.renderText([sans, emoji], 48, '😀\u200d\ufe0f😀'); if (plain.width !== joined.width) panic(`${file}: invisible characters should take no room (${plain.width} vs ${joined.width})`); + // sequences are one glyph (GSUB ligatures): skin tones, flags, keycaps, joined ones + const one = (await Image.renderText(emoji, 48, '👍')).width; + for (const sequence of ['👍🏽', '🇩🇪', '1️⃣', '👨‍👩‍👧']) + if ((await Image.renderText([sans, emoji], 48, sequence)).width > one * 1.2) panic(`${file}: ${sequence} should be a single glyph`); + + // the emoji variation selector picks the color glyph over the text font's heart + const heart = await Image.renderText([sans, emoji], 48, '❤️', {color: 0x000000ff}); + let red = 0; + for (let i = 0; i < heart.bitmap.length; i += 4) if (heart.bitmap[i + 3] > 128 && heart.bitmap[i] > 150 && heart.bitmap[i + 1] < 100) red++; + if (red < 100) panic(`${file}: ❤️ should be the red emoji`); + // characters the emoji font doesn't have come from the next font (the first one still sets the line height) const fallback = await Image.renderText([emoji, sans], 48, 'Hi'), direct = await Image.renderText(sans, 48, 'Hi'); const ink = image => image.bitmap.reduce((sum, v, i) => (i & 3) === 3 ? sum + v : sum, 0); diff --git a/tests/targets/emoji-cbdt.png b/tests/targets/emoji-cbdt.png index caa07d02..60b2234a 100644 Binary files a/tests/targets/emoji-cbdt.png and b/tests/targets/emoji-cbdt.png differ diff --git a/tests/targets/emoji-colr.png b/tests/targets/emoji-colr.png index edc21c7d..4a8ea876 100644 Binary files a/tests/targets/emoji-colr.png and b/tests/targets/emoji-colr.png differ