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/icondb.js ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * icondb.js — 内置 lucide 图标数据库:惰性加载 + 名称解析 + 关键词检索。
3
+ *
4
+ * data/lucide-icons.json 由 tools/build-icon-data.mjs 生成(源:lucide@1.70.0,ISC;
5
+ * 另含 lucide-static tags 与 lucide.dev 43 类目)。首次工具调用时一次性载入
6
+ * (约 0.91 MB),进程内缓存。
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const DATA_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "data", "lucide-icons.json");
13
+ const PACKS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "data", "packs");
14
+
15
+ // codereview #486:检索结果条数上限(search 的 Math.min 上界)——具名常量便于统一调整。
16
+ const MAX_RESULTS = 50;
17
+
18
+ // codereview #485:名字并列比较抽出为单层三元的助手——原先是排序器里的嵌套三元
19
+ //(项目规范禁止);刻意不用 localeCompare(其 collation 会改变代码单元序,
20
+ // 现有结果顺序是对外可观测行为,必须逐字节保持)。
21
+ function cmpName(x, y) {
22
+ return x < y ? -1 : x > y ? 1 : 0;
23
+ }
24
+
25
+ let db = null;
26
+ let aliasIndex = null; // alias(kebab) → 主名(kebab)
27
+ let categoryIndex = null; // category → [主名],用于「arrows/files 类目词」检索预过滤
28
+ let tagDf = null; // tag → 出现图标数,用于给常见 tag 降权(tags 共享词汇噪声大)
29
+ // 审查修复 #283:lucide 名→条目 Map 在 load() 一次构建并缓存——原先
30
+ // normalizeName/getIcon 每次调用都 new Map(Object.entries(...)) 重建 ~1600 条
31
+ let lucideEntryByName = null;
32
+
33
+ /** 可选数据包(v0.3.0):name → { raw, entryByName, aliasIndex };PACK_ORDER 记注册序。 */
34
+ const packs = new Map();
35
+ const PACK_ORDER = [];
36
+
37
+ function load() {
38
+ if (db !== null) return db;
39
+ // codereview #484:包文件缺失/JSON 损坏不再裸抛 ENOENT/SyntaxError——附上路径,
40
+ // 调用方能直接定位是哪个数据文件坏了(apply 的 iconPacks 循环按原样 catch 跳过)。
41
+ let raw;
42
+ try {
43
+ raw = JSON.parse(readFileSync(DATA_PATH, "utf8"));
44
+ } catch (error) {
45
+ throw new Error(`uilab 图标数据库不可读:${DATA_PATH}(${error?.message ?? error})`);
46
+ }
47
+ db = raw;
48
+ aliasIndex = new Map();
49
+ lucideEntryByName = new Map(Object.entries(raw.icons));
50
+ categoryIndex = new Map();
51
+ tagDf = new Map();
52
+ for (const [name, entry] of lucideEntryByName) {
53
+ for (const alias of entry.aliases ?? []) {
54
+ if (!aliasIndex.has(alias)) aliasIndex.set(alias, name);
55
+ }
56
+ for (const category of entry.categories ?? []) {
57
+ if (!categoryIndex.has(category)) categoryIndex.set(category, []);
58
+ categoryIndex.get(category).push(name);
59
+ }
60
+ for (const tag of entry.tags ?? []) tagDf.set(tag, (tagDf.get(tag) ?? 0) + 1);
61
+ }
62
+ return db;
63
+ }
64
+
65
+ /** 注册可选图标包(data/packs/<name>.icons.json,tools/build-icon-data.mjs --pack 生成)。
66
+ * 幂等;opts.dataDir 可覆盖包目录(自测用)。 */
67
+ function registerPack(packName, opts = {}) {
68
+ const dir = opts.dataDir ?? PACKS_DIR;
69
+ const existing = packs.get(packName);
70
+ if (existing !== undefined) {
71
+ // codereview #484:同名包从不同目录重复注册时不再静默返回旧缓存——那会让
72
+ // 调用方拿到与请求目录不符的数据(自测场景尤甚);同一来源仍幂等早退。
73
+ if (existing.dir !== dir) {
74
+ throw new Error(`icon pack "${packName}" 已从 ${existing.dir} 注册,本次请求的是 ${dir}——同源重复注册才幂等`);
75
+ }
76
+ return existing;
77
+ }
78
+ const file = join(dir, `${packName}.icons.json`);
79
+ let raw;
80
+ try {
81
+ raw = JSON.parse(readFileSync(file, "utf8"));
82
+ } catch (error) {
83
+ throw new Error(`icon pack 文件不可读:${file}(${error?.message ?? error})`);
84
+ }
85
+ if (raw.pack !== packName) throw new Error(`pack name mismatch: file declares "${raw.pack}", requested "${packName}"`);
86
+ const entryByName = new Map(Object.entries(raw.icons ?? {}));
87
+ const packAlias = new Map();
88
+ for (const [name, entry] of entryByName) {
89
+ for (const a of entry.aliases ?? []) {
90
+ if (!packAlias.has(a)) packAlias.set(a, name);
91
+ }
92
+ }
93
+ const rec = { raw, dir, entryByName, aliasIndex: packAlias };
94
+ packs.set(packName, rec);
95
+ PACK_ORDER.push(packName);
96
+ return rec;
97
+ }
98
+
99
+ /** kebab → PascalCase(a-arrow-down → AArrowDown) */
100
+ function kebabToPascal(kebab) {
101
+ return String(kebab).split("-").map((p) => (p === "" ? p : p[0].toUpperCase() + p.slice(1))).join("");
102
+ }
103
+
104
+ /** kebab → camelCase(chevron-down → chevronDown,Colibri ICONS 命名约定) */
105
+ function kebabToCamel(kebab) {
106
+ const p = kebabToPascal(kebab);
107
+ return p === "" ? p : p[0].toLowerCase() + p.slice(1);
108
+ }
109
+
110
+ /** 在单个「名→条目」源内解析任意大小写形态;返回主名或 undefined。 */
111
+ function resolveIn(entryByName, sourceAlias, input) {
112
+ const raw = String(input ?? "").trim();
113
+ if (raw === "") return undefined;
114
+ const candidates = [raw, raw.toLowerCase()];
115
+ const kebabed = raw.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").toLowerCase();
116
+ if (!candidates.includes(kebabed)) candidates.push(kebabed);
117
+ for (const c of candidates) {
118
+ if (entryByName.has(c)) return c;
119
+ if (sourceAlias.has(c)) return sourceAlias.get(c);
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ /** 任意大小写形态 → kebab 主名(lucide 库);解析失败返回 undefined。 */
125
+ function normalizeName(input) {
126
+ load();
127
+ return resolveIn(lucideEntryByName, aliasIndex, input);
128
+ }
129
+
130
+ /** 取图标完整条目;支持 kebab / Pascal / camel / 别名,及 "pack:name" 显式语法;
131
+ * 无显式包名时 lucide 优先,其次按注册顺序查 packs。 */
132
+ function getIcon(input) {
133
+ load();
134
+ const raw0 = String(input ?? "").trim();
135
+ if (raw0 === "") return undefined;
136
+ if (raw0.includes(":")) {
137
+ const idx = raw0.indexOf(":");
138
+ const rec = packs.get(raw0.slice(0, idx));
139
+ if (rec !== undefined) {
140
+ const name = resolveIn(rec.entryByName, rec.aliasIndex, raw0.slice(idx + 1));
141
+ if (name !== undefined) return buildEntry(raw0.slice(0, idx), name, rec.entryByName.get(name));
142
+ }
143
+ }
144
+ const lucideName = resolveIn(lucideEntryByName, aliasIndex, raw0);
145
+ if (lucideName !== undefined) return buildEntry("lucide", lucideName, lucideEntryByName.get(lucideName));
146
+ for (const pn of PACK_ORDER) {
147
+ const rec = packs.get(pn);
148
+ const name = resolveIn(rec.entryByName, rec.aliasIndex, raw0);
149
+ if (name !== undefined) return buildEntry(pn, name, rec.entryByName.get(name));
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ function buildEntry(pack, name, entry) {
155
+ return {
156
+ name,
157
+ pack,
158
+ pascal: entry.pascal,
159
+ camel: kebabToCamel(name),
160
+ aliases: [...(entry.aliases ?? [])],
161
+ node: entry.node,
162
+ ...(entry.tags !== undefined ? { tags: [...entry.tags] } : {}),
163
+ ...(entry.categories !== undefined ? { categories: [...entry.categories] } : {})
164
+ };
165
+ }
166
+
167
+ /** 图标总数与数据源信息(含已注册 packs)。 */
168
+ function meta() {
169
+ load();
170
+ return {
171
+ version: db.source.version,
172
+ license: db.source.license,
173
+ iconCount: db.iconCount,
174
+ aliasCount: aliasIndex.size,
175
+ tagged: db.tagCount ?? 0,
176
+ categories: db.categoryCount ?? 0,
177
+ packs: PACK_ORDER.map((name) => ({ name, iconCount: packs.get(name)?.raw?.iconCount ?? 0 }))
178
+ };
179
+ }
180
+
181
+ /** 单图标形状摘要,如 "2×path + 1×circle"。 */
182
+ function shapeSummary(node) {
183
+ const counts = new Map();
184
+ for (const [tag] of node) counts.set(tag, (counts.get(tag) ?? 0) + 1);
185
+ return [...counts.entries()].map(([tag, n]) => `${n}×${tag}`).join(" + ");
186
+ }
187
+
188
+ // codereview #487:原先悬在 SYNONYMS 之上的一段「关键词检索:精确 > 前缀 > 包含 >
189
+ // 分词子集」JSDoc 描述的是已被取代的旧排序阶梯,且与 search() 处的最新文档重复、
190
+ // 位置又误导读者以为它属于同义词表——已删除(现行行为以 search() 的文档为准)。
191
+ /**
192
+ * 中英同义词表(v0.3.0):查询词 → lucide 检索 token 扩展。
193
+ * 背景:lucide 官方 tags 元数据不随 npm 分发(见 docs/方案对标评估),
194
+ * 自建高频 UI 词汇的中英/同义映射补齐语义检索。命中的 token 以扩展集
195
+ * 参与覆盖率计分(中文词借此命中英文名;英文同义词借此互相打通)。
196
+ */
197
+ const SYNONYMS = {
198
+ // 中文 → lucide 英文 token
199
+ 加载: ["loader", "spinner", "loading"], 加载中: ["loader", "loader-circle"],
200
+ 完成: ["check", "done", "success"], 成功: ["check", "circle-check"], 失败: ["x", "circle-x"],
201
+ 关闭: ["x", "close"], 菜单: ["menu"], 汉堡: ["menu"],
202
+ 搜索: ["search"], 放大镜: ["search"], 设置: ["settings"], 齿轮: ["settings", "cog"],
203
+ 删除: ["trash", "delete"], 垃圾桶: ["trash"], 编辑: ["edit", "pencil"], 铅笔: ["pencil", "edit"],
204
+ 下载: ["download"], 上传: ["upload"], 播放: ["play"], 暂停: ["pause"],
205
+ 主题: ["sun", "moon"], 太阳: ["sun"], 月亮: ["moon"],
206
+ 用户: ["user"], 个人: ["user"], 人物: ["users", "user"],
207
+ 通知: ["bell"], 铃: ["bell"], 提醒: ["bell", "alarm"],
208
+ 添加: ["plus"], 新增: ["plus"], 复制: ["copy"], 克隆: ["copy"],
209
+ 刷新: ["refresh", "refresh-cw"], 同步: ["refresh-cw"], 返回: ["arrow-left"],
210
+ 首页: ["home"], 主页: ["home"], 时间: ["clock"], 时钟: ["clock"], 日历: ["calendar"],
211
+ // 形状/符号:中文几乎总是带量词或形状一起说("心形图标""加号按钮"),不映射就 0 命中
212
+ 心形: ["heart"], 星形: ["star"], 圆形: ["circle"], 方形: ["square"], 三角: ["triangle"],
213
+ 加号: ["plus"], 减号: ["minus"], 叉号: ["x"], 对勾: ["check"], 箭头: ["arrow"], 圆点: ["dot"],
214
+ 猫: ["cat"], 狗: ["dog"], 心: ["heart"], 收藏: ["heart", "star", "bookmark"], 点赞: ["thumbs-up", "heart"],
215
+ 分享: ["share"], 锁: ["lock"], 锁定: ["lock"], 安全: ["shield", "lock"], 盾: ["shield"],
216
+ 眼睛: ["eye"], 可见: ["eye"], 邮件: ["mail", "email"], 电话: ["phone"],
217
+ 相机: ["camera"], 拍照: ["camera"], 图片: ["image"], 照片: ["image"],
218
+ 文件: ["file"], 文档: ["file-text", "file"], 文件夹: ["folder"], 目录: ["folder"],
219
+ 链接: ["link"], 警告: ["triangle-alert"], 警示: ["triangle-alert"],
220
+ 信息: ["info"], 帮助: ["circle-help"], 问号: ["circle-help"],
221
+ 星: ["star"], 评分: ["star"], 举报: ["flag"], 旗帜: ["flag"],
222
+ // 英文同义(库内无同名图标或语义分叉的场景)
223
+ spinner: ["loader", "loader-circle"], done: ["check"], success: ["check", "circle-check"],
224
+ close: ["x"], dismiss: ["x"], hamburger: ["menu"], magnifier: ["search"],
225
+ gear: ["settings"], cog: ["settings"], remove: ["trash"], reload: ["refresh-cw"],
226
+ sync: ["refresh-cw"], favorite: ["heart", "star"], like: ["thumbs-up", "heart"],
227
+ notification: ["bell"], add: ["plus"], clone: ["copy"], photo: ["image"], picture: ["image"],
228
+ document: ["file-text", "file"], alert: ["triangle-alert"], question: ["circle-help"],
229
+ rating: ["star"], report: ["flag"], people: ["users"], person: ["user"],
230
+ // 2026-11-07 扩充:tags 是英文的,中文查询靠这层打通(常用 UI 名词/动作)
231
+ 垃圾: ["trash"], 垃圾桶: ["trash"], 箭头: ["arrow-right"], 房子: ["house"],
232
+ 住宅: ["house"], 建筑: ["building"], 导航: ["navigation"], 定位: ["map-pin"],
233
+ 地图: ["map"], 购物车: ["shopping-cart"], 钱包: ["wallet"], 银行卡: ["credit-card"],
234
+ 礼物: ["gift"], 钥匙: ["key"], 奖杯: ["trophy"], 咖啡: ["coffee"],
235
+ 汽车: ["car"], 飞机: ["plane"], 火车: ["train-front"], 地球: ["globe"],
236
+ 云: ["cloud"], 水滴: ["droplet"], 火焰: ["flame"], 闪电: ["zap"],
237
+ 火箭: ["rocket"], 大脑: ["brain"], 书: ["book"], 保存: ["save"],
238
+ 打印: ["printer"], 扫描: ["scan"], 二维码: ["qr-code"], 网格: ["grid-3x3"],
239
+ 列表: ["list"], 表格: ["table"], 图表: ["chart-line"], 趋势: ["trending-up"],
240
+ 数据库: ["database"], 服务器: ["server"], 网络: ["wifi"], 蓝牙: ["bluetooth"],
241
+ 电池: ["battery"], 充电: ["battery-charging"], 电源: ["power"], 扳手: ["wrench"],
242
+ 锤子: ["hammer"], 音量: ["volume-2"], 静音: ["volume-x"], 快进: ["skip-forward"],
243
+ 快退: ["skip-back"], 消息: ["message-square"], 聊天: ["messages-square"], 视频: ["video"],
244
+ 音乐: ["music"], 麦克风: ["mic"], 耳机: ["headphones"], 摄像头: ["camera"]
245
+ };
246
+
247
+ /** 查询 token 集合 → 含同义词扩展的 Map<token, 权重>:原词权重 1,
248
+ * 同义词按列表顺序递减(0.5 / 0.33 / 0.25)——「删除→trash,delete」时
249
+ * trash 比 delete 更能代表查询意图。 */
250
+ /**
251
+ * 风格意图:「实心 / 填充 / filled」要 -filled 变体,「线性 / 描边 / outline」要描边版。
252
+ * tabler 有 1088 个 -filled 图标,但名字只认英文 Filled——不映射就 0 命中,
253
+ * 模型会得出「库里没有填充图标」的错误结论。
254
+ */
255
+ const STYLE_INTENT = [
256
+ { style: "filled", re: /实心|填充|实底|填色|\b(?:filled|solid)\b/ },
257
+ { style: "outline", re: /线性|线框|描边|轮廓|\boutline\b/ }
258
+ ];
259
+
260
+ /** 常见中文品牌名 → tabler brand-* 英文名(tabler 有 411 个品牌图标,名字只认英文)。 */
261
+ const BRAND_ALIAS = {
262
+ 微信: "wechat", 淘宝: "taobao", 支付宝: "alipay", 抖音: "tiktok", 微博: "weibo", 知乎: "zhihu",
263
+ 哔哩哔哩: "bilibili", 钉钉: "dingtalk", 飞书: "lark", 美团: "meituan", 京东: "jd", 百度: "baidu",
264
+ 网易: "netease", 小红书: "xiaohongshu", 谷歌: "google", 苹果: "apple", 微软: "microsoft", 亚马逊: "amazon"
265
+ };
266
+ const BRAND_INTENT = /品牌|标志|商标|\bbrand\b|\blogo\b/;
267
+
268
+ function expandTokens(tokens) {
269
+ const weights = new Map();
270
+ for (const t of tokens) {
271
+ if (!weights.has(t)) weights.set(t, 1);
272
+ (SYNONYMS[t] ?? []).forEach((syn, i) => {
273
+ const w = 1 / (i + 2);
274
+ if (!weights.has(syn) || weights.get(syn) < w) weights.set(syn, w);
275
+ });
276
+ }
277
+ return weights;
278
+ }
279
+
280
+ /** 单字符 token(如 x)按词边界匹配,避免 axe/box 这类包含 x 的子串混入。 */
281
+ function nameHasToken(name, token) {
282
+ if (token.length > 1) return name.includes(token);
283
+ return name === token || name.startsWith(`${token}-`) || name.endsWith(`-${token}`);
284
+ }
285
+ function nameStartsWithToken(name, token) {
286
+ return token.length > 1 ? name.startsWith(token) : name.startsWith(`${token}-`);
287
+ }
288
+
289
+ /**
290
+ * 关键词检索:精确 > 前缀 > 包含 > 全 token 命中 > 官方 tags > 类目 > 多数命中(宽松回退);
291
+ * 别名同阶梯但降半级;tags/categories 来自 lucide-static tags.json 与 lucide.dev 43 类目。
292
+ * 多词/同义词堆叠(如 "loader loading spinner circle")走宽松回退按命中数排序,避免 0 命中。
293
+ */
294
+ function search(query, limit = 12) {
295
+ load();
296
+ // camelCase/PascalCase 先拆成 kebab 再小写:emit/getIcon 都接受 chevronDown,
297
+ // 但 search 原先只 toLowerCase → 同一个名字在 find 里搜不到(工具内部不一致)。
298
+ const raw = String(query ?? "")
299
+ .trim()
300
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
301
+ .toLowerCase();
302
+ if (raw === "") return [];
303
+ // 风格/品牌意图:先识别(在发布前测过:"实心"、"填充"、"线性"、"微信" 全部 0 命中)
304
+ const wantStyle = STYLE_INTENT.find((rule) => rule.re.test(raw))?.style;
305
+ const wantBrand = BRAND_INTENT.test(raw);
306
+ let normalized = raw;
307
+ for (const [zh, en] of Object.entries(BRAND_ALIAS)) if (normalized.includes(zh)) normalized = normalized.split(zh).join(` ${en} `);
308
+ // 风格词只当意图、不进内容匹配:否则 "线性 首页" 会先命中 text-outline(名字里带 outline)
309
+ normalized = normalized
310
+ .replace(/实心|填充|实底|填色|\bfilled\b|\bsolid\b/g, " ")
311
+ .replace(/线性|线框|描边|轮廓|\boutline\b/g, " ");
312
+ if (wantBrand) normalized = normalized.replace(/品牌|标志|商标|\bbrand\b|\blogo\b/g, " ");
313
+ // 中文助词/泛称:"填充的心形" 去掉「的」才剩「心形」可查同义词表(中文无空格分词)
314
+ normalized = normalized.replace(/的|图标|按钮|一个|个/g, " ");
315
+ normalized = normalized.replace(/\s+/g, " ").trim();
316
+ // 只给意图词(如"填充""品牌"):补一个能命中一批结果的默认 token
317
+ if (normalized === "") normalized = wantBrand ? "brand" : wantStyle === "filled" ? "filled" : "";
318
+ if (normalized === "") return [];
319
+ const q = normalized.replace(/[\s_]+/g, "-").replace(/-+/g, "-");
320
+ const tokens = q.split("-").filter(Boolean);
321
+ const expanded = expandTokens(tokens); // 同义词扩展(v0.3.0)
322
+ const activeCategories = tokens.filter((t) => categoryIndex.has(t));
323
+ const packRank = new Map([["lucide", 0]]);
324
+ PACK_ORDER.forEach((n, i) => packRank.set(n, i + 1));
325
+ const sources = [
326
+ { pack: "lucide", entries: lucideEntryByName.entries(), srcAlias: aliasIndex },
327
+ ...PACK_ORDER.map((n) => {
328
+ const rec = packs.get(n);
329
+ return { pack: n, entries: rec.entryByName.entries(), srcAlias: rec.aliasIndex };
330
+ })
331
+ ];
332
+ const scored = [];
333
+ for (const { pack, entries, srcAlias } of sources) {
334
+ for (const [name, entry] of entries) {
335
+ let score = Infinity;
336
+ if (name === q) score = 0;
337
+ else if (nameStartsWithToken(name, q)) score = 1;
338
+ else if (nameHasToken(name, q)) score = 2;
339
+ else if (tokens.length > 1 && tokens.every((t) => nameHasToken(name, t))) score = 3;
340
+ if (score === Infinity) {
341
+ // 命名子串命中(含同义词)与官方 tags 命中:tags 按出现频次降权——
342
+ // lucide 的 tags 共享词汇(remove/delete/clear)极大,不降权会把
343
+ // 语义无关的图标一起顶上来。
344
+ let nameHit = 0;
345
+ for (const [t, w] of expanded) if (nameHasToken(name, t)) nameHit += w;
346
+ let tagHit = 0;
347
+ if (entry.tags !== undefined) {
348
+ for (const [t, w] of expanded) {
349
+ if (entry.tags.includes(t)) tagHit += w / Math.log2(2 + (tagDf.get(t) ?? 1));
350
+ }
351
+ }
352
+ if (nameHit > 0 || tagHit > 0) {
353
+ // 名字命中(含同义词)优先于 tag 命中:同词义的 tag 只能作为无
354
+ // 名字命中时的补充(否则共享 tag 会把「收藏→file-heart」顶到 heart 前)
355
+ score = nameHit > 0 ? 3.2 - Math.min(1.8, nameHit * 0.8) : 3.9 - Math.min(1.5, tagHit * 1.2);
356
+ }
357
+ }
358
+ // 审查修复 #284:移除 score === Infinity 门控——原门控使循环内 Math.min
359
+ // 恒为直接赋值(死逻辑),且「名字仅包含命中(2)而别名精确命中」时排名
360
+ // 无法按 JSDoc 契约(别名同阶梯降半级 = 1.5)提升;别名命中始终参与比较
361
+ for (const alias of entry.aliases ?? []) {
362
+ if (alias === q) score = Math.min(score, 1.5);
363
+ else if (alias.startsWith(q)) score = Math.min(score, 2.5);
364
+ else if (alias.includes(q)) score = Math.min(score, 3.5);
365
+ }
366
+ // 43 类目:查询词本身就是类目名(如 arrows / files / design)时整类提升
367
+ if (score > 3 && activeCategories.length > 0 && entry.categories !== undefined) {
368
+ if (entry.categories.some((c) => activeCategories.includes(c))) score = Math.min(score, 3.0);
369
+ }
370
+ if (score !== Infinity) {
371
+ // 风格意图排序:要填充就 -filled 优先,要线性就把 -filled 压下去(只调排名,不改结果集)
372
+ if (wantStyle === "filled" && name.endsWith("-filled")) score = Math.max(0, score - 1.6);
373
+ if (wantStyle === "outline" && name.endsWith("-filled")) score += 1.6;
374
+ // 品牌意图:brand-* 优先,其它图标降权
375
+ if (wantBrand) score += name.startsWith("brand-") ? -0.7 : 0.9;
376
+ scored.push({ score, len: name.length, name, pack, entry });
377
+ }
378
+ }
379
+ }
380
+ scored.sort(
381
+ (a, b) => a.score - b.score || a.len - b.len || cmpName(a.name, b.name) || (packRank.get(a.pack) ?? 99) - (packRank.get(b.pack) ?? 99)
382
+ );
383
+ const hasName = (pack, name) => (pack === "lucide" ? lucideEntryByName.has(name) : (packs.get(pack)?.entryByName.has(name) ?? false));
384
+ return scored.slice(0, Math.max(1, Math.min(limit, MAX_RESULTS))).map(({ name, pack, entry }) => {
385
+ // 可选版本提示:有 −filled 双胞胎 → solid 可直接用;两级都有 → duotone 可合成
386
+ const outlined = !name.endsWith("-filled");
387
+ const twinName = outlined ? `${name}-filled` : name.slice(0, -"-filled".length);
388
+ const hasTwin = twinName !== "" && twinName !== name && hasName(pack, twinName);
389
+ return {
390
+ name,
391
+ pack,
392
+ variants: hasTwin ? (outlined ? ["filled", "duotone"] : ["outline", "duotone"]) : [],
393
+ pascal: entry.pascal,
394
+ camel: kebabToCamel(name),
395
+ aliases: [...(entry.aliases ?? [])],
396
+ nodeCount: entry.node.length,
397
+ shapes: shapeSummary(entry.node)
398
+ };
399
+ });
400
+ }
401
+
402
+ export { getIcon, kebabToCamel, kebabToPascal, meta, normalizeName, registerPack, search, shapeSummary };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * image-normalize.js — 把 JPEG / WebP 转成 PNG,好让位图体检能用。
3
+ *
4
+ * 为什么需要:`taste_check` 与 `layout_check image` 的解码器只认 PNG(自己写的、零依赖)。
5
+ * 但用户手里的图**大多不是 PNG**——截图工具导出 jpg、手机拍照是 jpg、网页另存是 webp,
6
+ * 每次都被一句“只支持 PNG”挡回去,等于这项能力在真实场景里经常用不上。
7
+ *
8
+ * 为什么不上完整解码器:JPEG 要 huffman + IDCT,WebP 要 VP8,两者都远超“一本正经写个小东西”
9
+ * 的量级,且必然引入精度风险。这里只做**尺寸解析 + 交给本机浏览器转码**——
10
+ * 浏览器本来就是我们已声明的可选依赖(HTML→PNG),复用它比自己写解码器更可靠。
11
+ * 解析尺寸是为了**按原尺寸**截图,不让浏览器缩放(缩放会改变构图比例,直接影响体检结论)。
12
+ */
13
+
14
+ const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
15
+
16
+ /** PNG:直接读 IHDR。 */
17
+ function pngSize(buf) {
18
+ if (buf.length < 24 || buf.subarray(0, 8).equals(PNG_SIG) === false) return null;
19
+ return { format: "png", width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
20
+ }
21
+
22
+ /** JPEG:扫 SOFn 段拿宽高(不解码像素)。 */
23
+ function jpegSize(buf) {
24
+ if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
25
+ let i = 2;
26
+ // 需要读到 i+8(宽度低字节)才够,所以是 `<=` 不是 `<`:
27
+ // 写成 `i + 9 < len` 会让**末尾刚好结束的合法 JPEG** 被漏掉(合成夹具当场拦下)。
28
+ while (i + 9 <= buf.length) {
29
+ if (buf[i] !== 0xff) {
30
+ i += 1;
31
+ continue;
32
+ }
33
+ const marker = buf[i + 1];
34
+ // SOF0/1/2/3/5/6/7/9/10/11/13/14/15(排除 4/8/12 等非 SOF)
35
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
36
+ if (isSof) return { format: "jpeg", height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
37
+ const len = buf.readUInt16BE(i + 2);
38
+ if (len < 2) return null;
39
+ i += 2 + len;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** WebP:RIFF 容器,按 VP8 / VP8L / VP8X 三种块取尺寸。 */
45
+ function webpSize(buf) {
46
+ if (buf.length < 30 || buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP") return null;
47
+ const chunk = buf.toString("ascii", 12, 16);
48
+ if (chunk === "VP8 ") {
49
+ // 关键帧头:0x9d 0x01 0x2a 后面是 w/h(各 14 位)
50
+ return { format: "webp", width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
51
+ }
52
+ if (chunk === "VP8L") {
53
+ const bits = buf.readUInt32LE(21);
54
+ return { format: "webp", width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
55
+ }
56
+ if (chunk === "VP8X") {
57
+ const width = (buf[24] | (buf[25] << 8) | (buf[26] << 16)) + 1;
58
+ const height = (buf[27] | (buf[28] << 8) | (buf[29] << 16)) + 1;
59
+ return { format: "webp", width, height };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * 识别格式并取尺寸。
66
+ * @param {Buffer} buf
67
+ * @returns {{format:"png"|"jpeg"|"webp", width:number, height:number}|null}
68
+ */
69
+ export function imageSize(buf) {
70
+ return pngSize(buf) ?? jpegSize(buf) ?? webpSize(buf);
71
+ }
72
+
73
+ /** 这个扩展名需不需要转码(PNG 不用)。 */
74
+ export function needsNormalize(file) {
75
+ return /\.(?:jpe?g|webp)$/i.test(String(file));
76
+ }
77
+
78
+ /**
79
+ * 读成 PNG 字节:PNG 直读,JPEG/WebP 先转码(临时文件用完即删)。
80
+ *
81
+ * 位图层(taste_check)与构图层(layout_check image)共用一个入口:
82
+ * 两边各自写一份的话,就会出现“AI 味体检能吃 jpg,构图体检还只说只支持 PNG”这种分叉。
83
+ *
84
+ * @param {string} file 源图绝对路径
85
+ * @param {{browserPath?:string}} [opts]
86
+ * @returns {Promise<Buffer>}
87
+ */
88
+ export async function readAsPng(file, opts = {}) {
89
+ const { readFileSync, rmSync } = await import("node:fs");
90
+ const { tmpdir } = await import("node:os");
91
+ const { join } = await import("node:path");
92
+ // **按内容判定,不按扩展名**:把 png 存成 .jpg 是常见操作(截图工具、批量重命名),
93
+ // 按扩展名分支会让它去转码,而转码函数看到真身是 png 又直接返回原路径,
94
+ // 两头一错开就读了一个不存在的临时文件(合成夹具当场拦下)。
95
+ const buf = readFileSync(file);
96
+ const size = imageSize(buf);
97
+ if (size === null) throw new Error(`${file} 不是可识别的 PNG/JPEG/WebP(读不到尺寸)`);
98
+ if (size.format === "png") return buf;
99
+ const temp = join(tmpdir(), `visualkit-img-${process.pid}-${Date.now()}.png`);
100
+ try {
101
+ await normalizeImage({ file, out: temp, browserPath: opts.browserPath });
102
+ return readFileSync(temp);
103
+ } finally {
104
+ rmSync(temp, { force: true });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * 用本机浏览器把任意图片转成同尺寸 PNG(不缩放)。
110
+ *
111
+ * @param {object} opts
112
+ * @param {string} opts.file 源图绝对路径
113
+ * @param {string} opts.out 输出 PNG 绝对路径
114
+ * @param {string} [opts.browserPath]
115
+ * @returns {Promise<{path:string, width:number, height:number, format:string}>}
116
+ */
117
+ export async function normalizeImage(opts) {
118
+ const { readFileSync } = await import("node:fs");
119
+ const { pathToFileURL } = await import("node:url");
120
+ const { renderHtml } = await import("./render-html.js");
121
+
122
+ const buf = readFileSync(opts.file);
123
+ const size = imageSize(buf);
124
+ if (size === null) {
125
+ throw new Error(`${opts.file} 不是可识别的 PNG/JPEG/WebP(读不到尺寸)——请先另存为标准格式`);
126
+ }
127
+ if (size.format === "png") return { path: opts.file, width: size.width, height: size.height, format: "png" };
128
+
129
+ // 页面只放一张图、零边距;窗口取原尺寸,避免任何缩放(缩放会改变构图比例)
130
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;background:#fff}img{display:block;width:${size.width}px;height:${size.height}px}</style></head><body><img src="${pathToFileURL(opts.file).href}"></body></html>`;
131
+ await renderHtml({
132
+ html,
133
+ out: opts.out,
134
+ width: size.width,
135
+ height: size.height,
136
+ scale: 1,
137
+ ...(opts.browserPath === undefined ? {} : { browserPath: opts.browserPath })
138
+ });
139
+ return { path: opts.out, width: size.width, height: size.height, format: size.format };
140
+ }