dsh-visualkit 0.0.0-stage → 0.0.2

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 +74 -0
  2. package/LICENSE +26 -0
  3. package/README.md +443 -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/render.js ADDED
@@ -0,0 +1,486 @@
1
+ /**
2
+ * render.js — design_render 的实现:SVG→PNG 与图层合成,**只用 resvg,不调外部进程**。
3
+ *
4
+ * 与旧 design-studio 的差别(2026-11-07 合并):
5
+ * 旧路径是 spawn `python 设计台.py render` / `node render_resvg.js`,依赖
6
+ * 外部设计工作区与已安装的 Python;新路径直接 import
7
+ * @resvg/resvg-js(npm prebuild 二进制),无系统软件依赖。
8
+ *
9
+ * 合成(composite)不求人:resvg 支持 `<image href="data:image/png;base64,…">`,
10
+ * 因此「PNG 底 + SVG/PNG 层」可以拼成一个合成 SVG 再一次性渲染——不需要 sharp
11
+ * 或任何像素库,也就不用为了叠两层图去背 20MB 的原生依赖。
12
+ */
13
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { dirname, extname } from "node:path";
16
+ import { createRequire } from "node:module";
17
+ import { cssBlurToSigma, parseShadows } from "./shadow.js";
18
+
19
+ // resvg 是 **vendored** 的(vendor/resvg/),不是 npm 依赖。
20
+ //
21
+ // 为何不走 dependencies(2026-11-07 实测):官方 install_bundle 会跑 pnpm,
22
+ // 它会把这类型 link 包声明的 dependencies 接管——实测直接把插件目录里的
23
+ // `@resvg/resvg-js` 主包删了,只剩平台二进制,导致 `Cannot find package`。
24
+ // vendored 后插件自带全部东西,装包/重组合都不会再动它。
25
+ //
26
+ // 用 createRequire 而非 ESM named import:vendor 里是 CJS,且 js-binding 的
27
+ // 导出是动态赋值(`module.exports.Resvg = Resvg`),cjs-module-lexer 不保证能识别。
28
+ //
29
+ // **必须延迟加载**:vendored 只带 win32-x64 绑定,在 linux / macOS 上 require 会直接抛
30
+ // "Failed to load native binding"。顶层 require 会让整个插件在这些平台「一 import 就崩」,
31
+ // 连累 CI 的 ubuntu 作业与渲染之外的纯 JS 能力(图标 / 体检 / 风格 / 素材)。所以第一次
32
+ // 真正渲染时才加载,并把平台信息写进错误消息。
33
+ const require = createRequire(import.meta.url);
34
+ let ResvgClass = null;
35
+ function resvgClass() {
36
+ if (ResvgClass === null) {
37
+ try {
38
+ ResvgClass = require("../vendor/resvg/index.js").Resvg;
39
+ } catch (error) {
40
+ throw new Error(
41
+ `SVG 渲染需要 vendored resvg 的 win32-x64 原生绑定(当前 ${process.platform}/${process.arch}):${error?.message ?? error}`
42
+ );
43
+ }
44
+ }
45
+ return ResvgClass;
46
+ }
47
+
48
+ /** PNG 尺寸从 IHDR 读取(不解析渲染器返回,避免上游字段变更导致 undefined)。 */
49
+ function pngSize(buf) {
50
+ if (buf.length < 24 || buf.toString("ascii", 12, 16) !== "IHDR") {
51
+ throw new Error("not a PNG (or truncated header)");
52
+ }
53
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
54
+ }
55
+
56
+ /** 自动判断输入是 SVG 标记、SVG 文件还是位图文件。 */
57
+ async function loadInput(input, label) {
58
+ const raw = String(input ?? "").trim();
59
+ if (raw === "") throw new Error(`${label} is empty`);
60
+ if (raw.includes("<")) return { kind: "markup", svg: raw };
61
+ if (!existsSync(raw)) throw new Error(`${label} not found: ${raw}`);
62
+ const ext = extname(raw).toLowerCase();
63
+ if (ext === ".svg") return { kind: "svgFile", path: raw, svg: await readFile(raw, "utf8") };
64
+ if (ext === ".png" || ext === ".jpg" || ext === ".jpeg" || ext === ".webp") {
65
+ return { kind: "raster", path: raw, buf: await readFile(raw) };
66
+ }
67
+ throw new Error(`${label}: unsupported file type "${ext}"(支持 .svg / .png / .jpg / .webp)`);
68
+ }
69
+
70
+ const RESVG_OPTS = {
71
+ // 系统字体:中文界面大量用雅黑,显式列出比依赖 fallback 稳
72
+ font: { loadSystemFonts: true, defaultFontFamily: "Microsoft YaHei" },
73
+ background: "rgba(0,0,0,0)"
74
+ };
75
+
76
+ /** 宽度上限:防模型传 100000 直接撑爆内存(实测 100000×30000 的 PNG 已 13MB)。 */
77
+ const MAX_WIDTH = 20000;
78
+
79
+ /** 校验 width:undefined = 用固有尺寸;否则必须是有界正整数。 */
80
+ function normalizeWidth(width) {
81
+ if (width === undefined || width === null) return undefined;
82
+ const n = typeof width === "number" ? width : Number.NaN;
83
+ if (!Number.isFinite(n) || n <= 0 || n > MAX_WIDTH) {
84
+ throw new Error(`width 必须是 1–${MAX_WIDTH} 的数字(收到 ${JSON.stringify(width)});不传则用 SVG 固有尺寸`);
85
+ }
86
+ return Math.round(n);
87
+ }
88
+
89
+ /** 输出必须是 .png:PNG 字节写到 .jpg 会让下游误判格式。 */
90
+ function assertPngPath(file, label) {
91
+ if (typeof file !== "string" || file.trim() === "") throw new Error(`${label} is required`);
92
+ if (!file.toLowerCase().endsWith(".png")) throw new Error(`${label} 必须以 .png 结尾(收到 "${file}"):本工具只输出 PNG`);
93
+ }
94
+
95
+ /** 渲染一个 SVG 输入到 PNG buffer。width 给定时按宽度等比缩放。 */
96
+ function renderToPng(svg, width) {
97
+ const opts = { ...RESVG_OPTS };
98
+ const fit = normalizeWidth(width);
99
+ if (fit !== undefined) opts.fitTo = { mode: "width", value: fit };
100
+ const image = new (resvgClass())(svg, opts).render();
101
+ return { buf: image.asPng(), width: image.width, height: image.height };
102
+ }
103
+
104
+ async function ensureDir(file) {
105
+ await mkdir(dirname(file), { recursive: true });
106
+ }
107
+
108
+ /**
109
+ * action=render:SVG(标记或文件)→ PNG。
110
+ * @returns {{path:string,width:number,height:number,bytes:number}}
111
+ */
112
+ async function renderSvg({ svg, png, width, sizes, ico }) {
113
+ assertPngPath(png, "png output path");
114
+ const input = await loadInput(svg, "svg");
115
+ if (input.kind === "raster") throw new Error("action=render 需要 SVG 输入;位图请用 action=composite");
116
+ const { buf, width: w, height: h } = renderToPng(input.svg, width);
117
+ await ensureDir(png);
118
+ await writeFile(png, buf);
119
+ const derived = await writeDerived(input.svg, png, sizes, ico);
120
+ return { path: png, width: w, height: h, bytes: buf.length, ...derived };
121
+ }
122
+
123
+ /** anchor 枚举 → 在可用区域内的相对位置(0=左/上,0.5=中,1=右/下)。 */
124
+ const ANCHORS = {
125
+ "top-left": [0, 0],
126
+ top: [0.5, 0],
127
+ "top-right": [1, 0],
128
+ left: [0, 0.5],
129
+ center: [0.5, 0.5],
130
+ right: [1, 0.5],
131
+ "bottom-left": [0, 1],
132
+ bottom: [0.5, 1],
133
+ "bottom-right": [1, 1]
134
+ };
135
+
136
+ /** fit 枚举 → 缩放策略。 */
137
+ const FITS = ["contain", "cover", "fill", "none"];
138
+
139
+ /**
140
+ * blend 枚举(与 CSS mix-blend-mode 同名)。
141
+ * 实测(2026-11-07)resvg 真的支持它:红底 + 绿层 multiply 像素为 [0,0,0],
142
+ * 不 blend 时是 [0,255,0]——不是“不报错但无效”。
143
+ */
144
+ const BLENDS = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "difference"];
145
+
146
+ /** 颜色字面量(与 emit 的 COLOR_RE 同口径,只用于判断 under 是不是纯色)。 */
147
+ const COLOR_LITERAL_RE = /^(#[0-9a-f]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-z]+)$/i;
148
+
149
+ /** canvas 参数:[w, h]。也接受 [w](只定宽,高度取底图等比)。 */
150
+ function normalizeCanvas(canvas) {
151
+ if (canvas === undefined || canvas === null) return undefined;
152
+ if (!Array.isArray(canvas) || canvas.length === 0 || canvas.length > 2) {
153
+ throw new Error(`canvas 必须是 [宽, 高](收到 ${JSON.stringify(canvas)})`);
154
+ }
155
+ const out = canvas.map((v) => {
156
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 1 || v > MAX_WIDTH) {
157
+ throw new Error(`canvas 的每项必须是 1–${MAX_WIDTH} 的数字(收到 ${JSON.stringify(v)})`);
158
+ }
159
+ return Math.round(v);
160
+ });
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * action=composite:把 over 图层叠到 under 底图上,输出 PNG。
166
+ *
167
+ * 实现:under 尺寸决定画布;两侧都归一成 PNG data URI 后嵌入一个合成 SVG,由 resvg 一次画完。
168
+ *
169
+ * **已从「只有 opacity/scale 两个旋钮」改成正交维度**(2026-11-07):
170
+ * 位置 anchor(9 种) + padding(px)
171
+ * 尺寸 fit(contain/cover/fill/none) + scale(倍率)
172
+ * 外观 opacity + blend(mix-blend-mode)
173
+ * 变换 rotate(任意角度)
174
+ * 画布 canvas([w,h]) + bg(纯色底)
175
+ * 早期实现把图层强行铺满画布,导致 24×24 图标叠到 400×120 上被拉变形;
176
+ * 后来固定为「居中 contain 不放大」,但那就变成了模板——现在能任意放置与缩放。
177
+ */
178
+ async function composite({ under, over, out, opacity, scale, anchor, fit, padding, blend, bg, shadow, canvas, rotate, sizes, ico }) {
179
+ for (const [key, value] of [["under", under], ["over", over], ["out", out]]) {
180
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${key} is required`);
181
+ }
182
+ assertPngPath(out, "out output path");
183
+ let alpha = 1;
184
+ if (opacity !== undefined && opacity !== null) {
185
+ if (typeof opacity !== "number" || !Number.isFinite(opacity) || opacity < 0 || opacity > 1) {
186
+ throw new Error(`opacity 必须是 0–1 的数字(收到 ${JSON.stringify(opacity)})`);
187
+ }
188
+ alpha = opacity;
189
+ }
190
+ // scale:图层相对「充满画布」的倍率(0.05–8)。不给 = 旧行为(contain,不放大);
191
+ // 给了才允许放大——做 app 图标/logo 放置时,24 小图标必须能放大到画布比例。
192
+ let userScale;
193
+ if (scale !== undefined && scale !== null) {
194
+ if (typeof scale !== "number" || !Number.isFinite(scale) || scale < 0.05 || scale > 8) {
195
+ throw new Error(`scale 必须是 0.05–8 的数字(收到 ${JSON.stringify(scale)})`);
196
+ }
197
+ userScale = scale;
198
+ }
199
+ // —— 新增维度校验 ——
200
+ const anchorKey = anchor === undefined || anchor === null ? "center" : String(anchor);
201
+ if (ANCHORS[anchorKey] === undefined) {
202
+ throw new Error(`anchor 必须是 ${Object.keys(ANCHORS).join(" / ")}(收到 ${JSON.stringify(anchor)})`);
203
+ }
204
+ const fitMode = fit === undefined || fit === null ? "contain" : String(fit);
205
+ if (!FITS.includes(fitMode)) throw new Error(`fit 必须是 ${FITS.join(" / ")}(收到 ${JSON.stringify(fit)})`);
206
+ const blendMode = blend === undefined || blend === null ? "normal" : String(blend);
207
+ if (!BLENDS.includes(blendMode)) throw new Error(`blend 必须是 ${BLENDS.join(" / ")}(收到 ${JSON.stringify(blend)})`);
208
+ let pad = 0;
209
+ if (padding !== undefined && padding !== null) {
210
+ if (typeof padding !== "number" || !Number.isFinite(padding) || padding < 0 || padding > MAX_WIDTH) {
211
+ throw new Error(`padding 必须是 ≥ 0 的数字(收到 ${JSON.stringify(padding)})`);
212
+ }
213
+ pad = Math.round(padding);
214
+ }
215
+ let deg = 0;
216
+ if (rotate !== undefined && rotate !== null) {
217
+ if (typeof rotate !== "number" || !Number.isFinite(rotate)) {
218
+ throw new Error(`rotate 必须是数字(角度,收到 ${JSON.stringify(rotate)})`);
219
+ }
220
+ deg = rotate % 360;
221
+ }
222
+ const canvasSize = normalizeCanvas(canvas);
223
+
224
+ // under 可以是**纯色**(不用先做一个 SVG 底图),但需要 canvas 定尺寸
225
+ const underIsColor = typeof under === "string" && !under.includes("<") && !existsSync(under) && COLOR_LITERAL_RE.test(under.trim());
226
+ const base = underIsColor ? null : await loadInput(under, "under");
227
+ const layer = await loadInput(over, "over");
228
+
229
+ // 底图:SVG 用它自己的固有尺寸;位图只收 PNG(resvg 能解码,但本模块靠 IHDR 量尺寸)
230
+ let baseBuf = null;
231
+ let baseW = 0;
232
+ let baseH = 0;
233
+ if (base !== null) {
234
+ if (base.kind === "raster") {
235
+ assertPngRaster(base, "under");
236
+ baseBuf = base.buf;
237
+ } else {
238
+ baseBuf = renderToPng(base.svg).buf;
239
+ }
240
+ const size = pngSize(baseBuf);
241
+ baseW = size.width;
242
+ baseH = size.height;
243
+ }
244
+ // 画布尺寸:canvas 显式给定时以它为准;只给 width 时高度跟底图等比
245
+ const canvasW = canvasSize !== undefined ? canvasSize[0] : baseW;
246
+ const canvasH =
247
+ canvasSize !== undefined ? (canvasSize[1] ?? Math.round((canvasSize[0] * baseH) / Math.max(1, baseW))) : baseH;
248
+ if (canvasW <= 0 || canvasH <= 0) {
249
+ throw new Error("算不出画布尺寸:under 是纯色时必须给 canvas=[宽, 高]");
250
+ }
251
+
252
+ // 图层:先拿到固有尺寸,再按 contain 缩放(绝不放大),最后居中放置
253
+ let layerBuf;
254
+ let naturalW;
255
+ let naturalH;
256
+ if (layer.kind === "raster") {
257
+ assertPngRaster(layer, "over");
258
+ layerBuf = layer.buf;
259
+ ({ width: naturalW, height: naturalH } = pngSize(layerBuf));
260
+ } else {
261
+ const intrinsic = renderToPng(layer.svg);
262
+ naturalW = intrinsic.width;
263
+ naturalH = intrinsic.height;
264
+ layerBuf = intrinsic.buf;
265
+ }
266
+ // 可用区域 = 画布减去 padding;padding 就是把图层推进安全区(app 图标常用)
267
+ const boxX = pad;
268
+ const boxY = pad;
269
+ const boxW = Math.max(1, canvasW - pad * 2);
270
+ const boxH = Math.max(1, canvasH - pad * 2);
271
+
272
+ const contain = Math.min(boxW / naturalW, boxH / naturalH);
273
+ const cover = Math.max(boxW / naturalW, boxH / naturalH);
274
+ let layerW;
275
+ let layerH;
276
+ if (fitMode === "fill") {
277
+ // fill:拉伸到可用区域(不保宽高比)——背景图/纹理层用
278
+ layerW = Math.max(1, Math.round(boxW));
279
+ layerH = Math.max(1, Math.round(boxH));
280
+ } else if (fitMode === "none") {
281
+ // none:保持固有尺寸(可配合 scale 手动调)
282
+ layerW = Math.max(1, Math.round(naturalW * (userScale ?? 1)));
283
+ layerH = Math.max(1, Math.round(naturalH * (userScale ?? 1)));
284
+ } else if (fitMode === "cover") {
285
+ layerW = Math.max(1, Math.round(naturalW * cover * (userScale ?? 1)));
286
+ layerH = Math.max(1, Math.round(naturalH * cover * (userScale ?? 1)));
287
+ } else {
288
+ // contain(默认):不放大,除非显式给了 scale
289
+ const factor = userScale === undefined ? Math.min(1, contain) : contain * userScale;
290
+ layerW = Math.max(1, Math.round(naturalW * factor));
291
+ layerH = Math.max(1, Math.round(naturalH * factor));
292
+ }
293
+ if (layerW !== naturalW || layerH !== naturalH) {
294
+ // SVG 图层按目标宽度重渲染(位图缩放交给 resvg 的 image 采样)
295
+ if (layer.kind !== "raster" && fitMode !== "fill") layerBuf = renderToPng(layer.svg, layerW).buf;
296
+ }
297
+ // 在可用区域内按 anchor 摆放(cover 时图层可能比区域大,居中裁掉)
298
+ const [ax, ay] = ANCHORS[anchorKey];
299
+ const offsetX = Math.round(boxX + (boxW - layerW) * ax);
300
+ const offsetY = Math.round(boxY + (boxH - layerH) * ay);
301
+
302
+ // 变换:绕图层中心旋转(rotate 不改变放置点,只改朝向)
303
+ const cx = offsetX + layerW / 2;
304
+ const cy = offsetY + layerH / 2;
305
+ const transform = deg === 0 ? "" : ` transform="rotate(${deg} ${cx} ${cy})"`;
306
+ const blendStyle = blendMode === "normal" ? "" : ` style="mix-blend-mode:${blendMode}"`;
307
+
308
+ // 底色:under 是纯色时直接画 rect(不用先做一张底图)
309
+ const baseLayer =
310
+ baseBuf === null
311
+ ? `<rect x="0" y="0" width="${canvasW}" height="${canvasH}" fill="${under.trim()}"/>`
312
+ : `<image href="data:image/png;base64,${baseBuf.toString("base64")}" x="0" y="0" width="${canvasW}" height="${canvasH}"/>`;
313
+ const bgLayer =
314
+ baseBuf === null && typeof bg === "string" && bg.trim() !== ""
315
+ ? "" // under 已是纯色,bg 无意义
316
+ : typeof bg === "string" && bg.trim() !== ""
317
+ ? `<rect x="0" y="0" width="${canvasW}" height="${canvasH}" fill="${bg.trim()}"/>`
318
+ : "";
319
+
320
+ /* ---------------------------- 阴影图元 ---------------------------- */
321
+ // 图层可能是一张位图:用 feGaussianBlur(SourceAlpha) + feFlood + feComposite
322
+ // 把图层的 alpha 染成阴影色并模糊——这样任意图层都能加任意颜色的投影。
323
+ // (resvg 的 feDropShadow 无效,实测;这条链路是验证过可用的替代)
324
+ const { list: shadowList } = parseShadows(shadow);
325
+ const shadowDefs = [];
326
+ const shadowLayers = [];
327
+ shadowList.filter((s) => !s.inset).forEach((sh, i) => {
328
+ const id = `ak-lsh-${i}`;
329
+ shadowDefs.push(
330
+ `<filter id="${id}" x="-120%" y="-120%" width="340%" height="340%">` +
331
+ `<feGaussianBlur in="SourceAlpha" stdDeviation="${cssBlurToSigma(sh.blur ?? 0)}" result="b"/>` +
332
+ `<feFlood flood-color="${sh.color}"/>` +
333
+ `<feComposite in2="b" operator="in"/>` +
334
+ `</filter>`
335
+ );
336
+ shadowLayers.push(
337
+ `<image href="data:image/png;base64,${layerBuf.toString("base64")}" x="${offsetX + (sh.dx ?? 0)}" y="${offsetY + (sh.dy ?? 0)}" width="${layerW}" height="${layerH}" filter="url(#${id})"/>`
338
+ );
339
+ });
340
+ const shadowDefsBlock = shadowDefs.length === 0 ? "" : `<defs>${shadowDefs.join("")}</defs>`;
341
+
342
+ const clip = fitMode === "cover" ? `<clipPath id="ak-c"><rect x="0" y="0" width="${canvasW}" height="${canvasH}"/></clipPath>` : "";
343
+ const clipAttr = fitMode === "cover" ? ` clip-path="url(#ak-c)"` : "";
344
+ // SVG <image> 默认 preserveAspectRatio="xMidYMid meet"——**会保持宽高比**,
345
+ // 即使给了不等比例的 width/height 也不拉伸。所以 fit=fill 必须显式 none,
346
+ // 否则「拉满」是假的(实测:传入 200×200 实际只画出 200×100)。
347
+ const stretchAttr = fitMode === "fill" ? ` preserveAspectRatio="none"` : "";
348
+ const composed =
349
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${canvasW}" height="${canvasH}" viewBox="0 0 ${canvasW} ${canvasH}">` +
350
+ (clip === "" ? "" : `<defs>${clip}</defs>`) +
351
+ shadowDefsBlock +
352
+ baseLayer +
353
+ bgLayer +
354
+ // 阴影块放在图层之下、背景之上
355
+ shadowLayers.join("") +
356
+ `<image${stretchAttr}${clipAttr} href="data:image/png;base64,${layerBuf.toString("base64")}" x="${offsetX}" y="${offsetY}" width="${layerW}" height="${layerH}"` +
357
+ (alpha < 1 ? ` opacity="${alpha}"` : "") +
358
+ transform +
359
+ blendStyle +
360
+ `/></svg>`;
361
+ const { buf } = renderToPng(composed);
362
+ await ensureDir(out);
363
+ await writeFile(out, buf);
364
+ // 多尺寸/ico 直接从合成 SVG 重渲染(比缩 PNG 质量好);app 图标一次出全套
365
+ const derived = await writeDerived(composed, out, sizes, ico);
366
+ // 把实际生效的维度回填:调用方要能确认「我传的 anchor 真的生效了」,
367
+ // 而不是像旧版那样传了未暴露的参数被静默忽略。
368
+ return {
369
+ path: out,
370
+ width: canvasW,
371
+ height: canvasH,
372
+ bytes: buf.length,
373
+ anchor: anchorKey,
374
+ fit: fitMode,
375
+ ...(pad > 0 ? { padding: pad } : {}),
376
+ ...(deg !== 0 ? { rotate: deg } : {}),
377
+ ...(blendMode !== "normal" ? { blend: blendMode } : {}),
378
+ ...(alpha < 1 ? { opacity: alpha } : {}),
379
+ layer: { width: layerW, height: layerH, x: offsetX, y: offsetY },
380
+ ...derived
381
+ };
382
+ }
383
+
384
+ /** composite 的位图只收 PNG:本模块用 IHDR 量尺寸,JPEG/WebP 量不到。 */
385
+ function assertPngRaster(input, label) {
386
+ if (!input.path.toLowerCase().endsWith(".png")) {
387
+ throw new Error(`${label}: composite 的位图只支持 .png(收到 ${extname(input.path)});先用图像工具转成 PNG`);
388
+ }
389
+ }
390
+
391
+ /** ICO 内嵌帧的尺寸集(Windows 图标标准 6 档)。 */
392
+ const ICO_SIZES = [16, 32, 48, 64, 128, 256];
393
+
394
+ /** sizes 校验:数组、每项整数 8–4096、去重、最多 12 个。 */
395
+ function normalizeSizes(sizes) {
396
+ if (sizes === undefined || sizes === null) return [];
397
+ if (!Array.isArray(sizes)) throw new Error(`sizes 必须是数字数组(收到 ${JSON.stringify(sizes)})`);
398
+ const out = [];
399
+ for (const raw of sizes) {
400
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 8 || raw > 4096) {
401
+ throw new Error(`sizes 里的尺寸必须是 8–4096 的数字(收到 ${JSON.stringify(raw)})`);
402
+ }
403
+ const n = Math.round(raw);
404
+ if (!out.includes(n)) out.push(n);
405
+ }
406
+ if (out.length > 12) throw new Error(`sizes 最多 12 个尺寸(收到 ${out.length} 个)`);
407
+ return out;
408
+ }
409
+
410
+ /** 多尺寸输出命名:app-1024.png 的变体是 app-1024-512.png?不——档位名即尺寸:app-512.png。 */
411
+ function withSizeSuffix(file, size) {
412
+ const ext = extname(file);
413
+ const stem = ext === "" ? file : file.slice(0, file.length - ext.length);
414
+ return `${stem}-${size}${ext === "" ? ".png" : ext}`;
415
+ }
416
+
417
+ function icoPathOf(png) {
418
+ const ext = extname(png);
419
+ return `${ext === "" ? png : png.slice(0, png.length - ext.length)}.ico`;
420
+ }
421
+
422
+ /**
423
+ * PNG 帧 → ICO 容器(不引入图像库):ICONDIR + ICONDIRENTRY[] + PNG 数据。
424
+ * Vista 以后的 .ico 允许直接嵌 PNG(Windows 10/11 原生支持),不需要 BMP 格式。
425
+ */
426
+ function pngIco(frames) {
427
+ const count = frames.length;
428
+ const header = Buffer.alloc(6);
429
+ header.writeUInt16LE(0, 0); // reserved
430
+ header.writeUInt16LE(1, 2); // type = icon
431
+ header.writeUInt16LE(count, 4);
432
+ const entries = Buffer.alloc(16 * count);
433
+ let offset = 6 + 16 * count;
434
+ frames.forEach((frame, i) => {
435
+ const e = i * 16;
436
+ entries.writeUInt8(frame.width >= 256 ? 0 : frame.width, e);
437
+ entries.writeUInt8(frame.height >= 256 ? 0 : frame.height, e + 1);
438
+ entries.writeUInt8(0, e + 2); // colorCount
439
+ entries.writeUInt8(0, e + 3); // reserved
440
+ entries.writeUInt16LE(1, e + 4); // planes
441
+ entries.writeUInt16LE(32, e + 6); // bitCount
442
+ entries.writeUInt32LE(frame.buf.length, e + 8);
443
+ entries.writeUInt32LE(offset, e + 12);
444
+ offset += frame.buf.length;
445
+ });
446
+ return Buffer.concat([header, entries, ...frames.map((f) => f.buf)]);
447
+ }
448
+
449
+ /** 写多尺寸变体 + 可选 .ico,返回附加字段(无变体时返回空对象)。 */
450
+ async function writeDerived(svg, png, sizes, ico) {
451
+ const extras = {};
452
+ const list = normalizeSizes(sizes);
453
+ if (list.length > 0) {
454
+ const variants = [];
455
+ for (const size of list) {
456
+ const { buf, width, height } = renderToPng(svg, size);
457
+ const file = withSizeSuffix(png, size);
458
+ await ensureDir(file);
459
+ await writeFile(file, buf);
460
+ variants.push({ path: file, width, height, bytes: buf.length });
461
+ }
462
+ extras.variants = variants;
463
+ }
464
+ if (ico === true) {
465
+ const frames = ICO_SIZES.map((size) => {
466
+ const { buf, width, height } = renderToPng(svg, size);
467
+ return { buf, width, height };
468
+ });
469
+ const file = icoPathOf(png);
470
+ await ensureDir(file);
471
+ await writeFile(file, pngIco(frames));
472
+ extras.ico = file;
473
+ }
474
+ return extras;
475
+ }
476
+
477
+ /** 供工具层读产物尺寸(失败返回 null,不抛——报告里不该因为量不到尺寸就失败)。 */
478
+ function sizeOf(file) {
479
+ try {
480
+ return pngSize(readFileSync(file));
481
+ } catch {
482
+ return null;
483
+ }
484
+ }
485
+
486
+ export { composite, pngSize, renderSvg, sizeOf };