#!/usr/bin/env node /** * 图标源固化:把第三方图标库的 SVG 取下来,归一化成仓库内的单一 JSON 真源。 * * 为什么要有这一步: * 1. 图标来自 TDesign 与 Element Plus(均 MIT),但它们的 SVG 文件有真实缺陷 —— * TDesign 全部 2356 个文件把 `viewBox` 写成 `view-box`(无效属性), * 照抄会让图标在部分浏览器里按错误尺寸缩放。必须归一化后再进仓库。 * 2. 构建链不能联网:CI 与 `npm run build:icons` 都必须离线可复现, * 所以「下载」只发生在这里,产物 sources.json 入库,之后全链路离线。 * * 产物:.design_library/kole-ui/icons/sources.json * { schemaVersion, generatedAt, sources: [{ id, package, version, license, sha256, icons }] } * icons: { "": { d: [ {p, mode} ], v?: "0 0 24 24" } } * mode: "stroke" 线性(描边)| "fill" 面性(填充) * * 用法: * node tools/fetch-icon-sources.mjs # 联网下载(校验 sha256) * node tools/fetch-icon-sources.mjs --from-dir # 用已解压的目录(离线) * * 零运行时依赖;退出码 0 = 成功,非 0 = 失败。 */ import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync, renameSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { createHash } from 'node:crypto'; import { pathToFileURL } from 'node:url'; import { execFileSync } from 'node:child_process'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const OUT_DIR = join(ROOT, '.design_library', 'kole-ui', 'icons'); const OUT = join(OUT_DIR, 'sources.json'); /* 钉死版本与校验和:源换了必须显式改这里,避免"某天图标悄悄变了"。 */ const SOURCES = [ { id: 'tdesign-outline', package: 'tdesign-icons-svg', version: '0.4.7', tarball: 'https://registry.npmjs.org/tdesign-icons-svg/-/tdesign-icons-svg-0.4.7.tgz', sha256: '4918267445549dfa0729c4f4cfe3ff54aa96251c01656cbce175d03e8cc175af', license: 'MIT', copyright: 'Copyright (c) 2021-present TDesign', homepage: 'https://github.com/Tencent/tdesign-icons', srcDir: 'src', /* 只取线性(描边)集:面性集另起一个 source,避免同名覆盖 */ filter: (n) => !n.endsWith('-filled'), nameStrip: null, }, { id: 'tdesign-filled', package: 'tdesign-icons-svg', version: '0.4.7', tarball: 'https://registry.npmjs.org/tdesign-icons-svg/-/tdesign-icons-svg-0.4.7.tgz', sha256: '4918267445549dfa0729c4f4cfe3ff54aa96251c01656cbce175d03e8cc175af', license: 'MIT', copyright: 'Copyright (c) 2021-present TDesign', homepage: 'https://github.com/Tencent/tdesign-icons', srcDir: 'src', filter: (n) => n.endsWith('-filled'), /* filled 集的文件名是 -filled,归一到 -filled 保序(不撞线性集) */ nameStrip: null, }, { id: 'element-plus', package: '@element-plus/icons-svg', version: '2.3.2', tarball: 'https://registry.npmjs.org/@element-plus/icons-svg/-/icons-svg-2.3.2.tgz', sha256: 'b525dc9d0f1150ac28fc6246df07288d6809dcb417927b7af894feb3fd1bbb4e', license: 'MIT', copyright: 'Copyright (c) 2016-present Element Plus', homepage: 'https://github.com/element-plus/element-plus-icons', srcDir: '', filter: () => true, nameStrip: null, }, ]; /* ---------- SVG 解析:抽图形 + 判断线性/面性 ---------- */ /** * 从一份 SVG 文本里提取描边与填充路径。 * * 判定规则(按 TDesign/ElementPlus 的实际写法,不做启发式猜测): * - `stroke="currentColor"` → 线性路径(mode: stroke) * - `fill="currentColor"` → 面性路径(mode: fill) * - `fill="transparent"` → 命中区(hit area),不是视觉内容,丢弃 * - 同一 d 同时出现在 stroke 与 fill 两条里 → 合并为一条 stroke(TDesign 的 * outline 集就是这么写的:先画透明命中区,再叠描边) * * ⚠️ 除 `` 外还必须处理 `` / ``: * 实测上游有 36 个文件(TDesign 的 circle / round / brightness / image 等) * 把图形写成 `` 而不是 ``。只认 `` 会**静默丢掉** * 这些图形 —— 图标看起来"渲染成功"但实际是空的(TDesign 的 `circle` 与 `round` * 就因此整个消失,是覆盖率审计才发现的)。这里把圆/椭圆等价换算成 path 的 arc 命令。 */ export function parseSvgPaths(text) { const nodes = [...text.matchAll(/<(path|circle|ellipse)\b([^>]*?)\/?>/g)].map((m) => ({ tag: m[1], attrs: m[2], })); const byD = new Map(); for (const { tag, attrs } of nodes) { const d = tag === 'path' ? (attrs.match(/\sd="([^"]+)"/) || [])[1] : shapeToPath(tag, attrs); if (!d) continue; const isStroke = /stroke="currentColor"/.test(attrs); const isFill = /fill="currentColor"/.test(attrs); const isTransparent = /fill="transparent"/.test(attrs); if (isTransparent) continue; if (!isStroke && !isFill) continue; const cur = byD.get(d) || { mode: 'fill' }; /* 描边优先级高于填充:同名 d 两者都有时按描边渲染(TDesign outline 的写法) */ if (isStroke) cur.mode = 'stroke'; byD.set(d, cur); } return [...byD.entries()].map(([d, meta]) => ({ d, mode: meta.mode })); } /** `` / `` → 等价 path(用两段 arc 画整圆/整椭圆) */ export function shapeToPath(tag, attrs) { const num = (name, dflt) => { const m = attrs.match(new RegExp(`\\s${name}="([^"]+)"`)); if (!m) return dflt; const v = parseFloat(m[1]); return Number.isFinite(v) ? v : dflt; }; if (tag === 'circle') { const cx = num('cx', 0), cy = num('cy', 0), r = num('r', 0); if (!(r > 0)) return null; return `M${cx} ${cy - r}A${r} ${r} 0 1 1 ${cx} ${cy + r}A${r} ${r} 0 1 1 ${cx} ${cy - r}Z`; } if (tag === 'ellipse') { const cx = num('cx', 0), cy = num('cy', 0), rx = num('rx', 0), ry = num('ry', 0); if (!(rx > 0 && ry > 0)) return null; return `M${cx} ${cy - ry}A${rx} ${ry} 0 1 1 ${cx} ${cy + ry}A${rx} ${ry} 0 1 1 ${cx} ${cy - ry}Z`; } return null; } /** * 归一化数字精度:2 位小数 + 压缩空白。 * * ⚠️ 这里有一个真实踩过的坑(Element Plus 的 quartz-watch 图标): * SVG path 允许「隐式分隔」—— `q-13.005.48-22.5` 是三个数:`-13.005`、`.48`、`-22.5`, * 靠 `-` 和 `.` 自身当分隔符。如果简单地把 `-13.005` 四舍五入成 `-13`(丢掉小数), * 后面的 `.48` 就会**粘成** `-13.48` —— 那是另一个坐标,图形会画错,而且不报错。 * 两条保险: * ① 原本有小数位的数字,输出**至少保留 1 位小数**(`-13.005` → `-13.0`), * 这样后面的 `.48` 不可能再被吸收; * ② 归一化后**复核数字序列**,与原串逐值比对,不一致就原样返回(宁可不省体积也不许画错)。 * * 不额外插空格:SVG 路径语法里 `5-3` 与 `5 .3` 都合法(`-`/`.` 本身就是分隔符), * 插空格实测让 registry 涨 185KB 而无一利。 */ export function optimizePath(d) { const optimized = d /* 有小数位的数:四舍五入到 2 位,去掉多余尾零但**至少留 1 位小数**。 留 1 位是关键:`-13.005` → `-13.0`(而不是 `-13`),这样后面隐式相连的 `.48` 不会被吸收成 `-13.48`。 */ .replace(/-?\d+\.\d+/g, (m) => { const v = Math.round(parseFloat(m) * 100) / 100; const s = v.toFixed(2).replace(/0+$/, ''); return s.endsWith('.') ? s + '0' : s; }) .replace(/\s+/g, ' ') .trim(); /* 复核:优化前后的数字序列必须逐个对得上,否则原样返回(宁可不省体积也不许画错) */ if (!sameNumbers(d, optimized)) return d; return optimized; } /** 抽出路径串里的数字序列(用于优化前后比对) */ export function numberSequence(d) { return (d.match(/-?\d*\.?\d+(?:[eE][-+]?\d+)?/g) || []).map((x) => { const v = parseFloat(x); return Number.isFinite(v) ? v : x; }); } /** 两个路径串是否表示同一组数字(顺序与数值都一致) */ function sameNumbers(a, b) { const x = numberSequence(a); const y = numberSequence(b); if (x.length !== y.length) return false; for (let i = 0; i < x.length; i++) { if (typeof x[i] === 'number' && typeof y[i] === 'number') { /* 四舍五入会改值,所以只比较「结构」:位数一致 + 值差在 0.005 容差内 */ if (Math.abs(x[i] - y[i]) > 0.005) return false; } else if (x[i] !== y[i]) return false; } return true; } /** * 读 viewBox。 * * ⚠️ TDesign 全部 2356 个 SVG 把属性写成 `view-box`(连字符、小写 b)—— * 那是**无效属性**,浏览器会忽略它并把图标按默认 300×150 缩放。 * 这里两种拼写都认,并在产物里统一写回规范的 `viewBox`, * 拼写错误不许流进仓库(tools/verify-icons.mjs 会卡这条)。 */ export function parseViewBox(text) { const m = text.match(/\sview-?Box="([^"]+)"/i); return m ? m[1].trim() : null; } function die(msg) { console.error('FAIL: ' + msg); process.exit(1); } function sha256File(p) { return createHash('sha256').update(readFileSync(p)).digest('hex'); } /* ---------- 取源:联网下载 或 复用已解压目录 ---------- */ const fromDirArg = process.argv.indexOf('--from-dir'); const FROM_DIR = fromDirArg >= 0 ? process.argv[fromDirArg + 1] : null; if (fromDirArg >= 0 && !FROM_DIR) die('--from-dir 需要跟一个目录参数'); const CACHE = join(ROOT, '.cache-icons'); if (!FROM_DIR && !existsSync(CACHE)) mkdirSync(CACHE, { recursive: true }); /** 返回每个 source 的「图标目录绝对路径」 */ function resolveDir(src) { if (FROM_DIR) { /* 离线模式:约定 --from-dir 下按 source.id 分子目录 */ const d = join(FROM_DIR, src.id, src.srcDir); if (!existsSync(d)) die(`离线目录不存在:${d}`); return d; } const tgz = join(CACHE, `${src.id}-${src.version}.tgz`); if (!existsSync(tgz)) { console.log(` 下载 ${src.package}@${src.version} …`); execFileSync('curl', ['-sL', '--fail', '-m', '120', src.tarball, '-o', tgz], { stdio: 'inherit' }); } const got = sha256File(tgz); if (got !== src.sha256) die(`${src.id} sha256 不符\n 期望 ${src.sha256}\n 实际 ${got}`); const extract = join(CACHE, `${src.id}-${src.version}`); /* 先解到 temp 再改名换入:避免半解压的目录被下次运行当成"已解压"复用。 两个 Windows 实测坑: ① tar 的 `-C <绝对路径>` 在含 `C:` 时会被当成远程主机("Cannot connect to C:"); ② `--one-top-level` 与 `--strip-components` 同用会解错层级。 所以改成 cwd=目标目录 + 只用 --strip-components。 */ if (!existsSync(extract)) { const tmpName = `.tmp-${src.id}-${Date.now()}`; const tmp = join(CACHE, tmpName); mkdirSync(tmp, { recursive: true }); try { /* cwd=CACHE + 相对路径:tar 把含 `C:` 的绝对路径当成远程主机 ("Cannot connect to C:" 实测),所以 -C 只能用相对目录名。 */ execFileSync('tar', ['xzf', `${src.id}-${src.version}.tgz`, '-C', tmpName, '--strip-components=1'], { cwd: CACHE, stdio: ['ignore', 'ignore', 'pipe'] }); } catch (e) { die(`${src.id} 解压失败:${e.stderr ? e.stderr.toString() : e.message}`); } renameSync(tmp, extract); } return join(extract, src.srcDir); } /* ---------- 主流程 ---------- 只在「作为脚本直接运行」时执行;被 import 时(测试要复用 optimizePath / parseSvgPaths 这些纯函数)不产生任何副作用 —— 否则 import 一次就会去联网拉包。 */ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) main(); function main() { console.log('图标源固化'); console.log(`仓库:${ROOT}`); const out = { schemaVersion: 1, generatedAt: null, sources: [] }; for (const src of SOURCES) { const dir = resolveDir(src); const files = readdirSync(dir).filter((f) => f.endsWith('.svg')); const icons = {}; let paths = 0; for (const f of files) { const name = src.nameStrip ? src.nameStrip(f.replace(/\.svg$/, '')) : f.replace(/\.svg$/, ''); if (!src.filter(name)) continue; const text = readFileSync(join(dir, f), 'utf8'); const ps = parseSvgPaths(text); if (!ps.length) continue; const vb = parseViewBox(text); icons[name] = { v: vb, d: ps.map((p) => ({ p: optimizePath(p.d), m: p.mode })) }; paths += ps.length; } const names = Object.keys(icons); const bytes = JSON.stringify(icons).length; console.log( ` ${src.id.padEnd(16)} ${names.length} 个图标 / ${paths} 条路径 / ${(bytes / 1024).toFixed(1)}KB` ); if (!names.length) die(`${src.id} 没有解析出任何图标`); out.sources.push({ id: src.id, package: src.package, version: src.version, license: src.license, copyright: src.copyright, homepage: src.homepage, sha256: src.sha256, iconCount: names.length, icons, }); } out.generatedAt = new Date().toISOString().slice(0, 10); if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }); writeFileSync(OUT, JSON.stringify(out), 'utf8'); const total = out.sources.reduce((a, s) => a + s.iconCount, 0); console.log(''); console.log(`写入 ${OUT}`); console.log(` 来源 ${out.sources.length} 个 | 图标合计 ${total} 个 | 文件 ${(readFileSync(OUT).length / 1024).toFixed(0)}KB`); console.log('OK'); }