Exports draw emoji (pictures from the system emoji font, sized to the… · nicoloboschi/giotto@fe4419c · GitHub
Skip to content

Commit fe4419c

Browse files
committed
Exports draw emoji (pictures from the system emoji font, sized to the label) and symbols like ① (from a font that has them, without the line losing its font or bold); the camera flies straight to a new focus instead of pulling back on the way
1 parent 1c98c62 commit fe4419c

7 files changed

Lines changed: 207 additions & 23 deletions

File tree

‎bin/giotto.js‎

Lines changed: 6 additions & 3 deletions

‎lib/emoji.js‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
}

‎lib/frame-worker.js‎

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,16 @@
33
// does it once per distinct label and every frame reuses the shapes.
44
import { parentPort, workerData } from 'node:worker_threads';
55
import { Resvg } from '@resvg/resvg-js';
6+
import { textShapes } from './emoji.js';
67

78
const font = { loadSystemFonts: false, fontFiles: workerData.fontFiles, defaultFontFamily: workerData.sans, sansSerifFamily: workerData.sans, monospaceFamily: workerData.mono };
89
const noFonts = { loadSystemFonts: false };
910

10-
// Each <text> in its own <g id="tN">, so its shapes can be told apart in resvg's output. A label whose shapes
11-
// need anything outside themselves (a gradient, a clip) stays text (null).
12-
function shapesOf(texts, sheet = '') {
13-
const svg = new Resvg(`<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">${sheet}${texts.map((t, i) => `<g id="t${i}">${t}</g>`).join('')}</svg>`, { font }).toString();
14-
const out = texts.map(() => '');
15-
for (const part of svg.split(/<g id="t(?=\d+">)/).slice(1)) {
16-
const i = parseInt(part, 10);
17-
let body = part.slice(part.indexOf('>') + 1).replace(/<\/svg>\s*$/, '').trimEnd();
18-
body = body.slice(0, body.lastIndexOf('</g>'));
19-
// Coordinates to 2 decimals: well under a pixel, and a quarter less to parse in every frame.
20-
out[i] = /url\(#|<defs|<image/.test(body) ? null : body.replace(/\s*\n\s*/g, '').replace(/\d+\.\d{3,}/g, (x) => String(Math.round(x * 100) / 100));
21-
}
22-
return out;
23-
}
11+
// Labels as shapes, in batches (see textShapes), with coordinates to 2 decimals: well under a pixel, and a
12+
// quarter less to parse in every frame.
13+
const shapesOf = (texts, sheet = '') =>
14+
textShapes(texts, sheet, Resvg, font, workerData.sans, workerData.symbols.join(', ')).map((body) =>
15+
body == null ? null : body.replace(/\s*\n\s*/g, '').replace(/\d+\.\d{3,}/g, (x) => String(Math.round(x * 100) / 100)));
2416

2517
// Frames go to ffmpeg as QOI (qoiformat.org): lossless, one pass over the pixels with no compression library,
2618
// about twice as fast as resvg's PNG and steadier with every core busy (PNG's big copy and deflate were the

‎lib/render.js‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,7 @@ export function drawPointer(pointer, byId, S) {
916916
// ---------- camera: where a scene looks ----------
917917

918918
// The part of the diagram a frame shows, `aspect` wide (width / height): its focus (some elements, or everything),
919-
// flying there from the previous one over CAMERA ms, pulling back a little on the way.
919+
// flying there from the previous one over CAMERA ms.
920920
export function cameraView(frame, elements, S, aspect, pad = 50) {
921921
const byId = Object.fromEntries(elements.map((e) => [e.id, e]));
922922
const all = elements.filter((e) => e.type !== 'legend');
@@ -939,8 +939,9 @@ export function cameraView(frame, elements, S, aspect, pad = 50) {
939939
const to = rectOf(cam?.focus);
940940
let v = to;
941941
if (cam && cam.ms < CAMERA && cam.prev !== undefined && JSON.stringify(cam.prev) !== JSON.stringify(cam.focus)) {
942-
const from = rectOf(cam.prev), k = easeInOut(cam.ms / CAMERA), out = 1 + 0.32 * Math.sin(Math.PI * k);
943-
v = { cx: lerp(from.cx, to.cx, k), cy: lerp(from.cy, to.cy, k), w: lerp(from.w, to.w, k) * out, h: lerp(from.h, to.h, k) * out };
942+
// Straight there, zooming at an even pace (no pulling back on the way: it read as zooming out and in by itself).
943+
const from = rectOf(cam.prev), k = easeInOut(cam.ms / CAMERA), zoom = (from.w / to.w) ** (1 - k);
944+
v = { cx: lerp(from.cx, to.cx, k), cy: lerp(from.cy, to.cy, k), w: to.w * zoom, h: to.h * zoom };
944945
}
945946
return { x: v.cx - v.w / 2, y: v.cy - v.h / 2, w: v.w, h: v.h };
946947
}

‎lib/video.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const FONTS = {
1313
sans: [['Arial', '/System/Library/Fonts/Supplemental/Arial.ttf', '/System/Library/Fonts/Supplemental/Arial Bold.ttf'], ['DejaVu Sans', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'], ['Arial', 'C:/Windows/Fonts/arial.ttf', 'C:/Windows/Fonts/arialbd.ttf']],
1414
mono: [['Courier New', '/System/Library/Fonts/Supplemental/Courier New.ttf'], ['DejaVu Sans Mono', '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf'], ['Consolas', 'C:/Windows/Fonts/consola.ttf']],
1515
// All that exist are loaded: Menlo has ✓ and ↻, Apple Symbols has arrows and math.
16-
symbols: [['Menlo', '/System/Library/Fonts/Menlo.ttc'], ['Apple Symbols', '/System/Library/Fonts/Apple Symbols.ttf'], ['Segoe UI Symbol', 'C:/Windows/Fonts/seguisym.ttf']],
16+
symbols: [['Menlo', '/System/Library/Fonts/Menlo.ttc'], ['Apple Symbols', '/System/Library/Fonts/Apple Symbols.ttf'], ['Arial Unicode MS', '/System/Library/Fonts/Supplemental/Arial Unicode.ttf'], ['Segoe UI Symbol', 'C:/Windows/Fonts/seguisym.ttf']],
1717
};
1818
// Classes a stylesheet styles by context (a rule with a combinator or a pseudo-class): labels with them can't be
1919
// laid out apart from the frame. null when some such rule ends in no class at all (it could match any label).
@@ -76,7 +76,7 @@ export async function toMp4(doc, style, out, { dark = false, fps = 24, zoom = 1.
7676
ff.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg failed: ${ffErr.slice(-500)}`))));
7777
});
7878
const n = Math.max(1, Math.min(os.cpus().length - 1, 16));
79-
const workers = Array.from({ length: n }, () => new Worker(new URL('./frame-worker.js', import.meta.url), { workerData: { fontFiles, sans: sans[0], mono: mono?.[0] || sans[0], zoom } }));
79+
const workers = Array.from({ length: n }, () => new Worker(new URL('./frame-worker.js', import.meta.url), { workerData: { fontFiles, sans: sans[0], mono: mono?.[0] || sans[0], symbols: symbols.map(([family]) => family), zoom } }));
8080
// Hands tasks to whichever worker is free, in order; done(id, result) as each comes back.
8181
const pool = (tasks, done) => new Promise((resolve, reject) => {
8282
let next = 0, finished = 0;

‎site/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@
221221
camAnim?.cancel();
222222
if (jumped) world.style.transform = key;
223223
else {
224-
// From wherever it is now. A new focus flies (pulling back on the way); anything else glides.
224+
// From wherever it is now. A new focus flies; anything else glides.
225225
const now = getComputedStyle(world).transform;
226226
const flying = pf?.camera && pf.camera.prev !== undefined && pf.camera.ms < CAMERA;
227227
const left = flying ? CAMERA - pf.camera.ms : 650;

‎test/emoji.test.js‎

Lines changed: 31 additions & 0 deletions

0 commit comments

Comments
 (0)