// tools/gen-favicon.ts — rasterize the simple OVE logo mark into PNG favicons. // // The mark is the same geometry as public/logo.svg and the header logo block: // a light-yellow rounded square with the deep royal-purple audio-waveform // wave (the lucide audio-waveform glyph) on top. No image libraries are // available in this sandbox, so this script rasterizes the shapes directly // (supersampled distance fields) and encodes PNGs by hand (zlib via // node:zlib). // // Run: bun tools/gen-favicon.ts (writes public/favicon-16.png, // public/favicon-32.png, public/apple-touch-icon.png) import { deflateSync } from 'node:zlib' import { writeFileSync } from 'node:fs' const BG = [0xfd, 0xe0, 0x47] // light yellow — the logo square const FG = [0x24, 0x12, 0x38] // deep royal purple — the wave glyph /** Signed distance to a rounded rectangle (negative inside). */ function roundedRectDist( x: number, y: number, cx: number, cy: number, hw: number, hh: number, r: number, ): number { const dx = Math.abs(x - cx) - (hw - r) const dy = Math.abs(y - cy) - (hh - r) const ax = Math.max(dx, 0) const ay = Math.max(dy, 0) return Math.hypot(ax, ay) + Math.min(Math.max(dx, dy), 0) - r } // The lucide audio-waveform centerline decoded from its path // (M2 13 … 22 11 in a 24×24 box): a continuous wave — up to a peak, down to // a trough, up to the tall peak, down, and out. Rounded joins/caps come // free from the min-distance-to-polyline rasterizer below. const WAVE: ReadonlyArray = [ [2, 13], [4, 11], [4, 7], [6, 5], [8, 7], [8, 20], [10, 22], [12, 20], [12, 4], [14, 2], [16, 4], [16, 17], [18, 19], [20, 17], [20, 13], [22, 11], ] // 24-unit icon space → 64-unit tile: scale 2.4, centered with offset 8 // (icon spans 2..22 → 12.8..60.8, stroke ±2.4 stays inside the tile). const S = 2.4 const OFF = 8 const HALF_STROKE = 2.4 /** Min distance from (x,y) to the wave polyline, in 64-unit space. */ function distToWave(x: number, y: number): number { let min = Infinity const px = (i: number) => OFF + WAVE[i][0] * S const py = (i: number) => OFF + WAVE[i][1] * S for (let i = 0; i + 1 < WAVE.length; i++) { const ax = px(i) const ay = py(i) const bx = px(i + 1) const by = py(i + 1) const dx = bx - ax const dy = by - ay const len2 = dx * dx + dy * dy let t = len2 > 0 ? ((x - ax) * dx + (y - ay) * dy) / len2 : 0 t = Math.max(0, Math.min(1, t)) const d = Math.hypot(x - (ax + t * dx), y - (ay + t * dy)) if (d < min) min = d } return min } /** Render the mark at `size`×`size` RGBA (supersampled 4×4 per pixel). */ function render(size: number): Buffer { const k = size / 64 // 64-unit space → pixel space const ss = 4 const total = ss * ss const out = Buffer.alloc(size * (1 + size * 4)) for (let py = 0; py < size; py++) { const rowStart = py * (1 + size * 4) out[rowStart] = 0 // PNG filter: none for (let px = 0; px < size; px++) { let bgCov = 0 let fgCov = 0 for (let sy = 0; sy < ss; sy++) { for (let sx = 0; sx < ss; sx++) { const x = (px + (sx + 0.5) / ss) / k const y = (py + (sy + 0.5) / ss) / k if (roundedRectDist(x, y, 32, 32, 32, 32, 14) < 0) { bgCov++ if (distToWave(x, y) <= HALF_STROKE) fgCov++ } } } const aBg = bgCov / total const aFg = fgCov / total // wave sits inside the square: glyph over square over transparent const o = rowStart + 1 + px * 4 out[o] = Math.round(BG[0] * (aBg - aFg) + FG[0] * aFg) out[o + 1] = Math.round(BG[1] * (aBg - aFg) + FG[1] * aFg) out[o + 2] = Math.round(BG[2] * (aBg - aFg) + FG[2] * aFg) out[o + 3] = Math.round(255 * aBg) } } return out } // ---- minimal PNG encoder ---------------------------------------------------- const CRC_TABLE = (() => { const t = new Uint32Array(256) for (let n = 0; n < 256; n++) { let c = n for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 t[n] = c >>> 0 } return t })() function crc32(buf: Buffer): number { let c = 0xffffffff for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8) return (c ^ 0xffffffff) >>> 0 } function chunk(type: string, data: Buffer): Buffer { const len = Buffer.alloc(4) len.writeUInt32BE(data.length) const t = Buffer.from(type, 'ascii') const crc = Buffer.alloc(4) crc.writeUInt32BE(crc32(Buffer.concat([t, data]))) return Buffer.concat([len, t, data, crc]) } function png(size: number, rgba: Buffer): Buffer { const ihdr = Buffer.alloc(13) ihdr.writeUInt32BE(size, 0) ihdr.writeUInt32BE(size, 4) ihdr[8] = 8 // bit depth ihdr[9] = 6 // color type RGBA const idat = deflateSync(rgba, { level: 9 }) return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0)), ]) } // ---- generate --------------------------------------------------------------- const targets: [string, number][] = [ ['public/favicon-16.png', 16], ['public/favicon-32.png', 32], ['public/apple-touch-icon.png', 180], ] for (const [path, size] of targets) { writeFileSync(path, png(size, render(size))) console.log(`wrote ${path} (${size}x${size})`) }