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,1204 @@
1
+ /**
2
+ * layouts/svg.js — layout_check action=svg:成品 SVG 的「真实文字几何」体检。
3
+ *
4
+ * 为什么不需要 OCR:SVG 的 <text> 自带 x/y/font-size/text-anchor,几何是**声明出来的**,
5
+ * 直接量比截图检测更准。代价是宽度只能靠字符宽度模型估算(不解析字体度量表):
6
+ * - 中文 / 全角字符:1.0 × fontSize
7
+ * - 拉丁字母 / 数字 / 空格:0.55 × fontSize
8
+ * 多行 <text> 用 <tspan dy> 逐行累加基线。所有长度都在「用户单位」里算,
9
+ * 只有需要「渲染后像素」的规则(G8 最小字号)才乘 scale:
10
+ * scale = width(px) / viewBoxWidth(没有 width 时 scale=1;没有 viewBox 时画布就是 width/height)。
11
+ * 几何坐标会减掉 viewBox 的 min-x / min-y,所以边距百分比以**可见区域**为准。
12
+ *
13
+ * 规则 id G1–G9 与阈值是产品契约(见 README「排版检测」),改动等同于改契约;
14
+ * G0-* 是解析层的信息项(默认字号 / transform 内文字 / 无文字),不参与质量判定。
15
+ *
16
+ * 已知误差(写在这里,避免后来者把误报当 bug 修):
17
+ * 1. 字体加粗、字距(letter-spacing)、字宽不同的字体族都不在宽度模型里 → 估算宽度可能偏小;
18
+ * 2. `font-size` 的 em/rem 按父级 16 近似;`font-size` / x / y / dy 的百分比无法换算,按缺省处理;
19
+ * 3. `<g transform>` 内的文字坐标系不可靠,一律计入 `unmeasured` 不参与几何判定(README 已声明);
20
+ * 4. 面积 ≥90% 画布的图形按「背景板」排除(不参与内容 bbox / G9),见 metrics.backgroundPlates;
21
+ * 比 90% 略小的大色块仍会算内容,可能把 G9 推到「拥挤」;
22
+ * 5. G6 按「行」比对且跳过同一文本块(同 x/字号/连续基线)内部的行:块内行贴行归 G3;
23
+ * 不同块的文字若只是外接框相擦,仍可能判重叠(保守估计);
24
+ * 6. G3 的「同块」只看字号 + 锚点 x(契约口径),不同段落恰好在同一 x 且间距 ≤6× 字号时可能被连起来看。
25
+ */
26
+ import { finalize, finding } from "./common.js";
27
+ import { countWideChars, measureTextPx } from "./metrics-text.js";
28
+ import { auditContrast, parseColor } from "./rules-contrast.js";
29
+ import { auditBaselineGrid } from "./rules-structure.js";
30
+ import { applyAllow, readIntent } from "./intent.js";
31
+
32
+ /* ============================== 阈值 / 常量 ============================== */
33
+
34
+ /** 浏览器默认字号:<text> 没写 font-size 时按它估算(并加 info 提示)。 */
35
+ const DEFAULT_FONT_SIZE = 16;
36
+ /** 估算文字 bbox 时,基线以上占字号的比例(近似 ascender),以下留 0.2 给 descender。 */
37
+ const ASCENT = 0.8;
38
+
39
+ /** G1 安全区:贴边 <3% 是 error、<5% 是 warn。 */
40
+ const SAFE_ERROR_PCT = 3;
41
+ const SAFE_WARN_PCT = 5;
42
+
43
+ /** G2 字号层级:唯一字号 <2 → error;最大/正文 <1.4 error、1.4–2 warn;档位 >6 warn。
44
+ * 文字块 ≤2(单行 banner / 徽标)时层级不成立,上述 error/warn 统一降为 info。 */
45
+ const HIERARCHY_ERROR_RATIO = 1.4;
46
+ const HIERARCHY_WARN_RATIO = 2;
47
+ const MAX_FONT_STEPS = 6;
48
+
49
+ /** G3 行距:间距/字号 <1.15 error;1.15–1.3 warn;>2.4 warn。 */
50
+ const LEADING_TIGHT_ERROR = 1.15;
51
+ const LEADING_TIGHT_WARN = 1.3;
52
+ const LEADING_LOOSE_WARN = 2.4;
53
+ /** 同一「块」的最大相邻间距(×字号):超过视为不同段落,防止页脚/页码被当成行距过大。 */
54
+ const LEADING_BLOCK_MAX_RATIO = 6;
55
+
56
+ /** G4 行长:估算宽度/画布宽 >0.85,或单行 CJK >45 字。 */
57
+ const LINE_WIDTH_WARN_RATIO = 0.85;
58
+ const CJK_PER_LINE_WARN = 45;
59
+
60
+ /** G6 文字重叠:交叠面积 / 较小面积 >0.15(只比不同文本块;块内行贴行归 G3)。 */
61
+ const OVERLAP_ERROR_RATIO = 0.15;
62
+
63
+ /** G7 对齐:容差 2px(渲染后像素),至少 4 行才判定。 */
64
+ const ALIGN_TOLERANCE_PX = 2;
65
+ const ALIGN_MIN_LINES = 4;
66
+
67
+ /** G8 最小字号(渲染后像素):<8 error;<10 warn。 */
68
+ const FONT_PX_ERROR = 8;
69
+ const FONT_PX_WARN = 10;
70
+
71
+ /** G9 留白:内容包围盒/画布 >88% 拥挤;<5% 可能空版。 */
72
+ const COVERAGE_CROWDED_PCT = 88;
73
+ const COVERAGE_EMPTY_PCT = 5;
74
+
75
+ /**
76
+ * 背景板:图形包围盒面积 ≥90% 画布、且本身只是色块/渐变底(纯 shape,内部不承载文字)。
77
+ * 这类满版底色不参与安全区、内容 bbox 与 G9——否则真实海报一铺底色就必吃 G1/G9 误报。
78
+ */
79
+ const BACKGROUND_PLATE_AREA_RATIO = 0.9;
80
+
81
+ /** 单条规则最多报几条:坏稿容易刷屏,多出来的同规则条目对模型没有新增信息。 */
82
+ const MAX_PER_RULE = 3;
83
+
84
+ /* ============================== 通用小工具 ============================== */
85
+
86
+ /** 两位小数并消除 -0(宿主会判 JSON 无损往返失败)。 */
87
+ function round2(value) {
88
+ const rounded = Math.round(value * 100) / 100;
89
+ return rounded === 0 ? 0 : rounded;
90
+ }
91
+
92
+ function clampText(value, max = 12) {
93
+ const text = String(value ?? "");
94
+ return text.length > max ? `${text.slice(0, max)}…` : text;
95
+ }
96
+
97
+ /** 元素可读标签,用于 where 字段(模型据此定位)。 */
98
+ function labelOf(index, sample) {
99
+ const tail = typeof sample === "string" && sample !== "" ? `「${clampText(sample)}」` : "";
100
+ return `文字#${index + 1}${tail}`;
101
+ }
102
+
103
+ /* ------------------------------ 字符宽度模型 ------------------------------ */
104
+
105
+ /**
106
+ * 估算一段文字的宽度(用户单位)。
107
+ * 走 metrics-text.js 的字符宽度表(ASCII 实测 advance + 全角 1em):
108
+ * 实测比旧的扁平 0.55 模型把纯英文误差从 10.0% 降到 0.4%,中英混排从 4.4% 降到 0.6%。
109
+ */
110
+ function estimateWidth(text, fontSize) {
111
+ return measureTextPx(text, fontSize);
112
+ }
113
+
114
+ /* ------------------------------ 解析:属性 / 长度 ------------------------------ */
115
+
116
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
117
+
118
+ function decodeEntities(input) {
119
+ return String(input).replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (whole, body) => {
120
+ if (body[0] === "#") {
121
+ const hex = /^#x/i.test(body);
122
+ const code = hex ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
123
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
124
+ }
125
+ const named = ENTITIES[body.toLowerCase()];
126
+ return named === undefined ? whole : named;
127
+ });
128
+ }
129
+
130
+ /** 解析标签属性串(属性名统一小写;值里的实体已解码)。 */
131
+ function parseAttrs(attrText) {
132
+ const attrs = Object.create(null);
133
+ if (typeof attrText !== "string" || attrText === "") return attrs;
134
+ const attrRe = /([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
135
+ let match;
136
+ while ((match = attrRe.exec(attrText)) !== null) {
137
+ const name = match[1].toLowerCase();
138
+ const raw = match[2] ?? match[3] ?? match[4] ?? "";
139
+ attrs[name] = decodeEntities(raw);
140
+ }
141
+ return attrs;
142
+ }
143
+
144
+ /** 属性或 style="..." 里的声明(SVG 两种写法都常见)。 */
145
+ function attrOrStyle(attrs, name) {
146
+ const direct = attrs[name];
147
+ if (typeof direct === "string" && direct.trim() !== "") return direct.trim();
148
+ const style = attrs.style;
149
+ if (typeof style === "string" && style !== "") {
150
+ for (const part of style.split(";")) {
151
+ const index = part.indexOf(":");
152
+ if (index === -1) continue;
153
+ if (part.slice(0, index).trim().toLowerCase() !== name) continue;
154
+ const value = part.slice(index + 1).trim();
155
+ if (value !== "") return value;
156
+ }
157
+ }
158
+ return undefined;
159
+ }
160
+
161
+ /**
162
+ * 坐标/长度解析:数字 + 单位 → 用户单位;百分比无法换算(返回 null)。
163
+ * em/rem 按 16 近似(不解析字体继承链),documented limitation。
164
+ */
165
+ function parseCoord(raw) {
166
+ if (raw === undefined || raw === null) return null;
167
+ const text = String(raw).trim();
168
+ if (text === "") return null;
169
+ const match = text.match(/^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)$/i);
170
+ if (match === null) return null;
171
+ const value = Number.parseFloat(match[1]);
172
+ if (!Number.isFinite(value)) return null;
173
+ const unit = match[2].toLowerCase();
174
+ if (unit === "%") return null;
175
+ if (unit === "pt") return (value * 4) / 3;
176
+ if (unit === "em" || unit === "rem") return value * 16;
177
+ if (unit === "pc") return value * 16;
178
+ if (unit === "mm") return (value * 96) / 25.4;
179
+ if (unit === "cm") return (value * 96) / 2.54;
180
+ if (unit === "in") return value * 96;
181
+ return value;
182
+ }
183
+
184
+ /** font-size:无法解析/非正数 → null(调用方回退默认 16 并记 info)。 */
185
+ function parseFontSize(raw) {
186
+ const value = parseCoord(raw);
187
+ return value !== null && value > 0 ? value : null;
188
+ }
189
+
190
+ function normalizeAnchor(raw) {
191
+ const value = String(raw ?? "").trim().toLowerCase();
192
+ if (value === "middle" || value === "center") return "middle";
193
+ if (value === "end") return "end";
194
+ return "start";
195
+ }
196
+
197
+ function parseViewBox(raw) {
198
+ if (typeof raw !== "string") return null;
199
+ const numbers = raw.trim().split(/[\s,]+/).map((part) => Number.parseFloat(part));
200
+ if (numbers.length !== 4 || numbers.some((n) => !Number.isFinite(n))) return null;
201
+ const [x, y, w, h] = numbers;
202
+ return w > 0 && h > 0 ? { x, y, w, h } : null;
203
+ }
204
+
205
+ /** 文本内容清洗:去标签、解实体、合并空白。 */
206
+ function cleanText(raw) {
207
+ return decodeEntities(String(raw))
208
+ .replace(/<[^>]*>/g, "")
209
+ .replace(/\s+/g, " ")
210
+ .trim();
211
+ }
212
+
213
+ /* ------------------------------ 解析:画布 ------------------------------ */
214
+
215
+ /**
216
+ * 画布 = viewBox 宽高(用户单位);无 viewBox 时 = width/height(像素)。
217
+ * 两者都缺 → 抛「SVG 缺画布尺寸」(工具层错误消息要可行动)。
218
+ */
219
+ function parseCanvas(markup) {
220
+ // 属性值里可能有 ">"(如 data-src="a>b"),所以不能简单用 [^>]* 截断根标签
221
+ const open = /<svg\b((?:"[^"]*"|'[^']*'|[^>"'])*)>/i.exec(markup);
222
+ if (open === null) throw new Error("不是 SVG 标记:缺少 <svg> 根元素");
223
+ const attrs = parseAttrs(open[1]);
224
+ const viewBox = parseViewBox(attrs.viewbox);
225
+ const widthPx = parseCoord(attrs.width);
226
+ const heightPx = parseCoord(attrs.height);
227
+ if (viewBox !== null) {
228
+ const scale = widthPx !== null && widthPx > 0 ? widthPx / viewBox.w : 1;
229
+ return { w: viewBox.w, h: viewBox.h, minX: viewBox.x, minY: viewBox.y, scale };
230
+ }
231
+ if (widthPx !== null && heightPx !== null && widthPx > 0 && heightPx > 0) {
232
+ return { w: widthPx, h: heightPx, minX: 0, minY: 0, scale: 1 };
233
+ }
234
+ throw new Error("SVG 缺画布尺寸:<svg> 既没有可解析的 width/height,也没有 viewBox");
235
+ }
236
+
237
+ /* ------------------------------ 解析:文字 ------------------------------ */
238
+
239
+ /** 一行文字的 bbox:宽度按字符模型估算,高度取该行最大字号,anchor 决定水平锚点。 */
240
+ function lineBox(line) {
241
+ const fontSize = line.segments.reduce((max, segment) => Math.max(max, segment.fs), 0);
242
+ const width = line.segments.reduce((sum, segment) => sum + estimateWidth(segment.text, segment.fs), 0);
243
+ const left = line.anchor === "middle" ? line.x - width / 2 : line.anchor === "end" ? line.x - width : line.x;
244
+ return { x: left, y: line.y - ASCENT * fontSize, w: width, h: fontSize };
245
+ }
246
+
247
+ function unionBoxes(boxes) {
248
+ const list = boxes.filter((box) => box !== null && box !== undefined);
249
+ if (list.length === 0) return null;
250
+ let minX = Infinity;
251
+ let minY = Infinity;
252
+ let maxX = -Infinity;
253
+ let maxY = -Infinity;
254
+ for (const box of list) {
255
+ minX = Math.min(minX, box.x);
256
+ minY = Math.min(minY, box.y);
257
+ maxX = Math.max(maxX, box.x + box.w);
258
+ maxY = Math.max(maxY, box.y + box.h);
259
+ }
260
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
261
+ }
262
+
263
+ /**
264
+ * 解析一个 <text> 元素(含 <tspan> 多行)。
265
+ * 行判定:tspan 带 dy / y → 新行(dy 逐行累加);带 x 且当前行已有内容 → 也当新行(另起锚点);
266
+ * 否则是同一行的样式片段(例如 <tspan fill="red">)。返回 null 表示没有可测文字。
267
+ */
268
+ function parseTextElement(attrs, inner) {
269
+ const elementFsRaw = attrOrStyle(attrs, "font-size");
270
+ const elementFs = parseFontSize(elementFsRaw);
271
+ const baseFs = elementFs ?? DEFAULT_FONT_SIZE;
272
+ const baseX = parseCoord(attrs.x) ?? 0;
273
+ const baseY = parseCoord(attrs.y) ?? 0;
274
+ const elementAnchorRaw = attrOrStyle(attrs, "text-anchor");
275
+ const elementAnchor = normalizeAnchor(elementAnchorRaw);
276
+ const elementAnchorExplicit = elementAnchorRaw !== undefined;
277
+ const weight = attrOrStyle(attrs, "font-weight") ?? "normal";
278
+ const fill = attrOrStyle(attrs, "fill") ?? "#000000";
279
+
280
+ let usedDefault = false;
281
+ /** @type {Array<{y:number,x:number,anchor:string,segments:Array<{text:string,fs:number}>}>} */
282
+ const lines = [];
283
+ let current = { y: baseY, x: baseX, anchor: elementAnchor, anchorExplicit: elementAnchorExplicit, segments: [] };
284
+ let currentY = baseY;
285
+ let currentX = baseX;
286
+ let currentFs = baseFs;
287
+
288
+ const flush = () => {
289
+ if (current.segments.length === 0) return;
290
+ lines.push({
291
+ y: current.y,
292
+ x: current.x,
293
+ anchor: current.anchor,
294
+ anchorExplicit: current.anchorExplicit,
295
+ segments: current.segments.map((segment) => ({ ...segment }))
296
+ });
297
+ current = { y: current.y, x: current.x, anchor: current.anchor, anchorExplicit: current.anchorExplicit, segments: [] };
298
+ };
299
+ const pushSegment = (text, fontSize) => {
300
+ if (text !== "") current.segments.push({ text, fs: fontSize });
301
+ };
302
+
303
+ const tspanRe = /<tspan\b((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>([\s\S]*?)<\/tspan>/gi;
304
+ let cursor = 0;
305
+ let match;
306
+ while ((match = tspanRe.exec(inner)) !== null) {
307
+ const plain = cleanText(inner.slice(cursor, match.index));
308
+ if (elementFsRaw === undefined && plain !== "") usedDefault = true;
309
+ pushSegment(plain, currentFs);
310
+ cursor = match.index + match[0].length;
311
+ const tspanAttrs = parseAttrs(match[1]);
312
+ const tspanFsRaw = attrOrStyle(tspanAttrs, "font-size");
313
+ const tspanFs = parseFontSize(tspanFsRaw);
314
+ const tspanAnchorRaw = attrOrStyle(tspanAttrs, "text-anchor");
315
+ const dy = parseCoord(tspanAttrs.dy);
316
+ const ty = parseCoord(tspanAttrs.y);
317
+ const tx = parseCoord(tspanAttrs.x);
318
+ const nextFs = tspanFs ?? currentFs;
319
+ if (elementFsRaw === undefined && tspanFsRaw === undefined) usedDefault = true;
320
+ const startsNewLine = dy !== null || ty !== null || (tx !== null && current.segments.length > 0);
321
+ if (startsNewLine) {
322
+ flush();
323
+ const nextY = ty !== null ? ty : currentY + (dy ?? 0);
324
+ const nextX = tx !== null ? tx : currentX;
325
+ current = {
326
+ y: nextY,
327
+ x: nextX,
328
+ anchor: tspanAnchorRaw === undefined ? current.anchor : normalizeAnchor(tspanAnchorRaw),
329
+ anchorExplicit: tspanAnchorRaw === undefined ? current.anchorExplicit : true,
330
+ segments: []
331
+ };
332
+ currentY = nextY;
333
+ currentX = nextX;
334
+ } else {
335
+ if (tx !== null) {
336
+ current.x = tx;
337
+ currentX = tx;
338
+ }
339
+ if (tspanAnchorRaw !== undefined) {
340
+ current.anchor = normalizeAnchor(tspanAnchorRaw);
341
+ current.anchorExplicit = true;
342
+ }
343
+ }
344
+ currentFs = nextFs;
345
+ pushSegment(cleanText(match[3]), currentFs);
346
+ }
347
+ const trailing = cleanText(inner.slice(cursor));
348
+ if (elementFsRaw === undefined && trailing !== "") usedDefault = true;
349
+ pushSegment(trailing, currentFs);
350
+ flush();
351
+ if (lines.length === 0) return null;
352
+
353
+ for (const line of lines) {
354
+ line.fs = line.segments.reduce((max, segment) => Math.max(max, segment.fs), 0);
355
+ line.bbox = lineBox(line);
356
+ }
357
+ const sample = lines
358
+ .map((line) => line.segments.map((segment) => segment.text).join(""))
359
+ .join(" ")
360
+ .trim();
361
+ return { lines, bbox: unionBoxes(lines.map((line) => line.bbox)), anchor: elementAnchor, weight, fill, usedDefault, sample };
362
+ }
363
+
364
+ /* ------------------------------ 解析:图形 ------------------------------ */
365
+
366
+ /** 只取矩形/椭圆/线与多边形的几何,用来算「内容包围盒」(G9)。 */
367
+ function shapeBox(tag, attrs) {
368
+ const coord = (name) => parseCoord(attrs[name]);
369
+ switch (tag) {
370
+ case "rect": {
371
+ const x = coord("x") ?? 0;
372
+ const y = coord("y") ?? 0;
373
+ const w = coord("width");
374
+ const h = coord("height");
375
+ return w !== null && h !== null && w > 0 && h > 0 ? { x, y, w, h } : null;
376
+ }
377
+ case "circle": {
378
+ const cx = coord("cx") ?? 0;
379
+ const cy = coord("cy") ?? 0;
380
+ const r = coord("r");
381
+ return r !== null && r > 0 ? { x: cx - r, y: cy - r, w: 2 * r, h: 2 * r } : null;
382
+ }
383
+ case "ellipse": {
384
+ const cx = coord("cx") ?? 0;
385
+ const cy = coord("cy") ?? 0;
386
+ const rx = coord("rx");
387
+ const ry = coord("ry");
388
+ return rx !== null && ry !== null && rx > 0 && ry > 0 ? { x: cx - rx, y: cy - ry, w: 2 * rx, h: 2 * ry } : null;
389
+ }
390
+ case "line": {
391
+ const x1 = coord("x1") ?? 0;
392
+ const y1 = coord("y1") ?? 0;
393
+ const x2 = coord("x2") ?? 0;
394
+ const y2 = coord("y2") ?? 0;
395
+ const w = Math.abs(x2 - x1);
396
+ const h = Math.abs(y2 - y1);
397
+ return w === 0 && h === 0 ? null : { x: Math.min(x1, x2), y: Math.min(y1, y2), w, h };
398
+ }
399
+ case "polygon":
400
+ case "polyline": {
401
+ const points = String(attrs.points ?? "")
402
+ .trim()
403
+ .split(/[\s,]+/)
404
+ .map((part) => Number.parseFloat(part))
405
+ .filter((n) => Number.isFinite(n));
406
+ if (points.length < 4) return null;
407
+ let minX = Infinity;
408
+ let minY = Infinity;
409
+ let maxX = -Infinity;
410
+ let maxY = -Infinity;
411
+ for (let i = 0; i + 1 < points.length; i += 2) {
412
+ minX = Math.min(minX, points[i]);
413
+ minY = Math.min(minY, points[i + 1]);
414
+ maxX = Math.max(maxX, points[i]);
415
+ maxY = Math.max(maxY, points[i + 1]);
416
+ }
417
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
418
+ }
419
+ default:
420
+ return null;
421
+ }
422
+ }
423
+
424
+ /* ------------------------------ 解析:一遍扫描 ------------------------------ */
425
+
426
+ const SHAPE_TAGS = new Set(["rect", "circle", "ellipse", "line", "polygon", "polyline"]);
427
+ /** 不渲染的容器(内容不应参与几何判定;<defs> 里的图形不是版面内容)。 */
428
+ const NON_RENDERED_TAGS = new Set([
429
+ "defs",
430
+ "symbol",
431
+ "clippath",
432
+ "mask",
433
+ "marker",
434
+ "pattern",
435
+ "lineargradient",
436
+ "radialgradient",
437
+ "filter",
438
+ "style",
439
+ "title",
440
+ "desc",
441
+ "metadata"
442
+ ]);
443
+
444
+ function hasTransform(attrs) {
445
+ const raw = attrs.transform;
446
+ if (typeof raw === "string" && raw.trim() !== "") return true;
447
+ const style = attrs.style;
448
+ return typeof style === "string" && /(?:^|;)\s*transform\s*:/i.test(style);
449
+ }
450
+
451
+ /**
452
+ * 一遍扫描标签:收集可测文字、图形包围盒、以及 transform 内无法测量的文字数。
453
+ * 只维护一个标签栈负责「是否在 transform / 不渲染容器内」两个上下文。
454
+ */
455
+ function scanElements(markup) {
456
+ const tagRe = /<(\/?)([A-Za-z][\w:.-]*)((?:\s+[^\s=/>]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/g;
457
+ const texts = [];
458
+ const graphics = [];
459
+ const stack = [];
460
+ let unmeasured = 0;
461
+ let rawTextCount = 0;
462
+ let match;
463
+ while ((match = tagRe.exec(markup)) !== null) {
464
+ const closing = match[1] === "/";
465
+ const tag = match[2].toLowerCase().split(":").pop();
466
+ const attrText = match[3] ?? "";
467
+ const selfClose = match[4] === "/" || /\/\s*$/.test(attrText);
468
+ if (closing) {
469
+ for (let i = stack.length - 1; i >= 0; i -= 1) {
470
+ if (stack[i].tag === tag) {
471
+ stack.length = i;
472
+ break;
473
+ }
474
+ }
475
+ continue;
476
+ }
477
+ const attrs = parseAttrs(attrText);
478
+ const parent = stack.length > 0 ? stack[stack.length - 1] : { transformed: false, hidden: false };
479
+ const transformed = parent.transformed || hasTransform(attrs);
480
+ const hidden = parent.hidden || NON_RENDERED_TAGS.has(tag);
481
+ if (tag === "text") {
482
+ rawTextCount += 1;
483
+ const start = tagRe.lastIndex;
484
+ const close = /<\/text\s*>/i.exec(markup.slice(start));
485
+ const innerEnd = close === null ? markup.length : start + close.index;
486
+ const inner = markup.slice(start, innerEnd);
487
+ if (close !== null) tagRe.lastIndex = innerEnd + close[0].length;
488
+ if (selfClose || hidden) continue;
489
+ if (transformed) {
490
+ unmeasured += 1;
491
+ continue;
492
+ }
493
+ const element = parseTextElement(attrs, inner);
494
+ if (element !== null) texts.push(element);
495
+ continue;
496
+ }
497
+ if (SHAPE_TAGS.has(tag)) {
498
+ // 自闭合只说明没有子元素,图形本身照样是版面内容(<rect .../> 是最常见写法)
499
+ if (!transformed && !hidden) {
500
+ const box = shapeBox(tag, attrs);
501
+ // 保留填充色:对比度检测要知道「文字叠在什么颜色上」
502
+ if (box !== null) graphics.push({ ...box, fill: attrOrStyle(attrs, "fill") });
503
+ }
504
+ continue;
505
+ }
506
+ if (selfClose) continue;
507
+ stack.push({ tag, transformed, hidden });
508
+ }
509
+ return { texts, graphics, unmeasured, rawTextCount };
510
+ }
511
+
512
+ /* ============================== 规则实现 ============================== */
513
+
514
+ /** 把行按「同字号 + 同锚点 x(容差 2px 渲染后像素)」聚成「块」,供 G3 判行距。 */
515
+ function groupLinesByBlock(lines, scale) {
516
+ const tolerance = ALIGN_TOLERANCE_PX / scale;
517
+ const groups = [];
518
+ for (const line of lines) {
519
+ const group = groups.find(
520
+ (item) => Math.abs(item.fs - line.fs) <= 0.5 && Math.abs(item.x - line.x) <= tolerance
521
+ );
522
+ if (group === undefined) groups.push({ fs: line.fs, x: line.x, lines: [line] });
523
+ else group.lines.push(line);
524
+ }
525
+ return groups;
526
+ }
527
+
528
+ /**
529
+ * 把行切成「文本块」:同字号 + 同锚点 x 的一组行里,基线递增的相邻行属于同一块;
530
+ * 同一基线(dy=0)起新块。G6 用它排除块内行与行的重叠(那属于行距问题,归 G3)。
531
+ * @returns {Map<object, number>} 行对象 → 块 id
532
+ */
533
+ function assignTextBlocks(lines, scale) {
534
+ const blockIds = new Map();
535
+ let nextBlock = 0;
536
+ for (const group of groupLinesByBlock(lines, scale)) {
537
+ const sorted = [...group.lines].sort((a, b) => a.y - b.y);
538
+ let previousY = null;
539
+ for (const line of sorted) {
540
+ if (previousY === null || line.y <= previousY) nextBlock += 1;
541
+ blockIds.set(line, nextBlock);
542
+ previousY = line.y;
543
+ }
544
+ }
545
+ return blockIds;
546
+ }
547
+
548
+ /** 一维值聚类:返回最大簇的行数(贪心相邻差 ≤tol,近似“行相近即同类”)。 */
549
+ function maxClusterSize(values, tolerance) {
550
+ if (values.length === 0) return 0;
551
+ const sorted = [...values].sort((a, b) => a - b);
552
+ let best = 1;
553
+ let run = 1;
554
+ for (let i = 1; i < sorted.length; i += 1) {
555
+ run = sorted[i] - sorted[i - 1] <= tolerance ? run + 1 : 1;
556
+ if (run > best) best = run;
557
+ }
558
+ return best;
559
+ }
560
+
561
+ function intersectionArea(a, b) {
562
+ const left = Math.max(a.x, b.x);
563
+ const top = Math.max(a.y, b.y);
564
+ const right = Math.min(a.x + a.w, b.x + b.w);
565
+ const bottom = Math.min(a.y + a.h, b.y + b.h);
566
+ if (right <= left || bottom <= top) return 0;
567
+ return (right - left) * (bottom - top);
568
+ }
569
+
570
+ /* ============================== 主入口 ============================== */
571
+
572
+ /**
573
+ * auditSvg — 对完整 SVG 标记做文字几何体检(纯函数:不读文件、不联网、确定性输出)。
574
+ * @param {string} markup 完整 SVG 标记(工具层已读文件或内联字符串)
575
+ * @param {{name?: string, intentDir?: string}} [options]
576
+ * name 只用于报告里的 target;intentDir 给定时从该目录读 .design-intent.json
577
+ * (文件模式的 .svg 用;内联标记没有目录,故不传)
578
+ * @returns 统一报告(common.js finalize:mode/score/grade/counts/metrics/findings)
579
+ */
580
+ export function auditSvg(markup, { name = "svg", intentDir } = {}) {
581
+ const target = typeof name === "string" && name.trim() !== "" ? name : "svg";
582
+ if (typeof markup !== "string" || markup.trim() === "") {
583
+ throw new Error("SVG 标记为空:action=svg 需要完整 SVG 标记或 .svg 路径");
584
+ }
585
+ if (!/<svg\b/i.test(markup)) {
586
+ throw new Error("不是 SVG 标记:缺少 <svg> 根元素(action=svg 只收 SVG 标记 / .svg 文件,图片请用 action=image)");
587
+ }
588
+
589
+ const canvas = parseCanvas(markup);
590
+ const scan = scanElements(markup);
591
+ const elements = scan.texts.map((element, index) => {
592
+ element.index = index;
593
+ for (const line of element.lines) line.elIndex = index;
594
+ return element;
595
+ });
596
+ const lines = elements.flatMap((element) => element.lines);
597
+ const measuredCount = elements.length;
598
+ const { scale } = canvas;
599
+ const canvasW = canvas.w;
600
+ const canvasH = canvas.h;
601
+ const canvasPxW = canvasW * scale;
602
+ const canvasPxH = canvasH * scale;
603
+
604
+ /* ------------------------------ metrics ------------------------------ */
605
+
606
+ const fontSizes = [...new Set(lines.map((line) => round2(line.fs)))].sort((a, b) => a - b);
607
+
608
+ // 正文 = 出现最多的字号(并列时取更小的那个:正文通常是小的)
609
+ const fontCount = new Map();
610
+ for (const line of lines) fontCount.set(line.fs, (fontCount.get(line.fs) ?? 0) + 1);
611
+ let bodySize = 0;
612
+ let bodyCount = 0;
613
+ let maxSize = 0;
614
+ for (const [fontSize, count] of fontCount) {
615
+ if (fontSize > maxSize) maxSize = fontSize;
616
+ if (count > bodyCount || (count === bodyCount && fontSize < bodySize)) {
617
+ bodySize = fontSize;
618
+ bodyCount = count;
619
+ }
620
+ }
621
+ const hierarchyRatio = measuredCount > 0 && bodySize > 0 ? round2(maxSize / bodySize) : 0;
622
+
623
+ const textBox = unionBoxes(lines.map((line) => line.bbox));
624
+ /** 从 bbox 算四边距(%);bbox 为空时返回 null(不能编造成 0——读者会以为内容顶到四边)。 */
625
+ const marginsOf = (box) =>
626
+ box === null
627
+ ? null
628
+ : {
629
+ left: round2(((box.x - canvas.minX) / canvasW) * 100),
630
+ top: round2(((box.y - canvas.minY) / canvasH) * 100),
631
+ right: round2(((canvas.minX + canvasW - (box.x + box.w)) / canvasW) * 100),
632
+ bottom: round2(((canvas.minY + canvasH - (box.y + box.h)) / canvasH) * 100)
633
+ };
634
+ // G1 只量文字(图形贴边是另一种语言,不属于排版);metrics 里报的则是**内容**边距。
635
+ const textMargins = marginsOf(textBox);
636
+
637
+ // 背景板识别:面积 ≥90% 画布的纯图形(满版 rect / 渐变底)视为背景,不进内容 bbox / G9。
638
+ // 文字是独立元素,叠在背景上不算「图形内部承载文字」;G1 本来也只量文字 bbox。
639
+ const canvasArea = canvasW * canvasH;
640
+ const contentGraphics = [];
641
+ let backgroundPlates = 0;
642
+ for (const box of scan.graphics) {
643
+ if ((box.w * box.h) / canvasArea >= BACKGROUND_PLATE_AREA_RATIO) backgroundPlates += 1;
644
+ else contentGraphics.push(box);
645
+ }
646
+ const contentBox = unionBoxes([...lines.map((line) => line.bbox), ...contentGraphics]);
647
+ const coveragePct =
648
+ contentBox === null
649
+ ? 0
650
+ : round2(((contentBox.w * contentBox.h) / (canvasW * canvasH)) * 100);
651
+ // 对外报的边距是「内容」边距(文字 ∪ 非背景图形),与 G0-no-text 的提示一致。
652
+ const margins = marginsOf(contentBox) ?? { left: 0, top: 0, right: 0, bottom: 0 };
653
+
654
+ const minFontPx = measuredCount > 0 ? round2(Math.min(...lines.map((line) => line.fs)) * scale) : 0;
655
+
656
+ /* ---------------------- 对比度:文字 fill vs 背景色 ---------------------- */
657
+ // 背景候选:面积 ≥ 20% 画布、且 fill 可解析的图形(含满版底色)。
658
+ // 只对「真的能确定背景」的文字做对比度:透明底 SVG 无法得知实际背景,宁可跳过不瞎报。
659
+ const backgroundShapes = scan.graphics
660
+ .filter((box) => (box.w * box.h) / canvasArea >= 0.2 && parseColor(box.fill) !== null)
661
+ .sort((a, b) => b.w * b.h - a.w * a.h);
662
+ const contrastPairs = [];
663
+ // 同一组「前景色 + 背景色 + 字号 + 字重」只算一次,否则 10 行同样颜色的次要文字
664
+ // 会报出 10 条一模一样的对比度 error,把真正的热点淹了。
665
+ const contrastSeen = new Map();
666
+ if (backgroundShapes.length > 0) {
667
+ for (const element of elements) {
668
+ const box = element.bbox;
669
+ if (box === null || box === undefined) continue;
670
+ const cx = box.x + box.w / 2;
671
+ const cy = box.y + box.h / 2;
672
+ // 取「包含该文字中心、且面积最小」的背景层:它才是文字实际压着的那一层
673
+ const covering = backgroundShapes
674
+ .filter((s) => cx >= s.x && cx <= s.x + s.w && cy >= s.y && cy <= s.y + s.h)
675
+ .sort((a, b) => a.w * a.h - b.w * b.h)[0];
676
+ if (covering === undefined) continue;
677
+ const pair = {
678
+ fg: String(element.fill ?? ""),
679
+ bg: String(covering.fill ?? ""),
680
+ fontSizePx: element.lines[0]?.fs !== undefined ? element.lines[0].fs * scale : DEFAULT_FONT_SIZE * scale,
681
+ bold: /^(bold|[6-9]00)$/.test(String(element.weight).trim())
682
+ };
683
+ const key = `${pair.fg}|${pair.bg}|${Math.round(pair.fontSizePx)}|${pair.bold}`;
684
+ const seen = contrastSeen.get(key);
685
+ if (seen !== undefined) {
686
+ seen.count += 1;
687
+ continue;
688
+ }
689
+ contrastSeen.set(key, { count: 1, pair });
690
+ contrastPairs.push({ ...pair, where: labelOf(element.index, element.sample) });
691
+ }
692
+ }
693
+
694
+ const metrics = {
695
+ canvas: [round2(canvasW), round2(canvasH)],
696
+ textCount: measuredCount,
697
+ fontSizes,
698
+ hierarchyRatio,
699
+ lines: lines.length,
700
+ marginsPct: margins,
701
+ coveragePct,
702
+ unmeasured: scan.unmeasured,
703
+ backgroundPlates,
704
+ minFontPx,
705
+ scale: round2(scale),
706
+ // 边距到底是量文字还是量全部内容:没文字时 / 图形比文字外扩时不一样,
707
+ // 明说一句,避免读者把「内容边距」当成「文字边距」。
708
+ ...(textMargins !== null ? { textMarginsPct: textMargins } : {}),
709
+ ...(contentBox === null ? { contentEmpty: true } : {}),
710
+ // 覆盖度:哪些规则因为「量不到」而被跳过。breaklint 的做法——
711
+ // 报告必须区分「检查了且通过」与「压根没检查」,否则 score 会骗人。
712
+ ...(contrastPairs.length > 0 ? { contrastChecked: contrastPairs.length } : {})
713
+ };
714
+
715
+ /* ------------------------------ findings ------------------------------ */
716
+
717
+ const findings = [];
718
+ const push = (item) => findings.push(item);
719
+
720
+ // G0:解析层信息(默认字号 / transform 内文字 / 完全没有文字)
721
+ const defaultedCount = elements.filter((element) => element.usedDefault).length;
722
+ if (scan.unmeasured > 0) {
723
+ push(
724
+ finding({
725
+ severity: "info",
726
+ rule: "G0-transform-unmeasured",
727
+ message: `${scan.unmeasured} 个 <text> 位于 transform 分组内,坐标系不可靠,未参与文字几何判定(unmeasured=${scan.unmeasured})`,
728
+ fix: "把 transform 展开成绝对坐标(或在导出时取消分组变换)后重新体检;否则这些文字的字号/边距/重叠都量不到",
729
+ where: `${scan.unmeasured} 个 transform 内文字`
730
+ })
731
+ );
732
+ }
733
+ if (defaultedCount > 0) {
734
+ push(
735
+ finding({
736
+ severity: "info",
737
+ rule: "G0-default-font-size",
738
+ message: `${defaultedCount} 个 <text> 没有写 font-size,按浏览器默认 ${DEFAULT_FONT_SIZE} 估算`,
739
+ fix: `显式写 font-size(海报/卡片请写绝对 px 或用字号 token),否则不同导出工具会给出不同默认值`,
740
+ where: labelOf(elements.find((element) => element.usedDefault)?.index ?? 0, elements.find((element) => element.usedDefault)?.sample ?? "")
741
+ })
742
+ );
743
+ }
744
+ if (scan.rawTextCount === 0) {
745
+ push(
746
+ finding({
747
+ severity: "info",
748
+ rule: "G0-no-text",
749
+ message:
750
+ `SVG 里没有 <text> 元素:文字层级/行距/对齐/最小字号都无从体检` +
751
+ (scan.graphics.length > 0 ? `(${scan.graphics.length} 个图形仍计入内容包围盒与留白)` : "(画布完全空白)"),
752
+ fix: "让文案保持为可编辑 <text>(不要转曲成 path),或改用 action=image 量渲染后的构图",
753
+ where: "整张画布"
754
+ })
755
+ );
756
+ }
757
+
758
+ // G1 边界/安全区:所有文字 bbox 到四边的最小距离百分比
759
+ if (textBox !== null && textMargins !== null) {
760
+ const sides = [
761
+ { name: "左", value: textMargins.left },
762
+ { name: "上", value: textMargins.top },
763
+ { name: "右", value: textMargins.right },
764
+ { name: "下", value: textMargins.bottom }
765
+ ];
766
+ const minMargin = Math.min(...sides.map((side) => side.value));
767
+ const worstSides = sides
768
+ .filter((side) => side.value < (minMargin < SAFE_ERROR_PCT ? SAFE_ERROR_PCT : SAFE_WARN_PCT))
769
+ .sort((a, b) => a.value - b.value);
770
+ const worstText = elements
771
+ .map((element) => ({ element, box: element.bbox }))
772
+ .sort((a, b) => marginOfBox(a.box, canvas) - marginOfBox(b.box, canvas))[0];
773
+ const where = worstText === undefined ? undefined : labelOf(worstText.element.index, worstText.element.sample);
774
+ const detail = worstSides.map((side) => `${side.name} ${side.value}%`).join("、");
775
+ if (minMargin < SAFE_ERROR_PCT) {
776
+ push(
777
+ finding({
778
+ severity: "error",
779
+ rule: "G1",
780
+ message: `文字贴边:${detail}(安全区要求 ≥${SAFE_ERROR_PCT}%,海报/印刷会裁切或缺呼吸感)`,
781
+ fix: `四周留白至少 ${SAFE_WARN_PCT}%(海报建议 8%):把最靠边的文字往内移,或缩小字号/缩短文案`,
782
+ where
783
+ })
784
+ );
785
+ } else if (minMargin < SAFE_WARN_PCT) {
786
+ push(
787
+ finding({
788
+ severity: "warn",
789
+ rule: "G1",
790
+ message: `文字离画布边缘偏近:${detail}(建议 ≥${SAFE_WARN_PCT}%)`,
791
+ fix: `把最近的文字内缩到 ${SAFE_WARN_PCT}% 以上的安全区,避免裁切风险`,
792
+ where
793
+ })
794
+ );
795
+ }
796
+ }
797
+
798
+ // G2 字号层级不足。文字块 ≤2 或只有一种字号时,版面多半是单行 banner / 徽标 / 按钮,
799
+ // 「层级」这个概念不成立 → 降为 info(不硬扣 15 分);真正该报的是「多行正文 + 无层级」。
800
+ if (measuredCount > 0 && fontSizes.length > 0) {
801
+ const fewTexts = measuredCount <= 2;
802
+ const hierarchySeverity = (normal) => (fewTexts ? "info" : normal);
803
+ if (fontSizes.length < 2) {
804
+ push(
805
+ finding({
806
+ severity: hierarchySeverity("error"),
807
+ rule: "G2",
808
+ message: fewTexts
809
+ ? `单行/徽标类版面(${measuredCount} 个文字块,仅 1 种字号 ${fontSizes[0]}),无层级可评估`
810
+ : `只有一种字号(${fontSizes[0]}):没有标题/正文层级,读者抓不到重点`,
811
+ fix: fewTexts
812
+ ? "单行/徽标类版面可以只有一档字号;一旦有正文段落,再补标题级字号(≥2×)"
813
+ : "至少两档字号:标题 ≥2× 正文(推荐 2–3.2×),正文之外再加档弱化文案",
814
+ where: labelOf(elements[0].index, elements[0].sample)
815
+ })
816
+ );
817
+ } else if (hierarchyRatio < HIERARCHY_ERROR_RATIO) {
818
+ push(
819
+ finding({
820
+ severity: hierarchySeverity("error"),
821
+ rule: "G2",
822
+ message: fewTexts
823
+ ? `单行/徽标类版面(${measuredCount} 个文字块),层级仅供参考:最大/正文 ${hierarchyRatio}×`
824
+ : `层级几乎不可见:最大字号 ${maxSize} / 正文 ${bodySize} = ${hierarchyRatio}×(<${HIERARCHY_ERROR_RATIO})`,
825
+ fix: `拉开到 2× 以上(标题 ${round2(bodySize * 2)} 左右),把标题加粗或加大`,
826
+ where: labelOf(elements[0].index, elements[0].sample)
827
+ })
828
+ );
829
+ } else if (hierarchyRatio < HIERARCHY_WARN_RATIO) {
830
+ push(
831
+ finding({
832
+ severity: hierarchySeverity("warn"),
833
+ rule: "G2",
834
+ message: fewTexts
835
+ ? `单行/徽标类版面(${measuredCount} 个文字块),层级仅供参考:最大/正文 ${hierarchyRatio}×`
836
+ : `字号层级偏弱:最大/正文仅 ${hierarchyRatio}×(<${HIERARCHY_WARN_RATIO}×,标题不够突出)`,
837
+ fix: `标题字号拉到正文的 2–3×(当前正文 ${bodySize},标题 ${maxSize})`,
838
+ where: labelOf(elements[0].index, elements[0].sample)
839
+ })
840
+ );
841
+ }
842
+ if (fontSizes.length > MAX_FONT_STEPS) {
843
+ push(
844
+ finding({
845
+ severity: "warn",
846
+ rule: "G2",
847
+ message: `字号档位过多(${fontSizes.length} 种,>${MAX_FONT_STEPS}):像随手挑的字号,缺系统感`,
848
+ fix: "收敛到 3–4 档(标题/副标题/正文/注释),把相近档位合并",
849
+ where: `字号:${fontSizes.join(" / ")}`
850
+ })
851
+ );
852
+ }
853
+ }
854
+
855
+ // G3 行距(同块相邻基线间距 / 字号)
856
+ const leadingCandidates = [];
857
+ for (const group of groupLinesByBlock(lines, scale)) {
858
+ if (group.lines.length < 2) continue;
859
+ const sorted = [...group.lines].sort((a, b) => a.y - b.y);
860
+ const fontSize = group.fs;
861
+ const ratios = [];
862
+ for (let i = 1; i < sorted.length; i += 1) {
863
+ const delta = sorted[i].y - sorted[i - 1].y;
864
+ if (delta <= 0) continue; // 同一基线不是“行距”,交给 G6 重叠判
865
+ const ratio = delta / fontSize;
866
+ if (ratio > LEADING_BLOCK_MAX_RATIO) continue; // 超过 6× 字号视为不同段落(防页脚误报)
867
+ ratios.push({ ratio, line: sorted[i], prev: sorted[i - 1] });
868
+ }
869
+ if (ratios.length === 0) continue;
870
+ const tightest = ratios.reduce((min, item) => (item.ratio < min.ratio ? item : min), ratios[0]);
871
+ const loosest = ratios.reduce((max, item) => (item.ratio > max.ratio ? item : max), ratios[0]);
872
+ const where = labelOf(tightest.line.elIndex, elements[tightest.line.elIndex]?.sample ?? "");
873
+ // 展示级大字号(≥40px)本来就该紧排:1080 海报上 92px 标题行距 1.17× 是正常排版,
874
+ // 用正文的 1.15/1.3 阈值会把专业海报误判成行距问题。32px 级标题仍按正文阈值卡。
875
+ const displayType = fontSize * scale >= 40;
876
+ const tightError = displayType ? 0.95 : LEADING_TIGHT_ERROR;
877
+ const tightWarn = displayType ? 1.05 : LEADING_TIGHT_WARN;
878
+ if (tightest.ratio < tightError) {
879
+ leadingCandidates.push({
880
+ severity: "error",
881
+ ratio: tightest.ratio,
882
+ item: finding({
883
+ severity: "error",
884
+ rule: "G3",
885
+ message: `行距过密:同一段相邻基线仅 ${round2(tightest.ratio)}×字号(<${tightError},行贴行会串读)`,
886
+ fix: `行距至少 ${tightError}×(正文推荐 1.4–1.6×,32px 以上标题 1.05–1.2×):加大相邻文字的 y 或改回 line-height`,
887
+ where
888
+ })
889
+ });
890
+ } else if (tightest.ratio < tightWarn || loosest.ratio > LEADING_LOOSE_WARN) {
891
+ const reason =
892
+ loosest.ratio > LEADING_LOOSE_WARN
893
+ ? `行距过大:${round2(loosest.ratio)}×字号(>${LEADING_LOOSE_WARN},段落被拆散)`
894
+ : `行距偏紧:${round2(tightest.ratio)}×字号(建议 1.3–1.6×)`;
895
+ leadingCandidates.push({
896
+ severity: "warn",
897
+ ratio: tightest.ratio,
898
+ item: finding({
899
+ severity: "warn",
900
+ rule: "G3",
901
+ message: reason,
902
+ fix: "正文行距取 1.4–1.6×字号,标题可 1.1–1.2×;同一段内保持一致",
903
+ where
904
+ })
905
+ });
906
+ }
907
+ }
908
+ leadingCandidates
909
+ .sort((a, b) => (a.severity === b.severity ? a.ratio - b.ratio : a.severity === "error" ? -1 : 1))
910
+ .slice(0, MAX_PER_RULE)
911
+ .forEach((candidate) => push(candidate.item));
912
+
913
+ // G4 行长失控(估算宽度占比 / 单行 CJK 字数)
914
+ if (lines.length > 0) {
915
+ let widest = { ratio: 0, line: lines[0] };
916
+ let longestCjk = { count: 0, line: lines[0] };
917
+ for (const line of lines) {
918
+ const ratio = line.bbox.w / canvasW;
919
+ if (ratio > widest.ratio) widest = { ratio, line };
920
+ const cjk = line.segments.reduce((sum, segment) => sum + countWideChars(segment.text), 0);
921
+ if (cjk > longestCjk.count) longestCjk = { count: cjk, line };
922
+ }
923
+ if (widest.ratio > LINE_WIDTH_WARN_RATIO) {
924
+ push(
925
+ finding({
926
+ severity: "warn",
927
+ rule: "G4",
928
+ message: `单行过长:估算宽度占画布 ${round2(widest.ratio * 100)}%(>${LINE_WIDTH_WARN_RATIO * 100}%,读起来容易串行)`,
929
+ fix: "把长句断开成多行(每行 30–45 个汉字 / 60–75 个拉丁字符),或加大左右留白",
930
+ where: labelOf(widest.line.elIndex, elements[widest.line.elIndex]?.sample ?? "")
931
+ })
932
+ );
933
+ }
934
+ if (longestCjk.count > CJK_PER_LINE_WARN) {
935
+ push(
936
+ finding({
937
+ severity: "warn",
938
+ rule: "G4",
939
+ message: `单行 ${longestCjk.count} 个汉字(>${CJK_PER_LINE_WARN}):超过舒适阅读行长`,
940
+ fix: "中文正文每行 20–35 字最舒适;把这段拆成多行或缩小字号",
941
+ where: labelOf(longestCjk.line.elIndex, elements[longestCjk.line.elIndex]?.sample ?? "")
942
+ })
943
+ );
944
+ }
945
+ }
946
+
947
+ // G5 文字溢出画布(超过 1px 才算:亚像素是导出误差)
948
+ if (lines.length > 0) {
949
+ let worst = { over: 0, side: "", line: lines[0] };
950
+ for (const line of lines) {
951
+ const box = line.bbox;
952
+ const leftPx = (box.x - canvas.minX) * scale;
953
+ const topPx = (box.y - canvas.minY) * scale;
954
+ const rightPx = (box.x + box.w - canvas.minX) * scale;
955
+ const bottomPx = (box.y + box.h - canvas.minY) * scale;
956
+ const overs = [
957
+ { over: -leftPx, side: "左" },
958
+ { over: -topPx, side: "上" },
959
+ { over: rightPx - canvasPxW, side: "右" },
960
+ { over: bottomPx - canvasPxH, side: "下" }
961
+ ].sort((a, b) => b.over - a.over);
962
+ if (overs[0].over > worst.over) worst = { over: overs[0].over, side: overs[0].side, line };
963
+ }
964
+ if (worst.over > 1) {
965
+ push(
966
+ finding({
967
+ severity: "error",
968
+ rule: "G5",
969
+ message: `文字溢出画布:${worst.side}侧超出 ${round2(worst.over)}px(导出/印刷会被裁掉)`,
970
+ fix: "把这块文字往画布内移或缩小字号;长文案先断行再定位",
971
+ where: labelOf(worst.line.elIndex, elements[worst.line.elIndex]?.sample ?? "")
972
+ })
973
+ );
974
+ }
975
+ }
976
+
977
+ // G6 文字重叠:只比「不同文本块」的行。同 x 锚 + 同字号 + 连续基线的相邻行属于同一文本块,
978
+ // 块内行贴行是行距问题(G3),不算「两个文本块重叠」。
979
+ if (measuredCount >= 2) {
980
+ const blockIds = assignTextBlocks(lines, scale);
981
+ const worstByPair = new Map();
982
+ for (let i = 0; i < lines.length; i += 1) {
983
+ for (let j = i + 1; j < lines.length; j += 1) {
984
+ const first = lines[i];
985
+ const second = lines[j];
986
+ if (first.elIndex === second.elIndex) continue; // 同一个 <text> 的 tspan 行不算重叠
987
+ if (blockIds.get(first) === blockIds.get(second)) continue; // 同一文本块内部不算重叠
988
+ const minArea = Math.min(first.bbox.w * first.bbox.h, second.bbox.w * second.bbox.h);
989
+ if (minArea <= 0) continue;
990
+ const ratio = intersectionArea(first.bbox, second.bbox) / minArea;
991
+ if (ratio <= OVERLAP_ERROR_RATIO) continue;
992
+ const low = Math.min(first.elIndex, second.elIndex);
993
+ const high = Math.max(first.elIndex, second.elIndex);
994
+ const key = `${low}-${high}`;
995
+ const previous = worstByPair.get(key);
996
+ if (previous === undefined || ratio > previous.ratio) worstByPair.set(key, { ratio, i: low, j: high });
997
+ }
998
+ }
999
+ [...worstByPair.values()]
1000
+ .sort((a, b) => b.ratio - a.ratio)
1001
+ .slice(0, MAX_PER_RULE)
1002
+ .forEach((overlap) => {
1003
+ push(
1004
+ finding({
1005
+ severity: "error",
1006
+ rule: "G6",
1007
+ message: `文字重叠:${labelOf(overlap.i, elements[overlap.i].sample)} 与 ${labelOf(overlap.j, elements[overlap.j].sample)} 重叠 ${round2(overlap.ratio * 100)}%(>${OVERLAP_ERROR_RATIO * 100}%)`,
1008
+ fix: "移动其中一块的位置(或缩短文案)让二者留出间距;多行块先确认 dy 累加没有重叠",
1009
+ where: `${labelOf(overlap.i, elements[overlap.i].sample)} × ${labelOf(overlap.j, elements[overlap.j].sample)}`
1010
+ })
1011
+ );
1012
+ });
1013
+ }
1014
+
1015
+ // G7 对齐关系:优先信 text-anchor(作者声明的对齐方式);没有声明时按边界回退
1016
+ // (左边界全等 → 左;右边界全等 → 右;左边界不等但中心全等 → 居中)。
1017
+ // 这样「等宽左对齐段落」(左/中/右边界全相等)只会归入左对齐,不会误判成对齐打架。
1018
+ if (lines.length >= ALIGN_MIN_LINES) {
1019
+ const tolerance = ALIGN_TOLERANCE_PX / scale;
1020
+ const modeCounts = { left: 0, center: 0, right: 0 };
1021
+ // text-anchor 的值是 start/middle/end,对齐方式键是 left/center/right,这里做一次映射
1022
+ const anchorMode = { start: "left", middle: "center", end: "right" };
1023
+ const unanchored = [];
1024
+ for (const line of lines) {
1025
+ if (line.anchorExplicit) modeCounts[anchorMode[line.anchor] ?? "left"] += 1;
1026
+ else unanchored.push(line);
1027
+ }
1028
+ if (unanchored.length >= 2) {
1029
+ const total = unanchored.length;
1030
+ const leftMax = maxClusterSize(unanchored.map((line) => line.bbox.x), tolerance);
1031
+ const rightMax = maxClusterSize(unanchored.map((line) => line.bbox.x + line.bbox.w), tolerance);
1032
+ const centerMax = maxClusterSize(unanchored.map((line) => line.bbox.x + line.bbox.w / 2), tolerance);
1033
+ if (leftMax === total) modeCounts.left += total;
1034
+ else if (rightMax === total) modeCounts.right += total;
1035
+ else if (centerMax === total) modeCounts.center += total;
1036
+ // 都不满足 → 这些行没有共同对齐方式,不计入任何 mode(会落入下面的「没有对齐关系」)
1037
+ }
1038
+ const modeNames = { left: "左对齐", center: "居中", right: "右对齐" };
1039
+ const present = Object.entries(modeCounts)
1040
+ .map(([mode, count]) => ({ mode, name: modeNames[mode], count }))
1041
+ .filter((item) => item.count >= 2)
1042
+ .sort((a, b) => b.count - a.count);
1043
+ if (present.length === 0) {
1044
+ push(
1045
+ finding({
1046
+ severity: "warn",
1047
+ rule: "G7",
1048
+ message: `没有任何对齐关系:${lines.length} 行文字的左右边界与中心都各自为政,版面显乱`,
1049
+ fix: "让至少 2 行共享同一条左边界(或中心/右边界);网格对齐之后再看留白",
1050
+ where: "整张画布"
1051
+ })
1052
+ );
1053
+ } else if (present.length >= 2) {
1054
+ push(
1055
+ finding({
1056
+ severity: "warn",
1057
+ rule: "G7",
1058
+ message: `对齐方式打架:${present.map((item) => `${item.name} ${item.count} 行`).join(" vs ")},不同对齐方式都成规模`,
1059
+ fix: "选一个主对齐方式(海报多用左对齐/居中),另一组统一到同一条轴线",
1060
+ where: `对齐簇:${present.map((item) => `${item.name} ${item.count}`).join(" / ")}`
1061
+ })
1062
+ );
1063
+ }
1064
+ }
1065
+
1066
+ // G8 最小字号(渲染后像素:字号 × viewBox 缩放)
1067
+ if (measuredCount > 0) {
1068
+ const smallest = Math.min(...lines.map((line) => line.fs));
1069
+ const minPx = smallest * scale;
1070
+ const where = labelOf(
1071
+ lines.find((line) => line.fs === smallest)?.elIndex ?? 0,
1072
+ elements[lines.find((line) => line.fs === smallest)?.elIndex ?? 0]?.sample ?? ""
1073
+ );
1074
+ if (minPx < FONT_PX_ERROR) {
1075
+ push(
1076
+ finding({
1077
+ severity: "error",
1078
+ rule: "G8",
1079
+ message: `最小字号渲染后只有 ${round2(minPx)}px(${round2(smallest)} × 缩放 ${round2(scale)},<${FONT_PX_ERROR}px 不可读)`,
1080
+ fix: `把最小字号提到渲染后 ≥${FONT_PX_WARN}px;印刷品正文建议 ≥3mm(约 8.5pt)`,
1081
+ where
1082
+ })
1083
+ );
1084
+ } else if (minPx < FONT_PX_WARN) {
1085
+ push(
1086
+ finding({
1087
+ severity: "warn",
1088
+ rule: "G8",
1089
+ message: `最小字号渲染后 ${round2(minPx)}px(<${FONT_PX_WARN}px 偏小,实际尺寸下难读)`,
1090
+ fix: `把最小字号提到渲染后 ≥${FONT_PX_WARN}px,或缩小画布尺寸让文字相对变大`,
1091
+ where
1092
+ })
1093
+ );
1094
+ }
1095
+ }
1096
+
1097
+ // G9 留白/密度:内容包围盒 / 画布面积
1098
+ if (contentBox !== null) {
1099
+ if (coveragePct > COVERAGE_CROWDED_PCT) {
1100
+ push(
1101
+ finding({
1102
+ severity: "warn",
1103
+ rule: "G9",
1104
+ message: `内容包围盒占画布 ${coveragePct}%(>${COVERAGE_CROWDED_PCT}%):留白不足,版面拥挤`,
1105
+ fix: `留白至少 ${round2(100 - COVERAGE_CROWDED_PCT)}%(海报建议 30–40% 视觉留白):缩小内容或加大画布`,
1106
+ where: "整张画布"
1107
+ })
1108
+ );
1109
+ } else if (coveragePct < COVERAGE_EMPTY_PCT) {
1110
+ push(
1111
+ finding({
1112
+ severity: "info",
1113
+ rule: "G9",
1114
+ message: `内容只占画布 ${coveragePct}%(<${COVERAGE_EMPTY_PCT}%):可能空版,或内容没导出/被裁掉`,
1115
+ fix: "确认文字/图层都在画布内;若是有意的极简留白,可忽略这条 info",
1116
+ where: "整张画布"
1117
+ })
1118
+ );
1119
+ }
1120
+ }
1121
+
1122
+ /* --------------------- 对比度(WCAG AA/AAA + APCA) --------------------- */
1123
+ // 行业实践:typography-skills 与 layout-lint-mcp 都做对比度。我们不需要测渲染像素——
1124
+ // SVG 的 text fill 与下面的形状 fill 都是声明的,能直接算。
1125
+ if (contrastPairs.length > 0) {
1126
+ // 级别映射:WCAG AA 是**Web 内容**的可访问性标准;SVG 成品往往是海报/印刷/大屏,
1127
+ // 同一套阈值不能当“error”。所以 svg 模式把 AA 的 error 降为 warn(仍然报,但不当伞)。
1128
+ // web 场景(action=ui)保持 error。
1129
+ for (const item of auditContrast(contrastPairs)) {
1130
+ findings.push(item.severity === "error" ? { ...item, severity: "warn" } : item);
1131
+ }
1132
+ }
1133
+
1134
+ /* --------------------------- 基线网格 --------------------------- */
1135
+ // 只对「成段正文」判:海报/封面是稀疏的自由排版(6 行、间距 108~436),
1136
+ // 拿 8px 基线网格去量它必然误报。所以额外要求「行间距中位数 ≤ 2.5×字号」——
1137
+ // 行贴行才算成段(此时垂直节奏才有约束意义)。
1138
+ const sortedY = [...lines].map((l) => l.y).sort((a, b) => a - b);
1139
+ const gaps = [];
1140
+ for (let i = 1; i < sortedY.length; i += 1) if (sortedY[i] > sortedY[i - 1]) gaps.push(sortedY[i] - sortedY[i - 1]);
1141
+ const medianGap = gaps.length === 0 ? 0 : [...gaps].sort((a, b) => a - b)[Math.floor(gaps.length / 2)];
1142
+ const medianFs = fontSizes.length === 0 ? 0 : fontSizes[Math.floor(fontSizes.length / 2)];
1143
+ const isParagraph = medianFs > 0 && medianGap > 0 && medianGap <= medianFs * 2.5;
1144
+ if (lines.length >= 4 && isParagraph) {
1145
+ findings.push(
1146
+ // 用 4px 网格而不是 8:正文 20px × 行高 1.4 = 28px 是合法节奏,但它不是 8 的倍数
1147
+ // (420%8=4)。拿 8 去量会把标准行高的段落判成「节奏失控」。
1148
+ // 4px 也是我们自己的质量底线口径(提示段里就是「4px grid」)。
1149
+ ...auditBaselineGrid(
1150
+ lines.map((line) => ({ y: line.y, height: line.fs, where: labelOf(line.elIndex, elements[line.elIndex]?.sample ?? "") })),
1151
+ { grid: 4 }
1152
+ )
1153
+ );
1154
+ }
1155
+
1156
+ /* ------------------------- 覆盖度声明 ------------------------- */
1157
+ // 不把「量不到」包装成 clean:没文字时 score 会很漂亮但什么都没测。
1158
+ // 这里给一条 info,让报告能区分「过了」与「没测」。
1159
+ const measuredKinds = [];
1160
+ if (measuredCount > 0) measuredKinds.push("文字几何");
1161
+ if (contrastPairs.length > 0) measuredKinds.push(`对比度×${contrastPairs.length}`);
1162
+ if (scan.graphics.length > 0) measuredKinds.push(`图形×${scan.graphics.length}`);
1163
+ const rulesRun = [];
1164
+ if (measuredCount > 0) rulesRun.push("G1", "G2", "G4", "G5", "G8");
1165
+ if (lines.length >= 2) rulesRun.push("G3");
1166
+ if (measuredCount >= 2) rulesRun.push("G6");
1167
+ if (lines.length >= 4) rulesRun.push("G7", "structure-baseline");
1168
+ if (contentBox !== null) rulesRun.push("G9");
1169
+ if (contrastPairs.length > 0) rulesRun.push("contrast");
1170
+ /* --- 声明豁免:.design-intent.json ----------------------------------------- */
1171
+ const intent = readIntent(intentDir);
1172
+ const applied = applyAllow(findings, intent);
1173
+ const metricsWithCoverage = {
1174
+ ...metrics,
1175
+ rulesRun: rulesRun.length,
1176
+ measured: measuredKinds,
1177
+ ...(applied.exempted.length > 0 ? { exempted: applied.exempted } : {})
1178
+ };
1179
+
1180
+ return finalize({
1181
+ mode: "svg",
1182
+ target,
1183
+ metrics: metricsWithCoverage,
1184
+ findings: applied.kept,
1185
+ // finalize 只透传 extras,不能写成兄弟键
1186
+ ...(applied.exempted.length > 0
1187
+ ? {
1188
+ extras: {
1189
+ intent: { allow: applied.exempted, ...(applied.exemptionReason !== undefined ? { reason: applied.exemptionReason } : {}) }
1190
+ }
1191
+ }
1192
+ : {})
1193
+ });
1194
+ }
1195
+
1196
+ /** 文字 bbox 到画布四边的最小距离百分比(G1 定位用)。 */
1197
+ function marginOfBox(box, canvas) {
1198
+ return Math.min(
1199
+ (box.x - canvas.minX) / canvas.w,
1200
+ (box.y - canvas.minY) / canvas.h,
1201
+ (canvas.minX + canvas.w - (box.x + box.w)) / canvas.w,
1202
+ (canvas.minY + canvas.h - (box.y + box.h)) / canvas.h
1203
+ );
1204
+ }