Files
WechatOnCloud/panel/web/scripts/gen-icons.mjs
T
2026-05-31 12:50:07 +08:00

121 lines
4.3 KiB
JavaScript

// 生成 PWA / Apple 图标:绿底 macOS 终端风格(白色 `>_` 提示符 + 左上小圆点)。
// 纯 Node 实现,无需外部工具(rsvg/imagemagick),保证本地 / Docker / CI 构建都产出一致图标。
// 与 public/favicon.svg 同一视觉;改图标时两边一起改(坐标基于 100×100 视图)。
import { deflateSync } from 'node:zlib';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const TOP = [0x13, 0xd8, 0x73]; // 顶部亮绿 #13D873
const BOT = [0x05, 0xa8, 0x52]; // 底部深绿 #05A852
const WHITE = [255, 255, 255];
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
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) {
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, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crc]);
}
// 点到线段距离(用于画带圆角端点的笔画)
function distToSeg(px, py, ax, ay, bx, by) {
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy || 1;
let t = ((px - ax) * dx + (py - ay) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const cx = ax + t * dx;
const cy = ay + t * dy;
return Math.hypot(px - cx, py - cy);
}
const clamp01 = (v) => Math.max(0, Math.min(1, v));
function makePng(size) {
const s = size / 100; // 视图 100 → 像素
const radius = 23 * s; // 圆角,与 favicon 一致
const stroke = (7.5 / 2) * s; // 笔画半宽
// 提示符坐标(基于 100 视图)
const chevron = [
[28, 24],
[42, 38],
[28, 52],
].map(([x, y]) => [x * s, y * s]);
const underline = [
[50, 54],
[66, 54],
].map(([x, y]) => [x * s, y * s]);
const inRounded = (x, y) => {
const r = radius;
const cx = x < r ? r : x > size - r ? size - r : x;
const cy = y < r ? r : y > size - r ? size - r : y;
if (cx === x && cy === y) return 1;
const d = Math.hypot(x - cx, y - cy);
return clamp01(r - d + 0.5); // 边缘 1px 抗锯齿
};
const rowLen = size * 4 + 1;
const raw = Buffer.alloc(rowLen * size);
for (let y = 0; y < size; y++) {
raw[y * rowLen] = 0; // filter type 0
const gy = y + 0.5;
const mix = gy / size; // 竖直渐变系数
const bg = [
Math.round(TOP[0] + (BOT[0] - TOP[0]) * mix),
Math.round(TOP[1] + (BOT[1] - TOP[1]) * mix),
Math.round(TOP[2] + (BOT[2] - TOP[2]) * mix),
];
for (let x = 0; x < size; x++) {
const gx = x + 0.5;
// 白色提示符覆盖度(取各形状最大值)
let cov = 0;
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[0][0], chevron[0][1], chevron[1][0], chevron[1][1]) + 0.5));
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[1][0], chevron[1][1], chevron[2][0], chevron[2][1]) + 0.5));
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, underline[0][0], underline[0][1], underline[1][0], underline[1][1]) + 0.5));
const o = y * rowLen + 1 + x * 4;
raw[o] = Math.round(bg[0] + (WHITE[0] - bg[0]) * cov);
raw[o + 1] = Math.round(bg[1] + (WHITE[1] - bg[1]) * cov);
raw[o + 2] = Math.round(bg[2] + (WHITE[2] - bg[2]) * cov);
raw[o + 3] = Math.round(255 * inRounded(gx, gy));
}
}
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 sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
}
mkdirSync(OUT, { recursive: true });
for (const [name, size] of [
['icon-192.png', 192],
['icon-512.png', 512],
['icon-180.png', 180],
]) {
writeFileSync(join(OUT, name), makePng(size));
console.log('generated', name);
}