import fs from "fs"; import path from "path"; import sharp from "sharp"; /** * Recursively search an object for any property called `icon` * and push the string value to the supplied Set. * * @param obj The object to search (may be nested) * @param set The Set that collects unique icon strings */ export function walkForIcons(obj: unknown, set: Set) { if (obj && typeof obj === "object") { if (Array.isArray(obj)) { for (const item of obj) walkForIcons(item, set); } else { for (const [key, value] of Object.entries(obj)) { if (key === "icon" && typeof value === "string") { set.add(value); } else { walkForIcons(value, set); } } } } } /** * Metadata for a single icon. * All icons are guaranteed to be square. */ interface IconMeta { name: string; // icon name (without extension) size: number; // width = height buffer: Buffer; file: string; // absolute path to the PNG } /** * Position of an icon inside its texture. */ export interface IconPosition { name: string; x: number; y: number; size: number; } async function validateIcons(icons: string[], scriptOutputPath: string) { const validIcons: IconMeta[] = []; for (const icon of icons) { const absPath = path.resolve(scriptOutputPath, icon); // Skip if file does not exist or is not a PNG if (!fs.existsSync(absPath) || path.extname(absPath).toLowerCase() !== ".png") continue; const stat = fs.statSync(absPath); if (stat.size === 0) continue; // skip empty files const buffer = fs.readFileSync(absPath); const meta = await sharp(buffer).metadata(); if (!meta.width || !meta.height || meta.width !== meta.height) continue; // skip non‑square validIcons.push({ name: icon, file: absPath, buffer, size: meta.width, }); } return validIcons; } function groupBySize(metas: IconMeta[]): Record { const groups: Record = {}; for (const m of metas) { const key = String(m.size); groups[key] ??= []; groups[key].push(m); } return groups; } /** * Packs a set of square icons of the same size into a single texture image. * * The icons are laid out in a regular grid (rows × columns) where the * number of columns is the ceiling of the square root of the icon count, * and the number of rows is the smallest integer that can hold all icons. * * @param icons Array of {@link IconMeta} objects that all share the same `size`. * @param iconSize Width (and height) of each icon in pixels. * @returns An object containing: * - `textureBuffer`: Buffer with the resulting WEBP image that contains * all icons arranged in the grid. * - `iconMap`: Mapping from icon name to its position (`x`, `y`, * `width`, `height`) inside the texture. `width` and `height` are equal * to `iconSize`. * * @remarks * * All icons are guaranteed to be square; `iconSize` is the common * dimension of every icon in `icons`. * * The texture background is fully transparent. */ async function createTextureMap( icons: IconMeta[], iconSize: number ): Promise<{ textureBuffer: Buffer; iconMap: Record }> { const n = icons.length; const cols = Math.ceil(Math.sqrt(n)); const rows = Math.ceil(n / cols); const canvasW = cols * iconSize; const canvasH = rows * iconSize; const composites: sharp.OverlayOptions[] = []; const map: Record = {}; icons.forEach((icon, idx) => { const x = (idx % cols) * iconSize; const y = Math.floor(idx / cols) * iconSize; composites.push({ input: icon.file, left: x, top: y }); map[icon.name] = { name: icon.name, x, y, size: iconSize }; }); const buffer = await sharp({ create: { width: canvasW, height: canvasH, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, }) .composite(composites) .webp({ lossless: true }) .toBuffer(); return { textureBuffer: buffer, iconMap: map }; } /** * Build a texture map from a list of PNG icons. * * @param icons Array of icon file paths (relative to scriptOutputPath) * @param scriptOutputPath Base directory where the icons are located * @param outputDir Destination directory for the generated files. * * The function will: * • Filter out non‑PNG files, missing files or empty entries. * • Ensure every PNG is square and that group icons by dimensions. * • Pack the icons into a single WebP texture (grid layout). * • Output `icon_{size}.webp` and `iconMap.json` into `outputDir`. */ export async function buildIconTextureMap(icons: string[], scriptOutputPath: string, outputDir: string): Promise { const validIcons = await validateIcons(icons, scriptOutputPath); if (validIcons.length === 0) { throw new Error("No valid PNG icons found."); } const iconPositions: IconPosition[] = []; for (let [size, group] of Object.entries(groupBySize(validIcons))) { const outTexturePath = path.join(outputDir, `icon_${size}.webp`); const { textureBuffer, iconMap } = await createTextureMap(group, parseInt(size)); fs.writeFileSync(outTexturePath, textureBuffer); iconPositions.push(...Object.values(iconMap)); } const outMapPath = path.join(outputDir, "iconMap.json"); fs.writeFileSync(outMapPath, JSON.stringify(iconPositions, null, 2), "utf-8"); }