openvisengine/tools/gen-favicon.ts
2026-10-29 07:19:50 +00:00

174 lines
5.3 KiB
TypeScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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})`)
}