diff --git a/package-lock.json b/package-lock.json index 534c6f462..b0d3037c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,8 +94,10 @@ "protobufjs": "^7.5.5", "sinon": "^21.0.1", "sinon-chai": "^4.0.0", + "skia-canvas": "^3.0.8", "tailwindcss": "^4.1.18", "tsconfig-paths": "^4.2.0", + "twemoji-colr-font": "^15.0.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "vite": "^7.3.2", @@ -7709,11 +7711,10 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -8692,6 +8693,26 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -11033,6 +11054,12 @@ "node": ">=6" } }, + "node_modules/parenthesis": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", + "dev": true + }, "node_modules/parse-bmfont-ascii": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", @@ -12374,6 +12401,19 @@ "node": ">=18" } }, + "node_modules/skia-canvas": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/skia-canvas/-/skia-canvas-3.0.8.tgz", + "integrity": "sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "detect-libc": "^2.1.1", + "follow-redirects": "^1.15.11", + "https-proxy-agent": "^7.0.6", + "string-split-by": "^1.0.0" + } + }, "node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -12475,6 +12515,15 @@ "node": ">=0.6.19" } }, + "node_modules/string-split-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", + "dev": true, + "dependencies": { + "parenthesis": "^3.1.5" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -13099,6 +13148,13 @@ "node": "*" } }, + "node_modules/twemoji-colr-font": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/twemoji-colr-font/-/twemoji-colr-font-15.0.3.tgz", + "integrity": "sha512-UsK4JUpaczeVoMGeYMnKaMTxKt7fujg1nQOk4NaC0teZmdOo+uAai0DIuaSzsMkShtG4J75F0OEsVdGJ+Q1pHQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 72e07af42..0990cde08 100644 --- a/package.json +++ b/package.json @@ -78,10 +78,12 @@ "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-sh": "^0.17.4", "protobufjs": "^7.5.5", + "skia-canvas": "^3.0.8", "sinon": "^21.0.1", "sinon-chai": "^4.0.0", "tailwindcss": "^4.1.18", "tsconfig-paths": "^4.2.0", + "twemoji-colr-font": "^15.0.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "vite": "^7.3.2", diff --git a/resources/images/namelayer-emojis.png b/resources/images/namelayer-emojis.png index 869440c22..304fc0bea 100644 Binary files a/resources/images/namelayer-emojis.png and b/resources/images/namelayer-emojis.png differ diff --git a/resources/images/namelayer-icons.png b/resources/images/namelayer-icons.png index ff3902741..cf61307b5 100644 Binary files a/resources/images/namelayer-icons.png and b/resources/images/namelayer-icons.png differ diff --git a/scripts/build-namelayer-assets.mjs b/scripts/build-namelayer-assets.mjs index c409ffeff..5debd25ca 100644 --- a/scripts/build-namelayer-assets.mjs +++ b/scripts/build-namelayer-assets.mjs @@ -2,6 +2,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { Canvas, FontLibrary, loadImage } from "skia-canvas"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); @@ -12,6 +13,10 @@ const imagesDir = path.join(root, "resources", "images"); const fontPng = "namelayer_overpass.png"; const fontXml = "namelayer_overpass.xml"; const fontFace = "namelayer_overpass"; +const emojiFontFamily = "NameLayerEmoji"; +const emojiFontPath = require.resolve("twemoji-colr-font/twemoji.woff2"); +const emojiFontSize = 96; +const atlasFramePaddingRatio = 1 / 16; const fontSourceCandidates = [ "overpass-regular.otf", "overpass-regular.ttf", @@ -46,45 +51,12 @@ const iconSources = [ fs.mkdirSync(fontsDir, { recursive: true }); fs.mkdirSync(imagesDir, { recursive: true }); -const canvasApi = await loadCanvasApi(); +FontLibrary.use(emojiFontFamily, emojiFontPath); await buildMsdfFont(); await buildIconAtlas(); await buildEmojiAtlas(); -async function loadCanvasApi() { - try { - const api = await import("canvas"); - const fontPath = fontSourceCandidates - .map((fileName) => path.join(fontsDir, fileName)) - .find((candidate) => fs.existsSync(candidate)); - try { - if (!fontPath) { - throw new Error( - `No Overpass font source found. Tried: ${fontSourceCandidates.join( - ", ", - )}`, - ); - } - api.registerFont(fontPath, { - family: "OverpassNameLayer", - }); - } catch (error) { - console.warn( - "Could not register Overpass; using canvas fallback font", - error, - ); - } - return api; - } catch (error) { - console.warn( - "canvas native bindings are unavailable; writing deterministic fallback NameLayer assets", - error, - ); - return null; - } -} - async function buildMsdfFont() { const fontPath = fontSourceCandidates .map((fileName) => path.join(fontsDir, fileName)) @@ -147,16 +119,10 @@ async function buildMsdfFont() { } async function buildIconAtlas() { - if (!canvasApi) { - writeFallbackAtlas("namelayer-icons", iconSources); - return; - } - - const { createCanvas, loadImage } = canvasApi; const cell = 256; const cols = 4; const rows = Math.ceil(iconSources.length / cols); - const canvas = createCanvas(cols * cell, rows * cell); + const canvas = new Canvas(cols * cell, rows * cell); const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); const frames = {}; @@ -168,8 +134,10 @@ async function buildIconAtlas() { const x = col * cell; const y = row * cell; try { - const img = await loadIconImage(path.join(imagesDir, source), loadImage); - ctx.drawImage(img, x, y, cell, cell); + const img = await loadIconImage(path.join(imagesDir, source)); + drawPackedAtlasFrame(ctx, x, y, cell, (scratchCtx, scratchSize) => { + drawContainedImage(scratchCtx, img, 0, 0, scratchSize, scratchSize); + }); } catch (error) { console.warn( `Could not pack ${source}; leaving empty atlas frame`, @@ -185,9 +153,14 @@ async function buildIconAtlas() { }; } + validateAtlasFramesPixels(ctx, canvas.width, canvas.height, frames, { + label: "icon", + requireColor: false, + }); + fs.writeFileSync( path.join(imagesDir, "namelayer-icons.png"), - canvas.toBuffer("image/png"), + await canvas.toBuffer("png"), ); fs.writeFileSync( path.join(imagesDir, "namelayer-icons.json"), @@ -208,7 +181,7 @@ async function buildIconAtlas() { ); } -async function loadIconImage(sourcePath, loadImage) { +async function loadIconImage(sourcePath) { if (path.extname(sourcePath).toLowerCase() !== ".svg") { return loadImage(sourcePath); } @@ -225,30 +198,17 @@ async function loadIconImage(sourcePath, loadImage) { ); } - return loadImage( - `data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`, - ); + return loadImage(Buffer.from(svg, "utf8")); } async function buildEmojiAtlas() { - if (!canvasApi) { - const emojis = readEmojiTable(); - writeFallbackAtlas("namelayer-emojis", emojis); - return; - } - - const { createCanvas } = canvasApi; const emojis = readEmojiTable(); const cell = 128; const cols = 8; const rows = Math.max(1, Math.ceil(emojis.length / cols)); - const canvas = createCanvas(cols * cell, rows * cell); + const canvas = new Canvas(cols * cell, rows * cell); const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.font = - '96px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif'; const frames = {}; emojis.forEach((emoji, index) => { @@ -256,7 +216,12 @@ async function buildEmojiAtlas() { const row = Math.floor(index / cols); const x = col * cell; const y = row * cell; - ctx.fillText(emoji, x + cell / 2, y + cell / 2); + drawPackedAtlasFrame(ctx, x, y, cell, (scratchCtx, scratchSize) => { + scratchCtx.textAlign = "center"; + scratchCtx.textBaseline = "middle"; + scratchCtx.font = `${emojiFontSize}px ${emojiFontFamily}`; + scratchCtx.fillText(emoji, scratchSize / 2, scratchSize / 2); + }); frames[emoji] = { frame: { x, y, w: cell, h: cell }, rotated: false, @@ -266,9 +231,14 @@ async function buildEmojiAtlas() { }; }); + validateAtlasFramesPixels(ctx, canvas.width, canvas.height, frames, { + label: "emoji", + requireColor: true, + }); + fs.writeFileSync( path.join(imagesDir, "namelayer-emojis.png"), - canvas.toBuffer("image/png"), + await canvas.toBuffer("png"), ); fs.writeFileSync( path.join(imagesDir, "namelayer-emojis.json"), @@ -289,6 +259,120 @@ async function buildEmojiAtlas() { ); } +function drawPackedAtlasFrame(targetCtx, x, y, cell, drawSource) { + const scratchSize = cell * 2; + const scratch = new Canvas(scratchSize, scratchSize); + const scratchCtx = scratch.getContext("2d"); + scratchCtx.clearRect(0, 0, scratchSize, scratchSize); + drawSource(scratchCtx, scratchSize); + + const bounds = findAlphaBounds( + scratchCtx.getImageData(0, 0, scratchSize, scratchSize).data, + scratchSize, + scratchSize, + ); + if (!bounds) { + throw new Error("NameLayer atlas frame source rendered empty"); + } + + const sourceWidth = bounds.maxX - bounds.minX + 1; + const sourceHeight = bounds.maxY - bounds.minY + 1; + const padding = Math.round(cell * atlasFramePaddingRatio); + const maxSize = cell - padding * 2; + const scale = Math.min(maxSize / sourceWidth, maxSize / sourceHeight, 1); + const drawWidth = Math.ceil(sourceWidth * scale); + const drawHeight = Math.ceil(sourceHeight * scale); + const drawX = x + Math.floor((cell - drawWidth) / 2); + const drawY = y + Math.floor((cell - drawHeight) / 2); + + targetCtx.drawImage( + scratch, + bounds.minX, + bounds.minY, + sourceWidth, + sourceHeight, + drawX, + drawY, + drawWidth, + drawHeight, + ); +} + +function drawContainedImage(ctx, image, x, y, width, height) { + const sourceWidth = image.width ?? width; + const sourceHeight = image.height ?? height; + const scale = Math.min(width / sourceWidth, height / sourceHeight); + const drawWidth = sourceWidth * scale; + const drawHeight = sourceHeight * scale; + ctx.drawImage( + image, + x + (width - drawWidth) / 2, + y + (height - drawHeight) / 2, + drawWidth, + drawHeight, + ); +} + +function findAlphaBounds(data, width, height) { + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (data[(y * width + x) * 4 + 3] === 0) { + continue; + } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + + return maxX >= minX && maxY >= minY ? { minX, minY, maxX, maxY } : null; +} + +function validateAtlasFramesPixels( + ctx, + width, + height, + frames, + { label, requireColor }, +) { + const data = ctx.getImageData(0, 0, width, height).data; + let colorfulPixels = 0; + + for (const [key, { frame }] of Object.entries(frames)) { + let alphaPixels = 0; + for (let y = frame.y; y < frame.y + frame.h; y++) { + for (let x = frame.x; x < frame.x + frame.w; x++) { + const offset = (y * width + x) * 4; + const r = data[offset]; + const g = data[offset + 1]; + const b = data[offset + 2]; + const a = data[offset + 3]; + if (a === 0) { + continue; + } + alphaPixels++; + if (Math.max(r, g, b) - Math.min(r, g, b) > 12) { + colorfulPixels++; + } + } + } + + if (alphaPixels === 0) { + throw new Error(`NameLayer ${label} atlas frame is empty: ${key}`); + } + } + + if (requireColor && colorfulPixels === 0) { + throw new Error(`NameLayer ${label} atlas rendered without color pixels`); + } +} + function readEmojiTable() { const utilPath = path.join(root, "src", "core", "Util.ts"); const utilSource = fs.readFileSync(utilPath, "utf8"); @@ -306,40 +390,3 @@ function readEmojiTable() { return Array.from(match[1].matchAll(/"([^"]+)"/g), (match) => match[1]); } - -function writeFallbackAtlas(name, keys) { - const transparentPng = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lD6N7wAAAABJRU5ErkJggg==", - "base64", - ); - const frames = {}; - - for (const key of keys) { - frames[key] = { - frame: { x: 0, y: 0, w: 1, h: 1 }, - rotated: false, - trimmed: false, - spriteSourceSize: { x: 0, y: 0, w: 1, h: 1 }, - sourceSize: { w: 1, h: 1 }, - }; - } - - fs.writeFileSync(path.join(imagesDir, `${name}.png`), transparentPng); - fs.writeFileSync( - path.join(imagesDir, `${name}.json`), - `${JSON.stringify( - { - frames, - meta: { - app: "scripts/build-namelayer-assets.mjs", - image: `${name}.png`, - format: "RGBA8888", - size: { w: 1, h: 1 }, - scale: "1", - }, - }, - null, - 2, - )}\n`, - ); -}