/** * Erzeugt Favicon und PWA-Icons aus den SVG-Quellen unter public/brand/. * * npm run icons * * Quelle ist immer das Vektor-Logo — die Rasterdateien sind reine Ableitungen * und werden bei Bedarf neu erzeugt. */ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import sharp from "sharp"; const ROOT = process.cwd(); const BRAND = join(ROOT, "public", "brand"); const ICONS = join(ROOT, "public", "icons"); const ICON_SVG = join(BRAND, "flimmer-icon.svg"); const MASKABLE_SVG = join(BRAND, "flimmer-icon-maskable.svg"); /** Rendert ein SVG in ein quadratisches PNG. */ async function png(svgPath: string, size: number): Promise { const svg = readFileSync(svgPath); return sharp(svg, { density: Math.max(72, Math.ceil((size / 128) * 96) * 4) }) .resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }) .png({ compressionLevel: 9 }) .toBuffer(); } /** * Minimaler ICO-Container mit eingebetteten PNGs. * Alle Browser ab IE11 verstehen PNG-in-ICO; das spart eine Abhängigkeit. */ function buildIco(images: { size: number; data: Buffer }[]): Buffer { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); // reserviert header.writeUInt16LE(1, 2); // Typ 1 = Icon header.writeUInt16LE(images.length, 4); const entries: Buffer[] = []; let offset = 6 + images.length * 16; for (const img of images) { const entry = Buffer.alloc(16); entry.writeUInt8(img.size >= 256 ? 0 : img.size, 0); // Breite (0 = 256) entry.writeUInt8(img.size >= 256 ? 0 : img.size, 1); // Höhe entry.writeUInt8(0, 2); // Farbpalette entry.writeUInt8(0, 3); // reserviert entry.writeUInt16LE(1, 4); // Farbebenen entry.writeUInt16LE(32, 6); // Bits pro Pixel entry.writeUInt32LE(img.data.length, 8); entry.writeUInt32LE(offset, 12); entries.push(entry); offset += img.data.length; } return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]); } const PWA_SIZES = [64, 96, 128, 192, 256, 384, 512]; const FAVICON_SIZES = [16, 32, 48]; async function main() { mkdirSync(ICONS, { recursive: true }); // PWA-Icons (transparenter Rand, „any"-Purpose) for (const size of PWA_SIZES) { const data = await png(ICON_SVG, size); writeFileSync(join(ICONS, `icon-${size}.png`), data); console.log(` icons/icon-${size}.png`); } // Maskable (randlose Fläche, Motiv in der Sicherheitszone) for (const size of [192, 512]) { const data = await png(MASKABLE_SVG, size); writeFileSync(join(ICONS, `maskable-${size}.png`), data); console.log(` icons/maskable-${size}.png`); } // Apple Touch Icon — iOS mag keine Transparenz, deshalb deckend const apple = await sharp(readFileSync(MASKABLE_SVG), { density: 576 }) .resize(180, 180) .flatten({ background: "#12101d" }) .png({ compressionLevel: 9 }) .toBuffer(); writeFileSync(join(ICONS, "apple-touch-icon.png"), apple); console.log(" icons/apple-touch-icon.png"); // Favicon: SVG (scharf in jeder Größe) + ICO (Fallback, Lesezeichenleisten) writeFileSync(join(ROOT, "public", "favicon.svg"), readFileSync(ICON_SVG)); console.log(" favicon.svg"); const icoImages = []; for (const size of FAVICON_SIZES) { icoImages.push({ size, data: await png(ICON_SVG, size) }); } writeFileSync(join(ROOT, "public", "favicon.ico"), buildIco(icoImages)); console.log(" favicon.ico"); // Open-Graph-Bild für geteilte Links const og = await sharp({ create: { width: 1200, height: 630, channels: 4, background: { r: 12, g: 10, b: 20, alpha: 1 }, }, }) .composite([ { input: await sharp(readFileSync(join(BRAND, "flimmer-logo.svg")), { density: 300 }) .resize({ width: 760 }) .png() .toBuffer(), gravity: "centre", }, ]) .png({ compressionLevel: 9 }) .toBuffer(); writeFileSync(join(ROOT, "public", "og-image.png"), og); console.log(" og-image.png"); } main().then( () => console.log("Icons erzeugt."), (err) => { console.error(err); process.exit(1); }, );