Files

1163 lines
64 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.
/**
* 构建脚本:从 CDN 下载开源图标库,解析生成内置图标库(离线素材)
* 产物:js/data/builtin-icons.js
* 用法:node scripts/build-icons.js
*
* 素材源(全部 MIT/ISC/Apache 等宽松协议,运行时离线内置):
* - Feather Icons 287 个(24 坐标,sprite)
* - Lucide Icons 1767 个(24 坐标,sprite)
* - Tabler Icons 5130 个(24 坐标,nodes JSON)
* - Bootstrap Icons 2050 个(16 坐标,构建时等比缩放到 24,sprite)
* - Remix Icon 3022 个(24 坐标,单文件,自带分类目录)
* - Ionicons 4069 个(24 坐标,outline/sharp/solid/logo 变体)
* - Phosphor Icons ~3000 个(256 坐标 → 构建时缩放到 24,regular+fill)
* - Heroicons 648 个(24 坐标,outline+solid)
* - Iconoir 1628 个(24 坐标,regular)
* - Boxicons 814 个(24 坐标,regular)
* 每个图标附 中文分类(category/categoryCn),供「分类列表可选」筛选与 AI 选用
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
/* ================= 数据源 ================= */
/* 来源清单统一维护在 js/data/icon-sources.js(构建脚本与「图标来源」页面共用) */
const SOURCES = require('../js/data/icon-sources.js');
/* ================= 网络 ================= */
function fetch(url, redirects) {
redirects = redirects || 0;
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (redirects >= 3) return reject(new Error('redirect loop ' + url));
let next = res.headers.location;
if (next.startsWith('/')) next = 'https://unpkg.com' + next;
return resolve(fetch(next, redirects + 1));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`${res.statusCode} ${url}`));
}
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(new Error('timeout ' + url)); });
});
}
/** 简易并发池(remix 单文件批量下载用) */
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
/* ================= 解析 ================= */
function parseSymbols(svgStr) {
const symbols = [];
const re = /<symbol[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/symbol>/g;
let m;
while ((m = re.exec(svgStr))) {
const rawBody = m[2];
const body = rawBody.replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim();
if (body) symbols.push({ id: m[1], body });
}
return symbols;
}
/** 数字格式化:保留 4 位小数精度并去掉尾零(避免 toFixed(2) 产生 "0.00-5.25" 非法 path 语法) */
function num(n) {
return String(Math.round(n * 1e4) / 1e4);
}
/**
* 坐标等比缩放:把 16/256 等坐标系的图标 body 缩放到 24 坐标系。
* 只缩放 path d / circle·rect·ellipse 的几何属性数字,不动 fill-opacity 等样式数值。
* 注意:必须用 num()(去尾零)而非 toFixed()——toFixed(2) 会把 `0` 变成 `0.00`,
* 在紧凑 path(`0 0 0-5.25` / `c-2.78 0-4.41`)中残留为 `0.00-5.25` 非法语法,图标渲染成碎片
*/
/** 缩放 path d:坐标数字 ×scale,但弧命令 A/a 的 large-arc-flag/sweep-flag(仅合法 0/1)保持不缩放 */
function scalePathD(d, scale) {
return d.replace(/([AaMmCcSsQqTtLlHhVvZz])([^A-Za-z]*)/g, (m, cmd, args) => {
const nums = (args.match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi) || []);
if (!nums.length) return cmd;
if (cmd === 'A' || cmd === 'a') {
const out = [];
for (let i = 0; i < nums.length; i += 7) {
const g = nums.slice(i, i + 7);
if (g.length >= 7) {
out.push(num(parseFloat(g[0]) * scale), num(parseFloat(g[1]) * scale),
num(parseFloat(g[2]) * scale), g[3], g[4],
num(parseFloat(g[5]) * scale), num(parseFloat(g[6]) * scale));
} else out.push(...g);
}
return cmd + out.join(' ');
}
return cmd + nums.map(n => num(parseFloat(n) * scale)).join(' ');
});
}
/** 缩放 transform 属性:rotate 的 cx/cy、translate 的 tx/ty、matrix 的 e/f(平移分量)按 scale 缩放,
* 角度/缩放因子不变。否则大坐标系源的 transform 参数未随 scale 缩放(如 carbon border 类
* transform="rotate(-90 4 16)" 中心点仍是原始坐标),图标旋转/平移后偏移出 24 网格 */
function scaleTransform(t, scale) {
return t
.replace(/rotate\((-?\d*\.?\d+(?:e[+-]?\d+)?)(?:\s+(-?\d*\.?\d+(?:e[+-]?\d+)?)\s+(-?\d*\.?\d+(?:e[+-]?\d+)?))?\)/g,
(m, a, cx, cy) => (cx !== undefined && cy !== undefined)
? `rotate(${num(parseFloat(a))} ${num(parseFloat(cx) * scale)} ${num(parseFloat(cy) * scale)})`
: m)
.replace(/translate\((-?\d*\.?\d+(?:e[+-]?\d+)?)(?:\s+(-?\d*\.?\d+(?:e[+-]?\d+)?))?\)/g,
(m, x, y) => (y !== undefined)
? `translate(${num(parseFloat(x) * scale)} ${num(parseFloat(y) * scale)})`
: `translate(${num(parseFloat(x) * scale)})`)
.replace(/matrix\(([^)]*)\)/g, (m, s) => {
const nums = s.trim().split(/[\s,]+/).filter(Boolean).map(Number);
if (nums.length === 6) {
nums[4] = parseFloat(nums[4]) * scale;
nums[5] = parseFloat(nums[5]) * scale;
return 'matrix(' + nums.map(n => num(n)).join(' ') + ')';
}
return m;
});
}
function scaleBody(body, scale) {
if (scale === 1) return body;
body = body.replace(/(<path[^>]*\bd=")([^"]*)(")/g, (m, pre, d) =>
pre + scalePathD(d, scale) + '"');
// 几何元素属性:全局属性级替换(不能用贪婪 [^>]* 逐元素,会只命中每元素最后一个属性),
// 长属性名优先(rx/ry/x1/y1 等),避免 x/y/r 抢先误匹配
body = body.replace(/(\b(?:cx|cy|rx|ry|x1|y1|x2|y2|width|height|x|y|r)=")(-?\d*\.?\d+(?:e[+-]?\d+)?)(")/g,
(m, pre, v) => pre + num(parseFloat(v) * scale) + '"');
// 同步缩放 stroke-width(如 Phosphor/Ionicons 大坐标系 stroke-width → 24 坐标系),
// 否则线宽相对 24 坐标系过大,图标糊成实心块(乱码)
body = body.replace(/(stroke-width=")(-?\d+(?:\.\d+)?)(")/g,
(m, pre, v) => pre + num(parseFloat(v) * scale) + '"');
// 缩放 transform 属性(rotate 中心 / translate 平移 / matrix 平移分量)
body = body.replace(/transform="([^"]*)"/g, (m, t) => 'transform="' + scaleTransform(t, scale) + '"');
return body;
}
/**
* 静态化 SMIL 动画(<animate>/<animateTransform>/<animateMotion>):
* 动效图标(line-md/eos-icons 等)把关键帧动画放在 path 上,静态渲染(预览/导出/截图)停在初始帧,
* 而初始帧常是"描边全偏移/半径 0"→ 图标空白。这里把动画目标属性的【最可见帧】写回父元素,再删除动画标签,
* 使静态渲染 = 完整可见图标。
* 注意不能一律取动画最终值:line-md 有"绘制"(values 大到小,终值=显示)与"擦除"(values 小到大,终值=隐藏)
* 两类动画,取终值会让擦除类图标空白。故按属性语义选"最可见值":
* - stroke-dashoffset/dasharray:取最小值(0=完整描边)
* - opacity/fill-opacity/stroke-opacity:取最大值(1=不透明)
* - r/width/height:取最大值(尺寸最大最可见)
* - 其余(x/y/cx/cy/stroke-width 等):取动画最后值(位置不偏移)
*/
function staticizeAnimate(body) {
if (!/<animate\b/.test(body)) return body;
const anims = [];
const re = /<animate(?:Transform|Motion)?\b[^>]*\/>/g;
let m;
while ((m = re.exec(body))) anims.push({ raw: m[0], start: m.index });
if (!anims.length) return body;
let out = body;
// 从后往前处理(删除/注入不影响前面 animate 的位置)
for (let i = anims.length - 1; i >= 0; i--) {
const raw = anims[i].raw;
const am = raw.match(/\battributeName="([^"]*)"/);
if (!am) continue;
const attr = am[1];
if (attr === 'transform') continue; // transform 动画复杂,删除即可(基础值可见)
const from = raw.match(/\bfrom="([^"]*)"/);
const to = raw.match(/\bto="([^"]*)"/);
const values = raw.match(/\bvalues="([^"]*)"/);
const nums = [];
if (values) nums.push(...values[1].split(/[;,]\s*/).filter(Boolean));
if (from && from[1]) nums.push(from[1]);
if (to && to[1]) nums.push(to[1]);
if (!nums.length) continue;
const numeric = nums.filter(n => /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/.test(n.trim()));
let val = nums[nums.length - 1];
if (numeric.length) {
const v = numeric.map(n => parseFloat(n));
if (attr === 'stroke-dashoffset' || attr === 'stroke-dasharray') val = String(Math.min.apply(null, v));
else if (/opacity/.test(attr) || attr === 'r' || attr === 'width' || attr === 'height') val = String(Math.max.apply(null, v));
}
const idx = out.indexOf(raw);
if (idx < 0) { out = out.replace(raw, ''); continue; }
// 定位父开始标签:animate 前最近的 '<'(非闭合/声明/注释)。
// 注意不能遇到 '>' 就 break——父标签闭合的 '>' 在 animate 之前,必须跳过继续往前找 '<'。
// 同时要跳过兄弟 SMIL 标签(path 内可能有多个 <animate>/<set> 相邻,它们不是父元素)
const before = out.slice(0, idx);
let open = -1;
for (let j = before.length - 1; j >= 0; j--) {
if (before[j] === '<') {
const c = before[j + 1];
if (c === '/' || c === '?' || c === '!') continue;
const tagName = before.slice(j + 1, j + 17); // animateTransform 最长
if (/^(animate|animateTransform|animateMotion|set|mpath|discard)\b/.test(tagName)) continue;
open = j; break;
}
}
if (open >= 0) {
const gt = out.indexOf('>', open);
if (gt >= 0 && gt < idx) {
const tagAttr = out.slice(open, gt);
const attrRe = new RegExp('\\b' + attr + '="[^"]*"');
if (attrRe.test(tagAttr)) {
// 已有基础值:基础值=动画起始帧(line-md 的 stroke-dashoffset="28"),
// 保留则永久停在起始帧 → 用动画最可见帧覆盖
out = out.slice(0, open) + tagAttr.replace(attrRe, attr + '="' + val + '"') + out.slice(gt);
} else {
out = out.slice(0, gt) + ' ' + attr + '="' + val + '"' + out.slice(gt);
}
}
}
out = out.replace(raw, '');
}
return out;
}
/** 是否有可渲染几何元素 */
function hasGeometry(body) {
return /<(?:path|circle|rect|ellipse|line|polyline|polygon|text|image|use|textPath)\b/.test(body);
}
/* ================= 内容 bbox 估算(修复"内容偏离声明 viewBox"的上游坏数据) ================= */
/**
* 从 path d 估算内容包围盒 [x0,y0,x1,y1]。
* 只取各命令终点轨迹(曲线控制点不参与,近似足以判断"内容是否严重偏离声明 viewBox")。
* 支持相对/绝对命令、H/V 单值、A 7 值组、C 6/S·Q 4 组、M 后的隐式 L。
*/
function pathBBox(d) {
const cmdRe = /([A-Za-z])([^A-Za-z]*)/g;
let cx = 0, cy = 0;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const p = (x, y) => {
if (x < minX) minX = x; if (y < minY) minY = y;
if (x > maxX) maxX = x; if (y > maxY) maxY = y;
};
let m;
while ((m = cmdRe.exec(d))) {
const args = m[2].match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi);
if (!args || !args.length) continue;
const rel = m[1] !== m[1].toUpperCase();
const C = m[1].toUpperCase();
let step = 2; // M/L/T(M 后隐式 L 同组处理)
if (C === 'C') step = 6;
else if (C === 'S' || C === 'Q') step = 4;
else if (C === 'A') step = 7;
else if (C === 'H' || C === 'V') step = 1;
for (let i = 0; i < args.length; i += step) {
if (step === 1) {
const v = parseFloat(args[i]);
if (C === 'H') cx = rel ? cx + v : v; else cy = rel ? cy + v : v;
p(cx, cy);
continue;
}
if (step === 7) {
if (i + 6 >= args.length) break;
const nx = parseFloat(args[i + 5]), ny = parseFloat(args[i + 6]);
cx = rel ? cx + nx : nx; cy = rel ? cy + ny : ny;
p(cx, cy);
continue;
}
const ei = i + step - 2;
if (ei + 1 >= args.length) break;
const nx = parseFloat(args[ei]), ny = parseFloat(args[ei + 1]);
cx = rel ? cx + nx : nx; cy = rel ? cy + ny : ny;
p(cx, cy);
}
}
if (minX === Infinity) return null;
return [minX, minY, maxX, maxY];
}
/** SVG 仿射矩阵 [a,b,c,d,e,f](matrix(a b c d e f),列主序)乘法:A*B(先应用 B 再应用 A) */
function mulMat(A, B) {
const [a1, b1, c1, d1, e1, f1] = A;
const [a2, b2, c2, d2, e2, f2] = B;
return [
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1
];
}
/** 点 (x,y) 经矩阵 M 变换 → [x',y'] */
function applyMat(M, x, y) {
return [M[0] * x + M[2] * y + M[4], M[1] * x + M[3] * y + M[5]];
}
/** 解析 SVG transform 字符串 → 仿射矩阵 [a,b,c,d,e,f]。
* 支持 translate/scale/matrix/rotate(skew 少见忽略)。transform="A B" 表示先 B 后 A(从右往左组合) */
function parseTransform(t) {
let M = [1, 0, 0, 1, 0, 0];
const re = /([A-Za-z]+)\(([^)]*)\)/g;
const parts = [];
let m;
while ((m = re.exec(t))) parts.push([m[1], m[2]]);
for (let i = parts.length - 1; i >= 0; i--) {
const fn = parts[i][0];
const args = (parts[i][1].match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi) || []).map(Number);
if (fn === 'translate') {
M = mulMat([1, 0, 0, 1, args[0] || 0, args[1] || 0], M);
} else if (fn === 'scale') {
const sx = args.length > 1 ? args[0] : (args[0] || 1);
const sy = args.length > 1 ? args[1] : (args[0] || 1);
M = mulMat([sx || 1, 0, 0, sy || 1, 0, 0], M);
} else if (fn === 'matrix') {
if (args.length >= 6) M = mulMat(args.slice(0, 6), M);
} else if (fn === 'rotate') {
const a = (args[0] || 0) * Math.PI / 180;
const cos = Math.cos(a), sin = Math.sin(a);
let R = [cos, sin, -sin, cos, 0, 0];
if (args.length >= 3) {
const cx = args[1], cy = args[2];
R = mulMat([1, 0, 0, 1, cx, cy], mulMat(R, [1, 0, 0, 1, -cx, -cy]));
}
M = mulMat(R, M);
}
}
return M;
}
/** body 内容 bbox:path/rect/circle/ellipse/line/polyline/polygon;忽略 mask/clipPath(裁剪结构会污染 bbox)。
* 返回 { bb:[x0,y0,x1,y1] }(null 表示无几何内容)。
* 嵌套 <g transform> 按仿射矩阵解析(translate/scale/matrix/rotate 全支持,顺序与 SVG 一致),
* 元素级 transform 同样计入——bbox 落在真实渲染位置:
* - julia/marvel-comics 等"内容包在 translate g 里"→ 偏移归位
* - kde-connect/etisalat/kelloggs/micron/popeyes 等"matrix 翻转/嵌套 scale"→ 坐标映射正确 */
function bodyBBox(body) {
const clean = body.replace(/<(?:mask|clipPath)[\s\S]*?<\/(?:mask|clipPath)>/g, '');
const numAttr = (s, name) => {
const mm = s.match(new RegExp('\\b' + name + '="(-?\\d*\\.?\\d+)"'));
return mm ? parseFloat(mm[1]) : NaN;
};
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const acc = (x, y) => {
if (isFinite(x) && isFinite(y)) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
};
// 遍历标签维护 g 嵌套矩阵栈;几何元素顶点经当前矩阵(含元素级 transform)变换后累加 bbox
const stack = [[1, 0, 0, 1, 0, 0]];
const tagRe = /<(\/?)(g|path|rect|circle|ellipse|line|polyline|polygon)\b([^>]*)/g;
let m;
while ((m = tagRe.exec(clean))) {
const closing = m[1] === '/';
const tag = m[2];
const attrs = m[3];
if (tag === 'g') {
if (closing) { if (stack.length > 1) stack.pop(); continue; }
const cur = stack[stack.length - 1];
const tm = attrs.match(/transform="([^"]*)"/);
stack.push(tm && tm[1].trim() ? mulMat(cur, parseTransform(tm[1])) : cur);
continue;
}
let M = stack[stack.length - 1];
const etm = attrs.match(/transform="([^"]*)"/);
if (etm && etm[1].trim()) M = mulMat(M, parseTransform(etm[1]));
const tr = (x, y) => { const p = applyMat(M, x, y); acc(p[0], p[1]); };
const trBB = (x0, y0, x1, y1) => { tr(x0, y0); tr(x1, y0); tr(x1, y1); tr(x0, y1); };
if (tag === 'path') {
const d = attrs.match(/\bd="([^"]*)"/);
const bb = d ? pathBBox(d[1]) : null;
if (bb) trBB(bb[0], bb[1], bb[2], bb[3]);
} else if (tag === 'rect') {
const x = numAttr(attrs, 'x'), y = numAttr(attrs, 'y');
const w = numAttr(attrs, 'width'), h = numAttr(attrs, 'height');
if (!isNaN(x) && !isNaN(y) && !isNaN(w) && !isNaN(h)) trBB(x, y, x + w, y + h);
} else if (tag === 'circle') {
const cx = numAttr(attrs, 'cx'), cy = numAttr(attrs, 'cy'), r = numAttr(attrs, 'r');
if (!isNaN(cx) && !isNaN(cy) && !isNaN(r)) trBB(cx - r, cy - r, cx + r, cy + r);
} else if (tag === 'ellipse') {
const cx = numAttr(attrs, 'cx'), cy = numAttr(attrs, 'cy');
const rx = numAttr(attrs, 'rx'), ry = numAttr(attrs, 'ry');
if (!isNaN(cx) && !isNaN(cy) && !isNaN(rx) && !isNaN(ry)) trBB(cx - rx, cy - ry, cx + rx, cy + ry);
} else if (tag === 'line') {
const x1 = numAttr(attrs, 'x1'), y1 = numAttr(attrs, 'y1');
const x2 = numAttr(attrs, 'x2'), y2 = numAttr(attrs, 'y2');
if (!isNaN(x1) && !isNaN(y1) && !isNaN(x2) && !isNaN(y2)) { tr(x1, y1); tr(x2, y2); }
} else if (tag === 'polyline' || tag === 'polygon') {
const pp = attrs.match(/\bpoints="([^"]*)"/);
if (pp) {
const nums = (pp[1].match(/-?\d*\.?\d+(?:e[+-]?\d+)?/g) || []).map(Number);
for (let i = 0; i + 1 < nums.length; i += 2) tr(nums[i], nums[i + 1]);
}
}
}
if (minX === Infinity) return { bb: null };
return { bb: [minX, minY, maxX, maxY] };
}
/** body 内 url(#id) / href="#id" 引用了未定义的 id(悬空引用)→ 渲染透明,判为坏数据 */
function hasDanglingRef(body) {
const ids = new Set();
let m;
const idRe = /\bid="([^"]+)"/g;
while ((m = idRe.exec(body))) ids.add(m[1]);
const refRe = /url\([^)]*#([^"')]+)[^)]*\)/g;
while ((m = refRe.exec(body))) {
if (!ids.has(m[1])) return true;
}
const hrefRe = /\bhref="[^"]*#([^"]+)"/g;
while ((m = hrefRe.exec(body))) {
if (!ids.has(m[1])) return true;
}
return false;
}
/* ================= 中文分类(启发式词根 → 中文分类) ================= */
/* 优先级即数组顺序:前面的先命中。词根匹配图标英文名(小写包含) */
const CATEGORY_RULES = [
{ cat: 'education', cn: '教育学习', words: ['book', 'graduation', 'school', 'study', 'learn', 'teacher', 'teach', 'pen', 'pencil', 'ruler', 'calculator', 'diploma', 'lectern', 'notebook', 'library', 'archive', 'paper', 'scroll', 'ink', 'spell', 'alphabet', 'languages', 'university', 'college', 'classroom', 'reading', 'letter', 'academic', 'grad', 'certificate', 'exam', 'lesson', 'quiz', 'baccalaureate', 'homework', 'abacus', 'chalk', 'eraser', 'backpack', 'satchel', 'tutor', 'scholar', 'satchel', 'workbook'] },
{ cat: 'food', cn: '餐饮美食', words: ['coffee', 'cup', 'tea', 'beer', 'wine', 'cocktail', 'food', 'restaurant', 'utensil', 'chef', 'pizza', 'burger', 'cake', 'cookie', 'donut', 'icecream', 'sandwich', 'bowl', 'fork', 'knife', 'mug', 'cook', 'hamburger', 'toast', 'egg', 'apple', 'banana', 'lemon', 'carrot', 'soup', 'glass', 'meal', 'fish', 'shrimp', 'lobster', 'croissant', 'oven', 'salt', 'kettle', 'fridge', 'grapes', 'cherry', 'orange', 'avocado', 'milk', 'cheese', 'pancake', 'pie', 'popsicle', 'noodle', 'sushi', 'ramen', 'steak', 'bacon', 'sausage', 'chili', 'garlic', 'onion', 'potato', 'corn', 'berry', 'mango', 'peach', 'melon', 'juice', 'soda', 'chopstick', 'dessert', 'pastry', 'snack', 'buffet', 'dining', 'bottle', 'can-', 'muffin', 'brownie', 'waffle', 'lollipop', 'candy', 'popcorn'] },
{ cat: 'tech', cn: '科技数码', words: ['chip', 'cpu', 'robot', 'code', 'terminal', 'server', 'database', 'cloud', 'wifi', 'network', 'antenna', 'smartphone', 'laptop', 'monitor', 'device', 'hard-drive', 'usb', 'keyboard', 'mouse', 'memory', 'processor', 'motherboard', 'bug', 'browser', 'git', 'command', 'binary', 'docker', 'javascript', 'github', 'barcode', 'qrcode', 'qr-', 'floppy', 'disc', 'cpu-', 'gpu', 'mainframe', 'cursor', 'data', 'coding', 'developer', 'software', 'hardware', 'cyber', 'digital', 'ai', 'artificial', 'algorithm', 'blockchain', 'mining', 'ethernet', 'bluetooth', 'bluetooth-', 'airdrop', 'airplay', 'scan', 'scanner'] },
{ cat: 'health', cn: '健康医疗', words: ['medical', 'hospital', 'stethoscope', 'pill', 'medicine', 'activity', 'pulse', 'vaccine', 'thermometer', 'bandage', 'first-aid', 'dna', 'blood', 'drip', 'clinic', 'doctor', 'syringe', 'ambulance', 'wellness', 'heart', 'pharmacy', 'emergency', 'microscope', 'brain', 'tooth', 'teeth', 'bone', 'nurse', 'patient', 'therapy', 'mental', 'fever', 'cough', 'ache', 'pain', 'heartbeat', 'anatomy', 'body', 'surgery', 'operation', 'medical-bag', 'healthcare', 'healing'] },
{ cat: 'travel', cn: '出行旅游', words: ['plane', 'flight', 'airport', 'car', 'bus', 'train', 'ship', 'boat', 'motorcycle', 'bike', 'bicycle', 'taxi', 'truck', 'travel', 'luggage', 'suitcase', 'passport', 'map', 'compass', 'location', 'pin', 'route', 'navigation', 'gps', 'gas', 'road', 'railway', 'anchor', 'caravan', 'sail', 'rocket-ship', 'trolley', 'van', 'departure', 'arrival', 'station', 'platform', 'runway', 'pilot', 'steering', 'brake', 'fuel', 'highway', 'ferry', 'cruise', 'yacht', 'surfboard', 'toll', 'parking', 'traffic', 'lane', 'street', 'driver', 'tour', 'tourism', 'vacation', 'holiday', 'ticket', 'metro', 'subway', 'tram', 'scooter', 'segway', 'helicopter', 'jet', 'rocket'] },
{ cat: 'business', cn: '商务办公', words: ['briefcase', 'chart', 'graph', 'pie-', 'analytics', 'finance', 'report', 'document', 'file', 'folder', 'office', 'building', 'bank', 'money', 'coin', 'dollar', 'yen', 'euro', 'wallet', 'credit-card', 'invoice', 'agreement', 'contract', 'meeting', 'presentation', 'board', 'strategy', 'profit', 'growth', 'stock', 'market', 'trade', 'exchange', 'barcode-scan', 'calculator', 'clipboard', 'receipt', 'percent', 'chart-bar', 'chart-line', 'chart-pie', 'deal', 'sales', 'quote', 'agenda', 'task', 'deadline', 'workflow', 'investor', 'startup', 'enterprise', 'business', 'company', 'corporation', 'employee', 'manager', 'team-leader', 'briefcase', 'trello', 'notion'] },
{ cat: 'people', cn: '人物用户', words: ['user', 'person', 'avatar', 'profile', 'man', 'woman', 'boy', 'girl', 'baby', 'people', 'group', 'friend', 'family', 'customer', 'guest', 'contact', 'account', 'fingerprint', 'id-card', 'badge', 'parent', 'child', 'handshake', 'face', 'smile', 'emotion', 'mood', 'ghost', 'alien', 'happy', 'sad', 'angry', 'cry', 'crying', 'laugh', 'smiling', 'surprised', 'scared', 'sleepy', 'team', 'staff', 'worker', 'boss', 'leader', 'singer', 'dancer', 'artist', 'writer', 'author', 'king', 'queen', 'prince', 'princess', 'knight', 'pirate', 'ninja', 'wizard', 'witch', 'angel', 'devil', 'superhero', 'soldier', 'police', 'firefighter', 'farmer', 'lawyer', 'judge', 'scientist', 'musician'] },
{ cat: 'animals', cn: '动物宠物', words: ['paw', 'cat', 'dog', 'rabbit', 'bird', 'fish', 'bee', 'butterfly', 'spider', 'turtle', 'lion', 'tiger', 'bear', 'fox', 'deer', 'whale', 'dolphin', 'horse', 'cow', 'chicken', 'penguin', 'owl', 'hoof', 'tail', 'monkey', 'frog', 'snail', 'crab', 'lobster', 'shrimp', 'squirrel', 'duck', 'dove', 'swan', 'octopus', 'shark', 'bug-', 'snake', 'ant', 'bug', 'kitten', 'puppy', 'sheep', 'goat', 'elephant', 'giraffe', 'zebra', 'koala', 'panda', 'hippo', 'crocodile', 'dragon', 'unicorn', 'phoenix', 'eagle', 'hawk', 'parrot', 'peacock', 'rooster', 'hen', 'kangaroo', 'sloth', 'raccoon', 'hedgehog', 'squid', 'jellyfish', 'seahorse', 'starfish', 'seal', 'walrus', 'leopard', 'cheetah', 'jaguar', 'lynx', 'wolf', 'coyote', 'mouse', 'rat', 'bat', 'pig', 'lamb', 'chick', 'goose', 'camel', 'gazelle', 'antelope', 'moose', 'buffalo'] },
{ cat: 'nature', cn: '自然风光', words: ['leaf', 'tree', 'plant', 'flower', 'grass', 'mountain', 'sun', 'moon', 'star', 'cloud', 'rain', 'snow', 'wind', 'lightning', 'storm', 'ocean', 'wave', 'water', 'drop', 'fire', 'flame', 'volcano', 'earth', 'globe', 'forest', 'seed', 'sprout', 'sky', 'rainbow', 'leaf-', 'flower-', 'sunrise', 'sunset', 'snowflake', 'tornado', 'moon-', 'cactus', 'mushroom', 'vine', 'bush', 'island', 'beach', 'desert', 'glacier', 'valley', 'river', 'lake', 'pond', 'stream', 'dew', 'breeze', 'mist', 'fog', 'hail', 'frost', 'tide', 'coral', 'eclipse', 'galaxy', 'comet', 'meteor', 'planet', 'orbit', 'stone', 'rock', 'hill', 'mountain', 'flowerpot', 'botany', 'photosynthesis'] },
{ cat: 'building', cn: '建筑家居', words: ['home', 'house', 'building', 'castle', 'church', 'tent', 'hotel', 'store', 'shop', 'market', 'factory', 'warehouse', 'door', 'window', 'wall', 'brick', 'ladder', 'construction', 'roof', 'key', 'lock', 'garage', 'cabin', 'tower', 'bridge', 'landmark', 'museum', 'palace', 'shelter', 'stairs', 'bed', 'sofa', 'couch', 'table', 'chair', 'lamp', 'bulb', 'light', 'vase', 'mirror', 'bath', 'shower', 'toilet', 'cottage', 'barn', 'hall', 'dome', 'kitchen', 'room', 'shelf', 'drawer', 'cabinet', 'stove', 'sink', 'faucet', 'pipe', 'chimney', 'courtyard', 'archway', 'gateway', 'balcony', 'basement', 'attic', 'floor', 'ceiling', 'pillar', 'column'] },
{ cat: 'tools', cn: '工具维修', words: ['wrench', 'screwdriver', 'hammer', 'gear', 'settings', 'tool', 'toolbox', 'saw', 'drill', 'plug', 'battery', 'power', 'engine', 'cog', 'bolt', 'nut', 'chain', 'paintbrush', 'brush', 'roller', 'pliers', 'axe', 'pickaxe', 'shovel', 'compass-', 'ruler-', 'dropper', 'bucket', 'spray', 'inbox', 'archive-box', 'level', 'measure', 'caliper', 'chisel', 'anvil', 'weld', 'torch', 'hardhat', 'helmet', 'goggle', 'glove', 'repair', 'maintenance', 'spanner', 'socket', 'switchgear', 'generator', 'turbine'] },
{ cat: 'communication', cn: '沟通交流', words: ['message', 'chat', 'mail', 'email', 'phone', 'telephone', 'call', 'sms', 'send', 'inbox', 'outbox', 'envelope', 'speech', 'bubble', 'comment', 'reply', 'notification', 'bell', 'microphone', 'speaker', 'headphone', 'radio', 'broadcast', 'satellite', 'signal', 'voicemail', 'paper-plane', 'telegram', 'whatsapp', 'wechat', 'dialpad', 'keypad', 'telegraph', 'intercom', 'fax', 'walkie', 'share', 'thread', 'post', 'mention', 'hashtag', 'at-sign', 'contact'] },
{ cat: 'media', cn: '媒体影音', words: ['music', 'note', 'guitar', 'piano', 'film', 'movie', 'video', 'play', 'camera', 'photo', 'image', 'picture', 'tv', 'television', 'game', 'controller', 'dice', 'card', 'trophy', 'medal', 'award', 'crown', 'sparkle', 'celebration', 'gift', 'balloon', 'party', 'confetti', 'ticket', 'mic', 'volume', 'vinyl', 'radio-', 'headphones', 'record', 'disc', 'drum', 'flute', 'trumpet', 'violin', 'harp', 'sax', 'concert', 'stage', 'theater', 'cinema', 'podcast', 'comic', 'sketch', 'movie-', 'camera-', 'lens', 'zoom-in', 'zoom-out', 'gallery', 'album', 'broadcast', 'streaming', 'player'] },
{ cat: 'arrows', cn: '方向箭头', words: ['arrow', 'chevron', 'move', 'up-', 'down-', 'left-', 'right-', 'back-', 'forward', 'next-', 'previous', 'turn', 'direction', 'transfer', 'shuffle', 'repeat', 'loop', 'refresh', 'rotate', 'exchange', 'swap', 'corner', 'caret', 'trending', 'upload', 'download', 'logout', 'login', 'expand', 'collapse', 'minimize', 'maximize', 'zoom', 'pan', 'drag', 'step', 'flow', 'process', 'timeline', 'trajectory', 'progress', 'jump', 'slide', 'flip', 'mirror-', 'horizon'] },
{ cat: 'time', cn: '时间日期', words: ['clock', 'time', 'hour', 'minute', 'watch', 'calendar', 'date', 'alarm', 'timer', 'hourglass', 'schedule', 'history', 'sand', 'month', 'day', 'week', 'year', 'timezone', 'stopwatch', 'hourglass-', 'clock-', 'deadline', 'countdown', 'overtime', 'agenda', 'sundial', 'daytime', 'nighttime', 'dawn', 'dusk', 'noon', 'midnight'] },
{ cat: 'money', cn: '财务金融', words: ['money', 'coin', 'dollar', 'yen', 'euro', 'pound', 'wallet', 'bank', 'cash', 'card', 'credit', 'payment', 'shopping-cart', 'bag', 'bill', 'price', 'tag', 'discount', 'sale', 'paypal', 'bitcoin', 'crypto', 'donate', 'atm', 'safe', 'gold', 'gem', 'diamond', 'pay', 'salary', 'wage', 'tax', 'fund', 'invest', 'loan', 'mortgage', 'debt', 'vault', 'treasure', 'fortune', 'finance', 'currency', 'exchange-2', 'piggy', 'expense', 'budget', 'cashier'] },
{ cat: 'symbol', cn: '符号标记', words: ['check', 'cross', 'plus', 'minus', 'circle', 'square', 'triangle', 'diamond', 'star', 'heart', 'unlock', 'eye', 'shield', 'flag', 'bookmark', 'pin', 'anchor', 'link', 'chain', 'print', 'search', 'filter', 'layers', 'grid', 'list', 'menu', 'settings', 'sliders', 'toggle', 'switch', 'info', 'help', 'question', 'warning', 'alert', 'error', 'cancel', 'x-', 'close', 'asterisk', 'hash', 'infinity', 'divide', 'multiply', 'equals', 'degree', 'omega', 'delta', 'sigma', 'symbol', 'sign', 'mark', 'stamp', 'seal', 'medallion', 'award-', 'laurel', 'wreath', 'ribbon'] },
{ cat: 'sport', cn: '运动健身', words: ['sport', 'football', 'basketball', 'baseball', 'tennis', 'golf', 'soccer', 'rugby', 'volleyball', 'swimming', 'running', 'jogging', 'gym', 'dumbbell', 'trophy', 'race', 'bike', 'ski', 'skate', 'surf', 'yoga', 'weight', 'target', 'goal', 'whistle', 'racket', 'hockey', 'boxing', 'jump', 'medal', 'first-place', 'prize', 'podium', 'pool', 'archery', 'bow', 'swim', 'climb', 'hike', 'camp', 'row', 'barbell', 'kettlebell', 'stretch', 'stadium', 'athlete', 'fitness', 'exercise', 'training', 'warmup', 'sprint', 'marathon', 'dive', 'diving', 'ride'] },
{ cat: 'others', cn: '其他', words: [] }
];
/** 英文图标名 → {cat, cn};未命中返回"其他" */
function classify(name) {
const n = String(name || '').toLowerCase();
for (const r of CATEGORY_RULES) {
if (r.words.some(w => n.includes(w))) return { cat: r.cat, cn: r.cn };
}
return { cat: 'others', cn: '其他' };
}
/* ================= Remix 源:文件树 + 并发下载 ================= */
async function fetchRemixFiles(npm) {
const tree = JSON.parse(await fetch(`https://data.jsdelivr.com/v1/packages/npm/${npm}`));
const files = [];
(function walk(list, prefix) {
for (const f of list) {
if (f.type === 'directory') walk(f.files || [], prefix + f.name + '/');
else if (/\.svg$/.test(f.name)) files.push(prefix + f.name);
}
})(tree.files || [], '');
// 只保留 icons/<类目>/<name>.svg
return files.filter(p => p.startsWith('icons/'));
}
async function downloadRemixIcons(npm, files, dirMap) {
const icons = [];
let ok = 0, fail = 0;
const results = await mapLimit(files, 16, async (file) => {
const url = `https://cdn.jsdelivr.net/npm/${npm}/${file}`;
try {
const svg = await fetch(url);
const m = svg.match(/<svg[^>]*>([\s\S]*?)<\/svg>/);
if (!m) return null;
const body = m[1].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim();
if (!body) return null;
return { file, body };
} catch (e) {
return null;
}
});
for (const r of results) {
if (!r) { fail++; continue; }
const dir = r.file.split('/')[1].toLowerCase();
const name = path.basename(r.file, '.svg');
const c = classify(name);
const dirCn = (dirMap && dirMap[dir] && dirMap[dir].cn) || '';
const dirCat = (dirMap && dirMap[dir] && dirMap[dir].cat) || '';
// 目录分类优先;但目录兜底为"其他"时,若启发式命中更具体分类则用启发式
// (如 Remix 把 graduation-cap 放在 Others,启发式可识别为教育学习)
const useDir = dirCat && dirCat !== 'others';
icons.push({
id: 'remix-' + name,
name,
svg: r.body,
category: useDir ? dirCat : c.cat,
categoryCn: useDir ? dirCn : c.cn
});
ok++;
}
return { icons, ok, fail };
}
/* ================= 通用:unpkg 目录枚举 + 单文件并发下载 ================= */
async function downloadDirFiles(src) {
const icons = [];
let ok = 0, fail = 0;
for (const dir of src.dirs) {
let j;
try {
j = JSON.parse(await fetch(`https://unpkg.com/${src.npm}/${dir}/?meta`));
} catch (e) {
console.error(` ⚠️ 目录 ${dir} 枚举失败:${e.message}`);
continue;
}
const files = (j.files || []).map(f => f.path).filter(p => /\.svg$/.test(p));
const weight = dir.split('/').pop(); // 子集名(regular/fill/outline/solid/svg…)
console.log(` ${dir}: ${files.length} 个 SVG,并发下载…`);
const results = await mapLimit(files, 16, async (p) => {
try {
// meta 的 path 带前导 /,需去掉避免双斜杠
const svg = await fetch(`https://unpkg.com/${src.npm}/${p.replace(/^\//, '')}`);
const m = svg.match(/<svg[^>]*>([\s\S]*?)<\/svg>/);
if (!m) return null;
const body = m[1].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim();
if (!body) return null;
return { file: p, body };
} catch (e) {
return null;
}
});
for (const r of results) {
if (!r) { fail++; continue; }
const raw = path.basename(r.file, '.svg');
// 同名子集(regular/fill/outline/solid)需加后缀区分,避免 id 冲突;
// 若文件名已自带同名字重后缀(如 Phosphor fill 目录的 acorn-fill.svg),先去掉再拼接,避免 -fill-fill 双重后缀
const name = raw.endsWith('-' + weight) ? raw.slice(0, raw.length - weight.length - 1) : raw;
// 跳过不符合命名规范的图标(如 Font Awesome 纯数字图标 0.svg~9.svg)
if (src.skipNameRe && new RegExp(src.skipNameRe).test(name)) { continue; }
const suffix = src.weightSuffix ? '-' + weight : '';
const c = classify(name + suffix);
let body = (src.scale && src.scale !== 1) ? scaleBody(r.body, src.scale) : r.body;
// 坐标系非 0 起点(如 antd viewBox 64 起)用 <g transform> 包装归一化到 24 坐标系
if (src.wrapOpen) body = src.wrapOpen + body + (src.wrapClose || '');
icons.push({
id: `${src.id}:${name}${suffix}`,
name: name + suffix,
svg: body,
category: c.cat,
categoryCn: c.cn
});
ok++;
}
}
return { icons, ok, fail };
}
/* ================= GitHub 仓库目录(jsDelivr gh 代理:枚举 + 并发下载) ================= */
async function downloadGhFiles(src) {
const icons = [];
let ok = 0, fail = 0;
const repo = `${src.gh}@${src.ghBranch || 'master'}`;
const tree = JSON.parse(await fetch(`https://data.jsdelivr.com/v1/packages/gh/${repo}`));
const files = [];
(function walk(list, prefix) {
for (const f of list) {
if (f.type === 'directory') walk(f.files || [], prefix + f.name + '/');
else if (/\.svg$/.test(f.name)) files.push(prefix + f.name);
}
})(tree.files || [], '');
// 只保留指定子目录
const allowed = (src.dirs || []).map(d => d.replace(/\/$/, '') + '/');
const picked = files.filter(p => allowed.some(d => p.startsWith(d)));
console.log(` ${picked.length} 个 SVG(${src.dirs.join(', ')}),并发下载…`);
const results = await mapLimit(picked, 16, async (p) => {
try {
const svg = await fetch(`https://cdn.jsdelivr.net/gh/${repo}/${p}`);
const m = svg.match(/<svg[^>]*>([\s\S]*?)<\/svg>/);
if (!m) return null;
const body = m[1].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim();
if (!body) return null;
// 提取 viewBox(供 scaleMode:'wrap' 逐图标归一化坐标各异的品牌 Logo)
let vb = null;
const tag = svg.match(/<svg[^>]*>/);
const vm = tag && tag[0].match(/viewBox=["']([^"']+)["']/);
if (vm) {
const parts = vm[1].trim().split(/[\s,]+/).map(Number);
if (parts.length === 4 && parts.every(n => !isNaN(n))) vb = parts;
}
return { file: p, body, vb };
} catch (e) {
return null;
}
});
for (const r of results) {
if (!r) { fail++; continue; }
const raw = path.basename(r.file, '.svg');
if (src.skipNameRe && new RegExp(src.skipNameRe).test(raw)) { continue; }
let body;
if (src.scaleMode === 'wrap' && r.vb) {
// 品牌 Logo 坐标各异:不碰 path 坐标,用 <g transform> 按 viewBox 归一化到 24(保留原色)
const [vx, vy, vw, vh] = r.vb;
const sc = (src.scale != null) ? src.scale : Math.min(24 / vw, 24 / vh);
const dx = num((24 - vw * sc) / 2 - vx * sc);
const dy = num((24 - vh * sc) / 2 - vy * sc);
body = `<g transform="translate(${dx} ${dy}) scale(${sc})">${r.body}</g>`;
} else {
body = (src.scale && src.scale !== 1) ? scaleBody(r.body, src.scale) : r.body;
}
const c = classify(raw);
// ghSubdirName 开启时记录子目录名(相对 dirs 根目录),供跨子目录同名图标消歧
let sub = '';
if (src.ghSubdirName) {
const root = (src.dirs || []).find(d => r.file.startsWith(d.replace(/\/$/, '') + '/'));
if (root) {
const rel = r.file.slice(root.replace(/\/$/, '').length + 1);
sub = rel.split('/')[0] || '';
}
}
icons.push({
id: `${src.id}:${raw}`,
name: raw,
svg: body,
category: c.cat,
categoryCn: c.cn,
_sub: sub
});
ok++;
}
// 跨子目录同名图标(如 objects/camera 与 interface/camera 是不同画作):同名时用子目录名作前缀消歧,
// 避免 id 冲突导致图标被覆盖。仅对开启 ghSubdirName 的源生效,其余源行为不变。
if (src.ghSubdirName) {
const cnt = {};
for (const ic of icons) cnt[ic.name] = (cnt[ic.name] || 0) + 1;
for (const ic of icons) {
if (cnt[ic.name] > 1 && ic._sub) {
ic.name = ic._sub + '-' + ic.name;
ic.id = `${src.id}:${ic.name}`;
}
delete ic._sub;
}
}
return { icons, ok, fail };
}
/* ================= Iconify 集合(@iconify-json npm 包,单文件 icons.json) ================= */
/**
* IconifyJSON 格式:{prefix, icons:{name:{body,width,height}}, aliases:{alias:{parent}}, width, height}
* scaleMode:
* body = 缩放 path 几何(单色图标,渲染时任意换色)
* wrap = <g transform> 包裹归一化(emoji/多色,含渐变/defs 不可动 path 坐标,保留原色)
* type:icon 单色 / emoji 多色保留原色 / brand 品牌 Logo
*/
async function parseIconify(src) {
const j = JSON.parse(await fetch(`https://unpkg.com/${src.npm}/icons.json`));
// 原始索引统计(用于下载完整性校验:最终内置数应 ≈ 官方 icons+aliases 总数,骤降即下载损坏/缺失)
const expected = Object.keys(j.icons || {}).length + Object.keys(j.aliases || {}).length;
// viewBox 分辨率合法性统计:图标级 width/height 声明为非法值(≤0 / 非有限数 / 超合理上限)的数量
let badViewBox = 0;
for (const ic of Object.values(j.icons || {})) {
const vw = ic.width != null ? Number(ic.width) : 0;
const vh = ic.height != null ? Number(ic.height) : 0;
if (!(isFinite(vw) && isFinite(vh)) || vw < 0 || vh < 0 || vw > 1e5 || vh > 1e5) badViewBox++;
}
// 集合默认宽高:顶层 width/height 优先;缺失时取「已声明图标级宽高的众数(最常用尺寸)」;
// 仍无则 16(Iconify 规范默认)。不能用"最大值"——部分集合(material-icon-theme)个别图标声明了
// 数千像素(如 3473x3473),用最大值会让其余未声明宽高的图标全部按错误缩放裁出视口变空白。
const topW = Number(j.width), topH = Number(j.height);
const sizeCount = {};
for (const ic of Object.values(j.icons || {})) {
if (ic.width != null || ic.height != null) {
const k = (ic.width != null ? Number(ic.width) : 0) + 'x' + (ic.height != null ? Number(ic.height) : 0);
sizeCount[k] = (sizeCount[k] || 0) + 1;
}
}
let modeW = 0, modeH = 0, modeN = 0;
for (const [k, n] of Object.entries(sizeCount)) {
if (n > modeN) {
modeN = n;
const parts = k.split('x');
modeW = Number(parts[0]); modeH = Number(parts[1]);
}
}
const w = topW || modeW || 16;
const h = topH || modeH || w;
const scale = (src.scale != null) ? src.scale : 24 / w;
const icons = [];
const bodyCache = new Map(); // name -> body(先 icons 后 aliases 递归解析)
/**
* body 归一化。iw/ih 为该图标的实际 viewBox 宽高(图标级声明优先,未声明回退集合默认)。
* - wrap 模式:逐图标用 <g transform> 包裹(多色/品牌,保留原色与 defs)
* - body 模式(单色):集合内所有图标坐标一致时用 scaleBody 缩放 path 几何;
* 但两类情况必须退回逐图标 wrap(否则空白):
* a) 图标级宽高 ≠ 集合默认宽高(fluent 的 48 尺寸、lineicons 的 64 尺寸、wi 的 1537 尺寸…)
* b) body 内存在元素级 transform(bpmn 等:坐标在原始 viewBox 内靠 transform 定位,
* scaleBody 只缩放几何属性不缩放 transform → 位移错乱全图移出视口)
* wrap 统一保留原始 body,由 <g> 的 transform 完成坐标系归一化,两者都安全。
*/
function transformBody(body, iw, ih) {
iw = iw || w; ih = ih || h;
// 内容 bbox 归一化:部分上游数据内容实际坐标与声明 viewBox 严重偏离(交集 < 50%),
// 如 material-icon-theme 的 gnuplot(内容 bbox 全在视口外)、thesvg 品牌图(坐标超出声明数倍);
// 或内容远小于视口(面积 < 15%,如 A4 页面导出的品牌图),wrap 后微小不可见。
// 这两类图标按内容实际 bbox 中心对齐 + 等比缩放拉回 24 视口;正常图标不受影响。
if (src.scale == null) {
const bbo = bodyBBox(body);
const bb = bbo.bb;
if (bb) {
const bw = bb[2] - bb[0], bh = bb[3] - bb[1];
if (bw > 0 && bh > 0) {
const ix0 = Math.max(bb[0], 0), iy0 = Math.max(bb[1], 0);
const ix1 = Math.min(bb[2], iw), iy1 = Math.min(bb[3], ih);
const inter = Math.max(0, ix1 - ix0) * Math.max(0, iy1 - iy0);
const interRatio = inter / (bw * bh); // 内容在声明视口内的比例
const areaRatio = (bw * bh) / (iw * ih); // 内容面积占声明视口比例
if (interRatio < 0.5 || areaRatio < 0.15) {
// 内容 bbox 与声明 viewBox 严重偏离或远小于视口(上游坏数据或内容平移出视口):
// 按内容实际 bbox 中心对齐 + 等比缩放拉回 24 视口。clip 等裁剪结构随 body
// 一起缩放,相对关系不变,不会破坏归一化。matrix/嵌套 scale 已由 bodyBBox 解析
const sc = Math.min(24 / bw, 24 / bh) * 0.92;
const dx = 12 - ((bb[0] + bb[2]) / 2) * sc;
const dy = 12 - ((bb[1] + bb[3]) / 2) * sc;
const needsStrokeFix = src.scaleMode !== 'wrap' &&
/stroke\s*=|fill="none"/.test(body) && !/stroke-width\s*=/.test(body);
const sw = needsStrokeFix ? ` stroke-width="${num(1.8 / sc)}"` : '';
return `<g transform="translate(${num(dx)} ${num(dy)}) scale(${num(sc)})"${sw}>${body}</g>`;
}
}
}
}
const wrapIcon = () => {
const sc = (src.scale != null) ? src.scale : Math.min(24 / iw, 24 / ih);
const dx = num((24 - iw * sc) / 2);
const dy = num((24 - ih * sc) / 2);
// body 模式单色图标走 wrap 时,根级 stroke-width(1.8)会被 <g> scale 缩小到看不见;
// body 带描边语义(stroke 属性或几何全 fill=none)且未自带 stroke-width 时,补偿使线宽复原
const needsStrokeFix = src.scaleMode !== 'wrap' &&
/stroke\s*=|fill="none"/.test(body) && !/stroke-width\s*=/.test(body);
const sw = needsStrokeFix ? ` stroke-width="${num(1.8 / sc)}"` : '';
return `<g transform="translate(${dx} ${dy}) scale(${sc})"${sw}>${body}</g>`;
};
if (src.scaleMode === 'wrap') return wrapIcon();
// body 模式:集合级归一化失效的两种情况 → 逐图标 wrap
if (iw !== w || ih !== h) return wrapIcon();
if (/<[a-z][^>]*\btransform\s*=\s*"/i.test(body)) return wrapIcon();
if (scale !== 1) return scaleBody(body, scale);
return body;
}
for (const [name, ic] of Object.entries(j.icons || {})) {
// 图标级 width/height(viewBox)优先;未声明时回退集合级
const iw = (ic.width != null) ? Number(ic.width) : w;
const ih = (ic.height != null) ? Number(ic.height) : h;
const body = transformBody(ic.body, iw, ih);
bodyCache.set(name, body);
const c = classify(name);
icons.push({ id: `${src.id}:${name}`, name, svg: body, category: c.cat, categoryCn: c.cn });
}
// 别名(alias → parent,可能链式):父为已解析的 icons 或别名
for (const [name, al] of Object.entries(j.aliases || {})) {
if (bodyCache.has(name)) continue; // 与 icons 同名不重复
let body = null, cur = al, guard = 0;
while (cur && cur.parent && guard++ < 8) {
if (bodyCache.has(cur.parent)) { body = bodyCache.get(cur.parent); break; }
const nxt = j.aliases[cur.parent];
if (!nxt) {
const direct = j.icons[cur.parent];
if (direct) {
const dw = (direct.width != null) ? Number(direct.width) : w;
const dh = (direct.height != null) ? Number(direct.height) : h;
body = transformBody(direct.body, dw, dh);
}
break;
}
cur = nxt;
}
if (!body) continue;
bodyCache.set(name, body);
const c = classify(name);
icons.push({ id: `${src.id}:${name}`, name, svg: body, category: c.cat, categoryCn: c.cn });
}
// 去掉空白/超短 body;剔除悬空引用(url(#id)/href="#id" 无对应定义 → 渲染透明)
const valid = icons.filter(x => x.svg && x.svg.length > 10 && !hasDanglingRef(x.svg));
return { icons: valid, width: w, height: h, expected, badViewBox };
}
/* ================= 批次下载完整性校验 ================= */
/**
* 每源下载+处理后执行:对比下载结果与原始官方索引,检测数据损坏 / 文件缺失 / 格式错误。
* - expected 原始索引图标数(iconify 源 = 官方 icons+aliases 总数;其他源无法获知 → null 跳过数量比对)
* - beforeN 统一后处理(SMIL 静态化 / 无几何剔除)前的图标数
* - afterN 最终内置图标数
* - badViewBox 原始数据中非法 viewBox 分辨率数量(构建已兜底,仅报告不阻断)
* - pix 内容级像素校验统计 { offView, tiny }(仅本次下载的源有值;缓存源 null 跳过)
* 判定:通过率 = afterN/expected,< 80% 视为异常;内容视口外图标占比 ≥ 5% 视为渲染空白风险。
* 输出:{ pass, msg },pass=false 时构建结束汇总报告标 ⚠️。
*/
function verifyBatch(src, expected, beforeN, afterN, badViewBox, pix) {
const rate = expected > 0 ? afterN / expected : null;
const dropped = beforeN - afterN;
const parts = [];
if (rate != null) {
if (rate < 0.8) parts.push(`⚠️ 通过率 ${(rate * 100).toFixed(1)}%(${afterN}/${expected})异常,疑似下载损坏/缺失`);
else if (dropped > 0) parts.push(`通过率 ${(rate * 100).toFixed(1)}%(${afterN}/${expected}),剔除 ${dropped} 个坏数据`);
else parts.push(`通过率 ${(rate * 100).toFixed(1)}%(${afterN}/${expected})`);
} else {
parts.push(`下载 ${afterN} 个(无官方索引可比对)`);
}
if (badViewBox > 0) parts.push(`${badViewBox} 个非法 viewBox 已兜底`);
let pass = rate == null || rate >= 0.8;
if (pix) {
if (pix.offView > 0) {
parts.push(`⚠️ 疑似空白 ${pix.offView} 个(内容不在视口内)`);
if (pix.offView / afterN >= 0.05) pass = false;
}
if (pix.tiny > 0) parts.push(`${pix.tiny} 个内容面积过小`);
}
return { pass, msg: parts.join(';') };
}
/** 内容级像素校验(构建端自动化的"像素级质量测试"):对已归一化图标计算内容 bbox,
* 检测两类渲染空白风险:
* - offView:内容 bbox 与 24 视口交集 < 内容自身的 5%(内容几乎全部在视口外 → 渲染空白)
* - tiny:内容面积 < 视口面积 1%(过小不可见)
* bbox 由 bodyBBox 仿射矩阵解析(translate/scale/matrix/rotate 全支持),落在真实渲染位置。
* 仅对本次实际下载的源执行(缓存源数据与上次一致,不重复计算)。 */
function contentPixCheck(icons) {
let offView = 0, tiny = 0;
const viewArea = 24 * 24;
for (const ic of icons) {
const bb = bodyBBox(ic.svg || '').bb;
if (!bb) continue;
const bw = bb[2] - bb[0], bh = bb[3] - bb[1];
// 零面积几何(纯直线/单点,如 feather 的 minus)跳过判定——描边宽度下仍可见,非空白
if (!(bw > 0 && bh > 0)) continue;
const ix = Math.max(0, Math.min(bb[2], 24) - Math.max(bb[0], 0));
const iy = Math.max(0, Math.min(bb[3], 24) - Math.max(bb[1], 0));
if (ix * iy / (bw * bh) < 0.05) offView++;
if (bw * bh < viewArea * 0.01) tiny++;
}
return { offView, tiny };
}
/* ================= DiceBear 头像风格(@dicebear/styles 定义 JSON) ================= */
/**
* DiceBear v10 风格定义为单文件 JSON(schema 描述部件组合),
* 每个风格一个文件:js/data/sources/dicebear-{style}.js
* 运行时以"风格 × seed × 部件选项"确定性组合出无限头像(不占图标配额)
*/
const DICEBEAR_NPM = '@dicebear/styles';
const DICEBEAR_STYLES = [
'adventurer', 'adventurer-neutral', 'avataaars', 'avataaars-neutral',
'big-ears', 'big-ears-neutral', 'big-smile', 'blobs', 'bottts', 'bottts-neutral',
'clay', 'constellation', 'critters', 'croodles', 'croodles-neutral',
'cutouts', 'disco', 'dylan', 'fun-emoji', 'glass', 'glyphs', 'icons',
'identicon', 'initial-face', 'initials', 'landscape', 'line-face', 'loops',
'lorelei', 'lorelei-neutral', 'micah', 'miniavs', 'moods', 'notionists',
'notionists-neutral', 'open-peeps', 'patchwork', 'personas', 'pixel-art',
'pixel-art-neutral', 'pixelbot', 'planets', 'rings', 'shape-grid', 'shapes',
'sprouts', 'squircles', 'stripes', 'thumbs', 'toon-head', 'triangles',
'voxel-art', 'voxel-bot', 'waves', 'weave'
];
async function parseDicebear(styleName, src) {
const txt = await fetch(`https://unpkg.com/${src.npm}@latest/${styleName}.json`);
return { styleName, definition: JSON.parse(txt) };
}
/** 配置 hash(djb2):判断某源配置是否变化,用于跳过未变更源的重新下载 */
function cfgHash(o) {
const s = JSON.stringify(o);
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
return h.toString(36);
}
/** 从现有源数据文件读回图标(缓存命中时避免重复下载) */
function readCachedIcons(id, cfg, srcDir) {
const fp = path.join(srcDir, id + '.js');
if (!fs.existsSync(fp)) return null;
const txt = fs.readFileSync(fp, 'utf8');
const m = txt.match(/@cfg:([a-z0-9]+)/);
if (!m || m[1] !== cfg) return null;
global.window = global.window || {};
eval(txt);
const data = global.window['HGB_Source_' + id];
if (!data || !Array.isArray(data.icons) || !data.icons.length) return null;
return data.icons;
}
async function main() {
const out = { meta: { generatedAt: new Date().toISOString(), total: 0 }, sources: {}, iconIndex: [] };
let grand = 0;
const verifyReport = []; // 每源批次校验报告(对比官方索引 → 检测下载损坏/缺失)
let verifyFailCount = 0; // 通过率异常(<80%)的源数
const srcDir = path.join(process.cwd(), 'js/data/sources');
fs.mkdirSync(srcDir, { recursive: true });
for (const src of SOURCES) {
let icons = null, fail = 0, usedUrl = '';
// 批次校验统计:expected=官方索引数(iconify 源);badViewBox=非法 viewBox 数
let expCount = null, badViewBox = 0;
let downloaded = false; // 本次是否实际下载(缓存命中 → false,跳过内容级像素校验)
// 构建逻辑版本:iconify 源归一化逻辑已更新(逐图标宽高/元素级 transform/众数默认/内容 bbox),
// 旧缓存是错误缩放的数据,必须全部重新下载处理;非 iconify 源处理逻辑未变,保留缓存
const cfg = (src.format === 'iconify') ? { ...src, _iconifyRev: 7 } : src;
// 缓存:配置未变且已有数据文件 → 直接读回,跳过网络下载
try {
const cached = readCachedIcons(src.id, cfgHash(cfg), srcDir);
if (cached) {
icons = cached;
usedUrl = 'https://unpkg.com/' + src.npm;
console.log(`⏩ ${src.name}: 缓存命中(${icons.length} 个图标)`);
}
} catch (e) { /* 缓存读取失败不致命,走正常下载 */ }
try {
if (icons) {
// 缓存命中:无需下载
} else if (src.format === 'remix-files') {
console.log(`获取 ${src.name} 文件树…`);
downloaded = true;
const files = await fetchRemixFiles(src.npm);
console.log(` ${files.length} 个 SVG 文件,并发下载…`);
const r = await downloadRemixIcons(src.npm, files, src.dirCategories);
icons = r.icons; fail = r.fail;
usedUrl = 'cdn.jsdelivr.net/npm/' + src.npm;
} else if (src.format === 'dir-files') {
console.log(`获取 ${src.name} 目录…`);
downloaded = true;
const r = await downloadDirFiles(src);
icons = r.icons; fail = r.fail;
usedUrl = 'unpkg.com/' + src.npm;
} else if (src.format === 'gh-files') {
console.log(`获取 ${src.name}(GitHub 仓库 ${src.gh})…`);
downloaded = true;
const r = await downloadGhFiles(src);
icons = r.icons; fail = r.fail;
usedUrl = 'cdn.jsdelivr.net/gh/' + src.gh;
} else if (src.format === 'iconify') {
console.log(`获取 ${src.name}(Iconify 集合)…`);
downloaded = true;
const r = await parseIconify(src);
icons = r.icons;
expCount = r.expected;
badViewBox = r.badViewBox;
usedUrl = 'unpkg.com/' + src.npm;
} else {
// sprite / nodes(Feather/Lucide/Tabler)
downloaded = true;
let svgStr = null;
for (const url of src.urls) {
try {
console.log(`尝试 ${src.id}: ${url}`);
svgStr = await fetch(url);
usedUrl = url;
break;
} catch (e) {
console.log(` ${e.message}`);
}
}
if (!svgStr) throw new Error('下载失败');
const symbols = parseSymbols(svgStr);
let list;
if (src.format === 'nodes') {
const json = JSON.parse(svgStr);
list = Object.entries(json).map(([name, nodes]) => {
const body = (Array.isArray(nodes) ? nodes : []).map(([type, attrs]) => {
const attrStr = Object.entries(attrs || {}).map(([k, v]) => `${k}="${v}"`).join(' ');
return `<${type} ${attrStr}/>`;
}).join('');
return { id: 'tabler:' + name, name, svg: body };
});
} else {
list = symbols.map(s => {
// 只剥离已知 sprite 前缀(lucide- / feather- / tabler- / bi-),避免误截断图标名
const name = s.id.replace(/^(lucide|feather|tabler|bi)-/, '');
let body = s.body;
if (src.scale && src.scale !== 1) body = scaleBody(body, src.scale); // 16 坐标 → 24
return { id: src.id + ':' + name, name, svg: body };
});
}
list = list.filter(x => x.svg && x.svg.length > 10);
// 打中文分类标签
for (const ic of list) {
const c = classify(ic.name);
ic.category = c.cat;
ic.categoryCn = c.cn;
}
icons = list;
}
} catch (e) {
console.error(`❌ 无法获取 ${src.id}:${e.message},跳过`);
continue;
}
if (!icons || !icons.length) {
console.error(`❌ ${src.id} 无有效图标,跳过`);
continue;
}
const type = src.type || 'icon';
// 每图标带素材类型(icon 单色 / emoji 多色 / brand 品牌),供渲染端 preserve 判断
for (const ic of icons) ic.type = type;
// 统一后处理(对缓存命中的源同样生效,无需重新下载):
// 1) 静态化 SMIL 动效(line-md/eos-icons 等:静态渲染停在动画初始帧会空白)
// 2) 剔除无任何可渲染几何的图标(thesvg-color oracle/paramount-dark、meteocons 纯 symbol 定义等)
const beforeN = icons.length;
icons = icons.filter(ic => {
const b = ic.svg || '';
if (!b.trim()) return false;
ic.svg = staticizeAnimate(b);
return hasGeometry(ic.svg);
});
if (icons.length < beforeN) console.log(` ↳ 剔除 ${beforeN - icons.length} 个无几何/动效空白图标`);
// 批次下载完整性校验:对比最终内置数 vs 官方索引数,检测下载损坏/缺失/格式错误;
// 本次实际下载的源额外执行内容级像素校验(内容是否在 24 视口内 / 面积是否过小)
const pix = downloaded ? contentPixCheck(icons) : null;
const v = verifyBatch(src, expCount, beforeN, icons.length, badViewBox, pix);
verifyReport.push({ name: src.name, type: src.type || 'icon', ...v });
console.log(` [校验] ${v.pass ? '✅' : '⚠️'} ${src.name}:${v.msg}`);
if (!v.pass) verifyFailCount++;
// 1) 每源独立数据文件(运行时按源懒加载);@cfg 为配置 hash,供下次构建缓存命中
// 注意:源 id 可能含连字符(akar-icons 等),必须用方括号绑定 window,点访问是非法语法
fs.writeFileSync(path.join(srcDir, src.id + '.js'),
`/* ${src.name}(由 scripts/build-icons.js 生成,勿手动编辑)@cfg:${cfgHash(cfg)} */\n` +
`window['HGB_Source_${src.id}'] = ` + JSON.stringify({ icons }) + ';\n', 'utf8');
// 2) 索引条目:元数据 + 前 4 个示例 svg(sources.html 展示),不内嵌全量 icons
out.sources[src.id] = {
name: src.name, nameCn: src.nameCn || src.name, license: src.license, home: src.home,
url: usedUrl, count: icons.length, type,
file: 'sources/' + src.id + '.js',
samples: icons.slice(0, 4).map(i => i.svg),
coordText: src.coordText || '', variants: src.variants || ''
};
// 3) 全量索引(id + 名称 + 中文分类,供 AI prompt / 搜索使用)
for (const ic of icons) {
out.iconIndex.push({ id: ic.id, name: ic.name, category: ic.categoryCn });
}
grand += icons.length;
console.log(`✅ ${src.name}: ${icons.length} 个图标(${type},失败 ${fail})`);
}
out.meta.total = grand;
out.meta.sources = Object.keys(out.sources).length;
// DiceBear 头像风格定义(懒加载数据文件,不占图标索引;失败按风格跳过)
let dbOk = 0;
const dbCfg = cfgHash({ npm: DICEBEAR_NPM });
for (const st of DICEBEAR_STYLES) {
const dbFile = path.join(srcDir, `dicebear-${st}.js`);
try {
let body = null;
if (fs.existsSync(dbFile)) {
const txt = fs.readFileSync(dbFile, 'utf8');
const m = txt.match(/@cfg:([a-z0-9]+)/);
if (m && m[1] === dbCfg) {
body = txt.slice(txt.indexOf('=') + 1).trim().replace(/;$/, '');
console.log(`⏩ DiceBear ${st}: 缓存命中`);
}
}
if (!body) {
const r = await parseDicebear(st, { npm: DICEBEAR_NPM });
body = JSON.stringify(r.definition);
}
fs.writeFileSync(dbFile,
`/* DiceBear 头像风格 ${st}(由 scripts/build-icons.js 生成,勿手动编辑)@cfg:${dbCfg} */\n` +
`window.HGB_Dicebear_${st.replace(/-/g, '_')} = ${body};\n`, 'utf8');
dbOk++;
} catch (e) {
console.error(`❌ DiceBear ${st} 下载失败:${e.message},跳过`);
}
}
if (dbOk) console.log(`✅ DiceBear 头像风格:${dbOk}/${DICEBEAR_STYLES.length} 个定义已内置`);
// 为控制 AI prompt 体积,索引只保留常用子集(常见主题词优先 + 字母序前 N 个)
const PROMPT_LIMIT = 1200;
const favWords = ['home', 'star', 'heart', 'book', 'graduation', 'brain', 'briefcase', 'building',
'rocket', 'sun', 'moon', 'plane', 'globe', 'flame', 'leaf', 'tree', 'crown', 'diamond', 'shield',
'check', 'arrow', 'user', 'search', 'phone', 'mail', 'camera', 'music', 'coffee', 'food',
'medal', 'trophy', 'chart', 'graph', 'money', 'bank', 'umbrella', 'award', 'sparkles', 'zap',
'pen', 'palette', 'brush', 'target', 'eye', 'light', 'lock', 'key', 'wrench', 'settings',
'plus', 'minus', 'x', 'circle', 'square', 'triangle', 'drop', 'snow', 'cloud', 'bolt', 'chip',
'robot', 'code', 'briefcase', 'dog', 'cat', 'paw', 'heart-pulse', 'message', 'gift', 'cake'];
const index = [];
const seen = new Set();
const favs = out.iconIndex.filter(x => {
const lower = x.name.toLowerCase();
return favWords.some(w => lower.startsWith(w)) && !seen.has(x.id) && (seen.add(x.id), true);
});
// favs 前缀命中可能远超上限(如 'x'/'circle' 在 19 万图标中命中海量)→ 截断保证 prompt 可控
index.push(...favs.slice(0, PROMPT_LIMIT));
const others = out.iconIndex.filter(x => !seen.has(x.id));
const rest = others.slice(0, Math.max(0, PROMPT_LIMIT - index.length));
index.push(...rest);
out.iconIndex = index;
out.meta.indexCount = index.length;
const output = 'js/data/builtin-icons.js';
const srcNames = SOURCES.map(s => s.name).join(' / ');
const js = '/* 内置素材库索引(由 scripts/build-icons.js 生成,勿手动编辑)\n' +
' * 素材:' + srcNames + ',全部离线内置;图标数据按源拆分到 js/data/sources/ 懒加载\n' +
' * 图标含中文分类 categoryCn;素材类型 type:icon 单色 / emoji 多色 / brand 品牌 */\n' +
'const HGB_BuiltinIcons = ' + JSON.stringify(out) + ';\n';
fs.writeFileSync(path.join(process.cwd(), output), js, 'utf8');
// ===== 机器可读 manifest(供外部项目/AI 直接读取,非前端必需;字段 = 配置 + 实际内置数) =====
const manifestDir = path.join(process.cwd(), 'js/data/manifest');
fs.mkdirSync(manifestDir, { recursive: true });
const iconMeta = {
kind: 'icons',
total: out.meta.total,
builtAt: out.meta.generatedAt,
guide: 'js/data/manifest/README.md',
idRule: '「源:名称」,如 lucide:graduation-cap',
dicebear: { styles: DICEBEAR_STYLES.length, files: 'js/data/sources/dicebear-{style}.js' },
sources: SOURCES
.filter(s => out.sources[s.id])
.map(s => {
const m = out.sources[s.id];
return {
id: s.id, type: s.type || 'icon', name: s.name, nameCn: s.nameCn || s.name,
desc: s.desc || '', style: s.style || '',
license: s.license || '', licenseNote: s.licenseNote || '',
home: s.home || '', github: s.github || '', npm: s.npm || '',
format: s.format || '', coordText: s.coordText || '',
variants: s.variants || '', added: s.added || '',
count: m.count, file: m.file,
workspaces: s.workspaces || ['logo', 'avatar']
};
})
};
fs.writeFileSync(path.join(manifestDir, 'icons.json'), JSON.stringify(iconMeta, null, 1), 'utf8');
console.log(` → manifest js/data/manifest/icons.json(${iconMeta.sources.length} 源元数据,供外部/AI 读取)`);
const sizeKB = (fs.statSync(path.join(process.cwd(), output)).size / 1024).toFixed(0);
const srcSizeKB = (fs.statSync(srcDir).size / 1024).toFixed(0);
console.log(`\n📦 完成:共 ${grand} 个图标(${Object.keys(out.sources).length} 源),AI 索引 ${index.length} 个`);
console.log(` → 索引 ${output} (${sizeKB}KB) + 按源拆分 js/data/sources/ (${srcSizeKB}KB)`);
// ===== 批次下载质量校验报告(对比官方索引,检测下载损坏/缺失/格式错误) =====
const passN = verifyReport.length - verifyFailCount;
console.log(`\n🔍 批次下载质量校验:${passN}/${verifyReport.length} 源通过(通过率 = 最终内置数 ÷ 官方索引数,<80% 判异常)`);
const warnList = verifyReport.filter(r => !r.pass);
if (warnList.length) {
console.warn(`⚠️ 以下 ${warnList.length} 个源通过率异常,需人工复核(检查网络/数据源版本):`);
for (const r of warnList) console.warn(` - ${r.name}:${r.msg}`);
} else {
console.log('✅ 全部源数量完整、无异常剔除、无非法 viewBox 格式问题');
}
}
main().catch(e => { console.error(e); process.exit(1); });