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
@@ -0,0 +1,213 @@
1
+ /**
2
+ * contrast-render.js — 在**真实渲染像素**上量文字对比度。
3
+ *
4
+ * 为什么需要它:静态解析 CSS 遇到 `rgba()`、CSS 变量、渐变背景时算不出真实对比度,
5
+ * 只能诚实跳过(就是体检里的 contrast-invalid)。而这三类恰好是现在最常用的写法。
6
+ *
7
+ * 做法(不靠 OCR、不靠 DOM):同一页面渲两遍——
8
+ * A:正常渲染 → 某个像素 = 文字色与背景色的混合结果
9
+ * B:把文字染成透明(`color: transparent`),布局不变 → 该像素 = **真实背景**
10
+ * A 与 B 差异大的地方就是文字墨迹;把墨迹最深处(最接近纯文字色)和它背后的 B
11
+ * 像素拿来算 WCAG 对比度。渐变、图片、半透明图层全部自动覆盖——因为量的是像素。
12
+ *
13
+ * 为什么不取纯前景色:文字有抗锯齿与字重差异,细字的“有效对比度”本来就低于
14
+ * 声明值。直接量像素反而更接近人眼看到的。
15
+ */
16
+ import { relativeLuminance } from "./layouts/rules-contrast.js";
17
+ import { decodePng } from "./layouts/png.js";
18
+ import { readFileSync, writeFileSync } from "node:fs";
19
+ import { renderHtml } from "./render-html.js";
20
+
21
+ /** 把页面里的文字全部染成透明(保留布局),用于取得“真实背景”。 */
22
+ export function hideTextCss() {
23
+ return `*{color:transparent !important;text-shadow:none !important;caret-color:transparent !important;}`;
24
+ }
25
+
26
+ /** 把注入用的 <style> 插到 </head> 前;没有 head 就插到最前。 */
27
+ export function injectCss(html, css) {
28
+ const tag = `<style data-dsh-inject>${css}</style>`;
29
+ const at = html.toLowerCase().lastIndexOf("</head>");
30
+ if (at !== -1) return html.slice(0, at) + tag + html.slice(at);
31
+ return tag + html;
32
+ }
33
+
34
+ const srgb = (v) => {
35
+ const c = v / 255;
36
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
37
+ };
38
+ const lum = (r, g, b) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
39
+
40
+ /** WCAG 对比度(1–21),两端用像素实际值。 */
41
+ export function pixelContrast(a, b) {
42
+ const la = lum(a[0], a[1], a[2]);
43
+ const lb = lum(b[0], b[1], b[2]);
44
+ const hi = Math.max(la, lb);
45
+ const lo = Math.min(la, lb);
46
+ return (hi + 0.05) / (lo + 0.05);
47
+ }
48
+
49
+ /**
50
+ * 量一张页面的真实文字对比度。
51
+ * @param {object} opts
52
+ * @param {string} opts.htmlPath HTML 文件路径
53
+ * @param {string} opts.outDir 两遍渲染的临时输出目录
54
+ * @param {number} [opts.width] 视口宽
55
+ * @param {number} [opts.height] 视口高
56
+ * @param {string} [opts.browserPath]
57
+ * @returns {{worst:object, samples:number, pairs:object[], rendered:boolean, reason?:string}}
58
+ */
59
+ export function measureRenderedContrast(opts) {
60
+ const width = Number.isFinite(opts.width) ? opts.width : 1280;
61
+ const height = Number.isFinite(opts.height) ? opts.height : 800;
62
+ const plain = `${opts.outDir}/__contrast_plain.png`;
63
+ const bare = `${opts.outDir}/__contrast_bare.png`;
64
+
65
+ const barePath = `${opts.outDir}/__contrast_bare.html`;
66
+
67
+ // 读文件与渲染都在 try 里:缺文件、没浏览器、路径不可写,都应该返回结构化的
68
+ // rendered:false + reason,而不是把异常抛给调用方(那样上层就没法降级了)。
69
+ try {
70
+ const source = readFileSync(opts.htmlPath, "utf8");
71
+ writeFileSync(barePath, injectCss(source, hideTextCss()), "utf8");
72
+ renderHtml({ html: opts.htmlPath, out: plain, width, height, browserPath: opts.browserPath });
73
+ renderHtml({ html: barePath, out: bare, width, height, browserPath: opts.browserPath });
74
+ } catch (error) {
75
+ return { rendered: false, reason: error.message, worst: null, samples: 0, pairs: [] };
76
+ }
77
+
78
+ const A = decodePng(readFileSync(plain));
79
+ const B = decodePng(readFileSync(bare));
80
+ const w = Math.min(A.width, B.width);
81
+ const h = Math.min(A.height, B.height);
82
+ const ch = A.channels;
83
+ const chB = B.channels;
84
+ const at = (img, c, x, y) => {
85
+ const i = (y * img.width + x) * c;
86
+ return [img.data[i], img.data[i + 1], img.data[i + 2]];
87
+ };
88
+
89
+ // 连通域分组:一个文字块就是一个连通域,里面**最墨的像素**才是它的本色。
90
+ // 为什么不用「按颜色聚类」:抗锯齿边缘的颜色恰好是“背景到文字色”的中间调,
91
+ // 会自成一个簇,被当成一种低对比文字报出来(实测误报 1.97:1 / 2.23:1)。
92
+ // 连通域天然把边缘和本体绑在一起,取域内 max-delta 像素就拿到纯色。
93
+ const W2 = w;
94
+ const H2 = h;
95
+ const inked = new Uint8Array(W2 * H2);
96
+ const deltas = new Uint16Array(W2 * H2);
97
+ let totalSamples = 0;
98
+ for (let y = 0; y < H2; y += 1) {
99
+ for (let x = 0; x < W2; x += 1) {
100
+ const a = at(A, ch, x, y);
101
+ const b = at(B, chB, x, y);
102
+ const d = Math.round((Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3);
103
+ if (d < 40) continue;
104
+ inked[y * W2 + x] = 1;
105
+ deltas[y * W2 + x] = d;
106
+ totalSamples += 1;
107
+ }
108
+ }
109
+
110
+ const labels = new Int32Array(W2 * H2).fill(-1);
111
+ const parent = [];
112
+ const find = (i) => {
113
+ let r = i;
114
+ while (parent[r] !== r) r = parent[r];
115
+ while (parent[i] !== r) {
116
+ const next = parent[i];
117
+ parent[i] = r;
118
+ i = next;
119
+ }
120
+ return r;
121
+ };
122
+ const union = (a1, b1) => {
123
+ const ra = find(a1);
124
+ const rb = find(b1);
125
+ if (ra !== rb) parent[rb] = ra;
126
+ };
127
+ let next = 0;
128
+ for (let y = 0; y < H2; y += 1) {
129
+ for (let x = 0; x < W2; x += 1) {
130
+ const idx = y * W2 + x;
131
+ if (inked[idx] === 0) continue;
132
+ const left = x > 0 ? labels[idx - 1] : -1;
133
+ const up = y > 0 ? labels[idx - W2] : -1;
134
+ if (left === -1 && up === -1) {
135
+ labels[idx] = next;
136
+ parent[next] = next;
137
+ next += 1;
138
+ } else if (left !== -1 && up !== -1) {
139
+ labels[idx] = Math.min(left, up);
140
+ union(left, up);
141
+ } else {
142
+ labels[idx] = left !== -1 ? left : up;
143
+ }
144
+ }
145
+ }
146
+
147
+ // 汇总每个连通域:像素数 + 最墨的那个像素(它的 fg/bg 就是该文字的纯色与真实背景)
148
+ const comps = new Map();
149
+ for (let y = 0; y < H2; y += 1) {
150
+ for (let x = 0; x < W2; x += 1) {
151
+ const idx = y * W2 + x;
152
+ if (labels[idx] === -1) continue;
153
+ const root = find(labels[idx]);
154
+ const d = deltas[idx];
155
+ const cur = comps.get(root);
156
+ if (cur === undefined) {
157
+ comps.set(root, { n: 1, best: d, x, y, fg: at(A, ch, x, y), bg: at(B, chB, x, y) });
158
+ } else {
159
+ cur.n += 1;
160
+ if (d > cur.best) {
161
+ cur.best = d;
162
+ cur.x = x;
163
+ cur.y = y;
164
+ cur.fg = at(A, ch, x, y);
165
+ cur.bg = at(B, chB, x, y);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ // 太小的连通域是网点/描边/图标碎片,不是正文
172
+ const MIN_PIXELS = 30;
173
+ const bands = [...comps.values()]
174
+ .filter((c) => c.n >= MIN_PIXELS)
175
+ .map((c) => ({
176
+ ratio: pixelContrast(c.fg, c.bg),
177
+ fg: c.fg,
178
+ bg: c.bg,
179
+ x: c.x,
180
+ y: c.y,
181
+ pixels: c.n
182
+ }))
183
+ .sort((p1, p2) => p1.ratio - p2.ratio);
184
+
185
+ // 同一色文字会切成很多连通域(中文每个字都是独立的),按「前景色」去重:
186
+ // 每种文字色只留最差的那一处,否则报告里会刷屏同一行字的六个字。
187
+ const byColor = new Map();
188
+ for (const bnd of bands) {
189
+ const k = bnd.fg.map((v) => Math.round(v / 16)).join(",");
190
+ const cur = byColor.get(k);
191
+ if (cur === undefined || bnd.ratio < cur.ratio) byColor.set(k, bnd);
192
+ }
193
+ const distinct = [...byColor.values()].sort((p1, p2) => p1.ratio - p2.ratio);
194
+
195
+ return {
196
+ rendered: true,
197
+ samples: totalSamples,
198
+ bands: distinct.length,
199
+ allRuns: bands.length,
200
+ worst: distinct[0],
201
+ // 按对比度从低到高给出前几套文字配色,便于一眼看出哪几种字最危险
202
+ pairs: distinct.slice(0, 6).map((bnd) => ({
203
+ ratio: Math.round(bnd.ratio * 100) / 100,
204
+ fg: `rgb(${bnd.fg.join(",")})`,
205
+ bg: `rgb(${bnd.bg.join(",")})`,
206
+ pixels: bnd.pixels,
207
+ x: bnd.x,
208
+ y: bnd.y
209
+ }))
210
+ };
211
+ }
212
+
213
+ void relativeLuminance;
@@ -0,0 +1,379 @@
1
+ /**
2
+ * css-rewrite.js — 把散落的数值收敛到一套规范(机械、可验收)。
3
+ *
4
+ * 为什么需要:这一路做真实项目时,同一件事手写了三次——「把 margin/padding/gap 对齐到 4px 网格」、
5
+ * 「把 11 档字号收敛到 5 档」。步骤固定、判据明确、结果可验证(渲染 diff 均差 1.2%),
6
+ * 却每次都得重写一遍脚本。这类活应该是一种能力,而不是一段随手代码。
7
+ *
8
+ * 三条铁律(都不是风格取向,而是“改了必然错”的情况):
9
+ * ① 注释与字符串里的数字不动(`content:"6px"` 不是尺寸声明)。
10
+ * ② var() / calc() / 百分比 / em / rem / auto / inherit 一律不碰——
11
+ * 这些值的实际尺寸由上下文决定,静态改数字是瞎猜。
12
+ * ③ 0.5 / 1 / 2px 保留:描边与微调本来就不该被拉到 4 的倍数。
13
+ * 另外:负数保留符号(负 margin 是刻意为之的重叠/对齐手段,只调幅度不改方向)。
14
+ */
15
+
16
+ /** 把注释与字符串挖成等长空白,保证索引与原文对齐。 */
17
+ function maskNoise(source) {
18
+ const s = String(source);
19
+ return s
20
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
21
+ .replace(/"(?:[^"\\\n]|\\.)*"/g, (m) => m.replace(/[^\n]/g, " "))
22
+ .replace(/'(?:[^'\\\n]|\\.)*'/g, (m) => m.replace(/[^\n]/g, " "));
23
+ }
24
+
25
+ const SKIP_VALUE = /var\(|calc\(|%|\bem\b|\brem\b|\bvmin\b|\bvmax\b|\bauto\b|\binherit\b|\bunset\b|\binitial\b|!important/i;
26
+
27
+ /** 间距类属性(能被网格约束的)。 */
28
+ export const SPACING_PROPS = new Set([
29
+ "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
30
+ "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
31
+ "gap", "row-gap", "column-gap"
32
+ ]);
33
+
34
+ /**
35
+ * 扫出所有声明(只扫 `prop: value` 形态,跳过选择器与 at-rule 条件)。
36
+ * @returns {{prop:string, value:string, valueStart:number, valueEnd:number}[]}
37
+ */
38
+ export function scanDecls(source) {
39
+ const masked = maskNoise(source);
40
+ const out = [];
41
+ // 声明一定在 `{ ... }` 里;这里用“最后一个 { 或 ; 之后到 : 之间”的弱判据即可:
42
+ // 选择器里的 `:`(伪类)不在 `{` 之后紧邻位置,会被下面的 `prev` 判断挡掉。
43
+ const re = /([-a-zA-Z]+)\s*:\s*([^;{}]+)/g;
44
+ let m;
45
+ while ((m = re.exec(masked)) !== null) {
46
+ const prop = m[1].toLowerCase();
47
+ // 伪类/伪元素的选择器片段会被当成 prop(如 `hover`),用“位置前一个有意义字符”排除:
48
+ const before = masked.slice(Math.max(0, m.index - 1), m.index);
49
+ if (before !== "{" && before !== ";" && before !== "\n" && before !== " " && before !== "\t" && before !== "") {
50
+ // 允许 `--x: 4px` 之类的自定义属性(prop 以 - 开头)
51
+ if (prop.startsWith("-") === false) continue;
52
+ }
53
+ const value = m[2].trim();
54
+ if (value === "") continue;
55
+ const valueStart = m.index + m[0].indexOf(m[2]) + (m[2].length - m[2].trimStart().length);
56
+ // 所属选择器在**声明之前**:先找这条声明的 `{`,再往前找上一个 `}` / `;`,两者之间就是选择器。
57
+ // 有些规则只对“正文级”成立(行高就是典型):判断建在选择器上比猜值可靠。
58
+ const open = masked.lastIndexOf("{", m.index);
59
+ let selector = "";
60
+ if (open !== -1) {
61
+ const prev = Math.max(masked.lastIndexOf("}", open), masked.lastIndexOf(";", open));
62
+ selector = masked.slice(prev + 1, open).trim();
63
+ }
64
+ out.push({ prop, value, valueStart, valueEnd: valueStart + value.length, selector });
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /** 值里每个 token 是否都是可判定的 px(或数字)——否则整条放弃。 */
70
+ function parsePxTokens(value) {
71
+ if (SKIP_VALUE.test(value)) return null;
72
+ const tokens = value.split(/\s+/).filter((t) => t !== "");
73
+ const parsed = [];
74
+ for (const t of tokens) {
75
+ // 裸 `0` 是合法且最常见的写法(`margin: 0 auto` 反过来不行)——
76
+ // 不认它会让 `margin:-6px 0 2px 12px` 整条被跳过(实测踩过)。
77
+ const zero = /^0(?:\.0+)?$/.exec(t);
78
+ if (zero !== null) {
79
+ parsed.push({ raw: t, num: 0, bareZero: true });
80
+ continue;
81
+ }
82
+ const m = /^(-?\d*\.?\d+)px$/.exec(t);
83
+ if (m === null) return null;
84
+ parsed.push({ raw: t, num: Number(m[1]) });
85
+ }
86
+ return parsed;
87
+ }
88
+
89
+ const snapOne = (num, grid) => {
90
+ const abs = Math.abs(num);
91
+ if (abs <= 2) return num; // 描边/微调保留
92
+ const snapped = Math.max(grid, Math.round(abs / grid) * grid);
93
+ return num < 0 ? -snapped : snapped;
94
+ };
95
+
96
+ /**
97
+ * 间距对齐网格。
98
+ * @param {string} source
99
+ * @param {{grid?:number}} [opts]
100
+ * @returns {{output:string, changes:{prop:string,from:string,to:string}[], skipped:number}}
101
+ */
102
+ export function snapSpacing(source, opts = {}) {
103
+ const grid = Number.isFinite(opts.grid) && opts.grid > 0 ? Math.max(1, Math.round(opts.grid)) : 4;
104
+ const changes = [];
105
+ let skipped = 0;
106
+ let output = String(source);
107
+ const decls = scanDecls(output).filter((d) => SPACING_PROPS.has(d.prop));
108
+ // 从后往前改,避免偏移漂移
109
+ for (let i = decls.length - 1; i >= 0; i -= 1) {
110
+ const decl = decls[i];
111
+ const tokens = parsePxTokens(decl.value);
112
+ if (tokens === null) {
113
+ skipped += 1;
114
+ continue;
115
+ }
116
+ const next = tokens.map((t) => (t.bareZero === true ? t.raw : `${snapOne(t.num, grid)}px`)).join(" ");
117
+ if (next === decl.value) continue;
118
+ output = output.slice(0, decl.valueStart) + next + output.slice(decl.valueEnd);
119
+ changes.push({ prop: decl.prop, from: decl.value, to: next });
120
+ }
121
+ changes.reverse();
122
+ return { output, changes, skipped };
123
+ }
124
+
125
+ /**
126
+ * 字号收敛到给定阶梯(就近归并)。
127
+ * @param {string} source
128
+ * @param {{scale:number[]}} opts
129
+ * @returns {{output:string, changes:{from:string,to:string}[], skipped:number}}
130
+ */
131
+ export function mapFontSizes(source, opts = {}) {
132
+ const scale = (Array.isArray(opts.scale) ? opts.scale : []).map(Number).filter((n) => Number.isFinite(n) && n > 0).sort((a, b) => a - b);
133
+ if (scale.length < 2) throw new Error("scale 至少给两档(如 12,14,16,20,28)");
134
+ // 平局时**取大的那档**:13px 到 12/14 距离相等,往小缩会让文字变糊(中文尤其),
135
+ // 往大走最多损失一点密度。实测 Colibri:94 处里大半是 13px,平局取小会整体缩字号。
136
+ const nearest = (n) => scale.reduce((best, s) => (Math.abs(s - n) <= Math.abs(best - n) ? s : best), scale[0]);
137
+ const changes = [];
138
+ let skipped = 0;
139
+ let output = String(source);
140
+ const decls = scanDecls(output).filter((d) => d.prop === "font-size");
141
+ for (let i = decls.length - 1; i >= 0; i -= 1) {
142
+ const decl = decls[i];
143
+ if (SKIP_VALUE.test(decl.value)) {
144
+ skipped += 1;
145
+ continue;
146
+ }
147
+ const m = /^(\d*\.?\d+)px$/.exec(decl.value.trim());
148
+ if (m === null) {
149
+ skipped += 1;
150
+ continue;
151
+ }
152
+ const target = nearest(Number(m[1]));
153
+ if (`${target}px` === decl.value) continue;
154
+ output = output.slice(0, decl.valueStart) + `${target}px` + output.slice(decl.valueEnd);
155
+ changes.push({ prop: "font-size", from: decl.value, to: `${target}px` });
156
+ }
157
+ changes.reverse();
158
+ return { output, changes, skipped };
159
+ }
160
+
161
+ /** 标题级选择器:与 layout_check 的 S3 用同一套判据——**改与不改要同理**。 */
162
+ const HEADING_SELECTOR = /(?:^|[\s,>+~])(?:h[1-6]|\.[\w-]*(?:title|heading|headline|display)[\w-]*)(?:$|[\s,.:#[>+~])/i;
163
+
164
+ /** 正文行高阶梯:从 4 个真实项目的 78 处声明里选出来的 */
165
+ export const LEADING_LADDER = [1.5, 1.6, 1.8];
166
+
167
+ /**
168
+ * 行高归一(规则 B)。
169
+ *
170
+ * 为什么长这样——不是拍脑袋,是拿 4 个项目 78 处真实声明模拟三种规则后选的:
171
+ *
172
+ * | 规则 | 改动 | 归一后 S3 剩 |
173
+ * |---|---|---|
174
+ * | A 全部就近归并到 1.2/1.35/1.5/1.6/1.8 | 23% | **10 处(等于没改)** |
175
+ * | **B 标题不动;正文死区抬到 1.5;≥1.5 就近归并** | 23% | **2 处** |
176
+ * | C 同 B,但标题也归并到 1.1/1.2/1.3 | 36% | 2 处 |
177
+ *
178
+ * A 失败的原因很具体:**1.35–1.5 正是 S3 的告警区**,把值归到 1.35 等于把问题从
179
+ * “没那么紧”变成“依然紧”。C 多改 56% 的声明却零收益。所以选 B。
180
+ *
181
+ * 保留下来的东西:<1.2(大字号紧凑排版是刻意为之)、>2.2(松散排版同理)、
182
+ * px / var() / calc()(实际行高由字号与变量决定,不知道就不改)。
183
+ *
184
+ * @param {string} source
185
+ * @param {{ladder?:number[]}} [opts]
186
+ */
187
+ export function normalizeLeading(source, opts = {}) {
188
+ const ladder = (Array.isArray(opts.ladder) ? opts.ladder : LEADING_LADDER)
189
+ .map(Number)
190
+ .filter((n) => Number.isFinite(n) && n >= 1.2 && n <= 2.2)
191
+ .sort((a, b) => a - b);
192
+ if (ladder.length === 0) throw new Error("行高阶梯至少给一档(1.2–2.2 之间)");
193
+ // 比较要带容差:浮点下 |1.6-1.7| 与 |1.8-1.7| 差在 1e-16 量级,
194
+ // 严格 `<=` 会让 1.7 判到 1.6(本意是平局取大)——合成夹具当场拦下。
195
+ const nearest = (n) => ladder.reduce((best, s) => (Math.abs(s - n) <= Math.abs(best - n) + 1e-9 ? s : best), ladder[0]);
196
+
197
+ const changes = [];
198
+ let skipped = 0;
199
+ let output = String(source);
200
+ const decls = scanDecls(output).filter((d) => d.prop === "line-height");
201
+ for (let i = decls.length - 1; i >= 0; i -= 1) {
202
+ const decl = decls[i];
203
+ const value = decl.value.trim();
204
+ if (/^\d*\.?\d+$/.test(value) === false) {
205
+ skipped += 1; // px / var / calc / normal:实际行高由上下文决定
206
+ continue;
207
+ }
208
+ if (HEADING_SELECTOR.test(decl.selector ?? "")) {
209
+ skipped += 1; // 标题紧凑行高是正确做法,S3 也不判它
210
+ continue;
211
+ }
212
+ const current = Number(value);
213
+ if (current < 1.2 || current > 2.2) {
214
+ skipped += 1;
215
+ continue;
216
+ }
217
+ // 死区 [1.2, 1.5) 抬到阶梯最小档;其余就近归并(平局取大)
218
+ const target = current < 1.5 ? ladder[0] : nearest(current);
219
+ const next = String(target);
220
+ if (next === value) continue;
221
+ output = output.slice(0, decl.valueStart) + next + output.slice(decl.valueEnd);
222
+ changes.push({ prop: "line-height", from: value, to: next, selector: decl.selector ?? "" });
223
+ }
224
+ changes.reverse();
225
+ return { output, changes, skipped };
226
+ }
227
+
228
+ /** 圆角属性(四角单写也要管,否则只改 border-radius 会漏一半)。 */
229
+ export const RADIUS_PROPS = new Set([
230
+ "border-radius",
231
+ "border-top-left-radius",
232
+ "border-top-right-radius",
233
+ "border-bottom-left-radius",
234
+ "border-bottom-right-radius"
235
+ ]);
236
+
237
+ /** 圆角阶梯:4 档,对齐 4px 网格(2px 给描边微调)。 */
238
+ export const RADIUS_LADDER = [2, 4, 8, 12];
239
+
240
+ /**
241
+ * 圆角盘点:**先判断“该不该改”,再谈怎么改**。
242
+ *
243
+ * 四个真实项目的数据(2026-11-08):
244
+ *
245
+ * | 项目 | 可改 px | 已是 var() | 保留(%/0/999) |
246
+ * |---|---|---|---|
247
+ * | Colibri | 9 | **119** | 16 |
248
+ * | Echo | 0 | **34** | 0 |
249
+ * | Flow-Pilot | 1 | **7** | 0 |
250
+ * | 旧版 0.5.4 | **124** | 0 | 47 |
251
+ *
252
+ * 结论:**现代项目已经令牌化了,改散落 px 等于白忙**;只有手写时代的项目才真需要收敛。
253
+ * 所以 tokenized 比例是“该不该做这件事”的门槛,而不是一个可选指标。
254
+ */
255
+ export function inspectRadius(source) {
256
+ const decls = scanDecls(source).filter((d) => RADIUS_PROPS.has(d.prop));
257
+ const counts = { px: [], tokenized: 0, preserved: 0, other: 0 };
258
+ for (const d of decls) {
259
+ const v = d.value.trim();
260
+ if (/var\(|calc\(/i.test(v)) {
261
+ counts.tokenized += 1;
262
+ continue;
263
+ }
264
+ // 保留:百分比(圆)、0、≥100px(胶囊)
265
+ if (/%/.test(v) || /^0(?:\.0+)?$/.test(v) || /^(?:\d*\.?\d+)px$/.test(v) === false) {
266
+ if (/%/.test(v) || /^0(?:\.0+)?$/.test(v)) counts.preserved += 1;
267
+ else counts.other += 1;
268
+ continue;
269
+ }
270
+ const n = Number.parseFloat(v);
271
+ if (n >= 100) {
272
+ counts.preserved += 1;
273
+ continue;
274
+ }
275
+ counts.px.push(n);
276
+ }
277
+ const total = decls.length;
278
+ return {
279
+ total,
280
+ px: counts.px,
281
+ tokenized: counts.tokenized,
282
+ preserved: counts.preserved,
283
+ other: counts.other,
284
+ // 令牌化率:≥0.5 就该去改 token 值,而不是改散落 px
285
+ tokenizedRatio: total === 0 ? 0 : counts.tokenized / total
286
+ };
287
+ }
288
+
289
+ /**
290
+ * 圆角收敛(只在非令牌化项目上才有意义)。
291
+ *
292
+ * **门槛是故意的**:tokenizedRatio ≥ 0.5 时直接抛错。
293
+ * 理由:把这些项目的 9/0/1 个散落 px 改掉,圆角外观几乎不会变(大头在 var 里),
294
+ * 用户以为“统一了”其实没动——这种“看起来成功、实际无效”的改写比不改更坏。
295
+ * 正确的做法是改 `:root` 里的 `--radius-*` 值。
296
+ *
297
+ * @param {string} source
298
+ * @param {{ladder?:number[]}} [opts]
299
+ */
300
+ export function normalizeRadius(source, opts = {}) {
301
+ const info = inspectRadius(source);
302
+ if (info.tokenizedRatio >= 0.5) {
303
+ throw new Error(
304
+ `圆角已令牌化 ${Math.round(info.tokenizedRatio * 100)}%(${info.tokenized}/${info.total} 处用 var()):` +
305
+ `逐条改散落 px 不会改变外观。应该改 :root 里的 --radius-* 值,或用 layout_check ui 的 radius-blob 看整体是否偏大。`
306
+ );
307
+ }
308
+ const ladder = (Array.isArray(opts.ladder) ? opts.ladder : RADIUS_LADDER)
309
+ .map(Number)
310
+ .filter((n) => Number.isFinite(n) && n > 0)
311
+ .sort((a, b) => a - b);
312
+ if (ladder.length === 0) throw new Error("圆角阶梯至少给一档(如 2,4,8,12)");
313
+ const nearest = (n) => ladder.reduce((best, s) => (Math.abs(s - n) <= Math.abs(best - n) + 1e-9 ? s : best), ladder[0]);
314
+
315
+ const changes = [];
316
+ let skipped = 0;
317
+ let output = String(source);
318
+ const decls = scanDecls(output).filter((d) => RADIUS_PROPS.has(d.prop));
319
+ for (let i = decls.length - 1; i >= 0; i -= 1) {
320
+ const decl = decls[i];
321
+ const v = decl.value.trim();
322
+ if (/var\(|calc\(/i.test(v) || /%/.test(v) || /^0(?:\.0+)?$/.test(v)) {
323
+ skipped += 1; // 令牌 / 圆 / 无圆角,都不碰
324
+ continue;
325
+ }
326
+ // 只处理纯 px 单值;多值简写与单角属性不动(单角常是刻意的不对称,简写拆开会丢语义)
327
+ const m = /^(\d*\.?\d+)px$/.exec(v);
328
+ if (m === null || decl.prop !== "border-radius") {
329
+ skipped += 1;
330
+ continue;
331
+ }
332
+ const current = Number(m[1]);
333
+ if (current >= 100) {
334
+ skipped += 1; // 胶囊
335
+ continue;
336
+ }
337
+ const target = nearest(current);
338
+ const next = `${target}px`;
339
+ if (next === v) continue;
340
+ output = output.slice(0, decl.valueStart) + next + output.slice(decl.valueEnd);
341
+ changes.push({ prop: "border-radius", from: v, to: next, selector: decl.selector ?? "" });
342
+ }
343
+ changes.reverse();
344
+ return { output, changes, skipped };
345
+ }
346
+
347
+ /** 只读盘点:间距值分布 + 字号档位(scan 用)。 */
348
+ export function inspectCss(source, opts = {}) {
349
+ const grid = Number.isFinite(opts.grid) && opts.grid > 0 ? Math.round(opts.grid) : 4;
350
+ const decls = scanDecls(source);
351
+ const spacing = new Map();
352
+ const sizes = new Map();
353
+ for (const d of decls) {
354
+ if (SPACING_PROPS.has(d.prop)) {
355
+ const tokens = parsePxTokens(d.value);
356
+ if (tokens === null) continue;
357
+ for (const t of tokens) {
358
+ const key = Math.abs(t.num);
359
+ if (key <= 2) continue;
360
+ const entry = spacing.get(key) ?? { px: key, count: 0, onGrid: key % grid === 0 };
361
+ entry.count += 1;
362
+ spacing.set(key, entry);
363
+ }
364
+ } else if (d.prop === "font-size") {
365
+ const m = /^(\d*\.?\d+)px$/.exec(d.value.trim());
366
+ if (m === null) continue;
367
+ sizes.set(Number(m[1]), (sizes.get(Number(m[1])) ?? 0) + 1);
368
+ }
369
+ }
370
+ const offGrid = [...spacing.values()].filter((v) => v.onGrid === false).sort((a, b) => b.count - a.count);
371
+ const total = [...spacing.values()].reduce((sum, v) => sum + v.count, 0);
372
+ return {
373
+ grid,
374
+ spacingTotal: total,
375
+ offGridCount: offGrid.reduce((sum, v) => sum + v.count, 0),
376
+ offGrid: offGrid.slice(0, 20),
377
+ fontSizes: [...sizes.entries()].sort((a, b) => a[0] - b[0]).map(([px, count]) => ({ px, count }))
378
+ };
379
+ }