12// Renders a game's category glyphs into the FMG archive as standalone SVGs.3//4// MapGenie ships its category icons as an icon font referenced from a5// stylesheet, so the glyph for "police_station" is a codepoint rather than a6// file. This resolves that indirection: the stylesheet gives key -> codepoint,7// the SVG font gives codepoint -> outline, and each pairing is written to8// <archive>/games/<game-dir>/icons/<key>.svg, which is where tools/generate9// looks for them.10//11// It runs once per game rather than per tile, which is why it stays a small12// script instead of moving into the Go crawler.13//14// node tools/icons/render-icons.mjs --archive ../gamemap/fmg-archive --game skyrim15//16// Without --game, every game already present in the archive is refreshed.1718import { createHash } from "node:crypto";19import { mkdir, readFile, writeFile } from "node:fs/promises";20import path from "node:path";21import process from "node:process";2223import { cropPNG, decodePNG, encodePNG, isBlank } from "./png.mjs";2425const API = "https://mapgenie.io/api/v1";26const CDN = "https://cdn.mapgenie.io";27// The CDN answers 403 to requests that do not look like a browser.28// Used when a category carries no legend colour of its own; light enough to29// stay legible on the dark map.30const FALLBACK_COLOR = "#c8ccbd";31const USER_AGENT =32 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +33 "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";3435const parseArguments = (argv) => {36 const options = { archive: "../gamemap/fmg-archive", game: null };37 for (let index = 0; index < argv.length; index += 1) {38 const flag = argv[index];39 if (flag === "--archive") options.archive = argv[++index];40 else if (flag === "--game") options.game = argv[++index];41 else if (flag === "--help" || flag === "-h") options.help = true;42 else throw new Error(`unknown argument ${flag}`);43 }44 return options;45};4647let lastRequest = 0;48// A single game needs only a handful of requests, but they are still spaced49// out so a loop over every game stays a light load.50const fetchPolitely = async (url, { optional = false } = {}) => {51 const wait = Math.max(0, lastRequest + 150 - Date.now());52 if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));53 lastRequest = Date.now();5455 const response = await fetch(url, {56 headers: { "User-Agent": USER_AGENT, Accept: "*/*" },57 });58 if (!response.ok) {59 if (optional && (response.status === 403 || response.status === 404)) return null;60 throw new Error(`HTTP ${response.status} from ${url}`);61 }62 return response;63};6465const fetchText = async (url, options) => {66 const response = await fetchPolitely(url, options);67 return response && response.text();68};6970const fetchJSON = async (url, options) => {71 const response = await fetchPolitely(url, options);72 return response && response.json();73};7475const fetchBuffer = async (url, options) => {76 const response = await fetchPolitely(url, options);77 return response && Buffer.from(await response.arrayBuffer());78};7980const cssColor = (value) => {81 if (!value) return null;82 const rgb = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(value);83 if (rgb) {84 return `#${[1, 2, 3]85 .map((index) => Number(rgb[index]).toString(16).padStart(2, "0"))86 .join("")}`;87 }88 return /#[0-9a-f]{3,8}/i.exec(value)?.[0] ?? null;89};9091// An icon is either a single glyph, `.icon-<key>:before`, or several stacked92// layers, `.icon-<key> .path1:before` .. `.pathN:before`, each with its own93// colour. Both forms are collected per key, in the order the layers paint.94const readIconLayers = (css) => {95 const icons = new Map();96 const layerAt = (key, order) => {97 if (!icons.has(key)) icons.set(key, []);98 const layers = icons.get(key);99 const existing = layers.find((layer) => layer.order === order);100 if (existing) return existing;101 const layer = { order };102 layers.push(layer);103 return layer;104 };105106 const rule =107 /\.icon-([a-z0-9_-]+)(?:\s+\.path(\d+))?:before\s*\{([^}]*)\}/gi;108 for (const match of css.matchAll(rule)) {109 const [, key, pathNumber, body] = match;110 const codepoint = /content:\s*"\\([0-9a-fA-F]+)"/.exec(body)?.[1];111 if (!codepoint) continue;112 const layer = layerAt(key, pathNumber ? Number(pathNumber) : 0);113 layer.codepoint = codepoint.toLowerCase();114 layer.color = cssColor(/color:\s*([^;]+)/i.exec(body)?.[1]);115 const opacity = /opacity:\s*([0-9.]+)/i.exec(body)?.[1];116 if (opacity) layer.opacity = Number(opacity);117 }118119 for (const layers of icons.values()) layers.sort((a, b) => a.order - b.order);120 return icons;121};122123const readFontURL = (css) => {124 const match = css.match(/url\(['"]?([^'")]*\.svg[^'")]*)['"]?\)/i);125 if (!match) return null;126 return new URL(match[1].replace(/#.*$/, ""), `${CDN}/css/themes/icons/`).href;127};128129// Some games publish one marker sprite that CSS windows into, rather than a130// font: `[class^="icon-"] { background: url(sprite.png); background-size: 27px;131// width: 27px; height: 33px }` with `.icon-<key> { background-position: -Xpx -Ypx }`132// placing each cell. Both offsets are read: a sprite may be a single column or133// a grid, and the pitch is not always a whole number of pixels.134const readSprite = (css) => {135 const source = /\[class\^?[*]?="icon-"\][^{]*\{([^}]*)\}/i.exec(css)?.[1];136 if (!source) return null;137 const url = /url\(['"]?([^'")]+\.png[^'")]*)['"]?\)/i.exec(source)?.[1];138 if (!url) return null;139 const size = Number(/background-size:\s*([0-9.]+)px/i.exec(source)?.[1] ?? 0);140 const cellWidth = Number(/[^-]width:\s*([0-9.]+)px/i.exec(source)?.[1] ?? size);141 const cellHeight = Number(/height:\s*([0-9.]+)px/i.exec(source)?.[1] ?? cellWidth);142 if (!cellWidth || !cellHeight) return null;143144 const offsets = new Map();145 const length = "(-?[0-9.]+)(?:px)?";146 const rule = new RegExp(147 `\\.icon-([a-z0-9_-]+)\\s*\\{[^}]*?background-position:\\s*${length}\\s+${length}`,148 "gi"149 );150 for (const match of css.matchAll(rule)) {151 // A key repeated in the sprite keeps its first cell; later ones are spares.152 if (offsets.has(match[1])) continue;153 offsets.set(match[1], { x: Math.abs(Number(match[2])), y: Math.abs(Number(match[3])) });154 }155 if (offsets.size === 0) return null;156 return {157 url: new URL(url, `${CDN}/css/themes/icons/`).href,158 cellWidth,159 cellHeight,160 displayWidth: size || cellWidth,161 offsets,162 };163};164165// An SVG font is XML, so the glyphs can be read directly rather than through a166// font parser: each carries its codepoint and its outline.167const readGlyphs = (svg) => {168 const glyphs = new Map();169 const element = /<glyph\b([^>]*)\/?>/gi;170 for (const match of svg.matchAll(element)) {171 const attributes = match[1];172 const unicode = /unicode="&#x([0-9a-fA-F]+);"/.exec(attributes);173 const outline = /\sd="([^"]*)"/.exec(attributes);174 if (!unicode || !outline || !outline[1].trim()) continue;175 glyphs.set(unicode[1].toLowerCase(), outline[1]);176 }177 const face = /<font-face\b([^>]*)/i.exec(svg)?.[1] ?? "";178 const number = (name, fallback) =>179 Number(new RegExp(`${name}="(-?[0-9.]+)"`).exec(face)?.[1] ?? fallback);180 return {181 glyphs,182 unitsPerEm: number("units-per-em", 1024),183 ascent: number("ascent", 960),184 };185};186187// Font outlines are y-up from the baseline; SVG is y-down from the top corner,188// so the glyph is flipped about the ascent line to sit in a square viewBox.189//190// Layers paint in order. Atlas draws these as background images, where nothing191// outside the file can tint them and `currentColor` has no inherited value to192// resolve against, so a layer without its own colour needs the legend colour193// carried on the root element rather than left to the page.194const renderSVG = (key, layers, unitsPerEm, ascent, defaultColor) => {195 const paths = layers196 .map(({ outline, color, opacity }) => {197 const fill = color ? `fill="${color}"` : `fill="currentColor"`;198 const alpha = opacity === undefined ? "" : ` fill-opacity="${opacity}"`;199 return ` <path ${fill}${alpha} d="${outline}"/>`;200 })201 .join("\n");202 return (203 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${unitsPerEm} ${unitsPerEm}" ` +204 `color="${defaultColor}" role="img">\n` +205 ` <title>${key}</title>\n` +206 ` <g transform="translate(0, ${ascent}) scale(1, -1)">\n` +207 `${paths}\n` +208 ` </g>\n` +209 `</svg>\n`210 );211};212213const renderGame = async (archiveRoot, game, directory) => {214 const css = await fetchText(`${CDN}/css/themes/icons/${game.slug}-icons.css`, {215 optional: true,216 });217 if (!css) {218 console.log(` ${game.slug}: no icon stylesheet published`);219 return 0;220 }221 const icons = readIconLayers(css);222 const fontURL = readFontURL(css);223 const sprite = fontURL ? null : readSprite(css);224 if ((!fontURL || icons.size === 0) && !sprite) {225 console.log(` ${game.slug}: stylesheet carries no usable glyph mapping`);226 return 0;227 }228 const font = fontURL ? readGlyphs(await fetchText(fontURL)) : null;229 const strip = sprite ? decodePNG(await fetchBuffer(sprite.url)) : null;230231 // Which glyphs matter, and what colour the legend gives them, comes from the232 // maps themselves.233 const usages = new Map();234 for (const reference of game.maps ?? []) {235 const full = await fetchJSON(`${API}/maps/${reference.id}/full`, { optional: true });236 if (!full) continue;237 for (const group of full.groups ?? []) {238 for (const category of group.categories ?? []) {239 if (!category.icon) continue;240 if (!usages.has(category.icon)) usages.set(category.icon, []);241 usages.get(category.icon).push({242 categoryId: category.id,243 categoryTitle: category.title,244 groupId: group.id,245 legendColor: (category.color ?? "").replace(/^#/, "") || undefined,246 mapId: full.id,247 });248 }249 }250 }251252 const iconDirectory = path.join(archiveRoot, directory, "icons");253 await mkdir(iconDirectory, { recursive: true });254255 const index = [];256 let missing = 0;257 for (const [key, uses] of [...usages].sort(([a], [b]) => a.localeCompare(b))) {258 const legend = uses.find((use) => use.legendColor)?.legendColor;259 const defaultColor = legend ? `#${legend.toLowerCase()}` : FALLBACK_COLOR;260261 if (strip) {262 const offset = sprite.offsets.get(key);263 if (!offset) {264 missing += 1;265 continue;266 }267 // Offsets are in displayed pixels; the sprite is published at a multiple268 // of that, so they scale to source pixels before cutting.269 const scale = strip.width / sprite.displayWidth;270 const w = Math.min(Math.round(sprite.cellWidth * scale), strip.width);271 const h = Math.round(sprite.cellHeight * scale);272 const left = Math.min(Math.round(offset.x * scale), strip.width - w);273 const top = Math.round(offset.y * scale);274 if (top + h > strip.height || left < 0) {275 missing += 1;276 continue;277 }278 const slice = cropPNG(strip, left, top, w, h);279 if (isBlank(slice)) {280 missing += 1;281 continue;282 }283 const png = encodePNG(slice);284 await writeFile(path.join(iconDirectory, `${key}.png`), png);285 index.push({286 contentHash: createHash("sha256").update(png).digest("hex"),287 defaultColor,288 file: `${key}.png`,289 key,290 spriteOffset: offset,291 spriteCell: { w: sprite.cellWidth, h: sprite.cellHeight },292 sourceCssUrl: `${CDN}/css/themes/icons/${game.slug}-icons.css`,293 sourceSpriteUrl: sprite.url,294 usages: uses,295 });296 continue;297 }298299 const drawn = (icons.get(key) ?? [])300 .map((layer) => ({ ...layer, outline: font.glyphs.get(layer.codepoint) }))301 .filter((layer) => layer.outline);302 if (drawn.length === 0) {303 missing += 1;304 continue;305 }306 const svg = renderSVG(key, drawn, font.unitsPerEm, font.ascent, defaultColor);307 await writeFile(path.join(iconDirectory, `${key}.svg`), svg);308 index.push({309 codepoint: drawn.map((layer) => layer.codepoint).join("+"),310 contentHash: createHash("sha256").update(svg).digest("hex"),311 defaultColor,312 file: `${key}.svg`,313 key,314 sourceCssUrl: `${CDN}/css/themes/icons/${game.slug}-icons.css`,315 sourceFontUrl: fontURL,316 usages: uses,317 });318 }319 await writeFile(320 path.join(iconDirectory, "index.json"),321 `${JSON.stringify(index, null, 2)}\n`322 );323 console.log(324 ` ${game.slug}: ${index.length} icons written` +325 (missing ? ` ยท ${missing} referenced but not in the font` : "")326 );327 return index.length;328};329330const main = async () => {331 const options = parseArguments(process.argv.slice(2));332 if (options.help) {333 console.log(334 "usage: render-icons.mjs [--archive PATH] [--game SLUG]\n" +335 "Renders category glyphs into <archive>/games/<game>/icons/."336 );337 return;338 }339340 const archiveRoot = path.resolve(options.archive);341 const archiveFile = JSON.parse(342 await readFile(path.join(archiveRoot, "archive.json"), "utf8")343 );344 const directories = new Map(345 (archiveFile.games ?? []).map((entry) => [entry.id, entry.directory])346 );347348 const games = await fetchJSON(`${API}/games`);349 const wanted = games.filter((game) =>350 options.game351 ? game.slug === options.game || String(game.id) === options.game352 : directories.has(game.id)353 );354 if (wanted.length === 0) {355 throw new Error(356 options.game357 ? `no game matches ${options.game}`358 : "archive.json lists no games"359 );360 }361362 console.log(`Rendering icons for ${wanted.length} game(s)`);363 let total = 0;364 for (const game of wanted) {365 const directory = directories.get(game.id);366 if (!directory) {367 console.log(` ${game.slug}: not in this archive, skipping`);368 continue;369 }370 total += await renderGame(archiveRoot, game, directory);371 }372 console.log(`${total} icons rendered`);373};374375main().catch((error) => {376 console.error("render-icons:", error.message);377 process.exit(1);378});