android2harmony 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +1 -407
  2. package/agents/self-tester.md +376 -339
  3. package/dist/index.js +131 -586
  4. package/dist/index.js.map +4 -4
  5. package/package.json +36 -37
  6. package/skills/a2h-resource-convert/SKILL.md +902 -0
  7. package/skills/a2h-resource-convert/references/code-vector-icon-rules.md +335 -0
  8. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/conversion-rules.md +15 -2
  9. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/dependency-analysis-rules.md +24 -2
  10. package/skills/a2h-resource-convert/references/lottie-conversion-rules.md +219 -0
  11. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/resource-mapping-rules.md +27 -2
  12. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/xml-drawable-to-svg-rules.md +118 -1
  13. package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +2166 -0
  14. package/skills/a2h-resource-convert/scripts/code_vector_icons.js +607 -0
  15. package/skills/a2h-resource-convert/scripts/package.json +3 -0
  16. package/skills/a2h-resource-convert/scripts/svg_fidelity_check.js +632 -0
  17. package/skills/a2h-ui-transfer/SKILL.md +420 -0
  18. package/skills/a2h-ui-transfer/references/conversion-procedure.md +572 -0
  19. package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/_/350/214/203/345/274/217/351/200/211/346/213/251/350/257/264/346/230/216.md +1 -1
  20. package/skills/a2h-ui-transfer/references/mvvm-v2/_/350/243/205/351/245/260/345/231/250/351/200/237/346/237/245.md +133 -0
  21. package/skills/{hmos-batch-ui-align/scripts/android_parse_fast.ts → a2h-ui-transfer/scripts/android_parse_fast.js} +397 -283
  22. package/skills/a2h-ui-transfer/scripts/arkts_static_check.js +1624 -0
  23. package/skills/a2h-ui-transfer/scripts/measure_pack.js +1005 -0
  24. package/skills/a2h-ui-transfer/scripts/package.json +3 -0
  25. package/skills/hmos-incremental-ui-align/README.md +251 -251
  26. package/skills/hmos-incremental-ui-align/SKILL.md +364 -364
  27. package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +2 -2
  28. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.js +790 -0
  29. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.js +285 -0
  30. package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -76
  31. package/skills/hmos-incremental-ui-align/scripts/page_capture.js +756 -0
  32. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.js +155 -0
  33. package/skills/hmos-batch-ui-align/SKILL.md +0 -141
  34. package/skills/hmos-batch-ui-align/references/conversion-procedure.md +0 -217
  35. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +0 -999
  36. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +0 -343
  37. package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +0 -977
  38. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +0 -188
  39. package/skills/hmos-resources-convert/SKILL.md +0 -654
  40. package/skills/hmos-resources-convert/template/AppScope/app.json5 +0 -10
  41. package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +0 -8
  42. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
  43. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
  44. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +0 -7
  45. package/skills/hmos-resources-convert/template/build-profile.json5 +0 -42
  46. package/skills/hmos-resources-convert/template/code-linter.json5 +0 -32
  47. package/skills/hmos-resources-convert/template/entry/build-profile.json5 +0 -33
  48. package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +0 -6
  49. package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +0 -23
  50. package/skills/hmos-resources-convert/template/entry/oh-package.json5 +0 -10
  51. package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +0 -48
  52. package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +0 -16
  53. package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +0 -23
  54. package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +0 -55
  55. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +0 -8
  56. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +0 -8
  57. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +0 -16
  58. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
  59. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
  60. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +0 -7
  61. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
  62. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +0 -3
  63. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +0 -5
  64. package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +0 -8
  65. package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +0 -2
  66. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +0 -35
  67. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +0 -5
  68. package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +0 -16
  69. package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +0 -5
  70. package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +0 -33
  71. package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +0 -23
  72. package/skills/hmos-resources-convert/template/hvigorfile.ts +0 -6
  73. package/skills/hmos-resources-convert/template/oh-package-lock.json5 +0 -28
  74. package/skills/hmos-resources-convert/template/oh-package.json5 +0 -10
  75. package/tools/autotest/deps/autotest-agent-0.1.1.tgz +0 -0
  76. package/tools/autotest/engine/batch-launcher.ts +0 -326
  77. package/tools/autotest/engine/report-tool.ts +0 -773
  78. package/tools/autotest/engine/self-test-runner.ts +0 -1024
  79. package/tools/autotest/engine/testcases-tool.ts +0 -246
  80. package/tools/autotest/resolve-metadata-tool.ts +0 -143
  81. package/tools/autotest/runner/logger.ts +0 -45
  82. package/tools/autotest/runner/process-utils.ts +0 -41
  83. package/tools/autotest/validate.ts +0 -115
  84. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +0 -0
  85. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +0 -0
  86. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +0 -0
  87. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  88. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md" +0 -0
  89. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md" +0 -0
  90. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  91. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md" +0 -0
  92. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md" +0 -0
  93. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md" +0 -0
  94. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  95. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md" +0 -0
  96. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  97. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md" +0 -0
  98. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
  99. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md" +0 -0
  100. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/!!/350/257/255/346/263/225/357/274/232/345/217/214/345/220/221/347/273/221/345/256/232.md" +0 -0
  101. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Computed/350/243/205/351/245/260/345/231/250/357/274/232/350/256/241/347/256/227/345/261/236/346/200/247.md" +0 -0
  102. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Event/350/243/205/351/245/260/345/231/250/357/274/232/350/247/204/350/214/203/347/273/204/344/273/266/350/276/223/345/207/272.md" +0 -0
  103. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Local/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/351/203/250/347/212/266/346/200/201.md" +0 -0
  104. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Monitor/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/344/277/256/346/224/271/345/274/202/346/255/245/347/233/221/345/220/254.md" +0 -0
  105. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@ObservedV2/350/243/205/351/245/260/345/231/250/345/222/214@Trace/350/243/205/351/245/260/345/231/250/357/274/232/347/261/273/345/261/236/346/200/247/345/217/230/345/214/226/350/247/202/346/265/213.md" +0 -0
  106. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Once/350/243/205/351/245/260/345/231/250/357/274/232/345/210/235/345/247/213/345/214/226/345/220/214/346/255/245/344/270/200/346/254/241.md" +0 -0
  107. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Param/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/244/226/351/203/250/350/276/223/345/205/245.md" +0 -0
  108. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Provider/350/243/205/351/245/260/345/231/250/345/222/214@Consumer/350/243/205/351/245/260/345/231/250/357/274/232/350/267/250/347/273/204/344/273/266/345/261/202/347/272/247/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  109. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Type/350/243/205/351/245/260/345/231/250/357/274/232/346/240/207/350/256/260/347/261/273/345/261/236/346/200/247/347/232/204/347/261/273/345/236/213.md" +0 -0
  110. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/AppStorageV2/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  111. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/MVVM/346/250/241/345/274/217/357/274/210V2/357/274/211.md" +0 -0
  112. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/PersistenceV2/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
  113. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2//347/212/266/346/200/201/347/256/241/347/220/206V1/345/220/221V2/350/277/201/347/247/273/344/270/216/346/267/267/347/224/250/346/214/207/345/257/274.md" +0 -0
@@ -0,0 +1,632 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Vector → SVG 保真校验工具(静态)
4
+ *
5
+ * 比对 Android `<vector>` drawable XML 与转换产出的 SVG,检出「文件存在但内容语义错误」
6
+ * 的一类缺陷。**纯静态**:只读两侧文件做结构比对,不连设备、不部署、不截图。
7
+ *
8
+ * 存在性检查(文件在不在)无法发现这些问题,因此需要独立的内容校验。
9
+ * 规则依据见 ../references/xml-drawable-to-svg-rules.md 的
10
+ * 「fill on <path>: three branches」「clip-path is geometry」「Vector Fidelity Checklist」。
11
+ *
12
+ * 零第三方依赖,仅使用 Node 内置模块,可独立分发。
13
+ * 纯 JavaScript(ESM),直接 `node svg_fidelity_check.js` 运行,无需编译、无依赖。
14
+ *
15
+ * 用法:
16
+ * node svg_fidelity_check.js --android-res <res dir> --harmony-media <base/media dir>
17
+ * [--json <report path>] [--strict] [--quiet]
18
+ *
19
+ * 退出码:
20
+ * 0 无 error(--strict 下亦无 warn)
21
+ * 1 存在 error(或 --strict 下存在 warn)
22
+ * 2 用法错误 / 路径不存在
23
+ */
24
+
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+ import { pathToFileURL } from 'node:url';
28
+
29
+ // ─────────────────────────────────────────────
30
+ // 类型
31
+ // ─────────────────────────────────────────────
32
+
33
+ // type Severity = 'error' | 'warn';
34
+
35
+ /** 检查规则标识 */
36
+ // type RuleId =
37
+ // | 'INVALID_PATH_DATA'
38
+ // | 'CLIP_PATH_LOST'
39
+ // | 'FILL_INJECTED'
40
+ // | 'FILL_LOST'
41
+ // | 'GRADIENT_LOST'
42
+ // | 'VIEWBOX_MISMATCH';
43
+
44
+ // interface Finding {
45
+ // rule: RuleId;
46
+ // severity: Severity;
47
+ // /** 资源名(不含扩展名) */
48
+ // name: string;
49
+ // androidFile: string;
50
+ // svgFile: string;
51
+ // /** Android 侧证据(截断后的原文片段或计数) */
52
+ // androidEvidence: string;
53
+ // /** SVG 侧证据 */
54
+ // svgEvidence: string;
55
+ // /** 一句话说明后果 */
56
+ // impact: string;
57
+ // }
58
+
59
+ // interface Report {
60
+ // androidRes: string;
61
+ // harmonyMedia: string;
62
+ // vectorsFound: number;
63
+ // pairsCompared: number;
64
+ // unpaired: string[];
65
+ // findings: Finding[];
66
+ // counts: Record<string, number>;
67
+ // }
68
+
69
+ // ─────────────────────────────────────────────
70
+ // 工具函数
71
+ // ─────────────────────────────────────────────
72
+
73
+ function fail(msg) {
74
+ console.error(`[svg_fidelity_check] ${msg}`);
75
+ process.exit(2);
76
+ }
77
+
78
+ function truncate(s, n = 72) {
79
+ const flat = s.replace(/\s+/g, ' ').trim();
80
+ return flat.length <= n ? flat : `${flat.slice(0, n - 1)}…`;
81
+ }
82
+
83
+ /** 递归收集匹配后缀的文件 */
84
+ function walkFiles(dir, ext, out = []) {
85
+ let entries;
86
+ try {
87
+ entries = fs.readdirSync(dir, { withFileTypes: true });
88
+ } catch {
89
+ return out;
90
+ }
91
+ for (const e of entries) {
92
+ const p = path.join(dir, e.name);
93
+ if (e.isDirectory()) {
94
+ walkFiles(p, ext, out);
95
+ } else if (e.isFile() && e.name.toLowerCase().endsWith(ext)) {
96
+ out.push(p);
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * 提取元素的属性表。刻意使用正则而非完整 XML 解析:
104
+ * 保持零依赖,且这些 drawable 结构简单、属性均为单行键值。
105
+ */
106
+ function attrsOf(tagText) {
107
+ const out = {};
108
+ for (const m of tagText.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) {
109
+ out[m[1]] = m[2];
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /** 取出所有指定标签的完整起始标签文本 */
115
+ function tagsOf(xml, tag) {
116
+ const re = new RegExp(`<${tag}\\b[^>]*?/?>`, 'g');
117
+ return Array.from(xml.matchAll(re), (m) => m[0]);
118
+ }
119
+
120
+ /**
121
+ * 去掉 `<clipPath>…</clipPath>` 整段,使其内部的 `<path>` 不被当作绘制路径。
122
+ * 裁剪几何只定义形状,不参与 fill/stroke 比对;若保留会让路径索引错位。
123
+ */
124
+ function stripClipInnerPaths(xml) {
125
+ return xml.replace(/<clipPath\b[\s\S]*?<\/clipPath>/g, '');
126
+ }
127
+
128
+ /** 归一化颜色为小写 #rrggbb / #aarrggbb,便于比较 */
129
+ function normColor(c) {
130
+ let v = c.trim().toLowerCase();
131
+ if (!v.startsWith('#')) return v; // @color/... 或 ?attr/... 等引用,交由上层判断
132
+ v = v.slice(1);
133
+ // #rgb -> #rrggbb
134
+ if (v.length === 3) v = v.split('').map((ch) => ch + ch).join('');
135
+ // #argb -> #aarrggbb
136
+ else if (v.length === 4) v = v.split('').map((ch) => ch + ch).join('');
137
+ return `#${v}`;
138
+ }
139
+
140
+ /** 从 #aarrggbb 取 rgb 部分;其它长度原样返回 */
141
+ function rgbPart(c) {
142
+ const v = normColor(c);
143
+ if (v.length === 9) return `#${v.slice(3)}`;
144
+ return v;
145
+ }
146
+
147
+ /**
148
+ * 判定 pathData 是否覆盖整个 viewport。
149
+ *
150
+ * 用「绝对坐标包围盒」而非匹配书写形态:同一个全幅矩形有多种等价写法
151
+ * (`M0 0h16v16H0z`、`M16 16H0V0h16z`、逆序绕向等),逐字符匹配会把等价写法误判成子区域。
152
+ * 因此累加 M/L/H/V/h/v/l/m 的位移得到实际包围盒,再与 viewport 比较。
153
+ *
154
+ * 仅支持直线段指令 —— 裁剪路径几乎总是矩形;出现曲线指令时保守返回 false
155
+ * (判为子区域,宁可误报为 error 也不放过真实的几何裁剪)。
156
+ */
157
+ function isFullViewportRect(pathData, vw, vh) {
158
+ if (vw <= 0 || vh <= 0) return false;
159
+ if (/[CcSsQqTtAa]/.test(pathData)) return false; // 含曲线,保守处理
160
+
161
+ let x = 0;
162
+ let y = 0;
163
+ let minX = Infinity;
164
+ let minY = Infinity;
165
+ let maxX = -Infinity;
166
+ let maxY = -Infinity;
167
+ let seen = false;
168
+
169
+ const mark = () => {
170
+ minX = Math.min(minX, x);
171
+ minY = Math.min(minY, y);
172
+ maxX = Math.max(maxX, x);
173
+ maxY = Math.max(maxY, y);
174
+ seen = true;
175
+ };
176
+
177
+ // 按「指令 + 其后的数值序列」切分
178
+ const tokens = pathData.matchAll(/([MmLlHhVvZz])([^MmLlHhVvZzCcSsQqTtAa]*)/g);
179
+ for (const t of tokens) {
180
+ const cmd = t[1];
181
+ const nums = Array.from(t[2].matchAll(/-?\d*\.?\d+/g), (m) => parseFloat(m[0]));
182
+ switch (cmd) {
183
+ case 'M':
184
+ case 'L':
185
+ for (let i = 0; i + 1 < nums.length; i += 2) {
186
+ x = nums[i];
187
+ y = nums[i + 1];
188
+ mark();
189
+ }
190
+ break;
191
+ case 'm':
192
+ case 'l':
193
+ for (let i = 0; i + 1 < nums.length; i += 2) {
194
+ x += nums[i];
195
+ y += nums[i + 1];
196
+ mark();
197
+ }
198
+ break;
199
+ case 'H':
200
+ for (const n of nums) {
201
+ x = n;
202
+ mark();
203
+ }
204
+ break;
205
+ case 'h':
206
+ for (const n of nums) {
207
+ x += n;
208
+ mark();
209
+ }
210
+ break;
211
+ case 'V':
212
+ for (const n of nums) {
213
+ y = n;
214
+ mark();
215
+ }
216
+ break;
217
+ case 'v':
218
+ for (const n of nums) {
219
+ y += n;
220
+ mark();
221
+ }
222
+ break;
223
+ default: // Z/z 闭合,不改变包围盒
224
+ break;
225
+ }
226
+ }
227
+
228
+ if (!seen) return false;
229
+ const tol = 0.5;
230
+ return (
231
+ Math.abs(minX) <= tol &&
232
+ Math.abs(minY) <= tol &&
233
+ Math.abs(maxX - vw) <= tol &&
234
+ Math.abs(maxY - vh) <= tol
235
+ );
236
+ }
237
+
238
+ // ─────────────────────────────────────────────
239
+ // 单文件比对
240
+ // ─────────────────────────────────────────────
241
+
242
+ function checkPair(name, androidFile, svgFile) {
243
+ const findings = [];
244
+ const ax = fs.readFileSync(androidFile, 'utf-8');
245
+ const sv = fs.readFileSync(svgFile, 'utf-8');
246
+
247
+ // ── 0. INVALID_PATH_DATA ─────────────────────────────
248
+ // `android:pathData` 可以写成 `@string/xxx` 引用(AppCompat 的密码可见性图标
249
+ // 就是这么写的)。若转换器把它原样搬进 SVG 的 `d`,产出的文件无法渲染,
250
+ // 而其余各条规则只比对「存在与否」和数量,全都发现不了。
251
+ // 实测 15 份已提交补丁中有 9 份带这类缺陷,正是因为此前缺这条检查。
252
+ for (const m of sv.matchAll(/\s(d|points)="([^"]*)"/g)) {
253
+ const v = m[2].trim();
254
+ if (v === '' || /^[MmZzLlHhVvCcSsQqTtAa][\s\d.,+eE-]*/.test(v)) continue;
255
+ findings.push({
256
+ rule: 'INVALID_PATH_DATA',
257
+ severity: 'error',
258
+ name,
259
+ androidFile,
260
+ svgFile,
261
+ androidEvidence: 'android:pathData is a resource reference, not literal path data',
262
+ svgEvidence: `${m[1]}="${truncate(v, 48)}"`,
263
+ impact: 'SVG path data is not renderable; the shape silently disappears at runtime.',
264
+ });
265
+ }
266
+
267
+ const vectorTag = tagsOf(ax, 'vector')[0] ?? '';
268
+ const va = attrsOf(vectorTag);
269
+ const vw = parseFloat(va['android:viewportWidth'] ?? '0');
270
+ const vh = parseFloat(va['android:viewportHeight'] ?? '0');
271
+
272
+ // ── 1. VIEWBOX_MISMATCH ──────────────────────────────
273
+ const svgTag = tagsOf(sv, 'svg')[0] ?? '';
274
+ const sa = attrsOf(svgTag);
275
+ const viewBox = (sa['viewBox'] ?? '').trim();
276
+ if (vw > 0 && vh > 0) {
277
+ const parts = viewBox.split(/[\s,]+/).map(parseFloat);
278
+ const ok =
279
+ parts.length === 4 &&
280
+ Math.abs(parts[2] - vw) <= 0.01 &&
281
+ Math.abs(parts[3] - vh) <= 0.01;
282
+ if (!ok) {
283
+ findings.push({
284
+ rule: 'VIEWBOX_MISMATCH',
285
+ severity: 'error',
286
+ name,
287
+ androidFile,
288
+ svgFile,
289
+ androidEvidence: `viewport=${vw}x${vh}`,
290
+ svgEvidence: viewBox ? `viewBox="${viewBox}"` : 'viewBox missing',
291
+ impact: 'Whole icon is scaled or cropped relative to the source artwork.',
292
+ });
293
+ }
294
+ }
295
+
296
+ // ── 2. CLIP_PATH_LOST ────────────────────────────────
297
+ const androidClips = tagsOf(ax, 'clip-path');
298
+ const svgClipDefs = tagsOf(sv, 'clipPath');
299
+ if (androidClips.length > svgClipDefs.length) {
300
+ // 判定每个丢失的 clip 是全幅还是子区域:子区域 => error,全幅 => warn
301
+ let worst = 'warn';
302
+ const details = [];
303
+ for (const c of androidClips) {
304
+ const pd = attrsOf(c)['android:pathData'] ?? '';
305
+ const full = isFullViewportRect(pd, vw, vh);
306
+ if (!full) worst = 'error';
307
+ details.push(`${truncate(pd, 34)}${full ? ' [full-viewport]' : ' [sub-region]'}`);
308
+ }
309
+ findings.push({
310
+ rule: 'CLIP_PATH_LOST',
311
+ severity: worst,
312
+ name,
313
+ androidFile,
314
+ svgFile,
315
+ androidEvidence: `${androidClips.length} clip-path: ${details.join(' | ')}`,
316
+ svgEvidence: `${svgClipDefs.length} clipPath in SVG`,
317
+ impact:
318
+ worst === 'error'
319
+ ? 'Sub-region clip dropped: artwork lands at wrong offset/scale, or a flood-filled path renders as a solid rectangle.'
320
+ : 'Full-viewport clip dropped: visually equivalent here, but the clip pipeline is not preserving clips.',
321
+ });
322
+ }
323
+
324
+ // ── 3. GRADIENT_LOST ─────────────────────────────────
325
+ // Android 用 <aapt:attr name="android:fillColor"><gradient> 内联渐变;
326
+ // SVG 需要 <defs><linearGradient|radialGradient> + fill="url(#id)"。
327
+ // 转换器若不支持 aapt:attr,往往退化成纯色(且常是注入的黑色)。
328
+ const aGradients = tagsOf(ax, 'gradient');
329
+ if (aGradients.length > 0) {
330
+ const svgGradDefs =
331
+ tagsOf(sv, 'linearGradient').length + tagsOf(sv, 'radialGradient').length;
332
+ if (svgGradDefs < aGradients.length) {
333
+ const stops = Array.from(ax.matchAll(/android:color="(#[0-9a-fA-F]+)"/g), (m) => m[1]);
334
+ const urlRefs = (sv.match(/fill="url\(#[^)]*\)"/g) ?? []).length;
335
+ findings.push({
336
+ rule: 'GRADIENT_LOST',
337
+ severity: 'error',
338
+ name,
339
+ androidFile,
340
+ svgFile,
341
+ androidEvidence: `${aGradients.length} <gradient> via aapt:attr, stops: ${stops.slice(0, 4).join(', ')}${stops.length > 4 ? ' …' : ''}`,
342
+ svgEvidence: `${svgGradDefs} gradient defs, ${urlRefs} fill="url(#…)" refs`,
343
+ impact:
344
+ 'Gradient collapsed to a flat color (often an injected black), losing the color ramp entirely.',
345
+ });
346
+ }
347
+ }
348
+
349
+ // ── 4/5. FILL_INJECTED / FILL_LOST ───────────────────
350
+ // 按出现顺序配对「绘制用」path。两侧都必须先剔除裁剪定义内部的 path:
351
+ // - Android: <clip-path> 是独立元素,不会被 tagsOf(ax,'path') 匹配到,
352
+ // 但 <clipPath> 形式(若转换器回写)需排除
353
+ // - SVG: <defs><clipPath><path/></clipPath></defs> 里的 path 只定义裁剪几何,
354
+ // 不参与填充;若不剔除会导致索引整体错位,把正确文件误报成 FILL_INJECTED
355
+ const aPaints = stripClipInnerPaths(ax);
356
+ const sPaints = stripClipInnerPaths(sv);
357
+ const aPaths = tagsOf(aPaints, 'path').map(attrsOf);
358
+ const sPaintPaths = tagsOf(sPaints, 'path').map(attrsOf);
359
+
360
+ const pairable = aPaths.length > 0 && aPaths.length <= sPaintPaths.length;
361
+
362
+ // 渐变 path 在 Android 侧写作 `<path …><aapt:attr …><gradient/></aapt:attr></path>`,
363
+ // 其 fillColor 由子元素提供而非属性;这类 path 交由 GRADIENT_LOST 处理,
364
+ // 不能按「无 fillColor」判断,否则会与描边-only 混淆。
365
+ const gradientPathIdx = new Set();
366
+ {
367
+ const withGrad = Array.from(
368
+ aPaints.matchAll(/<path\b[^>]*>[\s\S]*?<\/path>/g),
369
+ (m) => m[0],
370
+ ).filter((t) => /<gradient\b/.test(t));
371
+ const allOpen = tagsOf(aPaints, 'path');
372
+ for (const g of withGrad) {
373
+ const open = g.match(/<path\b[^>]*?>/)?.[0] ?? '';
374
+ const at = allOpen.indexOf(open);
375
+ if (at >= 0) gradientPathIdx.add(at);
376
+ }
377
+ }
378
+
379
+ for (let i = 0; i < aPaths.length; i++) {
380
+ if (gradientPathIdx.has(i)) continue; // 渐变已由 GRADIENT_LOST 覆盖
381
+ const ap = aPaths[i];
382
+ const aFill = ap['android:fillColor'];
383
+ const aStroke = ap['android:strokeColor'];
384
+ const sp = pairable ? sPaintPaths[i] : undefined;
385
+ if (!sp) break;
386
+ const sFill = sp['fill'];
387
+
388
+ const strokeOnly = !aFill && !!aStroke;
389
+ if (strokeOnly) {
390
+ // 必须显式 fill="none";具体颜色即为注入错误
391
+ const isNone = (sFill ?? '').trim().toLowerCase() === 'none';
392
+ if (!isNone) {
393
+ findings.push({
394
+ rule: 'FILL_INJECTED',
395
+ severity: 'error',
396
+ name,
397
+ androidFile,
398
+ svgFile,
399
+ androidEvidence: `path[${i}] strokeColor=${aStroke}, no fillColor (stroke-only)`,
400
+ svgEvidence: sFill === undefined ? 'fill attribute absent' : `fill="${sFill}"`,
401
+ impact:
402
+ 'Stroke-only outline is filled solid. SVG defaults fill to black, so fill="none" must be explicit.',
403
+ });
404
+ }
405
+ } else if (aFill) {
406
+ // 显式 fill 必须存在且颜色一致(忽略 alpha 通道差异,alpha 可能拆到 fill-opacity)
407
+ if (aFill.startsWith('#')) {
408
+ const want = rgbPart(aFill);
409
+ const got = sFill ? rgbPart(sFill) : '';
410
+ if (!sFill || (got !== want && normColor(sFill) !== normColor(aFill))) {
411
+ findings.push({
412
+ rule: 'FILL_LOST',
413
+ severity: 'error',
414
+ name,
415
+ androidFile,
416
+ svgFile,
417
+ androidEvidence: `path[${i}] fillColor=${aFill}`,
418
+ svgEvidence: sFill === undefined ? 'fill attribute absent' : `fill="${sFill}"`,
419
+ impact: 'Explicit fill color lost or altered; shape renders in the wrong color.',
420
+ });
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ return findings;
427
+ }
428
+
429
+ // ─────────────────────────────────────────────
430
+ // 主流程
431
+ // ─────────────────────────────────────────────
432
+
433
+ function run(androidRes, harmonyMedia) {
434
+ if (!fs.existsSync(androidRes)) fail(`--android-res not found: ${androidRes}`);
435
+ if (!fs.existsSync(harmonyMedia)) fail(`--harmony-media not found: ${harmonyMedia}`);
436
+
437
+ // 收集所有 drawable*/ 下根元素为 <vector> 的 XML
438
+ const xmls = walkFiles(androidRes, '.xml').filter((p) => {
439
+ const rel = path.relative(androidRes, p).replace(/\\/g, '/');
440
+ const isDrawable = /(^|\/)drawable[^/]*\//.test(rel) || /(^|\/)mipmap[^/]*\//.test(rel);
441
+ if (!isDrawable) return false;
442
+ // --harmony-media 指向的是**单个**限定符目录(通常是 base/media),
443
+ // 因此只有会落到该目录的 Android 源才能拿来配对。
444
+ // `drawable-night/x.xml` 转出的是 `dark/media/x.svg`;拿它跟 `base/media/x.svg`
445
+ // 比会误报 FILL_LOST——同名图标的浅色版与深色版本来就该是不同颜色。
446
+ // 能落到 base 的限定符只有:无限定符、anydpi、nodpi、v<N>。
447
+ const dir = rel.split('/').find((seg) => seg.startsWith('drawable') || seg.startsWith('mipmap')) ?? '';
448
+ return dir.split('-').slice(1).every((q) => q === 'anydpi' || q === 'nodpi' || /^v\d+$/.test(q));
449
+ });
450
+
451
+ const svgIndex = new Map();
452
+ for (const s of walkFiles(harmonyMedia, '.svg')) {
453
+ svgIndex.set(path.basename(s, '.svg'), s);
454
+ }
455
+
456
+ const findings = [];
457
+ const unpaired = [];
458
+ let vectorsFound = 0;
459
+ let pairsCompared = 0;
460
+
461
+ for (const xml of xmls) {
462
+ let text;
463
+ try {
464
+ text = fs.readFileSync(xml, 'utf-8');
465
+ } catch {
466
+ continue;
467
+ }
468
+ if (!/<vector\b/.test(text)) continue; // 只校验 vector,shape 由 ShapeDrawable 规则覆盖
469
+ vectorsFound++;
470
+
471
+ const name = path.basename(xml, '.xml');
472
+ // `<adaptive-icon>` 里内联的 `<vector>` 不会转成 `<name>.svg`,而是转成
473
+ // `<name>_foreground.svg` / `<name>_background.svg`(layered-image 只接受 $media:)。
474
+ // 不认这两个名字的话,这些图形就成了「unpaired」,等于没进保真度门禁。
475
+ const svg = svgIndex.get(name)
476
+ ?? (/<adaptive-icon\b/.test(text)
477
+ ? svgIndex.get(`${name}_foreground`) ?? svgIndex.get(`${name}_background`)
478
+ : undefined);
479
+ if (!svg) {
480
+ unpaired.push(name);
481
+ continue;
482
+ }
483
+ pairsCompared++;
484
+ findings.push(...checkPair(name, xml, svg));
485
+ }
486
+
487
+ const counts = {};
488
+ for (const f of findings) {
489
+ counts[f.rule] = (counts[f.rule] ?? 0) + 1;
490
+ counts[f.severity] = (counts[f.severity] ?? 0) + 1;
491
+ }
492
+
493
+ return { androidRes, harmonyMedia, vectorsFound, pairsCompared, unpaired, findings, counts };
494
+ }
495
+
496
+ function printReport(r, quiet) {
497
+ const errors = r.findings.filter((f) => f.severity === 'error');
498
+ const warns = r.findings.filter((f) => f.severity === 'warn');
499
+
500
+ console.log('');
501
+ console.log('Vector → SVG fidelity check (static)');
502
+ console.log(` android res : ${r.androidRes}`);
503
+ console.log(` harmony media : ${r.harmonyMedia}`);
504
+ console.log(` vectors found : ${r.vectorsFound}`);
505
+ console.log(` pairs compared : ${r.pairsCompared}`);
506
+ if (r.unpaired.length > 0) {
507
+ console.log(` unpaired : ${r.unpaired.length} (no same-name .svg; not an error by itself)`);
508
+ }
509
+ console.log(` errors : ${errors.length}`);
510
+ console.log(` warnings : ${warns.length}`);
511
+
512
+ if (r.findings.length === 0) {
513
+ // 区分「检查过且都通过」与「什么都没检查」——后者不是通过,
514
+ // 静默的空结果看起来像成功,正是本工具要消除的那类假信号。
515
+ if (r.pairsCompared === 0) {
516
+ console.log(
517
+ r.vectorsFound === 0
518
+ ? '\n NOTHING CHECKED: no <vector> drawables found under --android-res.' +
519
+ '\n Verify the path points at an Android res/ directory (expects drawable*/ subdirs).'
520
+ : '\n NOTHING CHECKED: found vectors but none had a same-name .svg under --harmony-media.' +
521
+ '\n Verify --harmony-media points at the converted resources/base/media/ directory.',
522
+ );
523
+ return;
524
+ }
525
+ console.log(`\n All ${r.pairsCompared} compared vectors are semantically faithful.`);
526
+ return;
527
+ }
528
+
529
+ // 按规则聚合,便于一眼看出是系统性问题还是个别文件
530
+ const byRule = new Map();
531
+ for (const f of r.findings) {
532
+ const arr = byRule.get(f.rule) ?? [];
533
+ arr.push(f);
534
+ byRule.set(f.rule, arr);
535
+ }
536
+
537
+ for (const [rule, list] of byRule) {
538
+ const files = new Set(list.map((f) => f.name)).size;
539
+ const scope =
540
+ list.length === files
541
+ ? `${files} file${files > 1 ? 's' : ''}`
542
+ : `${list.length} findings in ${files} file${files > 1 ? 's' : ''}`;
543
+ const errs = list.filter((f) => f.severity === 'error').length;
544
+ const wrns = list.length - errs;
545
+ const mix = errs > 0 && wrns > 0 ? `, ${errs} error / ${wrns} warn` : '';
546
+ console.log(`\n ${rule} (${scope}${mix})`);
547
+ // impact 随 severity 不同,取最严重的一条作为代表,避免用 warn 的措辞概括 error
548
+ const worst = list.find((f) => f.severity === 'error') ?? list[0];
549
+ console.log(` impact: ${worst.impact}`);
550
+ // error 优先展示,确保 --quiet 截断时不会只剩 warn
551
+ const ordered = [...list].sort(
552
+ (a, b) => (a.severity === b.severity ? 0 : a.severity === 'error' ? -1 : 1),
553
+ );
554
+ const show = quiet ? ordered.slice(0, 5) : ordered;
555
+ for (const f of show) {
556
+ console.log(` - ${f.name} [${f.severity}]`);
557
+ console.log(` android: ${f.androidEvidence}`);
558
+ console.log(` svg : ${f.svgEvidence}`);
559
+ }
560
+ if (show.length < list.length) {
561
+ console.log(` … ${list.length - show.length} more (omit --quiet to list all)`);
562
+ }
563
+ }
564
+
565
+ console.log('\n Fix per ../references/xml-drawable-to-svg-rules.md, then re-run.');
566
+ }
567
+
568
+ function main(argv) {
569
+ let androidRes = '';
570
+ let harmonyMedia = '';
571
+ let jsonOut = '';
572
+ let strict = false;
573
+ let quiet = false;
574
+
575
+ for (let i = 0; i < argv.length; i++) {
576
+ const a = argv[i];
577
+ if (a === '--android-res') androidRes = argv[++i] ?? '';
578
+ else if (a === '--harmony-media') harmonyMedia = argv[++i] ?? '';
579
+ else if (a === '--json') jsonOut = argv[++i] ?? '';
580
+ else if (a === '--strict') strict = true;
581
+ else if (a === '--quiet') quiet = true;
582
+ else if (a === '-h' || a === '--help') {
583
+ console.log(
584
+ [
585
+ 'Vector → SVG fidelity check (static; no device required)',
586
+ '',
587
+ 'Usage:',
588
+ ' node svg_fidelity_check.js --android-res <dir> --harmony-media <dir> [options]',
589
+ '',
590
+ 'Required:',
591
+ ' --android-res <dir> Android res/ directory (scans drawable*/ and mipmap*/ for <vector>)',
592
+ ' --harmony-media <dir> HarmonyOS resources/base/media/ directory holding converted .svg',
593
+ '',
594
+ 'Options:',
595
+ ' --json <path> Write the full findings report as JSON',
596
+ ' --strict Treat warnings as failures (exit 1)',
597
+ ' --quiet Cap per-rule listings to 5 entries',
598
+ ' -h, --help Show this help',
599
+ '',
600
+ 'Checks: INVALID_PATH_DATA, CLIP_PATH_LOST, FILL_INJECTED, FILL_LOST, GRADIENT_LOST, VIEWBOX_MISMATCH',
601
+ 'Exit: 0 clean, 1 findings, 2 usage/path error',
602
+ ].join('\n'),
603
+ );
604
+ process.exit(0);
605
+ } else {
606
+ fail(`unknown argument: ${a} (try --help)`);
607
+ }
608
+ }
609
+
610
+ if (!androidRes || !harmonyMedia) {
611
+ fail('--android-res and --harmony-media are both required (try --help)');
612
+ }
613
+
614
+ const report = run(path.resolve(androidRes), path.resolve(harmonyMedia));
615
+ printReport(report, quiet);
616
+
617
+ if (jsonOut) {
618
+ fs.mkdirSync(path.dirname(path.resolve(jsonOut)), { recursive: true });
619
+ fs.writeFileSync(path.resolve(jsonOut), JSON.stringify(report, null, 2), 'utf-8');
620
+ console.log(`\n JSON report: ${jsonOut}`);
621
+ }
622
+
623
+ const errors = report.findings.filter((f) => f.severity === 'error').length;
624
+ const warns = report.findings.filter((f) => f.severity === 'warn').length;
625
+ process.exit(errors > 0 || (strict && warns > 0) ? 1 : 0);
626
+ }
627
+
628
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
629
+ main(process.argv.slice(2));
630
+ }
631
+
632
+ export { run, checkPair, };