#!/usr/bin/env node // Requires sharp. On macOS, iconutil also exports the ICNS from the iconset. const fs = require("node:fs/promises"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const sharp = require("sharp"); const root = path.resolve(__dirname, ".."); const assets = path.join(root, "docs/assets"); const kit = path.join(assets, "icons"); const web = path.join(root, "panel/public"); const panelAssets = path.join(root, "panel/src/assets"); const sizes = [16, 20, 24, 32, 40, 48, 64, 96, 128, 180, 192, 256, 512, 1024]; function svg(defs, content, viewBox = "0 0 1024 1024", width = 1024, height = width) { return `\n${defs}\n${content}\n\n`; } function place(art, width = 896, offsetY = 0) { const scale = width / 1040; return `${art}`; } async function render(source, size, destination, background) { let image = sharp(Buffer.from(source), { density: 192 }).resize(size, size); if (background) image = image.flatten({ background }); const png = await image.png().toBuffer(); await fs.writeFile(destination, png); return png; } async function writeIco(pngs, destination) { const icoSizes = [16, 24, 32, 48, 64, 128, 256]; const directory = Buffer.alloc(6 + 16 * icoSizes.length); directory.writeUInt16LE(1, 2); directory.writeUInt16LE(icoSizes.length, 4); let offset = directory.length; icoSizes.forEach((size, index) => { const data = pngs.get(size); const entry = 6 + index * 16; directory[entry] = directory[entry + 1] = size === 256 ? 0 : size; directory.writeUInt16LE(1, entry + 4); directory.writeUInt16LE(32, entry + 6); directory.writeUInt32LE(data.length, entry + 8); directory.writeUInt32LE(offset, entry + 12); offset += data.length; }); await fs.writeFile(destination, Buffer.concat([directory, ...icoSizes.map((size) => pngs.get(size))])); } async function main() { await Promise.all([kit, `${kit}/png`, `${kit}/felis.iconset`, `${web}/icons`, panelAssets].map((dir) => fs.mkdir(dir, { recursive: true }))); const source = await fs.readFile(`${assets}/felis-logo.svg`, "utf8"); const defs = source.match(/[\s\S]*?<\/defs>/)[0]; const art = source.match(/[\s\S]*<\/g>/)[0]; const icon = svg(defs, place(art)); const tile = ''; const app = svg(defs, tile + place(art)); const dark = svg(defs, tile.replace("#e9f1e3", "#253d31") + place(art)); // The entire cat fits inside the central 80% diameter safe circle. const maskable = svg(defs, '' + place(art, 680)); const reference = svg(defs, '' + art, "0 0 1254 1254", 1254); // White unions the original filled shapes; black knocks out facial features. const silhouette = [...art.matchAll(/]*\sd="([^"]+)"[^>]*\/>/g)] .map((match) => ``).join(""); const cutouts = [...art.matchAll(/<(?:ellipse|path)\b[^>]*fill="url\(#eye\)"[^>]*\/>/g)] .map((match) => match[0].replace('fill="url(#eye)"', 'fill="#000"')).join(""); const monoDefs = `${silhouette}${cutouts}`; const monoArt = ''; const mono = svg(monoDefs, monoArt, "110 445 1040 640", 1040, 640); const mask = svg(monoDefs, place(monoArt)); // A slight optical offset aligns the wide cat face with the browser tab label. const favicon = svg(defs, place(art, 896, 96)); const faviconPngs = new Map(); for (const size of [16, 24, 32, 48, 64, 128, 256]) { const destination = size === 16 || size === 32 ? `${web}/favicon-${size}x${size}.png` : undefined; const png = await sharp(Buffer.from(favicon), { density: 192 }).resize(size, size).png().toBuffer(); faviconPngs.set(size, png); if (destination) await fs.writeFile(destination, png); } await fs.writeFile(`${web}/favicon.svg`, favicon); await writeIco(faviconPngs, `${web}/favicon.ico`); await fs.writeFile(`${web}/safari-pinned-tab.svg`, svg(monoDefs, place(monoArt, 896, 96))); if (process.argv.includes("--favicons-only")) return; await fs.writeFile(`${panelAssets}/felis-logo.svg`, app.replace('fill="#e9f1e3"', 'fill="#fff"')); for (const [name, value] of Object.entries({ "felis-icon.svg": icon, "felis-app.svg": app, "felis-app-dark.svg": dark, "felis-maskable.svg": maskable, "felis-monochrome.svg": mono, "felis-mask.svg": mask, "felis-reference.svg": reference, })) await fs.writeFile(`${kit}/${name}`, value); const pngs = new Map(); for (const size of sizes) pngs.set(size, await render(icon, size, `${kit}/png/icon-${size}.png`)); await render(app, 1024, `${kit}/felis-app.png`); await render(dark, 1024, `${kit}/felis-app-dark.png`); await render(maskable, 512, `${kit}/felis-maskable.png`); await render(reference, 1254, `${kit}/felis-reference.png`); await sharp(Buffer.from(source), { density: 192 }).resize(540, 341, { fit: "contain", background: "#00000000" }).png().toFile(`${assets}/felis-logo.png`); await writeIco(pngs, `${kit}/felis.ico`); for (const size of [16, 32, 128, 256, 512]) { await render(app, size, `${kit}/felis.iconset/icon_${size}x${size}.png`); await render(app, size * 2, `${kit}/felis.iconset/icon_${size}x${size}@2x.png`); } if (process.platform === "darwin") execFileSync("iconutil", ["-c", "icns", `${kit}/felis.iconset`, "-o", `${kit}/felis.icns`]); await render(app, 180, `${web}/apple-touch-icon.png`, "#e9f1e3"); for (const size of [192, 512]) { await render(app, size, `${web}/icons/icon-${size}.png`); await render(maskable, size, `${web}/icons/icon-maskable-${size}.png`, "#e9f1e3"); } const background = Buffer.from(` Felis / Icon Kit SVG paths · transparent PNG · ICO · ICNS · web icons VECTOR TRACE ON DARK APP / LIGHT APP / DARK MASKABLE MONOCHROME 16 / 24 / 32 / 48 / 64 / 128 px `); const large = await sharp(Buffer.from(source), { density: 192 }).resize(644, 396).png().toBuffer(); const onDark = await sharp(Buffer.from(source), { density: 192 }).resize(328, 202).png().toBuffer(); const previews = await Promise.all([app, dark, maskable, mask].map((value) => sharp(Buffer.from(value), { density: 192 }).resize(210, 210).png().toBuffer())); const layers = [{ input: large, left: 76, top: 184 }, { input: onDark, left: 800, top: 277 }, ...previews.map((input, index) => ({ input, left: 48 + index * 246, top: 682 }))]; let left = 420; for (const size of [16, 24, 32, 48, 64, 128]) { layers.push({ input: pngs.get(size), left, top: 978 - size }); left += size + 32; } await sharp(background).composite(layers).png().toFile(`${kit}/preview.png`); console.log(`Generated Felis icons in ${kit} and ${web}`); } main().catch((error) => { console.error(error); process.exitCode = 1; });