|
| 1 | +// Emoji and symbols in exports (PNG, video), where resvg draws the text. resvg can't draw color emoji fonts, so |
| 2 | +// emoji are pictures taken from the system's emoji font (Apple Color Emoji keeps a PNG per emoji, in its "sbix" |
| 3 | +// table), placed where the text layout left room for them; without such a font they're left out, as before. |
| 4 | +// Symbols the label's font lacks (①, ✓) are drawn on their own from a font that has them. |
| 5 | +import fs from 'node:fs'; |
| 6 | + |
| 7 | +const FONTS = ['/System/Library/Fonts/Apple Color Emoji.ttc']; |
| 8 | + |
| 9 | +let font; // { data, cmap: Map(codepoint → glyph), sbix } or null |
| 10 | +function load() { |
| 11 | + if (font !== undefined) return font; |
| 12 | + font = null; |
| 13 | + const file = FONTS.find((f) => fs.existsSync(f)); |
| 14 | + if (!file) return font; |
| 15 | + const data = fs.readFileSync(file); |
| 16 | + const u32 = (o) => data.readUInt32BE(o), u16 = (o) => data.readUInt16BE(o); |
| 17 | + const base = data.toString('latin1', 0, 4) === 'ttcf' ? u32(12) : 0; // the first font of a collection |
| 18 | + const tables = {}; |
| 19 | + for (let i = 0, n = u16(base + 4); i < n; i++) { |
| 20 | + const r = base + 12 + i * 16; |
| 21 | + tables[data.toString('latin1', r, r + 4)] = u32(r + 8); |
| 22 | + } |
| 23 | + if (!tables.cmap || !tables.sbix) return font; |
| 24 | + // cmap: the format 12 subtable maps every code point (emoji live above U+FFFF). |
| 25 | + const cmap = new Map(); |
| 26 | + for (let i = 0, n = u16(tables.cmap + 2); i < n; i++) { |
| 27 | + const sub = tables.cmap + u32(tables.cmap + 4 + i * 8 + 4); |
| 28 | + if (u16(sub) !== 12) continue; |
| 29 | + for (let g = 0, groups = u32(sub + 12); g < groups; g++) { |
| 30 | + const o = sub + 16 + g * 12, start = u32(o), end = u32(o + 4), glyph = u32(o + 8); |
| 31 | + for (let c = start; c <= end; c++) cmap.set(c, glyph + c - start); |
| 32 | + } |
| 33 | + break; |
| 34 | + } |
| 35 | + font = { data, cmap, sbix: tables.sbix }; |
| 36 | + return font; |
| 37 | +} |
| 38 | + |
| 39 | +// The PNG of one emoji (its first code point), at the smallest size the font has that's at least `px` pixels |
| 40 | +// (Apple's go from 20 to 160): a 160 px picture decoded in every frame of a video is most of that frame's work. |
| 41 | +const pngs = new Map(); |
| 42 | +export function emojiPng(cp, px = 160) { |
| 43 | + const key = `${cp}:${px}`; |
| 44 | + if (pngs.has(key)) return pngs.get(key); |
| 45 | + const f = load(); |
| 46 | + let png = null; |
| 47 | + const g = f?.cmap.get(cp); |
| 48 | + if (g != null) { |
| 49 | + const { data, sbix } = f, u32 = (o) => data.readUInt32BE(o); |
| 50 | + const strikes = Array.from({ length: u32(sbix + 4) }, (_, s) => sbix + u32(sbix + 8 + s * 4)).sort((a, b) => data.readUInt16BE(a) - data.readUInt16BE(b)); |
| 51 | + const fit = strikes.findIndex((st) => data.readUInt16BE(st) >= px); |
| 52 | + for (const strike of [...strikes.slice(fit < 0 ? strikes.length - 1 : fit), ...strikes.slice(0, Math.max(0, fit)).reverse()]) { |
| 53 | + const from = strike + u32(strike + 4 + g * 4), to = strike + u32(strike + 4 + (g + 1) * 4); |
| 54 | + if (to - from > 8 && data.toString('latin1', from + 4, from + 8) === 'png ') { png = data.subarray(from + 8, to); break; } |
| 55 | + } |
| 56 | + } |
| 57 | + pngs.set(key, png); |
| 58 | + return png; |
| 59 | +} |
| 60 | +export const hasEmojiFont = () => !!load(); |
| 61 | + |
| 62 | +// An emoji, with what can follow it in one picture (variation selector, skin tone, joined emoji). |
| 63 | +const EMOJI = /\p{Extended_Pictographic}(?:️|[\u{1F3FB}-\u{1F3FF}]|\p{Extended_Pictographic}️?)*/gu; |
| 64 | +// Symbols (①, ✓, ↻…) a label's own font may lack. Left in the line, resvg's fallback font took over the whole |
| 65 | +// line (losing its bold), so they're drawn on their own too. |
| 66 | +const SYMBOL = /(?!\p{Extended_Pictographic})[℀-⯿]/gu; |
| 67 | +const SPECIAL = new RegExp(`${EMOJI.source}|${SYMBOL.source}`, 'gu'); |
| 68 | +export const hasSpecial = (s) => /[℀-⯿]|\p{Extended_Pictographic}/u.test(s); |
| 69 | + |
| 70 | +// Before text layout: each emoji or symbol in a <text> becomes a marker one em wide (an em dash in its own color, |
| 71 | +// in the label's own font: a second font in a centered Helvetica line made resvg drop the line), so the rest of |
| 72 | +// the line lays out as it would around it. Only character data changes. |
| 73 | +export function markSpecial(text, emoji = hasEmojiFont()) { |
| 74 | + const marks = []; |
| 75 | + // dominant-baseline isn't inherited, so the marker gets the label's own (or it would sit higher or lower). |
| 76 | + const baseline = /\sdominant-baseline="([^"]+)"/.exec(text)?.[1], db = baseline ? ` dominant-baseline="${baseline}"` : ''; |
| 77 | + const out = text.replace(/>([^<]*)</g, (m, chars) => `>${chars.replace(SPECIAL, (c) => { |
| 78 | + const isEmoji = /\p{Extended_Pictographic}/u.test(c); |
| 79 | + if (marks.length > 255 || (isEmoji && !(emoji && emojiPng(c.codePointAt(0))))) return c; |
| 80 | + marks.push(isEmoji ? { emoji: c.codePointAt(0) } : { symbol: c }); |
| 81 | + return `<tspan${db} fill="#fe01${(marks.length - 1).toString(16).padStart(2, '0')}" fill-opacity="1" stroke="none">—</tspan>`; |
| 82 | + })}<`); |
| 83 | + return { text: out, marks }; |
| 84 | +} |
| 85 | +export const symbolsOf = (marks) => marks.filter((m) => m.symbol).map((m) => m.symbol); |
| 86 | + |
| 87 | +// The outline of each symbol, at 1000 units per em on a baseline at 0: { d, left, width } (or null). |
| 88 | +const symbolCache = new Map(); // per character: laid out once |
| 89 | +export function symbolShapes(chars, Resvg, font, families) { |
| 90 | + const list = [...new Set(chars)].filter((c) => !symbolCache.has(c)), out = symbolCache; |
| 91 | + if (!list.length) return out; |
| 92 | + const svg = new Resvg(`<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">${list.map((c, i) => `<g id="s${i}"><text x="0" y="0" font-size="1000" font-family="${families}">${c}</text></g>`).join('')}</svg>`, { font }).toString(); |
| 93 | + for (const part of svg.split(/<g id="s(?=\d+">)/).slice(1)) { |
| 94 | + const d = /\bd="([^"]+)"/.exec(part)?.[1]; |
| 95 | + const xs = d ? (d.match(/-?\d*\.?\d+/g) || []).map(Number).filter((_, k) => k % 2 === 0) : []; |
| 96 | + out.set(list[parseInt(part, 10)], d ? { d, left: Math.min(...xs), width: Math.max(...xs) - Math.min(...xs) } : null); |
| 97 | + } |
| 98 | + return out; |
| 99 | +} |
| 100 | + |
| 101 | +// After layout (resvg's text-as-paths output): each marker's path becomes the emoji picture, or the symbol's |
| 102 | +// outline in the label's color, in the marker's one-em slot. `dash`: where an em dash sits in its em box. |
| 103 | +export function placeSpecial(body, marks, size, dash, shapes = new Map()) { |
| 104 | + const paint = /<path\b(?![^>]*fill="#fe01)[^>]*?(fill="[^"]*"(?:\s+fill-opacity="[^"]*")?)/.exec(body)?.[1] || 'fill="#000"'; |
| 105 | + return body.replace(/<path\b[^>]*fill="#fe01([0-9a-f]{2})"[^>]*\bd="([^"]+)"[^>]*\/>/g, (m, hex, d) => { |
| 106 | + const mark = marks[parseInt(hex, 16)], nums = d.match(/-?\d*\.?\d+(?:e-?\d+)?/g).map(Number); |
| 107 | + const xs = nums.filter((_, i) => i % 2 === 0), ys = nums.filter((_, i) => i % 2 === 1); |
| 108 | + const x = Math.min(...xs) - dash.left * size, baseline = (Math.min(...ys) + Math.max(...ys)) / 2 + dash.middle * size; |
| 109 | + // Apple's emoji fill the em box from about 0.88 em above the baseline to 0.12 em below. |
| 110 | + if (mark.emoji) return `<image x="${x}" y="${baseline - 0.88 * size}" width="${size}" height="${size}" href="data:image/png;base64,${emojiPng(mark.emoji, size * 2).toString('base64')}"/>`; |
| 111 | + const g = shapes.get(mark.symbol); |
| 112 | + if (!g) return ''; |
| 113 | + const k = size / 1000, gx = x + (size - g.width * k) / 2 - g.left * k; // centered in its slot |
| 114 | + return `<path ${paint} transform="translate(${+gx.toFixed(2)} ${+baseline.toFixed(2)}) scale(${k})" d="${g.d}"/>`; |
| 115 | + }); |
| 116 | +} |
| 117 | + |
| 118 | +// Where an em dash sits in the given fonts (a font-family list), from resvg's own layout of one. |
| 119 | +const dashes = new Map(); // per font family: measured once |
| 120 | +export function dashMetrics(Resvg, font, family) { |
| 121 | + if (dashes.has(family)) return dashes.get(family); |
| 122 | + const svg = new Resvg(`<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><text x="0" y="0" font-size="1000" font-family="${family}">—</text></svg>`, { font }).toString(); |
| 123 | + const nums = (/\bd="([^"]+)"/.exec(svg)?.[1].match(/-?\d*\.?\d+/g) || ['0', '-310', '1000', '-290']).map(Number); |
| 124 | + const xs = nums.filter((_, i) => i % 2 === 0), ys = nums.filter((_, i) => i % 2 === 1); |
| 125 | + const m = { left: Math.min(...xs) / 1000, middle: -(Math.min(...ys) + Math.max(...ys)) / 2 / 1000 }; |
| 126 | + dashes.set(family, m); |
| 127 | + return m; |
| 128 | +} |
| 129 | + |
| 130 | +// Labels as shapes (resvg's own text layout, then emoji and symbols put in): `texts` are <text> elements, `sheet` |
| 131 | +// the SVG's <style> (CSS fonts apply). Returns one entry per text: its shapes, or null when they'd need something |
| 132 | +// outside themselves (a gradient, a clip) and it must stay text. |
| 133 | +export function textShapes(texts, sheet, Resvg, font, family, symbolFamilies) { |
| 134 | + const marked = texts.map((t) => (hasSpecial(t) ? markSpecial(t) : { text: t, marks: [] })); |
| 135 | + const svg = new Resvg(`<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">${sheet}${marked.map((m, i) => `<g id="t${i}">${m.text}</g>`).join('')}</svg>`, { font }).toString(); |
| 136 | + const special = marked.some((m) => m.marks.length); |
| 137 | + const dashOf = (t) => dashMetrics(Resvg, font, /\sfont-family="([^"]*)"/.exec(t)?.[1] || family), shapes = special ? symbolShapes(marked.flatMap((m) => symbolsOf(m.marks)), Resvg, font, symbolFamilies) : null; |
| 138 | + const out = texts.map(() => ''); |
| 139 | + for (const part of svg.split(/<g id="t(?=\d+">)/).slice(1)) { |
| 140 | + const i = parseInt(part, 10); |
| 141 | + let body = part.slice(part.indexOf('>') + 1).replace(/<\/svg>\s*$/, '').trimEnd(); |
| 142 | + body = body.slice(0, body.lastIndexOf('</g>')); |
| 143 | + if (/url\(#|<defs/.test(body)) { out[i] = null; continue; } |
| 144 | + out[i] = marked[i].marks.length ? placeSpecial(body, marked[i].marks, +(/font-size="([\d.]+)"/.exec(texts[i])?.[1] || 16), dashOf(texts[i]), shapes) : body; |
| 145 | + } |
| 146 | + return out; |
| 147 | +} |
| 148 | + |
| 149 | +// For still exports (PNG): only the <text> elements with emoji or symbols are laid out as shapes; the rest stay text. |
| 150 | +export function emojify(svg, Resvg, font, family, symbolFamilies = 'Arial Unicode MS, Apple Symbols, Menlo, Segoe UI Symbol') { |
| 151 | + if (!hasSpecial(svg)) return svg; |
| 152 | + const texts = [...new Set(svg.match(/<text\b[^>]*>[\s\S]*?<\/text>/g) || [])].filter(hasSpecial); |
| 153 | + if (!texts.length) return svg; |
| 154 | + const shapes = textShapes(texts, (svg.match(/<style>[\s\S]*?<\/style>/g) || []).join(''), Resvg, font, family, symbolFamilies); |
| 155 | + const byText = new Map(texts.map((t, i) => [t, shapes[i]])); |
| 156 | + return svg.replace(/<text\b[^>]*>[\s\S]*?<\/text>/g, (t) => byText.get(t) ?? t); |
| 157 | +} |
0 commit comments