dsh-visualkit 0.0.0-stage → 0.0.1

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 (283) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +26 -0
  3. package/README.md +427 -2
  4. package/THIRD_PARTY.md +55 -0
  5. package/cordis.patch.yml +29 -0
  6. package/data/assets/README.md +38 -0
  7. package/data/assets/icons/brands/bilibili.svg +1 -0
  8. package/data/assets/icons/brands/discord.svg +1 -0
  9. package/data/assets/icons/brands/neteasecloudmusic.svg +1 -0
  10. package/data/assets/icons/brands/qq.svg +1 -0
  11. package/data/assets/icons/brands/sinaweibo.svg +1 -0
  12. package/data/assets/icons/brands/steam.svg +1 -0
  13. package/data/assets/icons/brands/telegram.svg +1 -0
  14. package/data/assets/icons/brands/tiktok.svg +1 -0
  15. package/data/assets/icons/brands/twitch.svg +1 -0
  16. package/data/assets/icons/brands/wechat.svg +1 -0
  17. package/data/assets/icons/brands/x.svg +1 -0
  18. package/data/assets/icons/brands/youtube.svg +1 -0
  19. package/data/assets/icons/lucide/bell.svg +1 -0
  20. package/data/assets/icons/lucide/calendar.svg +1 -0
  21. package/data/assets/icons/lucide/clock.svg +1 -0
  22. package/data/assets/icons/lucide/gamepad-2.svg +1 -0
  23. package/data/assets/icons/lucide/gift.svg +1 -0
  24. package/data/assets/icons/lucide/heart.svg +1 -0
  25. package/data/assets/icons/lucide/mic.svg +1 -0
  26. package/data/assets/icons/lucide/play.svg +1 -0
  27. package/data/assets/icons/lucide/star.svg +1 -0
  28. package/data/assets/icons/lucide/video.svg +1 -0
  29. package/data/assets/icons/phosphor/LICENSE +21 -0
  30. package/data/assets/icons/phosphor/atom-duotone.svg +1 -0
  31. package/data/assets/icons/phosphor/binary-duotone.svg +1 -0
  32. package/data/assets/icons/phosphor/blueprint-duotone.svg +1 -0
  33. package/data/assets/icons/phosphor/circuitry-duotone.svg +1 -0
  34. package/data/assets/icons/phosphor/cloud-arrow-up-duotone.svg +1 -0
  35. package/data/assets/icons/phosphor/code-duotone.svg +1 -0
  36. package/data/assets/icons/phosphor/compass-duotone.svg +1 -0
  37. package/data/assets/icons/phosphor/cpu-duotone.svg +1 -0
  38. package/data/assets/icons/phosphor/cube-duotone.svg +1 -0
  39. package/data/assets/icons/phosphor/database-duotone.svg +1 -0
  40. package/data/assets/icons/phosphor/flask-duotone.svg +1 -0
  41. package/data/assets/icons/phosphor/frame-corners-duotone.svg +1 -0
  42. package/data/assets/icons/phosphor/function-duotone.svg +1 -0
  43. package/data/assets/icons/phosphor/hard-drives-duotone.svg +1 -0
  44. package/data/assets/icons/phosphor/hexagon-duotone.svg +1 -0
  45. package/data/assets/icons/phosphor/infinity-duotone.svg +1 -0
  46. package/data/assets/icons/phosphor/lightning-duotone.svg +1 -0
  47. package/data/assets/icons/phosphor/magic-wand-duotone.svg +1 -0
  48. package/data/assets/icons/phosphor/moon-stars-duotone.svg +1 -0
  49. package/data/assets/icons/phosphor/paint-brush-duotone.svg +1 -0
  50. package/data/assets/icons/phosphor/pen-nib-duotone.svg +1 -0
  51. package/data/assets/icons/phosphor/planet-duotone.svg +1 -0
  52. package/data/assets/icons/phosphor/rocket-launch-duotone.svg +1 -0
  53. package/data/assets/icons/phosphor/ruler-duotone.svg +1 -0
  54. package/data/assets/icons/phosphor/selection-plus-duotone.svg +1 -0
  55. package/data/assets/icons/phosphor/shapes-duotone.svg +1 -0
  56. package/data/assets/icons/phosphor/sparkle-duotone.svg +1 -0
  57. package/data/assets/icons/phosphor/sun-horizon-duotone.svg +1 -0
  58. package/data/assets/icons/phosphor/terminal-window-duotone.svg +1 -0
  59. package/data/assets/icons/phosphor/triangle-duotone.svg +1 -0
  60. package/data/assets/icons/phosphor/waveform-duotone.svg +1 -0
  61. package/data/assets/icons/tabler/alarm.svg +1 -0
  62. package/data/assets/icons/tabler/badge-ad.svg +1 -0
  63. package/data/assets/icons/tabler/bell.svg +1 -0
  64. package/data/assets/icons/tabler/bolt.svg +1 -0
  65. package/data/assets/icons/tabler/brand-bilibili.svg +1 -0
  66. package/data/assets/icons/tabler/brand-discord.svg +1 -0
  67. package/data/assets/icons/tabler/brand-qq.svg +1 -0
  68. package/data/assets/icons/tabler/brand-twitch.svg +1 -0
  69. package/data/assets/icons/tabler/brand-wechat.svg +1 -0
  70. package/data/assets/icons/tabler/brand-weibo.svg +1 -0
  71. package/data/assets/icons/tabler/brand-youtube.svg +1 -0
  72. package/data/assets/icons/tabler/broadcast.svg +1 -0
  73. package/data/assets/icons/tabler/calendar-event.svg +1 -0
  74. package/data/assets/icons/tabler/calendar-week.svg +1 -0
  75. package/data/assets/icons/tabler/clock-hour-3.svg +1 -0
  76. package/data/assets/icons/tabler/coin.svg +1 -0
  77. package/data/assets/icons/tabler/crown.svg +1 -0
  78. package/data/assets/icons/tabler/currency-yen.svg +1 -0
  79. package/data/assets/icons/tabler/device-gamepad-2.svg +1 -0
  80. package/data/assets/icons/tabler/diamond.svg +1 -0
  81. package/data/assets/icons/tabler/flame.svg +1 -0
  82. package/data/assets/icons/tabler/gift.svg +1 -0
  83. package/data/assets/icons/tabler/headphones.svg +1 -0
  84. package/data/assets/icons/tabler/heart-filled.svg +1 -0
  85. package/data/assets/icons/tabler/heart.svg +1 -0
  86. package/data/assets/icons/tabler/live-view.svg +1 -0
  87. package/data/assets/icons/tabler/microphone.svg +1 -0
  88. package/data/assets/icons/tabler/mood-smile.svg +1 -0
  89. package/data/assets/icons/tabler/mood-tongue.svg +1 -0
  90. package/data/assets/icons/tabler/movie.svg +1 -0
  91. package/data/assets/icons/tabler/player-play.svg +1 -0
  92. package/data/assets/icons/tabler/playlist.svg +1 -0
  93. package/data/assets/icons/tabler/rocket.svg +1 -0
  94. package/data/assets/icons/tabler/sparkles.svg +1 -0
  95. package/data/assets/icons/tabler/star-filled.svg +1 -0
  96. package/data/assets/icons/tabler/star.svg +1 -0
  97. package/data/assets/icons/tabler/tag.svg +1 -0
  98. package/data/assets/icons/tabler/thumb-up.svg +1 -0
  99. package/data/assets/icons/tabler/ticket.svg +1 -0
  100. package/data/assets/icons/tabler/video.svg +1 -0
  101. package/data/assets/illustrations/openpeeps/LICENSE +8 -0
  102. package/data/assets/illustrations/openpeeps/peep-10.svg +29 -0
  103. package/data/assets/illustrations/openpeeps/peep-100.svg +32 -0
  104. package/data/assets/illustrations/openpeeps/peep-101.svg +25 -0
  105. package/data/assets/illustrations/openpeeps/peep-102.svg +28 -0
  106. package/data/assets/illustrations/openpeeps/peep-103.svg +32 -0
  107. package/data/assets/illustrations/openpeeps/peep-105.svg +25 -0
  108. package/data/assets/illustrations/openpeeps/peep-12.svg +29 -0
  109. package/data/assets/illustrations/openpeeps/peep-13.svg +27 -0
  110. package/data/assets/illustrations/openpeeps/peep-16.svg +27 -0
  111. package/data/assets/illustrations/openpeeps/peep-17.svg +31 -0
  112. package/data/assets/illustrations/openpeeps/peep-22.svg +29 -0
  113. package/data/assets/illustrations/openpeeps/peep-25.svg +29 -0
  114. package/data/assets/illustrations/openpeeps/peep-26.svg +29 -0
  115. package/data/assets/illustrations/openpeeps/peep-27.svg +29 -0
  116. package/data/assets/illustrations/openpeeps/peep-30.svg +29 -0
  117. package/data/assets/illustrations/openpeeps/peep-31.svg +27 -0
  118. package/data/assets/illustrations/openpeeps/peep-34.svg +29 -0
  119. package/data/assets/illustrations/openpeeps/peep-38.svg +29 -0
  120. package/data/assets/illustrations/openpeeps/peep-39.svg +31 -0
  121. package/data/assets/illustrations/openpeeps/peep-4.svg +29 -0
  122. package/data/assets/illustrations/openpeeps/peep-41.svg +29 -0
  123. package/data/assets/illustrations/openpeeps/peep-44.svg +29 -0
  124. package/data/assets/illustrations/openpeeps/peep-47.svg +26 -0
  125. package/data/assets/illustrations/openpeeps/peep-48.svg +29 -0
  126. package/data/assets/illustrations/openpeeps/peep-50.svg +31 -0
  127. package/data/assets/illustrations/openpeeps/peep-51.svg +29 -0
  128. package/data/assets/illustrations/openpeeps/peep-53.svg +27 -0
  129. package/data/assets/illustrations/openpeeps/peep-54.svg +26 -0
  130. package/data/assets/illustrations/openpeeps/peep-55.svg +29 -0
  131. package/data/assets/illustrations/openpeeps/peep-56.svg +27 -0
  132. package/data/assets/illustrations/openpeeps/peep-58.svg +29 -0
  133. package/data/assets/illustrations/openpeeps/peep-61.svg +27 -0
  134. package/data/assets/illustrations/openpeeps/peep-64.svg +27 -0
  135. package/data/assets/illustrations/openpeeps/peep-68.svg +27 -0
  136. package/data/assets/illustrations/openpeeps/peep-69.svg +27 -0
  137. package/data/assets/illustrations/openpeeps/peep-7.svg +31 -0
  138. package/data/assets/illustrations/openpeeps/peep-72.svg +29 -0
  139. package/data/assets/illustrations/openpeeps/peep-76.svg +31 -0
  140. package/data/assets/illustrations/openpeeps/peep-77.svg +27 -0
  141. package/data/assets/illustrations/openpeeps/peep-80.svg +27 -0
  142. package/data/assets/illustrations/openpeeps/peep-81.svg +29 -0
  143. package/data/assets/illustrations/openpeeps/peep-83.svg +29 -0
  144. package/data/assets/manifest.json +1 -0
  145. package/data/assets/parts//342/206/223/346/212/225/345/275/261.png +0 -0
  146. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2711.png +0 -0
  147. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2712.png +0 -0
  148. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2713.png +0 -0
  149. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2714.png +0 -0
  150. package/data/assets/parts//345/217/214/351/251/254/345/260/276.png +0 -0
  151. package/data/assets/parts//345/217/214/351/251/254/345/260/2761.png +0 -0
  152. package/data/assets/parts//345/217/2211.png +0 -0
  153. package/data/assets/parts//345/217/2212.png +0 -0
  154. package/data/assets/parts//345/217/2213.png +0 -0
  155. package/data/assets/parts//345/217/221/345/234/210.png +0 -0
  156. package/data/assets/parts//345/217/221/345/244/2711.png +0 -0
  157. package/data/assets/parts//345/217/221/345/244/2712.png +0 -0
  158. package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/211/215.png +0 -0
  159. package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/220/216.png +0 -0
  160. package/data/assets/parts//345/217/263/344/270/212/347/234/274/347/235/221.png +0 -0
  161. package/data/assets/parts//345/217/263/346/211/213.png +0 -0
  162. package/data/assets/parts//345/217/263/346/211/2131.png +0 -0
  163. package/data/assets/parts//345/217/263/347/234/274/347/220/203.png +0 -0
  164. package/data/assets/parts//345/217/263/347/234/274/347/231/275.png +0 -0
  165. package/data/assets/parts//345/217/263/350/205/277.png +0 -0
  166. package/data/assets/parts//345/221/206/346/257/233.png +0 -0
  167. package/data/assets/parts//345/230/264/345/267/264.png +0 -0
  168. package/data/assets/parts//345/260/276/345/267/264.png +0 -0
  169. package/data/assets/parts//345/267/246/344/270/212/347/234/274/347/235/221.png +0 -0
  170. package/data/assets/parts//345/267/246/345/217/214/347/234/274/347/232/256.png +0 -0
  171. package/data/assets/parts//345/267/246/346/211/213.png +0 -0
  172. package/data/assets/parts//345/267/246/346/211/2131.png +0 -0
  173. package/data/assets/parts//345/267/246/347/234/274/347/220/203.png +0 -0
  174. package/data/assets/parts//345/267/246/347/234/274/347/231/275.png +0 -0
  175. package/data/assets/parts//345/267/246/350/205/277.png +0 -0
  176. package/data/assets/parts//346/212/225/345/275/261.png +0 -0
  177. package/data/assets/parts//347/234/211/346/257/233.png +0 -0
  178. package/data/assets/parts//347/234/274/347/275/251.png +0 -0
  179. package/data/assets/parts//350/200/263/346/234/2651.png +0 -0
  180. package/data/assets/parts//350/200/263/346/234/2652.png +0 -0
  181. package/data/assets/parts//350/204/221/345/243/263.png +0 -0
  182. package/data/assets/parts//350/241/243/346/234/215/345/211/215.png +0 -0
  183. package/data/assets/parts//350/241/243/346/234/215/345/220/216.png +0 -0
  184. package/data/assets/parts//350/241/243/346/234/215/351/273/221.png +0 -0
  185. package/data/assets/parts//350/241/243/346/234/215/351/273/2211.png +0 -0
  186. package/data/assets/parts//350/272/253/344/275/223.png +0 -0
  187. package/data/assets/parts//351/223/201/346/235/277.png +0 -0
  188. package/data/assets/parts//351/273/221/347/234/274/345/234/210.png +0 -0
  189. package/data/assets/parts//351/273/221/347/234/274/345/234/2102.png +0 -0
  190. package/data/assets/patterns/checker.svg +1 -0
  191. package/data/assets/patterns/circles-overlap.svg +1 -0
  192. package/data/assets/patterns/crosshatch.svg +1 -0
  193. package/data/assets/patterns/diagonal.svg +1 -0
  194. package/data/assets/patterns/dots.svg +1 -0
  195. package/data/assets/patterns/grid.svg +1 -0
  196. package/data/assets/patterns/halftone.svg +1 -0
  197. package/data/assets/patterns/plus.svg +1 -0
  198. package/data/assets/patterns/scales.svg +1 -0
  199. package/data/assets/patterns/triangles.svg +1 -0
  200. package/data/assets/patterns/waves.svg +1 -0
  201. package/data/assets/patterns/zigzag.svg +1 -0
  202. package/data/assets/stickers/openmoji/1F375.svg +12 -0
  203. package/data/assets/stickers/openmoji/1F9E8.svg +21 -0
  204. package/data/assets/stickers/twemoji/1f389.svg +1 -0
  205. package/data/assets/stickers/twemoji/1f3a4.svg +1 -0
  206. package/data/assets/stickers/twemoji/1f3a7.svg +1 -0
  207. package/data/assets/stickers/twemoji/1f3ae.svg +1 -0
  208. package/data/assets/stickers/twemoji/1f44b.svg +1 -0
  209. package/data/assets/stickers/twemoji/1f44d.svg +1 -0
  210. package/data/assets/stickers/twemoji/1f494.svg +1 -0
  211. package/data/assets/stickers/twemoji/1f4a2.svg +1 -0
  212. package/data/assets/stickers/twemoji/1f4a8.svg +1 -0
  213. package/data/assets/stickers/twemoji/1f4b5.svg +1 -0
  214. package/data/assets/stickers/twemoji/1f4d6.svg +1 -0
  215. package/data/assets/stickers/twemoji/1f525.svg +1 -0
  216. package/data/assets/stickers/twemoji/1f600.svg +1 -0
  217. package/data/assets/stickers/twemoji/1f602.svg +1 -0
  218. package/data/assets/stickers/twemoji/1f60d.svg +1 -0
  219. package/data/assets/stickers/twemoji/1f62d.svg +1 -0
  220. package/data/assets/stickers/twemoji/1f680.svg +1 -0
  221. package/data/assets/stickers/twemoji/1f979.svg +1 -0
  222. package/data/assets/stickers/twemoji/1f97a.svg +1 -0
  223. package/data/assets/stickers/twemoji/26a1.svg +1 -0
  224. package/data/assets/stickers/twemoji/2705.svg +1 -0
  225. package/data/assets/stickers/twemoji/274c.svg +1 -0
  226. package/data/assets/stickers/twemoji/2764.svg +1 -0
  227. package/data/assets/stickers/twemoji/2b50.svg +1 -0
  228. package/data/assets/stickers/twemoji_1f525.svg +1 -0
  229. package/data/assets/stickers/twemoji_1f600.svg +1 -0
  230. package/data/assets/stickers/twemoji_1f979.svg +1 -0
  231. package/data/assets/stickers/twemoji_2764.svg +1 -0
  232. package/data/assets/textures/grain-dark.png +0 -0
  233. package/data/assets/textures/grain-light.png +0 -0
  234. package/data/assets/textures/grain-warm.png +0 -0
  235. package/data/lucide-icons.json +1 -0
  236. package/data/packs/tabler.icons.json +1 -0
  237. package/icon.svg +26 -0
  238. package/lib/assets.js +228 -0
  239. package/lib/audit.js +284 -0
  240. package/lib/contrast-render.js +213 -0
  241. package/lib/css-rewrite.js +379 -0
  242. package/lib/detect.js +701 -0
  243. package/lib/diff.js +153 -0
  244. package/lib/emit.js +557 -0
  245. package/lib/html-context.js +315 -0
  246. package/lib/icon-render.js +107 -0
  247. package/lib/icondb.js +402 -0
  248. package/lib/image-normalize.js +140 -0
  249. package/lib/index.js +1289 -0
  250. package/lib/layouts/common.js +48 -0
  251. package/lib/layouts/image.js +1010 -0
  252. package/lib/layouts/index.js +10 -0
  253. package/lib/layouts/intent.js +66 -0
  254. package/lib/layouts/metrics-text.js +91 -0
  255. package/lib/layouts/png.js +75 -0
  256. package/lib/layouts/rules-contrast.js +575 -0
  257. package/lib/layouts/rules-punct.js +486 -0
  258. package/lib/layouts/rules-structure.js +182 -0
  259. package/lib/layouts/source.js +1961 -0
  260. package/lib/layouts/svg.js +1204 -0
  261. package/lib/morphcore.js +139 -0
  262. package/lib/preview.js +225 -0
  263. package/lib/render-html.js +255 -0
  264. package/lib/render.js +486 -0
  265. package/lib/rules.js +267 -0
  266. package/lib/shadow.js +83 -0
  267. package/lib/skip.js +56 -0
  268. package/lib/style.js +688 -0
  269. package/lib/svg.js +729 -0
  270. package/lib/taste-image.js +323 -0
  271. package/lib/taste.js +219 -0
  272. package/locale/en.json +6 -0
  273. package/locale/zh.json +6 -0
  274. package/package.json +92 -4
  275. package/vendor/morphicons-adapters.iife.js +903 -0
  276. package/vendor/morphicons.iife.js +2441 -0
  277. package/vendor/resvg/LICENSE +373 -0
  278. package/vendor/resvg/index.d.ts +98 -0
  279. package/vendor/resvg/index.js +23 -0
  280. package/vendor/resvg/js-binding.d.ts +52 -0
  281. package/vendor/resvg/js-binding.js +1 -0
  282. package/vendor/resvg/package.json +138 -0
  283. package/vendor/resvg/resvgjs.win32-x64-msvc.node +0 -0
package/lib/svg.js ADDED
@@ -0,0 +1,729 @@
1
+ /**
2
+ * svg.js — IconNode ⇄ SVG 标记的序列化/解析/体检。
3
+ *
4
+ * parseIconNodes 是 Colibri js/ui.js 同名函数的语义级移植(兼容性契约):
5
+ * morphicons 只认 path/line/circle/ellipse/rect/polyline/polygon 七种基本形,
6
+ * 含 <g>/transform/未识别标签即不兼容。两边必须保持判定一致,
7
+ * 否则「体检通过」到了项目里照样降级静态换图。
8
+ */
9
+ import { getIcon, shapeSummary } from "./icondb.js";
10
+ import { parseGradientStops } from "./icon-render.js";
11
+ import { adaptersSync } from "./morphcore.js";
12
+
13
+ /** IconNode([tag, attrs][])→ 内联 SVG 字符串(Colibri ICONS 字典同格式)。
14
+ * extraAttrs 会合并到每个元素上(覆盖同名属性)——colibri/echo 的 inner 嵌在外层 SVG 里,
15
+ * 不能靠 `<g>` 分组表达 duotone 的填充层(parseIconNodes 不认 <g>),只能下推到元素属性。 */
16
+ function iconToInnerSvg(node, extraAttrs) {
17
+ const esc = (v) => String(v).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
18
+ const extra = extraAttrs ? Object.entries(extraAttrs).map(([k, v]) => ` ${k}="${esc(v)}"`).join("") : "";
19
+ return node.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([k, v]) => `${k}="${esc(v)}"`).join(" ")}${extra}/>`).join("");
20
+ }
21
+
22
+ /**
23
+ * 根层要控制的画笔属性。包数据(尤其 tabler)把 stroke-width/linecap/linejoin
24
+ * 写在了每个 path 上,会**覆盖**根 <svg>/<g> 的同名属性——实测 tabler:search 把
25
+ * strokeWidth 从 1 改到 4,渲染像素一模一样(ratio 1.00),参数形同虚设。
26
+ * 所以凡是由本模块控制这些属性时,先把元素上的同名属性剥掉。
27
+ * 默认值与包数据一致(2 / round / round),所以剥掉不改变默认观感。
28
+ */
29
+ const PAINT_KEYS = ["stroke-width", "stroke-linecap", "stroke-linejoin"];
30
+
31
+ function stripPaint(node) {
32
+ return node.map(([tag, attrs]) => {
33
+ const next = { ...attrs };
34
+ for (const key of PAINT_KEYS) delete next[key];
35
+ return [tag, next];
36
+ });
37
+ }
38
+
39
+ /** IconNode → 完整 <svg> 标记(Colibri UI.svg 同构:24 视口 stroke 风格)。 */
40
+ function renderSvg(node, opts = {}) {
41
+ const size = opts.size ?? 24;
42
+ const strokeWidth = opts.strokeWidth ?? 2;
43
+ const color = opts.color ?? "currentColor";
44
+ const linecap = opts.linecap ?? "round";
45
+ const linejoin = opts.linejoin ?? "round";
46
+ const cls = opts.className ? ` class="${opts.className}"` : "";
47
+ const inner = iconToInnerSvg(stripPaint(node));
48
+ // fill 风格图标(品牌/实心包数据):根壳不能再继承 stroke(会多圈描边),
49
+ // 改用 color 属性让内部 fill="currentColor" 仍能随 color 参数变色。
50
+ const fillDrawn = node.some(([, attrs]) => typeof attrs?.fill === "string" && attrs.fill !== "none");
51
+ const paint = fillDrawn
52
+ ? `fill="none" stroke="none" color="${color}"`
53
+ : `fill="none" stroke="${color}" color="${color}" stroke-width="${strokeWidth}" stroke-linecap="${linecap}" stroke-linejoin="${linejoin}"`;
54
+ return (
55
+ `<svg${cls} xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" ` +
56
+ `${paint}>${inner}</svg>`
57
+ );
58
+ }
59
+
60
+ /**
61
+ * 多层渲染(duotone):base 作低透明度填充层(副色),accent 作线稿层(主色)。
62
+ * Phosphor 式 duotone = 实心形 20% 透明度 + 线性轮廓;tabler 有 1057 对 −filled/线性双胞胎,
63
+ * 不需要额外装一个 duotone 数据集(省 1.5–2MB 包体积)。
64
+ */
65
+ function renderSvgLayers({ base, accent, size = 24, strokeWidth = 2, color = "currentColor", secondaryOpacity = 0.2, linecap = "round", linejoin = "round", className }) {
66
+ const cls = className ? ` class="${className}"` : "";
67
+ const under = `<g fill="${color}" fill-opacity="${secondaryOpacity}" stroke="none">${iconToInnerSvg(stripPaint(base))}</g>`;
68
+ const over = `<g fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-linecap="${linecap}" stroke-linejoin="${linejoin}">${iconToInnerSvg(stripPaint(accent))}</g>`;
69
+ return (
70
+ `<svg${cls} xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" color="${color}">` +
71
+ `${under}${over}</svg>`
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Colibri parseIconNodes 的移植(判定语义逐条对齐):
77
+ * 内联 SVG 字符串 → IconNode;不兼容(g/transform/未识别标签/空)返回 null。
78
+ */
79
+ function parseIconNodes(inner) {
80
+ const src = String(inner ?? "");
81
+ if (!src || /<g[\s>]|transform=/.test(src)) return null;
82
+ const tagRe = /<(path|line|circle|ellipse|rect|polyline|polygon)\b([^>]*?)\/>/g;
83
+ if (/<[a-zA-Z/]/.test(src.replace(tagRe, ""))) return null; // 未识别标签残留(配对标签/text 等)
84
+ const out = [];
85
+ let m;
86
+ while ((m = tagRe.exec(src))) {
87
+ const attrs = {};
88
+ const attrRe = /([a-zA-Z][\w-]*)="([^"]*)"/g;
89
+ let a;
90
+ while ((a = attrRe.exec(m[2]))) attrs[a[1]] = a[2];
91
+ out.push([m[1], attrs]);
92
+ }
93
+ return out.length ? out : null;
94
+ }
95
+
96
+ /** 宽松取「图标内容」:接受内联串或完整 <svg> 标记(剥壳取 inner + viewBox)。 */
97
+ function extractInner(input) {
98
+ const src = String(input ?? "").trim();
99
+ if (src === "") return { inner: "", viewBox: undefined };
100
+ const svgOpen = src.match(/<svg\b[^>]*>/i);
101
+ if (svgOpen) {
102
+ const close = src.toLowerCase().lastIndexOf("</svg>");
103
+ const start = src.indexOf(svgOpen[0]) + svgOpen[0].length;
104
+ const end = close === -1 ? src.length : close;
105
+ const vb = svgOpen[0].match(/viewBox\s*=\s*"([^"]*)"/i);
106
+ return { inner: src.slice(start, end).trim(), viewBox: vb ? vb[1] : undefined };
107
+ }
108
+ return { inner: src, viewBox: undefined };
109
+ }
110
+
111
+ /** 明确的绝对坐标几何属性(path 的 d 串不查——曲线控制点合法出界,且
112
+ * 相对增量的负数不是坐标;v0.3.0 修复误报)。 */
113
+ const GEO_NUM_ATTRS = new Set(["x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry", "width", "height"]);
114
+
115
+ /** 属性级网格检查:只查明确的几何数值属性,命中一个即返回描述。 */
116
+ function offGridAttr(node) {
117
+ for (const [tag, attrs] of node) {
118
+ for (const [key, raw] of Object.entries(attrs)) {
119
+ if (key === "points") {
120
+ for (const num of String(raw).split(/[\s,]+/).map(Number)) {
121
+ if (Number.isFinite(num) && (num > 26 || num < -2)) return `${tag}.points=${num}`;
122
+ }
123
+ continue;
124
+ }
125
+ if (!GEO_NUM_ATTRS.has(key)) continue;
126
+ const v = Number(raw);
127
+ if (Number.isFinite(v) && (v > 26 || v < -2)) return `${tag}.${key}=${v}`;
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ /**
134
+ * 形变兼容性体检(v0.3.0 双层判定):
135
+ * 契约层(ok/parsed)——parseIconNodes 移植,与 Colibri/Echo 运行时管线逐字对齐;
136
+ * 引擎层(engine)——morphicons 2.x 官方 svgToIcon 权威判定(adapters 可用时),
137
+ * fill 标记按 region 可形变、transform 烘焙进几何、viewBox 自动 re-grid(fitIcon)。
138
+ * 输入可为内联串 / 完整 svg 标记 / IconNode 数组 / lucide 图标名。
139
+ */
140
+ function checkIcon(input, opts = {}) {
141
+ const result = { ok: false, parsed: false, nodeCount: 0, shapes: "", issues: [], warnings: [] };
142
+ let inner;
143
+ let nodeInput = null;
144
+ let sourceMarkup = null; // 引擎层判定用的原始标记(含或不含 <svg> 壳)
145
+ let viewBox;
146
+
147
+ if (Array.isArray(input)) {
148
+ nodeInput = input;
149
+ inner = iconToInnerSvg(input);
150
+ } else if (typeof input === "string" && opts.nameLookup !== false && !input.includes("<")) {
151
+ const icon = getIcon(input);
152
+ if (icon === undefined) {
153
+ result.issues.push(`unknown lucide icon name "${input}"`);
154
+ return result;
155
+ }
156
+ result.name = icon.name;
157
+ nodeInput = icon.node;
158
+ inner = iconToInnerSvg(icon.node);
159
+ } else {
160
+ const extracted = extractInner(input);
161
+ inner = extracted.inner;
162
+ viewBox = extracted.viewBox;
163
+ sourceMarkup = String(input);
164
+ if (viewBox !== undefined && viewBox.replace(/\s+/g, "") !== "002424") {
165
+ // 这是**阻塞项**而非提醒:项目管线(UI.svg / morphTo)按 24 网格取数,非 24 网格
166
+ // 直接接线会裁切/错位。ok=true 会让模型以为「体检通过可以接了」,与产品承诺相反。
167
+ result.issues.push(`viewBox "${viewBox}" 不是 0 0 24 24 —— 项目管线要求 24 网格:先归一化再接线(morph preview / design_render 会自动 re-grid,UI.svg / morphTo 不会)`);
168
+ }
169
+ }
170
+ if (inner === "") {
171
+ result.issues.push("empty icon content");
172
+ return result;
173
+ }
174
+
175
+ // 契约层逐坑诊断(顺序即优先级):g / transform / 未识别标签 / 空
176
+ const src = inner;
177
+ if (/<g[\s>]/i.test(src)) result.issues.push("含 <g> 分组 —— morphicons 只支持扁平结构");
178
+ if (/transform\s*=/i.test(src)) result.issues.push("含 transform 属性 —— 形变引擎不认局部坐标系");
179
+ const tagRe = /<(path|line|circle|ellipse|rect|polyline|polygon)\b[^>]*?\/>/gi;
180
+ const residual = src.replace(tagRe, "");
181
+ const unknown = residual.match(/<([a-zA-Z][\w:-]*)/);
182
+ if (unknown) result.issues.push(`未识别元素 <${unknown[1]}>(配对标签/clipPath/mask/text 等均不兼容)`);
183
+ if (/fill\s*=\s*"(?!none)[^"]*"/i.test(src)) result.warnings.push('含非 none 的 fill —— morphicons 2.x 可形变(fill 走 region),但 Colibri/Echo 的 UI.svg 仍是 stroke 渲染,接线前确认项目支持');
184
+ if (/style\s*=/i.test(src)) result.warnings.push("含 style 内联样式 —— 建议转为独立属性");
185
+
186
+ const node = nodeInput !== null && result.issues.length === 0 ? nodeInput : parseIconNodes(inner);
187
+ if (node === null || node.length === 0) {
188
+ if (result.issues.length === 0) result.issues.push("解析不出任何基本形节点");
189
+ return withEngine(result, sourceMarkup, inner, viewBox);
190
+ }
191
+ result.parsed = true;
192
+ result.nodeCount = node.length;
193
+ result.shapes = shapeSummary(node);
194
+ result.node = node;
195
+
196
+ // 属性级网格检查(v0.3.0:只查明确几何属性,修掉 d 串相对增量的误报)。
197
+ // 与 viewBox 同理:坐标超 24 就是接不进管线的数据,算 issue。
198
+ const offGrid = offGridAttr(node);
199
+ if (offGrid !== undefined) {
200
+ result.issues.push(`几何属性超出 24×24 网格(${offGrid})—— 先归一化到 24 网格再接线(path 曲线控制点出界不查,属合法)`);
201
+ }
202
+ result.ok = result.issues.length === 0;
203
+
204
+ return withEngine(result, sourceMarkup, inner, viewBox);
205
+ }
206
+
207
+ /** 引擎层判定:svgToIcon 权威裁决(adapters 未就绪时静默跳过)。 */
208
+ function withEngine(result, sourceMarkup, inner, viewBox) {
209
+ const adapters = adaptersSync();
210
+ if (adapters === undefined) return result;
211
+ const markup =
212
+ sourceMarkup !== null && sourceMarkup.includes("<svg")
213
+ ? sourceMarkup
214
+ : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox ?? "0 0 24 24"}" fill="none" stroke="currentColor" stroke-width="2">${inner}</svg>`;
215
+ try {
216
+ adapters.svgToIcon(markup); // 可能抛:引擎层拒绝本身就是判定结果
217
+ // 只有源 viewBox 不是 24 网格时才报「已 re-grid」——否则标准 24 网格图标的体检
218
+ // 文本也会写「viewBox 已自动 re-grid」,让人以为发生过转换(实测踩过)。
219
+ // 注意不能用返回值类型判断:stroke 型图标本来就会返回规范化 path 串。
220
+ const needsRegrid = viewBox !== undefined && viewBox.replace(/\s+/g, "") !== "002424";
221
+ result.engine = needsRegrid ? { accepted: true, regridded: true } : { accepted: true };
222
+ } catch (error) {
223
+ result.engine = { accepted: false, reason: String(error?.message ?? error).replace(/^morphicons:\s*/, "") };
224
+ }
225
+ return result;
226
+ }
227
+
228
+ /** 稳定短哈希(内容 → 36 进制字符串)。用于让内联 SVG 的 defs id 不碰撞。
229
+ * 不用随机数:同样的输入必须产出同样的字节(确定性是测试契约)。 */
230
+ function hash36(str) {
231
+ let h = 2166136261;
232
+ for (let i = 0; i < str.length; i += 1) {
233
+ h ^= str.charCodeAt(i);
234
+ h = Math.imul(h, 16777619);
235
+ }
236
+ return (h >>> 0).toString(36);
237
+ }
238
+
239
+ /** #rgb / #rrggbb → {r,g,b};其它写法(命名色/rgb())返回 null(渐变只接受 hex)。 */
240
+ function hexOf(value) {
241
+ const text = String(value ?? "").trim();
242
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(text);
243
+ if (short !== null) return { r: parseInt(short[1] + short[1], 16), g: parseInt(short[2] + short[2], 16), b: parseInt(short[3] + short[3], 16) };
244
+ const full = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(text);
245
+ if (full !== null) return { r: parseInt(full[1], 16), g: parseInt(full[2], 16), b: parseInt(full[3], 16) };
246
+ return null;
247
+ }
248
+
249
+ /** 亮度偏移:positive 向白,negative 向黑(0–1)。用于渐变末色,免手填两个色。 */
250
+ function shiftColor(hex, amount) {
251
+ const c = hexOf(hex);
252
+ if (c === null) return null;
253
+ const mix = (v) => {
254
+ const target = amount >= 0 ? 255 : 0;
255
+ return Math.round(v + (target - v) * Math.abs(amount));
256
+ };
257
+ const hex2 = (v) => v.toString(16).padStart(2, "0");
258
+ return `#${hex2(mix(c.r))}${hex2(mix(c.g))}${hex2(mix(c.b))}`.toUpperCase();
259
+ }
260
+
261
+ /**
262
+ * 容器型图标:glyph 装进底色块里(badge = 圆角方,knockout = 正圆)。
263
+ *
264
+ * **清晰度两个坑(2026-11-07 实测,24px 下字形糊成一团):**
265
+ * 1. 填充字形被「又填又描」:包数据的 path 自带 fill="currentColor",外层又给了
266
+ * stroke,于是字形外圈多糊一层。填充型只能填、不能描。
267
+ * 2. 描边没按 scale 补偿:字形被 scale(0.42) 缩小后,stroke-width 跟着缩,
268
+ * 24px 图标里的实际描边只有 0.92px(<1 像素必然发灰)。所以 strokeWidth 要 /s。
269
+ * 另外 inset 从 0.29 收到 0.24:字形大 1.4 个单位,可读像素多一层。
270
+ */
271
+ function renderSvgChip({ node, kind = "badge", size = 24, color = "currentColor", glyphColor = "#FFFFFF", inset = 0.24, strokeWidth = 1.8, radiusRatio = 0.3, linecap = "round", linejoin = "round", className }) {
272
+ const cls = className ? ` class="${className}"` : "";
273
+ const s = Math.round((1 - inset * 2) * 1000) / 1000;
274
+ const offset = 12 - (24 * s) / 2;
275
+ // 容器铺满 24 网格(图标场景里留 0.8 内缩会让它看起来像没对齐的贴纸)
276
+ const chip =
277
+ kind === "knockout"
278
+ ? `<circle cx="12" cy="12" r="12" fill="${color}"/>`
279
+ : `<rect x="0" y="0" width="24" height="24" rx="${Math.round(24 * radiusRatio * 100) / 100}" fill="${color}"/>`;
280
+ const stripped = stripPaint(node);
281
+ const fillDrawn = node.some(([, attrs]) => typeof attrs?.fill === "string" && attrs.fill !== "none");
282
+ // 填充字形:只填不描(描边会在字形外圈糊一层);描边字形:描边宽度按 scale 反向补偿
283
+ const paint = fillDrawn
284
+ ? `fill="${glyphColor}" stroke="none"`
285
+ : `fill="none" stroke="${glyphColor}" stroke-width="${Math.round((strokeWidth / s) * 1000) / 1000}"`;
286
+ // color= 让残留的 currentColor(如果有)也反白
287
+ const glyph = `<g transform="translate(${offset} ${offset}) scale(${s})" color="${glyphColor}"><g ${paint} stroke-linecap="${linecap}" stroke-linejoin="${linejoin}">${iconToInnerSvg(stripped)}</g></g>`;
288
+ return `<svg${cls} xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">${chip}${glyph}</svg>`;
289
+ }
290
+
291
+ /**
292
+ * 渐变描边图标:color 作起色,末色按亮度偏移自动导出(可传 color2 覆盖)。
293
+ * 非 hex 颜色无法算末色 → 返回 null,调用方回退实色并提示。
294
+ */
295
+ function renderSvgGradient({ node, size = 24, color = "currentColor", color2, strokeWidth = 2.2, linecap = "round", linejoin = "round", className }) {
296
+ const end = color2 ?? shiftColor(color, -0.28);
297
+ if (hexOf(color) === null || end === null || hexOf(end) === null) return null;
298
+ const cls = className ? ` class="${className}"` : "";
299
+ return (
300
+ `<svg${cls} xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">` +
301
+ `<defs><linearGradient id="ak-grad" x1="0" y1="0" x2="1" y2="1">` +
302
+ `<stop offset="0" stop-color="${color}"/><stop offset="1" stop-color="${end}"/></linearGradient></defs>` +
303
+ `<g fill="none" stroke="url(#ak-grad)" stroke-width="${strokeWidth}" stroke-linecap="${linecap}" stroke-linejoin="${linejoin}">${iconToInnerSvg(stripPaint(node))}</g></svg>`
304
+ );
305
+ }
306
+
307
+ /**
308
+ * 正交维度合成器:把图标视觉语言拆成可任意组合的轴,而不是互斥的 style 枚举。
309
+ *
310
+ * 图层顺序(从下到上):容器底 → 双色底图 → 字形(渐变或实色)
311
+ * 任一轴不传 = 不启用;全部不传 = 纯线性图标。
312
+ *
313
+ * @param {object} o
314
+ * @param {Array} o.node 字形(IconNode)
315
+ * @param {"stroke"|"fill"|"both"} [o.paint="stroke"] 绘法(both = 填充+描边共存)
316
+ * @param {number} [o.strokeWidth=2]
317
+ * @param {"round"|"butt"|"square"} [o.caps="round"] 线端(自动配对 join)
318
+ * @param {"round"|"miter"|"bevel"} [o.join] 显式转角(默认跟 caps)
319
+ * @param {null|{kind:"rounded"|"circle"|"squircle", radius?:number, inset?:number, color?:string}} [o.container]
320
+ * @param {null|{kind:"linear"|"radial", color:string, color2?:string, angle?:number}} [o.gradient]
321
+ * @param {null|{node:Array, opacity?:number}} [o.duo] 双色底层(需填充孪生体)
322
+ * @param {number} [o.size=24]
323
+ * @param {string} [o.color="currentColor"]
324
+ * @param {string} [o.glyphColor] 容器内字形颜色(默认自动取反)
325
+ * @param {string} [o.className]
326
+ * @returns {string|null} 渐变颜色不可解析时返回 null(需 hex)
327
+ */
328
+ /**
329
+ * 自绘路径校验:只接受 SVG path 指令 + 数字 + 空白/分隔符。
330
+ *
331
+ * 为什么必须做白名单而不是简单转义:path 数据会直接进 `d="..."` 属性,
332
+ * 引号和尖括号能直接破出属性、注入标签。白名单比黑名单可靠。
333
+ * 合法字符:命令字母 MmZzLlHhVvCcSsQqTtAa、数字、. , - + e E、空白。
334
+ */
335
+ const PATH_RE = /^[MmZzLlHhVvCcSsQqTtAa\d\s,.+\-eE]*$/;
336
+
337
+ /** 归一化一条自绘路径;非法返回 null。空串、不以 M/m 开头都算非法。 */
338
+ export function normalizePathData(raw) {
339
+ if (typeof raw !== "string") return null;
340
+ const d = raw.trim();
341
+ if (d === "" || !/^[Mm]/.test(d)) return null;
342
+ if (!PATH_RE.test(d)) return null;
343
+ // 字符数上限:防止一条参数把整个 SVG 撑爆(24 网格下 4000 字符已经极复杂)
344
+ if (d.length > 4000) return null;
345
+ return d;
346
+ }
347
+
348
+ /** 把一条 path 包成与库图标同构的 IconNode,这样现有能力(容器/阴影/渐变/遮罩/旋转)全部直接生效。 */
349
+ export const pathNode = (d) => [["path", { d }]];
350
+
351
+ /** 叠加/减形图层的合法区间(与字符串时代保持一致)。 */
352
+ export const SHAPE_LIMITS = { scale: [0.02, 4], offset: 48, weight: [0.1, 12], rotate: [-360, 360] };
353
+
354
+ /**
355
+ * 归一化一个 shapes 条目(对象形式)。非法返回 null,调用方跳过该条。
356
+ *
357
+ * 语法:{ name, scale?, dx?, dy?, rotate?, color?, weight?, subtract? }
358
+ *
359
+ * **为什么从字符串改成对象**:字符串时代语法已经挤到
360
+ * `tree@0.5+3,3:#22C55E!2.4` 四段,再加旋转就只能发明更多符号。
361
+ * 对象形式字段自解释、加属性不用改语法,代价是 schema 贵一点。
362
+ *
363
+ * 字段存在的理由(每个都是踩出来的):
364
+ * dx/dy 之前只能居中,而中心通常已被字形占住 → 中孔永远被盖住、毫无意义
365
+ * color 之前全部共用字形色 → 异色角标只能绕成「容器当底」
366
+ * weight 插画靠粗轮廓+细细节拉层次,全图一个线宽只能得图标
367
+ * rotate 场景组合里几乎一定需要(歪放的树、斜飞的鸟)
368
+ * subtract 减法只能是「在卡片上挖洞」,不应切割文字(文字画在 mask 之外)
369
+ */
370
+ export function normalizeShape(raw) {
371
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
372
+ // name 与 path 二选一:给了 path 就不需要图标名(自绘图层)
373
+ const path = normalizePathData(raw.path);
374
+ const name = typeof raw.name === "string" ? raw.name.trim() : "";
375
+ if (name === "" && path === null) return null;
376
+ // 缺省 → 用 dflt;给了但非法 → 返回 INVALID(不能把 null 当非法,否则可选项永远被拒)
377
+ const INVALID = Symbol("invalid");
378
+ const num = (v, [lo, hi], dflt) => {
379
+ if (v === undefined || v === null) return dflt;
380
+ const n = Number(v);
381
+ return Number.isFinite(n) && n >= lo && n <= hi ? n : INVALID;
382
+ };
383
+ const scale = num(raw.scale, SHAPE_LIMITS.scale, 1);
384
+ const dx = num(raw.dx, [-SHAPE_LIMITS.offset, SHAPE_LIMITS.offset], 0);
385
+ const dy = num(raw.dy, [-SHAPE_LIMITS.offset, SHAPE_LIMITS.offset], 0);
386
+ const rotate = num(raw.rotate, SHAPE_LIMITS.rotate, 0);
387
+ const weight = num(raw.weight, SHAPE_LIMITS.weight, null);
388
+ if ([scale, dx, dy, rotate, weight].some((v) => v === INVALID)) return null;
389
+ let color = null;
390
+ if (typeof raw.color === "string" && raw.color.trim() !== "") {
391
+ const c = raw.color.trim();
392
+ // 只做注入安全校验,不强制 hex(rgba()/命名色都行)
393
+ if (!/^[#a-zA-Z0-9(),.%\s/+-]+$/.test(c)) return null;
394
+ color = c;
395
+ }
396
+ // 每层独立渐变:真实品牌美术几乎每层都有自己的渐变(Firefox 火焰 5 层全帯渐变)。
397
+ // 给了 stops 就以渐变填充该层,不再用纯色。
398
+ // fill-rule:evenodd 让「同一路径里的多个子路径」变成外套内挖的环/中孔,
399
+ // 不用再靠 mask 做减法。默认 nonzero(同向子路径会填实)。
400
+ let fillRule = null;
401
+ if (raw.fillRule !== undefined && raw.fillRule !== null) {
402
+ if (raw.fillRule !== "evenodd" && raw.fillRule !== "nonzero") return null;
403
+ fillRule = raw.fillRule;
404
+ }
405
+ const stops = parseGradientStops(raw.stops);
406
+ if (raw.stops !== undefined && raw.stops !== null && stops === null) return null;
407
+ const gradient = raw.gradient === "radial" ? "radial" : "linear";
408
+ const angle = num(raw.angle, [-360, 360], 45);
409
+ if (angle === INVALID) return null;
410
+ return { name, path, scale, dx, dy, rotate, weight, color, stops, gradient, angle, fillRule, subtract: raw.subtract === true };
411
+ }
412
+
413
+ function renderComposed(o = {}) {
414
+ const size = o.size ?? 24;
415
+ const color = o.color ?? "currentColor";
416
+ const paint = o.paint ?? "stroke";
417
+ const strokeWidth = o.strokeWidth ?? 2;
418
+ const cap = o.caps ?? "round";
419
+ const defaultJoin = { round: "round", butt: "miter", square: "miter" }[cap] ?? "round";
420
+ const join = o.join ?? defaultJoin;
421
+ const cls = o.className ? ` class="${o.className}"` : "";
422
+ const container = o.container ?? null;
423
+ const gradient = o.gradient ?? null;
424
+ const duo = o.duo ?? null;
425
+
426
+ // 渐变:颜色必须可算(hex);算不出就返回 null,让调用方回退实色并提示
427
+ let strokePaint = color;
428
+ let gradDef = "";
429
+ if (gradient !== null) {
430
+ // 两条路径:
431
+ // ① 传了 stops(N 停靠点)→ 直接用,颜色不限 hex;
432
+ // ② 只传 color/color2(2 停靠点)→ 末色可自动推,但要求 hex。
433
+ // 区分开是因为自动推末色需要 hex;传了 stops 则完全由调用方决定,不该示范区标准。
434
+ const explicitStops = Array.isArray(gradient.stops) && gradient.stops.length >= 2 ? gradient.stops : null;
435
+ let stops;
436
+ if (explicitStops !== null) {
437
+ stops = explicitStops;
438
+ } else {
439
+ const end = gradient.color2 ?? shiftColor(gradient.color ?? color, -0.28);
440
+ const start = gradient.color ?? color;
441
+ if (hexOf(start) === null || end === null || hexOf(end) === null) return null;
442
+ stops = [{ color: start, offset: 0 }, { color: end, offset: 1 }];
443
+ }
444
+ const start = stops[0].color;
445
+ const end = stops[stops.length - 1].color;
446
+ // 渐变 id 必须按内容唯一:同一页面内联多个图标时,重复的 id 会让所有
447
+ // 渐变引用同一个定义 → **串色**(实测:蓝色的图标渲染成粉红,因为它们
448
+ // 都引用了文档里第一个 id="ak-g")。用内容哈希而不是随机数,保持确定性。
449
+ // id 要涵盖**所有**停靠点:只哈希首末色会让「同首末、中间不同」的两个多段渐变撞 id。
450
+ const id = `ak-g-${hash36(`${stops.map((s) => `${s.color}@${s.offset}`).join(",")}|${gradient.kind ?? "linear"}|${gradient.angle ?? 45}`)}`;
451
+ const a = ((gradient.angle ?? 45) * Math.PI) / 180;
452
+ // 角度 → 单位向量(0° 向上,顺时针)
453
+ const dx = Math.sin(a);
454
+ const dy = -Math.cos(a);
455
+ const stopMarkup = stops.map((s) => `<stop offset="${s.offset}" stop-color="${s.color}"/>`).join("");
456
+ gradDef =
457
+ gradient.kind === "radial"
458
+ ? `<radialGradient id="${id}" cx="0.5" cy="0.5" r="0.6">${stopMarkup}</radialGradient>`
459
+ : `<linearGradient id="${id}" x1="${(0.5 - dx / 2).toFixed(3)}" y1="${(0.5 - dy / 2).toFixed(3)}" x2="${(0.5 + dx / 2).toFixed(3)}" y2="${(0.5 + dy / 2).toFixed(3)}">${stopMarkup}</linearGradient>`;
460
+ strokePaint = `url(#${id})`;
461
+ }
462
+
463
+ // **渐变的作用对象**:
464
+ // 无容器 → 渐变画在字形上(线稿渐变图标)
465
+ // 有容器 → 渐变画在**容器底**上,字形用 glyphColor 反白
466
+ // 后者才是现实里的主流(iOS app 图标、Win11 磁贴、拟物按钮都是「渐变底 + 白字」)。
467
+ // 旧实现一律把渐变给字形,container+gradient 出来是「实色底 + 渐变字」——完全不对。
468
+ const gradientOnContainer = container !== null && gradient !== null;
469
+ const glyphColor =
470
+ container === null ? strokePaint : (container.glyphColor ?? "#FFFFFF");
471
+
472
+ // 填充型字形(包数据 path 自带 fill):不能拿 stroke 去“描”它——
473
+ // 外圈会多糊一层,24px 下直接糊成色块(2026-11-07 实测修过的 bug)。
474
+ // 所以 paint=stroke 遇到填充型字形时自动按 fill 处理;要真的「又填又描」请显式传 both。
475
+ const nodeFillDrawn = (o.node ?? []).some(([, attrs]) => typeof attrs?.fill === "string" && attrs.fill !== "none");
476
+ const effectivePaint = nodeFillDrawn && paint === "stroke" ? "fill" : paint;
477
+
478
+ // 字形画笔:stroke / fill / both 三选一;每个属性只出现一次
479
+ const wantStroke = effectivePaint === "stroke" || effectivePaint === "both";
480
+ const wantFill = effectivePaint === "fill" || effectivePaint === "both";
481
+ // 虚线:stroke-dasharray 是通用图元(技术图/蓝图/草图/待定区域都用它),
482
+ // 不是某种风格。数组数值按 24 网格单位。
483
+ const dashArray = Array.isArray(o.dash) && o.dash.length > 0 ? o.dash.filter((v) => Number.isFinite(v) && v >= 0).join(" ") : "";
484
+ // fill-rule:evenodd 是「一条路径里画环/中孔」的唯一办法(实现在 resvg 上验过:
485
+ // 外圆+内圆同向时 nonzero 中心 255、evenodd 中心 0)。
486
+ const fillRuleAttr = o.fillRule === "evenodd" || o.fillRule === "nonzero" ? [`fill-rule="${o.fillRule}"`, `clip-rule="${o.fillRule}"`] : [];
487
+ const glyphAttrs = [
488
+ wantFill ? `fill="${glyphColor}"` : `fill="none"`,
489
+ wantStroke ? `stroke="${glyphColor}"` : `stroke="none"`,
490
+ ...(wantStroke ? [`stroke-width="${strokeWidth}"`, `stroke-linecap="${cap}"`, `stroke-linejoin="${join}"`] : []),
491
+ ...(wantStroke && dashArray !== "" ? [`stroke-dasharray="${dashArray}"`] : []),
492
+ ...fillRuleAttr,
493
+ `color="${glyphColor}"`
494
+ ];
495
+
496
+ const glyphInner = iconToInnerSvg(stripPaint(o.node ?? []));
497
+ const layers = [];
498
+ // 图标内文字(字母标):存在时**取代字形**,而不是叠在上面。
499
+ // 典型需求是「圆角方块里写 7z / AI / K」,本质上是用文字当图形。
500
+ // 居中用 dominant-baseline="central"——实测 resvg 支持且准确(cy 11.93 vs 目标 12),
501
+ // middle 会偏 0.9 单位,所以不用它。
502
+ const hasText = o.text !== null && o.text !== undefined && typeof o.text.content === "string" && o.text.content !== "";
503
+ // 阴影图元:先算好「外轮廓」能画成什么,后面复用它生成阴影副本。
504
+ // 这是把深度效果做成**能力**而不是风格的关键:只要能给任意轮廓加阴影,
505
+ // 高程、卡片、浮层、按奥、浮雕、双光源都是调用方自己拼出来的。
506
+ const shadowList = Array.isArray(o.shadows) ? o.shadows : [];
507
+ /** 用指定颜色画出「最外层轮廓」——容器存在时是容器,否则是字形。 */
508
+ const silhouette = (fillColor) => {
509
+ if (container === null) return `<g ${glyphAttrs.map((a) => (/^(fill|stroke|color)=/.test(a) ? a.replace(/="[^"]*"/, `="${fillColor}"`) : a)).join(" ")}>${glyphInner}</g>`;
510
+ const inset = container.inset ?? 0.24;
511
+ const s = Math.round((1 - inset * 2) * 1000) / 1000;
512
+ if (container.kind === "circle") return `<circle cx="12" cy="12" r="12" fill="${fillColor}"/>`;
513
+ if (container.kind === "squircle") {
514
+ const r = Math.min(11.4, container.radius ?? 8);
515
+ const k = r * 0.55;
516
+ return `<path d="M ${r} 0 H ${24 - r} C ${24 - r + k} 0 ${24} ${r - k} ${24} ${r} V ${24 - r} C ${24} ${24 - r + k} ${24 - r + k} 24 ${24 - r} 24 H ${r} C ${r - k} 24 0 ${24 - r + k} 0 ${24 - r} V ${r} C 0 ${r - k} ${r - k} 0 ${r} 0 Z" fill="${fillColor}"/>`;
517
+ }
518
+ void s;
519
+ const rr = Math.min(12, Math.max(0, container.radius ?? 7.2));
520
+ return `<rect x="0" y="0" width="24" height="24" rx="${Math.round(rr * 100) / 100}" fill="${fillColor}"/>`;
521
+ };
522
+
523
+ // ① 容器底
524
+ let glyphGroupAttrs = glyphAttrs;
525
+ let wrappers = [];
526
+ if (container !== null) {
527
+ const inset = container.inset ?? 0.24;
528
+ const s = Math.round((1 - inset * 2) * 1000) / 1000;
529
+ const offset = 12 - (24 * s) / 2;
530
+ // 容器填色:有渐变时用渐变(这才是 iOS/Win11 的典型样子),否则用 container.color / color
531
+ const baseColor = gradientOnContainer ? strokePaint : (container.color ?? color);
532
+ if (container.kind === "circle") {
533
+ layers.push(`<circle cx="12" cy="12" r="12" fill="${baseColor}"/>`);
534
+ } else if (container.kind === "squircle") {
535
+ // 超椭圆:用 cubic bezier 近似 iOS 连续圆角(半径 = 边长的 45%)
536
+ const r = Math.min(11.4, container.radius ?? 8);
537
+ const k = r * 0.55;
538
+ layers.push(
539
+ `<path d="M ${r} 0 H ${24 - r} C ${24 - r + k} 0 ${24} ${r - k} ${24} ${r} V ${24 - r} C ${24} ${24 - r + k} ${24 - r + k} 24 ${24 - r} 24 H ${r} C ${r - k} 24 0 ${24 - r + k} 0 ${24 - r} V ${r} C 0 ${r - k} ${r - k} 0 ${r} 0 Z" fill="${baseColor}"/>`
540
+ );
541
+ } else {
542
+ const r = Math.min(12, Math.max(0, container.radius ?? 7.2));
543
+ layers.push(`<rect x="0" y="0" width="24" height="24" rx="${Math.round(r * 100) / 100}" fill="${baseColor}"/>`);
544
+ }
545
+ // 字形缩小后描边也跟着缩 → 必须反向补偿,否则 24px 下实际 <1px 必然发灰
546
+ const comp = paint === "fill" ? strokeWidth : Math.round((strokeWidth / s) * 1000) / 1000;
547
+ glyphGroupAttrs = glyphAttrs.map((a) => (/^stroke-width=/.test(a) ? `stroke-width="${comp}"` : a));
548
+ wrappers = [`translate(${offset} ${offset})`, `scale(${s})`];
549
+ }
550
+
551
+ // ② 双色底图(fill 层,低透明度)。有容器时渐变已给容器,底图用实色。
552
+ if (duo !== null && duo.node !== undefined) {
553
+ const duoFill = gradientOnContainer ? color : strokePaint;
554
+ layers.push(`<g fill="${duoFill}" fill-opacity="${duo.opacity ?? 0.2}" stroke="none">${iconToInnerSvg(stripPaint(duo.node))}</g>`);
555
+ }
556
+
557
+ // ③ 字形(或文字)
558
+ // 文字单独拿出来放**最后**(在 mask 之外):
559
+ // 实测发现,中孔(shapes 减法)会把文字也一起挖掉 ——
560
+ // 「K 徽章开个装饰孔」会变成「K 被咬掉一角」,看起来就像 bug。
561
+ // 语义上减法应该是「在卡片/图形上挖洞」,不是「切割文字」,
562
+ // 所以文字画在 mask 之外。要真的做镂刻字,用 shapes 先做模版再渲染成位图。
563
+ let textLayer = "";
564
+ if (hasText) {
565
+ const raw = o.text.content.slice(0, 4);
566
+ // 自动字号:在安全区内挤得下。比例字粗体平均 advance ≈ 0.62×字号。
567
+ const avail = container === null ? 20 : 24 * (1 - (container.inset ?? 0.24) * 2);
568
+ const auto = Math.min(avail / Math.max(1, raw.length) / 0.62, avail * 0.92);
569
+ const fontSize = Number.isFinite(o.text.size) && o.text.size > 0 ? Math.min(o.text.size, 24) : Math.round(auto * 100) / 100;
570
+ const esc = String(raw).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
571
+ textLayer =
572
+ `<text x="12" y="12" font-family="Arial, Helvetica, sans-serif" font-size="${fontSize}" font-weight="700" ` +
573
+ `fill="${glyphColor}" text-anchor="middle" dominant-baseline="central">${esc}</text>`;
574
+ } else {
575
+ const glyph = `<g ${glyphGroupAttrs.join(" ")}>${glyphInner}</g>`;
576
+ layers.push(wrappers.length === 0 ? glyph : `<g transform="${wrappers.join(" ")}">${glyph}</g>`);
577
+ }
578
+
579
+ /* ---------------------------- 阴影图元 ---------------------------- */
580
+ // 每个阴影一个独立 filter(blur 不同),id 按内容哈希保持确定性。
581
+ const shadowDefs = [];
582
+ const outerLayers = [];
583
+ const innerLayers = [];
584
+ shadowList.forEach((sh, idx) => {
585
+ const sigma = Math.max(0, (sh.blur ?? 0) / 2); // CSS blur ≈ 2σ
586
+ const id = `ak-sh-${idx}-${hash36(`${sh.dx}|${sh.dy}|${sigma}|${sh.color}`)}`;
587
+ shadowDefs.push(
588
+ `<filter id="${id}" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="${sigma}"/></filter>`
589
+ );
590
+ if (sh.inset) {
591
+ // 内阴影:裁到轮廓内部,画一个向外偏移的厚描边并模糊,得到「从边缘向内渐暗」
592
+ const clipId = `ak-cl-${id}`;
593
+ shadowDefs.push(`<clipPath id="${clipId}">${silhouette("#000")}</clipPath>`);
594
+ const w = Math.max(2, (sh.blur ?? 0) * 2);
595
+ innerLayers.push(
596
+ `<g clip-path="url(#${clipId})"><g filter="url(#${id})" transform="translate(${-(sh.dx ?? 0)} ${-(sh.dy ?? 0)})">` +
597
+ `<rect x="${-w}" y="${-w}" width="${24 + w * 2}" height="${24 + w * 2}" fill="none" stroke="${sh.color}" stroke-width="${w}"/>` +
598
+ `</g></g>`
599
+ );
600
+ } else {
601
+ outerLayers.push(`<g filter="url(#${id})" transform="translate(${sh.dx ?? 0} ${sh.dy ?? 0})">${silhouette(sh.color)}</g>`);
602
+ }
603
+ });
604
+
605
+ /* -------------------------- 多形状组合 -------------------------- */
606
+ // 两层组合能力,用同一个参数表达:
607
+ // 叠加(无前缀) → 另一个图形画在主字形之上,用同一套画笔
608
+ // 减去(- 前缀) → 把该图形从**整个**合成结果里挖掉(环/中孔/镂刻)
609
+ // 减去用 <mask>(白底 + 黑形)实现;这是 SVG 里最通用、且 resvg 完全支持的做法。
610
+ const extraShapes = Array.isArray(o.shapes) ? o.shapes.filter((s) => s && Array.isArray(s.node)) : [];
611
+ const additive = extraShapes.filter((s) => !s.subtract);
612
+ const subtractive = extraShapes.filter((s) => s.subtract);
613
+ // 变换顺序:缩放到中心 → 偏移 → 绕自身中心旋转。
614
+ // 旋转必须在偏移之后、且绕图形自身中心(0,0 是 24 网格左上,形心在 12,12)。
615
+ const shapeTransform = (s) => {
616
+ const scale = s.scale ?? 1;
617
+ const growth = (24 * (1 - scale)) / 2;
618
+ const tx = growth + (s.dx ?? 0);
619
+ const ty = growth + (s.dy ?? 0);
620
+ const parts = [`translate(${tx} ${ty})`];
621
+ if (scale !== 1) parts.push(`scale(${scale})`);
622
+ const rot = s.rotate ?? 0;
623
+ // 旋转中心:缩放后的坐标系里仍是 12,12;先平移到中心再转再平移回去
624
+ if (rot !== 0) parts.push(`rotate(${rot} 12 12)`);
625
+ return parts.join(" ");
626
+ };
627
+ // 每层独立渐变:用 objectBoundingBox 坐标,渐变自动适配该层自身的包围盒,
628
+ // 不用管缩放/偏移——否则每层都要手算绝对坐标,没法用。
629
+ const shapeGradDefs = [];
630
+ const shapeGradientFill = (s) => {
631
+ if (!Array.isArray(s.stops) || s.stops.length < 2) return null;
632
+ const id = `ak-sg-${hash36(`${s.stops.map((x) => `${x.color}@${x.offset}`).join(",")}|${s.gradient ?? "linear"}|${s.angle ?? 45}`)}`;
633
+ const stopMarkup = s.stops.map((x) => `<stop offset="${x.offset}" stop-color="${x.color}"/>`).join("");
634
+ if (s.gradient === "radial") {
635
+ shapeGradDefs.push(`<radialGradient id="${id}" cx="0.5" cy="0.5" r="0.5">${stopMarkup}</radialGradient>`);
636
+ } else {
637
+ const a = ((s.angle ?? 45) * Math.PI) / 180;
638
+ const dx = Math.sin(a);
639
+ const dy = -Math.cos(a);
640
+ shapeGradDefs.push(
641
+ `<linearGradient id="${id}" x1="${(0.5 - dx / 2).toFixed(3)}" y1="${(0.5 - dy / 2).toFixed(3)}" x2="${(0.5 + dx / 2).toFixed(3)}" y2="${(0.5 + dy / 2).toFixed(3)}">${stopMarkup}</linearGradient>`
642
+ );
643
+ }
644
+ return `url(#${id})`;
645
+ };
646
+ // 每个叠加形可以有自己的颜色(fill)与线宽(weight),否则沿用字形值。
647
+ // 指定了 weight 就强制该层描边——否则全局 paint=fill 时线宽会完全无效
648
+ // (那又是“看起来能用、实际零效果”的坑)。
649
+ const shapeAttrs = (s) => {
650
+ // 渐变优先于纯色:给了 stops 就用渐变填该层
651
+ const gradFill = shapeGradientFill(s);
652
+ const sFill = gradFill ?? s.color ?? s.fill;
653
+ const hasFill = typeof sFill === "string" && sFill !== "";
654
+ const hasWeight = Number.isFinite(s.weight) && s.weight > 0;
655
+ const hasRule = s.fillRule === "evenodd" || s.fillRule === "nonzero";
656
+ if (!hasFill && !hasWeight && !hasRule) return glyphAttrs;
657
+ // 解成键值对再改,比逐条 map 更不容易漏(之前就漏了 stroke-width 本身)
658
+ const kv = new Map();
659
+ for (const a of glyphAttrs) {
660
+ const eq = a.indexOf("=");
661
+ if (eq > 0) kv.set(a.slice(0, eq), a.slice(eq + 1).replace(/^"|"$/g, ""));
662
+ }
663
+ if (hasFill) {
664
+ kv.set("fill", sFill);
665
+ if (kv.has("stroke")) kv.set("stroke", sFill);
666
+ }
667
+ // 每层独立的 fill-rule:让该层自己就能画环/中孔
668
+ if (s.fillRule === "evenodd" || s.fillRule === "nonzero") {
669
+ kv.set("fill-rule", s.fillRule);
670
+ kv.set("clip-rule", s.fillRule);
671
+ }
672
+ if (hasWeight) {
673
+ // 全局没描边时先把描边打开,并把颜色对齐到该层颜色,否则线宽无处作用
674
+ if (!kv.get("stroke") || kv.get("stroke") === "none") kv.set("stroke", hasFill ? sFill : kv.get("fill") ?? "currentColor");
675
+ kv.set("stroke-width", String(s.weight));
676
+ if (!kv.has("stroke-linecap")) kv.set("stroke-linecap", cap);
677
+ if (!kv.has("stroke-linejoin")) kv.set("stroke-linejoin", join);
678
+ }
679
+ return [...kv.entries()].map(([k, v]) => `${k}="${v}"`);
680
+ };
681
+ // 自绘图层(给了 path)直接用它的 markup,不走库节点
682
+ const innerOf = (s) => (typeof s.path === "string" ? iconToInnerSvg(pathNode(s.path)) : iconToInnerSvg(stripPaint(s.node)));
683
+ const placeShape = (s) => {
684
+ const inner = innerOf(s);
685
+ const t = shapeTransform(s);
686
+ const attrs = shapeAttrs(s).join(" ");
687
+ return t === "translate(0 0)" ? `<g ${attrs}>${inner}</g>` : `<g transform="${t}"><g ${attrs}>${inner}</g></g>`;
688
+ };
689
+ const additiveMarkup = additive.map((s) => placeShape(s)).join("");
690
+ let content = `${outerLayers.join("")}${layers.join("")}${additiveMarkup}${innerLayers.join("")}`;
691
+ if (subtractive.length > 0) {
692
+ // mask 默认 luminance:白 = 保留,黑 = 挖掉
693
+ const holes = subtractive
694
+ .map((s) => {
695
+ const inner = innerOf(s);
696
+ const body = `<g fill="#000000" stroke="#000000" stroke-width="0">${inner}</g>`;
697
+ return `<g transform="${shapeTransform(s)}">${body}</g>`;
698
+ })
699
+ .join("");
700
+ const maskId = `ak-mk-${hash36(subtractive.map((s) => `${s.path ?? s.node.length}${s.scale}${s.dx ?? 0},${s.dy ?? 0}${s.rotate ?? 0}`).join("|"))}`;
701
+ const maskDef =
702
+ `<mask id="${maskId}" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">` +
703
+ `<rect x="0" y="0" width="24" height="24" fill="#FFFFFF"/>${holes}</mask>`;
704
+ content = `<g mask="url(#${maskId})">${content}</g>`;
705
+ var maskDefs = maskDef;
706
+ } else {
707
+ var maskDefs = "";
708
+ }
709
+
710
+ const defsParts = [gradDef, ...shapeGradDefs, ...shadowDefs, maskDefs].filter((s) => s !== "").join("");
711
+ const defs = defsParts === "" ? "" : `<defs>${defsParts}</defs>`;
712
+ // 层序:外阴影(最下)→ 内容(含叠加形,可被 mask 挖空)→ 文字(不受减法影响)
713
+ return `<svg${cls} xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">${defs}${content}${textLayer}</svg>`;
714
+ }
715
+
716
+ export {
717
+ checkIcon,
718
+ extractInner,
719
+ hexOf,
720
+ iconToInnerSvg,
721
+ parseIconNodes,
722
+ renderComposed,
723
+ renderSvg,
724
+ renderSvgChip,
725
+ renderSvgGradient,
726
+ renderSvgLayers,
727
+ shiftColor,
728
+ stripPaint
729
+ };