Make namelayer atlas generation deterministic

Replace node-canvas in the namelayer asset builder with skia-canvas so icon and emoji atlases are rasterized through the same cross-platform rendering backend. This removes the previous dependency on host Cairo/Pango emoji rendering, which could produce monochrome emoji sprites depending on the local font stack.

Add twemoji-colr-font as a pinned COLR/CPAL emoji font input for the build. The emoji atlas now renders from that explicit font instead of whichever OS emoji font happens to be installed, making the generated sprite sheet stable across developer machines and CI.

Generalize atlas frame packing for both SVG icons and emoji glyphs: render into a scratch canvas, trim to alpha bounds, center, and scale into the cell with consistent padding. This prevents icon frames from touching cell edges while preserving the existing Pixi atlas JSON contract.

Regenerate namelayer emoji and icon atlases. The builder now validates generated atlas frames and fails if any frame is empty or if the emoji atlas contains no color pixels, catching the monochrome-regeneration failure at build time.
This commit is contained in:
scamiv
2026-05-09 15:17:03 +02:00
parent 7139a111ab
commit bd3e8f5c3c
5 changed files with 208 additions and 103 deletions
+60 -4
View File
@@ -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",
+2
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 121 KiB

+146 -99
View File
@@ -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`,
);
}