diff --git a/CHANGELOG.md b/CHANGELOG.md index d9339478..7ea6d2d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ Rotating a 1920×1080 photo by 30° takes 51 instead of 119 ms (61 instead of 114 ms with alpha). Opaque pixels stay within 1 of 3.2.1 (0.5-2% of the values differ), what shows of translucent ones (also the faded edges) within 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). + Invisible characters like joiners and variation selectors take no room, as in browsers. Emoji sequences (skin tones, + flags, families) still show as their parts. ### Performance diff --git a/README.md b/README.md index e4a49e27..df8c0d4e 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 and TrueType hinting) + 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/image.ts b/src/image.ts index e294fb17..aa403ddc 100644 --- a/src/image.ts +++ b/src/image.ts @@ -2031,17 +2031,21 @@ export class Image { /** * Creates a new image containing the rendered text. - * @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use + * @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use, or several by priority: every character + * comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color + * glyphs (COLR version 0 layers and CBDT bitmaps, as in emoji fonts) keep their colors * @param size Font size in pixels (per em) * @param text Text to render * @param options Color, layout and style, see {@link TextOptions}; the image grows by the outline width on every * side * @return The rendered text */ - static renderText(font: BinaryData, size: number, text: string, options?: TextOptions): Promise; + static renderText(font: BinaryData | BinaryData[], size: number, text: string, options?: TextOptions): Promise; /** * Creates a new image containing the rendered text. - * @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use + * @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use, or several by priority: every character + * comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color + * glyphs (COLR version 0 layers and CBDT bitmaps, as in emoji fonts) keep their colors * @param size Font size in pixels (per em) * @param text Text to render * @param [color=0xffffffff] Text color to use @@ -2049,15 +2053,18 @@ export class Image { * @param style Outline, see {@link TextStyle}; the image grows by the outline width on every side * @return The rendered text */ - static renderText(font: BinaryData, size: number, text: string, color?: Color, layout?: TextLayout, style?: TextStyle): Promise; - static async renderText(font: BinaryData, size: number, text: string, colorOrOptions: Color | TextOptions = 0xffffffff, layoutArgument?: TextLayout, styleArgument: TextStyle = {}): Promise { + static renderText(font: BinaryData | BinaryData[], size: number, text: string, color?: Color, layout?: TextLayout, style?: TextStyle): Promise; + static async renderText(font: BinaryData | BinaryData[], size: number, text: string, colorOrOptions: Color | TextOptions = 0xffffffff, layoutArgument?: TextLayout, styleArgument: TextStyle = {}): Promise { const options: TextOptions = isOptions(colorOrOptions) ? colorOrOptions : { ...styleArgument, color: colorOrOptions, layout: layoutArgument }; const { color = 0xffffffff, layout = new TextLayout(), strokeWidth = 0, strokeColor = 0x000000ff, hinting = true } = options; if (isNaN(strokeWidth) || strokeWidth < 0) throw new RangeError('Invalid stroke width'); + const fonts = Array.isArray(font) ? font : [font]; + if (!fonts.length) throw new RangeError('No font given'); - const rendered = textRenderer.render(view(font), size, text, layout, hinting); const [r, g, b, a] = Image.colorToRGBA(color); + // color glyph layers in the text color take it without its alpha, which applies to the whole color glyph + const rendered = await textRenderer.render(fonts.map(view), size, text, layout, hinting, (r << 24 | g << 16 | b << 8 | 255) >>> 0); // the outline needs room around the glyphs const pad = Math.ceil(strokeWidth); @@ -2065,11 +2072,17 @@ export class Image { const coverage = new Uint8Array(width * height); for (let y = 0; y < rendered.height; y++) coverage.set(rendered.coverage.subarray(y * rendered.width, (y + 1) * rendered.width), (y + pad) * width + pad); + let colored: Uint8ClampedArray | null = null; + if (rendered.color) { + colored = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < rendered.height; y++) + colored.set(rendered.color.subarray(y * rendered.width * 4, (y + 1) * rendered.width * 4), ((y + pad) * width + pad) * 4); + } const image = new Image(width, height); const bitmap = image.bitmap; - if (!strokeWidth) { + if (!strokeWidth && !colored) { for (let i = 0; i < coverage.length; i++) { if (!coverage[i]) continue; bitmap[i * 4] = r; @@ -2080,19 +2093,38 @@ export class Image { return image; } - const stroke = outline(coverage, width, height, strokeWidth); + // the outline goes around the color glyphs as well + let shape = coverage; + if (colored && strokeWidth) { + shape = coverage.slice(); + for (let i = 0; i < shape.length; i++) shape[i] = Math.max(shape[i], colored[i * 4 + 3]); + } + const stroke = strokeWidth ? outline(shape, width, height, strokeWidth) : null; const [sr, sg, sb, sa] = Image.colorToRGBA(strokeColor); - // the glyphs drawn over their outline + // color glyphs over the text (premultiplied, with the text's alpha), both over their outline for (let i = 0; i < coverage.length; i++) { - const af = coverage[i] / 255 * a / 255, as = stroke[i] * sa / 255; - const ao = af + as * (1 - af); - if (ao <= 0) continue; - - bitmap[i * 4] = (r * af + sr * as * (1 - af)) / ao; - bitmap[i * 4 + 1] = (g * af + sg * as * (1 - af)) / ao; - bitmap[i * 4 + 2] = (b * af + sb * as * (1 - af)) / ao; - bitmap[i * 4 + 3] = ao * 255; + let af = coverage[i] / 255 * a / 255, pr = r * af, pg = g * af, pb = b * af; + if (colored && colored[i * 4 + 3]) { + const ca = colored[i * 4 + 3] / 255 * a / 255, keep = 1 - ca, f = a / 255; + pr = colored[i * 4] * f + pr * keep; + pg = colored[i * 4 + 1] * f + pg * keep; + pb = colored[i * 4 + 2] * f + pb * keep; + af = ca + af * keep; + } + if (stroke) { + const as = stroke[i] * sa / 255 * (1 - af); + pr += sr * as; + pg += sg * as; + pb += sb * as; + af += as; + } + if (af <= 0) continue; + + bitmap[i * 4] = pr / af; + bitmap[i * 4 + 1] = pg / af; + bitmap[i * 4 + 2] = pb / af; + bitmap[i * 4 + 3] = af * 255; } return image; diff --git a/src/render/color.ts b/src/render/color.ts new file mode 100644 index 00000000..24208358 --- /dev/null +++ b/src/render/color.ts @@ -0,0 +1,148 @@ +// color glyphs: layers of outlines with palette colors (COLR version 0 with CPAL), and bitmaps (CBDT/CBLC, PNG) + +import { Font } from './font.ts'; + +/** an outline glyph drawn in a color, RGBA as 0xrrggbbaa (null: the text color) */ +export interface ColorLayer { + glyph: number; + color: number | null; +} + +/** a glyph's PNG and where it goes, in pixels of its strike */ +export interface BitmapGlyph { + png: Uint8Array; + /** pixels per em of the strike */ + ppem: number; + /** size of the bitmap */ + width: number; + height: number; + /** offset of the bitmap's left edge from the pen position */ + left: number; + /** offset of the bitmap's top edge above the baseline */ + top: number; +} + +/** + * 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 + */ +export function parseColorLayers(font: Font): ((glyph: number) => ColorLayer[] | null) | null { + const colr = font.tables.COLR?.offset, cpal = font.tables.CPAL?.offset; + if (colr === undefined || cpal === undefined) return null; + + 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 cache = new Map(); + return glyph => { + let result = cache.get(glyph); + if (result !== undefined) return result; + result = null; + // base glyph records are sorted by glyph + for (let lo = 0, hi = baseCount - 1; lo <= hi;) { + const mid = lo + hi >> 1, record = bases + mid * 6, id = font.u16(record); + if (id < glyph) lo = mid + 1; + else if (id > glyph) hi = mid - 1; + else { + const start = font.u16(record + 2), count = font.u16(record + 4); + result = []; + for (let i = 0; i < count; i++) { + const layer = layers + (start + i) * 4; + result.push({ glyph: font.u16(layer), color: color(font.u16(layer + 2)) }); + } + break; + } + } + cache.set(glyph, result); + return result; + }; +} + +/** + * 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 + */ +export function parseBitmaps(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 array = cblc + font.u32(strike), subtables = font.u32(strike + 8); + + // where a glyph's data starts in CBDT, its image format, and its metrics if the index holds them (image format 19) + const locate = (glyph: number): { offset: number, format: number, metrics: number } | null => { + for (let i = 0; i < subtables; i++) { + const entry = array + i * 8, firstGlyph = font.u16(entry), lastGlyph = font.u16(entry + 2); + if (glyph < firstGlyph || glyph > lastGlyph) continue; + + const header = array + font.u32(entry + 4); + const indexFormat = font.u16(header), format = font.u16(header + 2), data = cbdt + font.u32(header + 4), k = glyph - firstGlyph; + switch (indexFormat) { + case 1: { + const start = font.u32(header + 8 + k * 4), end = font.u32(header + 12 + k * 4); + return end > start ? { offset: data + start, format, metrics: -1 } : null; + } + case 2: return { offset: data + k * font.u32(header + 8), format, metrics: header + 12 }; + case 3: { + const start = font.u16(header + 8 + k * 2), end = font.u16(header + 10 + k * 2); + return end > start ? { offset: data + start, format, metrics: -1 } : null; + } + case 4: { + const count = font.u32(header + 8); + for (let j = 0; j < count; j++) { + const pair = header + 12 + j * 4; + if (font.u16(pair) === glyph) return { offset: data + font.u16(pair + 2), format, metrics: -1 }; + } + return null; + } + case 5: { + const size = font.u32(header + 8), count = font.u32(header + 20); + for (let j = 0; j < count; j++) + if (font.u16(header + 24 + j * 2) === glyph) return { offset: data + j * size, format, metrics: header + 12 }; + return null; + } + default: return null; + } + } + return null; + }; + + const cache = new Map(); + return glyph => { + let result = cache.get(glyph); + if (result !== undefined) return result; + result = null; + + const location = locate(glyph); + if (location) { + const { format } = location; + let o = location.offset, metrics = location.metrics; + // small metrics (height, width, bearingX, bearingY, advance) and big ones start alike + if (format === 17) { metrics = o; o += 5; } + else if (format === 18) { metrics = o; o += 8; } + if ((format === 17 || format === 18 || format === 19) && metrics >= 0) { + const length = font.u32(o); + result = { png: font.data.subarray(o + 4, o + 4 + length), ppem: strikePpem, width: font.u8(metrics + 1), height: font.u8(metrics), left: font.i8(metrics + 2), top: font.i8(metrics + 3) }; + } + } + + cache.set(glyph, result); + return result; + }; +} diff --git a/src/render/font.ts b/src/render/font.ts index 292116d5..617b0d63 100644 --- a/src/render/font.ts +++ b/src/render/font.ts @@ -42,7 +42,8 @@ export class Font { lineGap: number; numberOfHMetrics: number; numGlyphs: number; - outlines: 'glyf' | 'cff'; + /** where the glyph outlines come from ('none' for fonts with only bitmap glyphs) */ + outlines: 'glyf' | 'cff' | 'none'; cff: CFF | null = null; cmap: (codepoint: number) => number; outlineCache: Map; @@ -89,6 +90,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 throw new Error('Font: no supported glyph outlines'); this.cmap = parseCmap(this, this.tables.cmap.offset); @@ -221,7 +223,7 @@ 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.cffOutline(glyph); + path = this.outlines === 'glyf' ? this.glyfOutline(glyph, 0) : this.outlines === 'cff' ? this.cffOutline(glyph) : []; this.outlineCache.set(glyph, path); } return path; diff --git a/src/render/text.ts b/src/render/text.ts index dd5de462..2fa60e8b 100644 --- a/src/render/text.ts +++ b/src/render/text.ts @@ -5,6 +5,9 @@ import { Font, PathCommand } from './font.ts'; import { Hinter } from './hinting.ts'; +import { ColorLayer, BitmapGlyph, parseColorLayers, parseBitmaps } from './color.ts'; +import { decode as decodePNG } from '../codecs/png.ts'; +import { resample, TRIANGLE } from '../util/resample.ts'; export interface Layout { maxWidth: number; @@ -25,8 +28,28 @@ export interface GlyphBitmap { top: number; } +/** a color glyph's premultiplied RGBA pixels, placed like a {@link GlyphBitmap} */ +interface ColorBitmap { + data: Uint8ClampedArray; + width: number; + height: number; + left: number; + top: number; +} + +/** a font with what's derived from it for one size */ +interface SizedFont { + font: Font; + /** pixels per font unit */ + scale: number; + hinter: Hinter | null; + layers: ((glyph: number) => ColorLayer[] | null) | null; + bitmaps: ((glyph: number) => BitmapGlyph | null) | null; +} + interface Glyph { index: number; + font: SizedFont; /** outline and its scale to pixels (1 for hinted outlines, which are in pixels) */ path: PathCommand[]; scale: number; @@ -36,6 +59,10 @@ 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; + /** color bitmaps per subpixel offset (bitmap glyphs only have the one for offset 0) */ + colors: Array; } interface Line { @@ -63,53 +90,76 @@ function loadFont(data: Uint8Array): Font { /** * Lays out and rasterizes text - * Lines start at ceil(ascender) and advance by ceil(ascender - descender + lineGap); 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 {Uint8Array} data TrueType/OpenType font - * @param {number} size font size in pixels per em - * @param {string} text - * @param {Layout} layout + * 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 + * @param size font size in pixels per em + * @param text + * @param layout * @param hinting Whether to run TrueType fonts' hinting instructions, the glyphs of hinted fonts are then placed on * whole pixels (fonts without instructions render as without hinting) - * @return {{width: number, height: number, coverage: Uint8Array}} + * @param foreground RGBA color (0xrrggbbaa) of color glyph layers drawn in the text color + * @return the coverage of the outline glyphs, and the premultiplied RGBA pixels of color glyphs (null without any) */ -export function render(data: Uint8Array, size: number, text: string, layout: Layout, hinting = false): { width: number, height: number, coverage: Uint8Array } { - const font = loadFont(data); - const scale = size / font.unitsPerEm; - 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; - } - // whole pixel placement only makes sense for hinted outlines - hinting = hinter !== null; - const ascent = Math.ceil(font.ascender * scale); - const lineHeight = Math.ceil((font.ascender - font.descender + font.lineGap) * scale); +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 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 glyph = (codepoint: number): Glyph => { - let g = glyphs.get(codepoint); - if (!g) { - const index = font.glyphIndex(codepoint); + 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) g = { index, path: hinted.path, scale: 1, advance: hinted.advance, ink: inkExtent(hinted.path, 1), bitmaps: [] }; + if (hinted) Object.assign(g, { path: hinted.path, scale: 1, advance: hinted.advance, ink: inkExtent(hinted.path, 1) }); else { - const path = font.outline(index); - const advance = font.advance(index) * scale; - g = { index, path, scale, advance: hinting ? Math.round(advance) : advance, ink: inkExtent(path, scale), bitmaps: [] }; + 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 kerning = font.kerning; - const kern = kerning ? (left: Glyph, right: Glyph): number => { - const k = kerning(left.index, right.index) * scale; - return hinting ? Math.round(k) : k; - } : () => 0; + const kern = (left: Glyph, right: Glyph): number => { + const kerning = left.font === right.font && 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); // lines that fit into maxHeight, chosen by the vertical alignment @@ -126,28 +176,106 @@ export function render(data: Uint8Array, size: number, text: string, layout: Lay const width = Math.ceil(Math.max(0, ...visible.map(line => line.extent))); const height = visible.length * lineHeight; const coverage = new Uint8Array(width * height); + let color: Uint8ClampedArray | null = null; - visible.forEach((line, n) => { + for (const [n, line] of visible.entries()) { let offset = layout.horizontalAlign === 'right' ? width - line.extent : layout.horizontalAlign === 'center' ? (width - line.extent) / 2 : 0; - if (hinting) offset = Math.round(offset); + if (primary.hinter) offset = Math.round(offset); const baseline = ascent + n * lineHeight; for (const { x, glyph: g } of line.glyphs) { if (!g.ink) continue; - // whole pixels plus the closest subpixel offset - const position = Math.round((offset + x) * SUBPIXELS) / SUBPIXELS; + // whole pixels plus the closest subpixel offset (none for hinted glyphs) + const position = g.font.hinter ? Math.round(offset + x) : Math.round((offset + x) * SUBPIXELS) / SUBPIXELS; const pixel = Math.floor(position); - const subpixel = Math.round((position - pixel) * SUBPIXELS); + let subpixel = Math.round((position - pixel) * SUBPIXELS); + + 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 + ? await scaledBitmap(g.color.bitmap, size / g.color.bitmap.ppem) + : layeredGlyph(g, g.color.layers, subpixel / SUBPIXELS, foreground); + const bitmap = g.colors[subpixel]; + if (bitmap) blitColor(color ??= new Uint8ClampedArray(width * height * 4), width, height, bitmap, pixel + bitmap.left, baseline - bitmap.top); + continue; + } if (g.bitmaps[subpixel] === undefined) g.bitmaps[subpixel] = rasterizeGlyph(g.path, g.scale, subpixel / SUBPIXELS); const bitmap = g.bitmaps[subpixel]; if (bitmap) blit(coverage, width, height, bitmap, pixel + bitmap.left, baseline - bitmap.top); } - }); + } + + return { width, height, coverage, color }; +} + +// 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; +} + +// a bitmap glyph's PNG decoded and scaled from its strike to the font size, premultiplied +async function scaledBitmap(glyph: BitmapGlyph, scale: number): Promise { + let decoded; + try { + decoded = await decodePNG(glyph.png); + } catch { + return null; + } + const width = Math.max(1, Math.round(decoded.width * scale)), height = Math.max(1, Math.round(decoded.height * scale)); + const data = new Uint8ClampedArray(width * height * 4); + resample(new Uint8ClampedArray(decoded.pixels.buffer, decoded.pixels.byteOffset, decoded.pixels.length), decoded.width, decoded.height, width, height, TRIANGLE, data, true); + premultiply(data); + return { data, width, height, left: Math.round(glyph.left * scale), top: Math.round(glyph.top * scale) }; +} + +// a layered color glyph: every layer's outline filled with its color over the ones below, premultiplied +function layeredGlyph(g: Glyph, layers: ColorLayer[], offset: number, foreground: number): ColorBitmap | null { + const rasters = layers.map(layer => ({ bitmap: rasterizeGlyph(g.font.font.outline(layer.glyph), g.scale, offset), color: layer.color ?? foreground })); + let left = Infinity, top = -Infinity, right = -Infinity, bottom = Infinity; + for (const { bitmap } of rasters) if (bitmap) { + left = Math.min(left, bitmap.left); + top = Math.max(top, bitmap.top); + right = Math.max(right, bitmap.left + bitmap.width); + bottom = Math.min(bottom, bitmap.top - bitmap.height); + } + if (right <= left) return null; + + const width = right - left, height = top - bottom; + const data = new Uint8ClampedArray(width * height * 4); + for (const { bitmap, color } of rasters) { + if (!bitmap) continue; + const r = color >>> 24, gr = color >> 16 & 255, b = color >> 8 & 255, a = color & 255; + for (let y = 0; y < bitmap.height; y++) + for (let x = 0; x < bitmap.width; x++) { + const coverage = bitmap.data[y * bitmap.width + x]; + if (!coverage) continue; + const o = ((top - bitmap.top + y) * width + bitmap.left - left + x) * 4; + // source over, premultiplied + const sa = coverage * a / 255, keep = 1 - sa / 255; + data[o] = r * sa / 255 + data[o] * keep; + data[o + 1] = gr * sa / 255 + data[o + 1] * keep; + data[o + 2] = b * sa / 255 + data[o + 2] * keep; + data[o + 3] = sa + data[o + 3] * keep; + } + } + return { data, width, height, left, top }; +} - return { width, height, coverage }; +function premultiply(rgba: Uint8ClampedArray): void { + for (let i = 0; i < rgba.length; i += 4) { + const a = rgba[i + 3] / 255; + rgba[i] *= a; + rgba[i + 1] *= a; + rgba[i + 2] *= a; + } } // horizontal ink extent of an outline in pixels @@ -244,6 +372,27 @@ function blit(target: Uint8Array, width: number, height: number, bitmap: GlyphBi } } +// draws premultiplied RGBA over the target (premultiplied RGBA as well) +function blitColor(target: Uint8ClampedArray, width: number, height: number, bitmap: ColorBitmap, x0: number, y0: number): void { + for (let y = 0; y < bitmap.height; y++) { + const ty = y0 + y; + if (ty < 0 || ty >= height) continue; + + for (let x = 0; x < bitmap.width; x++) { + const tx = x0 + x; + if (tx < 0 || tx >= width) continue; + + const s = (y * bitmap.width + x) * 4, sa = bitmap.data[s + 3]; + if (!sa) continue; + const o = (ty * width + tx) * 4, keep = 1 - sa / 255; + target[o] = bitmap.data[s] + target[o] * keep; + target[o + 1] = bitmap.data[s + 1] + target[o + 1] * keep; + target[o + 2] = bitmap.data[s + 2] + target[o + 2] * keep; + target[o + 3] = sa + target[o + 3] * keep; + } + } +} + /** * Rasterizes a glyph outline into an 8-bit coverage bitmap * @param {Array} path outline in font units (y up) diff --git a/tests/font.js b/tests/font.js index 99882bab..eb37a289 100644 --- a/tests/font.js +++ b/tests/font.js @@ -3,6 +3,8 @@ https://www.1001fonts.com/carbon-font.html https://www.1001fonts.com/ethnocentric-font.html https://github.com/adobe-fonts/source-sans (SIL OFL, see OFL-SourceSans3.md) + https://github.com/googlefonts/noto-emoji (subset, SIL OFL, see OFL-NotoColorEmoji.md) + https://github.com/mozilla/twemoji-colr (subset, CC-BY 4.0, see CC-BY-Twemoji.md) */ const fs = require('fs').promises; @@ -151,4 +153,35 @@ const panic = message => { if (!Buffer.from(on.bitmap).equals(Buffer.from(off.bitmap))) panic(`hinting: ${file} has no instructions and should render unchanged`); } } + + { + // color glyphs from fallback fonts: CBDT bitmaps (Noto Color Emoji) and COLR layers (Twemoji) + 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 encoded = await image.encode(1, {creationTime: 0, software: ''}); + if (process.env.OVERWRITE_TEST) + await fs.writeFile(`./tests/targets/${target}.png`, encoded); + if (!await samePixels(await fs.readFile(`./tests/targets/${target}.png`), encoded)) panic(`${target} doesn't match`); + + // black text, so colored pixels come from the emoji + let colored = 0; + for (let i = 0; i < image.bitmap.length; i += 4) if (image.bitmap[i + 3] > 128 && image.bitmap[i] > 128) colored++; + if (colored < 500) panic(`${file}: emoji should keep their colors`); + + // joiners and variation selectors take no room, even where the font has glyphs for them + 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})`); + + // 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); + if (fallback.width !== direct.width || ink(fallback) !== ink(direct)) panic(`${file}: missing characters should come from the next font`); + } + + // fonts with only bitmap glyphs render on their own + 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'); + } })(); diff --git a/tests/fonts/CC-BY-Twemoji.md b/tests/fonts/CC-BY-Twemoji.md new file mode 100644 index 00000000..ea04150b --- /dev/null +++ b/tests/fonts/CC-BY-Twemoji.md @@ -0,0 +1,8 @@ +`twemoji mozilla subset.ttf` is a subset (a few emoji, made with fontTools' pyftsubset) of Twemoji Mozilla 0.7.0 +(https://github.com/mozilla/twemoji-colr). + +The emoji graphics are from Twemoji (https://github.com/twitter/twemoji), Copyright 2019 Twitter, Inc and other +contributors, licensed under CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/). + +The font's build code is Copyright 2016-2018 Mozilla Foundation, licensed under the Apache License, Version 2.0 +(https://www.apache.org/licenses/LICENSE-2.0). diff --git a/tests/fonts/OFL-NotoColorEmoji.md b/tests/fonts/OFL-NotoColorEmoji.md new file mode 100644 index 00000000..6e0c92ae --- /dev/null +++ b/tests/fonts/OFL-NotoColorEmoji.md @@ -0,0 +1,93 @@ +Copyright 2022 Google Inc. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tests/fonts/noto color emoji subset.ttf b/tests/fonts/noto color emoji subset.ttf new file mode 100644 index 00000000..1a4563cb Binary files /dev/null and b/tests/fonts/noto color emoji subset.ttf differ diff --git a/tests/fonts/twemoji mozilla subset.ttf b/tests/fonts/twemoji mozilla subset.ttf new file mode 100644 index 00000000..a1fd84be Binary files /dev/null and b/tests/fonts/twemoji mozilla subset.ttf differ diff --git a/tests/targets/emoji-cbdt.png b/tests/targets/emoji-cbdt.png new file mode 100644 index 00000000..caa07d02 Binary files /dev/null and b/tests/targets/emoji-cbdt.png differ diff --git a/tests/targets/emoji-colr.png b/tests/targets/emoji-colr.png new file mode 100644 index 00000000..edc21c7d Binary files /dev/null and b/tests/targets/emoji-colr.png differ