127 lines
4.2 KiB
JavaScript
127 lines
4.2 KiB
JavaScript
// Generates simple PNG icons using pure Node.js (no external packages needed).
|
||
'use strict';
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const zlib = require('zlib');
|
||
|
||
const outDir = path.join(__dirname, 'public', 'icons');
|
||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||
|
||
function writePNG(filePath, size) {
|
||
const channels = 4; // RGBA
|
||
const row = size * channels;
|
||
const raw = Buffer.alloc(size * row);
|
||
|
||
// Background: dark green #145228
|
||
const bgR = 0x14, bgG = 0x52, bgB = 0x28;
|
||
// Heart color: red #e53935
|
||
const hR = 0xe5, hG = 0x39, hB = 0x35;
|
||
|
||
// Draw pixel by pixel: background + centered heart shape
|
||
// Heart expressed as two overlapping circles + a downward triangle
|
||
const cx = size / 2;
|
||
const cy = size / 2 + size * 0.05;
|
||
const r = size * 0.22;
|
||
|
||
// Two circle centers
|
||
const lx = cx - r * 0.6, ly = cy - r * 0.35;
|
||
const rx = cx + r * 0.6, ry = cy - r * 0.35;
|
||
// Triangle tip
|
||
const tipY = cy + r * 1.1;
|
||
|
||
for (let y = 0; y < size; y++) {
|
||
for (let x = 0; x < size; x++) {
|
||
const px = (y * size + x) * channels;
|
||
const nx = x + 0.5, ny = y + 0.5;
|
||
|
||
// Rounded corners (radius ~20% of size)
|
||
const cr = size * 0.2;
|
||
const inCorner = (nx < cr && ny < cr && Math.hypot(nx - cr, ny - cr) > cr) ||
|
||
(nx > size - cr && ny < cr && Math.hypot(nx - (size - cr), ny - cr) > cr) ||
|
||
(nx < cr && ny > size - cr && Math.hypot(nx - cr, ny - (size - cr)) > cr) ||
|
||
(nx > size - cr && ny > size - cr && Math.hypot(nx - (size - cr), ny - (size - cr)) > cr);
|
||
if (inCorner) { raw[px+3] = 0; continue; }
|
||
|
||
// Heart: point in left circle OR right circle OR downward triangle region
|
||
const inL = Math.hypot(nx - lx, ny - ly) <= r;
|
||
const inR = Math.hypot(nx - rx, ny - ry) <= r;
|
||
// Triangle: below the circle union and above the tip
|
||
// Approximate with two lines from the outer circle edges to the tip
|
||
const inT = ny >= Math.min(ly, ry) && nx >= lx - r + (nx - (lx - r)) * 0 &&
|
||
(ny - (cy - r * 0.35)) / (tipY - (cy - r * 0.35)) <=
|
||
1 - Math.abs(nx - cx) / (r * 1.2);
|
||
|
||
const inHeart = inL || inR || inT;
|
||
|
||
if (inHeart) {
|
||
raw[px] = hR;
|
||
raw[px+1] = hG;
|
||
raw[px+2] = hB;
|
||
raw[px+3] = 255;
|
||
} else {
|
||
raw[px] = bgR;
|
||
raw[px+1] = bgG;
|
||
raw[px+2] = bgB;
|
||
raw[px+3] = 255;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Encode to PNG
|
||
const chunks = [];
|
||
|
||
// PNG signature
|
||
chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||
|
||
function crc32(buf) {
|
||
let c = 0xffffffff;
|
||
const table = crc32.table || (crc32.table = (() => {
|
||
const t = new Uint32Array(256);
|
||
for (let i = 0; i < 256; i++) {
|
||
let v = i;
|
||
for (let k = 0; k < 8; k++) v = v & 1 ? 0xedb88320 ^ (v >>> 1) : v >>> 1;
|
||
t[i] = v;
|
||
}
|
||
return t;
|
||
})());
|
||
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||
return (c ^ 0xffffffff) >>> 0;
|
||
}
|
||
|
||
function chunk(type, data) {
|
||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
||
const tp = Buffer.from(type);
|
||
const crc = Buffer.alloc(4);
|
||
crc.writeUInt32BE(crc32(Buffer.concat([tp, data])));
|
||
return Buffer.concat([len, tp, data, crc]);
|
||
}
|
||
|
||
// IHDR
|
||
const ihdr = Buffer.alloc(13);
|
||
ihdr.writeUInt32BE(size, 0);
|
||
ihdr.writeUInt32BE(size, 4);
|
||
ihdr[8] = 8; // bit depth
|
||
ihdr[9] = 6; // RGBA
|
||
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||
chunks.push(chunk('IHDR', ihdr));
|
||
|
||
// IDAT: add filter byte (0) before each row
|
||
const filtered = Buffer.alloc(size * (row + 1));
|
||
for (let y = 0; y < size; y++) {
|
||
filtered[y * (row + 1)] = 0; // None filter
|
||
raw.copy(filtered, y * (row + 1) + 1, y * row, (y + 1) * row);
|
||
}
|
||
const compressed = zlib.deflateSync(filtered);
|
||
chunks.push(chunk('IDAT', compressed));
|
||
|
||
// IEND
|
||
chunks.push(chunk('IEND', Buffer.alloc(0)));
|
||
|
||
fs.writeFileSync(filePath, Buffer.concat(chunks));
|
||
console.log(`${path.basename(filePath)} written (${size}×${size})`);
|
||
}
|
||
|
||
writePNG(path.join(outDir, 'icon-192.png'), 192);
|
||
writePNG(path.join(outDir, 'icon-512.png'), 512);
|
||
console.log('Icons generated.');
|