174 lines
5.3 KiB
TypeScript
Executable file
174 lines
5.3 KiB
TypeScript
Executable file
// 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<readonly [number, number]> = [
|
||
[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})`)
|
||
}
|