feat: 初始化画个标仓库 - AI驱动设计系统(BYOK模式) - 224源/371504图标+25海报源+130技能
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 全量审计:找出渲染后为空白/不可见的图标
|
||||
* 检查项(对每个源每个图标):
|
||||
* 1. 无几何内容(body 里没有任何可渲染元素)
|
||||
* 2. 几何完全越界(所有坐标在 24 视口外,会被裁剪成空白)
|
||||
* 3. 所有几何元素均不可见(fill=none 无 stroke / opacity=0 / visibility=hidden / display=none,
|
||||
* 考虑 <g> 祖先的 fill/stroke/opacity 继承)
|
||||
* 用法:node scripts/audit-blank.js [源id过滤,逗号分隔]
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC_DIR = path.join(process.cwd(), 'js/data/sources');
|
||||
const only = process.argv[2] ? process.argv[2].split(',').map(s => s.trim()) : null;
|
||||
|
||||
const GEOM = ['path', 'circle', 'rect', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'image', 'use', 'textPath'];
|
||||
|
||||
/** 与 js/ui/panels.js 的 STROKE_SOURCES 一致:描边风来源(构建时 stroke 被剥离只剩 path d) */
|
||||
const STROKE_SOURCES = new Set(['feather', 'lucide', 'tabler']);
|
||||
|
||||
function load(sid) {
|
||||
const txt = fs.readFileSync(path.join(SRC_DIR, sid + '.js'), 'utf8');
|
||||
global.window = global.window || {};
|
||||
eval(txt);
|
||||
return global.window['HGB_Source_' + sid];
|
||||
}
|
||||
|
||||
/** 轻量标签解析:返回元素树,含 parent 指针 */
|
||||
function parseBody(body) {
|
||||
const roots = [];
|
||||
const stack = [];
|
||||
const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g;
|
||||
let m;
|
||||
while ((m = re.exec(body))) {
|
||||
const closing = m[1] === '/';
|
||||
const tag = m[2].toLowerCase();
|
||||
const attrsStr = m[3];
|
||||
const selfClose = m[4] === '/' || tag === 'style' || tag === 'defs' || tag === 'title' || tag === 'desc';
|
||||
if (closing) { stack.pop(); continue; }
|
||||
const attrs = {};
|
||||
const am = attrsStr.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g);
|
||||
for (const a of am) attrs[a[1]] = a[2];
|
||||
const node = { tag, attrs, children: [], parent: stack.length ? stack[stack.length - 1] : null };
|
||||
if (stack.length) stack[stack.length - 1].children.push(node);
|
||||
else roots.push(node);
|
||||
if (!selfClose) stack.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** 收集节点自身的最近有效属性(含祖先继承) */
|
||||
function effAttrs(node) {
|
||||
const e = {};
|
||||
for (let n = node; n; n = n.parent) {
|
||||
if (e.fill === undefined && n.attrs.fill !== undefined) e.fill = n.attrs.fill;
|
||||
if (e.stroke === undefined && n.attrs.stroke !== undefined) e.stroke = n.attrs.stroke;
|
||||
if (e['stroke-width'] === undefined && n.attrs['stroke-width'] !== undefined) e['stroke-width'] = n.attrs['stroke-width'];
|
||||
if (e.opacity === undefined && n.attrs.opacity !== undefined) e.opacity = n.attrs.opacity;
|
||||
if (e.visibility === undefined && n.attrs.visibility !== undefined) e.visibility = n.attrs.visibility;
|
||||
if (e.display === undefined && n.attrs.display !== undefined) e.display = n.attrs.display;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
/** 单个几何元素是否可见(纯静态,无 svg 根属性模拟) */
|
||||
function nodeVisible(node) {
|
||||
const e = effAttrs(node);
|
||||
if (e.visibility === 'hidden' || e.visibility === 'collapse') return false;
|
||||
if (e.display === 'none') return false;
|
||||
if (e.opacity !== undefined && parseFloat(e.opacity) === 0) return false;
|
||||
const hasStroke = e.stroke !== undefined && e.stroke !== 'none';
|
||||
if (e.fill === undefined) return true; // 全链无 fill → svg 默认黑色填充 → 可见
|
||||
const hasFill = e.fill !== 'none' && e.fill !== 'transparent'; // url(#…) 引用也算可渲染
|
||||
return hasStroke || hasFill;
|
||||
}
|
||||
|
||||
/** 收集 body 中所有几何元素 */
|
||||
function collectGeoms(body) {
|
||||
const geoms = [];
|
||||
(function walk(ns) { for (const n of ns) { if (GEOM.includes(n.tag)) geoms.push(n); walk(n.children); } })(parseBody(body));
|
||||
return geoms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模拟:复刻 js/ui/panels.js renderBuiltinGrid 的判定
|
||||
* - preserve(emoji/brand 多色)保留原色
|
||||
* - stroke 型:<svg fill="none" stroke=灰 …>,几何靠描边或自带彩色填充显示
|
||||
* - fill 型:<svg fill=灰 stroke="none">,几何靠填充显示
|
||||
* body 预处理:fill/stroke="currentColor" → "inherit"
|
||||
*/
|
||||
function renderVisible(geoms, body, sid, type) {
|
||||
const replaced = body.replace(/fill="currentColor"/g, 'fill="inherit"').replace(/stroke="currentColor"/g, 'stroke="inherit"');
|
||||
if (type && type !== 'icon') {
|
||||
// 保留原色:按元素自身属性判定
|
||||
return geoms.some(nodeVisible);
|
||||
}
|
||||
const isStroke = STROKE_SOURCES.has(sid) || /stroke\s*=|\bstroke-linecap\s*=|\bstroke-linejoin\s*=/.test(replaced);
|
||||
const rootFill = isStroke ? 'none' : '#374151';
|
||||
const rootStroke = isStroke ? '#374151' : 'none';
|
||||
return geoms.some(g => {
|
||||
const e = effAttrs(g);
|
||||
if (e.visibility === 'hidden' || e.visibility === 'collapse') return false;
|
||||
if (e.display === 'none') return false;
|
||||
if (e.opacity !== undefined && parseFloat(e.opacity) === 0) return false;
|
||||
// currentColor→inherit 已在 replaced 中体现,但 effAttrs 用的是原始 body;这里手动映射
|
||||
const fill = e.fill === undefined ? rootFill : (e.fill === 'currentColor' ? 'inherit' : e.fill);
|
||||
const stroke = e.stroke === undefined ? rootStroke : (e.stroke === 'currentColor' ? 'inherit' : e.stroke);
|
||||
const hasStroke = stroke !== undefined && stroke !== 'none' && stroke !== 'inherit' || (stroke === 'inherit' && rootStroke !== 'none');
|
||||
const hasFill = fill !== 'none' && fill !== 'transparent' && fill !== 'inherit' || (fill === 'inherit' && rootFill !== 'none');
|
||||
return hasStroke || hasFill;
|
||||
});
|
||||
}
|
||||
|
||||
/** 提取 path d 字符串列表 */
|
||||
function pathDs(body) {
|
||||
const out = [];
|
||||
let re = /<path\b[^>]*>/g, m;
|
||||
while ((m = re.exec(body))) {
|
||||
const dm = m[0].match(/\bd="([^"]*)"/);
|
||||
if (dm) out.push(dm[1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 从 body 提取所有数值(path d 坐标 + 几何属性,排除弧段 flags) */
|
||||
function geomNumbers(body) {
|
||||
const nums = [];
|
||||
const dRe = /\bd="([^"]*)"/g;
|
||||
let dm;
|
||||
while ((dm = dRe.exec(body))) {
|
||||
// 按命令分段,跳过弧段 flags(仅 0/1,混入会干扰越界判断)
|
||||
const d = dm[1];
|
||||
const cmds = d.match(/[a-zA-Z][^a-zA-Z]*/g) || [];
|
||||
for (const c of cmds) {
|
||||
const cmd = c[0];
|
||||
const vals = (c.slice(1).match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi) || []).map(parseFloat);
|
||||
if ((cmd === 'A' || cmd === 'a') && vals.length >= 7) {
|
||||
const rx = vals.shift(), ry = vals.shift();
|
||||
nums.push(rx, ry);
|
||||
vals.shift(); // x-rotation
|
||||
vals.shift(); vals.shift(); // flags 0/1 不参与
|
||||
nums.push(...vals);
|
||||
} else {
|
||||
nums.push(...vals);
|
||||
}
|
||||
}
|
||||
}
|
||||
const attrs = /(?:cx|cy|rx|ry|x1|y1|x2|y2|width|height|x|y|r|points)="(-?[\d\s.,eE+-]+)"/g;
|
||||
let m;
|
||||
while ((m = attrs.exec(body))) {
|
||||
const vals = m[1].match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi) || [];
|
||||
for (const n of vals) nums.push(parseFloat(n));
|
||||
}
|
||||
return nums;
|
||||
}
|
||||
|
||||
function hasGeom(body) {
|
||||
return GEOM.some(t => new RegExp('<' + t + '\\b').test(body));
|
||||
}
|
||||
|
||||
function checkIcon(ic, sid) {
|
||||
const body = ic.svg || '';
|
||||
const reasons = [];
|
||||
if (!body.trim()) reasons.push('空 body');
|
||||
if (!hasGeom(body)) { reasons.push('无几何元素'); return reasons; }
|
||||
const geoms = collectGeoms(body);
|
||||
// 渲染模拟可见性:复刻 panels.js 渲染判定
|
||||
const visible = renderVisible(geoms, body, sid, ic.type);
|
||||
if (!visible) {
|
||||
const sample = geoms.slice(0, 2).map(g => {
|
||||
const e = effAttrs(g);
|
||||
return `${g.tag}{fill:${e.fill},stroke:${e.stroke},opacity:${e.opacity}}`;
|
||||
}).join(' ');
|
||||
reasons.push(`渲染后不可见 (${geoms.length}个: ${sample})`);
|
||||
}
|
||||
// 越界检查:非 wrap 归一化时,几何完全落在 24 视口外
|
||||
if (!/transform="translate\([^"]*\)\s*scale\(/.test(body)) {
|
||||
const nums = geomNumbers(body);
|
||||
if (nums.length) {
|
||||
const min = Math.min(...nums), max = Math.max(...nums);
|
||||
if (max < -1 || min > 25) reasons.push(`几何越界 [${min},${max}]`);
|
||||
}
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = fs.readdirSync(SRC_DIR).filter(f => f.endsWith('.js') && !f.startsWith('dicebear-'));
|
||||
const total = { icons: 0, bad: 0 };
|
||||
const perSource = [];
|
||||
for (const f of files) {
|
||||
const sid = f.replace(/\.js$/, '');
|
||||
if (only && !only.includes(sid)) continue;
|
||||
let data;
|
||||
try { data = load(sid); } catch (e) { perSource.push({ sid, error: '加载失败 ' + e.message }); continue; }
|
||||
if (!data || !Array.isArray(data.icons)) { perSource.push({ sid, error: '无 icons 数组' }); continue; }
|
||||
const bad = [];
|
||||
for (const ic of data.icons) {
|
||||
total.icons++;
|
||||
const reasons = checkIcon(ic, sid);
|
||||
if (reasons.length) {
|
||||
total.bad++;
|
||||
bad.push({ id: ic.id || ic.name, reasons, len: (ic.svg || '').length });
|
||||
}
|
||||
}
|
||||
if (bad.length) perSource.push({ sid, count: data.icons.length, bad, ratio: (bad.length / data.icons.length * 100).toFixed(1) + '%' });
|
||||
}
|
||||
console.log(`\n扫描 ${total.icons.toLocaleString()} 个图标,发现可疑 ${total.bad.toLocaleString()} 个`);
|
||||
perSource.filter(s => s.bad).sort((a, b) => b.bad.length - a.bad.length).forEach(s => {
|
||||
console.log(`\n■ ${s.sid}: ${s.bad.length}/${s.count}(${s.ratio})`);
|
||||
s.bad.slice(0, 40).forEach(b => console.log(` ${b.id} [${b.reasons.join('; ')}] len=${b.len}`));
|
||||
if (s.bad.length > 40) console.log(` … 等 ${s.bad.length - 40} 个`);
|
||||
});
|
||||
perSource.filter(s => s.error).forEach(s => console.log(`\n✗ ${s.sid}: ${s.error}`));
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user