| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165 |
- 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<string>) {
- 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<string, IconMeta[]> {
- const groups: Record<string, IconMeta[]> = {};
- 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<string, IconPosition> }> {
- 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<string, IconPosition> = {};
- 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<void> {
- 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");
- }
|