Files
nl-um-vue-ts/scripts/generate-icons.mjs
2026-08-24 15:24:00 +08:00

328 lines
12 KiB
JavaScript
Raw Permalink 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.
/**
* 全套 Logo/图标生成流水线(无第三方依赖,纯 Node 实现 PNG 解码、缩放、编码与 ICO 封装)
*
* 输入build/logo-master.png品牌主视觉源图奶酪聊天气泡 + 年糕崽崽小猫,正方形大图)
*
* 输出(按用途划分):
* - build/icon.png 256 Electron 打包图标png 备用)
* - build/icon.ico 16/24/32/48/64/128/256 多尺寸 Windows 图标(安装包/exe/快捷方式)
* - public/icons/icon.png 256 桌面端窗口图标 / 系统通知图标(主进程运行时读取)
* - public/icons/tray.png 32 桌面端系统托盘图标
* - public/favicon.png 64 网站浏览器标签页图标
* - public/logo.png 256 稳定 URL 的通用 logo后台/外部页面可直接引用 /logo.png
* - src/assets/logo.png 256 经 Vite 打包的应用内 logo登录页、Web 通知图标)
* - ../nl-im-uniapp/src/static/logo.png 512 移动端 App 图标源图HBuilderX 生成各尺寸时用,保留直角)
*
* 说明:除 uniapp 源图保留直角外,其余图标统一加 22% 圆角遮罩(带抗锯齿),
* 在任务栏/托盘/标签页中呈现现代应用图标观感。
*
* 用法node scripts/generate-icons.mjspackage.json 已配置 npm run icons
* 更换品牌视觉时只需替换 build/logo-master.png 后重跑本脚本。
*/
import { deflateSync, inflateSync } from 'node:zlib'
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const masterFile = join(projectRoot, 'build', 'logo-master.png')
// ---------- CRC32PNG 块校验必需Node zlib 没有直接暴露,自行实现查表版) ----------
const CRC_TABLE = (() => {
const table = new Int32Array(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
}
table[n] = c
}
return table
})()
function crc32(buf) {
let c = -1
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
}
return (c ^ -1) >>> 0
}
// ---------- PNG 解码(支持 8bit RGB/RGBA、非隔行覆盖常见生成图格式 ----------
/** 解码 PNG 为 RGBA 像素数据 */
function decodePng(buf) {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
for (let i = 0; i < 8; i++) {
if (buf[i] !== signature[i]) throw new Error('不是有效的 PNG 文件')
}
let pos = 8
let width = 0
let height = 0
let bitDepth = 0
let colorType = 0
let interlace = 0
const idatParts = []
while (pos < buf.length) {
const len = buf.readUInt32BE(pos)
const type = buf.toString('ascii', pos + 4, pos + 8)
const data = buf.subarray(pos + 8, pos + 8 + len)
if (type === 'IHDR') {
width = data.readUInt32BE(0)
height = data.readUInt32BE(4)
bitDepth = data[8]
colorType = data[9]
interlace = data[12]
} else if (type === 'IDAT') {
idatParts.push(data)
} else if (type === 'IEND') {
break
}
pos += 12 + len
}
if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6) || interlace !== 0) {
throw new Error(`暂不支持的 PNG 格式bitDepth=${bitDepth} colorType=${colorType} interlace=${interlace}),请提供 8bit RGB/RGBA 非隔行 PNG`)
}
const bpp = colorType === 6 ? 4 : 3
const stride = width * bpp
const raw = inflateSync(Buffer.concat(idatParts))
const out = Buffer.alloc(width * height * 4)
// 每行首字节是滤波器类型,按 PNG 规范逐字节还原Sub/Up/Average/Paeth
let prevRow = Buffer.alloc(stride)
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)]
const row = raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride)
for (let i = 0; i < stride; i++) {
const a = i >= bpp ? row[i - bpp] : 0 // 左侧同通道
const b = prevRow[i] // 上方
const c = i >= bpp ? prevRow[i - bpp] : 0 // 左上
let v = row[i]
switch (filter) {
case 0:
break
case 1:
v = (v + a) & 0xff
break
case 2:
v = (v + b) & 0xff
break
case 3:
v = (v + ((a + b) >> 1)) & 0xff
break
case 4: {
// Paeth 预测器:取最接近 p=a+b-c 的邻居
const p = a + b - c
const pa = Math.abs(p - a)
const pb = Math.abs(p - b)
const pc = Math.abs(p - c)
const pr = pa <= pb && pa <= pc ? a : pb <= pc ? b : c
v = (v + pr) & 0xff
break
}
default:
throw new Error(`未知的 PNG 滤波器类型: ${filter}`)
}
row[i] = v
}
prevRow = row
for (let x = 0; x < width; x++) {
const si = x * bpp
const di = (y * width + x) * 4
out[di] = row[si]
out[di + 1] = row[si + 1]
out[di + 2] = row[si + 2]
out[di + 3] = bpp === 4 ? row[si + 3] : 255
}
}
return { width, height, data: out }
}
// ---------- PNG 编码8bit RGBA无隔行扫描 ----------
/** 组装一个 PNG 数据块:长度 + 类型 + 数据 + CRC */
function pngChunk(type, data) {
const typeBuf = Buffer.from(type, 'ascii')
const lenBuf = Buffer.alloc(4)
lenBuf.writeUInt32BE(data.length)
const crcBuf = Buffer.alloc(4)
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])))
return Buffer.concat([lenBuf, typeBuf, data, crcBuf])
}
/** 将 RGBA 像素数据编码为 PNG 文件 Buffer */
function encodePng(width, height, rgba) {
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(width, 0)
ihdr.writeUInt32BE(height, 4)
ihdr[8] = 8 // 位深 8bit
ihdr[9] = 6 // 颜色类型 6 = RGBA
const raw = Buffer.alloc((width * 4 + 1) * height)
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0 // 滤波器类型 0 = None
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4)
}
return Buffer.concat([
signature,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', deflateSync(raw, { level: 9 })),
pngChunk('IEND', Buffer.alloc(0)),
])
}
// ---------- 图像处理 ----------
/** 中心裁剪为正方形(源图若已是正方形则原样返回) */
function cropSquare(img) {
const { width, height, data } = img
if (width === height) return img
const size = Math.min(width, height)
const ox = Math.floor((width - size) / 2)
const oy = Math.floor((height - size) / 2)
const out = Buffer.alloc(size * size * 4)
for (let y = 0; y < size; y++) {
data.copy(out, y * size * 4, ((y + oy) * width + ox) * 4, ((y + oy) * width + ox + size) * 4)
}
return { width: size, height: size, data: out }
}
/**
* 面积平均法缩放box filter
* 缩小图标时每个目标像素取源区域的加权平均,比近邻采样平滑得多,小尺寸下依然清晰
*/
function resizeRgba(src, sw, sh, tw, th) {
const dst = Buffer.alloc(tw * th * 4)
const xr = sw / tw
const yr = sh / th
for (let ty = 0; ty < th; ty++) {
const y0 = ty * yr
const y1 = y0 + yr
for (let tx = 0; tx < tw; tx++) {
const x0 = tx * xr
const x1 = x0 + xr
let r = 0
let g = 0
let b = 0
let a = 0
let area = 0
for (let sy = Math.floor(y0); sy < y1 && sy < sh; sy++) {
const wy = Math.min(sy + 1, y1) - Math.max(sy, y0)
if (wy <= 0) continue
for (let sx = Math.floor(x0); sx < x1 && sx < sw; sx++) {
const wx = Math.min(sx + 1, x1) - Math.max(sx, x0)
if (wx <= 0) continue
const w = wx * wy
const i = (sy * sw + sx) * 4
r += src[i] * w
g += src[i + 1] * w
b += src[i + 2] * w
a += src[i + 3] * w
area += w
}
}
const di = (ty * tw + tx) * 4
dst[di] = Math.round(r / area)
dst[di + 1] = Math.round(g / area)
dst[di + 2] = Math.round(b / area)
dst[di + 3] = Math.round(a / area)
}
}
return dst
}
/** 圆角矩形有符号距离场:返回值 <0 表示在内部,用于逐像素计算覆盖率实现抗锯齿 */
function roundedRectDist(x, y, cx, cy, halfW, halfH, r) {
const dx = Math.abs(x - cx) - (halfW - r)
const dy = Math.abs(y - cy) - (halfH - 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
}
/** 应用圆角遮罩(半径 = 尺寸 * ratio边缘 1px 平滑抗锯齿),现代应用图标观感 */
function applyRoundedMask(data, size, ratio = 0.22) {
const r = size * ratio
const half = size / 2
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dist = roundedRectDist(x + 0.5, y + 0.5, half, half, half, half, r)
const coverage = dist <= -0.5 ? 1 : dist >= 0.5 ? 0 : 0.5 - dist
if (coverage < 1) {
const i = (y * size + x) * 4
data[i + 3] = Math.round(data[i + 3] * coverage)
}
}
}
}
// ---------- ICO 封装 ----------
/**
* 将多张 PNG 打包为 .ico 容器ICO 支持直接内嵌 PNG 数据Windows Vista+ 原生支持)
* 为什么自己生成electron-builder 的 png→ico 原生转换工具在部分环境会崩溃,
* 直接提供 icon.ico 可让打包器跳过转换步骤
*/
function encodeIco(pngBuffers) {
const count = pngBuffers.length
const header = Buffer.alloc(6)
header.writeUInt16LE(0, 0) // 保留位
header.writeUInt16LE(1, 2) // 类型1 = 图标
header.writeUInt16LE(count, 4)
const entries = []
const images = []
let offset = 6 + 16 * count
for (const { size, data } of pngBuffers) {
const entry = Buffer.alloc(16)
entry[0] = size >= 256 ? 0 : size // 0 表示 256
entry[1] = size >= 256 ? 0 : size
entry[2] = 0 // 调色板色数(真彩色为 0
entry[3] = 0 // 保留位
entry.writeUInt16LE(1, 4) // 颜色平面数
entry.writeUInt16LE(32, 6) // 位深
entry.writeUInt32LE(data.length, 8) // 数据字节数
entry.writeUInt32LE(offset, 12) // 数据偏移
entries.push(entry)
images.push(data)
offset += data.length
}
return Buffer.concat([header, ...entries, ...images])
}
// ---------- 主流程 ----------
if (!existsSync(masterFile)) {
console.error(`未找到源图 ${masterFile},请先放置品牌主视觉 PNG正方形大图建议 1024x1024`)
process.exit(1)
}
const master = cropSquare(decodePng(readFileSync(masterFile)))
console.log(`源图 ${masterFile} (${master.width}x${master.height})`)
/** 生成指定尺寸的 RGBA可选圆角遮罩 */
function renderSize(size, rounded) {
const data = resizeRgba(master.data, master.width, master.height, size, size)
if (rounded) {
applyRoundedMask(data, size)
}
return data
}
/** 输出单个 PNG 文件 */
function writePng(file, size, rounded) {
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, encodePng(size, size, renderSize(size, rounded)))
console.log(`已生成 ${file} (${size}x${size}${rounded ? ' 圆角' : ''})`)
}
// 桌面端 / 网站 / 通用(统一圆角)
writePng(join(projectRoot, 'build', 'icon.png'), 256, true)
writePng(join(projectRoot, 'public', 'icons', 'icon.png'), 256, true)
writePng(join(projectRoot, 'public', 'icons', 'tray.png'), 32, true)
writePng(join(projectRoot, 'public', 'favicon.png'), 64, true)
writePng(join(projectRoot, 'public', 'logo.png'), 256, true)
writePng(join(projectRoot, 'src', 'assets', 'logo.png'), 256, true)
// 移动端 App 图标源图保留直角HBuilderX/应用商店会按平台规范自行裁切圆角)
writePng(join(projectRoot, '..', 'nl-im-uniapp', 'src', 'static', 'logo.png'), 512, false)
// Windows 多尺寸 ICOexe/安装包/快捷方式共用)
const icoSizes = [16, 24, 32, 48, 64, 128, 256]
const icoFile = join(projectRoot, 'build', 'icon.ico')
writeFileSync(icoFile, encodeIco(icoSizes.map((size) => ({ size, data: encodePng(size, size, renderSize(size, true)) }))))
console.log(`已生成 ${icoFile} (${icoSizes.join('/')})`)