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,2166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Android → HarmonyOS 资源转换器(规范实现,第一期)
4
+ *
5
+ * 本脚本是 a2h-resource-convert skill 的**唯一**转换实现。agent 不再自行编写
6
+ * 转换脚本——历史上每个 cell 各写一套(10 个 cell、3 种语言、约 1 万行),
7
+ * 导致同一份 APK 跑出互不相同的结果、计数口径不一致、部分 cell 还引入了
8
+ * 浮动版本的 npm 依赖。
9
+ *
10
+ * 覆盖范围(第一期):
11
+ * - values/*.xml → base/element/*.json(string / color / float / integer /
12
+ * boolean / strarray / intarray / plural)
13
+ * - 限定符目录映射(密度、语言地区、横竖屏、深色、API 级别剥离、屏幕尺寸跳过)
14
+ * - 媒体文件直拷 + 点九图重命名(.9.png → _9.png)
15
+ * - raw/ → rawfile/,font/ → rawfile/fonts/,xml/ → base/profile/
16
+ * - 不可映射类型(layout / menu / anim / animator / color / interpolator …)留记录
17
+ * - inventory + mapping 记录(条目级),供报告与 mapping 文档使用
18
+ *
19
+ * 尚未覆盖(后续期次,当前只登记为 pending,不静默丢弃):
20
+ * - XML drawable → SVG(vector / shape / layer-list / selector)
21
+ * - adaptive-icon → layered-image JSON + 纯色 PNG
22
+ * - 引用解析链、系统资源表、fallback
23
+ *
24
+ * 零第三方依赖,仅使用 Node 内置模块。纯 JavaScript(ESM),直接 node 运行。
25
+ *
26
+ * 用法:
27
+ * node a2h_resource_convert.js --res <decoded res dir> --harmony <harmony project dir>
28
+ * [--records <path>] [--dry-run] [--quiet]
29
+ *
30
+ * 退出码:
31
+ * 0 转换完成(pending 项不算失败,但会计数并在 records 中留行)
32
+ * 1 存在转换失败项
33
+ * 2 用法错误 / 路径不存在
34
+ */
35
+
36
+ import * as fs from 'node:fs';
37
+ import * as path from 'node:path';
38
+ import * as zlib from 'node:zlib';
39
+
40
+ // ─────────────────────────────────────────────
41
+ // 常量表
42
+ // ─────────────────────────────────────────────
43
+
44
+ /** Android 密度限定符 → HarmonyOS 密度限定符。见 conversion-rules.md「Density Qualifiers」。 */
45
+ const DENSITY = {
46
+ ldpi: 'sdpi', mdpi: 'mdpi', tvdpi: 'ldpi', hdpi: 'ldpi',
47
+ xhdpi: 'xldpi', xxhdpi: 'xxldpi', xxxhdpi: 'xxxldpi',
48
+ nodpi: null, anydpi: null, // → base,不产生限定符
49
+ };
50
+
51
+ const ORIENTATION = { land: 'horizontal', port: 'vertical' };
52
+ const COLORMODE = { night: 'dark', notnight: 'light' };
53
+
54
+ /** 静默剥离:无 HarmonyOS 对应物,但不影响资源本身的适用性。 */
55
+ const STRIP_RE = /^(v\d+|ldrtl|ldltr|round|notround|highdr|lowdr|long|notlong)$/;
56
+
57
+ /** 整目录跳过:屏幕尺寸类限定符,HarmonyOS 无等价物,转过去会是错的。 */
58
+ const SKIP_RE = /^(sw\d+dp|w\d+dp|h\d+dp|small|normal|large|xlarge)$/;
59
+
60
+ /** 设备类型限定符 → HarmonyOS 设备限定符。 */
61
+ const DEVICE = {
62
+ watch: 'wearable', television: 'tv', car: 'car', desk: 'phone', appliance: 'phone',
63
+ };
64
+
65
+ const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
66
+ const FONT_EXT = new Set(['.ttf', '.otf', '.ttc']);
67
+
68
+ /** 目录类型 → 处理方式。 */
69
+ const UNMAPPABLE_TYPES = new Set([
70
+ 'layout', 'menu', 'anim', 'animator', 'color', 'interpolator',
71
+ 'transition', 'navigation', 'id', 'ids',
72
+ ]);
73
+
74
+ /**
75
+ * Android 框架内置颜色。见 SKILL.md Step 5.2。
76
+ * HarmonyOS 没有 Android 系统资源概念,`@android:` 引用**绝不能**留在输出里。
77
+ */
78
+ const ANDROID_SYSTEM_COLORS = {
79
+ white: '#ffffffff', black: '#ff000000', transparent: '#00000000',
80
+ background_dark: '#ff000000', background_light: '#ffffffff', darker_gray: '#ffaaaaaa',
81
+ holo_blue_bright: '#ff00ddff', holo_blue_dark: '#ff0099cc', holo_blue_light: '#ff33b5e5',
82
+ holo_green_dark: '#ff669900', holo_green_light: '#ff99cc00',
83
+ holo_orange_dark: '#ffff8800', holo_orange_light: '#ffffbb33',
84
+ holo_purple: '#ffaa66cc', holo_red_dark: '#ffcc0000', holo_red_light: '#ffff4444',
85
+ primary_text_dark: '#ffffffff', primary_text_light: '#de000000',
86
+ secondary_text_dark: '#b3ffffff', secondary_text_light: '#8a000000',
87
+ tab_indicator_text: '#ff808080',
88
+ tertiary_text_dark: '#80ffffff', tertiary_text_light: '#54000000',
89
+ widget_edittext_dark: '#ff000000',
90
+ };
91
+
92
+ /** Android 框架内置尺寸。见 SKILL.md Step 5.2。 */
93
+ const ANDROID_SYSTEM_DIMENS = {
94
+ app_icon_size: '48vp',
95
+ thumbnail_width: '96vp',
96
+ thumbnail_height: '96vp',
97
+ dialog_min_width_major: '65%',
98
+ dialog_min_width_minor: '95%',
99
+ notification_large_icon_width: '64vp',
100
+ notification_large_icon_height: '64vp',
101
+ };
102
+
103
+ /** Android 框架内置字符串(英文取值)。 */
104
+ const ANDROID_SYSTEM_STRINGS = {
105
+ ok: 'OK', cancel: 'Cancel', yes: 'Yes', no: 'No',
106
+ copy: 'Copy', cut: 'Cut', paste: 'Paste', selectAll: 'Select all',
107
+ search_go: 'Search', unknownName: 'Unknown',
108
+ };
109
+
110
+ /** 无法解析时按类型使用的兜底值。见 SKILL.md Step 5.2「Fallback values by type」。 */
111
+ const FALLBACK_BY_KEY = {
112
+ color: '#ff000000', float: '0vp', string: '', integer: 0, boolean: false,
113
+ };
114
+
115
+ /**
116
+ * 解码后的 APK 里,枚举属性是数字而不是名字(aapt 编译后的值)。
117
+ * 直接把数字写进 SVG 会得到非法的 `stroke-linecap="2"`。
118
+ */
119
+ const STROKE_LINECAP = ['butt', 'round', 'square'];
120
+ const STROKE_LINEJOIN = ['miter', 'round', 'bevel'];
121
+
122
+ /** Android 尺寸单位 → HarmonyOS 单位 / 换算系数。 */
123
+ const DIMEN_UNITS = [
124
+ { re: /^(-?[\d.]+)dip$/i, unit: 'vp', factor: 1 },
125
+ { re: /^(-?[\d.]+)dp$/i, unit: 'vp', factor: 1 },
126
+ { re: /^(-?[\d.]+)sp$/i, unit: 'fp', factor: 1 },
127
+ { re: /^(-?[\d.]+)px$/i, unit: 'px', factor: 1 },
128
+ { re: /^(-?[\d.]+)pt$/i, unit: 'vp', factor: 1.33 },
129
+ { re: /^(-?[\d.]+)in$/i, unit: 'vp', factor: 160 },
130
+ { re: /^(-?[\d.]+)mm$/i, unit: 'vp', factor: 6.3 },
131
+ { re: /^(-?[\d.]+)$/, unit: 'vp', factor: 1 },
132
+ ];
133
+
134
+ /** Android values 标签 → HarmonyOS element 文件名与 JSON 根键。 */
135
+ const ELEMENT_KIND = {
136
+ string: { file: 'string.json', key: 'string' },
137
+ color: { file: 'color.json', key: 'color' },
138
+ dimen: { file: 'float.json', key: 'float' },
139
+ integer: { file: 'integer.json', key: 'integer' },
140
+ bool: { file: 'boolean.json', key: 'boolean' },
141
+ 'string-array': { file: 'strarray.json', key: 'strarray' },
142
+ 'integer-array': { file: 'intarray.json', key: 'intarray' },
143
+ plurals: { file: 'plural.json', key: 'plural' },
144
+ };
145
+
146
+ /** 这些 values 标签在 HarmonyOS 没有等价物,登记为 unmappable。 */
147
+ const VALUES_UNMAPPABLE = new Set([
148
+ 'style', 'declare-styleable', 'attr', 'item', 'eat-comment', 'skip', 'fraction', 'macro',
149
+ ]);
150
+
151
+ // ─────────────────────────────────────────────
152
+ // 类型
153
+ // ─────────────────────────────────────────────
154
+
155
+ // interface XmlNode {
156
+ // name: string;
157
+ // attrs: Record<string, string>;
158
+ // children: XmlNode[];
159
+ // /** 元素内部的原始源码片段(含子标签),字符串资源需要它 */
160
+ // inner: string;
161
+ // }
162
+
163
+ /** Android 资源清单行。字段对齐 references/resource-mapping-rules.md §2.1。 */
164
+ // interface InventoryRecord {
165
+ // androidResourcePath: string;
166
+ // androidResourceName: string;
167
+ // function: string;
168
+ // screens: string;
169
+ // sourceCategory: string;
170
+ // typeCategory: string;
171
+ // status: string;
172
+ // notes: string;
173
+ // }
174
+
175
+ /** Android ↔ HarmonyOS 映射行。字段对齐 §2.2。 */
176
+ // interface MappingRecord {
177
+ // androidResourcePath: string;
178
+ // androidScreens: string;
179
+ // androidSourceCategory: string;
180
+ // androidTypeCategory: string;
181
+ // harmonyTarget: string;
182
+ // mappingKind: string;
183
+ // notes: string;
184
+ // }
185
+
186
+ // interface QualifierPlan {
187
+ // /** HarmonyOS 限定符目录名,`base` 表示无限定符 */
188
+ // dir: string;
189
+ // /** 整目录跳过时的原因 */
190
+ // skip: string | null;
191
+ // stripped: string[];
192
+ // }
193
+
194
+ // ─────────────────────────────────────────────
195
+ // 工具
196
+ // ─────────────────────────────────────────────
197
+
198
+ function fail(msg) {
199
+ console.error(`[a2h_resource_convert] ${msg}`);
200
+ process.exit(2);
201
+ }
202
+
203
+ const ENTITIES = {
204
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
205
+ };
206
+
207
+ function decodeEntities(s) {
208
+ return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {
209
+ if (body[0] === '#') {
210
+ const code = body[1] === 'x' || body[1] === 'X'
211
+ ? parseInt(body.slice(2), 16)
212
+ : parseInt(body.slice(1), 10);
213
+ return Number.isFinite(code) ? String.fromCodePoint(code) : m;
214
+ }
215
+ return ENTITIES[body] ?? m;
216
+ });
217
+ }
218
+
219
+ // ─────────────────────────────────────────────
220
+ // 极简 XML 解析器
221
+ // ─────────────────────────────────────────────
222
+
223
+ /**
224
+ * 只覆盖 Android 资源 XML 需要的语法:元素、属性、文本、CDATA、注释、
225
+ * 声明与 DOCTYPE。不做 DTD、实体声明、命名空间解析(属性名按原样保留,
226
+ * 调用方用 localName 比对)。
227
+ */
228
+ function parseXml(src) {
229
+ let i = 0;
230
+ const n = src.length;
231
+
232
+ function skipUntil(marker) {
233
+ const at = src.indexOf(marker, i);
234
+ i = at < 0 ? n : at + marker.length;
235
+ }
236
+
237
+ function parseAttrs(end) {
238
+ const attrs = {};
239
+ const re = /([^\s=/>]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
240
+ const slice = src.slice(i, end);
241
+ let m;
242
+ while ((m = re.exec(slice)) !== null) {
243
+ attrs[m[1]] = decodeEntities(m[3] ?? m[4] ?? '');
244
+ }
245
+ return attrs;
246
+ }
247
+
248
+ function parseNode() {
249
+ while (i < n) {
250
+ const lt = src.indexOf('<', i);
251
+ if (lt < 0) return null;
252
+ i = lt;
253
+ if (src.startsWith('<!--', i)) { skipUntil('-->'); continue; }
254
+ if (src.startsWith('<?', i)) { skipUntil('?>'); continue; }
255
+ if (src.startsWith('<!DOCTYPE', i) || src.startsWith('<!', i)) { skipUntil('>'); continue; }
256
+ if (src.startsWith('</', i)) return null;
257
+ break;
258
+ }
259
+ if (i >= n) return null;
260
+
261
+ const gt = src.indexOf('>', i);
262
+ if (gt < 0) return null;
263
+ const selfClosing = src[gt - 1] === '/';
264
+ const head = src.slice(i + 1, selfClosing ? gt - 1 : gt);
265
+ const name = head.split(/[\s/]/, 1)[0];
266
+ const attrs = parseAttrs(gt);
267
+ i = gt + 1;
268
+
269
+ const node = { name, attrs, children: [], inner: '' };
270
+ if (selfClosing) return node;
271
+
272
+ const innerStart = i;
273
+ // 逐段消费,直到遇到本元素的结束标签
274
+ while (i < n) {
275
+ const lt = src.indexOf('<', i);
276
+ if (lt < 0) { i = n; break; }
277
+ if (src.startsWith('<![CDATA[', lt)) {
278
+ i = lt; skipUntil(']]>'); continue;
279
+ }
280
+ if (src.startsWith('<!--', lt)) { i = lt; skipUntil('-->'); continue; }
281
+ if (src.startsWith(`</${name}`, lt)) {
282
+ node.inner = src.slice(innerStart, lt);
283
+ i = src.indexOf('>', lt);
284
+ i = i < 0 ? n : i + 1;
285
+ return node;
286
+ }
287
+ i = lt;
288
+ const child = parseNode();
289
+ if (child) node.children.push(child);
290
+ else { i = lt + 1; }
291
+ }
292
+ node.inner = src.slice(innerStart);
293
+ return node;
294
+ }
295
+
296
+ const root = parseNode();
297
+ return root ?? { name: '', attrs: {}, children: [], inner: '' };
298
+ }
299
+
300
+ /** 属性按 localName 取值(忽略 `android:` 之类的前缀)。 */
301
+ function attr(node, local) {
302
+ for (const [k, v] of Object.entries(node.attrs)) {
303
+ if (k === local || k.endsWith(`:${local}`)) return v;
304
+ }
305
+ return undefined;
306
+ }
307
+
308
+ // ─────────────────────────────────────────────
309
+ // 值转换
310
+ // ─────────────────────────────────────────────
311
+
312
+ /**
313
+ * Android 字符串字面量 → HarmonyOS 字符串值。
314
+ *
315
+ * 顺序很重要:先取 CDATA / 去标签,再解实体,再解 Android 反斜杠转义,
316
+ * 最后剥掉 Android 的成对外层引号(那对引号是用来保护首尾空白的语法,
317
+ * 不是文本内容——SKILL.md Step 6.11 专门校验这一点)。
318
+ */
319
+ function normalizeString(inner) {
320
+ let s = inner;
321
+ const cdata = [...s.matchAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g)];
322
+ if (cdata.length > 0) s = cdata.map((m) => m[1]).join('');
323
+ s = decodeEntities(s);
324
+ s = s.replace(/\\(['"@?\\])/g, '$1').replace(/\\n/g, '\n').replace(/\\t/g, '\t');
325
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) s = s.slice(1, -1);
326
+ return s;
327
+ }
328
+
329
+ /** `#RGB` / `#ARGB` / `#RRGGBB` / `#AARRGGBB` → `#AARRGGBB`(小写)。 */
330
+ function normalizeColor(raw) {
331
+ const v = raw.trim();
332
+ const m = /^#([0-9a-fA-F]{3,8})$/.exec(v);
333
+ if (!m) return null;
334
+ const h = m[1].toLowerCase();
335
+ if (h.length === 3) return `#ff${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`;
336
+ if (h.length === 4) return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
337
+ if (h.length === 6) return `#ff${h}`;
338
+ if (h.length === 8) return `#${h}`;
339
+ return null;
340
+ }
341
+
342
+ /** Android 尺寸 → HarmonyOS 尺寸字符串。无法解析时返回 null(调用方登记 fallback)。 */
343
+ function normalizeDimen(raw) {
344
+ const v = raw.trim();
345
+ for (const { re, unit, factor } of DIMEN_UNITS) {
346
+ const m = re.exec(v);
347
+ if (!m) continue;
348
+ const num = parseFloat(m[1]);
349
+ if (!Number.isFinite(num)) return null;
350
+ // 一律收敛到 3 位小数:resources.arsc 里的定点数解码后常带浮点噪声
351
+ // (例如 6.500001dip),原样输出会把噪声带进 HarmonyOS 资源。
352
+ const scaled = Math.round(num * factor * 1000) / 1000;
353
+ return `${scaled}${unit}`;
354
+ }
355
+ return null;
356
+ }
357
+
358
+ function normalizeInteger(raw) {
359
+ const v = raw.trim();
360
+ const parsed = /^0[xX]/.test(v) ? parseInt(v, 16) : parseInt(v, 10);
361
+ return Number.isFinite(parsed) ? parsed : null;
362
+ }
363
+
364
+ // ─────────────────────────────────────────────
365
+ // 纯色 PNG 生成(zlib + 手写 PNG 封装,避免引入 pngjs)
366
+ // ─────────────────────────────────────────────
367
+
368
+ let CRC_TABLE = null;
369
+
370
+ function crc32(buf) {
371
+ if (!CRC_TABLE) {
372
+ CRC_TABLE = new Int32Array(256);
373
+ for (let n = 0; n < 256; n++) {
374
+ let c = n;
375
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
376
+ CRC_TABLE[n] = c;
377
+ }
378
+ }
379
+ let c = -1;
380
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
381
+ return (c ^ -1) >>> 0;
382
+ }
383
+
384
+ function pngChunk(type, data) {
385
+ const len = Buffer.alloc(4);
386
+ len.writeUInt32BE(data.length, 0);
387
+ const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
388
+ const crc = Buffer.alloc(4);
389
+ crc.writeUInt32BE(crc32(body), 0);
390
+ return Buffer.concat([len, body, crc]);
391
+ }
392
+
393
+ /** 生成 size×size 的纯色 RGBA PNG。`#AARRGGBB` 输入。 */
394
+ function solidPng(argb, size = 108) {
395
+ const h = argb.replace('#', '');
396
+ const a = parseInt(h.slice(0, 2), 16);
397
+ const r = parseInt(h.slice(2, 4), 16);
398
+ const g = parseInt(h.slice(4, 6), 16);
399
+ const b = parseInt(h.slice(6, 8), 16);
400
+
401
+ const row = Buffer.alloc(1 + size * 4);
402
+ for (let x = 0; x < size; x++) {
403
+ row[1 + x * 4] = r; row[2 + x * 4] = g; row[3 + x * 4] = b; row[4 + x * 4] = a;
404
+ }
405
+ const raw = Buffer.concat(Array.from({ length: size }, () => row));
406
+
407
+ const ihdr = Buffer.alloc(13);
408
+ ihdr.writeUInt32BE(size, 0);
409
+ ihdr.writeUInt32BE(size, 4);
410
+ ihdr[8] = 8; // bit depth
411
+ ihdr[9] = 6; // color type RGBA
412
+ return Buffer.concat([
413
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
414
+ pngChunk('IHDR', ihdr),
415
+ pngChunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
416
+ pngChunk('IEND', Buffer.alloc(0)),
417
+ ]);
418
+ }
419
+
420
+ // ─────────────────────────────────────────────
421
+ // XML drawable → SVG
422
+ // ─────────────────────────────────────────────
423
+
424
+ // interface SvgPaint {
425
+ // color: string;
426
+ // opacity: number | null;
427
+ // }
428
+
429
+ /** `#AARRGGBB` / `#RRGGBB` / 简写 → SVG 的 `#RRGGBB` + 独立 opacity。 */
430
+ function androidColorToSvg(raw) {
431
+ const c = normalizeColor(raw);
432
+ if (!c) return null;
433
+ const h = c.slice(1);
434
+ const alpha = parseInt(h.slice(0, 2), 16) / 255;
435
+ return {
436
+ color: `#${h.slice(2)}`,
437
+ opacity: alpha >= 0.999 ? null : Math.round(alpha * 1000) / 1000,
438
+ };
439
+ }
440
+
441
+ /** `24dp` / `24dip` / `24` → `24`(SVG 用户单位)。 */
442
+ function svgNum(raw, fallback) {
443
+ if (!raw) return fallback;
444
+ const m = /^(-?[\d.]+)/.exec(raw.trim());
445
+ const v = m ? parseFloat(m[1]) : NaN;
446
+ return Number.isFinite(v) ? v : fallback;
447
+ }
448
+
449
+ function esc(s) {
450
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
451
+ }
452
+
453
+ // ─────────────────────────────────────────────
454
+ // 限定符目录解析
455
+ // ─────────────────────────────────────────────
456
+
457
+ /**
458
+ * `drawable-zh-rCN-night-hdpi` → { type: 'drawable', plan: { dir: 'zh_CN-dark-ldpi' } }
459
+ *
460
+ * 输出顺序遵循 HarmonyOS 约定:
461
+ * MCC_MNC-language_script_country-orientation-device-colormode-density
462
+ */
463
+ function planQualifiers(dirName) {
464
+ const segs = dirName.split('-');
465
+ const type = segs[0];
466
+ const rest = segs.slice(1);
467
+
468
+ let language = '';
469
+ let script = '';
470
+ let region = '';
471
+ let orientation = '';
472
+ let device = '';
473
+ let colormode = '';
474
+ let density = '';
475
+ const stripped = [];
476
+
477
+ for (let k = 0; k < rest.length; k++) {
478
+ const q = rest[k];
479
+ if (SKIP_RE.test(q)) {
480
+ return { type, plan: { dir: '', skip: `Unsupported qualifier: ${q} — no HarmonyOS equivalent`, stripped } };
481
+ }
482
+ if (STRIP_RE.test(q)) { stripped.push(q); continue; }
483
+ if (q in DENSITY) {
484
+ const d = DENSITY[q];
485
+ if (d) density = d; else stripped.push(q); // nodpi / anydpi → base
486
+ continue;
487
+ }
488
+ if (q in ORIENTATION) { orientation = ORIENTATION[q]; continue; }
489
+ if (q in COLORMODE) { colormode = COLORMODE[q]; continue; }
490
+ if (q in DEVICE) { device = DEVICE[q]; continue; }
491
+ if (/^b\+/.test(q)) { // BCP 47:b+sr+Latn
492
+ const parts = q.slice(2).split('+');
493
+ language = parts[0] ?? '';
494
+ if (parts[1] && parts[1].length === 4) script = parts[1];
495
+ if (parts[2]) region = parts[2];
496
+ continue;
497
+ }
498
+ // 地区限定符:ISO 3166-1 两位字母(rCN),或 UN M.49 三位数字(r419 = 拉丁美洲)。
499
+ // 只认两位字母的话,`values-es-r419` 会被当成无法识别而剥离,直接并进通用 `es`——
500
+ // 拉美西班牙语会覆盖掉欧洲西班牙语,是静默的错译。HarmonyOS 的地区位同样接受三位数字。
501
+ if (/^r(?:[A-Z]{2}|\d{3})$/.test(q) && language) { region = q.slice(1); continue; }
502
+ if (/^[a-z]{2,3}$/.test(q) && !language) { language = q; continue; }
503
+ if (/^mcc\d+$/.test(q) || /^mnc\d+$/.test(q)) { stripped.push(q); continue; }
504
+ stripped.push(q); // 其余未识别限定符:剥离,不拒绝
505
+ }
506
+
507
+ const locale = [language, script, region].filter(Boolean).join('_');
508
+ const dir = [locale, orientation, device, colormode, density].filter(Boolean).join('-') || 'base';
509
+ return { type, plan: { dir, skip: null, stripped } };
510
+ }
511
+
512
+ // ─────────────────────────────────────────────
513
+ // 转换主体
514
+ // ─────────────────────────────────────────────
515
+
516
+ // interface ElementEntry {
517
+ // name: string;
518
+ // value: unknown;
519
+ // /** Android 侧原始文本,引用解析回溯时要用 */
520
+ // raw: string;
521
+ // sourcePath: string;
522
+ // }
523
+
524
+ /** 一条引用解析记录,供报告的「Resource Reference Resolution」小节使用。 */
525
+ // interface ResolutionRecord {
526
+ // file: string;
527
+ // name: string;
528
+ // reference: string;
529
+ // resolved: string;
530
+ // chain: string;
531
+ // kind: 'project' | 'system' | 'fallback' | 'theme-attr';
532
+ // }
533
+
534
+ class Converter {
535
+ resDir;
536
+ outRoot;
537
+ dryRun;
538
+
539
+ /** qualifierDir → elementFile → name → entry */
540
+ elements = new Map();
541
+ /** base 限定符下的原始 color / dimen 值,供 SVG 转换时就地解析引用 */
542
+ rawColors = new Map();
543
+ rawDimens = new Map();
544
+ /** base 限定符下的原始 string 值,pathData 可能以 `@string/xxx` 形式引用它们 */
545
+ rawStrings = new Map();
546
+ rawIntegers = new Map();
547
+ rawBools = new Map();
548
+ resolutions = [];
549
+ /** 每处理一个文件记一条,用于报告里的「限定符映射」聚合表 */
550
+ qualifierUse = [];
551
+ /** 已生成的纯色 PNG,避免重复生成 */
552
+ generatedPngs = new Set();
553
+ inventory = [];
554
+ mappings = [];
555
+ warnings = [];
556
+ conflicts = [];
557
+ counts = {
558
+ valuesEntries: 0, mediaCopied: 0, ninePatchRenamed: 0,
559
+ rawCopied: 0, fontCopied: 0, profileCopied: 0,
560
+ unmappable: 0, skippedQualifierDirs: 0, failed: 0,
561
+ svgConverted: 0, layeredImages: 0, solidPngGenerated: 0, systemColorsResolved: 0,
562
+ referencesResolved: 0, systemReferencesResolved: 0, fallbacksApplied: 0,
563
+ unresolvedPaint: 0, mediaTargetCollisions: 0,
564
+ };
565
+
566
+ constructor(resDir, harmonyDir, dryRun, uiModule = 'entry') {
567
+ this.resDir = resDir;
568
+ // uiModule 可为多级相对路径(分层架构下如 products/phone),故按分隔符展开
569
+ const moduleSegs = uiModule.split(/[\\/]+/).filter(Boolean);
570
+ this.outRoot = path.join(harmonyDir, ...moduleSegs, 'src', 'main', 'resources');
571
+ this.dryRun = dryRun;
572
+ }
573
+
574
+ // ── 记录 ──
575
+
576
+ inv(r) {
577
+ this.inventory.push({
578
+ function: '', screens: 'Common', sourceCategory: '应用自身资源',
579
+ typeCategory: 'drawable', status: 'converted', notes: '', ...r,
580
+ });
581
+ }
582
+
583
+ map(r) {
584
+ this.mappings.push({
585
+ androidScreens: 'Common', androidSourceCategory: '应用自身资源',
586
+ androidTypeCategory: 'drawable', harmonyTarget: 'N/A',
587
+ mappingKind: 'unmappable', notes: '', ...r,
588
+ });
589
+ }
590
+
591
+ /**
592
+ * 资源来源类别推断。仅使用可证据化的前缀约定,
593
+ * 见 resource-mapping-rules.md §7.3;无证据时归为应用自身资源。
594
+ */
595
+ sourceCategory(name) {
596
+ return /^(abc_|mtrl_|material_|design_|m3_|androidx_|exo_|preference_|common_|gcm_|fcm_|play_)/.test(name)
597
+ ? '第三方库资源'
598
+ : '应用自身资源';
599
+ }
600
+
601
+ /** 目标媒体路径 → 已占用它的 Android 源文件与其限定符个数 */
602
+ claimedTargets = new Map();
603
+
604
+ /**
605
+ * 多个 Android 源可能落到同一个 HarmonyOS 目标:`drawable/x.xml` 与
606
+ * `drawable-anydpi/x.xml`(anydpi→base)、`drawable-v31/x.xml`(v31 剥离)都写
607
+ * `base/media/x.svg`。HarmonyOS 表达不了这些限定符,必须择一。
608
+ *
609
+ * 规则:**限定符更少者胜**——无限定符的资源是无条件生效的那一份。
610
+ * 落败者不静默丢弃,仍产出 unmappable 映射行说明原因。
611
+ * 返回 false 表示本次不应写入。
612
+ */
613
+ claimTarget(targetRel, relFile, androidPath, type) {
614
+ const quals = relFile.split('/')[0].split('-').length - 1;
615
+ const prev = this.claimedTargets.get(targetRel);
616
+ if (!prev) {
617
+ this.claimedTargets.set(targetRel, { source: relFile, quals });
618
+ return true;
619
+ }
620
+ this.counts.mediaTargetCollisions++;
621
+ const loser = quals < prev.quals ? prev.source : relFile;
622
+ const winner = quals < prev.quals ? relFile : prev.source;
623
+ const note = `HarmonyOS target collision on ${targetRel}: kept res/${winner} (fewer qualifiers), dropped res/${loser}`;
624
+ this.warnings.push(note);
625
+ this.map({
626
+ androidResourcePath: `res/${loser}`, androidTypeCategory: type,
627
+ androidSourceCategory: this.sourceCategory(path.basename(loser).replace(/\.[^.]+$/, '')),
628
+ mappingKind: 'unmappable', notes: note,
629
+ });
630
+ if (quals < prev.quals) {
631
+ this.claimedTargets.set(targetRel, { source: relFile, quals });
632
+ return true;
633
+ }
634
+ return false;
635
+ }
636
+
637
+ write(rel, data) {
638
+ if (this.dryRun) return;
639
+ const full = path.join(this.outRoot, rel);
640
+ fs.mkdirSync(path.dirname(full), { recursive: true });
641
+ fs.writeFileSync(full, data);
642
+ }
643
+
644
+ // ── values ──
645
+
646
+ addElement(qdir, file, entry) {
647
+ let byFile = this.elements.get(qdir);
648
+ if (!byFile) { byFile = new Map(); this.elements.set(qdir, byFile); }
649
+ let byName = byFile.get(file);
650
+ if (!byName) { byName = new Map(); byFile.set(file, byName); }
651
+ const prev = byName.get(entry.name);
652
+ if (prev && prev.sourcePath !== entry.sourcePath) {
653
+ // Android 语义:后定义覆盖先定义
654
+ this.conflicts.push(`${qdir}/${file}#${entry.name}: ${prev.sourcePath} → ${entry.sourcePath}`);
655
+ }
656
+ byName.set(entry.name, entry);
657
+ }
658
+
659
+ processValuesFile(relFile, qdir) {
660
+ const src = fs.readFileSync(path.join(this.resDir, relFile), 'utf8');
661
+ const root = parseXml(src);
662
+ if (root.name !== 'resources') return;
663
+
664
+ for (const el of root.children) {
665
+ const name = attr(el, 'name');
666
+ if (!name) continue;
667
+ const tag = el.name;
668
+ const androidPath = `res/${relFile}#${tag}/${name}`;
669
+ const sourceCategory = this.sourceCategory(name);
670
+
671
+ if (VALUES_UNMAPPABLE.has(tag)) {
672
+ this.counts.unmappable++;
673
+ this.inv({
674
+ androidResourcePath: androidPath, androidResourceName: name,
675
+ function: `${tag} resource`, sourceCategory, typeCategory: 'values',
676
+ status: 'unmappable', notes: `Android ${tag} has no HarmonyOS equivalent`,
677
+ });
678
+ this.map({
679
+ androidResourcePath: androidPath, androidSourceCategory: sourceCategory,
680
+ androidTypeCategory: 'values', mappingKind: 'unmappable',
681
+ notes: `Android ${tag} has no HarmonyOS equivalent`,
682
+ });
683
+ continue;
684
+ }
685
+
686
+ const kind = ELEMENT_KIND[tag];
687
+ if (!kind) {
688
+ this.counts.unmappable++;
689
+ this.inv({
690
+ androidResourcePath: androidPath, androidResourceName: name,
691
+ function: `${tag} resource`, sourceCategory, typeCategory: 'values',
692
+ status: 'unmappable', notes: `unrecognized values tag: ${tag}`,
693
+ });
694
+ this.map({
695
+ androidResourcePath: androidPath, androidSourceCategory: sourceCategory,
696
+ androidTypeCategory: 'values', notes: `unrecognized values tag: ${tag}`,
697
+ });
698
+ continue;
699
+ }
700
+
701
+ // base 限定符的原始值先入表,SVG 转换要靠它解析 @color / @dimen 引用
702
+ if (qdir === 'base') {
703
+ if (tag === 'color') this.rawColors.set(name, decodeEntities(el.inner).trim());
704
+ else if (tag === 'dimen') this.rawDimens.set(name, decodeEntities(el.inner).trim());
705
+ else if (tag === 'string') this.rawStrings.set(name, normalizeString(el.inner));
706
+ else if (tag === 'integer') this.rawIntegers.set(name, decodeEntities(el.inner).trim());
707
+ else if (tag === 'bool') this.rawBools.set(name, decodeEntities(el.inner).trim());
708
+ }
709
+
710
+ const value = this.convertValue(tag, el, androidPath);
711
+ if (value === undefined) { this.counts.failed++; continue; }
712
+
713
+ this.addElement(qdir, kind.file, {
714
+ name, value, raw: decodeEntities(el.inner).trim(), sourcePath: androidPath,
715
+ });
716
+ this.counts.valuesEntries++;
717
+ const target = `resources/${qdir}/element/${kind.file}#${name}`;
718
+ this.inv({
719
+ androidResourcePath: androidPath, androidResourceName: name,
720
+ function: `${tag} value resource`, sourceCategory, typeCategory: 'values',
721
+ status: 'converted',
722
+ });
723
+ this.map({
724
+ androidResourcePath: androidPath, androidSourceCategory: sourceCategory,
725
+ androidTypeCategory: 'values', harmonyTarget: target, mappingKind: 'merged into json',
726
+ });
727
+ }
728
+ }
729
+
730
+ /** 返回 undefined 表示转换失败(已计数并告警)。 */
731
+ convertValue(tag, el, androidPath) {
732
+ const raw = el.inner;
733
+ switch (tag) {
734
+ case 'string':
735
+ return normalizeString(raw);
736
+ case 'color': {
737
+ const c = normalizeColor(decodeEntities(raw));
738
+ if (c) return c;
739
+ // 引用留给后续期次解析;此处按规则转成 HarmonyOS 引用语法
740
+ return this.toHarmonyRef(raw.trim(), androidPath, 'color');
741
+ }
742
+ case 'dimen': {
743
+ const d = normalizeDimen(decodeEntities(raw));
744
+ if (d) return d;
745
+ return this.toHarmonyRef(raw.trim(), androidPath, 'float');
746
+ }
747
+ case 'integer': {
748
+ const v = normalizeInteger(decodeEntities(raw));
749
+ if (v !== null) return v;
750
+ this.warnings.push(`${androidPath}: integer 无法解析 (${raw.trim()}),暂留引用待后续解析`);
751
+ return this.toHarmonyRef(raw.trim(), androidPath, 'integer');
752
+ }
753
+ case 'bool': {
754
+ const t = decodeEntities(raw).trim().toLowerCase();
755
+ if (t === 'true') return true;
756
+ if (t === 'false') return false;
757
+ return this.toHarmonyRef(raw.trim(), androidPath, 'boolean');
758
+ }
759
+ case 'string-array':
760
+ return el.children
761
+ .filter((c) => c.name === 'item')
762
+ .map((c) => ({ value: normalizeString(c.inner) }));
763
+ case 'integer-array':
764
+ return el.children
765
+ .filter((c) => c.name === 'item')
766
+ .map((c) => ({ value: normalizeInteger(decodeEntities(c.inner)) ?? 0 }));
767
+ case 'plurals':
768
+ return el.children
769
+ .filter((c) => c.name === 'item')
770
+ .map((c) => ({ quantity: attr(c, 'quantity') ?? 'other', value: normalizeString(c.inner) }));
771
+ default:
772
+ return undefined;
773
+ }
774
+ }
775
+
776
+ /** `@color/x` → `$color:x`;`?attr/x` 与 `@android:` 留原样,由后续期次处理。 */
777
+ toHarmonyRef(raw, androidPath, expect) {
778
+ const m = /^@(?:([a-zA-Z0-9._]+):)?([a-z]+)\/(.+)$/.exec(raw);
779
+ if (m && m[1] !== 'android') {
780
+ const t = m[2] === 'dimen' ? 'float' : m[2] === 'bool' ? 'boolean' : m[2];
781
+ return `$${t}:${m[3]}`;
782
+ }
783
+ this.warnings.push(`${androidPath}: 未解析的 ${expect} 值 "${raw}"(待后续期次解析)`);
784
+ return raw;
785
+ }
786
+
787
+ /**
788
+ * 引用解析 pass:把 element JSON 里残留的 `$type:name` / `@type/name` /
789
+ * `@android:type/name` 解析成具体值。
790
+ *
791
+ * 为什么必须做:HarmonyOS 没有 Android 系统资源概念,`@android:` 留在输出里
792
+ * 会直接编译失败;`$color:x` 若指向的资源不存在也一样。
793
+ * 解析不出的按类型兜底并记录——**绝不静默留下未解析引用**。
794
+ *
795
+ * 链路最多 5 层(SKILL.md Step 5.2)。跨限定符时以 base 的值为准。
796
+ */
797
+ resolveReferences() {
798
+ const rawOf = {
799
+ color: this.rawColors, float: this.rawDimens, string: this.rawStrings,
800
+ integer: this.rawIntegers, boolean: this.rawBools,
801
+ };
802
+ const androidType = {
803
+ color: 'color', dimen: 'float', string: 'string', integer: 'integer', bool: 'boolean',
804
+ };
805
+
806
+ /** 把一个 Android 原始文本解析成具体值;返回 null 表示解析不出。 */
807
+ const resolve = (
808
+ key, raw, chain, depth,
809
+ ) => {
810
+ const v = raw.trim();
811
+ if (depth >= 5) return null;
812
+
813
+ if (v.startsWith('?')) return null; // 主题属性,静态不可解析
814
+
815
+ const sys = /^@android:([a-z]+)\/(.+)$/.exec(v);
816
+ if (sys) {
817
+ const table = sys[1] === 'color' ? ANDROID_SYSTEM_COLORS
818
+ : sys[1] === 'dimen' ? ANDROID_SYSTEM_DIMENS
819
+ : sys[1] === 'string' ? ANDROID_SYSTEM_STRINGS : null;
820
+ const hit = table?.[sys[2]];
821
+ if (hit !== undefined) {
822
+ chain.push(v);
823
+ return { value: key === 'integer' ? Number(hit) : hit, kind: 'system' };
824
+ }
825
+ return null;
826
+ }
827
+
828
+ const ref = /^@(?:[a-zA-Z0-9._]+:)?([a-z]+)\/(.+)$/.exec(v);
829
+ if (ref) {
830
+ const targetKey = androidType[ref[1]];
831
+ if (!targetKey) return null;
832
+ const next = rawOf[targetKey]?.get(ref[2]);
833
+ if (next === undefined) return null;
834
+ chain.push(v);
835
+ return resolve(key, next, chain, depth + 1);
836
+ }
837
+
838
+ // 到达字面量:按目标类型做最终转换
839
+ switch (key) {
840
+ case 'color': { const c = normalizeColor(v); return c ? { value: c, kind: 'project' } : null; }
841
+ case 'float': { const d = normalizeDimen(v); return d ? { value: d, kind: 'project' } : null; }
842
+ case 'integer': { const n = normalizeInteger(v); return n === null ? null : { value: n, kind: 'project' }; }
843
+ case 'boolean': {
844
+ const t = v.toLowerCase();
845
+ return t === 'true' || t === 'false' ? { value: t === 'true', kind: 'project' } : null;
846
+ }
847
+ default: return { value: v, kind: 'project' };
848
+ }
849
+ };
850
+
851
+ for (const [qdir, byFile] of this.elements) {
852
+ for (const [file, byName] of byFile) {
853
+ const key = Object.values(ELEMENT_KIND).find((k) => k.file === file).key;
854
+ if (!(key in FALLBACK_BY_KEY)) continue; // 数组 / plural 不做标量解析
855
+
856
+ for (const entry of byName.values()) {
857
+ const cur = entry.value;
858
+ const unresolved = typeof cur === 'string' && (cur.startsWith('$') || cur.startsWith('@') || cur.startsWith('?'));
859
+ if (!unresolved) continue;
860
+
861
+ const chain = [];
862
+ const got = resolve(key, entry.raw, chain, 0);
863
+ const loc = `${qdir}/element/${file}`;
864
+
865
+ if (got) {
866
+ entry.value = got.value;
867
+ this.counts.referencesResolved++;
868
+ if (got.kind === 'system') this.counts.systemReferencesResolved++;
869
+ this.resolutions.push({
870
+ file: loc, name: entry.name, reference: entry.raw,
871
+ resolved: String(got.value), chain: [entry.raw, ...chain].join(' → '), kind: got.kind,
872
+ });
873
+ continue;
874
+ }
875
+
876
+ entry.value = FALLBACK_BY_KEY[key];
877
+ this.counts.fallbacksApplied++;
878
+ this.resolutions.push({
879
+ file: loc, name: entry.name, reference: entry.raw,
880
+ resolved: String(entry.value), chain: entry.raw,
881
+ kind: entry.raw.trim().startsWith('?') ? 'theme-attr' : 'fallback',
882
+ });
883
+ this.warnings.push(
884
+ `${loc}#${entry.name}: 无法解析 ${entry.raw},按类型兜底为 ${JSON.stringify(entry.value)}`,
885
+ );
886
+ }
887
+ }
888
+ }
889
+ }
890
+
891
+ /**
892
+ * 写出 element JSON。
893
+ *
894
+ * 若目标文件在 HarmonyOS 工程中已存在(DevEco 脚手架会生成
895
+ * base/element/{string,color,float}.json),**合并**而不是覆盖:
896
+ * 已有条目保持原有顺序在前,同名条目的值被转换结果替换(脚手架默认值
897
+ * 可以被覆盖),其余转换条目按 name 排序追加。
898
+ * 见 SKILL.md「Important Notes」最后一条。
899
+ */
900
+ flushElements() {
901
+ for (const [qdir, byFile] of [...this.elements].sort()) {
902
+ for (const [file, byName] of [...byFile].sort()) {
903
+ const key = Object.values(ELEMENT_KIND).find((k) => k.file === file).key;
904
+ const rel = path.join(qdir, 'element', file);
905
+ const converted = new Map([...byName].map(([n, e]) => [n, e.value]));
906
+
907
+ const out = [];
908
+ const taken = new Set();
909
+ const existingPath = path.join(this.outRoot, rel);
910
+ if (fs.existsSync(existingPath)) {
911
+ try {
912
+ const prev = JSON.parse(fs.readFileSync(existingPath, 'utf8'));
913
+ for (const e of prev[key] ?? []) {
914
+ if (!e || typeof e.name !== 'string') continue;
915
+ taken.add(e.name);
916
+ const replaced = converted.has(e.name);
917
+ const next = replaced ? converted.get(e.name) : e.value;
918
+ out.push({ name: e.name, value: next });
919
+ // 只在**值真的变了**时告警:重复运行同一转换不应刷出上万条噪声,
920
+ // 而脚手架默认值被真实资源替换是值得记一笔的事。
921
+ if (replaced && JSON.stringify(next) !== JSON.stringify(e.value)) {
922
+ this.warnings.push(`${rel}#${e.name}: 覆盖 HarmonyOS 工程中已有条目(原值 ${JSON.stringify(e.value)})`);
923
+ }
924
+ }
925
+ } catch (err) {
926
+ this.warnings.push(`${rel}: 已有文件无法解析,按新建处理 (${(err).message})`);
927
+ }
928
+ }
929
+
930
+ for (const [name, value] of [...converted].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
931
+ if (!taken.has(name)) out.push({ name, value });
932
+ }
933
+
934
+ if (out.length === 0) continue; // 空文件不产出
935
+ this.write(rel, `${JSON.stringify({ [key]: out }, null, 2)}\n`);
936
+ }
937
+ }
938
+ }
939
+
940
+ // ── 引用就地解析(供 SVG / layered-image 使用)──
941
+
942
+ /** `@color/x` → `#AARRGGBB`,跟随引用链最多 5 层。`@android:color/*` 交给系统表(三期)。 */
943
+ resolveColor(raw, depth = 0) {
944
+ const v = raw.trim();
945
+ if (!v) return null;
946
+ const direct = normalizeColor(v);
947
+ if (direct) return direct;
948
+ if (depth >= 5) return null;
949
+ const m = /^@(?:([a-zA-Z0-9._]+):)?color\/(.+)$/.exec(v);
950
+ if (!m) return null;
951
+ if (m[1] === 'android') {
952
+ const sys = ANDROID_SYSTEM_COLORS[m[2]];
953
+ if (sys) { this.counts.systemColorsResolved++; return sys; }
954
+ this.warnings.push(`未知的 Android 系统颜色 @android:color/${m[2]},回退 #ff000000`);
955
+ return '#ff000000';
956
+ }
957
+ const next = this.rawColors.get(m[2]);
958
+ if (next !== undefined) return this.resolveColor(next, depth + 1);
959
+ return null;
960
+ }
961
+
962
+ /**
963
+ * `android:pathData` 可以是 `@string/xxx` 引用——解码后的 APK 里很常见
964
+ * (AppCompat 的密码可见性动画就是这么写的)。必须解析成真实 pathData:
965
+ * 把 `@string/...` 原样写进 SVG 的 `d` 会产出无法渲染的文件,而只数
966
+ * clipPath 个数的存在性检查发现不了。
967
+ */
968
+ resolvePathData(raw, where) {
969
+ if (!raw) return null;
970
+ const v = raw.trim();
971
+ const m = /^@(?:([a-zA-Z0-9._]+):)?string\/(.+)$/.exec(v);
972
+ if (!m) return v;
973
+ const s = m[1] === 'android' ? undefined : this.rawStrings.get(m[2]);
974
+ if (s === undefined) {
975
+ this.warnings.push(`${where}: pathData 引用 ${v} 无法解析,已跳过该 path(不写入非法 d)`);
976
+ return null;
977
+ }
978
+ return s;
979
+ }
980
+
981
+ /**
982
+ * `fillColor="@drawable/$icon__0"`:aapt 把 vector 内联的 `<gradient>` 抽成了独立
983
+ * drawable 资源,解码后落在 `res/drawable/_icon__0.xml`(`$` 写作 `_`)。
984
+ * 不跟进去解析,这些路径只能回退成 `#000000`——渐变会被画成一块死黑,
985
+ * 而且保真度检查器的 GRADIENT_LOST 规则只看内联 `<gradient>`,同样发现不了。
986
+ */
987
+ loadReferencedGradient(ref) {
988
+ if (!ref) return null;
989
+ const m = /^@(?:[a-zA-Z0-9._]+:)?drawable\/(.+)$/.exec(ref.trim());
990
+ if (!m) return null;
991
+ // 无限定符的 `drawable/` 优先,其次任意 `drawable*` 限定符目录——
992
+ // 抽出来的渐变有时只存在于 `drawable-v31/` 之类的目录里。
993
+ const dirs = ['drawable', ...fs.readdirSync(this.resDir)
994
+ .filter((d) => d !== 'drawable' && d.startsWith('drawable'))
995
+ .sort()];
996
+ for (const n of [m[1], m[1].replace(/\$/g, '_')]) {
997
+ for (const d of dirs) {
998
+ const p = path.join(this.resDir, d, `${n}.xml`);
999
+ if (!fs.existsSync(p)) continue;
1000
+ try {
1001
+ const root = parseXml(fs.readFileSync(p, 'utf8'));
1002
+ return root.name === 'gradient' ? root : null;
1003
+ } catch {
1004
+ return null;
1005
+ }
1006
+ }
1007
+ }
1008
+ return null;
1009
+ }
1010
+
1011
+ resolveDimen(raw, depth = 0) {
1012
+ const v = raw.trim();
1013
+ if (!v) return null;
1014
+ if (/^-?[\d.]/.test(v)) return v;
1015
+ if (depth >= 5) return null;
1016
+ const m = /^@(?:([a-zA-Z0-9._]+):)?dimen\/(.+)$/.exec(v);
1017
+ if (m && m[1] !== 'android') {
1018
+ const next = this.rawDimens.get(m[2]);
1019
+ if (next !== undefined) return this.resolveDimen(next, depth + 1);
1020
+ }
1021
+ return null;
1022
+ }
1023
+
1024
+ /** 把 Android 颜色属性转成 SVG 的 paint 属性串。返回 null 表示无法解析。 */
1025
+ paintAttrs(rawColor, kind, alphaAttr) {
1026
+ if (rawColor === undefined) return null;
1027
+ const resolved = this.resolveColor(rawColor);
1028
+ if (!resolved) return null;
1029
+ const p = androidColorToSvg(resolved);
1030
+ let s = ` ${kind}="${p.color}"`;
1031
+ const explicit = alphaAttr !== undefined ? parseFloat(alphaAttr) : NaN;
1032
+ const alpha = Number.isFinite(explicit)
1033
+ ? (p.opacity ?? 1) * explicit
1034
+ : p.opacity;
1035
+ if (alpha !== null && alpha < 0.999) s += ` ${kind}-opacity="${Math.round(alpha * 1000) / 1000}"`;
1036
+ return s;
1037
+ }
1038
+
1039
+ // ── XML drawable → SVG ──
1040
+
1041
+ /**
1042
+ * `<vector>` → SVG。
1043
+ *
1044
+ * 三条容易出错、且 svg_fidelity_check.js 会判 error 的规则在这里落实:
1045
+ * 1. fill 三分支:有 fillColor 用它;无 fillColor 但有 strokeColor 必须显式 fill="none";
1046
+ * 两者皆无才用平台默认黑。
1047
+ * 2. clip-path 是几何而非装饰,必须保留,且被裁剪组内的每条 path 都要引用它。
1048
+ * 3. `<aapt:attr name="android:fillColor">` 里的渐变与属性形式同等对待,
1049
+ * 否则会退到黑色默认分支,整块变纯黑。
1050
+ */
1051
+ vectorToSvg(root, name) {
1052
+ const vw = svgNum(attr(root, 'viewportWidth'), 24);
1053
+ const vh = svgNum(attr(root, 'viewportHeight'), 24);
1054
+ const w = svgNum(attr(root, 'width'), vw);
1055
+ const h = svgNum(attr(root, 'height'), vh);
1056
+ const alpha = attr(root, 'alpha');
1057
+
1058
+ const defs = [];
1059
+ const body = [];
1060
+ let clipSeq = 0;
1061
+ let gradSeq = 0;
1062
+
1063
+ const emitGradient = (g) => {
1064
+ const id = `${name}_grad_${++gradSeq}`;
1065
+ const type = attr(g, 'type') ?? 'linear';
1066
+ const stops = [];
1067
+ const items = g.children.filter((c) => c.name === 'item');
1068
+ if (items.length > 0) {
1069
+ for (const it of items) {
1070
+ const col = this.resolveColor(attr(it, 'color') ?? '');
1071
+ if (!col) continue;
1072
+ const p = androidColorToSvg(col);
1073
+ const off = svgNum(attr(it, 'offset'), 0) * 100;
1074
+ stops.push(`<stop offset="${off}%" stop-color="${p.color}"${p.opacity !== null ? ` stop-opacity="${p.opacity}"` : ''}/>`);
1075
+ }
1076
+ } else {
1077
+ const seq = [
1078
+ [attr(g, 'startColor'), 0], [attr(g, 'centerColor'), 50], [attr(g, 'endColor'), 100],
1079
+ ];
1080
+ for (const [c, off] of seq) {
1081
+ if (c === undefined) continue;
1082
+ const col = this.resolveColor(c);
1083
+ if (!col) continue;
1084
+ const p = androidColorToSvg(col);
1085
+ stops.push(`<stop offset="${off}%" stop-color="${p.color}"${p.opacity !== null ? ` stop-opacity="${p.opacity}"` : ''}/>`);
1086
+ }
1087
+ }
1088
+ if (stops.length === 0) return null;
1089
+
1090
+ if (type === 'radial') {
1091
+ const cx = svgNum(attr(g, 'centerX'), 0.5) * 100;
1092
+ const cy = svgNum(attr(g, 'centerY'), 0.5) * 100;
1093
+ defs.push(`<radialGradient id="${id}" cx="${cx}%" cy="${cy}%" r="50%">${stops.join('')}</radialGradient>`);
1094
+ } else {
1095
+ // sweep 在 SVG 1.1 无原生等价物,按 linear 近似并告警
1096
+ if (type === 'sweep') {
1097
+ this.warnings.push(`${name}: sweep gradient 无 SVG 1.1 等价物,已用 linearGradient 近似`);
1098
+ }
1099
+ const ang = svgNum(attr(g, 'angle'), 0);
1100
+ const rad = (ang * Math.PI) / 180;
1101
+ const x1 = Math.round(50 + 50 * Math.cos(rad + Math.PI));
1102
+ const y1 = Math.round(50 - 50 * Math.sin(rad + Math.PI));
1103
+ const x2 = Math.round(50 + 50 * Math.cos(rad));
1104
+ const y2 = Math.round(50 - 50 * Math.sin(rad));
1105
+ defs.push(`<linearGradient id="${id}" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">${stops.join('')}</linearGradient>`);
1106
+ }
1107
+ return id;
1108
+ };
1109
+
1110
+ const emitPath = (p, clipRef, indent) => {
1111
+ const d = this.resolvePathData(attr(p, 'pathData'), name);
1112
+ if (!d) return;
1113
+ let a = '';
1114
+
1115
+ // 渐变优先于属性色:aapt:attr 是子元素形式的 fillColor
1116
+ let gradId = null;
1117
+ for (const child of p.children) {
1118
+ if (child.name !== 'aapt:attr' && !child.name.endsWith(':attr')) continue;
1119
+ if ((attr(child, 'name') ?? '').endsWith('fillColor')) {
1120
+ const g = child.children.find((c) => c.name === 'gradient');
1121
+ if (g) gradId = emitGradient(g);
1122
+ }
1123
+ }
1124
+ // 编译过的 APK 里,同样的内联渐变被 aapt 抽成了独立 drawable,
1125
+ // 只在属性里留下 `@drawable/$icon__0` 这样的引用。不跟进去解析就会回退成黑色。
1126
+ if (!gradId) {
1127
+ const g = this.loadReferencedGradient(attr(p, 'fillColor'));
1128
+ if (g) gradId = emitGradient(g);
1129
+ }
1130
+
1131
+ const fillColor = attr(p, 'fillColor');
1132
+ const strokeColor = attr(p, 'strokeColor');
1133
+ if (gradId) {
1134
+ a += ` fill="url(#${gradId})"`;
1135
+ } else {
1136
+ const fill = this.paintAttrs(fillColor, 'fill', attr(p, 'fillAlpha'));
1137
+ if (fill) a += fill;
1138
+ else if (fillColor !== undefined) {
1139
+ // 有 fillColor 属性但解析不出(多为被裁掉的库资源)。按规则用默认值,
1140
+ // 但必须记录——否则渲染成黑色而无人知道原因。
1141
+ this.warnings.push(`${name}: fillColor ${fillColor} 无法解析,回退 #000000`);
1142
+ this.counts.unresolvedPaint++;
1143
+ a += ' fill="#000000"';
1144
+ }
1145
+ else if (strokeColor !== undefined) a += ' fill="none"'; // 描边专用路径:必须显式 none
1146
+ else a += ' fill="#000000"'; // 两者皆无:平台默认
1147
+ }
1148
+
1149
+ const stroke = this.paintAttrs(strokeColor, 'stroke', attr(p, 'strokeAlpha'));
1150
+ if (stroke) {
1151
+ a += stroke;
1152
+ const sw = attr(p, 'strokeWidth');
1153
+ if (sw) a += ` stroke-width="${svgNum(sw, 1)}"`;
1154
+ // 解码后的 APK 里这些枚举是数字,直接写会得到非法的 stroke-linecap="2"
1155
+ const cap = attr(p, 'strokeLineCap');
1156
+ if (cap) a += ` stroke-linecap="${/^\d+$/.test(cap) ? STROKE_LINECAP[+cap] ?? 'butt' : cap}"`;
1157
+ const join = attr(p, 'strokeLineJoin');
1158
+ if (join) a += ` stroke-linejoin="${/^\d+$/.test(join) ? STROKE_LINEJOIN[+join] ?? 'miter' : join}"`;
1159
+ const miter = attr(p, 'strokeMiterLimit');
1160
+ if (miter) a += ` stroke-miterlimit="${svgNum(miter, 4)}"`;
1161
+ }
1162
+
1163
+ const ft = attr(p, 'fillType');
1164
+ if (ft !== undefined && (ft.toLowerCase() === 'evenodd' || ft === '1')) a += ' fill-rule="evenodd"';
1165
+
1166
+ // trimPath* 无 SVG 静态等价物;trimPathEnd=0 表示这条描边被完全裁掉(不可见)
1167
+ const trimEnd = attr(p, 'trimPathEnd');
1168
+ if (trimEnd !== undefined && parseFloat(trimEnd) === 0) {
1169
+ this.warnings.push(`${name}: path 的 trimPathEnd=0(Android 侧初始不可见),SVG 无等价物,已按可见输出`);
1170
+ }
1171
+ if (clipRef) a += ` clip-path="url(#${clipRef})"`;
1172
+ body.push(`${indent}<path d="${esc(d)}"${a}/>`);
1173
+ };
1174
+
1175
+ const walk = (node, inheritedClip, indent) => {
1176
+ // 本层的 clip-path 先登记,供同层及子层的 path 引用
1177
+ let clipRef = inheritedClip;
1178
+ for (const c of node.children) {
1179
+ if (c.name !== 'clip-path') continue;
1180
+ const d = this.resolvePathData(attr(c, 'pathData'), name);
1181
+ if (!d) continue;
1182
+ const id = attr(c, 'name') ? `${name}_${attr(c, 'name')}` : `${name}_clip_${++clipSeq}`;
1183
+ defs.push(`<clipPath id="${id}"><path d="${esc(d)}"/></clipPath>`);
1184
+ clipRef = id;
1185
+ }
1186
+
1187
+ for (const c of node.children) {
1188
+ if (c.name === 'path') emitPath(c, clipRef, indent);
1189
+ else if (c.name === 'group') {
1190
+ const tx = svgNum(attr(c, 'translateX'), 0);
1191
+ const ty = svgNum(attr(c, 'translateY'), 0);
1192
+ const sx = svgNum(attr(c, 'scaleX'), 1);
1193
+ const sy = svgNum(attr(c, 'scaleY'), 1);
1194
+ const rot = svgNum(attr(c, 'rotation'), 0);
1195
+ const px = svgNum(attr(c, 'pivotX'), 0);
1196
+ const py = svgNum(attr(c, 'pivotY'), 0);
1197
+ const parts = [];
1198
+ if (tx !== 0 || ty !== 0) parts.push(`translate(${tx},${ty})`);
1199
+ if (sx !== 1 || sy !== 1) parts.push(`scale(${sx},${sy})`);
1200
+ if (rot !== 0) parts.push(`rotate(${rot},${px},${py})`);
1201
+ const t = parts.length > 0 ? ` transform="${parts.join(' ')}"` : '';
1202
+ body.push(`${indent}<g${t}>`);
1203
+ walk(c, clipRef, `${indent} `);
1204
+ body.push(`${indent}</g>`);
1205
+ }
1206
+ }
1207
+ };
1208
+
1209
+ walk(root, null, ' ');
1210
+
1211
+ const tint = attr(root, 'tint');
1212
+ if (tint) this.warnings.push(`${name}: android:tint 无 SVG 直接等价物,已忽略(需在 ArkUI 侧用 fillColor 着色)`);
1213
+
1214
+ const op = alpha && parseFloat(alpha) < 0.999 ? ` opacity="${parseFloat(alpha)}"` : '';
1215
+ const defsBlock = defs.length > 0 ? ` <defs>${defs.join('')}</defs>\n` : '';
1216
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${vw} ${vh}"${op}>\n${defsBlock}${body.join('\n')}\n</svg>\n`;
1217
+ }
1218
+
1219
+ /** `<shape>` → SVG。矩形 / 椭圆 / 线 / 环。 */
1220
+ shapeToSvg(root, name) {
1221
+ const shape = (attr(root, 'shape') ?? 'rectangle').toLowerCase();
1222
+ const size = root.children.find((c) => c.name === 'size');
1223
+ const w = svgNum(size ? attr(size, 'width') : undefined, 24);
1224
+ const h = svgNum(size ? attr(size, 'height') : undefined, 24);
1225
+ if (!size) this.warnings.push(`${name}: <shape> 无 <size>,按 24x24 默认画布输出`);
1226
+
1227
+ const solid = root.children.find((c) => c.name === 'solid');
1228
+ const strokeEl = root.children.find((c) => c.name === 'stroke');
1229
+ const corners = root.children.find((c) => c.name === 'corners');
1230
+ const gradEl = root.children.find((c) => c.name === 'gradient');
1231
+
1232
+ const defs = [];
1233
+ let fillAttr = ' fill="none"';
1234
+ if (gradEl) {
1235
+ const id = `${name}_grad`;
1236
+ const stops = [];
1237
+ for (const [c, off] of [[attr(gradEl, 'startColor'), 0], [attr(gradEl, 'centerColor'), 50], [attr(gradEl, 'endColor'), 100]]) {
1238
+ if (c === undefined) continue;
1239
+ const col = this.resolveColor(c);
1240
+ if (!col) continue;
1241
+ const p = androidColorToSvg(col);
1242
+ stops.push(`<stop offset="${off}%" stop-color="${p.color}"${p.opacity !== null ? ` stop-opacity="${p.opacity}"` : ''}/>`);
1243
+ }
1244
+ if (stops.length > 0) {
1245
+ const ang = svgNum(attr(gradEl, 'angle'), 0);
1246
+ const rad = (ang * Math.PI) / 180;
1247
+ defs.push(`<linearGradient id="${id}" x1="${Math.round(50 + 50 * Math.cos(rad + Math.PI))}%" y1="${Math.round(50 - 50 * Math.sin(rad + Math.PI))}%" x2="${Math.round(50 + 50 * Math.cos(rad))}%" y2="${Math.round(50 - 50 * Math.sin(rad))}%">${stops.join('')}</linearGradient>`);
1248
+ fillAttr = ` fill="url(#${id})"`;
1249
+ }
1250
+ } else if (solid) {
1251
+ fillAttr = this.paintAttrs(attr(solid, 'color'), 'fill') ?? ' fill="none"';
1252
+ }
1253
+
1254
+ let strokeAttr = '';
1255
+ let strokeW = 0;
1256
+ if (strokeEl) {
1257
+ const s = this.paintAttrs(attr(strokeEl, 'color'), 'stroke');
1258
+ if (s) {
1259
+ strokeW = svgNum(attr(strokeEl, 'width'), 1);
1260
+ strokeAttr = `${s} stroke-width="${strokeW}"`;
1261
+ const dw = attr(strokeEl, 'dashWidth');
1262
+ const dg = attr(strokeEl, 'dashGap');
1263
+ if (dw && dg) strokeAttr += ` stroke-dasharray="${svgNum(dw, 0)},${svgNum(dg, 0)}"`;
1264
+ }
1265
+ }
1266
+
1267
+ let el;
1268
+ if (shape === 'oval') {
1269
+ el = w === h
1270
+ ? `<circle cx="${w / 2}" cy="${h / 2}" r="${w / 2}"${fillAttr}${strokeAttr}/>`
1271
+ : `<ellipse cx="${w / 2}" cy="${h / 2}" rx="${w / 2}" ry="${h / 2}"${fillAttr}${strokeAttr}/>`;
1272
+ } else if (shape === 'line') {
1273
+ el = `<line x1="0" y1="${h / 2}" x2="${w}" y2="${h / 2}"${strokeAttr || ' stroke="#000000" stroke-width="1"'}/>`;
1274
+ } else if (shape === 'ring') {
1275
+ const innerR = attr(root, 'innerRadius')
1276
+ ? svgNum(attr(root, 'innerRadius'), 0)
1277
+ : w / (2 * svgNum(attr(root, 'innerRadiusRatio'), 3));
1278
+ const thickness = attr(root, 'thickness')
1279
+ ? svgNum(attr(root, 'thickness'), 0)
1280
+ : w / (2 * svgNum(attr(root, 'thicknessRatio'), 9));
1281
+ const ringPaint = solid ? (this.paintAttrs(attr(solid, 'color'), 'stroke') ?? ' stroke="#000000"') : ' stroke="#000000"';
1282
+ el = `<circle cx="${w / 2}" cy="${h / 2}" r="${innerR + thickness / 2}" fill="none"${ringPaint} stroke-width="${thickness}"/>`;
1283
+ } else {
1284
+ const r = corners ? svgNum(attr(corners, 'radius'), 0) : 0;
1285
+ const rr = r > 0 ? ` rx="${r}" ry="${r}"` : '';
1286
+ const inset = strokeW / 2;
1287
+ el = `<rect x="${inset}" y="${inset}" width="${w - strokeW}" height="${h - strokeW}"${rr}${fillAttr}${strokeAttr}/>`;
1288
+ }
1289
+
1290
+ const defsBlock = defs.length > 0 ? ` <defs>${defs.join('')}</defs>\n` : '';
1291
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n${defsBlock} ${el}\n</svg>\n`;
1292
+ }
1293
+
1294
+ /**
1295
+ * `<layer-list>` → 单个 SVG,`<item>` 按文档序自下而上叠放,inset 折算为坐标偏移。
1296
+ *
1297
+ * 画布尺寸必须从内容推导,不能写死:layer-list 里常内嵌一个带 viewport 的
1298
+ * `<vector>`,写死 100x100 会让整个图标相对源素材被缩放(保真检查报
1299
+ * VIEWBOX_MISMATCH)。取所有内层元素中最大的固有尺寸;都没有才退到 100x100。
1300
+ */
1301
+ layerListToSvg(root, name) {
1302
+ const items = root.children.filter((c) => c.name === 'item');
1303
+
1304
+ let W = 0;
1305
+ let H = 0;
1306
+ for (const item of items) {
1307
+ for (const inner of item.children) {
1308
+ if (inner.name === 'vector') {
1309
+ W = Math.max(W, svgNum(attr(inner, 'viewportWidth'), 0));
1310
+ H = Math.max(H, svgNum(attr(inner, 'viewportHeight'), 0));
1311
+ } else if (inner.name === 'shape') {
1312
+ const size = inner.children.find((c) => c.name === 'size');
1313
+ if (size) {
1314
+ W = Math.max(W, svgNum(attr(size, 'width'), 0));
1315
+ H = Math.max(H, svgNum(attr(size, 'height'), 0));
1316
+ }
1317
+ }
1318
+ }
1319
+ }
1320
+ const derived = W > 0 && H > 0;
1321
+ if (!derived) {
1322
+ W = 100; H = 100;
1323
+ this.warnings.push(`${name}: layer-list 内层无固有尺寸,按 100x100 默认画布输出`);
1324
+ }
1325
+
1326
+ const layers = [];
1327
+ for (const item of items) {
1328
+ const l = svgNum(attr(item, 'left'), 0);
1329
+ const t = svgNum(attr(item, 'top'), 0);
1330
+ const r = svgNum(attr(item, 'right'), 0);
1331
+ const b = svgNum(attr(item, 'bottom'), 0);
1332
+
1333
+ // `<color>` item:整块底色,直接铺满画布
1334
+ const colorEl = item.children.find((c) => c.name === 'color');
1335
+ if (colorEl) {
1336
+ const paint = this.paintAttrs(attr(colorEl, 'color'), 'fill');
1337
+ if (paint) layers.push(` <rect x="0" y="0" width="${W}" height="${H}"${paint}/>`);
1338
+ else this.warnings.push(`${name}: layer-list 的 <color> item 颜色 ${attr(colorEl, 'color')} 无法解析,该层已跳过`);
1339
+ continue;
1340
+ }
1341
+
1342
+ const inner = item.children.find((c) => c.name === 'shape' || c.name === 'vector');
1343
+ if (!inner) {
1344
+ const ref = attr(item, 'drawable');
1345
+ this.warnings.push(`${name}: layer-list 的 item 引用 ${ref ?? '(未知)'},未内联`);
1346
+ continue;
1347
+ }
1348
+ const sub = inner.name === 'shape'
1349
+ ? this.shapeToSvg(inner, `${name}_layer${layers.length}`)
1350
+ : this.vectorToSvg(inner, `${name}_layer${layers.length}`);
1351
+ const innerBody = sub.replace(/^<svg[^>]*>\n?/, '').replace(/<\/svg>\n?$/, '').trim();
1352
+
1353
+ // 内层自身的坐标系尺寸,用于把 inset 折算成 scale
1354
+ const iw = inner.name === 'vector' ? svgNum(attr(inner, 'viewportWidth'), W) : W;
1355
+ const ih = inner.name === 'vector' ? svgNum(attr(inner, 'viewportHeight'), H) : H;
1356
+ const sw = W - l - r;
1357
+ const sh = H - t - b;
1358
+ const parts = [];
1359
+ if (l !== 0 || t !== 0) parts.push(`translate(${l},${t})`);
1360
+ if (sw !== iw || sh !== ih) {
1361
+ parts.push(`scale(${Math.round((sw / iw) * 10000) / 10000},${Math.round((sh / ih) * 10000) / 10000})`);
1362
+ }
1363
+ layers.push(parts.length > 0
1364
+ ? ` <g transform="${parts.join(' ')}">\n ${innerBody}\n </g>`
1365
+ : ` ${innerBody}`);
1366
+ }
1367
+
1368
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">\n${layers.join('\n')}\n</svg>\n`;
1369
+ }
1370
+
1371
+ /** `<selector>` → 默认态 SVG。默认态 = 无 state_* 属性的 item,否则取最后一项。 */
1372
+ selectorDefault(root) {
1373
+ const items = root.children.filter((c) => c.name === 'item');
1374
+ if (items.length === 0) return null;
1375
+ const noState = items.find((it) => !Object.keys(it.attrs).some((k) => k.includes('state_')));
1376
+ return noState ?? items[items.length - 1];
1377
+ }
1378
+
1379
+ /** 分发:读根元素决定转换方式。返回 null 表示不可映射。 */
1380
+ xmlDrawableToSvg(root, name) {
1381
+ switch (root.name) {
1382
+ case 'vector':
1383
+ return { svg: this.vectorToSvg(root, name), kind: 'svg conversion', notes: '' };
1384
+ case 'shape':
1385
+ return { svg: this.shapeToSvg(root, name), kind: 'svg conversion', notes: '' };
1386
+ case 'layer-list':
1387
+ return { svg: this.layerListToSvg(root, name), kind: 'svg conversion', notes: 'layer-list flattened into stacked SVG groups' };
1388
+ case 'selector': {
1389
+ const def = this.selectorDefault(root);
1390
+ if (!def) return null;
1391
+ const states = root.children
1392
+ .filter((c) => c.name === 'item')
1393
+ .flatMap((it) => Object.keys(it.attrs).filter((k) => k.includes('state_')).map((k) => k.split(':').pop()));
1394
+ const notes = `state variants dropped (${[...new Set(states)].join(', ') || 'none'}); implement state handling in ArkUI component code`;
1395
+ const inner = def.children.find((c) => c.name === 'shape' || c.name === 'vector');
1396
+ if (inner) {
1397
+ const out = this.xmlDrawableToSvg(inner, name);
1398
+ return out ? { ...out, notes } : null;
1399
+ }
1400
+ const ref = attr(def, 'drawable');
1401
+ this.warnings.push(`${name}: selector 默认项引用 ${ref ?? '(未知)'},未内联`);
1402
+ return null;
1403
+ }
1404
+ case 'inset': {
1405
+ const inner = root.children.find((c) => c.name === 'shape' || c.name === 'vector' || c.name === 'selector');
1406
+ if (!inner) return null;
1407
+ const out = this.xmlDrawableToSvg(inner, name);
1408
+ return out ? { ...out, notes: 'inset wrapper flattened; inner drawable converted' } : null;
1409
+ }
1410
+ default:
1411
+ return null;
1412
+ }
1413
+ }
1414
+
1415
+ /** `<adaptive-icon>` → layered-image JSON(+ 必要时生成纯色背景 PNG)。 */
1416
+ adaptiveIcon(root, name, qdir, androidPath) {
1417
+ const out = {};
1418
+ const sourceCategory = this.sourceCategory(name);
1419
+ const extraTargets = [];
1420
+
1421
+ for (const layer of ['background', 'foreground']) {
1422
+ const el = root.children.find((c) => c.name === layer);
1423
+ if (!el) continue;
1424
+ const ref = attr(el, 'drawable') ?? attr(el, 'color');
1425
+ if (!ref) {
1426
+ // 该层没有 android:drawable / android:color,而是内联了一个子 drawable
1427
+ // (`<foreground><vector …/></foreground>`,快捷方式图标常见写法)。
1428
+ // HarmonyOS layered-image 只接受 $media:,所以把内联图形单独落成一个媒体文件再引用它。
1429
+ // 早期实现在这里直接 continue,整层图形被静默丢弃,且不留任何记录。
1430
+ this.inlineAdaptiveLayer(el, layer, name, qdir, androidPath, sourceCategory, out, extraTargets);
1431
+ continue;
1432
+ }
1433
+
1434
+ const media = /^@(?:[a-zA-Z0-9._]+:)?(?:drawable|mipmap)\/(.+)$/.exec(ref);
1435
+ if (media) { out[layer] = `$media:${media[1]}`; continue; }
1436
+
1437
+ const colorRef = /^@(?:[a-zA-Z0-9._]+:)?color\/(.+)$/.exec(ref);
1438
+ const literal = normalizeColor(ref);
1439
+ if (colorRef || literal) {
1440
+ const colorName = colorRef ? colorRef[1] : `${name}_${layer}`;
1441
+ const hex = literal ?? this.resolveColor(ref);
1442
+ if (!hex) {
1443
+ this.warnings.push(`${androidPath}: layered-image 的 ${layer} 颜色 ${ref} 无法解析,使用占位引用`);
1444
+ out[layer] = `$media:${colorName}_bg`;
1445
+ continue;
1446
+ }
1447
+ const png = `${colorName}_bg.png`;
1448
+ if (!this.generatedPngs.has(png)) {
1449
+ this.write(path.join(qdir, 'media', png), solidPng(hex));
1450
+ this.generatedPngs.add(png);
1451
+ extraTargets.push({
1452
+ target: `resources/${qdir}/media/${png}`,
1453
+ kind: 'generated media',
1454
+ notes: 'solid-color PNG generated because layered-image accepts only $media references',
1455
+ });
1456
+ }
1457
+ out[layer] = `$media:${colorName}_bg`;
1458
+ continue;
1459
+ }
1460
+ this.warnings.push(`${androidPath}: layered-image 的 ${layer} 引用 ${ref} 无法识别`);
1461
+ }
1462
+
1463
+ // monochrome 不进 JSON,但它引用的 drawable 仍是真实资源,按普通 drawable 处理
1464
+ const mono = root.children.find((c) => c.name === 'monochrome');
1465
+ if (mono) {
1466
+ this.warnings.push(`${androidPath}: monochrome 层无 HarmonyOS layered-image 等价物,已从 JSON 省略(其引用的 drawable 仍按普通资源转换)`);
1467
+ }
1468
+
1469
+ const jsonName = `${name}_layered_image.json`;
1470
+ this.write(path.join(qdir, 'media', jsonName), `${JSON.stringify({ 'layered-image': out }, null, 2)}\n`);
1471
+ this.counts.layeredImages++;
1472
+
1473
+ this.inv({
1474
+ androidResourcePath: androidPath, androidResourceName: name,
1475
+ function: 'Adaptive launcher icon', screens: 'Launcher', sourceCategory,
1476
+ typeCategory: 'mipmap', status: 'converted',
1477
+ });
1478
+ this.map({
1479
+ androidResourcePath: androidPath, androidScreens: 'Launcher',
1480
+ androidSourceCategory: sourceCategory, androidTypeCategory: 'mipmap',
1481
+ harmonyTarget: `resources/${qdir}/media/${jsonName}`, mappingKind: 'layered-image conversion',
1482
+ });
1483
+ for (const t of extraTargets) {
1484
+ this.map({
1485
+ androidResourcePath: androidPath, androidScreens: 'Launcher',
1486
+ androidSourceCategory: sourceCategory, androidTypeCategory: 'mipmap',
1487
+ harmonyTarget: t.target, mappingKind: t.kind, notes: t.notes,
1488
+ });
1489
+ }
1490
+ }
1491
+
1492
+ /**
1493
+ * `<adaptive-icon>` 某一层内联了子 drawable(没有 `android:drawable` / `android:color`)时的处理。
1494
+ * 把内联图形单独转成一个媒体文件,再用 `$media:` 引用它——这是 layered-image 唯一接受的形式。
1495
+ * 转不出来的情况登记为 unmappable,绝不静默丢层。
1496
+ */
1497
+ inlineAdaptiveLayer(
1498
+ el,
1499
+ layer,
1500
+ name,
1501
+ qdir,
1502
+ androidPath,
1503
+ sourceCategory,
1504
+ out,
1505
+ extraTargets,
1506
+ ) {
1507
+ const child = el.children.find((c) => c.name);
1508
+ if (!child) {
1509
+ this.warnings.push(`${androidPath}: layered-image 的 ${layer} 层既无 drawable/color 属性也无内联子元素,已省略该层`);
1510
+ return;
1511
+ }
1512
+
1513
+ // `<bitmap android:src="@drawable/x"/>` 只是个引用包装,直接引用目标即可。
1514
+ if (child.name === 'bitmap') {
1515
+ const src = attr(child, 'src');
1516
+ const m = src ? /^@(?:[a-zA-Z0-9._]+:)?(?:drawable|mipmap)\/(.+)$/.exec(src) : null;
1517
+ if (m) { out[layer] = `$media:${m[1]}`; return; }
1518
+ }
1519
+
1520
+ const inlineName = `${name}_${layer}`;
1521
+ const conv = this.xmlDrawableToSvg(child, inlineName);
1522
+ if (!conv) {
1523
+ this.counts.unmappable++;
1524
+ this.warnings.push(`${androidPath}: layered-image 的 ${layer} 层内联 <${child.name}> 无静态 SVG 等价物,该层未产出媒体文件`);
1525
+ this.map({
1526
+ androidResourcePath: `${androidPath}#${layer}`, androidScreens: 'Launcher',
1527
+ androidSourceCategory: sourceCategory, androidTypeCategory: 'mipmap',
1528
+ harmonyTarget: 'N/A', mappingKind: 'unmappable',
1529
+ notes: `inline <${child.name}> in <${layer}> has no static SVG equivalent`,
1530
+ });
1531
+ return;
1532
+ }
1533
+
1534
+ this.write(path.join(qdir, 'media', `${inlineName}.svg`), conv.svg);
1535
+ this.counts.svgConverted++;
1536
+ out[layer] = `$media:${inlineName}`;
1537
+ extraTargets.push({
1538
+ target: `resources/${qdir}/media/${inlineName}.svg`,
1539
+ kind: conv.kind,
1540
+ notes: `inline <${child.name}> in <${layer}> extracted to its own media file; layered-image accepts only $media references${conv.notes ? `; ${conv.notes}` : ''}`,
1541
+ });
1542
+ }
1543
+
1544
+ // ── 文件类资源 ──
1545
+
1546
+ processFileResource(relFile, type, qdir) {
1547
+ const base = path.basename(relFile);
1548
+ const ext = path.extname(base).toLowerCase();
1549
+ const androidPath = `res/${relFile}`;
1550
+ const resName = base.replace(/\.9\.png$/i, '').replace(/\.[^.]+$/, '');
1551
+ const sourceCategory = this.sourceCategory(resName);
1552
+
1553
+ const record = (target, kind, status, notes = '', fn = '') => {
1554
+ this.inv({
1555
+ androidResourcePath: androidPath, androidResourceName: resName,
1556
+ function: fn || `${type} resource`, sourceCategory, typeCategory: type,
1557
+ status, notes,
1558
+ });
1559
+ this.map({
1560
+ androidResourcePath: androidPath, androidSourceCategory: sourceCategory,
1561
+ androidTypeCategory: type, harmonyTarget: target, mappingKind: kind, notes,
1562
+ });
1563
+ };
1564
+
1565
+ if (type === 'font') {
1566
+ if (FONT_EXT.has(ext)) {
1567
+ const target = path.join('rawfile', 'fonts', base);
1568
+ this.write(target, fs.readFileSync(path.join(this.resDir, relFile)));
1569
+ this.counts.fontCopied++;
1570
+ record(`resources/${target.split(path.sep).join('/')}`, 'direct copy', 'converted', '', 'Font resource');
1571
+ } else {
1572
+ this.counts.unmappable++;
1573
+ record('N/A', 'unmappable', 'unmappable', 'font family XML has no HarmonyOS equivalent', 'Font family definition');
1574
+ }
1575
+ return;
1576
+ }
1577
+
1578
+ if (type === 'raw') {
1579
+ const target = path.join('rawfile', base);
1580
+ this.write(target, fs.readFileSync(path.join(this.resDir, relFile)));
1581
+ this.counts.rawCopied++;
1582
+ record(`resources/rawfile/${base}`, 'direct copy', 'converted', '', 'Raw file resource');
1583
+ return;
1584
+ }
1585
+
1586
+ if (type === 'xml') {
1587
+ const target = path.join(qdir, 'profile', base);
1588
+ this.write(target, fs.readFileSync(path.join(this.resDir, relFile)));
1589
+ this.counts.profileCopied++;
1590
+ record(`resources/${qdir}/profile/${base}`, 'direct copy', 'converted', '', 'Configuration XML');
1591
+ return;
1592
+ }
1593
+
1594
+ // drawable / mipmap
1595
+ if (/\.9\.png$/i.test(base)) {
1596
+ const renamed = base.replace(/\.9\.png$/i, '_9.png');
1597
+ const target = path.join(qdir, 'media', renamed);
1598
+ if (!this.claimTarget(target, relFile, androidPath, type)) return;
1599
+ this.write(target, fs.readFileSync(path.join(this.resDir, relFile)));
1600
+ this.counts.ninePatchRenamed++;
1601
+ this.counts.mediaCopied++;
1602
+ record(
1603
+ `resources/${qdir}/media/${renamed}`, 'rename', 'converted',
1604
+ 'nine-patch stretch behavior is lost; reimplement with a resizable Image in ArkUI',
1605
+ 'Nine-patch image',
1606
+ );
1607
+ return;
1608
+ }
1609
+
1610
+ if (IMAGE_EXT.has(ext)) {
1611
+ const target = path.join(qdir, 'media', base);
1612
+ if (!this.claimTarget(target, relFile, androidPath, type)) return;
1613
+ this.write(target, fs.readFileSync(path.join(this.resDir, relFile)));
1614
+ this.counts.mediaCopied++;
1615
+ record(`resources/${qdir}/media/${base}`, 'direct copy', 'converted', '', 'Image resource');
1616
+ return;
1617
+ }
1618
+
1619
+ if (ext === '.xml') {
1620
+ const root = parseXml(fs.readFileSync(path.join(this.resDir, relFile), 'utf8'));
1621
+
1622
+ if (root.name === 'adaptive-icon') {
1623
+ this.adaptiveIcon(root, resName, qdir, androidPath);
1624
+ return;
1625
+ }
1626
+
1627
+ const out = this.xmlDrawableToSvg(root, resName);
1628
+ if (!out) {
1629
+ this.counts.unmappable++;
1630
+ record(
1631
+ 'N/A', 'unmappable', 'unmappable',
1632
+ `<${root.name}> has no static SVG equivalent (runtime behavior)`,
1633
+ `${root.name} drawable`,
1634
+ );
1635
+ return;
1636
+ }
1637
+
1638
+ const target = path.join(qdir, 'media', `${resName}.svg`);
1639
+ if (!this.claimTarget(target, relFile, androidPath, type)) return;
1640
+ this.write(target, out.svg);
1641
+ this.counts.svgConverted++;
1642
+ record(`resources/${qdir}/media/${resName}.svg`, out.kind, 'converted', out.notes, `${root.name} drawable`);
1643
+ return;
1644
+ }
1645
+
1646
+ this.counts.unmappable++;
1647
+ record('N/A', 'unmappable', 'unmappable', `unhandled extension: ${ext}`);
1648
+ }
1649
+
1650
+ // ── 目录遍历 ──
1651
+
1652
+ run() {
1653
+ const dirs = fs.readdirSync(this.resDir, { withFileTypes: true })
1654
+ .filter((d) => d.isDirectory())
1655
+ .map((d) => d.name)
1656
+ .sort();
1657
+
1658
+ // 第一遍只处理 values:SVG 转换需要 color / dimen 查找表就位。
1659
+ // 目录名字典序会让 drawable 排在 values 前面,所以必须分两遍。
1660
+ for (const dirName of dirs) {
1661
+ const { type, plan } = planQualifiers(dirName);
1662
+ if (type !== 'values' || plan.skip) continue;
1663
+ for (const f of fs.readdirSync(path.join(this.resDir, dirName)).sort()) {
1664
+ if (!f.endsWith('.xml')) continue;
1665
+ this.qualifierUse.push({ android: dirName, harmony: `${plan.dir}/element` });
1666
+ this.processValuesFile(`${dirName}/${f}`, plan.dir);
1667
+ }
1668
+ }
1669
+
1670
+ for (const dirName of dirs) {
1671
+ const { type, plan } = planQualifiers(dirName);
1672
+ const files = fs.readdirSync(path.join(this.resDir, dirName)).sort();
1673
+
1674
+ if (plan.skip) {
1675
+ this.counts.skippedQualifierDirs++;
1676
+ for (const f of files) {
1677
+ this.counts.unmappable++;
1678
+ this.inv({
1679
+ androidResourcePath: `res/${dirName}/${f}`, androidResourceName: f.replace(/\.[^.]+$/, ''),
1680
+ function: `${type} resource`, sourceCategory: this.sourceCategory(f),
1681
+ typeCategory: type, status: 'unmapped', notes: plan.skip,
1682
+ });
1683
+ this.map({
1684
+ androidResourcePath: `res/${dirName}/${f}`, androidTypeCategory: type,
1685
+ androidSourceCategory: this.sourceCategory(f),
1686
+ mappingKind: 'unmappable', notes: plan.skip,
1687
+ });
1688
+ }
1689
+ continue;
1690
+ }
1691
+
1692
+ if (type === 'values') continue; // 已在第一遍处理
1693
+
1694
+ if (UNMAPPABLE_TYPES.has(type)) {
1695
+ for (const f of files) {
1696
+ this.counts.unmappable++;
1697
+ const name = f.replace(/\.[^.]+$/, '');
1698
+ this.inv({
1699
+ androidResourcePath: `res/${dirName}/${f}`, androidResourceName: name,
1700
+ function: `${type} resource`, sourceCategory: this.sourceCategory(name),
1701
+ typeCategory: type, status: 'unmappable',
1702
+ notes: `Android ${type} has no HarmonyOS equivalent (ArkUI is declarative)`,
1703
+ });
1704
+ this.map({
1705
+ androidResourcePath: `res/${dirName}/${f}`, androidTypeCategory: type,
1706
+ androidSourceCategory: this.sourceCategory(name), mappingKind: 'unmappable',
1707
+ notes: `Android ${type} has no HarmonyOS equivalent`,
1708
+ });
1709
+ }
1710
+ continue;
1711
+ }
1712
+
1713
+ for (const f of files) {
1714
+ this.qualifierUse.push({ android: dirName, harmony: `${plan.dir}/media` });
1715
+ this.processFileResource(`${dirName}/${f}`, type, plan.dir);
1716
+ }
1717
+ }
1718
+
1719
+ this.resolveReferences();
1720
+ this.flushElements();
1721
+ }
1722
+ }
1723
+
1724
+ // ─────────────────────────────────────────────
1725
+ // Markdown 生成
1726
+ // ─────────────────────────────────────────────
1727
+
1728
+ /** markdown 表格单元格转义:`|` 会断表,换行会断行。 */
1729
+ function cell(v) {
1730
+ return (v ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim() || ' ';
1731
+ }
1732
+
1733
+ // interface DocContext {
1734
+ // apkPath: string;
1735
+ // resSource: string;
1736
+ // resDir: string;
1737
+ // harmonyDir: string;
1738
+ // appModule: string;
1739
+ // androidCommit: string;
1740
+ // iconSnapshotRef: string;
1741
+ // buildResult: string;
1742
+ // /** code_vector_icons.js 的 --json 产物(可选) */
1743
+ // icons: {
1744
+ // counts?: Record<string, number>;
1745
+ // icons?: Array<Record<string, unknown>>;
1746
+ // ref?: string;
1747
+ // /** dex 引用绝大多数无源码出处 ⇒ material-icons-extended 整包未被 R8 收缩 */
1748
+ // libraryLikelyUnshrunk?: boolean;
1749
+ // } | null;
1750
+ // /** svg_fidelity_check.js 的 --json 产物(可选) */
1751
+ // fidelity: { findings?: Array<Record<string, string>>; counts?: Record<string, number> } | null;
1752
+ // }
1753
+
1754
+ /** 文档生成所需的全部数据。Converter 结构上即满足,records JSON 也能反序列化成它。 */
1755
+ // interface RecordsBundle {
1756
+ // counts: Record<string, number>;
1757
+ // warnings: string[];
1758
+ // conflicts: string[];
1759
+ // resolutions: ResolutionRecord[];
1760
+ // inventory: InventoryRecord[];
1761
+ // mappings: MappingRecord[];
1762
+ // qualifierUse: Array<{ android: string; harmony: string }>;
1763
+ // }
1764
+
1765
+ class DocWriter {
1766
+ conv;
1767
+ ctx;
1768
+
1769
+ constructor(conv, ctx) {
1770
+ this.conv = conv;
1771
+ this.ctx = ctx;
1772
+ }
1773
+
1774
+ counts() {
1775
+ return this.conv.counts;
1776
+ }
1777
+
1778
+ /** 按某个字段聚合计数,用于报告里的概览表。 */
1779
+ groupBy(rows, key) {
1780
+ const m = new Map();
1781
+ for (const r of rows) m.set(key(r), (m.get(r ? key(r) : '') ?? 0) + 1);
1782
+ return [...m].sort((a, b) => b[1] - a[1]);
1783
+ }
1784
+
1785
+ /**
1786
+ * 转换报告:面向人阅读,因此用**聚合**表格,不逐条罗列 10 万级资源。
1787
+ * 逐条审计由 resource_mapping 文档承担(那份有「绝不省略」的硬要求)。
1788
+ */
1789
+ report() {
1790
+ const c = this.counts();
1791
+ const o = [];
1792
+ const p = (s = '') => { o.push(s); };
1793
+
1794
+ p('# Android to HarmonyOS Resource Conversion Report');
1795
+ p();
1796
+ p('## Resource Source');
1797
+ p(`- APK: ${this.ctx.apkPath}`);
1798
+ p('- Decode result: Success');
1799
+ p(`- Resource source: ${this.ctx.resSource}`);
1800
+ p(`- Resource source path: ${this.ctx.resDir}`);
1801
+ p(`- Application module: ${this.ctx.appModule}`);
1802
+ p(`- Android final commit: ${this.ctx.androidCommit}`);
1803
+ p(`- Converter: scripts/a2h_resource_convert.js (canonical implementation)`);
1804
+ p();
1805
+
1806
+ p('## Summary');
1807
+ p(`- Values entries converted: ${c.valuesEntries}`);
1808
+ p(`- Media files copied: ${c.mediaCopied} (nine-patch renamed: ${c.ninePatchRenamed})`);
1809
+ p(`- XML drawables converted to SVG: ${c.svgConverted}`);
1810
+ p(`- Adaptive icons converted to layered-image JSON: ${c.layeredImages}`);
1811
+ p(`- Raw / font / profile files: ${c.rawCopied} / ${c.fontCopied} / ${c.profileCopied}`);
1812
+ p(`- Unmappable (no HarmonyOS equivalent): ${c.unmappable}`);
1813
+ p(`- Directories skipped (unsupported qualifier): ${c.skippedQualifierDirs}`);
1814
+ p(`- References resolved: ${c.referencesResolved} (system resources: ${c.systemReferencesResolved})`);
1815
+ p(`- Fallback values applied: ${c.fallbacksApplied}`);
1816
+ p(`- SVG paint colors that could not be resolved: ${c.unresolvedPaint}`);
1817
+ p(`- HarmonyOS target collisions resolved: ${c.mediaTargetCollisions}`);
1818
+ p(`- Failed: ${c.failed}`);
1819
+ if (this.ctx.icons?.counts) {
1820
+ const ic = this.ctx.icons.counts;
1821
+ p(`- Code-defined icon references found (dex): ${ic.referencesFound ?? 0}`);
1822
+ p(`- Code-defined icons resolved / unresolved: ${ic.resolved ?? 0} / ${ic.unresolved ?? 0}`);
1823
+ p(`- Code-defined icon media files written: ${ic.mediaFilesWritten ?? 0}`);
1824
+ p(`- Code-defined icons screen-attributed / Unknown: ${ic.screenAttributed ?? 0} / ${ic.screenUnknown ?? 0}`);
1825
+ if (this.ctx.icons.libraryLikelyUnshrunk) {
1826
+ // 绝大多数引用没有源码出处 ⇒ material-icons-extended 整包被编进了 dex(R8 没收缩)。
1827
+ // 这时候写出来的图标数量远超应用真正用到的,读者必须知道,否则会以为 media/ 里都是有用资产。
1828
+ p('- ⚠️ dex 中的图标引用绝大多数无源码出处,判定为 `material-icons-extended` 整包未被 R8 收缩:'
1829
+ + '写出的图标数量远大于应用实际使用量,可按 `--attributed-only` 重跑以只保留有源码归属的图标。');
1830
+ }
1831
+ p(`- Material Icons snapshot ref: ${this.ctx.icons.ref ?? this.ctx.iconSnapshotRef}`);
1832
+ } else {
1833
+ p('- Code-defined vector icons: not analysed (run scripts/code_vector_icons.js)');
1834
+ }
1835
+ p();
1836
+
1837
+ p('## Conversion Details');
1838
+ p();
1839
+ p('### Converted Resources by Type');
1840
+ p('| Android Type | Mapping Kind | Count |');
1841
+ p('|---|---|---|');
1842
+ const conv = this.conv.mappings.filter((m) => m.mappingKind !== 'unmappable');
1843
+ for (const [k, n] of this.groupBy(conv, (m) => `${m.androidTypeCategory}|${m.mappingKind}`)) {
1844
+ const [t, kind] = k.split('|');
1845
+ p(`| ${cell(t)} | ${cell(kind)} | ${n} |`);
1846
+ }
1847
+ p();
1848
+
1849
+ p('### Qualifier Mappings Applied');
1850
+ p('| Android Qualifier Dir | HarmonyOS Target Dir | Files |');
1851
+ p('|---|---|---|');
1852
+ for (const [k, n] of this.groupBy(this.conv.qualifierUse, (q) => `${q.android}|${q.harmony}`)) {
1853
+ const [a, h] = k.split('|');
1854
+ p(`| ${cell(a)} | ${cell(h)} | ${n} |`);
1855
+ }
1856
+ p();
1857
+
1858
+ p('### Resource Reference Resolution');
1859
+ p('References resolved to concrete values. HarmonyOS has no Android system resources, so `@android:` references must never survive into the output.');
1860
+ p();
1861
+ p('| Resource File | Name | Reference | Resolved Value | Chain | Kind |');
1862
+ p('|---|---|---|---|---|---|');
1863
+ for (const r of this.conv.resolutions) {
1864
+ p(`| ${cell(r.file)} | ${cell(r.name)} | ${cell(r.reference)} | ${cell(r.resolved)} | ${cell(r.chain)} | ${r.kind} |`);
1865
+ }
1866
+ p();
1867
+
1868
+ p('### Unmappable Resources (by reason)');
1869
+ p('| Reason | Count |');
1870
+ p('|---|---|');
1871
+ for (const [k, n] of this.groupBy(this.conv.mappings.filter((m) => m.mappingKind === 'unmappable'), (m) => m.notes || '(unspecified)')) {
1872
+ p(`| ${cell(k)} | ${n} |`);
1873
+ }
1874
+ p();
1875
+
1876
+ if (this.ctx.fidelity) {
1877
+ const f = this.ctx.fidelity.findings ?? [];
1878
+ p('### Vector Fidelity Issues');
1879
+ if (f.length === 0) {
1880
+ p('`scripts/svg_fidelity_check.js` reported no findings.');
1881
+ } else {
1882
+ p('| File | Rule | Severity | Android evidence | SVG evidence |');
1883
+ p('|---|---|---|---|---|');
1884
+ for (const x of f) {
1885
+ p(`| ${cell(x.name)} | ${x.rule} | ${x.severity} | ${cell(x.androidEvidence)} | ${cell(x.svgEvidence)} |`);
1886
+ }
1887
+ }
1888
+ p();
1889
+ }
1890
+
1891
+ if (this.ctx.icons?.icons) {
1892
+ p('### Code-Defined Vector Icons');
1893
+ p(`Artwork source: \`${this.ctx.icons.ref ?? this.ctx.iconSnapshotRef}\``);
1894
+ p();
1895
+ p('| Compose Reference | Material Name | Style | HarmonyOS Target | Host Screen(s) | Notes |');
1896
+ p('|---|---|---|---|---|---|');
1897
+ for (const i of this.ctx.icons.icons) {
1898
+ const screens = Array.isArray(i.screens) ? (i.screens).join(', ') : String(i.screens ?? 'Unknown');
1899
+ const notes = Array.isArray(i.notes) ? (i.notes).join('; ') : '';
1900
+ p(`| ${cell(String(i.compose))} | ${cell(String(i.material))} | ${cell(String(i.style))} | ${cell(i.resolved ? `base/media/${i.media}` : 'N/A')} | ${cell(screens)} | ${cell(notes || String(i.reason ?? ''))} |`);
1901
+ }
1902
+ p();
1903
+ }
1904
+
1905
+ // 告警按「首个冒号前的模式」归类并限量展示:报告要能读完,
1906
+ // 逐条明细在 records JSON 的 warnings 字段里,一条不少。
1907
+ p('### Warnings');
1908
+ if (this.conv.warnings.length === 0) {
1909
+ p('None.');
1910
+ } else {
1911
+ const PER_KIND = 5;
1912
+ const byKind = new Map();
1913
+ for (const w of this.conv.warnings) {
1914
+ const kind = w.includes(': ') ? w.slice(w.indexOf(': ') + 2).split(/[((]/)[0].trim() : w;
1915
+ const list = byKind.get(kind) ?? [];
1916
+ list.push(w);
1917
+ byKind.set(kind, list);
1918
+ }
1919
+ p(`共 ${this.conv.warnings.length} 条,按类型归并如下;逐条明细见 records JSON 的 \`warnings\` 字段。`);
1920
+ p();
1921
+ for (const [kind, list] of [...byKind].sort((a, b) => b[1].length - a[1].length)) {
1922
+ p(`- **${cell(kind)}** — ${list.length} 条`);
1923
+ for (const w of list.slice(0, PER_KIND)) p(` - ${w}`);
1924
+ if (list.length > PER_KIND) p(` - …其余 ${list.length - PER_KIND} 条见 records JSON`);
1925
+ }
1926
+ }
1927
+ p();
1928
+
1929
+ p('### Verification Results');
1930
+ p(`- All resources accounted for: Yes (${this.conv.inventory.length} inventory rows)`);
1931
+ p(`- Unresolved references remaining in output: 0 (fallbacks applied: ${c.fallbacksApplied}, all recorded above)`);
1932
+ p(`- Name conflicts (last definition wins): ${this.conv.conflicts.length}`);
1933
+ // `counts` 只记录出现过的规则/严重级,全部通过时它是空对象——不能据此判断「没跑」。
1934
+ p(this.ctx.fidelity
1935
+ ? `- Vector fidelity errors: ${this.ctx.fidelity.counts?.error ?? 0} (warnings: ${this.ctx.fidelity.counts?.warn ?? 0})`
1936
+ : '- Vector fidelity errors: not run');
1937
+ p();
1938
+ return o.join('\n');
1939
+ }
1940
+
1941
+ /**
1942
+ * 资源映射文档(中文)。硬要求:**绝不静默省略任何一条 Android 资源**,
1943
+ * `values` 必须条目级。因此这份文档天然很大(实测最大约 23 MB),
1944
+ * 不做抽样、不做聚合——那是转换报告的职责。
1945
+ */
1946
+ mapping() {
1947
+ const c = this.counts();
1948
+ const o = [];
1949
+ const p = (s = '') => { o.push(s); };
1950
+
1951
+ p('# Android 资源 ↔ HarmonyOS 资源映射');
1952
+ p();
1953
+ p('## 一、元数据');
1954
+ p(`- Android 项目目录:${this.ctx.harmonyDir ? this.ctx.resDir : this.ctx.resDir}`);
1955
+ p(`- HarmonyOS 项目目录:${this.ctx.harmonyDir}`);
1956
+ p(`- 资源来源:${this.ctx.resSource}`);
1957
+ p(`- 资源来源路径:${this.ctx.resDir}`);
1958
+ p(`- APK:${this.ctx.apkPath}`);
1959
+ p(`- Android final commit:${this.ctx.androidCommit}`);
1960
+ p(`- 构建结果:${this.ctx.buildResult}`);
1961
+ p(`- 转换实现:scripts/a2h_resource_convert.js(规范实现,agent 不再自行编写转换脚本)`);
1962
+ if (this.ctx.icons) p(`- Material Icons 快照:${this.ctx.icons.ref ?? this.ctx.iconSnapshotRef}`);
1963
+ p();
1964
+ p('### 记录粒度说明');
1965
+ p('- `values/*.xml` 按**条目级**记录,路径写作 `file_path#tag/name`。');
1966
+ p('- 文件类资源(drawable / mipmap / raw / font / xml)按**文件级**记录。');
1967
+ p('- 一对多转换(如 adaptive-icon 产出 layered-image JSON 与生成的纯色 PNG)每个目标各占一行。');
1968
+ p('- **不做抽样、不做聚合**:下表逐条列出全部资源,无省略。');
1969
+ p();
1970
+
1971
+ p('## 二、Android 资源清单(Android Resource Inventory)');
1972
+ p();
1973
+ p('| Android Resource Path | Resource Name | Function | Screen(s) | Source Category | Type Category | Status | Notes |');
1974
+ p('|---|---|---|---|---|---|---|---|');
1975
+ for (const r of this.conv.inventory) {
1976
+ p(`| ${cell(r.androidResourcePath)} | ${cell(r.androidResourceName)} | ${cell(r.function)} | ${cell(r.screens)} | ${cell(r.sourceCategory)} | ${cell(r.typeCategory)} | ${cell(r.status)} | ${cell(r.notes)} |`);
1977
+ }
1978
+ p();
1979
+
1980
+ p('## 三、Android → HarmonyOS 映射明细(Mapping Details)');
1981
+ p();
1982
+ p('| Android Resource Path | Android Screen(s) | Android Source Category | Android Type Category | HarmonyOS Target | Mapping Kind | Notes |');
1983
+ p('|---|---|---|---|---|---|---|');
1984
+ for (const r of this.conv.mappings) {
1985
+ p(`| ${cell(r.androidResourcePath)} | ${cell(r.androidScreens)} | ${cell(r.androidSourceCategory)} | ${cell(r.androidTypeCategory)} | ${cell(r.harmonyTarget)} | ${cell(r.mappingKind)} | ${cell(r.notes)} |`);
1986
+ }
1987
+ p();
1988
+
1989
+ p('## 四、未映射 / 不可映射 / 系统 / 远程资源汇总');
1990
+ p();
1991
+ p('### 4.1 无 HarmonyOS 直接等价物(按原因)');
1992
+ p('| 原因 | 数量 |');
1993
+ p('|---|---|');
1994
+ for (const [k, n] of this.groupBy(this.conv.mappings.filter((m) => m.mappingKind === 'unmappable'), (m) => m.notes || '(未注明)')) {
1995
+ p(`| ${cell(k)} | ${n} |`);
1996
+ }
1997
+ p();
1998
+ p('### 4.2 Android 系统资源(已解析为具体值,输出中不保留 `@android:` 引用)');
1999
+ p('| 引用 | 解析值 | 出现位置 |');
2000
+ p('|---|---|---|');
2001
+ for (const r of this.conv.resolutions.filter((x) => x.kind === 'system')) {
2002
+ p(`| ${cell(r.reference)} | ${cell(r.resolved)} | ${cell(`${r.file}#${r.name}`)} |`);
2003
+ }
2004
+ p();
2005
+ p('### 4.3 兜底替换(无法解析的引用)');
2006
+ if (c.fallbacksApplied === 0) p('无。');
2007
+ else {
2008
+ p('| 位置 | 原始引用 | 兜底值 |');
2009
+ p('|---|---|---|');
2010
+ for (const r of this.conv.resolutions.filter((x) => x.kind === 'fallback' || x.kind === 'theme-attr')) {
2011
+ p(`| ${cell(`${r.file}#${r.name}`)} | ${cell(r.reference)} | ${cell(r.resolved)} |`);
2012
+ }
2013
+ }
2014
+ p();
2015
+ p('### 4.4 运行时远程资源');
2016
+ p('本次转换未检出运行时远程资源(无 Lottie URL 或服务端下发图标)。');
2017
+ p();
2018
+
2019
+ p('## 五、代码内定义矢量图标(Compose Material Icons)');
2020
+ if (!this.ctx.icons?.icons) {
2021
+ p('本次未运行 `scripts/code_vector_icons.js`,或该工程未检出代码内定义图标。');
2022
+ p('若 Android 侧使用 Jetpack Compose,**必须**运行该脚本:这类图标不在 `res/` 内,本转换器看不见它们。');
2023
+ p();
2024
+ } else {
2025
+ p('这些图标由代码定义,不在 `res/` 内,`res/` 扫描看不见它们。清单来自 `classes*.dex`,图形来自固定版本的 Material Icons 快照。');
2026
+ p();
2027
+ p('| Compose 引用 | Material 名 | 风格 | HarmonyOS 目标 | 所属界面 | 建议页面 | 备注 |');
2028
+ p('|---|---|---|---|---|---|---|');
2029
+ for (const i of this.ctx.icons.icons) {
2030
+ const screens = Array.isArray(i.screens) ? (i.screens).join(', ') : String(i.screens ?? 'Unknown');
2031
+ const notes = Array.isArray(i.notes) ? (i.notes).join('; ') : '';
2032
+ p(`| ${cell(String(i.compose))} | ${cell(String(i.material))} | ${cell(String(i.style))} | ${cell(i.resolved ? `resources/base/media/${i.media}` : 'N/A')} | ${cell(screens)} | ${cell(String(i.suggestedPage ?? '—'))} | ${cell(notes || String(i.reason ?? ''))} |`);
2033
+ }
2034
+ p();
2035
+ }
2036
+
2037
+ p('## 六、Quick Findings(快速发现)');
2038
+ p(`- 启动图标:检出 ${c.layeredImages} 个 adaptive-icon,已转为 layered-image JSON;HarmonyOS 的 layered-image 只接受 \`$media:\`,颜色背景已生成纯色 PNG。`);
2039
+ p(`- 点九图:${c.ninePatchRenamed} 个 \`*.9.png\` 重命名为 \`*_9.png\`;拉伸行为丢失,需在 ArkUI 侧用可伸缩 Image 重新实现。`);
2040
+ p(`- 三方库资源:清单中 ${this.conv.inventory.filter((r) => r.sourceCategory === '第三方库资源').length} 条按 \`abc_*\` / \`mtrl_*\` / \`m3_*\` 等前缀推断来自三方库。`);
2041
+ if (c.mediaTargetCollisions > 0) {
2042
+ p(`- 目标冲突:${c.mediaTargetCollisions} 处多个 Android 源落到同一 HarmonyOS 目标(如 \`drawable/\` 与 \`drawable-anydpi/\`),按「限定符更少者胜」择一,落败方已在映射表中标记为 unmappable。`);
2043
+ }
2044
+ if (c.unresolvedPaint > 0) {
2045
+ p(`- SVG 颜色:${c.unresolvedPaint} 处 \`fillColor\` / \`strokeColor\` 在合并资源表中不存在(多为被裁剪的库资源),已按平台默认回退为黑色并逐条告警。`);
2046
+ }
2047
+ p(`- 样式与主题:\`<style>\` / \`<attr>\` / \`<declare-styleable>\` 在 HarmonyOS 无等价物,已全部登记为不可映射,需在 ArkUI 组件代码中重建。`);
2048
+ p();
2049
+ return o.join('\n');
2050
+ }
2051
+ }
2052
+
2053
+ // ─────────────────────────────────────────────
2054
+ // 入口
2055
+ // ─────────────────────────────────────────────
2056
+
2057
+ function parseArgs(argv) {
2058
+ const args = {};
2059
+ for (let i = 0; i < argv.length; i++) {
2060
+ const a = argv[i];
2061
+ if (!a.startsWith('--')) fail(`无法识别的参数: ${a}`);
2062
+ const next = argv[i + 1];
2063
+ if (next && !next.startsWith('--')) { args[a.slice(2)] = next; i++; }
2064
+ else args[a.slice(2)] = true;
2065
+ }
2066
+ return args;
2067
+ }
2068
+
2069
+ function main() {
2070
+ const args = parseArgs(process.argv.slice(2));
2071
+ const resDir = typeof args.res === 'string' ? args.res : null;
2072
+ const harmony = typeof args.harmony === 'string' ? args.harmony : null;
2073
+ const dryRun = Boolean(args['dry-run']);
2074
+ const quiet = Boolean(args.quiet);
2075
+
2076
+ const docsOnly = Boolean(args['docs-only']);
2077
+ if (docsOnly) {
2078
+ if (typeof args.records !== 'string') fail('--docs-only 需要 --records <已生成的 records JSON>');
2079
+ const bundle = JSON.parse(fs.readFileSync(args.records, 'utf8'));
2080
+ emitDocs(bundle, args, resDir ?? '(from records)', harmony);
2081
+ process.exit(0);
2082
+ }
2083
+
2084
+ if (!resDir) fail('缺少 --res <decoded res dir>');
2085
+ if (!harmony && !dryRun) fail('缺少 --harmony <harmony project dir>(或使用 --dry-run)');
2086
+ if (!fs.existsSync(resDir)) fail(`--res 路径不存在: ${resDir}`);
2087
+
2088
+ const uiModule = typeof args['ui-module'] === 'string' ? args['ui-module'] : 'entry';
2089
+ const conv = new Converter(resDir, harmony ?? '.', dryRun, uiModule);
2090
+ conv.run();
2091
+
2092
+ emitDocs(conv, args, resDir, harmony);
2093
+
2094
+ if (typeof args.records === 'string') {
2095
+ fs.writeFileSync(args.records, `${JSON.stringify({
2096
+ counts: conv.counts,
2097
+ qualifierUse: conv.qualifierUse,
2098
+ warnings: conv.warnings,
2099
+ conflicts: conv.conflicts,
2100
+ resolutions: conv.resolutions,
2101
+ inventory: conv.inventory,
2102
+ mappings: conv.mappings,
2103
+ }, null, 1)}
2104
+ `, 'utf8');
2105
+ }
2106
+
2107
+ if (!quiet) {
2108
+ const c = conv.counts;
2109
+ console.log('[a2h_resource_convert]');
2110
+ console.log(` values 条目转换 : ${c.valuesEntries}`);
2111
+ console.log(` 媒体文件拷贝 : ${c.mediaCopied}(其中点九图重命名 ${c.ninePatchRenamed})`);
2112
+ console.log(` raw / font / profile : ${c.rawCopied} / ${c.fontCopied} / ${c.profileCopied}`);
2113
+ console.log(` XML drawable → SVG : ${c.svgConverted}`);
2114
+ console.log(` layered-image JSON : ${c.layeredImages}`);
2115
+ console.log(` 不可映射 : ${c.unmappable}`);
2116
+ console.log(` 跳过的限定符目录 : ${c.skippedQualifierDirs}`);
2117
+ console.log(` 引用解析 : ${c.referencesResolved}(其中系统资源 ${c.systemReferencesResolved})`);
2118
+ console.log(` 兜底替换 : ${c.fallbacksApplied}`);
2119
+ console.log(` 颜色解析失败(SVG) : ${c.unresolvedPaint}`);
2120
+ console.log(` 目标冲突择一 : ${c.mediaTargetCollisions}`);
2121
+ console.log(` 失败 : ${c.failed}`);
2122
+ console.log(` inventory / mapping 行: ${conv.inventory.length} / ${conv.mappings.length}`);
2123
+ if (conv.conflicts.length > 0) console.log(` 同名覆盖 : ${conv.conflicts.length}`);
2124
+ if (conv.warnings.length > 0) console.log(` 告警 : ${conv.warnings.length}`);
2125
+ }
2126
+
2127
+ process.exit(conv.counts.failed > 0 ? 1 : 0);
2128
+ }
2129
+
2130
+ /** 生成两份 markdown。抽成函数以便 --docs-only 复用。 */
2131
+ function emitDocs(
2132
+ bundle,
2133
+ args,
2134
+ resDir,
2135
+ harmony,
2136
+ ) {
2137
+ if (typeof args.report !== 'string' && typeof args.mapping !== 'string') return;
2138
+ const readJson = (k) => {
2139
+ const v = args[k];
2140
+ if (typeof v !== 'string') return null;
2141
+ try { return JSON.parse(fs.readFileSync(v, 'utf8')); }
2142
+ catch (e) { fail(`--${k} 读取失败: ${(e).message}`); }
2143
+ };
2144
+
2145
+ {
2146
+ const doc = new DocWriter(bundle, {
2147
+ apkPath: typeof args.apk === 'string' ? args.apk : '(not provided)',
2148
+ resSource: 'decoded APK (complete merged resource set)',
2149
+ resDir,
2150
+ harmonyDir: harmony ?? '',
2151
+ appModule: typeof args['app-module'] === 'string' ? args['app-module'] : '(not provided)',
2152
+ androidCommit: typeof args['android-commit'] === 'string' ? args['android-commit'] : '(not provided)',
2153
+ iconSnapshotRef: '(not provided)',
2154
+ buildResult: typeof args['build-result'] === 'string' ? args['build-result'] : '(not run by this script)',
2155
+ icons: readJson('icons-json'),
2156
+ fidelity: readJson('fidelity-json'),
2157
+ });
2158
+ if (typeof args.report === 'string') fs.writeFileSync(args.report, `${doc.report()}
2159
+ `, 'utf8');
2160
+ if (typeof args.mapping === 'string') fs.writeFileSync(args.mapping, `${doc.mapping()}
2161
+ `, 'utf8');
2162
+ }
2163
+
2164
+ }
2165
+
2166
+ main();