cardbuddy 0.13.3 → 0.14.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 (61) hide show
  1. package/README.md +7 -3
  2. package/dist/cli.js +4 -4
  3. package/dist/config.js +7 -7
  4. package/dist/init.js +15 -4
  5. package/dist/portGuard.js +2 -2
  6. package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
  7. package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
  8. package/dist/preview-editor/index.html +2 -2
  9. package/dist/standalone.js +14 -6
  10. package/dist/vendored/card-editor/api.js +6 -6
  11. package/dist/vendored/mcp/client.mjs +53 -3
  12. package/dist/vendored/mcp/tools.mjs +17 -1
  13. package/dist/vendored/server/core/ai-chat.js +1443 -271
  14. package/dist/vendored/server/core/cardFiles.js +271 -39
  15. package/dist/vendored/server/core/cardImportMeta.js +16 -0
  16. package/dist/vendored/server/core/chatHistory.js +122 -0
  17. package/dist/vendored/server/core/index.js +10 -3
  18. package/dist/vendored/server/core/turnJournal.js +386 -0
  19. package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
  20. package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
  21. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
  22. package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
  23. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
  24. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
  26. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
  27. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
  28. package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
  29. package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
  30. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
  31. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
  33. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
  34. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
  36. package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
  37. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
  38. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
  41. package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
  42. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
  43. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
  44. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
  45. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
  46. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
  47. package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
  49. package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
  50. package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
  51. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
  52. package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
  53. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
  54. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
  55. package/dist/vendored/tools/finmall-env.mjs +6 -6
  56. package/package.json +1 -1
  57. package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
  58. package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
  59. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
  60. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
  61. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +0 -48
@@ -1,328 +1,388 @@
1
- /**
2
- * design_sample.mjs — 从设计稿聚焦图(card-N.png)采样顶部色带。
3
- *
4
- * 用途:弥散背景/浅色顶带没有切片素材时,按「缺素材用纯色」约束,
5
- * 从设计稿截图采样带高与纯色近似值(不裁切图片,只取颜色)。
6
- *
7
- * 算法(纯几何 + 统计,无任何业务/样本硬编码):
8
- * 1. 取中央竖条(30%-70% 宽)避开圆角与侧边装饰;
9
- * 2. 每行取中位色(对文字等前景像素稳健);
10
- * 3. 卡体基色 = 底部 8% 行中位色的中位数;
11
- * 4. 从顶部扫描:行色与基色平均通道差 > 6 视为色带行,
12
- * 允许开头 ≤8 CSS px 的基色行(圆角行),允许 1 行例外(抗锯齿);
13
- * 5. 带高 ≥10 且 ≤60% 卡高才采纳。
14
- */
15
- import fs from 'node:fs'
16
- import { PNG } from 'pngjs'
17
-
18
- function median(nums) {
19
- if (!nums.length) return 0
20
- const sorted = [...nums].sort((a, b) => a - b)
21
- return sorted[Math.floor(sorted.length / 2)]
22
- }
23
-
24
- function toHex(r, g, b) {
25
- const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0').toUpperCase()
26
- return `#${c(r)}${c(g)}${c(b)}`
27
- }
28
-
29
- /**
30
- * @param {string} designPath 设计稿聚焦图路径
31
- * @param {number} cardWidth 卡片宽(CSS px)
32
- * @param {number} cardHeight 卡片高(CSS px)
33
- * @returns {{ height: number, color: string } | null}
34
- */
35
- export function sampleTopBand(designPath, cardWidth, cardHeight) {
36
- try {
37
- if (!designPath || !fs.existsSync(designPath)) return null
38
- const png = PNG.sync.read(fs.readFileSync(designPath))
39
- const scale = png.width / Math.max(1, cardWidth)
40
- if (!(scale > 0.5 && scale < 5)) return null
41
- const { width: w, height: h, data } = png
42
- const x0 = Math.floor(w * 0.3)
43
- const x1 = Math.max(x0 + 2, Math.ceil(w * 0.7))
44
-
45
- const rowMedian = (y) => {
46
- const rs = [], gs = [], bs = []
47
- for (let x = x0; x < x1; x += 2) {
48
- const i = (y * w + x) * 4
49
- rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
50
- }
51
- return [median(rs), median(gs), median(bs)]
52
- }
53
-
54
- // 卡体基色:底部 8% 的行
55
- const bodyRows = []
56
- for (let y = Math.floor(h * 0.92); y < h; y++) bodyRows.push(rowMedian(y))
57
- const body = [0, 1, 2].map((i) => median(bodyRows.map((c) => c[i])))
58
- const dist = (a, b) => (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3
59
-
60
- const maxLeadRows = Math.round(8 * scale) // 圆角保护
61
- const bandColors = []
62
- let bandEndRow = -1
63
- let started = false
64
- let exceptions = 0
65
- for (let y = 0; y < h; y++) {
66
- const c = rowMedian(y)
67
- if (dist(c, body) > 6) {
68
- started = true
69
- bandColors.push(c)
70
- bandEndRow = y
71
- exceptions = 0
72
- } else {
73
- if (!started) {
74
- if (y > maxLeadRows) return null // 顶部 8px 内没有出现色带 → 无顶带
75
- continue
76
- }
77
- exceptions += 1
78
- if (exceptions > 1) break // 容忍 1 行例外(分隔线/抗锯齿)
79
- }
80
- }
81
- if (!started || bandEndRow < 0) return null
82
- const heightCss = (bandEndRow + 1) / scale
83
- // 上限 80%:整卡同色时底行=带色根本检不出带,能检出的高带(如顶面板+异色底栏)是合法形态
84
- if (heightCss < 10 || heightCss > cardHeight * 0.8) return null
85
- const color = toHex(
86
- median(bandColors.map((c) => c[0])),
87
- median(bandColors.map((c) => c[1])),
88
- median(bandColors.map((c) => c[2])),
89
- )
90
- return { height: Math.round(heightCss), color }
91
- } catch {
92
- return null
93
- }
94
- }
95
-
96
- /**
97
- * 采样设计稿聚焦图中某个卡内区域的主色(区域中央 50% 的中位色,对内部文字稳健)。
98
- * 用途:无导出素材的大切片(如烘焙按钮图)用纯色块近似(constraint:缺素材用纯色)。
99
- * @param {string} designPath
100
- * @param {number} cardWidth CSS px
101
- * @param {{x:number,y:number,width:number,height:number}} region 卡内相对坐标
102
- * @returns {string|null} hex 颜色
103
- */
104
- export function sampleRegionColor(designPath, cardWidth, region) {
105
- try {
106
- if (!designPath || !fs.existsSync(designPath)) return null
107
- const png = PNG.sync.read(fs.readFileSync(designPath))
108
- const scale = png.width / Math.max(1, cardWidth)
109
- if (!(scale > 0.5 && scale < 5)) return null
110
- const { width: w, height: h, data } = png
111
- const x0 = Math.max(0, Math.round((region.x + region.width * 0.25) * scale))
112
- const x1 = Math.min(w, Math.round((region.x + region.width * 0.75) * scale))
113
- const y0 = Math.max(0, Math.round((region.y + region.height * 0.25) * scale))
114
- const y1 = Math.min(h, Math.round((region.y + region.height * 0.75) * scale))
115
- if (x1 - x0 < 2 || y1 - y0 < 2) return null
116
- const rs = [], gs = [], bs = []
117
- for (let y = y0; y < y1; y += 2) {
118
- for (let x = x0; x < x1; x += 2) {
119
- const i = (y * w + x) * 4
120
- rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
121
- }
122
- }
123
- return toHex(median(rs), median(gs), median(bs))
124
- } catch {
125
- return null
126
- }
127
- }
128
-
129
- /**
130
- * 采样区域边缘条带主色(左右两条竖带的中位色,避开中央文字)。
131
- * 用途:按钮/pill 的标注填充色与设计稿像素交叉验证——中央采样会被按钮文字污染,
132
- * 边缘条带(圆角半径之外的纯背景区)才是填充色真值。
133
- * @param {string} designPath
134
- * @param {number} cardWidth CSS px
135
- * @param {{x:number,y:number,width:number,height:number}} region 卡内相对坐标
136
- * @returns {string|null} hex 颜色
137
- */
138
- export function sampleRegionEdgeColor(designPath, cardWidth, region) {
139
- try {
140
- if (!designPath || !fs.existsSync(designPath)) return null
141
- const png = PNG.sync.read(fs.readFileSync(designPath))
142
- const scale = png.width / Math.max(1, cardWidth)
143
- if (!(scale > 0.5 && scale < 5)) return null
144
- const { width: w, height: h, data } = png
145
- // 条带内缩:横向 8%-22% / 78%-92%(避开圆角与中央文字),纵向 30%-70%
146
- const y0 = Math.max(0, Math.round((region.y + region.height * 0.3) * scale))
147
- const y1 = Math.min(h, Math.round((region.y + region.height * 0.7) * scale))
148
- const strips = [
149
- [Math.round((region.x + region.width * 0.08) * scale), Math.round((region.x + region.width * 0.22) * scale)],
150
- [Math.round((region.x + region.width * 0.78) * scale), Math.round((region.x + region.width * 0.92) * scale)],
151
- ]
152
- const rs = [], gs = [], bs = []
153
- for (const [sx0, sx1] of strips) {
154
- const x0 = Math.max(0, sx0), x1 = Math.min(w, sx1)
155
- if (x1 - x0 < 2 || y1 - y0 < 2) continue
156
- for (let y = y0; y < y1; y += 2) {
157
- for (let x = x0; x < x1; x += 2) {
158
- const i = (y * w + x) * 4
159
- rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
160
- }
161
- }
162
- }
163
- if (!rs.length) return null
164
- return toHex(median(rs), median(gs), median(bs))
165
- } catch {
166
- return null
167
- }
168
- }
169
-
170
- /**
171
- * 采样卡体主色(设计稿聚焦图中央竖条底部 8% 行的中位色)。
172
- * 用于校验/纠正 spec cardFill 与设计稿明显不一致的情况(视觉优先)。
173
- * @returns {string|null} hex
174
- */
175
- export function sampleCardBodyColor(designPath, cardWidth) {
176
- try {
177
- if (!designPath || !fs.existsSync(designPath)) return null
178
- const png = PNG.sync.read(fs.readFileSync(designPath))
179
- const scale = png.width / Math.max(1, cardWidth)
180
- if (!(scale > 0.5 && scale < 5)) return null
181
- const { width: w, height: h, data } = png
182
- const x0 = Math.floor(w * 0.3)
183
- const x1 = Math.max(x0 + 2, Math.ceil(w * 0.7))
184
- const rs = [], gs = [], bs = []
185
- for (let y = Math.floor(h * 0.94); y < h; y++) {
186
- for (let x = x0; x < x1; x += 2) {
187
- const i = (y * w + x) * 4
188
- rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
189
- }
190
- }
191
- return toHex(median(rs), median(gs), median(bs))
192
- } catch {
193
- return null
194
- }
195
- }
196
-
197
- /**
198
- * 文本颜色交叉校验(标注缺陷治理,视觉优先):
199
- * 标注 textStyle.color 与设计稿实际字形颜色明显不一致时(如设计是蓝灰字、标注写深色),
200
- * 从设计稿聚焦图采样字形真实颜色覆盖标注。
201
- *
202
- * 算法(纯几何 + 统计,无业务词):
203
- * 1. 文本所在小壳(按钮/pill)bbox 内缩 15% 取中央区(避开圆角/壳边抗锯齿);
204
- * 2. 全区中位色 ≈ 壳底色(文字稀疏);
205
- * 3. 与底色距离 >40 的像素视为字形;取其中亮度最低 25%(抗锯齿核心)的中位色;
206
- * 4. 采样色与标注色平均通道差 >28 才覆盖(小差不折腾,保留标注)。
207
- *
208
- * @param {string} designPath 设计稿聚焦图路径(card-N.png)
209
- * @param {number} cardWidth 卡片宽(CSS px)
210
- * @param {{x:number,y:number,width:number,height:number}} bboxCardRel 壳 bbox(卡内相对坐标)
211
- * @param {string} specColorHex 标注颜色(#RRGGBB)
212
- * @returns {string|null} 需覆盖时返回采样色 #RRGGBB,否则 null
213
- */
214
- export function crossCheckTextColor(designPath, cardWidth, bboxCardRel, specColorHex) {
215
- try {
216
- if (!designPath || !fs.existsSync(designPath)) return null
217
- const png = PNG.sync.read(fs.readFileSync(designPath))
218
- const scale = png.width / Math.max(1, cardWidth)
219
- if (!(scale > 0.5 && scale < 5)) return null
220
- const { width: w, height: h, data } = png
221
- const bx = (bboxCardRel.x || 0) * scale
222
- const by = (bboxCardRel.y || 0) * scale
223
- const bw = (bboxCardRel.width || 0) * scale
224
- const bh = (bboxCardRel.height || 0) * scale
225
- if (bw < 8 || bh < 6) return null
226
- const x0 = Math.max(0, Math.round(bx + bw * 0.15))
227
- const x1 = Math.min(w - 1, Math.round(bx + bw * 0.85))
228
- const y0 = Math.max(0, Math.round(by + bh * 0.15))
229
- const y1 = Math.min(h - 1, Math.round(by + bh * 0.85))
230
- if (x1 - x0 < 4 || y1 - y0 < 4) return null
231
- const rs = [], gs = [], bs = []
232
- for (let y = y0; y <= y1; y++) {
233
- for (let x = x0; x <= x1; x++) {
234
- const i = (y * w + x) * 4
235
- rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
236
- }
237
- }
238
- const bg = [median(rs), median(gs), median(bs)]
239
- const dist = (r, g, b, t) => (Math.abs(r - t[0]) + Math.abs(g - t[1]) + Math.abs(b - t[2])) / 3
240
- const glyphs = []
241
- for (let k = 0; k < rs.length; k++) {
242
- if (dist(rs[k], gs[k], bs[k], bg) > 40) {
243
- glyphs.push([rs[k], gs[k], bs[k], rs[k] * 0.299 + gs[k] * 0.587 + bs[k] * 0.114])
244
- }
245
- }
246
- if (glyphs.length < 30) return null
247
- glyphs.sort((a, b) => a[3] - b[3])
248
- const core = glyphs.slice(0, Math.max(10, Math.floor(glyphs.length * 0.25)))
249
- const sampled = [
250
- median(core.map((c) => c[0])),
251
- median(core.map((c) => c[1])),
252
- median(core.map((c) => c[2])),
253
- ]
254
- const m = String(specColorHex || '').replace('#', '')
255
- if (!/^[0-9a-fA-F]{6}$/.test(m)) return toHex(...sampled)
256
- const spec = [parseInt(m.slice(0, 2), 16), parseInt(m.slice(2, 4), 16), parseInt(m.slice(4, 6), 16)]
257
- if (dist(sampled[0], sampled[1], sampled[2], spec) > 28) return toHex(...sampled)
258
- return null
259
- } catch {
260
- return null
261
- }
262
- }
263
-
264
- /**
265
- * 描边环采样(标注缺陷治理,视觉优先):
266
- * 壳标注无 fill 无 border("隐形"壳),但设计稿可见描边时(如线框 pill 按钮),
267
- * 从设计稿采样壳边缘环带颜色,判定是否存在描边并取其颜色。
268
- *
269
- * 算法(纯几何 + 统计):
270
- * 1. 壳 bbox 四边各取中段 30%-70%(避开圆角);
271
- * 2. 环带 = bbox 边缘向内 max(1, round(1.6×scale)) px 的窄带;
272
- * 3. 内部参照色 = 中央 30% 区域中位色;
273
- * 4. 环带亮度最低 40% 像素的中位色与内部参照色距离 >18 → 存在描边,返回其颜色。
274
- *
275
- * @param {string} designPath 设计稿聚焦图路径
276
- * @param {number} cardWidth 卡片宽(CSS px)
277
- * @param {{x:number,y:number,width:number,height:number}} bboxCardRel 壳 bbox(卡内相对坐标)
278
- * @returns {string|null} 描边色 #RRGGBB;无描边/采样失败返回 null
279
- */
280
- export function sampleOutlineRing(designPath, cardWidth, bboxCardRel) {
281
- try {
282
- if (!designPath || !fs.existsSync(designPath)) return null
283
- const png = PNG.sync.read(fs.readFileSync(designPath))
284
- const scale = png.width / Math.max(1, cardWidth)
285
- if (!(scale > 0.5 && scale < 5)) return null
286
- const { width: w, height: h, data } = png
287
- const bx = Math.round((bboxCardRel.x || 0) * scale)
288
- const by = Math.round((bboxCardRel.y || 0) * scale)
289
- const bw = Math.round((bboxCardRel.width || 0) * scale)
290
- const bh = Math.round((bboxCardRel.height || 0) * scale)
291
- if (bw < 10 || bh < 8) return null
292
- const t = Math.max(1, Math.round(1.6 * scale))
293
- const at = (x, y) => {
294
- const i = (Math.min(h - 1, Math.max(0, y)) * w + Math.min(w - 1, Math.max(0, x))) * 4
295
- return [data[i], data[i + 1], data[i + 2]]
296
- }
297
- const ring = []
298
- const push = (x, y) => { const c = at(x, y); ring.push([c[0], c[1], c[2], c[0] * 0.299 + c[1] * 0.587 + c[2] * 0.114]) }
299
- for (let x = bx + Math.round(bw * 0.3); x <= bx + Math.round(bw * 0.7); x++) {
300
- for (let k = 0; k < t; k++) { push(x, by + k); push(x, by + bh - 1 - k) }
301
- }
302
- for (let y = by + Math.round(bh * 0.3); y <= by + Math.round(bh * 0.7); y++) {
303
- for (let k = 0; k < t; k++) { push(bx + k, y); push(bx + bw - 1 - k, y) }
304
- }
305
- if (ring.length < 20) return null
306
- // 内部参照色(中央 30% 区域中位色)
307
- const ir = [], ig = [], ib = []
308
- for (let y = by + Math.round(bh * 0.35); y <= by + Math.round(bh * 0.65); y += 2) {
309
- for (let x = bx + Math.round(bw * 0.35); x <= bx + Math.round(bw * 0.65); x += 2) {
310
- const c = at(x, y); ir.push(c[0]); ig.push(c[1]); ib.push(c[2])
311
- }
312
- }
313
- const interior = [median(ir), median(ig), median(ib)]
314
- ring.sort((a, b) => a[3] - b[3])
315
- const core = ring.slice(0, Math.max(8, Math.floor(ring.length * 0.4)))
316
- const ringColor = [
317
- median(core.map((c) => c[0])),
318
- median(core.map((c) => c[1])),
319
- median(core.map((c) => c[2])),
320
- ]
321
- const dist = (Math.abs(ringColor[0] - interior[0])
322
- + Math.abs(ringColor[1] - interior[1])
323
- + Math.abs(ringColor[2] - interior[2])) / 3
324
- return dist > 18 ? toHex(...ringColor) : null
325
- } catch {
326
- return null
327
- }
328
- }
1
+ /**
2
+ * design_sample.mjs — 从设计稿聚焦图(card-N.png)采样顶部色带。
3
+ *
4
+ * 用途:弥散背景/浅色顶带没有切片素材时,按「缺素材用纯色」约束,
5
+ * 从设计稿截图采样带高与纯色近似值(不裁切图片,只取颜色)。
6
+ *
7
+ * 算法(纯几何 + 统计,无任何业务/样本硬编码):
8
+ * 1. 取中央竖条(30%-70% 宽)避开圆角与侧边装饰;
9
+ * 2. 每行取中位色(对文字等前景像素稳健);
10
+ * 3. 卡体基色 = 底部 8% 行中位色的中位数;
11
+ * 4. 从顶部扫描:行色与基色平均通道差 > 6 视为色带行,
12
+ * 允许开头 ≤8 CSS px 的基色行(圆角行),允许 1 行例外(抗锯齿);
13
+ * 5. 带高 ≥10 且 ≤60% 卡高才采纳。
14
+ */
15
+ import fs from 'node:fs'
16
+ import { PNG } from 'pngjs'
17
+
18
+ function median(nums) {
19
+ if (!nums.length) return 0
20
+ const sorted = [...nums].sort((a, b) => a - b)
21
+ return sorted[Math.floor(sorted.length / 2)]
22
+ }
23
+
24
+ function toHex(r, g, b) {
25
+ const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0').toUpperCase()
26
+ return `#${c(r)}${c(g)}${c(b)}`
27
+ }
28
+
29
+ /**
30
+ * @param {string} designPath 设计稿聚焦图路径
31
+ * @param {number} cardWidth 卡片宽(CSS px)
32
+ * @param {number} cardHeight 卡片高(CSS px)
33
+ * @returns {{ height: number, color: string } | null}
34
+ */
35
+ export function sampleTopBand(designPath, cardWidth, cardHeight) {
36
+ try {
37
+ if (!designPath || !fs.existsSync(designPath)) return null
38
+ const png = PNG.sync.read(fs.readFileSync(designPath))
39
+ const scale = png.width / Math.max(1, cardWidth)
40
+ if (!(scale > 0.5 && scale < 5)) return null
41
+ const { width: w, height: h, data } = png
42
+ const x0 = Math.floor(w * 0.3)
43
+ const x1 = Math.max(x0 + 2, Math.ceil(w * 0.7))
44
+
45
+ const rowMedian = (y) => {
46
+ const rs = [], gs = [], bs = []
47
+ for (let x = x0; x < x1; x += 2) {
48
+ const i = (y * w + x) * 4
49
+ rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
50
+ }
51
+ return [median(rs), median(gs), median(bs)]
52
+ }
53
+
54
+ // 卡体基色:底部 8% 的行
55
+ const bodyRows = []
56
+ for (let y = Math.floor(h * 0.92); y < h; y++) bodyRows.push(rowMedian(y))
57
+ const body = [0, 1, 2].map((i) => median(bodyRows.map((c) => c[i])))
58
+ const dist = (a, b) => (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3
59
+
60
+ const maxLeadRows = Math.round(8 * scale) // 圆角保护
61
+ const bandColors = []
62
+ let bandEndRow = -1
63
+ let started = false
64
+ let exceptions = 0
65
+ for (let y = 0; y < h; y++) {
66
+ const c = rowMedian(y)
67
+ if (dist(c, body) > 6) {
68
+ started = true
69
+ bandColors.push(c)
70
+ bandEndRow = y
71
+ exceptions = 0
72
+ } else {
73
+ if (!started) {
74
+ if (y > maxLeadRows) return null // 顶部 8px 内没有出现色带 → 无顶带
75
+ continue
76
+ }
77
+ exceptions += 1
78
+ if (exceptions > 1) break // 容忍 1 行例外(分隔线/抗锯齿)
79
+ }
80
+ }
81
+ if (!started || bandEndRow < 0) return null
82
+ const heightCss = (bandEndRow + 1) / scale
83
+ // 上限 80%:整卡同色时底行=带色根本检不出带,能检出的高带(如顶面板+异色底栏)是合法形态
84
+ if (heightCss < 10 || heightCss > cardHeight * 0.8) return null
85
+ const color = toHex(
86
+ median(bandColors.map((c) => c[0])),
87
+ median(bandColors.map((c) => c[1])),
88
+ median(bandColors.map((c) => c[2])),
89
+ )
90
+ return { height: Math.round(heightCss), color }
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 采样设计稿聚焦图中某个卡内区域的主色(区域中央 50% 的中位色,对内部文字稳健)。
98
+ * 用途:无导出素材的大切片(如烘焙按钮图)用纯色块近似(constraint:缺素材用纯色)。
99
+ * @param {string} designPath
100
+ * @param {number} cardWidth CSS px
101
+ * @param {{x:number,y:number,width:number,height:number}} region 卡内相对坐标
102
+ * @returns {string|null} hex 颜色
103
+ */
104
+ export function sampleRegionColor(designPath, cardWidth, region) {
105
+ try {
106
+ if (!designPath || !fs.existsSync(designPath)) return null
107
+ const png = PNG.sync.read(fs.readFileSync(designPath))
108
+ const scale = png.width / Math.max(1, cardWidth)
109
+ if (!(scale > 0.5 && scale < 5)) return null
110
+ const { width: w, height: h, data } = png
111
+ const x0 = Math.max(0, Math.round((region.x + region.width * 0.25) * scale))
112
+ const x1 = Math.min(w, Math.round((region.x + region.width * 0.75) * scale))
113
+ const y0 = Math.max(0, Math.round((region.y + region.height * 0.25) * scale))
114
+ const y1 = Math.min(h, Math.round((region.y + region.height * 0.75) * scale))
115
+ if (x1 - x0 < 2 || y1 - y0 < 2) return null
116
+ const rs = [], gs = [], bs = []
117
+ for (let y = y0; y < y1; y += 2) {
118
+ for (let x = x0; x < x1; x += 2) {
119
+ const i = (y * w + x) * 4
120
+ rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
121
+ }
122
+ }
123
+ return toHex(median(rs), median(gs), median(bs))
124
+ } catch {
125
+ return null
126
+ }
127
+ }
128
+
129
+ /**
130
+ * 采样区域边缘条带主色(左右两条竖带的中位色,避开中央文字)。
131
+ * 用途:按钮/pill 的标注填充色与设计稿像素交叉验证——中央采样会被按钮文字污染,
132
+ * 边缘条带(圆角半径之外的纯背景区)才是填充色真值。
133
+ * @param {string} designPath
134
+ * @param {number} cardWidth CSS px
135
+ * @param {{x:number,y:number,width:number,height:number}} region 卡内相对坐标
136
+ * @returns {string|null} hex 颜色
137
+ */
138
+ export function sampleRegionEdgeColor(designPath, cardWidth, region) {
139
+ try {
140
+ if (!designPath || !fs.existsSync(designPath)) return null
141
+ const png = PNG.sync.read(fs.readFileSync(designPath))
142
+ const scale = png.width / Math.max(1, cardWidth)
143
+ if (!(scale > 0.5 && scale < 5)) return null
144
+ const { width: w, height: h, data } = png
145
+ // 条带内缩:横向 8%-22% / 78%-92%(避开圆角与中央文字),纵向 30%-70%
146
+ const y0 = Math.max(0, Math.round((region.y + region.height * 0.3) * scale))
147
+ const y1 = Math.min(h, Math.round((region.y + region.height * 0.7) * scale))
148
+ const strips = [
149
+ [Math.round((region.x + region.width * 0.08) * scale), Math.round((region.x + region.width * 0.22) * scale)],
150
+ [Math.round((region.x + region.width * 0.78) * scale), Math.round((region.x + region.width * 0.92) * scale)],
151
+ ]
152
+ const rs = [], gs = [], bs = []
153
+ for (const [sx0, sx1] of strips) {
154
+ const x0 = Math.max(0, sx0), x1 = Math.min(w, sx1)
155
+ if (x1 - x0 < 2 || y1 - y0 < 2) continue
156
+ for (let y = y0; y < y1; y += 2) {
157
+ for (let x = x0; x < x1; x += 2) {
158
+ const i = (y * w + x) * 4
159
+ rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
160
+ }
161
+ }
162
+ }
163
+ if (!rs.length) return null
164
+ return toHex(median(rs), median(gs), median(bs))
165
+ } catch {
166
+ return null
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 采样卡体主色(设计稿聚焦图中央竖条底部 8% 行的中位色)。
172
+ * 用于校验/纠正 spec cardFill 与设计稿明显不一致的情况(视觉优先)。
173
+ * @returns {string|null} hex
174
+ */
175
+ export function sampleCardBodyColor(designPath, cardWidth) {
176
+ try {
177
+ if (!designPath || !fs.existsSync(designPath)) return null
178
+ const png = PNG.sync.read(fs.readFileSync(designPath))
179
+ const scale = png.width / Math.max(1, cardWidth)
180
+ if (!(scale > 0.5 && scale < 5)) return null
181
+ const { width: w, height: h, data } = png
182
+ // 采样卡体左右margin条带(各 ~3% 宽、全高):内缩壳/列表行背景永远盖不到margin,
183
+ // 此前采底部 6% 中条会把「最后一行彩色壳」误当卡底色(整卡被染成行壳色)。
184
+ const edge = Math.max(2, Math.floor(w * 0.03))
185
+ const buckets = new Map()
186
+ const pick = (x, y) => {
187
+ const i = (y * w + x) * 4
188
+ const key = `${data[i] >> 4},${data[i + 1] >> 4},${data[i + 2] >> 4}`
189
+ const slot = buckets.get(key) || (buckets.set(key, { n: 0, rs: [], gs: [], bs: [] }), buckets.get(key))
190
+ slot.n += 1
191
+ slot.rs.push(data[i]); slot.gs.push(data[i + 1]); slot.bs.push(data[i + 2])
192
+ }
193
+ for (let y = 0; y < h; y += 2) {
194
+ for (let x = 0; x < edge; x += 1) pick(x, y)
195
+ for (let x = Math.max(0, w - edge); x < w; x += 1) pick(x, y)
196
+ }
197
+ if (!buckets.size) return null
198
+ const top = [...buckets.values()].sort((a, b) => b.n - a.n)[0]
199
+ return toHex(median(top.rs), median(top.gs), median(top.bs))
200
+ } catch {
201
+ return null
202
+ }
203
+ }
204
+
205
+ /**
206
+ * 文本颜色交叉校验(标注缺陷治理,视觉优先):
207
+ * 标注 textStyle.color 与设计稿实际字形颜色明显不一致时(如设计是蓝灰字、标注写深色),
208
+ * 从设计稿聚焦图采样字形真实颜色覆盖标注。
209
+ *
210
+ * 算法(纯几何 + 统计,无业务词):
211
+ * 1. 文本所在小壳(按钮/pill)bbox 内缩 15% 取中央区(避开圆角/壳边抗锯齿);
212
+ * 2. 全区中位色 ≈ 壳底色(文字稀疏);
213
+ * 3. 与底色距离 >40 的像素视为字形;取其中亮度最低 25%(抗锯齿核心)的中位色;
214
+ * 4. 采样色与标注色平均通道差 >28 才覆盖(小差不折腾,保留标注)。
215
+ *
216
+ * @param {string} designPath 设计稿聚焦图路径(card-N.png)
217
+ * @param {number} cardWidth 卡片宽(CSS px)
218
+ * @param {{x:number,y:number,width:number,height:number}} bboxCardRel 壳 bbox(卡内相对坐标)
219
+ * @param {string} specColorHex 标注颜色(#RRGGBB)
220
+ * @returns {string|null} 需覆盖时返回采样色 #RRGGBB,否则 null
221
+ */
222
+ export function crossCheckTextColor(designPath, cardWidth, bboxCardRel, specColorHex) {
223
+ try {
224
+ if (!designPath || !fs.existsSync(designPath)) return null
225
+ const png = PNG.sync.read(fs.readFileSync(designPath))
226
+ const scale = png.width / Math.max(1, cardWidth)
227
+ if (!(scale > 0.5 && scale < 5)) return null
228
+ const { width: w, height: h, data } = png
229
+ const bx = (bboxCardRel.x || 0) * scale
230
+ const by = (bboxCardRel.y || 0) * scale
231
+ const bw = (bboxCardRel.width || 0) * scale
232
+ const bh = (bboxCardRel.height || 0) * scale
233
+ if (bw < 8 || bh < 6) return null
234
+ const x0 = Math.max(0, Math.round(bx + bw * 0.15))
235
+ const x1 = Math.min(w - 1, Math.round(bx + bw * 0.85))
236
+ const y0 = Math.max(0, Math.round(by + bh * 0.15))
237
+ const y1 = Math.min(h - 1, Math.round(by + bh * 0.85))
238
+ if (x1 - x0 < 4 || y1 - y0 < 4) return null
239
+ const rs = [], gs = [], bs = []
240
+ for (let y = y0; y <= y1; y++) {
241
+ for (let x = x0; x <= x1; x++) {
242
+ const i = (y * w + x) * 4
243
+ rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
244
+ }
245
+ }
246
+ const bg = [median(rs), median(gs), median(bs)]
247
+ const dist = (r, g, b, t) => (Math.abs(r - t[0]) + Math.abs(g - t[1]) + Math.abs(b - t[2])) / 3
248
+ const glyphs = []
249
+ for (let k = 0; k < rs.length; k++) {
250
+ if (dist(rs[k], gs[k], bs[k], bg) > 40) {
251
+ glyphs.push([rs[k], gs[k], bs[k], rs[k] * 0.299 + gs[k] * 0.587 + bs[k] * 0.114])
252
+ }
253
+ }
254
+ if (glyphs.length < 30) return null
255
+ glyphs.sort((a, b) => a[3] - b[3])
256
+ const core = glyphs.slice(0, Math.max(10, Math.floor(glyphs.length * 0.25)))
257
+ const sampled = [
258
+ median(core.map((c) => c[0])),
259
+ median(core.map((c) => c[1])),
260
+ median(core.map((c) => c[2])),
261
+ ]
262
+ const m = String(specColorHex || '').replace('#', '')
263
+ if (!/^[0-9a-fA-F]{6}$/.test(m)) return toHex(...sampled)
264
+ const spec = [parseInt(m.slice(0, 2), 16), parseInt(m.slice(2, 4), 16), parseInt(m.slice(4, 6), 16)]
265
+ if (dist(sampled[0], sampled[1], sampled[2], spec) > 28) return toHex(...sampled)
266
+ return null
267
+ } catch {
268
+ return null
269
+ }
270
+ }
271
+
272
+ /**
273
+ * 描边环采样(标注缺陷治理,视觉优先):
274
+ * 壳标注无 fill 无 border("隐形"壳),但设计稿可见描边时(如线框 pill 按钮),
275
+ * 从设计稿采样壳边缘环带颜色,判定是否存在描边并取其颜色。
276
+ *
277
+ * 算法(纯几何 + 统计):
278
+ * 1. 壳 bbox 四边各取中段 30%-70%(避开圆角);
279
+ * 2. 环带 = bbox 边缘向内 max(1, round(1.6×scale)) px 的窄带;
280
+ * 3. 内部参照色 = 中央 30% 区域中位色;
281
+ * 4. 环带亮度最低 40% 像素的中位色与内部参照色距离 >18 → 存在描边,返回其颜色。
282
+ *
283
+ * @param {string} designPath 设计稿聚焦图路径
284
+ * @param {number} cardWidth 卡片宽(CSS px)
285
+ * @param {{x:number,y:number,width:number,height:number}} bboxCardRel 壳 bbox(卡内相对坐标)
286
+ * @returns {string|null} 描边色 #RRGGBB;无描边/采样失败返回 null
287
+ */
288
+ export function sampleOutlineRing(designPath, cardWidth, bboxCardRel) {
289
+ try {
290
+ if (!designPath || !fs.existsSync(designPath)) return null
291
+ const png = PNG.sync.read(fs.readFileSync(designPath))
292
+ const scale = png.width / Math.max(1, cardWidth)
293
+ if (!(scale > 0.5 && scale < 5)) return null
294
+ const { width: w, height: h, data } = png
295
+ const bx = Math.round((bboxCardRel.x || 0) * scale)
296
+ const by = Math.round((bboxCardRel.y || 0) * scale)
297
+ const bw = Math.round((bboxCardRel.width || 0) * scale)
298
+ const bh = Math.round((bboxCardRel.height || 0) * scale)
299
+ if (bw < 10 || bh < 8) return null
300
+ const t = Math.max(1, Math.round(1.6 * scale))
301
+ const at = (x, y) => {
302
+ const i = (Math.min(h - 1, Math.max(0, y)) * w + Math.min(w - 1, Math.max(0, x))) * 4
303
+ return [data[i], data[i + 1], data[i + 2]]
304
+ }
305
+ const ring = []
306
+ const push = (x, y) => { const c = at(x, y); ring.push([c[0], c[1], c[2], c[0] * 0.299 + c[1] * 0.587 + c[2] * 0.114]) }
307
+ for (let x = bx + Math.round(bw * 0.3); x <= bx + Math.round(bw * 0.7); x++) {
308
+ for (let k = 0; k < t; k++) { push(x, by + k); push(x, by + bh - 1 - k) }
309
+ }
310
+ for (let y = by + Math.round(bh * 0.3); y <= by + Math.round(bh * 0.7); y++) {
311
+ for (let k = 0; k < t; k++) { push(bx + k, y); push(bx + bw - 1 - k, y) }
312
+ }
313
+ if (ring.length < 20) return null
314
+ // 内部参照色(中央 30% 区域中位色)
315
+ const ir = [], ig = [], ib = []
316
+ for (let y = by + Math.round(bh * 0.35); y <= by + Math.round(bh * 0.65); y += 2) {
317
+ for (let x = bx + Math.round(bw * 0.35); x <= bx + Math.round(bw * 0.65); x += 2) {
318
+ const c = at(x, y); ir.push(c[0]); ig.push(c[1]); ib.push(c[2])
319
+ }
320
+ }
321
+ const interior = [median(ir), median(ig), median(ib)]
322
+ ring.sort((a, b) => a[3] - b[3])
323
+ const core = ring.slice(0, Math.max(8, Math.floor(ring.length * 0.4)))
324
+ const ringColor = [
325
+ median(core.map((c) => c[0])),
326
+ median(core.map((c) => c[1])),
327
+ median(core.map((c) => c[2])),
328
+ ]
329
+ const dist = (Math.abs(ringColor[0] - interior[0])
330
+ + Math.abs(ringColor[1] - interior[1])
331
+ + Math.abs(ringColor[2] - interior[2])) / 3
332
+ return dist > 18 ? toHex(...ringColor) : null
333
+ } catch {
334
+ return null
335
+ }
336
+ }
337
+
338
+
339
+ /**
340
+ * 分区底色带采样:设计稿卡体「上灰下白」等 flat 分区底色,按左缘逐行取色分段。
341
+ * 仅采纳行内均匀(flat 填充)且高度 >=24 的带;渐变/弥散背景行色逐行变化 → 自动排除。
342
+ * @returns {{ y0: number, y1: number, color: string }[] | null} 卡坐标 px 带列表
343
+ */
344
+ export function sampleBackdropBands(designPath, cardWidth) {
345
+ try {
346
+ if (!designPath || !fs.existsSync(designPath)) return null
347
+ const png = PNG.sync.read(fs.readFileSync(designPath))
348
+ const scale = png.width / Math.max(1, cardWidth)
349
+ if (!(scale > 0.5 && scale < 5)) return null
350
+ const { width: w, height: h, data } = png
351
+ const dist = (a, b) => (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3
352
+ const rowColor = (py) => {
353
+ const rs = [], gs = [], bs = []
354
+ for (const x of [1, 2, 3]) {
355
+ const i = (py * w + x) * 4
356
+ rs.push(data[i]); gs.push(data[i + 1]); bs.push(data[i + 2])
357
+ }
358
+ return [median(rs), median(gs), median(bs)]
359
+ }
360
+ const cardH = Math.round(h / scale)
361
+ const raw = []
362
+ let cur = null
363
+ for (let cy = 0; cy < cardH; cy++) {
364
+ const c = rowColor(Math.min(h - 1, Math.round((cy + 0.5) * scale)))
365
+ if (!cur || dist(cur.color, c) > 4) {
366
+ if (cur) raw.push(cur)
367
+ cur = { y0: cy, y1: cy + 1, color: c, rows: [c] }
368
+ } else {
369
+ cur.y1 = cy + 1
370
+ cur.rows.push(c)
371
+ }
372
+ }
373
+ if (cur) raw.push(cur)
374
+ const out = []
375
+ for (const b of raw) {
376
+ if (b.y1 - b.y0 < 24) continue
377
+ if (!b.rows.every((r) => dist(r, b.color) <= 4)) continue
378
+ const med = [0, 1, 2].map((ci) => median(b.rows.map((r) => r[ci])))
379
+ const hex = toHex(med[0], med[1], med[2])
380
+ const last = out[out.length - 1]
381
+ if (last && last.color === hex) last.y1 = b.y1
382
+ else out.push({ y0: b.y0, y1: b.y1, color: hex })
383
+ }
384
+ return out.length >= 2 ? out : null
385
+ } catch {
386
+ return null
387
+ }
388
+ }