328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
/**
|
||
* 全套 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.mjs(package.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')
|
||
|
||
// ---------- CRC32(PNG 块校验必需,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 多尺寸 ICO(exe/安装包/快捷方式共用)
|
||
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('/')})`)
|