@paircode/tool-art 0.1.0

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 (2) hide show
  1. package/index.js +2129 -0
  2. package/package.json +23 -0
package/index.js ADDED
@@ -0,0 +1,2129 @@
1
+ // tool-art — 矢量/图像创作工具面(纯 goja 零依赖)
2
+ //
3
+ // 设计原则(对齐《创作域支持方案》§195「真相源必须文本」):
4
+ // · **真相源 = 画板工程 JSON**(art.project.json:画板 / 图层 / 图元),全部可 diff;
5
+ // · **SVG 是唯一文本产物**(零依赖生成、可回读 → V4 往返契约);
6
+ // PNG 光栅化属「服务端导出链」阶段(Node 桥 @resvg/resvg-js,MPL-2.0),
7
+ // 本插件不含二进制渲染 —— 面板提供浏览器侧 PNG 导出(canvas 绘制 SVG);
8
+ // · 图元几何一律**确定性数值**(最多 3 位小数、固定输出顺序、固定属性顺序)→ 位级可复现(V5)。
9
+ //
10
+ // 本插件是磁盘 goja 轨插件:沙箱内没有 require / Buffer / Node API,一律走 ctx 服务(ctx.fs)。
11
+ // 沙箱没有 canvas,无法测量字体 → 文本宽度用确定性估算模型(见 textWidth),仅供几何检查使用。
12
+ //
13
+ // 图元模型(扁平字段,便于 diff):
14
+ // { id, type, layer, z, name?, ...几何, fill, stroke, strokeWidth, opacity, transform? }
15
+ // · 几何字段按类型取用:rect(x,y,w,h) circle(cx,cy,r) ellipse(cx,cy,rx,ry)
16
+ // line(x1,y1,x2,y2) polyline/polygon(points[[x,y],…]) path(d) text(x,y,text,fontSize,fontWeight,anchor)
17
+ // · transform 用 2D 仿射矩阵 [a,b,c,d,e,f](仅非恒等时写入;导出为 matrix(...),
18
+ // 与 SVG 语义完全等价 → 导入任意 transform 列表都能无损往返)
19
+
20
+ // ── 常量 ───────────────────────────────────────────────────
21
+ var SCHEMA = 'paircode.art/1';
22
+
23
+ var SHAPE_TYPES = ['rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'path', 'text'];
24
+
25
+ // 默认色板 —— 全部取自 Tailwind CSS 官方色板(公开标准值,非随机生成):
26
+ // 中性灰阶 6 档 + 语义色 4 档(info / success / warning / error)。
27
+ // 刻意避开"AI 味"的高饱和紫蓝渐变;需要的色请从既有品牌/设计系统取值。
28
+ var PALETTE_DEFAULT = [
29
+ '#FFFFFF', // White
30
+ '#F9FAFB', // Gray 50
31
+ '#E5E7EB', // Gray 200
32
+ '#9CA3AF', // Gray 400
33
+ '#4B5563', // Gray 600
34
+ '#111827', // Gray 900
35
+ '#2563EB', // Blue 600 (info / 主色)
36
+ '#059669', // Emerald 600 (success)
37
+ '#F59E0B', // Amber 500 (warning)
38
+ '#EF4444', // Red 500 (error)
39
+ ];
40
+
41
+ // CSS 命名色子集(HTML4 基础色 + 常用):解析用,避免全表 148 项膨胀
42
+ var NAMED_COLORS = {
43
+ black: '#000000', silver: '#C0C0C0', gray: '#808080', grey: '#808080', white: '#FFFFFF',
44
+ maroon: '#800000', red: '#FF0000', purple: '#800080', fuchsia: '#FF00FF', magenta: '#FF00FF',
45
+ green: '#008000', lime: '#00FF00', olive: '#808000', yellow: '#FFFF00', navy: '#000080',
46
+ blue: '#0000FF', teal: '#008080', aqua: '#00FFFF', cyan: '#00FFFF', orange: '#FFA500',
47
+ };
48
+
49
+ var LIMIT_CANVAS = 8192; // 画板最大边长(覆盖 4K+ 素材,防误建超大画布)
50
+ var LIMIT_SHAPES = 5000; // 图元数量上限(超出视为工程异常)
51
+ var EPS = 1e-6;
52
+
53
+ // ── 基础工具 ───────────────────────────────────────────────
54
+ function isNum(v) { return typeof v === 'number' && isFinite(v); }
55
+ function isInt(v) { return isNum(v) && Math.floor(v) === v; }
56
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
57
+ function nowIso() { return new Date().toISOString(); }
58
+ function num(v, def) {
59
+ var n = typeof v === 'number' ? v : parseFloat(v);
60
+ return isNum(n) ? n : def;
61
+ }
62
+
63
+ // 数字 → 字符串:最多 3 位小数、去掉尾随 0(保证 SVG 产物位级可复现)
64
+ function fmtNum(n) {
65
+ if (!isNum(n)) return '0';
66
+ return String(Math.round(n * 1000) / 1000);
67
+ }
68
+
69
+ function xmlEscape(s) {
70
+ return String(s === undefined || s === null ? '' : s)
71
+ .replace(/&/g, '&amp;')
72
+ .replace(/</g, '&lt;')
73
+ .replace(/>/g, '&gt;')
74
+ .replace(/"/g, '&quot;')
75
+ .replace(/'/g, '&apos;');
76
+ }
77
+
78
+ function xmlUnescape(s) {
79
+ return String(s === undefined || s === null ? '' : s)
80
+ .replace(/&lt;/g, '<')
81
+ .replace(/&gt;/g, '>')
82
+ .replace(/&quot;/g, '"')
83
+ .replace(/&apos;/g, "'")
84
+ .replace(/&#(\d+);/g, function (_, d) { return String.fromCharCode(parseInt(d, 10)); })
85
+ .replace(/&amp;/g, '&');
86
+ }
87
+
88
+ // 取对象自有键(固定顺序:插入序 —— 由构造顺序决定,保证导出可复现)
89
+ function keysOf(o) {
90
+ var out = [];
91
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) out.push(k);
92
+ return out;
93
+ }
94
+
95
+ // ── 颜色:解析 / 序列化 / WCAG 对比度 ──────────────────────
96
+ // 返回 { r, g, b (0..255), a (0..1), none: bool, current: bool };不可解析返回 null
97
+ function parseColor(input) {
98
+ if (input === undefined || input === null) return null;
99
+ var v = String(input).trim().toLowerCase();
100
+ if (v === '') return null;
101
+ if (v === 'none') return { r: 0, g: 0, b: 0, a: 0, none: true, current: false };
102
+ if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0, none: false, current: false };
103
+ if (v === 'currentcolor') return { r: 0, g: 0, b: 0, a: 1, none: false, current: true };
104
+ if (v.charAt(0) === '#') {
105
+ var hex = v.slice(1);
106
+ if (!/^[0-9a-f]+$/.test(hex)) return null;
107
+ if (hex.length === 3 || hex.length === 4) {
108
+ var r3 = parseInt(hex.charAt(0) + hex.charAt(0), 16);
109
+ var g3 = parseInt(hex.charAt(1) + hex.charAt(1), 16);
110
+ var b3 = parseInt(hex.charAt(2) + hex.charAt(2), 16);
111
+ var a3 = hex.length === 4 ? parseInt(hex.charAt(3) + hex.charAt(3), 16) / 255 : 1;
112
+ return { r: r3, g: g3, b: b3, a: a3, none: false, current: false };
113
+ }
114
+ if (hex.length === 6 || hex.length === 8) {
115
+ var r6 = parseInt(hex.slice(0, 2), 16);
116
+ var g6 = parseInt(hex.slice(2, 4), 16);
117
+ var b6 = parseInt(hex.slice(4, 6), 16);
118
+ var a6 = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
119
+ return { r: r6, g: g6, b: b6, a: a6, none: false, current: false };
120
+ }
121
+ return null;
122
+ }
123
+ var m = /^rgba?\(([^)]+)\)$/.exec(v);
124
+ if (m) {
125
+ var parts = m[1].split(/[\s,\/]+/).filter(function (s) { return s !== ''; });
126
+ if (parts.length < 3) return null;
127
+ var chan = function (s) {
128
+ if (s.charAt(s.length - 1) === '%') return clamp(parseFloat(s) / 100 * 255, 0, 255);
129
+ return clamp(parseFloat(s), 0, 255);
130
+ };
131
+ var alpha = 1;
132
+ if (parts.length >= 4) {
133
+ alpha = parts[3].charAt(parts[3].length - 1) === '%'
134
+ ? clamp(parseFloat(parts[3]) / 100, 0, 1)
135
+ : clamp(parseFloat(parts[3]), 0, 1);
136
+ }
137
+ var cc = [chan(parts[0]), chan(parts[1]), chan(parts[2])];
138
+ if (!isNum(cc[0]) || !isNum(cc[1]) || !isNum(cc[2]) || !isNum(alpha)) return null;
139
+ return { r: cc[0], g: cc[1], b: cc[2], a: alpha, none: false, current: false };
140
+ }
141
+ if (Object.prototype.hasOwnProperty.call(NAMED_COLORS, v)) return parseColor(NAMED_COLORS[v]);
142
+ return null;
143
+ }
144
+
145
+ function colorToCss(c) {
146
+ if (!c) return 'none';
147
+ if (c.none) return 'none';
148
+ if (c.current) return 'currentColor';
149
+ if (c.a >= 1 - EPS) return 'rgb(' + Math.round(c.r) + ',' + Math.round(c.g) + ',' + Math.round(c.b) + ')';
150
+ return 'rgba(' + Math.round(c.r) + ',' + Math.round(c.g) + ',' + Math.round(c.b) + ',' + fmtNum(c.a) + ')';
151
+ }
152
+
153
+ // WCAG 2.1 相对亮度
154
+ function relLuminance(c) {
155
+ var f = function (v) {
156
+ var x = v / 255;
157
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
158
+ };
159
+ return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
160
+ }
161
+
162
+ // WCAG 2.1 对比度(1..21)
163
+ function contrastRatio(fg, bg) {
164
+ var a = relLuminance(fg);
165
+ var b = relLuminance(bg);
166
+ var hi = a > b ? a : b;
167
+ var lo = a > b ? b : a;
168
+ return (hi + 0.05) / (lo + 0.05);
169
+ }
170
+
171
+ // 半透明前景压在背景上的实际视觉色(对比度必须按合成后的颜色算)
172
+ function blendOver(top, bottom) {
173
+ if (!top) return bottom;
174
+ if (top.a >= 1 - EPS) return top;
175
+ var a = clamp(top.a, 0, 1);
176
+ return {
177
+ r: top.r * a + bottom.r * (1 - a),
178
+ g: top.g * a + bottom.g * (1 - a),
179
+ b: top.b * a + bottom.b * (1 - a),
180
+ a: 1, none: false, current: false,
181
+ };
182
+ }
183
+
184
+ // ── 2D 仿射矩阵 [a,b,c,d,e,f] ─────────────────────────────
185
+ var M_ID = [1, 0, 0, 1, 0, 0];
186
+
187
+ function mMul(m, n) { // 结果 = m·n(点先受 n 作用,再受 m 作用)
188
+ return [
189
+ m[0] * n[0] + m[2] * n[1],
190
+ m[1] * n[0] + m[3] * n[1],
191
+ m[0] * n[2] + m[2] * n[3],
192
+ m[1] * n[2] + m[3] * n[3],
193
+ m[0] * n[4] + m[2] * n[5] + m[4],
194
+ m[1] * n[4] + m[3] * n[5] + m[5],
195
+ ];
196
+ }
197
+
198
+ function mApply(m, x, y) {
199
+ return { x: m[0] * x + m[2] * y + m[4], y: m[1] * x + m[3] * y + m[5] };
200
+ }
201
+
202
+ function mIsIdentity(m) {
203
+ if (!m) return true;
204
+ for (var i = 0; i < 6; i++) {
205
+ var expect = (i === 0 || i === 3) ? 1 : 0;
206
+ if (Math.abs(num(m[i], expect) - expect) > EPS) return false;
207
+ }
208
+ return true;
209
+ }
210
+
211
+ function mNorm(m) { // 归一化:恒等 → null(工程里不写冗余字段)
212
+ if (!m) return null;
213
+ var arr = [];
214
+ for (var i = 0; i < 6; i++) arr.push(num(m[i], (i === 0 || i === 3) ? 1 : 0));
215
+ return mIsIdentity(arr) ? null : arr;
216
+ }
217
+
218
+ function mTranslate(tx, ty) { return [1, 0, 0, 1, tx, ty]; }
219
+
220
+ function mScaleAbout(sx, sy, cx, cy) {
221
+ return mMul(mMul(mTranslate(cx, cy), [sx, 0, 0, sy, 0, 0]), mTranslate(-cx, -cy));
222
+ }
223
+
224
+ function mRotateAbout(deg, cx, cy) {
225
+ var r = deg * Math.PI / 180;
226
+ var c = Math.cos(r);
227
+ var s = Math.sin(r);
228
+ return mMul(mMul(mTranslate(cx, cy), [c, s, -s, c, 0, 0]), mTranslate(-cx, -cy));
229
+ }
230
+
231
+ // SVG transform 列表 → 矩阵(translate / scale / rotate / matrix / skewX / skewY)
232
+ function mParseTransform(str) {
233
+ var m = M_ID.slice();
234
+ if (!str) return m;
235
+ var re = /([a-zA-Z]+)\s*\(([^)]*)\)/g;
236
+ var hit;
237
+ while ((hit = re.exec(String(str))) !== null) {
238
+ var fn = hit[1].toLowerCase();
239
+ var nums = [];
240
+ var raw = hit[2].split(/[\s,]+/);
241
+ for (var i = 0; i < raw.length; i++) {
242
+ if (raw[i] === '') continue;
243
+ var v = parseFloat(raw[i]);
244
+ if (isNum(v)) nums.push(v);
245
+ }
246
+ var t = null;
247
+ if (fn === 'translate') {
248
+ t = mTranslate(num(nums[0], 0), nums.length > 1 ? num(nums[1], 0) : 0);
249
+ } else if (fn === 'scale') {
250
+ var sx = nums.length ? num(nums[0], 1) : 1;
251
+ t = [sx, 0, 0, nums.length > 1 ? num(nums[1], sx) : sx, 0, 0];
252
+ } else if (fn === 'rotate') {
253
+ var r = num(nums[0], 0) * Math.PI / 180;
254
+ var cs = Math.cos(r);
255
+ var sn = Math.sin(r);
256
+ var rm = [cs, sn, -sn, cs, 0, 0];
257
+ if (nums.length >= 3) {
258
+ t = mMul(mMul(mTranslate(num(nums[1], 0), num(nums[2], 0)), rm), mTranslate(-num(nums[1], 0), -num(nums[2], 0)));
259
+ } else {
260
+ t = rm;
261
+ }
262
+ } else if (fn === 'matrix' && nums.length >= 6) {
263
+ t = [nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]];
264
+ } else if (fn === 'skewx') {
265
+ t = [1, 0, Math.tan(num(nums[0], 0) * Math.PI / 180), 1, 0, 0];
266
+ } else if (fn === 'skewy') {
267
+ t = [1, Math.tan(num(nums[0], 0) * Math.PI / 180), 0, 1, 0, 0];
268
+ }
269
+ if (t) m = mMul(m, t);
270
+ }
271
+ return m;
272
+ }
273
+
274
+ function mToSvgAttr(m) {
275
+ return 'matrix(' + fmtNum(m[0]) + ' ' + fmtNum(m[1]) + ' ' + fmtNum(m[2]) + ' ' +
276
+ fmtNum(m[3]) + ' ' + fmtNum(m[4]) + ' ' + fmtNum(m[5]) + ')';
277
+ }
278
+
279
+ // 矩阵是否只含平移(用于圆/椭圆保持"可参数化"的判断)
280
+ function mIsTranslateOnly(m) {
281
+ return Math.abs(m[0] - 1) < EPS && Math.abs(m[1]) < EPS && Math.abs(m[2]) < EPS && Math.abs(m[3] - 1) < EPS;
282
+ }
283
+
284
+ // ── 几何:包围盒 ───────────────────────────────────────────
285
+ // 文本宽度估算(沙箱无 canvas):确定性模型 —— CJK/全角 = 1.0 em,其余 = 0.55 em,粗体 ×1.05。
286
+ // 不追求与浏览器字形完全一致,只服务几何检查(视口越界 / 对齐 / 分布);
287
+ // 面板预览由浏览器渲染真实字形,两者不会互相污染。
288
+ function textWidth(text, fontSize, fontWeight) {
289
+ var s = String(text === undefined || text === null ? '' : text);
290
+ var units = 0;
291
+ for (var i = 0; i < s.length; i++) {
292
+ var c = s.charCodeAt(i);
293
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) i++; // 代理对(emoji 等)算一次宽字符
294
+ units += c > 0x2e80 ? 1 : 0.55;
295
+ }
296
+ var fw = String(fontWeight === undefined || fontWeight === null ? '400' : fontWeight);
297
+ var bold = fw === 'bold' || fw === 'bolder' || num(fw, 400) >= 600;
298
+ return units * num(fontSize, 16) * (bold ? 1.05 : 1);
299
+ }
300
+
301
+ // path d 的保守包围盒:收集所有绝对坐标点(含贝塞尔控制点)→ AABB
302
+ // 说明:控制点会让包围盒略大于真实曲线(保守方向:宁可报"可能越界",不漏报)
303
+ function pathPoints(d) {
304
+ var pts = [];
305
+ if (!d) return pts;
306
+ var tokens = String(d).match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/g) || [];
307
+ var cx = 0;
308
+ var cy = 0;
309
+ var sx = 0;
310
+ var sy = 0;
311
+ var cmd = '';
312
+ var i = 0;
313
+ while (i < tokens.length) {
314
+ var tk = tokens[i];
315
+ if (/^[a-zA-Z]$/.test(tk)) {
316
+ cmd = tk;
317
+ i++;
318
+ if (cmd === 'Z' || cmd === 'z') { cx = sx; cy = sy; continue; }
319
+ continue;
320
+ }
321
+ var rel = cmd === cmd.toLowerCase();
322
+ var take = function (n) {
323
+ var out = [];
324
+ for (var k = 0; k < n; k++) {
325
+ var v = parseFloat(tokens[i + k]);
326
+ out.push(isNum(v) ? v : 0);
327
+ }
328
+ i += n;
329
+ return out;
330
+ };
331
+ var up = cmd.toUpperCase();
332
+ if (up === 'M' || up === 'L' || up === 'T') {
333
+ var p = take(2);
334
+ cx = rel ? cx + p[0] : p[0];
335
+ cy = rel ? cy + p[1] : p[1];
336
+ if (up === 'M') { sx = cx; sy = cy; cmd = rel ? 'l' : 'L'; }
337
+ pts.push([cx, cy]);
338
+ } else if (up === 'H') {
339
+ var hx = take(1)[0];
340
+ cx = rel ? cx + hx : hx;
341
+ pts.push([cx, cy]);
342
+ } else if (up === 'V') {
343
+ var vy = take(1)[0];
344
+ cy = rel ? cy + vy : vy;
345
+ pts.push([cx, cy]);
346
+ } else if (up === 'C') {
347
+ var c1 = take(2);
348
+ var c2 = take(2);
349
+ var c3 = take(2);
350
+ var ax1 = rel ? cx + c1[0] : c1[0];
351
+ var ay1 = rel ? cy + c1[1] : c1[1];
352
+ var ax2 = rel ? cx + c2[0] : c2[0];
353
+ var ay2 = rel ? cy + c2[1] : c2[1];
354
+ var ax3 = rel ? cx + c3[0] : c3[0];
355
+ var ay3 = rel ? cy + c3[1] : c3[1];
356
+ pts.push([ax1, ay1], [ax2, ay2], [ax3, ay3]);
357
+ cx = ax3; cy = ay3;
358
+ } else if (up === 'S' || up === 'Q') {
359
+ var q1 = take(2);
360
+ var q2 = take(2);
361
+ var qx1 = rel ? cx + q1[0] : q1[0];
362
+ var qy1 = rel ? cy + q1[1] : q1[1];
363
+ var qx2 = rel ? cx + q2[0] : q2[0];
364
+ var qy2 = rel ? cy + q2[1] : q2[1];
365
+ pts.push([qx1, qy1], [qx2, qy2]);
366
+ cx = qx2; cy = qy2;
367
+ } else if (up === 'A') {
368
+ var ap = take(7);
369
+ cx = rel ? cx + ap[5] : ap[5];
370
+ cy = rel ? cy + ap[6] : ap[6];
371
+ pts.push([cx, cy]);
372
+ } else {
373
+ i++; // 未知记号:跳过,避免死循环
374
+ }
375
+ }
376
+ return pts;
377
+ }
378
+
379
+ function pathBBox(d) {
380
+ var pts = pathPoints(d);
381
+ if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
382
+ var minX = Infinity;
383
+ var minY = Infinity;
384
+ var maxX = -Infinity;
385
+ var maxY = -Infinity;
386
+ for (var i = 0; i < pts.length; i++) {
387
+ if (pts[i][0] < minX) minX = pts[i][0];
388
+ if (pts[i][0] > maxX) maxX = pts[i][0];
389
+ if (pts[i][1] < minY) minY = pts[i][1];
390
+ if (pts[i][1] > maxY) maxY = pts[i][1];
391
+ }
392
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
393
+ }
394
+
395
+ // 图元本地包围盒(未应用 transform)
396
+ function localBBox(sh) {
397
+ var t = sh.type;
398
+ if (t === 'rect') return { x: num(sh.x, 0), y: num(sh.y, 0), w: num(sh.w, 0), h: num(sh.h, 0) };
399
+ if (t === 'circle') {
400
+ var r = num(sh.r, 0);
401
+ return { x: num(sh.cx, 0) - r, y: num(sh.cy, 0) - r, w: 2 * r, h: 2 * r };
402
+ }
403
+ if (t === 'ellipse') {
404
+ var rx = num(sh.rx, 0);
405
+ var ry = num(sh.ry, 0);
406
+ return { x: num(sh.cx, 0) - rx, y: num(sh.cy, 0) - ry, w: 2 * rx, h: 2 * ry };
407
+ }
408
+ if (t === 'line') {
409
+ var x1 = num(sh.x1, 0);
410
+ var y1 = num(sh.y1, 0);
411
+ var x2 = num(sh.x2, 0);
412
+ var y2 = num(sh.y2, 0);
413
+ return { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
414
+ }
415
+ if (t === 'polyline' || t === 'polygon') {
416
+ var pts = sh.points || [];
417
+ if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
418
+ var minX = Infinity;
419
+ var minY = Infinity;
420
+ var maxX = -Infinity;
421
+ var maxY = -Infinity;
422
+ for (var i = 0; i < pts.length; i++) {
423
+ var px = num(pts[i][0], 0);
424
+ var py = num(pts[i][1], 0);
425
+ if (px < minX) minX = px;
426
+ if (px > maxX) maxX = px;
427
+ if (py < minY) minY = py;
428
+ if (py > maxY) maxY = py;
429
+ }
430
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
431
+ }
432
+ if (t === 'path') return pathBBox(sh.d);
433
+ if (t === 'text') {
434
+ var fs = num(sh.fontSize, 16);
435
+ var w = textWidth(sh.text, fs, sh.fontWeight);
436
+ var anchor = sh.anchor || 'start';
437
+ var x0 = anchor === 'middle' ? num(sh.x, 0) - w / 2 : (anchor === 'end' ? num(sh.x, 0) - w : num(sh.x, 0));
438
+ // 基线近似:上升 0.8em、下降 0.2em → 覆盖字形实际占位
439
+ return { x: x0, y: num(sh.y, 0) - fs * 0.8, w: w, h: fs };
440
+ }
441
+ return { x: 0, y: 0, w: 0, h: 0 };
442
+ }
443
+
444
+ // 采样点:四角足够(path 已含控制点、保守);圆/椭圆用 16 点圆周采样(旋转后 AABB 才准确)
445
+ function bboxSamples(sh, b) {
446
+ var t = sh.type;
447
+ if (t === 'circle' || t === 'ellipse') {
448
+ var rx = t === 'circle' ? num(sh.r, 0) : num(sh.rx, 0);
449
+ var ry = t === 'circle' ? num(sh.r, 0) : num(sh.ry, 0);
450
+ var out = [];
451
+ for (var i = 0; i < 16; i++) {
452
+ var a = Math.PI * 2 * i / 16;
453
+ out.push([num(sh.cx, 0) + rx * Math.cos(a), num(sh.cy, 0) + ry * Math.sin(a)]);
454
+ }
455
+ return out;
456
+ }
457
+ return [[b.x, b.y], [b.x + b.w, b.y], [b.x + b.w, b.y + b.h], [b.x, b.y + b.h]];
458
+ }
459
+
460
+ // 应用 transform 后的轴对齐包围盒
461
+ function shapeBBox(sh) {
462
+ var b = localBBox(sh);
463
+ var m = sh.transform;
464
+ if (!m || mIsIdentity(m)) return b;
465
+ var pts = bboxSamples(sh, b);
466
+ var minX = Infinity;
467
+ var minY = Infinity;
468
+ var maxX = -Infinity;
469
+ var maxY = -Infinity;
470
+ for (var i = 0; i < pts.length; i++) {
471
+ var p = mApply(m, pts[i][0], pts[i][1]);
472
+ if (p.x < minX) minX = p.x;
473
+ if (p.x > maxX) maxX = p.x;
474
+ if (p.y < minY) minY = p.y;
475
+ if (p.y > maxY) maxY = p.y;
476
+ }
477
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
478
+ }
479
+
480
+ function bboxUnion(a, b) {
481
+ if (!a) return { x: b.x, y: b.y, w: b.w, h: b.h };
482
+ var x = Math.min(a.x, b.x);
483
+ var y = Math.min(a.y, b.y);
484
+ var x2 = Math.max(a.x + a.w, b.x + b.w);
485
+ var y2 = Math.max(a.y + a.h, b.y + b.h);
486
+ return { x: x, y: y, w: x2 - x, h: y2 - y };
487
+ }
488
+
489
+ function bboxIntersectArea(a, b) {
490
+ var w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
491
+ var h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
492
+ if (w <= 0 || h <= 0) return 0;
493
+ return w * h;
494
+ }
495
+
496
+ // 是否"有重叠"(含退化为线的情形:面积可为 0,但区间相交即算可见)
497
+ // ★ 不能用面积判断:水平/垂直线的包围盒高度/宽度为 0,面积恒为 0,会被误判为"完全在画板外"。
498
+ function bboxOverlaps(a, b) {
499
+ return Math.min(a.x + a.w, b.x + b.w) >= Math.max(a.x, b.x) - EPS &&
500
+ Math.min(a.y + a.h, b.y + b.h) >= Math.max(a.y, b.y) - EPS;
501
+ }
502
+
503
+ // b 是否完全包含 a(含 EPS 容差)
504
+ function bboxContains(b, a, tol) {
505
+ var t = num(tol, 0);
506
+ return a.x >= b.x - t && a.y >= b.y - t &&
507
+ a.x + a.w <= b.x + b.w + t && a.y + a.h <= b.y + b.h + t;
508
+ }
509
+
510
+ function bboxArea(b) { return Math.max(0, b.w) * Math.max(0, b.h); }
511
+
512
+ // ── SVG 生成(唯一文本产物) ───────────────────────────────
513
+ var SVG_NS = 'http://www.w3.org/2000/svg';
514
+
515
+ function attrsToStr(pairs) {
516
+ var out = '';
517
+ for (var i = 0; i < pairs.length; i++) {
518
+ if (pairs[i][1] === undefined || pairs[i][1] === null) continue;
519
+ out += ' ' + pairs[i][0] + '="' + pairs[i][1] + '"';
520
+ }
521
+ return out;
522
+ }
523
+
524
+ // 颜色值判定:'none'(不画)/ 'color'(可解析的实色)
525
+ function normColorOrRaw(v) {
526
+ if (v === undefined || v === null) return 'none';
527
+ var c = parseColor(v);
528
+ if (!c) return String(v);
529
+ return c.none ? 'none' : (c.a <= EPS ? 'none' : 'color');
530
+ }
531
+
532
+ // 归一化 path d:折叠多余空白(让同一几何的 d 串稳定,服务 V5 确定性)
533
+ function normPathD(d) {
534
+ return String(d === undefined || d === null ? '' : d).replace(/\s+/g, ' ').trim();
535
+ }
536
+
537
+ // 图元 → SVG 元素文本。
538
+ // ★ 属性顺序固定(id → data-name → transform → 几何 → fill/stroke/stroke-width/opacity):
539
+ // 这是 V5「两次导出位级一致」的前提,改动顺序会让 V5 失败。
540
+ function shapeToSvg(sh, pad) {
541
+ var t = sh.type;
542
+ var p = pad || ' ';
543
+ var a = [['id', xmlEscape(sh.id)]];
544
+ if (sh.name) a.push(['data-name', xmlEscape(sh.name)]);
545
+ if (sh.transform && !mIsIdentity(sh.transform)) a.push(['transform', mToSvgAttr(sh.transform)]);
546
+
547
+ var geom = [];
548
+ if (t === 'rect') {
549
+ geom.push(['x', fmtNum(num(sh.x, 0))], ['y', fmtNum(num(sh.y, 0))],
550
+ ['width', fmtNum(Math.max(0, num(sh.w, 0)))], ['height', fmtNum(Math.max(0, num(sh.h, 0)))]);
551
+ if (num(sh.rx, 0) > 0) geom.push(['rx', fmtNum(num(sh.rx, 0))]);
552
+ } else if (t === 'circle') {
553
+ geom.push(['cx', fmtNum(num(sh.cx, 0))], ['cy', fmtNum(num(sh.cy, 0))],
554
+ ['r', fmtNum(Math.max(0, num(sh.r, 0)))]);
555
+ } else if (t === 'ellipse') {
556
+ geom.push(['cx', fmtNum(num(sh.cx, 0))], ['cy', fmtNum(num(sh.cy, 0))],
557
+ ['rx', fmtNum(Math.max(0, num(sh.rx, 0)))], ['ry', fmtNum(Math.max(0, num(sh.ry, 0)))]);
558
+ } else if (t === 'line') {
559
+ geom.push(['x1', fmtNum(num(sh.x1, 0))], ['y1', fmtNum(num(sh.y1, 0))],
560
+ ['x2', fmtNum(num(sh.x2, 0))], ['y2', fmtNum(num(sh.y2, 0))]);
561
+ } else if (t === 'polyline' || t === 'polygon') {
562
+ var pts = sh.points || [];
563
+ var parts = [];
564
+ for (var i = 0; i < pts.length; i++) {
565
+ parts.push(fmtNum(num(pts[i][0], 0)) + ',' + fmtNum(num(pts[i][1], 0)));
566
+ }
567
+ geom.push(['points', parts.join(' ')]);
568
+ } else if (t === 'path') {
569
+ geom.push(['d', xmlEscape(normPathD(sh.d))]);
570
+ } else if (t === 'text') {
571
+ geom.push(['x', fmtNum(num(sh.x, 0))], ['y', fmtNum(num(sh.y, 0))]);
572
+ geom.push(['font-size', fmtNum(num(sh.fontSize, 16))]);
573
+ if (sh.fontWeight !== undefined && sh.fontWeight !== null && String(sh.fontWeight) !== '') {
574
+ geom.push(['font-weight', xmlEscape(sh.fontWeight)]);
575
+ }
576
+ if (sh.anchor && sh.anchor !== 'start') geom.push(['text-anchor', xmlEscape(sh.anchor)]);
577
+ }
578
+ a = a.concat(geom);
579
+
580
+ // line 无填充语义;其余图元总是显式写 fill/stroke(明确性优先,且保证往返一致)
581
+ if (t !== 'line') {
582
+ a.push(['fill', xmlEscape(sh.fill === undefined || sh.fill === null ? 'none' : sh.fill)]);
583
+ }
584
+ a.push(['stroke', xmlEscape(sh.stroke === undefined || sh.stroke === null ? 'none' : sh.stroke)]);
585
+ if (num(sh.strokeWidth, 0) > 0 && normColorOrRaw(sh.stroke) === 'color') {
586
+ a.push(['stroke-width', fmtNum(num(sh.strokeWidth, 1))]);
587
+ }
588
+ if (num(sh.opacity, 1) < 1 - EPS) a.push(['opacity', fmtNum(clamp(num(sh.opacity, 1), 0, 1))]);
589
+
590
+ if (t === 'text') {
591
+ return p + '<text' + attrsToStr(a) + '>' + xmlEscape(sh.text || '') + '</text>';
592
+ }
593
+ return p + '<' + t + attrsToStr(a) + '/>';
594
+ }
595
+
596
+ // 图元是否归入某图层(孤儿图元——layer 指向不存在的层——统一并入第一个图层,避免产物丢内容)
597
+ function wantsLayer(proj, shapeLayer, layerId) {
598
+ var layers = proj.layers || [];
599
+ if (!layers.length) return layerId === undefined;
600
+ var first = layers[0].id;
601
+ var known = false;
602
+ for (var i = 0; i < layers.length; i++) if (layers[i].id === shapeLayer) known = true;
603
+ var effective = known ? shapeLayer : first;
604
+ return effective === layerId;
605
+ }
606
+
607
+ // 图层内按 z 排序(z 相同按添加顺序 → 稳定)
608
+ function shapesOfLayerInZOrder(proj, layerId) {
609
+ var list = [];
610
+ var all = proj.shapes || [];
611
+ for (var i = 0; i < all.length; i++) {
612
+ if (!wantsLayer(proj, all[i].layer, layerId)) continue;
613
+ list.push({ sh: all[i], idx: i });
614
+ }
615
+ list.sort(function (a, b) {
616
+ var za = num(a.sh.z, 0);
617
+ var zb = num(b.sh.z, 0);
618
+ if (za !== zb) return za - zb;
619
+ return a.idx - b.idx;
620
+ });
621
+ var out = [];
622
+ for (var k = 0; k < list.length; k++) out.push(list[k].sh);
623
+ return out;
624
+ }
625
+
626
+ function projectToSvg(proj) {
627
+ var cv = proj.canvas || {};
628
+ var w = num(cv.width, 800);
629
+ var h = num(cv.height, 600);
630
+ var vb = cv.viewBox || ('0 0 ' + fmtNum(w) + ' ' + fmtNum(h));
631
+ var title = (proj.meta && proj.meta.title) || '未命名画板';
632
+ var lines = [];
633
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
634
+ lines.push('<svg xmlns="' + SVG_NS + '" width="' + fmtNum(w) + '" height="' + fmtNum(h) +
635
+ '" viewBox="' + xmlEscape(vb) + '" role="img" aria-label="' + xmlEscape(title) + '">');
636
+ lines.push(' <title>' + xmlEscape(title) + '</title>');
637
+ lines.push(' <desc>' + xmlEscape('由 tool-art 生成;真相源 = art.project.json(改 SVG 不回流,请改工程)') + '</desc>');
638
+
639
+ var bgRaw = cv.background === undefined || cv.background === null ? 'none' : cv.background;
640
+ var bg = parseColor(bgRaw);
641
+ if (bg && !bg.none && bg.a > EPS) {
642
+ lines.push(' <rect id="bg" x="0" y="0" width="' + fmtNum(w) + '" height="' + fmtNum(h) +
643
+ '" fill="' + xmlEscape(bgRaw) + '"/>');
644
+ }
645
+
646
+ var layers = proj.layers || [];
647
+ for (var i = 0; i < layers.length; i++) {
648
+ var ly = layers[i];
649
+ var ga = [['id', xmlEscape(ly.id)]];
650
+ if (ly.name) ga.push(['data-name', xmlEscape(ly.name)]);
651
+ if (ly.visible === false) ga.push(['display', 'none']);
652
+ var kids = shapesOfLayerInZOrder(proj, ly.id);
653
+ if (!kids.length) {
654
+ lines.push(' <g' + attrsToStr(ga) + '/>');
655
+ continue;
656
+ }
657
+ lines.push(' <g' + attrsToStr(ga) + '>');
658
+ for (var k = 0; k < kids.length; k++) lines.push(shapeToSvg(kids[k], ' '));
659
+ lines.push(' </g>');
660
+ }
661
+ lines.push('</svg>');
662
+ return lines.join('\n') + '\n';
663
+ }
664
+
665
+ // ── SVG 解析(导入) ───────────────────────────────────────
666
+ // 沙箱无 DOMParser → 自实现极简 XML 扫描器(元素树 + 属性 + 文本节点)。
667
+ // 只处理 SVG 需要的部分:声明/注释/DOCTYPE 剥离、单双引号属性、自闭合标签、实体反转义。
668
+ function parseXml(text) {
669
+ var root = { tag: '#root', attrs: {}, children: [], text: '' };
670
+ var stack = [root];
671
+ var s = String(text === undefined || text === null ? '' : text);
672
+ s = s.replace(/<\?[\s\S]*?\?>/g, '').replace(/<!--[\s\S]*?-->/g, '').replace(/<!DOCTYPE[^>]*>/gi, '');
673
+ var pos = 0;
674
+ while (pos < s.length) {
675
+ var lt = s.indexOf('<', pos);
676
+ if (lt < 0) {
677
+ var tail = s.slice(pos);
678
+ if (tail.trim()) stack[stack.length - 1].text += tail;
679
+ break;
680
+ }
681
+ if (lt > pos) {
682
+ var chunk = s.slice(pos, lt);
683
+ if (chunk.trim()) stack[stack.length - 1].text += chunk;
684
+ }
685
+ var gt = s.indexOf('>', lt);
686
+ if (gt < 0) break;
687
+ var rawTag = s.slice(lt + 1, gt);
688
+ pos = gt + 1;
689
+ if (rawTag.charAt(0) === '/') {
690
+ var closeName = rawTag.slice(1).trim().split(/\s/)[0];
691
+ for (var i = stack.length - 1; i > 0; i--) {
692
+ if (stack[i].tag === closeName) { stack.length = i; break; }
693
+ }
694
+ continue;
695
+ }
696
+ var selfClose = /\/\s*$/.test(rawTag);
697
+ var body = selfClose ? rawTag.replace(/\/\s*$/, '') : rawTag;
698
+ var m = /^([a-zA-Z_][\w:.-]*)/.exec(body);
699
+ if (!m) continue;
700
+ var attrs = {};
701
+ var attrRe = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
702
+ var am;
703
+ while ((am = attrRe.exec(body)) !== null) {
704
+ attrs[am[1]] = xmlUnescape(am[2] !== undefined ? am[2] : am[3]);
705
+ }
706
+ var node = { tag: m[1], attrs: attrs, children: [], text: '' };
707
+ stack[stack.length - 1].children.push(node);
708
+ if (!selfClose) stack.push(node);
709
+ }
710
+ return root;
711
+ }
712
+
713
+ // 展现属性 + style 内联声明(style 优先)
714
+ var PRESENTATION_ATTRS = ['fill', 'stroke', 'stroke-width', 'opacity', 'fill-opacity', 'stroke-opacity',
715
+ 'font-size', 'font-weight', 'text-anchor', 'font-family', 'display'];
716
+
717
+ function styleMap(node) {
718
+ var map = {};
719
+ for (var i = 0; i < PRESENTATION_ATTRS.length; i++) {
720
+ var k = PRESENTATION_ATTRS[i];
721
+ if (node.attrs[k] !== undefined) map[k] = node.attrs[k];
722
+ }
723
+ if (node.attrs.style) {
724
+ var decls = String(node.attrs.style).split(';');
725
+ for (var d = 0; d < decls.length; d++) {
726
+ var idx = decls[d].indexOf(':');
727
+ if (idx <= 0) continue;
728
+ var key = decls[d].slice(0, idx).trim().toLowerCase();
729
+ var val = decls[d].slice(idx + 1).trim();
730
+ if (key && val) map[key] = val;
731
+ }
732
+ }
733
+ return map;
734
+ }
735
+
736
+ function attrNum(attrs, name, def) {
737
+ if (!attrs || attrs[name] === undefined || attrs[name] === null) return def;
738
+ var v = parseFloat(attrs[name]);
739
+ return isNum(v) ? v : def;
740
+ }
741
+
742
+ function pointsAttrToArr(str) {
743
+ var raw = String(str || '').split(/[\s,]+/);
744
+ var vals = [];
745
+ for (var i = 0; i < raw.length; i++) {
746
+ if (raw[i] === '') continue;
747
+ var v = parseFloat(raw[i]);
748
+ if (isNum(v)) vals.push(v);
749
+ }
750
+ var pts = [];
751
+ for (var k = 0; k + 1 < vals.length; k += 2) pts.push([vals[k], vals[k + 1]]);
752
+ return pts;
753
+ }
754
+
755
+ // 颜色值净化:剥离脚本注入面(javascript: / url(...));非法值原样保留交由校验器报错
756
+ function sanitizeColorValue(v, warn, where) {
757
+ var s = String(v === undefined || v === null ? '' : v).trim();
758
+ var low = s.toLowerCase();
759
+ if (low.indexOf('javascript:') >= 0 || low.indexOf('url(') >= 0) {
760
+ if (warn) warn.push('已丢弃不安全的 ' + where + ' 值: ' + s);
761
+ return 'none';
762
+ }
763
+ return s === '' ? 'none' : s;
764
+ }
765
+
766
+ // 元素 → 图元(返回 null 表示该标签不属于 SVG 图元子集)
767
+ function nodeToShape(node, layerId, parentM, warn) {
768
+ var type = node.tag.toLowerCase();
769
+ if (SHAPE_TYPES.indexOf(type) < 0) return null;
770
+ var st = styleMap(node);
771
+ var m = mMul(parentM || M_ID, mParseTransform(node.attrs.transform));
772
+ var sh = { id: node.attrs.id || '', type: type, layer: layerId, z: 0 };
773
+ if (node.attrs['data-name']) sh.name = node.attrs['data-name'];
774
+
775
+ if (type === 'rect') {
776
+ sh.x = attrNum(node.attrs, 'x', 0);
777
+ sh.y = attrNum(node.attrs, 'y', 0);
778
+ sh.w = Math.max(0, attrNum(node.attrs, 'width', 0));
779
+ sh.h = Math.max(0, attrNum(node.attrs, 'height', 0));
780
+ var rx = attrNum(node.attrs, 'rx', 0);
781
+ if (rx > 0) sh.rx = rx;
782
+ } else if (type === 'circle') {
783
+ sh.cx = attrNum(node.attrs, 'cx', 0);
784
+ sh.cy = attrNum(node.attrs, 'cy', 0);
785
+ sh.r = Math.max(0, attrNum(node.attrs, 'r', 0));
786
+ } else if (type === 'ellipse') {
787
+ sh.cx = attrNum(node.attrs, 'cx', 0);
788
+ sh.cy = attrNum(node.attrs, 'cy', 0);
789
+ sh.rx = Math.max(0, attrNum(node.attrs, 'rx', 0));
790
+ sh.ry = Math.max(0, attrNum(node.attrs, 'ry', 0));
791
+ } else if (type === 'line') {
792
+ sh.x1 = attrNum(node.attrs, 'x1', 0);
793
+ sh.y1 = attrNum(node.attrs, 'y1', 0);
794
+ sh.x2 = attrNum(node.attrs, 'x2', 0);
795
+ sh.y2 = attrNum(node.attrs, 'y2', 0);
796
+ } else if (type === 'polyline' || type === 'polygon') {
797
+ sh.points = pointsAttrToArr(node.attrs.points);
798
+ } else if (type === 'path') {
799
+ sh.d = normPathD(node.attrs.d);
800
+ } else if (type === 'text') {
801
+ sh.x = attrNum(node.attrs, 'x', 0);
802
+ sh.y = attrNum(node.attrs, 'y', 0);
803
+ sh.text = String(node.text || '').replace(/\s+/g, ' ').trim();
804
+ sh.fontSize = num(parseFloat(st['font-size']), 16);
805
+ if (st['font-weight']) sh.fontWeight = st['font-weight'];
806
+ if (st['text-anchor'] === 'middle' || st['text-anchor'] === 'end') sh.anchor = st['text-anchor'];
807
+ }
808
+
809
+ var strokeRaw = st.stroke === undefined ? 'none' : st.stroke;
810
+ if (type !== 'line') {
811
+ sh.fill = sanitizeColorValue(st.fill === undefined ? '#000000' : st.fill, warn, 'fill');
812
+ }
813
+ sh.stroke = sanitizeColorValue(strokeRaw, warn, 'stroke');
814
+ sh.strokeWidth = normColorOrRaw(strokeRaw) === 'none'
815
+ ? 0
816
+ : clamp(num(parseFloat(st['stroke-width']), 1), 0, LIMIT_CANVAS);
817
+ sh.opacity = st.opacity === undefined ? 1 : clamp(num(parseFloat(st.opacity), 1), 0, 1);
818
+
819
+ if (!mIsIdentity(m)) sh.transform = m;
820
+ return sh;
821
+ }
822
+
823
+ function attrLength(v) {
824
+ if (v === undefined || v === null) return 0;
825
+ var s = String(v).trim();
826
+ if (s.indexOf('%') >= 0) return 0;
827
+ var n = parseFloat(s);
828
+ return isNum(n) ? n : 0;
829
+ }
830
+
831
+ function vbNumbers(v) {
832
+ if (!v) return null;
833
+ var parts = String(v).split(/[\s,]+/).filter(function (x) { return x !== ''; });
834
+ if (parts.length < 4) return null;
835
+ var out = [];
836
+ for (var i = 0; i < 4; i++) {
837
+ var n = parseFloat(parts[i]);
838
+ if (!isNum(n)) return null;
839
+ out.push(n);
840
+ }
841
+ return out;
842
+ }
843
+
844
+ // 层内 z 归一化为 0..n-1(按现有 z 稳定排序)
845
+ function normalizeZ(proj) {
846
+ var layers = proj.layers || [];
847
+ for (var i = 0; i < layers.length; i++) {
848
+ var list = [];
849
+ for (var k = 0; k < (proj.shapes || []).length; k++) {
850
+ if (wantsLayer(proj, proj.shapes[k].layer, layers[i].id)) list.push({ sh: proj.shapes[k], idx: k });
851
+ }
852
+ list.sort(function (a, b) {
853
+ var za = num(a.sh.z, 0);
854
+ var zb = num(b.sh.z, 0);
855
+ if (za !== zb) return za - zb;
856
+ return a.idx - b.idx;
857
+ });
858
+ for (var n = 0; n < list.length; n++) list[n].sh.z = n;
859
+ }
860
+ }
861
+
862
+ var SKIPPED_HINTS = ['defs', 'use', 'image', 'tspan', 'filter', 'clipPath', 'mask',
863
+ 'linearGradient', 'radialGradient', 'pattern', 'symbol', 'marker', 'foreignObject'];
864
+
865
+ // SVG 文本 → 画板工程。返回 { project, warnings }
866
+ function svgToProject(text, opts) {
867
+ var options = opts || {};
868
+ var warn = [];
869
+ var doc = parseXml(text);
870
+ var svg = null;
871
+ for (var i = 0; i < doc.children.length; i++) {
872
+ if (doc.children[i].tag.toLowerCase() === 'svg') { svg = doc.children[i]; break; }
873
+ }
874
+ if (!svg) throw new Error('不是有效的 SVG:未找到 <svg> 根元素');
875
+
876
+ var w = attrLength(svg.attrs.width);
877
+ var h = attrLength(svg.attrs.height);
878
+ var vb = vbNumbers(svg.attrs.viewBox);
879
+ if ((!w || !h) && vb) { w = w || vb[2]; h = h || vb[3]; }
880
+ w = Math.round(w || 800);
881
+ h = Math.round(h || 600);
882
+
883
+ var title = options.title || '';
884
+ for (var t = 0; t < svg.children.length; t++) {
885
+ if (svg.children[t].tag.toLowerCase() === 'title' && !title) {
886
+ title = String(svg.children[t].text || '').trim();
887
+ }
888
+ }
889
+
890
+ var proj = {
891
+ schema: SCHEMA,
892
+ meta: { title: title || '导入的画板', createdAt: nowIso() },
893
+ canvas: {
894
+ width: w,
895
+ height: h,
896
+ viewBox: svg.attrs.viewBox || ('0 0 ' + fmtNum(w) + ' ' + fmtNum(h)),
897
+ background: 'none',
898
+ },
899
+ palette: PALETTE_DEFAULT.slice(),
900
+ layers: [],
901
+ shapes: [],
902
+ };
903
+
904
+ var defaultLayer = { id: 'l1', name: '图层 1', visible: true };
905
+ var layerIndex = {};
906
+ var shapeSeq = 0;
907
+ var idUsed = {};
908
+
909
+ function ensureLayer(id, name, visible) {
910
+ if (!layerIndex[id]) {
911
+ var ly = { id: id, name: name || id, visible: visible !== false };
912
+ layerIndex[id] = ly;
913
+ proj.layers.push(ly);
914
+ }
915
+ return layerIndex[id];
916
+ }
917
+
918
+ function nextId(preferred) {
919
+ var base = preferred && /^[A-Za-z_][\w.-]*$/.test(preferred) ? preferred : ('s' + (++shapeSeq));
920
+ var id = base;
921
+ var n = 2;
922
+ while (idUsed[id]) { id = base + '-' + n; n++; }
923
+ idUsed[id] = true;
924
+ return id;
925
+ }
926
+
927
+ function walk(node, layerId, parentM) {
928
+ for (var c = 0; c < node.children.length; c++) {
929
+ var child = node.children[c];
930
+ var tag = child.tag.toLowerCase();
931
+ if (tag === 'title' || tag === 'desc' || tag === 'metadata') continue;
932
+ if (tag === 'script' || tag === 'style') {
933
+ warn.push('已忽略 <' + tag + '> 元素(安全策略:不执行、不保留脚本)');
934
+ continue;
935
+ }
936
+ if (tag === 'g') {
937
+ var rawId = child.attrs.id || '';
938
+ var gid = /^[A-Za-z_][\w.-]*$/.test(rawId) ? rawId : ('l' + (proj.layers.length + 1));
939
+ ensureLayer(gid, child.attrs['data-name'] || rawId || gid,
940
+ String(styleMap(child).display || '').toLowerCase() !== 'none');
941
+ walk(child, gid, mMul(parentM, mParseTransform(child.attrs.transform)));
942
+ continue;
943
+ }
944
+ var sh = nodeToShape(child, layerId, parentM, warn);
945
+ if (!sh) {
946
+ if (warn.length < 40) {
947
+ var hint = SKIPPED_HINTS.indexOf(child.tag) >= 0 ? '(当前 SVG 子集不含该特性)' : '';
948
+ warn.push('已跳过不支持的元素 <' + child.tag + '>' + hint);
949
+ }
950
+ continue;
951
+ }
952
+ // 背景板识别:id=bg 且铺满画板、无描边的矩形 → canvas.background(不计入图元)
953
+ var isBg = child.attrs.id === 'bg' && sh.type === 'rect' &&
954
+ Math.abs(sh.x) < EPS && Math.abs(sh.y) < EPS &&
955
+ Math.abs(sh.w - proj.canvas.width) < 1 && Math.abs(sh.h - proj.canvas.height) < 1 &&
956
+ normColorOrRaw(sh.stroke) === 'none';
957
+ if (isBg && proj.canvas.background === 'none') {
958
+ proj.canvas.background = sh.fill;
959
+ continue;
960
+ }
961
+ sh.id = nextId(child.attrs.id);
962
+ sh.z = proj.shapes.length;
963
+ proj.shapes.push(sh);
964
+ }
965
+ }
966
+
967
+ walk(svg, defaultLayer.id, M_ID);
968
+ if (!proj.layers.length) proj.layers.push(defaultLayer);
969
+ normalizeZ(proj);
970
+ return { project: proj, warnings: warn };
971
+ }
972
+
973
+ // ── 工程模型 ───────────────────────────────────────────────
974
+ function emptyProject(title, w, h) {
975
+ var width = clamp(Math.round(num(w, 800)), 1, LIMIT_CANVAS);
976
+ var height = clamp(Math.round(num(h, 600)), 1, LIMIT_CANVAS);
977
+ return {
978
+ schema: SCHEMA,
979
+ meta: { title: title || '未命名画板', createdAt: nowIso() },
980
+ canvas: {
981
+ width: width,
982
+ height: height,
983
+ viewBox: '0 0 ' + width + ' ' + height,
984
+ background: '#FFFFFF',
985
+ },
986
+ palette: PALETTE_DEFAULT.slice(),
987
+ layers: [{ id: 'l1', name: '图层 1', visible: true }],
988
+ shapes: [],
989
+ };
990
+ }
991
+
992
+ function normalizeProject(proj) {
993
+ if (!proj || typeof proj !== 'object') throw new Error('工程内容不是对象');
994
+ if (!proj.canvas) proj.canvas = { width: 800, height: 600, background: '#FFFFFF' };
995
+ proj.canvas.width = clamp(Math.round(num(proj.canvas.width, 800)), 1, LIMIT_CANVAS);
996
+ proj.canvas.height = clamp(Math.round(num(proj.canvas.height, 600)), 1, LIMIT_CANVAS);
997
+ if (!proj.canvas.viewBox) proj.canvas.viewBox = '0 0 ' + proj.canvas.width + ' ' + proj.canvas.height;
998
+ if (proj.canvas.background === undefined) proj.canvas.background = 'none';
999
+ if (!proj.meta || typeof proj.meta !== 'object') proj.meta = { title: '未命名画板' };
1000
+ if (!proj.meta.createdAt) proj.meta.createdAt = nowIso();
1001
+ if (!proj.palette || !proj.palette.length) proj.palette = PALETTE_DEFAULT.slice();
1002
+ if (!proj.layers || !proj.layers.length) proj.layers = [{ id: 'l1', name: '图层 1', visible: true }];
1003
+ if (!proj.shapes) proj.shapes = [];
1004
+ for (var i = 0; i < proj.shapes.length; i++) {
1005
+ var sh = proj.shapes[i];
1006
+ if (sh.transform && mIsIdentity(sh.transform)) delete sh.transform;
1007
+ if (sh.opacity === undefined) sh.opacity = 1;
1008
+ if (sh.type !== 'line' && (sh.fill === undefined || sh.fill === null)) sh.fill = '#000000';
1009
+ if (sh.stroke === undefined || sh.stroke === null) sh.stroke = 'none';
1010
+ if (sh.strokeWidth === undefined || sh.strokeWidth === null) {
1011
+ sh.strokeWidth = normColorOrRaw(sh.stroke) === 'none' ? 0 : 1;
1012
+ }
1013
+ if (sh.z === undefined || sh.z === null) sh.z = i;
1014
+ }
1015
+ return proj;
1016
+ }
1017
+
1018
+ function loadProject(ctx, path) {
1019
+ if (!ctx.fs.exists(path)) throw new Error('画板工程不存在: ' + path + '(先用 art_project mode=create 创建)');
1020
+ var txt = '';
1021
+ try {
1022
+ txt = ctx.fs.readFile(path);
1023
+ } catch (e) {
1024
+ throw new Error('读取工程失败: ' + ((e && e.message) ? e.message : e));
1025
+ }
1026
+ var obj;
1027
+ try {
1028
+ obj = JSON.parse(txt);
1029
+ } catch (e) {
1030
+ throw new Error('工程 JSON 解析失败(' + path + '): ' + ((e && e.message) ? e.message : e));
1031
+ }
1032
+ return normalizeProject(obj);
1033
+ }
1034
+
1035
+ function saveProject(ctx, path, proj) {
1036
+ ctx.fs.writeFile(path, JSON.stringify(proj, null, 2) + '\n');
1037
+ }
1038
+
1039
+ // ── 图元查询与几何操作 ─────────────────────────────────────
1040
+ function findShape(proj, idOrIndex) {
1041
+ var list = proj.shapes || [];
1042
+ for (var i = 0; i < list.length; i++) if (list[i].id === idOrIndex) return list[i];
1043
+ if (isInt(idOrIndex) && idOrIndex >= 0 && idOrIndex < list.length) return list[idOrIndex];
1044
+ var asInt = parseInt(idOrIndex, 10);
1045
+ if (isNum(asInt) && asInt >= 0 && asInt < list.length) return list[asInt];
1046
+ return null;
1047
+ }
1048
+
1049
+ // 目标解析:ids / id / type / layer / name / all —— 空条件直接报错(防止误改全部)
1050
+ function resolveTargets(proj, spec) {
1051
+ var s = spec || {};
1052
+ var out = [];
1053
+ var seen = {};
1054
+ function push(sh) { if (sh && !seen[sh.id]) { seen[sh.id] = true; out.push(sh); } }
1055
+
1056
+ if (s.ids && s.ids.length) {
1057
+ for (var i = 0; i < s.ids.length; i++) {
1058
+ var found = findShape(proj, s.ids[i]);
1059
+ if (!found) throw new Error('图元不存在: ' + s.ids[i]);
1060
+ push(found);
1061
+ }
1062
+ return out;
1063
+ }
1064
+ if (s.id) {
1065
+ var one = findShape(proj, s.id);
1066
+ if (!one) throw new Error('图元不存在: ' + s.id);
1067
+ push(one);
1068
+ return out;
1069
+ }
1070
+ var hasFilter = s.type || s.layer || s.name || s.all;
1071
+ if (!hasFilter) {
1072
+ throw new Error('必须指定目标:ids / id / type / layer / name / all=true(避免误改全部图元)');
1073
+ }
1074
+ var list = proj.shapes || [];
1075
+ for (var k = 0; k < list.length; k++) {
1076
+ var sh = list[k];
1077
+ if (s.type && sh.type !== s.type) continue;
1078
+ if (s.layer && sh.layer !== s.layer) continue;
1079
+ if (s.name && String(sh.name || '') !== String(s.name)) continue;
1080
+ push(sh);
1081
+ }
1082
+ return out;
1083
+ }
1084
+
1085
+ function layerIds(proj) {
1086
+ var out = [];
1087
+ for (var i = 0; i < (proj.layers || []).length; i++) out.push(proj.layers[i].id);
1088
+ return out;
1089
+ }
1090
+
1091
+ function firstLayerId(proj) {
1092
+ var ids = layerIds(proj);
1093
+ return ids.length ? ids[0] : 'l1';
1094
+ }
1095
+
1096
+ function newShapeId(proj) {
1097
+ var used = {};
1098
+ for (var i = 0; i < (proj.shapes || []).length; i++) used[proj.shapes[i].id] = true;
1099
+ var n = 1;
1100
+ while (used['s' + n]) n++;
1101
+ return 's' + n;
1102
+ }
1103
+
1104
+ function shapeCenter(sh) {
1105
+ var b = shapeBBox(sh);
1106
+ return { x: b.x + b.w / 2, y: b.y + b.h / 2 };
1107
+ }
1108
+
1109
+ // 平移:无 transform(或纯平移)的直接改几何字段(diff 友好);否则累加矩阵平移
1110
+ function moveShapeBy(sh, dx, dy) {
1111
+ var t = sh.transform;
1112
+ if (t && !mIsTranslateOnly(t)) {
1113
+ var m = t.slice();
1114
+ m[4] += dx;
1115
+ m[5] += dy;
1116
+ sh.transform = mNorm(m);
1117
+ return;
1118
+ }
1119
+ if (t) { // 纯平移:吸收进几何字段,避免矩阵残留
1120
+ dx += t[4];
1121
+ dy += t[5];
1122
+ delete sh.transform;
1123
+ }
1124
+ var type = sh.type;
1125
+ if (type === 'rect' || type === 'text') {
1126
+ sh.x = num(sh.x, 0) + dx;
1127
+ sh.y = num(sh.y, 0) + dy;
1128
+ } else if (type === 'circle' || type === 'ellipse') {
1129
+ sh.cx = num(sh.cx, 0) + dx;
1130
+ sh.cy = num(sh.cy, 0) + dy;
1131
+ } else if (type === 'line') {
1132
+ sh.x1 = num(sh.x1, 0) + dx;
1133
+ sh.y1 = num(sh.y1, 0) + dy;
1134
+ sh.x2 = num(sh.x2, 0) + dx;
1135
+ sh.y2 = num(sh.y2, 0) + dy;
1136
+ } else if (type === 'polyline' || type === 'polygon') {
1137
+ var pts = sh.points || [];
1138
+ var out = [];
1139
+ for (var i = 0; i < pts.length; i++) out.push([num(pts[i][0], 0) + dx, num(pts[i][1], 0) + dy]);
1140
+ sh.points = out;
1141
+ } else {
1142
+ // path:坐标内嵌于 d,无法逐点平移 → 退化为矩阵平移
1143
+ sh.transform = mNorm([1, 0, 0, 1, dx, dy]);
1144
+ }
1145
+ }
1146
+
1147
+ // 改尺寸:about = 'nw'(默认,保持左上角)| 'center'(保持中心)
1148
+ function resizeShape(sh, opts) {
1149
+ var o = opts || {};
1150
+ var about = o.about === 'center' ? 'center' : 'nw';
1151
+ var before = shapeBBox(sh);
1152
+ var type = sh.type;
1153
+ if (type === 'rect') {
1154
+ var w = o.w === undefined ? num(sh.w, 0) : Math.max(0, num(o.w, 0));
1155
+ var h = o.h === undefined ? num(sh.h, 0) : Math.max(0, num(o.h, 0));
1156
+ sh.w = w;
1157
+ sh.h = h;
1158
+ } else if (type === 'circle') {
1159
+ sh.r = o.r === undefined ? num(sh.r, 0) * num(o.scale, 1) : Math.max(0, num(o.r, 0));
1160
+ } else if (type === 'ellipse') {
1161
+ sh.rx = o.rx === undefined ? num(sh.rx, 0) * num(o.scale, 1) : Math.max(0, num(o.rx, 0));
1162
+ sh.ry = o.ry === undefined ? num(sh.ry, 0) * num(o.scale, 1) : Math.max(0, num(o.ry, 0));
1163
+ } else if (type === 'text') {
1164
+ sh.fontSize = Math.max(1, o.fontSize === undefined ? num(sh.fontSize, 16) * num(o.scale, 1) : num(o.fontSize, 16));
1165
+ } else {
1166
+ throw new Error('shape.resize 不支持 ' + type + '(path/polyline/polygon 请用 art_edit 的 shape.set 或重建)');
1167
+ }
1168
+ if (about === 'center') {
1169
+ var after = shapeBBox(sh);
1170
+ moveShapeBy(sh, (before.x + before.w / 2) - (after.x + after.w / 2),
1171
+ (before.y + before.h / 2) - (after.y + after.h / 2));
1172
+ }
1173
+ return before;
1174
+ }
1175
+
1176
+ // 允许通过 shape.set 改的样式字段(id/type 等结构性字段不允许改)
1177
+ var SETTABLE = ['fill', 'stroke', 'strokeWidth', 'opacity', 'name', 'layer', 'anchor',
1178
+ 'fontWeight', 'fontSize', 'rx', 'visible', 'href', 'text'];
1179
+
1180
+ function setShapeProps(ctx, proj, sh, set) {
1181
+ var applied = [];
1182
+ for (var k in set) {
1183
+ if (!Object.prototype.hasOwnProperty.call(set, k)) continue;
1184
+ if (SETTABLE.indexOf(k) < 0) {
1185
+ throw new Error('shape.set 不允许修改字段: ' + k + '(可改:' + SETTABLE.join('/') + ')');
1186
+ }
1187
+ if (k === 'layer') {
1188
+ var ids = layerIds(proj);
1189
+ if (ids.indexOf(set[k]) < 0) throw new Error('图层不存在: ' + set[k] + '(现有:' + ids.join(', ') + ')');
1190
+ }
1191
+ if (k === 'fill' || k === 'stroke') {
1192
+ sh[k] = sanitizeColorValue(set[k], null, k);
1193
+ } else {
1194
+ sh[k] = set[k];
1195
+ }
1196
+ applied.push(k + '=' + set[k]);
1197
+ }
1198
+ if (set.stroke !== undefined && set.strokeWidth === undefined && normColorOrRaw(sh.stroke) !== 'none' &&
1199
+ num(sh.strokeWidth, 0) <= 0) {
1200
+ sh.strokeWidth = 1; // 由 none 改为实色描边时补默认线宽,避免"设了颜色却看不见"
1201
+ }
1202
+ return applied;
1203
+ }
1204
+
1205
+ // ── 图元构造 ───────────────────────────────────────────────
1206
+ var ADD_GEOM = {
1207
+ rect: ['x', 'y', 'w', 'h', 'rx'],
1208
+ circle: ['cx', 'cy', 'r'],
1209
+ ellipse: ['cx', 'cy', 'rx', 'ry'],
1210
+ line: ['x1', 'y1', 'x2', 'y2'],
1211
+ polyline: ['points'],
1212
+ polygon: ['points'],
1213
+ path: ['d'],
1214
+ text: ['x', 'y', 'text', 'fontSize', 'fontWeight', 'anchor'],
1215
+ };
1216
+
1217
+ function makeShape(proj, spec) {
1218
+ var type = String(spec.type || '').toLowerCase();
1219
+ if (SHAPE_TYPES.indexOf(type) < 0) {
1220
+ throw new Error('未知图元类型: ' + spec.type + '(可用:' + SHAPE_TYPES.join(' / ') + ')');
1221
+ }
1222
+ var ids = layerIds(proj);
1223
+ var layer = spec.layer || firstLayerId(proj);
1224
+ if (ids.indexOf(layer) < 0) {
1225
+ throw new Error('图层不存在: ' + layer + '(现有:' + ids.join(', ') + ';可先 layer.add)');
1226
+ }
1227
+ var sh = { id: spec.id ? String(spec.id) : newShapeId(proj), type: type, layer: layer, z: 0 };
1228
+ if (spec.name) sh.name = String(spec.name);
1229
+
1230
+ var fields = ADD_GEOM[type];
1231
+ for (var i = 0; i < fields.length; i++) {
1232
+ var f = fields[i];
1233
+ if (spec[f] === undefined || spec[f] === null) continue;
1234
+ if (f === 'points') {
1235
+ sh.points = typeof spec[f] === 'string' ? pointsAttrToArr(spec[f]) : spec[f];
1236
+ } else if (f === 'text' || f === 'anchor' || f === 'fontWeight' || f === 'd') {
1237
+ sh[f] = String(spec[f]);
1238
+ } else {
1239
+ sh[f] = num(spec[f], 0);
1240
+ }
1241
+ }
1242
+ if (type === 'text' && (sh.text === undefined || sh.text === '')) {
1243
+ throw new Error('text 图元必须提供 text 内容');
1244
+ }
1245
+ if (type === 'path' && !sh.d) throw new Error('path 图元必须提供 d(路径数据)');
1246
+ if ((type === 'polyline' || type === 'polygon') && (!sh.points || sh.points.length < 2)) {
1247
+ throw new Error(type + ' 至少需要 2 个点(points 形如 "0,0 40,0 40,30")');
1248
+ }
1249
+ if (type === 'text' && sh.fontSize === undefined) sh.fontSize = 16;
1250
+
1251
+ if (type !== 'line') sh.fill = sanitizeColorValue(spec.fill === undefined ? '#000000' : spec.fill, null, 'fill');
1252
+ sh.stroke = sanitizeColorValue(spec.stroke === undefined ? 'none' : spec.stroke, null, 'stroke');
1253
+ sh.strokeWidth = spec.strokeWidth === undefined
1254
+ ? (normColorOrRaw(sh.stroke) === 'none' ? 0 : 1)
1255
+ : clamp(num(spec.strokeWidth, 1), 0, LIMIT_CANVAS);
1256
+ sh.opacity = spec.opacity === undefined ? 1 : clamp(num(spec.opacity, 1), 0, 1);
1257
+ return sh;
1258
+ }
1259
+
1260
+ // ── op 引擎(命令式编辑链,与 UI 操作同源) ────────────────
1261
+ function applyOps(proj, ops) {
1262
+ var log = [];
1263
+ for (var i = 0; i < ops.length; i++) {
1264
+ var op = ops[i] || {};
1265
+ var name = String(op.op || '');
1266
+ if (name === 'shape.add') {
1267
+ var sh = makeShape(proj, op);
1268
+ var sameLayer = shapesOfLayerInZOrder(proj, sh.layer);
1269
+ sh.z = sameLayer.length;
1270
+ proj.shapes.push(sh);
1271
+ log.push('新增 ' + sh.type + ' ' + sh.id + '(图层 ' + sh.layer + ',z=' + sh.z + ')');
1272
+ } else if (name === 'shape.remove') {
1273
+ var dels = resolveTargets(proj, op);
1274
+ var keep = [];
1275
+ var removed = 0;
1276
+ var delIds = {};
1277
+ for (var a = 0; a < dels.length; a++) delIds[dels[a].id] = true;
1278
+ for (var b = 0; b < proj.shapes.length; b++) {
1279
+ if (delIds[proj.shapes[b].id]) { removed++; continue; }
1280
+ keep.push(proj.shapes[b]);
1281
+ }
1282
+ proj.shapes = keep;
1283
+ log.push('删除 ' + removed + ' 个图元');
1284
+ } else if (name === 'shape.duplicate') {
1285
+ var srcs = resolveTargets(proj, op);
1286
+ var dx = num(op.dx, 16);
1287
+ var dy = num(op.dy, 16);
1288
+ for (var d = 0; d < srcs.length; d++) {
1289
+ var copy = JSON.parse(JSON.stringify(srcs[d]));
1290
+ copy.id = op.newId ? String(op.newId) : newShapeId(proj);
1291
+ moveShapeBy(copy, dx, dy);
1292
+ copy.z = shapesOfLayerInZOrder(proj, copy.layer).length;
1293
+ proj.shapes.push(copy);
1294
+ log.push('复制 ' + srcs[d].id + ' → ' + copy.id + '(偏移 ' + fmtNum(dx) + ',' + fmtNum(dy) + ')');
1295
+ }
1296
+ } else if (name === 'shape.move') {
1297
+ var mv = resolveTargets(proj, op);
1298
+ for (var m = 0; m < mv.length; m++) {
1299
+ if (op.to && typeof op.to === 'object') {
1300
+ var box = shapeBBox(mv[m]);
1301
+ var tx = num(op.to.x, box.x);
1302
+ var ty = num(op.to.y, box.y);
1303
+ moveShapeBy(mv[m], tx - box.x, ty - box.y);
1304
+ } else {
1305
+ moveShapeBy(mv[m], num(op.dx, 0), num(op.dy, 0));
1306
+ }
1307
+ }
1308
+ log.push('平移 ' + mv.length + ' 个图元' + (op.to ? '(到指定坐标)' : '(dx=' + fmtNum(num(op.dx, 0)) + ', dy=' + fmtNum(num(op.dy, 0)) + ')'));
1309
+ } else if (name === 'shape.resize') {
1310
+ var rz = resolveTargets(proj, op);
1311
+ for (var r = 0; r < rz.length; r++) {
1312
+ resizeShape(rz[r], { w: op.w, h: op.h, r: op.r, rx: op.rx, ry: op.ry, scale: op.scale, fontSize: op.fontSize, about: op.about });
1313
+ }
1314
+ log.push('调整 ' + rz.length + ' 个图元尺寸(about=' + (op.about === 'center' ? 'center' : 'nw') + ')');
1315
+ } else if (name === 'shape.set') {
1316
+ var sts = resolveTargets(proj, op);
1317
+ if (!op.set || typeof op.set !== 'object') throw new Error('shape.set 需要 set 对象(如 {"fill":"#2563EB"})');
1318
+ for (var s = 0; s < sts.length; s++) setShapeProps(null, proj, sts[s], op.set);
1319
+ log.push('修改 ' + sts.length + ' 个图元属性:' + keysOf(op.set).join(', '));
1320
+ } else if (name === 'shape.text') {
1321
+ var txs = resolveTargets(proj, op);
1322
+ if (op.text === undefined) throw new Error('shape.text 需要 text 参数');
1323
+ for (var t = 0; t < txs.length; t++) {
1324
+ if (txs[t].type !== 'text') throw new Error('shape.text 只能用于 text 图元(' + txs[t].id + ' 是 ' + txs[t].type + ')');
1325
+ txs[t].text = String(op.text);
1326
+ }
1327
+ log.push('更新 ' + txs.length + ' 个文本内容');
1328
+ } else if (name === 'shape.align') {
1329
+ log.push(applyAlign(proj, op));
1330
+ } else if (name === 'shape.distribute') {
1331
+ log.push(applyDistribute(proj, op));
1332
+ } else if (name === 'shape.z') {
1333
+ log.push(applyZOrder(proj, op));
1334
+ } else if (name === 'layer.add') {
1335
+ var lid = String(op.id || ('l' + (proj.layers.length + 1)));
1336
+ if (layerIds(proj).indexOf(lid) >= 0) throw new Error('图层已存在: ' + lid);
1337
+ proj.layers.push({ id: lid, name: op.name ? String(op.name) : ('图层 ' + (proj.layers.length + 1)), visible: op.visible === false ? false : true });
1338
+ log.push('新增图层 ' + lid + '(' + (op.name || '') + ')');
1339
+ } else if (name === 'layer.remove') {
1340
+ var rid = String(op.id || '');
1341
+ if (layerIds(proj).indexOf(rid) < 0) throw new Error('图层不存在: ' + rid);
1342
+ var has = shapesOfLayerInZOrder(proj, rid);
1343
+ if (has.length) {
1344
+ if (!op.moveTo) throw new Error('图层 ' + rid + ' 还有 ' + has.length + ' 个图元(传 moveTo 指定迁移目标层,或先删图元)');
1345
+ if (layerIds(proj).indexOf(op.moveTo) < 0) throw new Error('目标图层不存在: ' + op.moveTo);
1346
+ for (var q = 0; q < has.length; q++) has[q].layer = op.moveTo;
1347
+ }
1348
+ var rest = [];
1349
+ for (var l = 0; l < proj.layers.length; l++) if (proj.layers[l].id !== rid) rest.push(proj.layers[l]);
1350
+ if (!rest.length) throw new Error('不能删除最后一个图层');
1351
+ proj.layers = rest;
1352
+ log.push('删除图层 ' + rid + (has.length ? '(' + has.length + ' 个图元迁移至 ' + op.moveTo + ')' : ''));
1353
+ } else if (name === 'layer.rename') {
1354
+ var lyr = findLayer(proj, op.id);
1355
+ if (!lyr) throw new Error('图层不存在: ' + op.id);
1356
+ lyr.name = String(op.name === undefined ? lyr.name : op.name);
1357
+ log.push('图层 ' + lyr.id + ' 重命名为 ' + lyr.name);
1358
+ } else if (name === 'layer.visible') {
1359
+ var lv = findLayer(proj, op.id);
1360
+ if (!lv) throw new Error('图层不存在: ' + op.id);
1361
+ lv.visible = op.visible === undefined ? !lv.visible : !!op.visible;
1362
+ log.push('图层 ' + lv.id + ' → ' + (lv.visible ? '显示' : '隐藏'));
1363
+ } else if (name === 'layer.reorder') {
1364
+ var idx = num(op.index, -1);
1365
+ var cur = -1;
1366
+ for (var g = 0; g < proj.layers.length; g++) if (proj.layers[g].id === op.id) cur = g;
1367
+ if (cur < 0) throw new Error('图层不存在: ' + op.id);
1368
+ if (idx < 0 || idx >= proj.layers.length) throw new Error('index 越界(0..' + (proj.layers.length - 1) + ')');
1369
+ var moved = proj.layers.splice(cur, 1)[0];
1370
+ proj.layers.splice(idx, 0, moved);
1371
+ log.push('图层 ' + moved.id + ' 移到位置 ' + idx);
1372
+ } else if (name === 'set.canvas') {
1373
+ if (op.width !== undefined) proj.canvas.width = clamp(Math.round(num(op.width, proj.canvas.width)), 1, LIMIT_CANVAS);
1374
+ if (op.height !== undefined) proj.canvas.height = clamp(Math.round(num(op.height, proj.canvas.height)), 1, LIMIT_CANVAS);
1375
+ if (op.background !== undefined) proj.canvas.background = sanitizeColorValue(op.background, null, 'background');
1376
+ if (op.viewBox) proj.canvas.viewBox = String(op.viewBox);
1377
+ else proj.canvas.viewBox = '0 0 ' + proj.canvas.width + ' ' + proj.canvas.height;
1378
+ log.push('画布 → ' + proj.canvas.width + '×' + proj.canvas.height + '(背景 ' + proj.canvas.background + ')');
1379
+ } else if (name === 'set.palette') {
1380
+ if (!op.colors || !op.colors.length) throw new Error('set.palette 需要 colors 数组');
1381
+ var cols = [];
1382
+ for (var p = 0; p < op.colors.length; p++) {
1383
+ if (!parseColor(op.colors[p])) throw new Error('色板值无法解析: ' + op.colors[p]);
1384
+ cols.push(String(op.colors[p]));
1385
+ }
1386
+ proj.palette = cols;
1387
+ log.push('色板 → ' + cols.length + ' 色');
1388
+ } else if (name === 'project.rename') {
1389
+ proj.meta.title = String(op.title === undefined ? proj.meta.title : op.title);
1390
+ log.push('标题 → ' + proj.meta.title);
1391
+ } else {
1392
+ throw new Error('未知 op: ' + name + '(可用:shape.add/remove/duplicate/move/resize/set/text/align/distribute/z、' +
1393
+ 'layer.add/remove/rename/visible/reorder、set.canvas/set.palette、project.rename)');
1394
+ }
1395
+ }
1396
+ normalizeZ(proj);
1397
+ return log;
1398
+ }
1399
+
1400
+ function findLayer(proj, id) {
1401
+ for (var i = 0; i < (proj.layers || []).length; i++) if (proj.layers[i].id === id) return proj.layers[i];
1402
+ return null;
1403
+ }
1404
+
1405
+ var ALIGN_KEYS = ['left', 'hcenter', 'right', 'top', 'vcenter', 'bottom'];
1406
+
1407
+ function applyAlign(proj, op) {
1408
+ var to = String(op.to || 'left');
1409
+ if (ALIGN_KEYS.indexOf(to) < 0) {
1410
+ throw new Error('未知对齐方式: ' + to + '(可用:' + ALIGN_KEYS.join(' / ') + ')');
1411
+ }
1412
+ var targets = resolveTargets(proj, op);
1413
+ var refBox;
1414
+ if (op.ref && op.ref !== 'canvas') {
1415
+ var refShape = findShape(proj, op.ref);
1416
+ if (!refShape) throw new Error('参照图元不存在: ' + op.ref);
1417
+ refBox = shapeBBox(refShape);
1418
+ } else {
1419
+ refBox = { x: 0, y: 0, w: proj.canvas.width, h: proj.canvas.height };
1420
+ }
1421
+ for (var i = 0; i < targets.length; i++) {
1422
+ var b = shapeBBox(targets[i]);
1423
+ var dx = 0;
1424
+ var dy = 0;
1425
+ if (to === 'left') dx = refBox.x - b.x;
1426
+ else if (to === 'hcenter') dx = (refBox.x + refBox.w / 2) - (b.x + b.w / 2);
1427
+ else if (to === 'right') dx = (refBox.x + refBox.w) - (b.x + b.w);
1428
+ else if (to === 'top') dy = refBox.y - b.y;
1429
+ else if (to === 'vcenter') dy = (refBox.y + refBox.h / 2) - (b.y + b.h / 2);
1430
+ else if (to === 'bottom') dy = (refBox.y + refBox.h) - (b.y + b.h);
1431
+ moveShapeBy(targets[i], dx, dy);
1432
+ }
1433
+ return '对齐 ' + targets.length + ' 个图元 → ' + to + '(参照 ' + (op.ref && op.ref !== 'canvas' ? op.ref : '画板') + ')';
1434
+ }
1435
+
1436
+ function applyDistribute(proj, op) {
1437
+ var axis = String(op.axis || 'h');
1438
+ if (axis !== 'h' && axis !== 'v') throw new Error('axis 只支持 h(水平)或 v(垂直)');
1439
+ var targets = resolveTargets(proj, op);
1440
+ if (targets.length < 3) throw new Error('等距分布至少需要 3 个图元(当前 ' + targets.length + ' 个)');
1441
+ var items = [];
1442
+ for (var i = 0; i < targets.length; i++) items.push({ sh: targets[i], b: shapeBBox(targets[i]) });
1443
+ items.sort(function (a, b) {
1444
+ return axis === 'h' ? (a.b.x - b.b.x) : (a.b.y - b.b.y);
1445
+ });
1446
+ var first = items[0].b;
1447
+ var last = items[items.length - 1].b;
1448
+ var span = axis === 'h' ? (last.x + last.w - first.x) : (last.y + last.h - first.y);
1449
+ var sum = 0;
1450
+ for (var k = 0; k < items.length; k++) sum += axis === 'h' ? items[k].b.w : items[k].b.h;
1451
+ var gap = (span - sum) / (items.length - 1);
1452
+ var cursor = axis === 'h' ? first.x : first.y;
1453
+ for (var n = 0; n < items.length; n++) {
1454
+ var box = items[n].b;
1455
+ if (n > 0) {
1456
+ var delta = cursor - (axis === 'h' ? box.x : box.y);
1457
+ moveShapeBy(items[n].sh, axis === 'h' ? delta : 0, axis === 'h' ? 0 : delta);
1458
+ }
1459
+ cursor += (axis === 'h' ? box.w : box.h) + gap;
1460
+ }
1461
+ return '等距分布 ' + items.length + ' 个图元(' + (axis === 'h' ? '水平' : '垂直') + ',间隙 ' + fmtNum(gap) + ')';
1462
+ }
1463
+
1464
+ function applyZOrder(proj, op) {
1465
+ var targets = resolveTargets(proj, op);
1466
+ if (isInt(op.z)) {
1467
+ for (var i = 0; i < targets.length; i++) targets[i].z = op.z;
1468
+ return '设置 ' + targets.length + ' 个图元 z=' + op.z;
1469
+ }
1470
+ var mode = String(op.mode || '');
1471
+ if (['front', 'back', 'forward', 'backward'].indexOf(mode) < 0) {
1472
+ throw new Error('shape.z 需要 z(整数)或 mode=front/back/forward/backward');
1473
+ }
1474
+ for (var t = 0; t < targets.length; t++) {
1475
+ var sh = targets[t];
1476
+ var list = shapesOfLayerInZOrder(proj, sh.layer);
1477
+ var idx = list.indexOf(sh);
1478
+ if (mode === 'front') sh.z = list.length + 10;
1479
+ else if (mode === 'back') sh.z = -1 - idx;
1480
+ else if (mode === 'forward') sh.z = (idx + 1 < list.length ? num(list[idx + 1].z, 0) + 0.5 : num(sh.z, 0) + 1);
1481
+ else if (mode === 'backward') sh.z = (idx > 0 ? num(list[idx - 1].z, 0) - 0.5 : num(sh.z, 0) - 1);
1482
+ }
1483
+ normalizeZ(proj);
1484
+ return '调整 ' + targets.length + ' 个图元层级(' + mode + ')';
1485
+ }
1486
+
1487
+ // ── 摘要 ───────────────────────────────────────────────────
1488
+ function projectSummary(proj, path) {
1489
+ var lines = [];
1490
+ var cv = proj.canvas;
1491
+ lines.push('## 画板 ' + (path || ''));
1492
+ lines.push('- 标题: ' + ((proj.meta && proj.meta.title) || '未命名画板'));
1493
+ lines.push('- 画布: ' + cv.width + '×' + cv.height + '(viewBox ' + cv.viewBox + ',背景 ' + cv.background + ')');
1494
+ var ly = [];
1495
+ for (var i = 0; i < proj.layers.length; i++) {
1496
+ var n = shapesOfLayerInZOrder(proj, proj.layers[i].id).length;
1497
+ ly.push(proj.layers[i].id + (proj.layers[i].visible === false ? '(隐藏)' : '') + ':' + n);
1498
+ }
1499
+ lines.push('- 图层: ' + proj.layers.length + '(' + ly.join(' / ') + ')');
1500
+ var byType = {};
1501
+ var union = null;
1502
+ var colors = {};
1503
+ for (var s = 0; s < proj.shapes.length; s++) {
1504
+ var sh = proj.shapes[s];
1505
+ byType[sh.type] = (byType[sh.type] || 0) + 1;
1506
+ union = bboxUnion(union, shapeBBox(sh));
1507
+ if (sh.fill && normColorOrRaw(sh.fill) === 'color') colors[String(sh.fill)] = true;
1508
+ if (sh.stroke && normColorOrRaw(sh.stroke) === 'color') colors[String(sh.stroke)] = true;
1509
+ }
1510
+ var typeStr = [];
1511
+ for (var t in byType) if (Object.prototype.hasOwnProperty.call(byType, t)) typeStr.push(t + ' ' + byType[t]);
1512
+ lines.push('- 图元: ' + proj.shapes.length + (typeStr.length ? '(' + typeStr.join(', ') + ')' : ''));
1513
+ if (union) {
1514
+ var cover = clamp(bboxArea(union) / Math.max(1, cv.width * cv.height) * 100, 0, 100);
1515
+ lines.push('- 覆盖范围: x ' + fmtNum(union.x) + '..' + fmtNum(union.x + union.w) +
1516
+ ',y ' + fmtNum(union.y) + '..' + fmtNum(union.y + union.h) + '(占画板 ' + cover.toFixed(1) + '%)');
1517
+ }
1518
+ lines.push('- 用色: ' + keysOf(colors).length + ' 种' + (keysOf(colors).length ? '(' + keysOf(colors).join(', ') + ')' : ''));
1519
+ lines.push('- 色板: ' + (proj.palette || []).length + ' 色(可用 art_edit set.palette 替换)');
1520
+ return lines.join('\n');
1521
+ }
1522
+
1523
+ // ── 校验器(7 项判据) ─────────────────────────────────────
1524
+ var TOL_ROUND = 1e-3; // 导出精度(3 位小数)对应的往返容差
1525
+
1526
+ function near(a, b) { return Math.abs(num(a, 0) - num(b, 0)) <= TOL_ROUND; }
1527
+
1528
+ function shapeRequiredFields(sh) {
1529
+ var t = sh.type;
1530
+ if (t === 'rect') return ['x', 'y', 'w', 'h'];
1531
+ if (t === 'circle') return ['cx', 'cy', 'r'];
1532
+ if (t === 'ellipse') return ['cx', 'cy', 'rx', 'ry'];
1533
+ if (t === 'line') return ['x1', 'y1', 'x2', 'y2'];
1534
+ if (t === 'path') return ['d'];
1535
+ if (t === 'text') return ['x', 'y', 'text', 'fontSize'];
1536
+ if (t === 'polyline' || t === 'polygon') return ['points'];
1537
+ return [];
1538
+ }
1539
+
1540
+ // 单图元结构检查 → 错误信息数组
1541
+ function shapeStructErrors(sh) {
1542
+ var errs = [];
1543
+ if (SHAPE_TYPES.indexOf(sh.type) < 0) {
1544
+ errs.push(sh.id + ': 未知类型 ' + sh.type);
1545
+ return errs;
1546
+ }
1547
+ var fields = shapeRequiredFields(sh);
1548
+ for (var i = 0; i < fields.length; i++) {
1549
+ var f = fields[i];
1550
+ if (f === 'points') {
1551
+ var pts = sh.points;
1552
+ if (!pts || pts.length < 2) { errs.push(sh.id + ': points 至少需要 2 个点'); continue; }
1553
+ for (var k = 0; k < pts.length; k++) {
1554
+ if (!isNum(num(pts[k][0], NaN)) || !isNum(num(pts[k][1], NaN))) {
1555
+ errs.push(sh.id + ': points[' + k + '] 非法');
1556
+ break;
1557
+ }
1558
+ }
1559
+ } else if (f === 'd') {
1560
+ if (!sh.d) errs.push(sh.id + ': path 缺少 d');
1561
+ } else if (f === 'text') {
1562
+ if (sh.text === undefined || sh.text === null || String(sh.text) === '') errs.push(sh.id + ': text 内容为空');
1563
+ } else if (!isNum(num(sh[f], NaN))) {
1564
+ errs.push(sh.id + ': ' + f + ' 非有限数值');
1565
+ }
1566
+ }
1567
+ var pos = { rect: ['w', 'h'], circle: ['r'], ellipse: ['rx', 'ry'], text: ['fontSize'] };
1568
+ if (pos[sh.type]) {
1569
+ for (var p = 0; p < pos[sh.type].length; p++) {
1570
+ var key = pos[sh.type][p];
1571
+ if (isNum(num(sh[key], NaN)) && num(sh[key], 0) <= 0) errs.push(sh.id + ': ' + key + ' 必须 > 0');
1572
+ }
1573
+ }
1574
+ if (sh.fill !== undefined && parseColor(sh.fill) === null) errs.push(sh.id + ': fill 无法解析(' + sh.fill + ')');
1575
+ if (sh.stroke !== undefined && parseColor(sh.stroke) === null) errs.push(sh.id + ': stroke 无法解析(' + sh.stroke + ')');
1576
+ if (sh.transform && !mIsIdentity(sh.transform)) {
1577
+ for (var m = 0; m < 6; m++) {
1578
+ if (!isNum(num(sh.transform[m], NaN))) { errs.push(sh.id + ': transform 非法'); break; }
1579
+ }
1580
+ }
1581
+ return errs;
1582
+ }
1583
+
1584
+ // 文本「背后」的颜色:按 (图层序, z) 升序找最后一个包住文本中心的实心图元;无则画板背景(缺省白)
1585
+ function textBackdrop(proj, sh) {
1586
+ var center = shapeCenter(sh);
1587
+ var found = null;
1588
+ for (var li = 0; li < (proj.layers || []).length; li++) {
1589
+ if (proj.layers[li].visible === false) continue;
1590
+ var list = shapesOfLayerInZOrder(proj, proj.layers[li].id);
1591
+ for (var i = 0; i < list.length; i++) {
1592
+ var o = list[i];
1593
+ if (o === sh || o.type === 'text') continue;
1594
+ var f = parseColor(o.fill);
1595
+ if (!f || f.none || f.a <= 0.05) continue;
1596
+ var b = shapeBBox(o);
1597
+ if (center.x >= b.x && center.x <= b.x + b.w && center.y >= b.y && center.y <= b.y + b.h) {
1598
+ found = blendOver({ r: f.r, g: f.g, b: f.b, a: f.a * clamp(num(o.opacity, 1), 0, 1), none: false, current: false },
1599
+ found || { r: 255, g: 255, b: 255, a: 1, none: false, current: false });
1600
+ }
1601
+ }
1602
+ }
1603
+ if (found) return { color: found, from: 'shape' };
1604
+ var bg = parseColor(proj.canvas.background);
1605
+ if (bg && !bg.none && bg.a > 0.05) return { color: bg, from: 'canvas' };
1606
+ return { color: { r: 255, g: 255, b: 255, a: 1, none: false, current: false }, from: 'default' };
1607
+ }
1608
+
1609
+ function isLargeText(sh) {
1610
+ var fs = num(sh.fontSize, 16);
1611
+ var fw = String(sh.fontWeight === undefined ? '400' : sh.fontWeight);
1612
+ var bold = fw === 'bold' || fw === 'bolder' || num(fw, 400) >= 600;
1613
+ return fs >= 24 || (bold && fs >= 18.66);
1614
+ }
1615
+
1616
+ // 工程往返比较(导出 SVG → 再导入)→ 差异描述数组
1617
+ function compareProjects(proj, back) {
1618
+ var diffs = [];
1619
+ if (back.canvas.width !== proj.canvas.width || back.canvas.height !== proj.canvas.height) {
1620
+ diffs.push('画布尺寸不一致: ' + proj.canvas.width + '×' + proj.canvas.height + ' → ' +
1621
+ back.canvas.width + '×' + back.canvas.height);
1622
+ }
1623
+ if (String(back.canvas.background) !== String(proj.canvas.background)) {
1624
+ diffs.push('背景不一致: ' + proj.canvas.background + ' → ' + back.canvas.background);
1625
+ }
1626
+ if ((back.layers || []).length !== (proj.layers || []).length) {
1627
+ diffs.push('图层数量不一致: ' + proj.layers.length + ' → ' + back.layers.length);
1628
+ }
1629
+ if (back.shapes.length !== proj.shapes.length) {
1630
+ diffs.push('图元数量不一致: ' + proj.shapes.length + ' → ' + back.shapes.length);
1631
+ return diffs; // 数量不同后逐项比没有意义
1632
+ }
1633
+ var LAYER_GEOM = {
1634
+ rect: ['x', 'y', 'w', 'h', 'rx'], circle: ['cx', 'cy', 'r'], ellipse: ['cx', 'cy', 'rx', 'ry'],
1635
+ line: ['x1', 'y1', 'x2', 'y2'], text: ['x', 'y', 'fontSize', 'text'],
1636
+ };
1637
+ for (var li = 0; li < proj.layers.length; li++) {
1638
+ var a = shapesOfLayerInZOrder(proj, proj.layers[li].id);
1639
+ var b = shapesOfLayerInZOrder(back, proj.layers[li].id);
1640
+ if (a.length !== b.length) {
1641
+ diffs.push('图层 ' + proj.layers[li].id + ' 图元数不一致: ' + a.length + ' → ' + b.length);
1642
+ continue;
1643
+ }
1644
+ for (var i = 0; i < a.length; i++) {
1645
+ var x = a[i];
1646
+ var y = b[i];
1647
+ if (x.id !== y.id) { diffs.push('第 ' + i + ' 项 id 不一致: ' + x.id + ' → ' + y.id); continue; }
1648
+ if (x.type !== y.type) { diffs.push(x.id + ': 类型不一致 ' + x.type + ' → ' + y.type); continue; }
1649
+ var keys = LAYER_GEOM[x.type] || [];
1650
+ for (var k = 0; k < keys.length; k++) {
1651
+ var key = keys[k];
1652
+ if (key === 'text') {
1653
+ if (String(x.text === undefined ? '' : x.text) !== String(y.text === undefined ? '' : y.text)) {
1654
+ diffs.push(x.id + ': text 内容不一致');
1655
+ }
1656
+ } else if ((x[key] === undefined) !== (y[key] === undefined)) {
1657
+ diffs.push(x.id + ': ' + key + ' 存在性不一致');
1658
+ } else if (x[key] !== undefined && !near(x[key], y[key])) {
1659
+ diffs.push(x.id + ': ' + key + ' ' + fmtNum(num(x[key], 0)) + ' → ' + fmtNum(num(y[key], 0)));
1660
+ }
1661
+ }
1662
+ if (x.type === 'polyline' || x.type === 'polygon') {
1663
+ var px = x.points || [];
1664
+ var py = y.points || [];
1665
+ if (px.length !== py.length) diffs.push(x.id + ': points 点数不一致');
1666
+ else {
1667
+ for (var p = 0; p < px.length; p++) {
1668
+ if (!near(px[p][0], py[p][0]) || !near(px[p][1], py[p][1])) {
1669
+ diffs.push(x.id + ': points[' + p + '] 不一致');
1670
+ break;
1671
+ }
1672
+ }
1673
+ }
1674
+ }
1675
+ if (x.type === 'path' && normPathD(x.d) !== normPathD(y.d)) diffs.push(x.id + ': path d 不一致');
1676
+ if (String(x.fill === undefined ? '' : x.fill) !== String(y.fill === undefined ? '' : y.fill)) {
1677
+ diffs.push(x.id + ': fill 不一致(' + x.fill + ' → ' + y.fill + ')');
1678
+ }
1679
+ if (String(x.stroke === undefined ? '' : x.stroke) !== String(y.stroke === undefined ? '' : y.stroke)) {
1680
+ diffs.push(x.id + ': stroke 不一致(' + x.stroke + ' → ' + y.stroke + ')');
1681
+ }
1682
+ if (!near(num(x.strokeWidth, 0), num(y.strokeWidth, 0))) diffs.push(x.id + ': strokeWidth 不一致');
1683
+ if (!near(num(x.opacity, 1), num(y.opacity, 1))) diffs.push(x.id + ': opacity 不一致');
1684
+ var mx = x.transform && !mIsIdentity(x.transform) ? x.transform : null;
1685
+ var my = y.transform && !mIsIdentity(y.transform) ? y.transform : null;
1686
+ if ((mx === null) !== (my === null)) {
1687
+ diffs.push(x.id + ': transform 存在性不一致');
1688
+ } else if (mx) {
1689
+ for (var mm = 0; mm < 6; mm++) {
1690
+ if (!near(mx[mm], my[mm])) { diffs.push(x.id + ': transform 不一致'); break; }
1691
+ }
1692
+ }
1693
+ if (diffs.length > 6) break;
1694
+ }
1695
+ }
1696
+ return diffs;
1697
+ }
1698
+
1699
+ function verifyProject(ctx, proj, opts) {
1700
+ var o = opts || {};
1701
+ var checks = [];
1702
+ var cv = proj.canvas;
1703
+
1704
+ // V1 画板
1705
+ var vb = vbNumbers(cv.viewBox);
1706
+ var v1pb = isInt(num(cv.width, 0)) && num(cv.width, 0) >= 1 && num(cv.width, 0) <= LIMIT_CANVAS &&
1707
+ isInt(num(cv.height, 0)) && num(cv.height, 0) >= 1 && num(cv.height, 0) <= LIMIT_CANVAS;
1708
+ var bgc = parseColor(cv.background);
1709
+ var v1 = v1pb && !!vb && vb[2] > 0 && vb[3] > 0 && bgc !== null;
1710
+ var v1m = cv.width + '×' + cv.height + ';viewBox ' + cv.viewBox + '(' + (vb ? '合法' : '非法') +
1711
+ ');背景 ' + cv.background + '(' + (bgc === null ? '无法解析' : '可解析') + ')';
1712
+ checks.push({ id: 'V1', name: '画板合法(尺寸 / viewBox / 背景色)', pass: v1, metric: v1m });
1713
+
1714
+ // V2 图元结构
1715
+ var errsAll = [];
1716
+ var seenId = {};
1717
+ var dupIds = 0;
1718
+ for (var i = 0; i < proj.shapes.length; i++) {
1719
+ var sh = proj.shapes[i];
1720
+ if (seenId[sh.id]) dupIds++;
1721
+ seenId[sh.id] = true;
1722
+ if (!sh.id) errsAll.push('存在空 id');
1723
+ if (layerIds(proj).indexOf(sh.layer) < 0) errsAll.push(sh.id + ': 图层不存在(' + sh.layer + ')');
1724
+ var es = shapeStructErrors(sh);
1725
+ for (var e = 0; e < es.length; e++) errsAll.push(es[e]);
1726
+ }
1727
+ if (dupIds) errsAll.push('重复 id ' + dupIds + ' 个');
1728
+ if (proj.shapes.length > LIMIT_SHAPES) errsAll.push('图元数 ' + proj.shapes.length + ' 超过上限 ' + LIMIT_SHAPES);
1729
+ var v2 = errsAll.length === 0;
1730
+ var v2m = proj.shapes.length + ' 个图元;结构问题 ' + errsAll.length + ' 处' +
1731
+ (errsAll.length ? '|' + errsAll.slice(0, 3).join('|') : '');
1732
+ checks.push({ id: 'V2', name: '图元结构合法(类型/必填字段/id 唯一/layer 存在)', pass: v2, metric: v2m });
1733
+
1734
+ // V3 视口可见性
1735
+ var canvasBox = { x: 0, y: 0, w: cv.width, h: cv.height };
1736
+ var outside = [];
1737
+ var partial = 0;
1738
+ var visible = 0;
1739
+ for (var v = 0; v < proj.shapes.length; v++) {
1740
+ var box = shapeBBox(proj.shapes[v]);
1741
+ var layerDef = findLayer(proj, proj.shapes[v].layer);
1742
+ var layerVisible = !layerDef || layerDef.visible !== false;
1743
+ if (!layerVisible) continue;
1744
+ visible++;
1745
+ if (!bboxOverlaps(box, canvasBox)) outside.push(proj.shapes[v].id);
1746
+ else if (!bboxContains(canvasBox, box, TOL_ROUND)) partial++;
1747
+ }
1748
+ var v3 = outside.length === 0 && visible > 0;
1749
+ var v3m = proj.shapes.length === 0 ? '画板为空(尚无任何图元)'
1750
+ : ('可见图元 ' + visible + '/' + proj.shapes.length + ';完全在画板外 ' + outside.length +
1751
+ (outside.length ? '(' + outside.slice(0, 3).join(', ') + ')' : '') + ';部分越界 ' + partial + '(提示项)');
1752
+ checks.push({ id: 'V3', name: '视口可见性(无图元完全落在画板外)', pass: v3, metric: v3m });
1753
+
1754
+ // V4 SVG 往返一致
1755
+ var svgText = projectToSvg(proj);
1756
+ var back = svgToProject(svgText, {}).project;
1757
+ var diffs = compareProjects(proj, back);
1758
+ var v4 = diffs.length === 0;
1759
+ var v4m = v4 ? ('SVG ' + svgText.length + ' 字符,往返 ' + proj.shapes.length + ' 个图元逐字段一致(容差 ' + TOL_ROUND + ')')
1760
+ : ('差异 ' + diffs.length + ' 处|' + diffs.slice(0, 3).join('|'));
1761
+ checks.push({ id: 'V4', name: 'SVG 往返一致(导出再导入,几何/样式/id 逐字段比对)', pass: v4, metric: v4m });
1762
+
1763
+ // V5 确定性
1764
+ var svg2 = projectToSvg(proj);
1765
+ var v5 = svgText === svg2;
1766
+ checks.push({
1767
+ id: 'V5',
1768
+ name: '确定性(两次导出位级一致)',
1769
+ pass: v5,
1770
+ metric: v5 ? ('两次导出同为 ' + svgText.length + ' 字符,逐字节一致') : '两次导出不一致(存在非确定性来源)',
1771
+ });
1772
+
1773
+ // V6 颜色与对比度
1774
+ var minContrast = o.minContrast === undefined || o.minContrast === null ? null : num(o.minContrast, 4.5);
1775
+ var badColors = [];
1776
+ var worst = null;
1777
+ var lowList = [];
1778
+ var textCount = 0;
1779
+ for (var c = 0; c < proj.shapes.length; c++) {
1780
+ var s6 = proj.shapes[c];
1781
+ if (s6.fill !== undefined && parseColor(s6.fill) === null) badColors.push(s6.id + '.fill');
1782
+ if (s6.stroke !== undefined && parseColor(s6.stroke) === null) badColors.push(s6.id + '.stroke');
1783
+ if (s6.type !== 'text') continue;
1784
+ textCount++;
1785
+ var fgRaw = parseColor(s6.fill === undefined ? '#000000' : s6.fill);
1786
+ var backdrop = textBackdrop(proj, s6);
1787
+ var threshold = minContrast === null ? (isLargeText(s6) ? 3.0 : 4.5) : minContrast;
1788
+ if (!fgRaw || fgRaw.none) {
1789
+ lowList.push({ id: s6.id, ratio: 0, threshold: threshold, note: 'fill 为 none(文字不可见)' });
1790
+ continue;
1791
+ }
1792
+ var fg = blendOver({ r: fgRaw.r, g: fgRaw.g, b: fgRaw.b, a: fgRaw.a * clamp(num(s6.opacity, 1), 0, 1), none: false, current: false },
1793
+ backdrop.color);
1794
+ var ratio = contrastRatio(fg, backdrop.color);
1795
+ if (worst === null || ratio < worst) worst = ratio;
1796
+ if (ratio < threshold) lowList.push({ id: s6.id, ratio: ratio, threshold: threshold, note: '' });
1797
+ }
1798
+ lowList.sort(function (a, b2) { return a.ratio - b2.ratio; });
1799
+ var v6 = badColors.length === 0 && lowList.length === 0;
1800
+ var v6m = '文本 ' + textCount + ' 个;颜色非法 ' + badColors.length +
1801
+ (badColors.length ? '(' + badColors.slice(0, 3).join(', ') + ')' : '') +
1802
+ ';最低对比度 ' + (worst === null ? 'n/a' : worst.toFixed(2) + ':1') +
1803
+ (lowList.length ? '|低于阈值 ' + lowList.length + ' 处:' +
1804
+ lowList.slice(0, 3).map(function (z) { return z.id + ' ' + z.ratio.toFixed(2) + ':1<' + z.threshold + (z.note ? ' ' + z.note : ''); }).join('|') : '');
1805
+ checks.push({ id: 'V6', name: '颜色合法 + 文本对比度达标(WCAG AA:4.5:1,大字 3:1)', pass: v6, metric: v6m });
1806
+
1807
+ // V7 图层 / 层级 / 安全
1808
+ var lerrs = [];
1809
+ var lseen = {};
1810
+ for (var l = 0; l < proj.layers.length; l++) {
1811
+ var ly = proj.layers[l];
1812
+ if (!ly.id) lerrs.push('存在空图层 id');
1813
+ if (lseen[ly.id]) lerrs.push('图层 id 重复: ' + ly.id);
1814
+ lseen[ly.id] = true;
1815
+ var zs = {};
1816
+ var kids = shapesOfLayerInZOrder(proj, ly.id);
1817
+ for (var q = 0; q < kids.length; q++) {
1818
+ var z = num(kids[q].z, 0);
1819
+ if (zs[z]) lerrs.push('图层 ' + ly.id + ' 内 z 重复: ' + z);
1820
+ zs[z] = true;
1821
+ }
1822
+ }
1823
+ var secFlags = [];
1824
+ if (/<script/i.test(svgText)) secFlags.push('<script>');
1825
+ if (/\son[a-z]+\s*=/i.test(svgText)) secFlags.push('on* 事件属性');
1826
+ if (/(?:href|xlink:href)\s*=\s*["']?\s*javascript:/i.test(svgText)) secFlags.push('javascript: URL');
1827
+ var v7 = lerrs.length === 0 && secFlags.length === 0;
1828
+ var v7m = '图层 ' + proj.layers.length + '(id 唯一' + (lerrs.length ? ':' + lerrs.slice(0, 2).join('|') : '') +
1829
+ ');SVG 脚本面 ' + (secFlags.length ? secFlags.join(', ') : '无');
1830
+ checks.push({ id: 'V7', name: '图层与层级合法 + 产物无脚本面', pass: v7, metric: v7m });
1831
+
1832
+ var passAll = true;
1833
+ for (var k2 = 0; k2 < checks.length; k2++) if (!checks[k2].pass) passAll = false;
1834
+
1835
+ var lines = [];
1836
+ lines.push('## 画板校验(' + (passAll ? '✅ 全部通过' : '❌ 存在失败项') + ')');
1837
+ lines.push('');
1838
+ for (var p2 = 0; p2 < checks.length; p2++) {
1839
+ lines.push(' ' + (checks[p2].pass ? '✅' : '❌') + ' ' + checks[p2].id + ' ' + checks[p2].name);
1840
+ lines.push(' 指标: ' + checks[p2].metric);
1841
+ }
1842
+ return { checks: checks, pass: passAll, report: lines.join('\n') };
1843
+ }
1844
+
1845
+ // ── 工具实现 ───────────────────────────────────────────────
1846
+ function argStr(args, key, def) {
1847
+ var v = args[key];
1848
+ if (v === undefined || v === null || v === '') return def;
1849
+ return String(v);
1850
+ }
1851
+
1852
+ function argInt(args, key, def) {
1853
+ var v = args[key];
1854
+ if (v === undefined || v === null || v === '') return def;
1855
+ var n = typeof v === 'number' ? v : parseInt(v, 10);
1856
+ return isNum(n) ? n : def;
1857
+ }
1858
+
1859
+ function argBool(args, key, def) {
1860
+ var v = args[key];
1861
+ if (v === undefined || v === null || v === '') return def;
1862
+ if (typeof v === 'boolean') return v;
1863
+ return v === 'true' || v === '1' || v === 1;
1864
+ }
1865
+
1866
+ function artProject(args, exec, ctx) {
1867
+ var path = argStr(args, 'path', 'art.project.json');
1868
+ var mode = argStr(args, 'mode', 'show');
1869
+ if (mode === 'create') {
1870
+ if (ctx.fs.exists(path) && !argBool(args, 'overwrite', false)) {
1871
+ return '画板工程已存在: ' + path + '(要重建请传 overwrite=true;改内容请用 art_edit)';
1872
+ }
1873
+ var proj = emptyProject(argStr(args, 'title', undefined), args.width, args.height);
1874
+ if (args.background !== undefined) proj.canvas.background = sanitizeColorValue(args.background, null, 'background');
1875
+ if (args.palette && args.palette.length) {
1876
+ var cols = [];
1877
+ for (var i = 0; i < args.palette.length; i++) {
1878
+ if (!parseColor(args.palette[i])) throw new Error('色板值无法解析: ' + args.palette[i]);
1879
+ cols.push(String(args.palette[i]));
1880
+ }
1881
+ proj.palette = cols;
1882
+ }
1883
+ if (args.layers && args.layers.length) {
1884
+ var lys = [];
1885
+ for (var k = 0; k < args.layers.length; k++) {
1886
+ var ly = args.layers[k] || {};
1887
+ lys.push({
1888
+ id: ly.id || ('l' + (k + 1)),
1889
+ name: ly.name || ('图层 ' + (k + 1)),
1890
+ visible: ly.visible === false ? false : true,
1891
+ });
1892
+ }
1893
+ proj.layers = lys;
1894
+ }
1895
+ saveProject(ctx, path, proj);
1896
+ return '✅ 已创建画板工程: ' + path + '\n\n' + projectSummary(proj, path);
1897
+ }
1898
+ if (mode === 'show') {
1899
+ var p1 = loadProject(ctx, path);
1900
+ return projectSummary(p1, path);
1901
+ }
1902
+ if (mode === 'update') {
1903
+ var p2 = loadProject(ctx, path);
1904
+ var ops = [];
1905
+ if (args.title) ops.push({ op: 'project.rename', title: args.title });
1906
+ if (args.width !== undefined || args.height !== undefined || args.background !== undefined) {
1907
+ ops.push({ op: 'set.canvas', width: args.width, height: args.height, background: args.background });
1908
+ }
1909
+ if (!ops.length) return '未提供任何要更新的字段(title / width / height / background 至少一个)';
1910
+ var log = applyOps(p2, ops);
1911
+ saveProject(ctx, path, p2);
1912
+ return '✅ 已更新画板: ' + path + '\n\n' + log.join('\n') + '\n\n' + projectSummary(p2, path);
1913
+ }
1914
+ throw new Error('未知 mode: ' + mode + '(可用 create / show / update)');
1915
+ }
1916
+
1917
+ function artEdit(args, exec, ctx) {
1918
+ var path = argStr(args, 'path', 'art.project.json');
1919
+ var proj = loadProject(ctx, path);
1920
+ var ops = args.ops;
1921
+ if (args.op) {
1922
+ ops = [{
1923
+ op: args.op, ids: args.ids, id: args.id, type: args.type, layer: args.layer, name: args.name,
1924
+ all: args.all, ref: args.ref, newId: args.newId, moveTo: args.moveTo,
1925
+ x: args.x, y: args.y, w: args.w, h: args.h, r: args.r, cx: args.cx, cy: args.cy,
1926
+ rx: args.rx, ry: args.ry, x1: args.x1, y1: args.y1, x2: args.x2, y2: args.y2,
1927
+ points: args.points, d: args.d, text: args.text, fontSize: args.fontSize,
1928
+ fontWeight: args.fontWeight, anchor: args.anchor, fill: args.fill, stroke: args.stroke,
1929
+ strokeWidth: args.strokeWidth, opacity: args.opacity, dx: args.dx, dy: args.dy, to: args.to,
1930
+ scale: args.scale, about: args.about, set: args.set, axis: args.axis, z: args.z, mode: args.mode,
1931
+ index: args.index, visible: args.visible, background: args.background, width: args.width,
1932
+ height: args.height, colors: args.colors, title: args.title, viewBox: args.viewBox,
1933
+ }];
1934
+ }
1935
+ if (!ops || !ops.length) throw new Error('需要 ops 数组(或 op + 同层参数)');
1936
+ var log = applyOps(proj, ops);
1937
+ saveProject(ctx, path, proj);
1938
+ return '✅ 已应用 ' + ops.length + ' 条编辑命令到 ' + path + '\n\n' + log.join('\n') +
1939
+ '\n\n' + projectSummary(proj, path);
1940
+ }
1941
+
1942
+ function artImport(args, exec, ctx) {
1943
+ var path = argStr(args, 'path', undefined);
1944
+ if (!path) throw new Error('需要 path(要导入的 SVG 文件)');
1945
+ var out = argStr(args, 'out', 'art.project.json');
1946
+ if (ctx.fs.exists(out) && !argBool(args, 'overwrite', false)) {
1947
+ return '目标工程已存在: ' + out + '(要覆盖请传 overwrite=true,或换 out 路径)';
1948
+ }
1949
+ var text = ctx.fs.readFile(path);
1950
+ var res = svgToProject(text, { title: argStr(args, 'title', undefined) });
1951
+ saveProject(ctx, out, res.project);
1952
+ var tail = '';
1953
+ if (res.warnings.length) {
1954
+ tail = '\n\n⚠️ 导入提示(' + res.warnings.length + ' 条):\n - ' + res.warnings.slice(0, 8).join('\n - ');
1955
+ }
1956
+ return '✅ 已导入 SVG → ' + out + '\n来源: ' + path + '(' + String(text).length + ' 字符,' +
1957
+ res.project.shapes.length + ' 个图元)' + tail + '\n\n' + projectSummary(res.project, out);
1958
+ }
1959
+
1960
+ function artExport(args, exec, ctx) {
1961
+ var path = argStr(args, 'path', 'art.project.json');
1962
+ var proj = loadProject(ctx, path);
1963
+ var fmt = argStr(args, 'format', 'svg');
1964
+ var out = argStr(args, 'out', '');
1965
+ if (fmt === 'png') {
1966
+ // 如实说明边界(不假装能做到):沙箱无渲染器,PNG 由浏览器或后续 Node 桥导出
1967
+ return '⚠️ PNG 光栅化不在本插件能力内(goja 沙箱没有渲染器)。两条可行路径:\n' +
1968
+ ' 1) 在 ui-art 面板点「导出 PNG」——浏览器 canvas 绘制 SVG 后落盘(零依赖,推荐);\n' +
1969
+ ' 2) 需要服务端批量出图时,走「服务端导出链」阶段(Node 桥 @resvg/resvg-js,MPL-2.0)。\n' +
1970
+ '现在也可直接导出矢量:art_export format=svg(SVG 无损、可再编辑、可被 read_image 视觉核对)。';
1971
+ }
1972
+ if (fmt !== 'svg') {
1973
+ throw new Error('未知 format: ' + fmt + '(本插件可用 svg;png 见返回说明)');
1974
+ }
1975
+ if (!out) out = 'art.svg';
1976
+ var svg = projectToSvg(proj);
1977
+ ctx.fs.writeFile(out, svg);
1978
+ proj.artifacts = proj.artifacts || {};
1979
+ proj.artifacts.svg = out;
1980
+ saveProject(ctx, path, proj);
1981
+ return '✅ 已导出 SVG → ' + out + '\n' + svg.length + ' 字符,' + proj.shapes.length + ' 个图元,' +
1982
+ proj.layers.length + ' 个图层(工程 ' + path + ' 已登记产物)\n' +
1983
+ '提示:可用 read_image 直接「看」产物核对渲染;PNG 请在 ui-art 面板导出。';
1984
+ }
1985
+
1986
+ function artVerify(args, exec, ctx) {
1987
+ var path = argStr(args, 'path', 'art.project.json');
1988
+ var proj = loadProject(ctx, path);
1989
+ var res = verifyProject(ctx, proj, args);
1990
+ // ★ 旁挂校验报告(供 ui-art 面板展示):不写进工程 JSON —— 工程是真相源,必须保持纯净可 diff
1991
+ var reportPath = argStr(args, 'report', 'art.verify.json');
1992
+ var reportErr = '';
1993
+ try {
1994
+ ctx.fs.writeFile(reportPath, JSON.stringify({ ts: nowIso(), project: path, pass: res.pass, checks: res.checks }, null, 2));
1995
+ } catch (e) {
1996
+ reportErr = '\n⚠️ 校验报告写入失败(' + reportPath + '): ' + ((e && e.message) ? e.message : e) + '(不影响校验结论)';
1997
+ }
1998
+ var tail = res.pass ? '\n\n工程: ' + path + '(全部判据通过,可交付)' : '\n\n工程: ' + path + '(存在失败项——修完再导出)';
1999
+ return res.report + tail + '\n校验报告: ' + reportPath + '(供 UI 面板展示)' + reportErr;
2000
+ }
2001
+
2002
+ // ── 工具声明 ───────────────────────────────────────────────
2003
+ var TOOL_DEFS = [
2004
+ {
2005
+ name: 'art_project',
2006
+ description: '矢量画板工程管理(文本真相源 art.project.json):创建/查看/更新画布尺寸、背景色、色板、图层。图元几何用绝对坐标 + 可选 2D 仿射矩阵 transform;SVG 是唯一文本产物(零依赖生成、可回读)。PNG 光栅化不在沙箱内(见 art_export)。',
2007
+ usageGuide: '创作第一步:mode=create 建画板(width/height/background,可带 layers=[{id,name}])→ art_edit 加图元(shape.add)与调整(move/resize/set/align/distribute/z)→ art_export 出 SVG → art_verify 校验。mode=show 只看摘要,mode=update 改画布属性。已有 SVG 素材请用 art_import 导入。',
2008
+ category: '创作',
2009
+ parameters: {
2010
+ type: 'object',
2011
+ properties: {
2012
+ path: { type: 'string', description: '工程文件路径(默认 art.project.json;相对主项目根解析)' },
2013
+ mode: { type: 'string', description: 'create(新建)| show(默认,查看摘要)| update(改画布属性)' },
2014
+ title: { type: 'string', description: '可选:画板标题' },
2015
+ width: { type: 'integer', description: '可选:画布宽(像素,1..8192,默认 800)' },
2016
+ height: { type: 'integer', description: '可选:画布高(像素,1..8192,默认 600)' },
2017
+ background: { type: 'string', description: '可选:背景色(#RRGGBB / rgb() / 命名色 / none)' },
2018
+ palette: { type: 'array', description: '可选:色板(颜色字符串数组,默认 Tailwind 标准 10 色)' },
2019
+ layers: { type: 'array', description: '可选(仅 create):图层定义 [{id,name,visible}]' },
2020
+ overwrite: { type: 'boolean', description: '可选(仅 create):已存在时是否重建(默认 false)' },
2021
+ },
2022
+ },
2023
+ },
2024
+ {
2025
+ name: 'art_edit',
2026
+ description: '向画板工程追加编辑命令(命令式 op,与面板操作同源)。支持:图元(shape.add/remove/duplicate/move/resize/set/text/z)、排版(shape.align 六向对齐 + shape.distribute 等距分布)、图层(layer.add/remove/rename/visible/reorder)、画布与色板(set.canvas/set.palette)、标题(project.rename)。图元类型:rect/circle/ellipse/line/polyline/polygon/path/text。目标选择支持 ids / id / type / layer / name / all 过滤(不指定目标直接报错,避免误改全部)。',
2027
+ usageGuide: '示例:{"op":"shape.add","type":"rect","x":40,"y":40,"w":320,"h":180,"fill":"#2563EB","rx":8} / {"op":"shape.add","type":"text","x":40,"y":80,"text":"标题","fontSize":24,"fontWeight":"600","fill":"#111827"} / {"op":"shape.align","type":"rect","to":"hcenter"} / {"op":"shape.distribute","ids":["s1","s2","s3"],"axis":"h"} / move:{"op":"shape.move","ids":["s1"],"to":{"x":100,"y":60}} 或 dx/dy 相对偏移。改完用 art_verify 校验、art_export 出 SVG。',
2028
+ category: '创作',
2029
+ parameters: {
2030
+ type: 'object',
2031
+ properties: {
2032
+ path: { type: 'string', description: '可选:工程路径(默认 art.project.json)' },
2033
+ ops: { type: 'array', description: '编辑命令数组(按顺序应用)' },
2034
+ op: { type: 'string', description: '可选:单条 op 名(与同层参数合成为一条命令)' },
2035
+ ids: { type: 'array', description: '可选:目标图元 id 数组' },
2036
+ id: { type: 'string', description: '可选:单个目标(图元 id 或图层 id,按 op 语义)' },
2037
+ type: { type: 'string', description: '可选:按图元类型过滤目标 / shape.add 的图元类型' },
2038
+ fill: { type: 'string', description: '可选:填充色(none 表示不填充)' },
2039
+ stroke: { type: 'string', description: '可选:描边色' },
2040
+ },
2041
+ },
2042
+ },
2043
+ {
2044
+ name: 'art_import',
2045
+ description: '把 SVG 文本导入为画板工程(可 diff 的真相源)。解析 SVG 子集:svg 根尺寸/viewBox、g 分组(→ 图层,含 display:none → 隐藏图层)、rect/circle/ellipse/line/polyline/polygon/path/text、fill/stroke/stroke-width/opacity/font-size/font-weight/text-anchor、transform(translate/scale/rotate/matrix/skew 全支持,归一为矩阵)、style 内联声明。安全:<script>/<style> 一律丢弃,javascript: / url() 颜色值被剥离。不支持的元素(defs/use/image/gradient 等)会列入提示但不影响导入。',
2046
+ usageGuide: '把设计工具导出的 SVG 变成可编辑工程:art_import path=logo.svg out=art.project.json(out 已存在需 overwrite=true)→ 之后用 art_edit 改 → art_export 出 SVG → art_verify 校验。导入后如提示"部分越界",多为原素材画布尺寸与内容不符,可用 set.canvas 调整。',
2047
+ category: '创作',
2048
+ parameters: {
2049
+ type: 'object',
2050
+ properties: {
2051
+ path: { type: 'string', description: '要导入的 SVG 文件路径(相对主项目根解析)' },
2052
+ out: { type: 'string', description: '可选:输出的工程路径(默认 art.project.json)' },
2053
+ title: { type: 'string', description: '可选:画板标题(默认取 SVG 的 <title>)' },
2054
+ overwrite: { type: 'boolean', description: '可选:目标工程已存在时是否覆盖(默认 false)' },
2055
+ },
2056
+ required: ['path'],
2057
+ },
2058
+ },
2059
+ {
2060
+ name: 'art_export',
2061
+ description: '由画板工程导出产物。format=svg(默认):零依赖生成标准 SVG(含 width/height/viewBox/title/desc、按图层分组 <g>、固定属性顺序)。PNG 光栅化不在 goja 沙箱能力内(无渲染器)——本工具会返回两条可行路径(浏览器 canvas 导出 / 服务端 Node 桥导出链),而不是假装完成。',
2062
+ usageGuide: '导出前先 art_verify(V4 往返 / V5 确定性是核心契约)。SVG 产物可被 read_image 直接「看」来核对渲染,也可交回设计工具继续编辑(但改 SVG 不会自动回流工程——真相源始终是艺术工程 JSON)。',
2063
+ category: '创作',
2064
+ parameters: {
2065
+ type: 'object',
2066
+ properties: {
2067
+ path: { type: 'string', description: '可选:工程路径(默认 art.project.json)' },
2068
+ format: { type: 'string', description: '产物格式:svg(默认,矢量文本产物);png 会返回替代路径说明(沙箱无渲染器)' },
2069
+ out: { type: 'string', description: '可选:输出路径(默认 art.svg)' },
2070
+ },
2071
+ },
2072
+ },
2073
+ {
2074
+ name: 'art_verify',
2075
+ description: '画板工程专项自检(7 项判据):V1 画板合法(尺寸/viewBox/背景可解析)、V2 图元结构合法(类型/必填字段/正尺寸/id 唯一/layer 存在)、V3 视口可见性(无图元完全落在画板外;部分越界只作提示)、V4 SVG 往返一致(导出 SVG 再导入,几何/样式/id 逐字段比对,容差 1e-3)、V5 确定性(两次导出位级一致)、V6 颜色合法 + 文本对比度达标(WCAG AA,大字 3:1;背景取文本下方的实心图元或画板底色)、V7 图层与层级合法 + 产物无脚本面(无 <script>/on* 事件/javascript: URL)。任一 FAIL 说明产物与真相源不一致或存在设计缺陷,应视为构建失败。',
2076
+ usageGuide: '导出前跑一遍最省事;也可在 CI 里对工程文件跑(只读工程)。V4/V5 是本插件的核心契约;V6 用于挡住"看不见的文字"(低对比度),可用 minContrast 放宽阈值但建议保持 AA。结果同时写成旁挂报告(默认 art.verify.json)供 ui-art 面板展示——写报告不改工程本体。',
2077
+ category: '创作',
2078
+ parameters: {
2079
+ type: 'object',
2080
+ properties: {
2081
+ path: { type: 'string', description: '可选:工程路径(默认 art.project.json)' },
2082
+ report: { type: 'string', description: '可选:旁挂校验报告路径(默认 art.verify.json)' },
2083
+ minContrast: { type: 'number', description: '可选:文本对比度统一阈值(默认按 WCAG:4.5,大字 3.0)' },
2084
+ },
2085
+ },
2086
+ },
2087
+ ];
2088
+
2089
+ var IMPLS = {
2090
+ art_project: artProject,
2091
+ art_edit: artEdit,
2092
+ art_import: artImport,
2093
+ art_export: artExport,
2094
+ art_verify: artVerify,
2095
+ };
2096
+
2097
+ // 双轨入口:goja 沙箱把本文件当函数体执行 → 顶层 return;
2098
+ // Node 侧(测试/打包)走 module.exports。
2099
+ var PLUGIN = {
2100
+ name: 'tool-art',
2101
+ purpose: '矢量/图像创作工具面(纯 goja 零依赖):画板工程 JSON 文本真相源 + SVG 生成/解析/回读 + 六向对齐与等距分布 + 7 项自检(含 WCAG 对比度)—— 可 diff、产物可回读、位级可复现',
2102
+ // ★ logger 必须显式 inject:宿主按注入表装配 ctx 服务(jsplugin.go:1839 case "logger"),
2103
+ // 未声明时 ctx.logger 为 undefined —— 装载日志会静默丢失。
2104
+ inject: ['fs', 'logger'],
2105
+ apply: function (ctx) {
2106
+ for (var i = 0; i < TOOL_DEFS.length; i++) {
2107
+ (function (t) {
2108
+ ctx.tools.register({
2109
+ name: t.name,
2110
+ description: t.description,
2111
+ usageGuide: t.usageGuide,
2112
+ category: t.category,
2113
+ readOnly: !!t.readOnly,
2114
+ parameters: t.parameters,
2115
+ execute: function (args) { return IMPLS[t.name](args || {}, {}, ctx); },
2116
+ });
2117
+ })(TOOL_DEFS[i]);
2118
+ }
2119
+ try {
2120
+ // ★ 宿主 logger 是「服务工厂」:ctx.logger(scope) → {log,info,warn,…}
2121
+ if (ctx && typeof ctx.logger === 'function') {
2122
+ ctx.logger('tool-art').info('已注册 ' + TOOL_DEFS.length + ' 个工具(goja 轨 · 零依赖 · SVG 文本真相源)');
2123
+ }
2124
+ } catch (_) { /* ignore */ }
2125
+ },
2126
+ };
2127
+
2128
+ if (typeof module !== 'undefined' && module.exports) module.exports = PLUGIN;
2129
+ return PLUGIN;