/** * 全量审计:找出渲染后为空白/不可见的图标 * 检查项(对每个源每个图标): * 1. 无几何内容(body 里没有任何可渲染元素) * 2. 几何完全越界(所有坐标在 24 视口外,会被裁剪成空白) * 3. 所有几何元素均不可见(fill=none 无 stroke / opacity=0 / visibility=hidden / display=none, * 考虑 祖先的 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 型:,几何靠描边或自带彩色填充显示 * - fill 型:,几何靠填充显示 * 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 = /]*>/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();