#!/usr/bin/env node /** * Vector → SVG 保真校验工具(静态,TypeScript 版) * * 比对 Android `` drawable XML 与转换产出的 SVG,检出「文件存在但内容语义错误」 * 的一类缺陷。**纯静态**:只读两侧文件做结构比对,不连设备、不部署、不截图。 * * 存在性检查(文件在不在)无法发现这些问题,因此需要独立的内容校验。 * 规则依据见 ../references/xml-drawable-to-svg-rules.md 的 * 「fill on : three branches」「clip-path is geometry」「Vector Fidelity Checklist」。 * * 零第三方依赖,仅使用 Node 内置模块,可独立分发。 * 需要 Node >= 22.18(原生 strip-types,直接 `node svg_fidelity_check.ts` 运行)。 * * 用法: * node svg_fidelity_check.ts --android-res --harmony-media * [--json ] [--strict] [--quiet] * * 退出码: * 0 无 error(--strict 下亦无 warn) * 1 存在 error(或 --strict 下存在 warn) * 2 用法错误 / 路径不存在 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; // ───────────────────────────────────────────── // 类型 // ───────────────────────────────────────────── type Severity = 'error' | 'warn'; /** 检查规则标识 */ type RuleId = | 'INVALID_PATH_DATA' | 'CLIP_PATH_LOST' | 'FILL_INJECTED' | 'FILL_LOST' | 'GRADIENT_LOST' | 'VIEWBOX_MISMATCH'; interface Finding { rule: RuleId; severity: Severity; /** 资源名(不含扩展名) */ name: string; androidFile: string; svgFile: string; /** Android 侧证据(截断后的原文片段或计数) */ androidEvidence: string; /** SVG 侧证据 */ svgEvidence: string; /** 一句话说明后果 */ impact: string; } interface Report { androidRes: string; harmonyMedia: string; vectorsFound: number; pairsCompared: number; unpaired: string[]; findings: Finding[]; counts: Record; } // ───────────────────────────────────────────── // 工具函数 // ───────────────────────────────────────────── function fail(msg: string): never { console.error(`[svg_fidelity_check] ${msg}`); process.exit(2); } function truncate(s: string, n = 72): string { const flat = s.replace(/\s+/g, ' ').trim(); return flat.length <= n ? flat : `${flat.slice(0, n - 1)}…`; } /** 递归收集匹配后缀的文件 */ function walkFiles(dir: string, ext: string, out: string[] = []): string[] { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; } for (const e of entries) { const p = path.join(dir, e.name); if (e.isDirectory()) { walkFiles(p, ext, out); } else if (e.isFile() && e.name.toLowerCase().endsWith(ext)) { out.push(p); } } return out; } /** * 提取元素的属性表。刻意使用正则而非完整 XML 解析: * 保持零依赖,且这些 drawable 结构简单、属性均为单行键值。 */ function attrsOf(tagText: string): Record { const out: Record = {}; for (const m of tagText.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) { out[m[1]] = m[2]; } return out; } /** 取出所有指定标签的完整起始标签文本 */ function tagsOf(xml: string, tag: string): string[] { const re = new RegExp(`<${tag}\\b[^>]*?/?>`, 'g'); return Array.from(xml.matchAll(re), (m) => m[0]); } /** * 去掉 `…` 整段,使其内部的 `` 不被当作绘制路径。 * 裁剪几何只定义形状,不参与 fill/stroke 比对;若保留会让路径索引错位。 */ function stripClipInnerPaths(xml: string): string { return xml.replace(//g, ''); } /** 归一化颜色为小写 #rrggbb / #aarrggbb,便于比较 */ function normColor(c: string): string { let v = c.trim().toLowerCase(); if (!v.startsWith('#')) return v; // @color/... 或 ?attr/... 等引用,交由上层判断 v = v.slice(1); // #rgb -> #rrggbb if (v.length === 3) v = v.split('').map((ch) => ch + ch).join(''); // #argb -> #aarrggbb else if (v.length === 4) v = v.split('').map((ch) => ch + ch).join(''); return `#${v}`; } /** 从 #aarrggbb 取 rgb 部分;其它长度原样返回 */ function rgbPart(c: string): string { const v = normColor(c); if (v.length === 9) return `#${v.slice(3)}`; return v; } /** * 判定 pathData 是否覆盖整个 viewport。 * * 用「绝对坐标包围盒」而非匹配书写形态:同一个全幅矩形有多种等价写法 * (`M0 0h16v16H0z`、`M16 16H0V0h16z`、逆序绕向等),逐字符匹配会把等价写法误判成子区域。 * 因此累加 M/L/H/V/h/v/l/m 的位移得到实际包围盒,再与 viewport 比较。 * * 仅支持直线段指令 —— 裁剪路径几乎总是矩形;出现曲线指令时保守返回 false * (判为子区域,宁可误报为 error 也不放过真实的几何裁剪)。 */ function isFullViewportRect(pathData: string, vw: number, vh: number): boolean { if (vw <= 0 || vh <= 0) return false; if (/[CcSsQqTtAa]/.test(pathData)) return false; // 含曲线,保守处理 let x = 0; let y = 0; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; let seen = false; const mark = (): void => { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); seen = true; }; // 按「指令 + 其后的数值序列」切分 const tokens = pathData.matchAll(/([MmLlHhVvZz])([^MmLlHhVvZzCcSsQqTtAa]*)/g); for (const t of tokens) { const cmd = t[1]; const nums = Array.from(t[2].matchAll(/-?\d*\.?\d+/g), (m) => parseFloat(m[0])); switch (cmd) { case 'M': case 'L': for (let i = 0; i + 1 < nums.length; i += 2) { x = nums[i]; y = nums[i + 1]; mark(); } break; case 'm': case 'l': for (let i = 0; i + 1 < nums.length; i += 2) { x += nums[i]; y += nums[i + 1]; mark(); } break; case 'H': for (const n of nums) { x = n; mark(); } break; case 'h': for (const n of nums) { x += n; mark(); } break; case 'V': for (const n of nums) { y = n; mark(); } break; case 'v': for (const n of nums) { y += n; mark(); } break; default: // Z/z 闭合,不改变包围盒 break; } } if (!seen) return false; const tol = 0.5; return ( Math.abs(minX) <= tol && Math.abs(minY) <= tol && Math.abs(maxX - vw) <= tol && Math.abs(maxY - vh) <= tol ); } // ───────────────────────────────────────────── // 单文件比对 // ───────────────────────────────────────────── function checkPair(name: string, androidFile: string, svgFile: string): Finding[] { const findings: Finding[] = []; const ax = fs.readFileSync(androidFile, 'utf-8'); const sv = fs.readFileSync(svgFile, 'utf-8'); // ── 0. INVALID_PATH_DATA ───────────────────────────── // `android:pathData` 可以写成 `@string/xxx` 引用(AppCompat 的密码可见性图标 // 就是这么写的)。若转换器把它原样搬进 SVG 的 `d`,产出的文件无法渲染, // 而其余各条规则只比对「存在与否」和数量,全都发现不了。 // 实测 15 份已提交补丁中有 9 份带这类缺陷,正是因为此前缺这条检查。 for (const m of sv.matchAll(/\s(d|points)="([^"]*)"/g)) { const v = m[2].trim(); if (v === '' || /^[MmZzLlHhVvCcSsQqTtAa][\s\d.,+eE-]*/.test(v)) continue; findings.push({ rule: 'INVALID_PATH_DATA', severity: 'error', name, androidFile, svgFile, androidEvidence: 'android:pathData is a resource reference, not literal path data', svgEvidence: `${m[1]}="${truncate(v, 48)}"`, impact: 'SVG path data is not renderable; the shape silently disappears at runtime.', }); } const vectorTag = tagsOf(ax, 'vector')[0] ?? ''; const va = attrsOf(vectorTag); const vw = parseFloat(va['android:viewportWidth'] ?? '0'); const vh = parseFloat(va['android:viewportHeight'] ?? '0'); // ── 1. VIEWBOX_MISMATCH ────────────────────────────── const svgTag = tagsOf(sv, 'svg')[0] ?? ''; const sa = attrsOf(svgTag); const viewBox = (sa['viewBox'] ?? '').trim(); if (vw > 0 && vh > 0) { const parts = viewBox.split(/[\s,]+/).map(parseFloat); const ok = parts.length === 4 && Math.abs(parts[2] - vw) <= 0.01 && Math.abs(parts[3] - vh) <= 0.01; if (!ok) { findings.push({ rule: 'VIEWBOX_MISMATCH', severity: 'error', name, androidFile, svgFile, androidEvidence: `viewport=${vw}x${vh}`, svgEvidence: viewBox ? `viewBox="${viewBox}"` : 'viewBox missing', impact: 'Whole icon is scaled or cropped relative to the source artwork.', }); } } // ── 2. CLIP_PATH_LOST ──────────────────────────────── const androidClips = tagsOf(ax, 'clip-path'); const svgClipDefs = tagsOf(sv, 'clipPath'); if (androidClips.length > svgClipDefs.length) { // 判定每个丢失的 clip 是全幅还是子区域:子区域 => error,全幅 => warn let worst: Severity = 'warn'; const details: string[] = []; for (const c of androidClips) { const pd = attrsOf(c)['android:pathData'] ?? ''; const full = isFullViewportRect(pd, vw, vh); if (!full) worst = 'error'; details.push(`${truncate(pd, 34)}${full ? ' [full-viewport]' : ' [sub-region]'}`); } findings.push({ rule: 'CLIP_PATH_LOST', severity: worst, name, androidFile, svgFile, androidEvidence: `${androidClips.length} clip-path: ${details.join(' | ')}`, svgEvidence: `${svgClipDefs.length} clipPath in SVG`, impact: worst === 'error' ? 'Sub-region clip dropped: artwork lands at wrong offset/scale, or a flood-filled path renders as a solid rectangle.' : 'Full-viewport clip dropped: visually equivalent here, but the clip pipeline is not preserving clips.', }); } // ── 3. GRADIENT_LOST ───────────────────────────────── // Android 用 内联渐变; // SVG 需要 + fill="url(#id)"。 // 转换器若不支持 aapt:attr,往往退化成纯色(且常是注入的黑色)。 const aGradients = tagsOf(ax, 'gradient'); if (aGradients.length > 0) { const svgGradDefs = tagsOf(sv, 'linearGradient').length + tagsOf(sv, 'radialGradient').length; if (svgGradDefs < aGradients.length) { const stops = Array.from(ax.matchAll(/android:color="(#[0-9a-fA-F]+)"/g), (m) => m[1]); const urlRefs = (sv.match(/fill="url\(#[^)]*\)"/g) ?? []).length; findings.push({ rule: 'GRADIENT_LOST', severity: 'error', name, androidFile, svgFile, androidEvidence: `${aGradients.length} via aapt:attr, stops: ${stops.slice(0, 4).join(', ')}${stops.length > 4 ? ' …' : ''}`, svgEvidence: `${svgGradDefs} gradient defs, ${urlRefs} fill="url(#…)" refs`, impact: 'Gradient collapsed to a flat color (often an injected black), losing the color ramp entirely.', }); } } // ── 4/5. FILL_INJECTED / FILL_LOST ─────────────────── // 按出现顺序配对「绘制用」path。两侧都必须先剔除裁剪定义内部的 path: // - Android: 是独立元素,不会被 tagsOf(ax,'path') 匹配到, // 但 形式(若转换器回写)需排除 // - SVG: 里的 path 只定义裁剪几何, // 不参与填充;若不剔除会导致索引整体错位,把正确文件误报成 FILL_INJECTED const aPaints = stripClipInnerPaths(ax); const sPaints = stripClipInnerPaths(sv); const aPaths = tagsOf(aPaints, 'path').map(attrsOf); const sPaintPaths = tagsOf(sPaints, 'path').map(attrsOf); const pairable = aPaths.length > 0 && aPaths.length <= sPaintPaths.length; // 渐变 path 在 Android 侧写作 ``, // 其 fillColor 由子元素提供而非属性;这类 path 交由 GRADIENT_LOST 处理, // 不能按「无 fillColor」判断,否则会与描边-only 混淆。 const gradientPathIdx = new Set(); { const withGrad = Array.from( aPaints.matchAll(/]*>[\s\S]*?<\/path>/g), (m) => m[0], ).filter((t) => /]*?>/)?.[0] ?? ''; const at = allOpen.indexOf(open); if (at >= 0) gradientPathIdx.add(at); } } for (let i = 0; i < aPaths.length; i++) { if (gradientPathIdx.has(i)) continue; // 渐变已由 GRADIENT_LOST 覆盖 const ap = aPaths[i]; const aFill = ap['android:fillColor']; const aStroke = ap['android:strokeColor']; const sp = pairable ? sPaintPaths[i] : undefined; if (!sp) break; const sFill = sp['fill']; const strokeOnly = !aFill && !!aStroke; if (strokeOnly) { // 必须显式 fill="none";具体颜色即为注入错误 const isNone = (sFill ?? '').trim().toLowerCase() === 'none'; if (!isNone) { findings.push({ rule: 'FILL_INJECTED', severity: 'error', name, androidFile, svgFile, androidEvidence: `path[${i}] strokeColor=${aStroke}, no fillColor (stroke-only)`, svgEvidence: sFill === undefined ? 'fill attribute absent' : `fill="${sFill}"`, impact: 'Stroke-only outline is filled solid. SVG defaults fill to black, so fill="none" must be explicit.', }); } } else if (aFill) { // 显式 fill 必须存在且颜色一致(忽略 alpha 通道差异,alpha 可能拆到 fill-opacity) if (aFill.startsWith('#')) { const want = rgbPart(aFill); const got = sFill ? rgbPart(sFill) : ''; if (!sFill || (got !== want && normColor(sFill) !== normColor(aFill))) { findings.push({ rule: 'FILL_LOST', severity: 'error', name, androidFile, svgFile, androidEvidence: `path[${i}] fillColor=${aFill}`, svgEvidence: sFill === undefined ? 'fill attribute absent' : `fill="${sFill}"`, impact: 'Explicit fill color lost or altered; shape renders in the wrong color.', }); } } } } return findings; } // ───────────────────────────────────────────── // 主流程 // ───────────────────────────────────────────── function run(androidRes: string, harmonyMedia: string): Report { if (!fs.existsSync(androidRes)) fail(`--android-res not found: ${androidRes}`); if (!fs.existsSync(harmonyMedia)) fail(`--harmony-media not found: ${harmonyMedia}`); // 收集所有 drawable*/ 下根元素为 的 XML const xmls = walkFiles(androidRes, '.xml').filter((p) => { const rel = path.relative(androidRes, p).replace(/\\/g, '/'); const isDrawable = /(^|\/)drawable[^/]*\//.test(rel) || /(^|\/)mipmap[^/]*\//.test(rel); if (!isDrawable) return false; // --harmony-media 指向的是**单个**限定符目录(通常是 base/media), // 因此只有会落到该目录的 Android 源才能拿来配对。 // `drawable-night/x.xml` 转出的是 `dark/media/x.svg`;拿它跟 `base/media/x.svg` // 比会误报 FILL_LOST——同名图标的浅色版与深色版本来就该是不同颜色。 // 能落到 base 的限定符只有:无限定符、anydpi、nodpi、v。 const dir = rel.split('/').find((seg) => seg.startsWith('drawable') || seg.startsWith('mipmap')) ?? ''; return dir.split('-').slice(1).every((q) => q === 'anydpi' || q === 'nodpi' || /^v\d+$/.test(q)); }); const svgIndex = new Map(); for (const s of walkFiles(harmonyMedia, '.svg')) { svgIndex.set(path.basename(s, '.svg'), s); } const findings: Finding[] = []; const unpaired: string[] = []; let vectorsFound = 0; let pairsCompared = 0; for (const xml of xmls) { let text: string; try { text = fs.readFileSync(xml, 'utf-8'); } catch { continue; } if (!/` 里内联的 `` 不会转成 `.svg`,而是转成 // `_foreground.svg` / `_background.svg`(layered-image 只接受 $media:)。 // 不认这两个名字的话,这些图形就成了「unpaired」,等于没进保真度门禁。 const svg = svgIndex.get(name) ?? (/ = {}; for (const f of findings) { counts[f.rule] = (counts[f.rule] ?? 0) + 1; counts[f.severity] = (counts[f.severity] ?? 0) + 1; } return { androidRes, harmonyMedia, vectorsFound, pairsCompared, unpaired, findings, counts }; } function printReport(r: Report, quiet: boolean): void { const errors = r.findings.filter((f) => f.severity === 'error'); const warns = r.findings.filter((f) => f.severity === 'warn'); console.log(''); console.log('Vector → SVG fidelity check (static)'); console.log(` android res : ${r.androidRes}`); console.log(` harmony media : ${r.harmonyMedia}`); console.log(` vectors found : ${r.vectorsFound}`); console.log(` pairs compared : ${r.pairsCompared}`); if (r.unpaired.length > 0) { console.log(` unpaired : ${r.unpaired.length} (no same-name .svg; not an error by itself)`); } console.log(` errors : ${errors.length}`); console.log(` warnings : ${warns.length}`); if (r.findings.length === 0) { // 区分「检查过且都通过」与「什么都没检查」——后者不是通过, // 静默的空结果看起来像成功,正是本工具要消除的那类假信号。 if (r.pairsCompared === 0) { console.log( r.vectorsFound === 0 ? '\n NOTHING CHECKED: no drawables found under --android-res.' + '\n Verify the path points at an Android res/ directory (expects drawable*/ subdirs).' : '\n NOTHING CHECKED: found vectors but none had a same-name .svg under --harmony-media.' + '\n Verify --harmony-media points at the converted resources/base/media/ directory.', ); return; } console.log(`\n All ${r.pairsCompared} compared vectors are semantically faithful.`); return; } // 按规则聚合,便于一眼看出是系统性问题还是个别文件 const byRule = new Map(); for (const f of r.findings) { const arr = byRule.get(f.rule) ?? []; arr.push(f); byRule.set(f.rule, arr); } for (const [rule, list] of byRule) { const files = new Set(list.map((f) => f.name)).size; const scope = list.length === files ? `${files} file${files > 1 ? 's' : ''}` : `${list.length} findings in ${files} file${files > 1 ? 's' : ''}`; const errs = list.filter((f) => f.severity === 'error').length; const wrns = list.length - errs; const mix = errs > 0 && wrns > 0 ? `, ${errs} error / ${wrns} warn` : ''; console.log(`\n ${rule} (${scope}${mix})`); // impact 随 severity 不同,取最严重的一条作为代表,避免用 warn 的措辞概括 error const worst = list.find((f) => f.severity === 'error') ?? list[0]; console.log(` impact: ${worst.impact}`); // error 优先展示,确保 --quiet 截断时不会只剩 warn const ordered = [...list].sort( (a, b) => (a.severity === b.severity ? 0 : a.severity === 'error' ? -1 : 1), ); const show = quiet ? ordered.slice(0, 5) : ordered; for (const f of show) { console.log(` - ${f.name} [${f.severity}]`); console.log(` android: ${f.androidEvidence}`); console.log(` svg : ${f.svgEvidence}`); } if (show.length < list.length) { console.log(` … ${list.length - show.length} more (omit --quiet to list all)`); } } console.log('\n Fix per ../references/xml-drawable-to-svg-rules.md, then re-run.'); } function main(argv: string[]): void { let androidRes = ''; let harmonyMedia = ''; let jsonOut = ''; let strict = false; let quiet = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--android-res') androidRes = argv[++i] ?? ''; else if (a === '--harmony-media') harmonyMedia = argv[++i] ?? ''; else if (a === '--json') jsonOut = argv[++i] ?? ''; else if (a === '--strict') strict = true; else if (a === '--quiet') quiet = true; else if (a === '-h' || a === '--help') { console.log( [ 'Vector → SVG fidelity check (static; no device required)', '', 'Usage:', ' node svg_fidelity_check.ts --android-res --harmony-media [options]', '', 'Required:', ' --android-res Android res/ directory (scans drawable*/ and mipmap*/ for )', ' --harmony-media HarmonyOS resources/base/media/ directory holding converted .svg', '', 'Options:', ' --json Write the full findings report as JSON', ' --strict Treat warnings as failures (exit 1)', ' --quiet Cap per-rule listings to 5 entries', ' -h, --help Show this help', '', 'Checks: INVALID_PATH_DATA, CLIP_PATH_LOST, FILL_INJECTED, FILL_LOST, GRADIENT_LOST, VIEWBOX_MISMATCH', 'Exit: 0 clean, 1 findings, 2 usage/path error', ].join('\n'), ); process.exit(0); } else { fail(`unknown argument: ${a} (try --help)`); } } if (!androidRes || !harmonyMedia) { fail('--android-res and --harmony-media are both required (try --help)'); } const report = run(path.resolve(androidRes), path.resolve(harmonyMedia)); printReport(report, quiet); if (jsonOut) { fs.mkdirSync(path.dirname(path.resolve(jsonOut)), { recursive: true }); fs.writeFileSync(path.resolve(jsonOut), JSON.stringify(report, null, 2), 'utf-8'); console.log(`\n JSON report: ${jsonOut}`); } const errors = report.findings.filter((f) => f.severity === 'error').length; const warns = report.findings.filter((f) => f.severity === 'warn').length; process.exit(errors > 0 || (strict && warns > 0) ? 1 : 0); } if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { main(process.argv.slice(2)); } export { run, checkPair, type Report, type Finding };