1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135 | // Generates PWA icons (a lit candle on midnight indigo) as PNGs with zero
// dependencies: per-pixel SDF rendering + hand-rolled PNG encoding via node:zlib.
// Adapted from sueta's icon generator.
import { deflateSync } from "node:zlib";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const outDir = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "icons");
mkdirSync(outDir, { recursive: true });
const BG = [13, 10, 20];
const WAX = [232, 219, 197];
const FLAME = [255, 179, 71];
const CORE = [255, 236, 179];
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const smooth = (d) => clamp(0.5 - d, 0, 1); // 1px antialias band around SDF zero
function roundedRect(px, py, cx, cy, hw, hh, r) {
const qx = Math.abs(px - cx) - hw + r;
const qy = Math.abs(py - cy) - hh + r;
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}
const circle = (px, py, cx, cy, r) => Math.hypot(px - cx, py - cy) - r;
// teardrop flame: circle bottom + narrowing tip via two circles blended
function flame(px, py, cx, cy, s) {
const body = circle(px, py, cx, cy + 0.22 * s, 0.34 * s);
const tip = circle(px, py, cx, cy - 0.18 * s, 0.16 * s);
// smooth-min union
const k = 0.28 * s;
const h = clamp(0.5 + (0.5 * (tip - body)) / k, 0, 1);
return tip * (1 - h) + body * h - k * h * (1 - h);
}
function mix(dst, src, c) {
dst[0] += (src[0] - dst[0]) * c;
dst[1] += (src[1] - dst[1]) * c;
dst[2] += (src[2] - dst[2]) * c;
}
function draw(S, { maskable = false, opaque = false } = {}) {
const img = Buffer.alloc(S * S * 4);
const pad = maskable ? 0.1 * S : 0;
const bgRadius = maskable || opaque ? 0 : 0.22 * S;
const u = S - 2 * pad;
const off = pad;
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
const px = x + 0.5, py = y + 0.5;
const col = [0, 0, 0];
let a = 0;
const dBg = roundedRect(px, py, S / 2, S / 2, S / 2, S / 2, bgRadius);
const cBg = maskable || opaque ? 1 : smooth(dBg);
if (cBg > 0) {
col[0] = BG[0]; col[1] = BG[1]; col[2] = BG[2];
a = cBg;
}
const cx = off + 0.5 * u;
// warm halo behind the flame
const halo = clamp(1 - Math.hypot(px - cx, py - (off + 0.33 * u)) / (0.38 * u), 0, 1);
if (a > 0 && halo > 0) mix(col, FLAME, halo * halo * 0.35);
// candle body
const dWax = roundedRect(px, py, cx, off + 0.72 * u, 0.13 * u, 0.20 * u, 0.045 * u);
const cWax = smooth(dWax);
if (cWax > 0) { mix(col, WAX, cWax); a = Math.max(a, cWax); }
// wick
const dWick = roundedRect(px, py, cx, off + 0.485 * u, 0.012 * u, 0.035 * u, 0.01 * u);
const cWick = smooth(dWick);
if (cWick > 0) mix(col, [60, 45, 35], cWick);
// flame + bright core
const dFl = flame(px, py, cx, off + 0.335 * u, 0.22 * u);
const cFl = smooth(dFl);
if (cFl > 0) { mix(col, FLAME, cFl); a = Math.max(a, cFl); }
const dCore = flame(px, py, cx, off + 0.365 * u, 0.115 * u);
const cCore = smooth(dCore);
if (cCore > 0) mix(col, CORE, cCore);
const i = (y * S + x) * 4;
img[i] = Math.round(col[0]);
img[i + 1] = Math.round(col[1]);
img[i + 2] = Math.round(col[2]);
img[i + 3] = Math.round(a * 255);
}
}
return img;
}
function png(S, rgba) {
const crcTable = [];
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;
crcTable[n] = c >>> 0;
}
const crc = (buf) => {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type, data) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type), data]);
const cc = Buffer.alloc(4);
cc.writeUInt32BE(crc(td));
return Buffer.concat([len, td, cc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(S, 0);
ihdr.writeUInt32BE(S, 4);
ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
const raw = Buffer.alloc(S * (S * 4 + 1));
for (let y = 0; y < S; y++) {
raw[y * (S * 4 + 1)] = 0; // filter: none
rgba.copy(raw, y * (S * 4 + 1) + 1, y * S * 4, (y + 1) * S * 4);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
const targets = [
["icon-192.png", 192, {}],
["icon-512.png", 512, {}],
["icon-512-maskable.png", 512, { maskable: true }],
["apple-touch-icon.png", 180, { opaque: true }],
];
for (const [name, size, opts] of targets) {
writeFileSync(join(outDir, name), png(size, draw(size, opts)));
console.log("wrote", name);
}
|