dsh-visualkit 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/diff.js CHANGED
@@ -1,153 +1,153 @@
1
- /**
2
- * diff.js — 两张图的像素差异:整体差距 + 分区差距 + 热力图。
3
- *
4
- * **为什么值得做成工具**:视觉工作最难的不是“改”,而是“改完到底好了没有”。
5
- * 靠眼睛看只能判断“像不像”,没法回答“这次改动是提升还是回退”。
6
- * 实测动机:视频站动态页参照实现迭代时,每一轮都要手写一段 node 脚本算差异——
7
- * 同一个需求写第三遍,就该进工具了。
8
- *
9
- * 输出三件东西:
10
- * 1. 均像素差(0-255)—— 一个数字,能横比多轮迭代;
11
- * 2. 分区差—— 告诉你差异集中在哪,而不是只能看整图;
12
- * 3. 热力图 PNG—— 哪里差一眼看到,不用猜。
13
- */
14
- import { readFileSync } from "node:fs";
15
-
16
- import { decodePng } from "./layouts/png.js";
17
- import { renderSvg } from "./render.js";
18
-
19
- export async function imageDiff({ base, compare, out, cols, rows, amplify, align }) {
20
-
21
- const a = decodePng(readFileSync(base));
22
- const b = decodePng(readFileSync(compare));
23
- if (a.width !== b.width || a.height !== b.height) {
24
- throw new Error(
25
- `两张图尺寸不同:${a.width}×${a.height} vs ${b.width}×${b.height}。\n` +
26
- "比对前先统一尺寸(可用 design_render action=render 的 width,或 composite 把图层放到同尺寸画布)。"
27
- );
28
- }
29
-
30
- const W = a.width;
31
- const H = a.height;
32
- const NCOL = Number.isFinite(cols) ? Math.min(12, Math.max(1, Math.round(cols))) : 4;
33
- const NROW = Number.isFinite(rows) ? Math.min(12, Math.max(1, Math.round(rows))) : 1;
34
- const gain = Number.isFinite(amplify) ? Math.min(4, Math.max(1, amplify)) : 2.0;
35
-
36
- const at = (img, x, y) => {
37
- const i = (y * img.width + x) * img.channels;
38
- return [img.data[i], img.data[i + 1], img.data[i + 2]];
39
- };
40
-
41
- const bS = b;
42
- const SAMPLE = 2; // 每 2px 采样:2400×1400 的图也只要 ~84 万次,快且足够
43
- let sum = 0;
44
- let n = 0;
45
- let max = 0;
46
- let changed = 0;
47
- const cells = Array.from({ length: NCOL * NROW }, () => ({ s: 0, c: 0 }));
48
- const rects = [];
49
-
50
- for (let y = 0; y < H; y += SAMPLE) {
51
- for (let x = 0; x < W; x += SAMPLE) {
52
- const p = at(a, x, y);
53
- const q = at(b, x, y);
54
- const d = (Math.abs(p[0] - q[0]) + Math.abs(p[1] - q[1]) + Math.abs(p[2] - q[2])) / 3;
55
- sum += d;
56
- n += 1;
57
- if (d > max) max = d;
58
- if (d > 24) changed += 1;
59
- const ci = Math.min(NCOL - 1, Math.floor((x / W) * NCOL));
60
- const ri = Math.min(NROW - 1, Math.floor((y / H) * NROW));
61
- const cell = cells[ri * NCOL + ci];
62
- cell.s += d;
63
- cell.c += 1;
64
- if (d > 24) {
65
- const g = Math.min(255, Math.round(d * gain * 1.6));
66
- rects.push(`<rect x="${x}" y="${y}" width="${SAMPLE}" height="${SAMPLE}" fill="rgb(${g},${Math.round(g * 0.24)},0)" opacity="0.85"/>`);
67
- }
68
- }
69
- }
70
-
71
- // 只保留一位小数:注意不要写成 round1(x)/10——除号会重新引入浮点噪声
72
- // (实测输出过 13.719999999999999%,把百分比直接四舍五入才干净)
73
- const round1 = (v) => Math.round(v * 10) / 10;
74
- const pct = (v) => Math.round(v * 10) / 10;
75
- const regions = cells.map((cell, i) => ({
76
- row: Math.floor(i / NCOL),
77
- col: i % NCOL,
78
- meanDelta: cell.c === 0 ? 0 : round1(cell.s / cell.c),
79
- share: cell.c === 0 ? 0 : pct(((cell.s / cell.c) / 255) * 100)
80
- }));
81
-
82
- const result = {
83
- width: W,
84
- height: H,
85
- meanDelta: round1(sum / n),
86
- meanPct: pct(((sum / n) / 255) * 100),
87
- maxDelta: round1(max),
88
- changedPct: pct((changed / n) * 100),
89
- cols: NCOL,
90
- rows: NROW,
91
- regions
92
- };
93
-
94
- /* 偏移搜索:重影的元凶往往是“整体平移了几像素”,而不是“哪里排错了”。
95
- * 实测:参照稿行距已经对齐到 48 vs 48,但热力图满是重影——文字每行出现两次。
96
- * 这不是排错了,是整块内容比原图高/低了几像素。不把这个相位报出来,
97
- * “差 13.6%”这个数字就只会让人到处乱改。 */
98
- if (align === true) {
99
- const STEP = 8;
100
- const sampleAt = (img, x, y) => {
101
- const i = (y * img.width + x) * img.channels;
102
- return [img.data[i], img.data[i + 1], img.data[i + 2]];
103
- };
104
- const cost = (dx, dy) => {
105
- let acc = 0;
106
- let cnt = 0;
107
- for (let y = 0; y < H; y += STEP) {
108
- const sy = y + dy;
109
- if (sy < 0 || sy >= H) continue;
110
- for (let x = 0; x < W; x += STEP) {
111
- const sx = x + dx;
112
- if (sx < 0 || sx >= W) continue;
113
- const p1 = sampleAt(a, x, y);
114
- const p2 = sampleAt(bS, sx, sy);
115
- acc += (Math.abs(p1[0] - p2[0]) + Math.abs(p1[1] - p2[1]) + Math.abs(p1[2] - p2[2])) / 3;
116
- cnt += 1;
117
- }
118
- }
119
- return cnt === 0 ? Infinity : acc / cnt;
120
- };
121
- let best = { dx: 0, dy: 0, delta: cost(0, 0) };
122
- for (let dy = -12; dy <= 12; dy += 2) {
123
- for (let dx = -6; dx <= 6; dx += 2) {
124
- if (dx === 0 && dy === 0) continue;
125
- const d = cost(dx, dy);
126
- if (d < best.delta) best = { dx, dy, delta: d };
127
- }
128
- }
129
- result.align = {
130
- bestDx: best.dx,
131
- bestDy: best.dy,
132
- deltaAtBest: round1(best.delta),
133
- deltaAtZero: round1(cost(0, 0)),
134
- // 提升幅度:越大说明“错位”越是主因
135
- gainPct: round1(((cost(0, 0) - best.delta) / Math.max(1, cost(0, 0))) * 100)
136
- };
137
- }
138
-
139
- // 热力图(保留原图轮廓作底,叠差异块)——只有给了 out 才写,没给就只返回数字
140
- if (typeof out === "string" && out.trim() !== "") {
141
- const b64 = readFileSync(base).toString("base64");
142
- const svg =
143
- `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">` +
144
- `<image href="data:image/png;base64,${b64}" width="${W}" height="${H}" opacity="0.28"/>` +
145
- `<rect width="${W}" height="${H}" fill="#05070A" opacity="0.55"/>` +
146
- rects.join("") +
147
- `</svg>`;
148
- const written = await renderSvg({ svg, png: out, width: W });
149
- result.heatmap = written.path;
150
- result.heatmapBytes = written.bytes;
151
- }
152
- return result;
153
- }
1
+ /**
2
+ * diff.js — 两张图的像素差异:整体差距 + 分区差距 + 热力图。
3
+ *
4
+ * **为什么值得做成工具**:视觉工作最难的不是“改”,而是“改完到底好了没有”。
5
+ * 靠眼睛看只能判断“像不像”,没法回答“这次改动是提升还是回退”。
6
+ * 实测动机:视频站动态页参照实现迭代时,每一轮都要手写一段 node 脚本算差异——
7
+ * 同一个需求写第三遍,就该进工具了。
8
+ *
9
+ * 输出三件东西:
10
+ * 1. 均像素差(0-255)—— 一个数字,能横比多轮迭代;
11
+ * 2. 分区差—— 告诉你差异集中在哪,而不是只能看整图;
12
+ * 3. 热力图 PNG—— 哪里差一眼看到,不用猜。
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+
16
+ import { decodePng } from "./layouts/png.js";
17
+ import { renderSvg } from "./render.js";
18
+
19
+ export async function imageDiff({ base, compare, out, cols, rows, amplify, align }) {
20
+
21
+ const a = decodePng(readFileSync(base));
22
+ const b = decodePng(readFileSync(compare));
23
+ if (a.width !== b.width || a.height !== b.height) {
24
+ throw new Error(
25
+ `两张图尺寸不同:${a.width}×${a.height} vs ${b.width}×${b.height}。\n` +
26
+ "比对前先统一尺寸(可用 design_render action=render 的 width,或 composite 把图层放到同尺寸画布)。"
27
+ );
28
+ }
29
+
30
+ const W = a.width;
31
+ const H = a.height;
32
+ const NCOL = Number.isFinite(cols) ? Math.min(12, Math.max(1, Math.round(cols))) : 4;
33
+ const NROW = Number.isFinite(rows) ? Math.min(12, Math.max(1, Math.round(rows))) : 1;
34
+ const gain = Number.isFinite(amplify) ? Math.min(4, Math.max(1, amplify)) : 2.0;
35
+
36
+ const at = (img, x, y) => {
37
+ const i = (y * img.width + x) * img.channels;
38
+ return [img.data[i], img.data[i + 1], img.data[i + 2]];
39
+ };
40
+
41
+ const bS = b;
42
+ const SAMPLE = 2; // 每 2px 采样:2400×1400 的图也只要 ~84 万次,快且足够
43
+ let sum = 0;
44
+ let n = 0;
45
+ let max = 0;
46
+ let changed = 0;
47
+ const cells = Array.from({ length: NCOL * NROW }, () => ({ s: 0, c: 0 }));
48
+ const rects = [];
49
+
50
+ for (let y = 0; y < H; y += SAMPLE) {
51
+ for (let x = 0; x < W; x += SAMPLE) {
52
+ const p = at(a, x, y);
53
+ const q = at(b, x, y);
54
+ const d = (Math.abs(p[0] - q[0]) + Math.abs(p[1] - q[1]) + Math.abs(p[2] - q[2])) / 3;
55
+ sum += d;
56
+ n += 1;
57
+ if (d > max) max = d;
58
+ if (d > 24) changed += 1;
59
+ const ci = Math.min(NCOL - 1, Math.floor((x / W) * NCOL));
60
+ const ri = Math.min(NROW - 1, Math.floor((y / H) * NROW));
61
+ const cell = cells[ri * NCOL + ci];
62
+ cell.s += d;
63
+ cell.c += 1;
64
+ if (d > 24) {
65
+ const g = Math.min(255, Math.round(d * gain * 1.6));
66
+ rects.push(`<rect x="${x}" y="${y}" width="${SAMPLE}" height="${SAMPLE}" fill="rgb(${g},${Math.round(g * 0.24)},0)" opacity="0.85"/>`);
67
+ }
68
+ }
69
+ }
70
+
71
+ // 只保留一位小数:注意不要写成 round1(x)/10——除号会重新引入浮点噪声
72
+ // (实测输出过 13.719999999999999%,把百分比直接四舍五入才干净)
73
+ const round1 = (v) => Math.round(v * 10) / 10;
74
+ const pct = (v) => Math.round(v * 10) / 10;
75
+ const regions = cells.map((cell, i) => ({
76
+ row: Math.floor(i / NCOL),
77
+ col: i % NCOL,
78
+ meanDelta: cell.c === 0 ? 0 : round1(cell.s / cell.c),
79
+ share: cell.c === 0 ? 0 : pct(((cell.s / cell.c) / 255) * 100)
80
+ }));
81
+
82
+ const result = {
83
+ width: W,
84
+ height: H,
85
+ meanDelta: round1(sum / n),
86
+ meanPct: pct(((sum / n) / 255) * 100),
87
+ maxDelta: round1(max),
88
+ changedPct: pct((changed / n) * 100),
89
+ cols: NCOL,
90
+ rows: NROW,
91
+ regions
92
+ };
93
+
94
+ /* 偏移搜索:重影的元凶往往是“整体平移了几像素”,而不是“哪里排错了”。
95
+ * 实测:参照稿行距已经对齐到 48 vs 48,但热力图满是重影——文字每行出现两次。
96
+ * 这不是排错了,是整块内容比原图高/低了几像素。不把这个相位报出来,
97
+ * “差 13.6%”这个数字就只会让人到处乱改。 */
98
+ if (align === true) {
99
+ const STEP = 8;
100
+ const sampleAt = (img, x, y) => {
101
+ const i = (y * img.width + x) * img.channels;
102
+ return [img.data[i], img.data[i + 1], img.data[i + 2]];
103
+ };
104
+ const cost = (dx, dy) => {
105
+ let acc = 0;
106
+ let cnt = 0;
107
+ for (let y = 0; y < H; y += STEP) {
108
+ const sy = y + dy;
109
+ if (sy < 0 || sy >= H) continue;
110
+ for (let x = 0; x < W; x += STEP) {
111
+ const sx = x + dx;
112
+ if (sx < 0 || sx >= W) continue;
113
+ const p1 = sampleAt(a, x, y);
114
+ const p2 = sampleAt(bS, sx, sy);
115
+ acc += (Math.abs(p1[0] - p2[0]) + Math.abs(p1[1] - p2[1]) + Math.abs(p1[2] - p2[2])) / 3;
116
+ cnt += 1;
117
+ }
118
+ }
119
+ return cnt === 0 ? Infinity : acc / cnt;
120
+ };
121
+ let best = { dx: 0, dy: 0, delta: cost(0, 0) };
122
+ for (let dy = -12; dy <= 12; dy += 2) {
123
+ for (let dx = -6; dx <= 6; dx += 2) {
124
+ if (dx === 0 && dy === 0) continue;
125
+ const d = cost(dx, dy);
126
+ if (d < best.delta) best = { dx, dy, delta: d };
127
+ }
128
+ }
129
+ result.align = {
130
+ bestDx: best.dx,
131
+ bestDy: best.dy,
132
+ deltaAtBest: round1(best.delta),
133
+ deltaAtZero: round1(cost(0, 0)),
134
+ // 提升幅度:越大说明“错位”越是主因
135
+ gainPct: round1(((cost(0, 0) - best.delta) / Math.max(1, cost(0, 0))) * 100)
136
+ };
137
+ }
138
+
139
+ // 热力图(保留原图轮廓作底,叠差异块)——只有给了 out 才写,没给就只返回数字
140
+ if (typeof out === "string" && out.trim() !== "") {
141
+ const b64 = readFileSync(base).toString("base64");
142
+ const svg =
143
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">` +
144
+ `<image href="data:image/png;base64,${b64}" width="${W}" height="${H}" opacity="0.28"/>` +
145
+ `<rect width="${W}" height="${H}" fill="#05070A" opacity="0.55"/>` +
146
+ rects.join("") +
147
+ `</svg>`;
148
+ const written = await renderSvg({ svg, png: out, width: W });
149
+ result.heatmap = written.path;
150
+ result.heatmapBytes = written.bytes;
151
+ }
152
+ return result;
153
+ }
@@ -1,107 +1,107 @@
1
- /**
2
- * icon-render.js — 图标视觉语言的**正交维度合成器**。
3
- *
4
- * 为什么不靠 style 枚举(旧架构的局限):
5
- * 9 个 style 是 9 条写死的配方,彼此互斥(badge 就不能同时是 gradient)。
6
- * 那是模板,不是工具——用户想要「圆角方容器 + 细描边 + 双色渐变字形 + 平头线端」时,
7
- * 没有任何一个 style 能表达。
8
- *
9
- * 现在把视觉语言拆成 6 个正交维,任意组合:
10
- *
11
- * ① paint stroke | fill | both 绘法(填充+描边可共存)
12
- * ② weight 0–64 描边粗细
13
- * ③ caps round | butt | square 线端(自动配对 join)
14
- * ④ container none | rounded | circle | squircle
15
- * + radius 0–12 容器形状与圆角(连续可调)
16
- * + inset 0–0.45 字形在容器里的留白
17
- * ⑤ gradient none | linear | radial
18
- * + gradientAngle 0–360 渐变类型与方向
19
- * ⑥ duo none | 0–1 双色底层不透明度(需要填充孪生体)
20
- *
21
- * style 仍然保留,但退化为「一组维度值的快捷命名」(预设)。
22
- * 显式传入的维度参数会逐个覆盖预设——所以总是能调到底。
23
- */
24
-
25
- /** 9 个预设 → 维度值。仅作为默认起点,任何一项都能被显式参数覆盖。 */
26
- export const PRESETS = {
27
- outline: { paint: "stroke", weight: 2 },
28
- hairline: { paint: "stroke", weight: 1 },
29
- bold: { paint: "stroke", weight: 2.8 },
30
- sharp: { paint: "stroke", weight: 2, caps: "butt" },
31
- solid: { paint: "fill", needsTwin: true },
32
- duotone: { paint: "stroke", weight: 2, duo: 0.2, needsTwin: true },
33
- badge: { paint: "stroke", weight: 1.8, container: "rounded", radius: 7.2, inset: 0.24 },
34
- knockout: { paint: "stroke", weight: 1.8, container: "circle", inset: 0.24 },
35
- gradient: { paint: "stroke", weight: 2.2, gradient: "linear", gradientAngle: 45 }
36
- };
37
-
38
- /** caps → [linecap, linejoin]。平头线端配斜接转角才不会在转折处出小缺口。 */
39
- export const CAP_TO_JOIN = {
40
- round: ["round", "round"],
41
- butt: ["butt", "miter"],
42
- square: ["square", "miter"]
43
- };
44
-
45
- /** 容器型:把字形装进底色里。none 返回 null。 */
46
- export const CONTAINERS = ["none", "rounded", "circle", "squircle"];
47
-
48
- export const DIMENSION_LIMITS = {
49
- weight: [0, 64],
50
- radius: [0, 12],
51
- inset: [0, 0.45],
52
- gradientAngle: [0, 360],
53
- duo: [0, 1]
54
- };
55
-
56
- /**
57
- * 解析渐变停靠点:`color` 或 `color@offset`(offset 为 0–1,对齐 SVG 的 offset)。
58
- *
59
- * 为什么必须是**可扩展的数组而不是内置几种渐变**:
60
- * 彩虹、多段金属、双色环、条纹…全都只是「停下来描多个颜色」;
61
- * 内置 `rainbow`/`metal` 这种名字就把用户锁死了。
62
- *
63
- * 未写 offset 的停靠点均匀分布(首尾固定 0 与 1)。
64
- * 颜色只做**注入安全校验**(不允许引号/尖括号),不强制 hex——
65
- * hex 只在「不传 stops、由 color2 自动推末色」时才必需。
66
- */
67
- const COLOR_SAFE_RE = /^[#a-zA-Z0-9(),.%\s/+-]+$/;
68
-
69
- export function parseGradientStops(spec, fallback = []) {
70
- if (!Array.isArray(spec) || spec.length === 0) return null;
71
- const parsed = [];
72
- for (const raw of spec) {
73
- if (typeof raw !== "string") return null;
74
- const text = raw.trim();
75
- if (text === "") return null;
76
- const at = text.lastIndexOf("@");
77
- let color = text;
78
- let offset = null;
79
- if (at !== -1) {
80
- const num = Number(text.slice(at + 1));
81
- if (!Number.isFinite(num) || num < 0 || num > 1) return null;
82
- offset = num;
83
- color = text.slice(0, at).trim();
84
- }
85
- if (color === "" || !COLOR_SAFE_RE.test(color)) return null;
86
- parsed.push({ color, offset });
87
- }
88
- if (parsed.length === 1) parsed.push({ color: parsed[0].color, offset: 1 });
89
- // 未写的 offset 按位置均匀分布;已写的保留(允许渐变锐变)
90
- const n = parsed.length;
91
- return parsed.map((stop, i) => ({
92
- color: stop.color,
93
- offset: stop.offset !== null ? stop.offset : i === 0 ? 0 : i === n - 1 ? 1 : Math.round((i / (n - 1)) * 1000) / 1000
94
- }));
95
- }
96
-
97
- // 曾经的 SPREADS(spreadMethod: repeat/reflect)已移除:
98
- // 实测 resvg 里它**零效果**——我们的渐变向量总是铺满整个 24 网格盒子,
99
- // 一次循环就覆盖全部,pad/repeat/reflect 的采样值完全相同。
100
- // 条纹用「多停靠点 + 同偏移锐变」实现更精确(可控条纹数与位置)。
101
-
102
- /** 把可能越界的数值夹到合法区间;非数字返回 undefined(让调用方回落到预设)。 */
103
- export function clampDim(name, value) {
104
- if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
105
- const [lo, hi] = DIMENSION_LIMITS[name] ?? [-Infinity, Infinity];
106
- return Math.min(hi, Math.max(lo, value));
107
- }
1
+ /**
2
+ * icon-render.js — 图标视觉语言的**正交维度合成器**。
3
+ *
4
+ * 为什么不靠 style 枚举(旧架构的局限):
5
+ * 9 个 style 是 9 条写死的配方,彼此互斥(badge 就不能同时是 gradient)。
6
+ * 那是模板,不是工具——用户想要「圆角方容器 + 细描边 + 双色渐变字形 + 平头线端」时,
7
+ * 没有任何一个 style 能表达。
8
+ *
9
+ * 现在把视觉语言拆成 6 个正交维,任意组合:
10
+ *
11
+ * ① paint stroke | fill | both 绘法(填充+描边可共存)
12
+ * ② weight 0–64 描边粗细
13
+ * ③ caps round | butt | square 线端(自动配对 join)
14
+ * ④ container none | rounded | circle | squircle
15
+ * + radius 0–12 容器形状与圆角(连续可调)
16
+ * + inset 0–0.45 字形在容器里的留白
17
+ * ⑤ gradient none | linear | radial
18
+ * + gradientAngle 0–360 渐变类型与方向
19
+ * ⑥ duo none | 0–1 双色底层不透明度(需要填充孪生体)
20
+ *
21
+ * style 仍然保留,但退化为「一组维度值的快捷命名」(预设)。
22
+ * 显式传入的维度参数会逐个覆盖预设——所以总是能调到底。
23
+ */
24
+
25
+ /** 9 个预设 → 维度值。仅作为默认起点,任何一项都能被显式参数覆盖。 */
26
+ export const PRESETS = {
27
+ outline: { paint: "stroke", weight: 2 },
28
+ hairline: { paint: "stroke", weight: 1 },
29
+ bold: { paint: "stroke", weight: 2.8 },
30
+ sharp: { paint: "stroke", weight: 2, caps: "butt" },
31
+ solid: { paint: "fill", needsTwin: true },
32
+ duotone: { paint: "stroke", weight: 2, duo: 0.2, needsTwin: true },
33
+ badge: { paint: "stroke", weight: 1.8, container: "rounded", radius: 7.2, inset: 0.24 },
34
+ knockout: { paint: "stroke", weight: 1.8, container: "circle", inset: 0.24 },
35
+ gradient: { paint: "stroke", weight: 2.2, gradient: "linear", gradientAngle: 45 }
36
+ };
37
+
38
+ /** caps → [linecap, linejoin]。平头线端配斜接转角才不会在转折处出小缺口。 */
39
+ export const CAP_TO_JOIN = {
40
+ round: ["round", "round"],
41
+ butt: ["butt", "miter"],
42
+ square: ["square", "miter"]
43
+ };
44
+
45
+ /** 容器型:把字形装进底色里。none 返回 null。 */
46
+ export const CONTAINERS = ["none", "rounded", "circle", "squircle"];
47
+
48
+ export const DIMENSION_LIMITS = {
49
+ weight: [0, 64],
50
+ radius: [0, 12],
51
+ inset: [0, 0.45],
52
+ gradientAngle: [0, 360],
53
+ duo: [0, 1]
54
+ };
55
+
56
+ /**
57
+ * 解析渐变停靠点:`color` 或 `color@offset`(offset 为 0–1,对齐 SVG 的 offset)。
58
+ *
59
+ * 为什么必须是**可扩展的数组而不是内置几种渐变**:
60
+ * 彩虹、多段金属、双色环、条纹…全都只是「停下来描多个颜色」;
61
+ * 内置 `rainbow`/`metal` 这种名字就把用户锁死了。
62
+ *
63
+ * 未写 offset 的停靠点均匀分布(首尾固定 0 与 1)。
64
+ * 颜色只做**注入安全校验**(不允许引号/尖括号),不强制 hex——
65
+ * hex 只在「不传 stops、由 color2 自动推末色」时才必需。
66
+ */
67
+ const COLOR_SAFE_RE = /^[#a-zA-Z0-9(),.%\s/+-]+$/;
68
+
69
+ export function parseGradientStops(spec, fallback = []) {
70
+ if (!Array.isArray(spec) || spec.length === 0) return null;
71
+ const parsed = [];
72
+ for (const raw of spec) {
73
+ if (typeof raw !== "string") return null;
74
+ const text = raw.trim();
75
+ if (text === "") return null;
76
+ const at = text.lastIndexOf("@");
77
+ let color = text;
78
+ let offset = null;
79
+ if (at !== -1) {
80
+ const num = Number(text.slice(at + 1));
81
+ if (!Number.isFinite(num) || num < 0 || num > 1) return null;
82
+ offset = num;
83
+ color = text.slice(0, at).trim();
84
+ }
85
+ if (color === "" || !COLOR_SAFE_RE.test(color)) return null;
86
+ parsed.push({ color, offset });
87
+ }
88
+ if (parsed.length === 1) parsed.push({ color: parsed[0].color, offset: 1 });
89
+ // 未写的 offset 按位置均匀分布;已写的保留(允许渐变锐变)
90
+ const n = parsed.length;
91
+ return parsed.map((stop, i) => ({
92
+ color: stop.color,
93
+ offset: stop.offset !== null ? stop.offset : i === 0 ? 0 : i === n - 1 ? 1 : Math.round((i / (n - 1)) * 1000) / 1000
94
+ }));
95
+ }
96
+
97
+ // 曾经的 SPREADS(spreadMethod: repeat/reflect)已移除:
98
+ // 实测 resvg 里它**零效果**——我们的渐变向量总是铺满整个 24 网格盒子,
99
+ // 一次循环就覆盖全部,pad/repeat/reflect 的采样值完全相同。
100
+ // 条纹用「多停靠点 + 同偏移锐变」实现更精确(可控条纹数与位置)。
101
+
102
+ /** 把可能越界的数值夹到合法区间;非数字返回 undefined(让调用方回落到预设)。 */
103
+ export function clampDim(name, value) {
104
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
105
+ const [lo, hi] = DIMENSION_LIMITS[name] ?? [-Infinity, Infinity];
106
+ return Math.min(hi, Math.max(lo, value));
107
+ }