/** * Generate every raster brand asset from the vector marks in public/assets/brand/ * (cropped from reference/SVG/Artboard *.svg — the official LogShip artwork): * * PWA public/icon-{size}.png (blue tile, white mark), icon-maskable-* (mark inside the * safe zone), apple-touch-icon.png, favicon-16/32 (blue mark, transparent), icon.png * Android mipmap-/ic_launcher.png (rounded blue tile), ic_launcher_round.png (blue disc), * ic_launcher_foreground.png (white mark on transparent, adaptive-icon safe zone), * drawable-/splash.png (full logo centred on white, existing sizes kept) * * Run: node scripts/generate-icons.js (also runs in `npm run build`) */ import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.join(__dirname, '..') const publicDir = path.join(root, 'public') const brandDir = path.join(publicDir, 'assets', 'brand') const resDir = path.join(root, 'android', 'app', 'src', 'main', 'res') const BRAND_BLUE = '#1E5BFF' const MARK_WHITE = path.join(brandDir, 'logship-mark-white.svg') const MARK_BLUE = path.join(brandDir, 'logship-mark.svg') const LOGO = path.join(brandDir, 'logship-logo.svg') const PWA_SIZES = [72, 96, 128, 144, 152, 192, 384, 512] const MASKABLE_SIZES = [192, 512] // Android launcher: legacy icon px per density, adaptive foreground = 108dp canvas const DENSITY = { mdpi: 1, hdpi: 1.5, xhdpi: 2, xxhdpi: 3, xxxhdpi: 4 } async function main() { let sharp try { sharp = (await import('sharp')).default } catch { console.warn('⚠️ sharp is not installed — brand assets were not regenerated (existing PNGs stay in place).') return } for (const f of [MARK_WHITE, MARK_BLUE, LOGO]) { if (!fs.existsSync(f)) throw new Error('Missing brand source ' + f) } // Render an SVG so that it fits inside box×box (keeps the mark's 354×375 aspect). const renderInside = async (svgPath, box) => sharp(svgPath, { density: 600 }).resize(box, box, { fit: 'inside' }).png().toBuffer() const roundedRect = (size, radius, fill) => Buffer.from(``) const circle = (size, fill) => Buffer.from(``) // Solid tile with the white mark centred; `markRatio` = mark height / tile size. const tile = async (size, { markRatio = 0.6, radius = 0, shape = 'square' } = {}) => { const mark = await renderInside(MARK_WHITE, Math.round(size * markRatio)) const bg = shape === 'circle' ? circle(size, BRAND_BLUE) : roundedRect(size, radius, BRAND_BLUE) return sharp(bg).png().composite([{ input: mark, gravity: 'centre' }]).png().toBuffer() } const write = async (buf, file) => { fs.mkdirSync(path.dirname(file), { recursive: true }) await sharp(buf).png().toFile(file) console.log('✅', path.relative(root, file)) } console.log('📱 PWA icons') for (const size of PWA_SIZES) await write(await tile(size, { markRatio: 0.6 }), path.join(publicDir, `icon-${size}x${size}.png`)) for (const size of MASKABLE_SIZES) await write(await tile(size, { markRatio: 0.46 }), path.join(publicDir, `icon-maskable-${size}x${size}.png`)) await write(await tile(512, { markRatio: 0.6 }), path.join(publicDir, 'icon.png')) await write(await tile(180, { markRatio: 0.6 }), path.join(publicDir, 'apple-touch-icon.png')) // Favicons: the blue mark itself on a transparent background (reads better in a browser tab) for (const size of [16, 32]) { const mark = await renderInside(MARK_BLUE, size) const canvas = sharp({ create: { width: size, height: size, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }) await write(await canvas.composite([{ input: mark, gravity: 'centre' }]).png().toBuffer(), path.join(publicDir, `favicon-${size}x${size}.png`)) } if (!fs.existsSync(resDir)) { console.log('ℹ️ no android/ project — skipping launcher icons'); return } console.log('🤖 Android launcher icons') for (const [density, scale] of Object.entries(DENSITY)) { const dir = path.join(resDir, `mipmap-${density}`) const legacy = Math.round(48 * scale) const fg = Math.round(108 * scale) await write(await tile(legacy, { markRatio: 0.58, radius: Math.round(legacy * 0.18) }), path.join(dir, 'ic_launcher.png')) await write(await tile(legacy, { markRatio: 0.54, shape: 'circle' }), path.join(dir, 'ic_launcher_round.png')) // Adaptive foreground: transparent 108dp canvas, artwork inside the central 66dp safe zone const mark = await renderInside(MARK_WHITE, Math.round(fg * 0.44)) const canvas = sharp({ create: { width: fg, height: fg, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }) await write(await canvas.composite([{ input: mark, gravity: 'centre' }]).png().toBuffer(), path.join(dir, 'ic_launcher_foreground.png')) } // Adaptive-icon background colour const bgXml = path.join(resDir, 'values', 'ic_launcher_background.xml') fs.writeFileSync(bgXml, `\n\n ${BRAND_BLUE}\n\n`) console.log('✅', path.relative(root, bgXml)) console.log('🖼 Android splash screens') for (const entry of fs.readdirSync(resDir)) { if (!/^drawable(-|$)/.test(entry)) continue const file = path.join(resDir, entry, 'splash.png') if (!fs.existsSync(file)) continue const { width, height } = await sharp(file).metadata() const logoWidth = Math.round(Math.min(width, height) * 0.5) const logo = await sharp(LOGO, { density: 600 }).resize(logoWidth, Math.round(logoWidth * 0.6), { fit: 'inside' }).png().toBuffer() const canvas = sharp({ create: { width, height, channels: 4, background: '#ffffff' } }) await write(await canvas.composite([{ input: logo, gravity: 'centre' }]).png().toBuffer(), file) } } main().catch((e) => { console.error('❌ generate-icons failed:', e.message); process.exit(1) })