Files
grimoire/scripts/generate-pwa-icons.mjs
Alejandro 34bad20ce9 Fix logo SVG centering and regenerate icons (#218)
The SVG path extended slightly beyond the viewBox (bezier control points
reached x=121.464 while viewBox ended at x=121), while the left edge was
flush at x=0. This caused uneven spacing.

Changed viewBox from "0 0 121 160" to "-0.5 0 122 160" to add equal
0.5px margins on both sides, properly centering the logo content.

https://claude.ai/code/session_019PGCQHRovoNE81udohhU3R

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-27 11:07:43 +01:00

257 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Generate PWA icons and favicons from the Grimoire logo SVG.
*
* Usage: node scripts/generate-pwa-icons.mjs
*
* Requires: npm install --save-dev sharp
*
* This script generates:
* - favicon-16x16.png
* - favicon-32x32.png
* - favicon-192x192.png (PWA icon)
* - favicon-512x512.png (PWA icon)
* - favicon-192x192-maskable.png (PWA maskable icon with padding)
* - favicon-512x512-maskable.png (PWA maskable icon with padding)
* - apple-touch-icon.png (180x180)
* - favicon.ico (multi-resolution ico file)
*/
import sharp from "sharp";
import { readFileSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = join(__dirname, "..");
const PUBLIC_DIR = join(ROOT_DIR, "public");
const LOGO_PATH = join(PUBLIC_DIR, "logo.svg");
// Read the SVG file
const svgBuffer = readFileSync(LOGO_PATH);
// Icon sizes to generate
const STANDARD_SIZES = [16, 32, 192, 512];
const MASKABLE_SIZES = [192, 512];
const APPLE_TOUCH_SIZE = 180;
// Transparent background for the icons
const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 };
/**
* Generate a standard icon (logo fills most of the space with small padding)
*/
async function generateStandardIcon(size, outputName) {
// Add 10% padding on each side for standard icons
const padding = Math.round(size * 0.1);
const logoSize = size - padding * 2;
// Calculate logo dimensions maintaining aspect ratio (122:160)
const aspectRatio = 122 / 160;
const logoHeight = logoSize;
const logoWidth = Math.round(logoHeight * aspectRatio);
// Center the logo
const left = Math.round((size - logoWidth) / 2);
const top = Math.round((size - logoHeight) / 2);
const resizedLogo = await sharp(svgBuffer)
.resize(logoWidth, logoHeight, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
await sharp({
create: {
width: size,
height: size,
channels: 4,
background: TRANSPARENT,
},
})
.composite([{ input: resizedLogo, left, top }])
.png()
.toFile(join(PUBLIC_DIR, outputName));
console.log(`Generated: ${outputName} (${size}x${size})`);
}
/**
* Generate a maskable icon (logo with more padding for safe zone)
* Maskable icons need the important content within the "safe zone" (center 80%)
*/
async function generateMaskableIcon(size, outputName) {
// Maskable icons need 20% padding (40% total safe zone margin)
const padding = Math.round(size * 0.2);
const logoSize = size - padding * 2;
// Calculate logo dimensions maintaining aspect ratio (122:160)
const aspectRatio = 122 / 160;
const logoHeight = logoSize;
const logoWidth = Math.round(logoHeight * aspectRatio);
// Center the logo
const left = Math.round((size - logoWidth) / 2);
const top = Math.round((size - logoHeight) / 2);
const resizedLogo = await sharp(svgBuffer)
.resize(logoWidth, logoHeight, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
await sharp({
create: {
width: size,
height: size,
channels: 4,
background: TRANSPARENT,
},
})
.composite([{ input: resizedLogo, left, top }])
.png()
.toFile(join(PUBLIC_DIR, outputName));
console.log(`Generated: ${outputName} (${size}x${size}, maskable)`);
}
/**
* Generate favicon.ico with multiple resolutions
*/
async function generateFavicon() {
// Generate 16x16, 32x32, and 48x48 versions for the .ico file
const sizes = [16, 32, 48];
const pngBuffers = [];
for (const size of sizes) {
const padding = Math.round(size * 0.1);
const logoSize = size - padding * 2;
const aspectRatio = 121 / 160;
const logoHeight = logoSize;
const logoWidth = Math.round(logoHeight * aspectRatio);
const left = Math.round((size - logoWidth) / 2);
const top = Math.round((size - logoHeight) / 2);
const resizedLogo = await sharp(svgBuffer)
.resize(logoWidth, logoHeight, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
const buffer = await sharp({
create: {
width: size,
height: size,
channels: 4,
background: TRANSPARENT,
},
})
.composite([{ input: resizedLogo, left, top }])
.png()
.toBuffer();
pngBuffers.push({ size, buffer });
}
// Create a simple ICO file manually
// ICO format: https://en.wikipedia.org/wiki/ICO_(file_format)
const icoBuffer = createIcoFromPngs(pngBuffers);
writeFileSync(join(PUBLIC_DIR, "favicon.ico"), icoBuffer);
console.log("Generated: favicon.ico (16x16, 32x32, 48x48)");
}
/**
* Create an ICO file from PNG buffers
*/
function createIcoFromPngs(pngBuffers) {
const numImages = pngBuffers.length;
// Calculate total size
let dataOffset = 6 + numImages * 16; // Header (6) + Directory entries (16 each)
const imageData = [];
for (const { size, buffer } of pngBuffers) {
imageData.push({
size,
buffer,
offset: dataOffset,
});
dataOffset += buffer.length;
}
// Create the ICO buffer
const totalSize = dataOffset;
const ico = Buffer.alloc(totalSize);
let offset = 0;
// ICO Header
ico.writeUInt16LE(0, offset); // Reserved
offset += 2;
ico.writeUInt16LE(1, offset); // Type (1 = ICO)
offset += 2;
ico.writeUInt16LE(numImages, offset); // Number of images
offset += 2;
// Directory entries
for (const { size, buffer, offset: dataOff } of imageData) {
ico.writeUInt8(size === 256 ? 0 : size, offset); // Width (0 means 256)
offset += 1;
ico.writeUInt8(size === 256 ? 0 : size, offset); // Height (0 means 256)
offset += 1;
ico.writeUInt8(0, offset); // Color palette
offset += 1;
ico.writeUInt8(0, offset); // Reserved
offset += 1;
ico.writeUInt16LE(1, offset); // Color planes
offset += 2;
ico.writeUInt16LE(32, offset); // Bits per pixel
offset += 2;
ico.writeUInt32LE(buffer.length, offset); // Image size
offset += 4;
ico.writeUInt32LE(dataOff, offset); // Image offset
offset += 4;
}
// Image data
for (const { buffer } of imageData) {
buffer.copy(ico, offset);
offset += buffer.length;
}
return ico;
}
async function main() {
console.log("Generating PWA icons from logo.svg...\n");
// Generate standard icons
for (const size of STANDARD_SIZES) {
const name =
size === 16 || size === 32
? `favicon-${size}x${size}.png`
: `favicon-${size}x${size}.png`;
await generateStandardIcon(size, name);
}
// Generate maskable icons
for (const size of MASKABLE_SIZES) {
await generateMaskableIcon(size, `favicon-${size}x${size}-maskable.png`);
}
// Generate Apple Touch Icon
await generateStandardIcon(APPLE_TOUCH_SIZE, "apple-touch-icon.png");
// Generate favicon.ico
await generateFavicon();
console.log("\nAll icons generated successfully!");
}
main().catch((err) => {
console.error("Error generating icons:", err);
process.exit(1);
});