From 0471752b204475978ba0aeeeb9f903be26e4ef55 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Fri, 17 Apr 2026 06:42:06 +0700 Subject: [PATCH] fix: render emojis and HTML entities in MCP-created screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP-created screens rendered HTML through Satori, which ships no emoji font and has no built-in entity decoder. Agent-authored HTML that used emojis or numeric entities (e.g. ● for status-bar dots) rendered as tofu boxes or literal text. Two fixes: 1. Wire Satori's `loadAdditionalAsset` callback to resolve emoji graphemes to Twemoji SVGs fetched from jsDelivr (version-pinned to jdecked/twemoji@15.1.0). New `emoji-loader.js` adds a three-layer cache (in-flight promise dedupe, in-memory resolved Map, on-disk ~/.cache/drawd-mcp/emoji/.svg), a 3 s AbortSignal timeout, and a transparent-SVG fallback so renders never hang or crash. Handles keycaps, ZWJ families, regional flags, skin-tone modifiers, and the canonical/fallback filename split (rainbow flag keeps FE0F while eye-in-speech drops it — retry covers both). 2. Preprocess HTML with `decodeSafeEntities` before satori-html parses it: decode all numeric entities (&#ddd; / &#xhhh;) and a whitelist of safe named entities whose decoded form contains no HTML metachar ( , …, •, “/”, arrows, etc.). Leaves &/</>/"/' untouched to avoid breaking the HTML parser. Bumps mcp-server to v1.2.2. --- mcp-server/package-lock.json | 4 +- mcp-server/package.json | 2 +- mcp-server/src/renderer/emoji-loader.js | 137 +++++++++++++++++++++ mcp-server/src/renderer/satori-renderer.js | 50 +++++++- 4 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 mcp-server/src/renderer/emoji-loader.js diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index c98adbf..3c6d2e2 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "drawd-mcp-server", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "drawd-mcp-server", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/mcp-server/package.json b/mcp-server/package.json index 3d491a0..6d12fcd 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "drawd-mcp-server", - "version": "1.2.1", + "version": "1.2.2", "description": "MCP server for Drawd — AI agent flow builder. Create app flow designs programmatically with AI agents.", "type": "module", "bin": { diff --git a/mcp-server/src/renderer/emoji-loader.js b/mcp-server/src/renderer/emoji-loader.js new file mode 100644 index 0000000..41ebd14 --- /dev/null +++ b/mcp-server/src/renderer/emoji-loader.js @@ -0,0 +1,137 @@ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const TWEMOJI_VERSION = "15.1.0"; +const CDN_BASE = `https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg`; +const CACHE_DIR = join(homedir(), ".cache", "drawd-mcp", "emoji"); +const FETCH_TIMEOUT_MS = 3000; + +const TRANSPARENT_SVG_DATA_URI = + "data:image/svg+xml;base64," + + Buffer.from( + '' + ).toString("base64"); + +const resolvedCache = new Map(); +const inFlight = new Map(); +const warnedCodes = new Set(); + +let cacheDirEnsured = null; + +async function ensureCacheDir() { + if (!cacheDirEnsured) { + cacheDirEnsured = mkdir(CACHE_DIR, { recursive: true }).catch(() => null); + } + return cacheDirEnsured; +} + +function toDataUri(svgText) { + return `data:image/svg+xml;base64,${Buffer.from(svgText, "utf8").toString("base64")}`; +} + +function toCodePoint(rune, sep = "-") { + const r = []; + let p = 0; + let i = 0; + while (i < rune.length) { + const c = rune.charCodeAt(i++); + if (p) { + r.push((0x10000 + ((p - 0xd800) << 10) + (c - 0xdc00)).toString(16)); + p = 0; + } else if (c >= 0xd800 && c <= 0xdbff) { + p = c; + } else { + r.push(c.toString(16)); + } + } + return r.join(sep); +} + +// Returns [primary, fallback]. jdecked/twemoji asset names mostly follow +// Twemoji's canonical rule (strip U+FE0F unless the sequence contains U+200D), +// but there are inconsistencies — e.g. "eye in speech bubble" is stored without +// FE0F despite being a ZWJ sequence. We try the canonical name first and fall +// back to the fully-stripped variant so both patterns resolve. +export function getEmojiCode(segment) { + const hasZwj = segment.includes("\u200d"); + const stripped = toCodePoint(segment.replace(/\ufe0f/g, "")); + if (!hasZwj) return [stripped, null]; + const kept = toCodePoint(segment); + return kept === stripped ? [kept, null] : [kept, stripped]; +} + +async function readDisk(code) { + try { + const svg = await readFile(join(CACHE_DIR, `${code}.svg`), "utf8"); + return toDataUri(svg); + } catch { + return null; + } +} + +async function writeDisk(code, svgText) { + try { + await ensureCacheDir(); + await writeFile(join(CACHE_DIR, `${code}.svg`), svgText, "utf8"); + } catch { + // Silently ignore: read-only FS, permissions, etc. + } +} + +async function fetchFromCdn(code) { + const url = `${CDN_BASE}/${code}.svg`; + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) { + throw new Error(`Twemoji fetch ${code}: HTTP ${res.status}`); + } + return await res.text(); +} + +async function resolveOne(code) { + const fromDisk = await readDisk(code); + if (fromDisk) return fromDisk; + + const svgText = await fetchFromCdn(code); + await writeDisk(code, svgText); + return toDataUri(svgText); +} + +async function resolveEmoji(primary, fallback) { + try { + return await resolveOne(primary); + } catch (err) { + if (!fallback) throw err; + return await resolveOne(fallback); + } +} + +export async function loadEmojiSvg(codes) { + const [primary, fallback] = Array.isArray(codes) ? codes : [codes, null]; + + const cached = resolvedCache.get(primary); + if (cached) return cached; + + const pending = inFlight.get(primary); + if (pending) return pending; + + const promise = resolveEmoji(primary, fallback) + .then((dataUri) => { + resolvedCache.set(primary, dataUri); + return dataUri; + }) + .catch((err) => { + if (!warnedCodes.has(primary)) { + warnedCodes.add(primary); + console.warn(`[drawd-mcp] emoji ${primary} unavailable: ${err.message}`); + } + resolvedCache.set(primary, TRANSPARENT_SVG_DATA_URI); + return TRANSPARENT_SVG_DATA_URI; + }) + .finally(() => { + inFlight.delete(primary); + }); + + inFlight.set(primary, promise); + return promise; +} diff --git a/mcp-server/src/renderer/satori-renderer.js b/mcp-server/src/renderer/satori-renderer.js index a09ba78..9329d8f 100644 --- a/mcp-server/src/renderer/satori-renderer.js +++ b/mcp-server/src/renderer/satori-renderer.js @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { resolveViewport, DEVICE_PRESETS } from "./device-presets.js"; +import { getEmojiCode, loadEmojiSvg } from "./emoji-loader.js"; // Dynamic imports resolved at runtime to support esbuild bundling let _satori = null; @@ -60,8 +61,14 @@ export class SatoriRenderer { const { device, width, height } = options; const viewport = resolveViewport(device, width, height); + // satori-html does not decode HTML entities. Agents frequently write + // numeric entities (●, ●) and safe named entities (•, + // …) — decode them before parsing so they render as glyphs, not + // literal text. + const decodedHtml = decodeSafeEntities(htmlString); + // Wrap bare content in a full-page container if needed - const wrappedHtml = ensureRootContainer(htmlString, viewport.width, viewport.height); + const wrappedHtml = ensureRootContainer(decodedHtml, viewport.width, viewport.height); // Satori requires every element with multiple children to have an explicit // display property. Auto-inject display:flex;flex-direction:column on any @@ -76,6 +83,12 @@ export class SatoriRenderer { width: viewport.width, height: viewport.height, fonts: this.fonts, + loadAdditionalAsset: async (code, segment) => { + if (code === "emoji") { + return await loadEmojiSvg(getEmojiCode(segment)); + } + return []; + }, }); // SVG -> PNG buffer at 2x (Retina) @@ -171,3 +184,38 @@ function ensureRootContainer(htmlString, width, height) { // Wrap in a root container return `
${trimmed}
`; } + +// Named entities whose decoded form contains no HTML metacharacter +// (<, >, &, ", '). Safe to decode before HTML parsing because the result +// can never be mistaken for markup. Deliberately excludes &/</>/ +// "/' — decoding those would break the parser. +const SAFE_NAMED_ENTITIES = { + nbsp: "\u00a0", copy: "\u00a9", reg: "\u00ae", trade: "\u2122", + hellip: "\u2026", mdash: "\u2014", ndash: "\u2013", + laquo: "\u00ab", raquo: "\u00bb", middot: "\u00b7", bull: "\u2022", + deg: "\u00b0", plusmn: "\u00b1", times: "\u00d7", divide: "\u00f7", + para: "\u00b6", sect: "\u00a7", dagger: "\u2020", Dagger: "\u2021", + spades: "\u2660", clubs: "\u2663", hearts: "\u2665", diams: "\u2666", + larr: "\u2190", uarr: "\u2191", rarr: "\u2192", darr: "\u2193", + harr: "\u2194", crarr: "\u21b5", lArr: "\u21d0", rArr: "\u21d2", + check: "\u2713", cross: "\u2717", + lsquo: "\u2018", rsquo: "\u2019", ldquo: "\u201c", rdquo: "\u201d", + prime: "\u2032", Prime: "\u2033", +}; + +function decodeSafeEntities(html) { + return html + .replace(/&#(\d+);/g, (match, dec) => { + const code = parseInt(dec, 10); + return Number.isFinite(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : match; + }) + .replace(/&#[xX]([0-9a-fA-F]+);/g, (match, hex) => { + const code = parseInt(hex, 16); + return Number.isFinite(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : match; + }) + .replace(/&([a-zA-Z]+);/g, (match, name) => SAFE_NAMED_ENTITIES[name] || match); +}