@weasel-js/svg 0.5.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.
package/dist/index.js ADDED
@@ -0,0 +1,1384 @@
1
+ import { pathFromD, PATH_M, PATH_L, PATH_Z, boundsOfPath, dwarn, createInsertOp, PathBuilder, parseColor, rgbaToHex, PATH_C, PATH_Q } from '@weasel-js/core';
2
+
3
+ // src/parse.ts
4
+
5
+ // src/types.ts
6
+ var IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0];
7
+
8
+ // src/transform.ts
9
+ function multiply(a, b) {
10
+ return [
11
+ a[0] * b[0] + a[2] * b[1],
12
+ a[1] * b[0] + a[3] * b[1],
13
+ a[0] * b[2] + a[2] * b[3],
14
+ a[1] * b[2] + a[3] * b[3],
15
+ a[0] * b[4] + a[2] * b[5] + a[4],
16
+ a[1] * b[4] + a[3] * b[5] + a[5]
17
+ ];
18
+ }
19
+ function applyMatrix(m, x, y) {
20
+ return { x: m[0] * x + m[2] * y + m[4], y: m[1] * x + m[3] * y + m[5] };
21
+ }
22
+ function isIdentity(m, eps = 1e-9) {
23
+ return Math.abs(m[0] - 1) < eps && Math.abs(m[1]) < eps && Math.abs(m[2]) < eps && Math.abs(m[3] - 1) < eps && Math.abs(m[4]) < eps && Math.abs(m[5]) < eps;
24
+ }
25
+ var NUM_RE = /[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g;
26
+ function readNumbers(s) {
27
+ const out = [];
28
+ const matches = s.match(NUM_RE);
29
+ if (!matches) return out;
30
+ for (const m of matches) {
31
+ const n = Number(m);
32
+ if (Number.isFinite(n)) out.push(n);
33
+ }
34
+ return out;
35
+ }
36
+ function parseTransform(attr, onWarn) {
37
+ if (!attr) return IDENTITY_MATRIX;
38
+ let result = IDENTITY_MATRIX;
39
+ const fnRe = /([A-Za-z]+)\s*\(([^)]*)\)/g;
40
+ let match;
41
+ while ((match = fnRe.exec(attr)) !== null) {
42
+ const name = match[1].toLowerCase();
43
+ const nums = readNumbers(match[2]);
44
+ let local = null;
45
+ if (name === "matrix" && nums.length >= 6) {
46
+ local = [nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]];
47
+ } else if (name === "translate") {
48
+ const tx = nums[0] ?? 0;
49
+ const ty = nums[1] ?? 0;
50
+ local = [1, 0, 0, 1, tx, ty];
51
+ } else if (name === "scale") {
52
+ const sx = nums[0] ?? 1;
53
+ const sy = nums.length >= 2 ? nums[1] : sx;
54
+ local = [sx, 0, 0, sy, 0, 0];
55
+ } else if (name === "rotate") {
56
+ const ang = (nums[0] ?? 0) * Math.PI / 180;
57
+ const c = Math.cos(ang);
58
+ const s = Math.sin(ang);
59
+ const rot = [c, s, -s, c, 0, 0];
60
+ if (nums.length >= 3) {
61
+ const cx = nums[1];
62
+ const cy = nums[2];
63
+ local = multiply(multiply([1, 0, 0, 1, cx, cy], rot), [1, 0, 0, 1, -cx, -cy]);
64
+ } else {
65
+ local = rot;
66
+ }
67
+ } else if (name === "skewx" || name === "skewy") {
68
+ const ang = (nums[0] ?? 0) * Math.PI / 180;
69
+ const t = Math.tan(ang);
70
+ local = name === "skewx" ? [1, 0, t, 1, 0, 0] : [1, t, 0, 1, 0, 0];
71
+ } else {
72
+ onWarn?.(`unsupported transform function: ${name}`);
73
+ continue;
74
+ }
75
+ result = multiply(result, local);
76
+ }
77
+ return result;
78
+ }
79
+ function decomposeRotation(m, cx, cy, eps = 1e-4) {
80
+ const [a, b, c, d, e, f] = m;
81
+ if (Math.abs(a - d) > eps) return null;
82
+ if (Math.abs(b + c) > eps) return null;
83
+ const det = a * d - b * c;
84
+ if (Math.abs(det - 1) > eps) return null;
85
+ const theta = Math.atan2(b, a);
86
+ const expectedE = cx - cx * a + cy * b;
87
+ const expectedF = cy - cx * b - cy * a;
88
+ if (Math.abs(e - expectedE) > eps) return null;
89
+ if (Math.abs(f - expectedF) > eps) return null;
90
+ return theta;
91
+ }
92
+ function rotationComponent(m) {
93
+ return Math.atan2(m[1], m[0]);
94
+ }
95
+ function formatMatrix(m) {
96
+ if (isIdentity(m)) return null;
97
+ return `matrix(${m.map((n) => trimNumber(n)).join(" ")})`;
98
+ }
99
+ function trimNumber(n) {
100
+ if (!Number.isFinite(n)) return "0";
101
+ const r = Math.round(n * 1e6) / 1e6;
102
+ if (Number.isInteger(r)) return String(r);
103
+ return String(r);
104
+ }
105
+
106
+ // src/shapes.ts
107
+ var KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
108
+ function isAxisAlignedScale(m, eps = 1e-9) {
109
+ return Math.abs(m[1]) < eps && Math.abs(m[2]) < eps;
110
+ }
111
+ function transformPath(path, m) {
112
+ if (isIdentity(m)) return path;
113
+ if (path.kind === "rect") {
114
+ if (isAxisAlignedScale(m)) {
115
+ const a = m[0], d = m[3], e = m[4], f = m[5];
116
+ let x = path.x * a + e;
117
+ let y = path.y * d + f;
118
+ let w = path.width * a;
119
+ let h = path.height * d;
120
+ if (w < 0) {
121
+ x += w;
122
+ w = -w;
123
+ }
124
+ if (h < 0) {
125
+ y += h;
126
+ h = -h;
127
+ }
128
+ return { kind: "rect", x, y, width: w, height: h };
129
+ }
130
+ return transformPath(rectToPolygon(path), m);
131
+ }
132
+ const xs = path.coords;
133
+ const out = new Float32Array(xs.length);
134
+ for (let i = 0; i < xs.length; i += 2) {
135
+ const p = applyMatrix(m, xs[i], xs[i + 1]);
136
+ out[i] = p.x;
137
+ out[i + 1] = p.y;
138
+ }
139
+ return {
140
+ kind: "polygon",
141
+ commands: new Uint8Array(path.commands),
142
+ coords: out,
143
+ fillRule: path.fillRule
144
+ };
145
+ }
146
+ function rectToPolygon(r) {
147
+ const b = new PathBuilder();
148
+ b.moveTo(r.x, r.y);
149
+ b.lineTo(r.x + r.width, r.y);
150
+ b.lineTo(r.x + r.width, r.y + r.height);
151
+ b.lineTo(r.x, r.y + r.height);
152
+ b.close();
153
+ return b.build();
154
+ }
155
+ function rectElementToPath(x, y, width, height, rx, ry, m = IDENTITY_MATRIX) {
156
+ if (rx <= 0 && ry <= 0) {
157
+ return transformPath({ kind: "rect", x, y, width, height }, m);
158
+ }
159
+ const r = Math.min(Math.max(rx, ry), Math.min(width, height) / 2);
160
+ const r2 = Math.min(rx > 0 ? rx : r, ry > 0 ? ry : r, Math.min(width, height) / 2);
161
+ const rad = r2;
162
+ const k = KAPPA * rad;
163
+ const x2 = x + width;
164
+ const y2 = y + height;
165
+ const b = new PathBuilder();
166
+ b.moveTo(x + rad, y);
167
+ b.lineTo(x2 - rad, y);
168
+ b.curveTo(x2 - rad + k, y, x2, y + rad - k, x2, y + rad);
169
+ b.lineTo(x2, y2 - rad);
170
+ b.curveTo(x2, y2 - rad + k, x2 - rad + k, y2, x2 - rad, y2);
171
+ b.lineTo(x + rad, y2);
172
+ b.curveTo(x + rad - k, y2, x, y2 - rad + k, x, y2 - rad);
173
+ b.lineTo(x, y + rad);
174
+ b.curveTo(x, y + rad - k, x + rad - k, y, x + rad, y);
175
+ b.close();
176
+ return transformPath(b.build(), m);
177
+ }
178
+ function ellipseToPath(cx, cy, rx, ry, m = IDENTITY_MATRIX) {
179
+ const kx = KAPPA * rx;
180
+ const ky = KAPPA * ry;
181
+ const b = new PathBuilder();
182
+ b.moveTo(cx + rx, cy);
183
+ b.curveTo(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
184
+ b.curveTo(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
185
+ b.curveTo(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
186
+ b.curveTo(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
187
+ b.close();
188
+ return transformPath(b.build(), m);
189
+ }
190
+ function circleToPath(cx, cy, r, m = IDENTITY_MATRIX) {
191
+ return ellipseToPath(cx, cy, r, r, m);
192
+ }
193
+ function lineToPath(x1, y1, x2, y2, m = IDENTITY_MATRIX) {
194
+ const b = new PathBuilder();
195
+ b.moveTo(x1, y1);
196
+ b.lineTo(x2, y2);
197
+ return transformPath(b.build(), m);
198
+ }
199
+ function parsePoints(raw) {
200
+ const out = [];
201
+ const nums = raw.match(/[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g);
202
+ if (!nums) return out;
203
+ for (let i = 0; i + 1 < nums.length; i += 2) {
204
+ out.push({ x: parseFloat(nums[i]), y: parseFloat(nums[i + 1]) });
205
+ }
206
+ return out;
207
+ }
208
+ function polylineToPath(points, m = IDENTITY_MATRIX) {
209
+ if (points.length === 0) {
210
+ return { kind: "polygon", commands: new Uint8Array(), coords: new Float32Array(), fillRule: "nonzero" };
211
+ }
212
+ const b = new PathBuilder();
213
+ b.moveTo(points[0].x, points[0].y);
214
+ for (let i = 1; i < points.length; i++) b.lineTo(points[i].x, points[i].y);
215
+ return transformPath(b.build(), m);
216
+ }
217
+ function polygonToPath(points, m = IDENTITY_MATRIX) {
218
+ if (points.length === 0) {
219
+ return { kind: "polygon", commands: new Uint8Array(), coords: new Float32Array(), fillRule: "nonzero" };
220
+ }
221
+ const b = new PathBuilder();
222
+ b.moveTo(points[0].x, points[0].y);
223
+ for (let i = 1; i < points.length; i++) b.lineTo(points[i].x, points[i].y);
224
+ b.close();
225
+ return transformPath(b.build(), m);
226
+ }
227
+ function parsePaintAttr(raw) {
228
+ if (raw == null) return null;
229
+ const s = raw.trim();
230
+ if (s === "") return null;
231
+ if (s === "none") return { kind: "none" };
232
+ if (s === "currentColor" || s === "currentcolor") {
233
+ return { kind: "solid", color: "#000000", alpha: 1 };
234
+ }
235
+ const urlMatch = /^url\(\s*#([^)\s]+)\s*\)/.exec(s);
236
+ if (urlMatch) return { kind: "ref", id: urlMatch[1] };
237
+ try {
238
+ const [r, g, b, a] = parseColor(s);
239
+ return { kind: "solid", color: rgbaToHex([r, g, b]), alpha: a };
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ // src/gradients.ts
246
+ function collectGradients(svg, onWarn) {
247
+ const out = /* @__PURE__ */ new Map();
248
+ const defs = svg.getElementsByTagName("defs");
249
+ for (let d = 0; d < defs.length; d++) {
250
+ const root = defs[d];
251
+ for (let i = 0; i < root.children.length; i++) {
252
+ const child = root.children[i];
253
+ const tag = child.tagName.toLowerCase();
254
+ const id = child.getAttribute("id");
255
+ if (!id) continue;
256
+ if (tag === "lineargradient") {
257
+ const paint = readLinearGradient(child, onWarn);
258
+ if (paint) out.set(id, paint);
259
+ } else if (tag === "radialgradient") {
260
+ const paint = readRadialGradient(child, onWarn);
261
+ if (paint) out.set(id, paint);
262
+ } else if (tag !== "lineargradient" && tag !== "radialgradient") {
263
+ onWarn?.(`unsupported <defs> child: <${child.tagName}>`);
264
+ }
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+ function readStops(el, onWarn) {
270
+ const stops = [];
271
+ for (let i = 0; i < el.children.length; i++) {
272
+ const c = el.children[i];
273
+ if (c.tagName.toLowerCase() !== "stop") continue;
274
+ const offsetRaw = c.getAttribute("offset") ?? "0";
275
+ const offset = offsetRaw.endsWith("%") ? parseFloat(offsetRaw) / 100 : parseFloat(offsetRaw);
276
+ const colorAttr = c.getAttribute("stop-color") ?? "#000000";
277
+ const parsed = parsePaintAttr(colorAttr);
278
+ if (parsed && parsed.kind === "solid") {
279
+ const opacityAttr = c.getAttribute("stop-opacity");
280
+ const alpha = opacityAttr != null ? parseFloat(opacityAttr) : parsed.alpha;
281
+ stops.push({ offset, color: alpha < 1 ? applyAlpha(parsed.color, alpha) : parsed.color });
282
+ } else {
283
+ onWarn?.(`gradient stop has unrecognized stop-color: ${colorAttr}`);
284
+ stops.push({ offset, color: "#000000" });
285
+ }
286
+ }
287
+ return stops;
288
+ }
289
+ function applyAlpha(hex, alpha) {
290
+ const a = Math.max(0, Math.min(255, Math.round(alpha * 255)));
291
+ return `${hex}${a.toString(16).padStart(2, "0")}`;
292
+ }
293
+ function readLinearGradient(el, onWarn) {
294
+ const x1 = parseFloat(el.getAttribute("x1") ?? "0");
295
+ const y1 = parseFloat(el.getAttribute("y1") ?? "0");
296
+ const x2 = parseFloat(el.getAttribute("x2") ?? "1");
297
+ const y2 = parseFloat(el.getAttribute("y2") ?? "0");
298
+ const stops = readStops(el, onWarn);
299
+ return {
300
+ fill: "linear-gradient",
301
+ from: { x: x1, y: y1 },
302
+ to: { x: x2, y: y2 },
303
+ stops
304
+ };
305
+ }
306
+ function readRadialGradient(el, onWarn) {
307
+ const cx = parseFloat(el.getAttribute("cx") ?? "0.5");
308
+ const cy = parseFloat(el.getAttribute("cy") ?? "0.5");
309
+ const r = parseFloat(el.getAttribute("r") ?? "0.5");
310
+ const stops = readStops(el, onWarn);
311
+ return {
312
+ fill: "radial-gradient",
313
+ center: { x: cx, y: cy },
314
+ radius: r,
315
+ stops
316
+ };
317
+ }
318
+ var GradientRegistry = class {
319
+ byPaint = /* @__PURE__ */ new Map();
320
+ order = [];
321
+ counter = 0;
322
+ register(paint) {
323
+ const existing = this.byPaint.get(paint);
324
+ if (existing) return existing;
325
+ const id = `grad${this.counter++}`;
326
+ this.byPaint.set(paint, id);
327
+ this.order.push(paint);
328
+ return id;
329
+ }
330
+ /** Emit `<defs>...</defs>` XML for all registered gradients. */
331
+ toDefsXml() {
332
+ if (this.order.length === 0) return "";
333
+ const parts = ["<defs>"];
334
+ for (const paint of this.order) {
335
+ const id = this.byPaint.get(paint);
336
+ parts.push(gradientXml(id, paint));
337
+ }
338
+ parts.push("</defs>");
339
+ return parts.join("");
340
+ }
341
+ };
342
+ function gradientXml(id, paint) {
343
+ if (paint.fill === "linear-gradient") {
344
+ const stops = paint.stops.map(stopXml).join("");
345
+ return `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${trimNumber(paint.from.x)}" y1="${trimNumber(paint.from.y)}" x2="${trimNumber(paint.to.x)}" y2="${trimNumber(paint.to.y)}">${stops}</linearGradient>`;
346
+ }
347
+ if (paint.fill === "radial-gradient") {
348
+ const stops = paint.stops.map(stopXml).join("");
349
+ return `<radialGradient id="${id}" gradientUnits="userSpaceOnUse" cx="${trimNumber(paint.center.x)}" cy="${trimNumber(paint.center.y)}" r="${trimNumber(paint.radius)}">${stops}</radialGradient>`;
350
+ }
351
+ return "";
352
+ }
353
+ function stopXml(s) {
354
+ const m = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(s.color);
355
+ if (m) {
356
+ const alpha = parseInt(m[2], 16) / 255;
357
+ return `<stop offset="${trimNumber(s.offset)}" stop-color="#${m[1].toLowerCase()}" stop-opacity="${trimNumber(alpha)}"/>`;
358
+ }
359
+ return `<stop offset="${trimNumber(s.offset)}" stop-color="${s.color}"/>`;
360
+ }
361
+
362
+ // src/cascade.ts
363
+ var EMPTY_STYLE = {};
364
+ var INHERITABLE = [
365
+ "fill",
366
+ "fill-opacity",
367
+ "fill-rule",
368
+ "stroke",
369
+ "stroke-width",
370
+ "stroke-opacity",
371
+ "stroke-linecap",
372
+ "stroke-linejoin",
373
+ "stroke-dasharray",
374
+ "stroke-miterlimit",
375
+ "color",
376
+ "font-size",
377
+ "font-family",
378
+ "font-weight",
379
+ "font-style",
380
+ "text-anchor"
381
+ ];
382
+ function readStyleProp(el, prop) {
383
+ const style = el.getAttribute("style");
384
+ if (!style) return null;
385
+ const re = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, "i");
386
+ const m = re.exec(style);
387
+ if (!m) return null;
388
+ return m[1].trim();
389
+ }
390
+ function ownProp(el, prop) {
391
+ return readStyleProp(el, prop) ?? el.getAttribute(prop);
392
+ }
393
+ function deriveStyle(parent, el) {
394
+ let next = null;
395
+ for (const prop of INHERITABLE) {
396
+ const own = ownProp(el, prop);
397
+ if (own == null || own === "inherit") continue;
398
+ if (!next) next = { ...parent };
399
+ next[prop] = own;
400
+ }
401
+ return next ?? parent;
402
+ }
403
+ function resolveCurrentColor(raw, style) {
404
+ if (raw == null) return null;
405
+ if (raw.trim().toLowerCase() === "currentcolor") return style["color"] ?? "#000000";
406
+ return raw;
407
+ }
408
+
409
+ // src/parse.ts
410
+ var SUPPORTED_LEAF_TAGS = /* @__PURE__ */ new Set([
411
+ "rect",
412
+ "circle",
413
+ "ellipse",
414
+ "line",
415
+ "polyline",
416
+ "polygon",
417
+ "path"
418
+ ]);
419
+ var SUPPORTED_GROUP_TAGS = /* @__PURE__ */ new Set(["g", "svg"]);
420
+ var IGNORED_TAGS = /* @__PURE__ */ new Set(["defs", "linearGradient", "radialGradient", "title", "desc", "metadata"]);
421
+ function parseSvg(svg, opts = {}) {
422
+ const warnings = [];
423
+ const onWarn = (m) => {
424
+ warnings.push(m);
425
+ };
426
+ let doc;
427
+ try {
428
+ doc = new DOMParser().parseFromString(svg, "image/svg+xml");
429
+ } catch (e) {
430
+ return { nodes: [], warnings: [`failed to parse SVG: ${e.message}`] };
431
+ }
432
+ const errEl = doc.getElementsByTagName("parsererror")[0];
433
+ if (errEl) {
434
+ return { nodes: [], warnings: [`SVG parse error: ${errEl.textContent ?? "unknown"}`] };
435
+ }
436
+ const root = doc.documentElement;
437
+ if (!root || root.tagName.toLowerCase() !== "svg") {
438
+ return { nodes: [], warnings: ["root element is not <svg>"] };
439
+ }
440
+ const namespaces = opts.namespaces ?? {};
441
+ const uriToPrefix = /* @__PURE__ */ new Map();
442
+ for (const [prefix, uri] of Object.entries(namespaces)) {
443
+ uriToPrefix.set(uri, prefix);
444
+ }
445
+ const documentMeta = collectDocumentMeta(root, uriToPrefix);
446
+ const gradients = collectGradients(root, onWarn);
447
+ const rootStyle = deriveStyle(EMPTY_STYLE, root);
448
+ const nodes = parseChildren(root, IDENTITY_MATRIX, rootStyle, gradients, onWarn, uriToPrefix);
449
+ const result = { nodes, warnings };
450
+ if (documentMeta) result.documentMeta = documentMeta;
451
+ const viewBox = parseViewBoxAttr(root.getAttribute("viewBox"));
452
+ if (viewBox) result.viewBox = viewBox;
453
+ const widthAttr = root.getAttribute("width");
454
+ if (widthAttr != null) {
455
+ const n = parseFloat(widthAttr);
456
+ if (Number.isFinite(n)) result.width = n;
457
+ }
458
+ const heightAttr = root.getAttribute("height");
459
+ if (heightAttr != null) {
460
+ const n = parseFloat(heightAttr);
461
+ if (Number.isFinite(n)) result.height = n;
462
+ }
463
+ for (let i = 0; i < root.children.length; i++) {
464
+ const c = root.children[i];
465
+ if (c.tagName.toLowerCase() === "title") {
466
+ result.title = c.textContent ?? "";
467
+ break;
468
+ }
469
+ }
470
+ return result;
471
+ }
472
+ function parseViewBoxAttr(raw) {
473
+ if (raw == null) return void 0;
474
+ const parts = raw.trim().split(/[\s,]+/).filter(Boolean);
475
+ if (parts.length !== 4) return void 0;
476
+ const nums = parts.map(parseFloat);
477
+ if (nums.some((n) => !Number.isFinite(n))) return void 0;
478
+ return { x: nums[0], y: nums[1], width: nums[2], height: nums[3] };
479
+ }
480
+ function collectDocumentMeta(root, uriToPrefix) {
481
+ if (uriToPrefix.size === 0) return void 0;
482
+ const meta = {};
483
+ for (let i = 0; i < root.attributes.length; i++) {
484
+ const a = root.attributes[i];
485
+ if (!a.namespaceURI) continue;
486
+ const prefix = uriToPrefix.get(a.namespaceURI);
487
+ if (!prefix) continue;
488
+ const bucket = meta[prefix] ??= {};
489
+ (bucket.attrs ??= {})[a.localName] = a.value;
490
+ }
491
+ for (let i = 0; i < root.children.length; i++) {
492
+ const c = root.children[i];
493
+ if (!c.namespaceURI) continue;
494
+ const prefix = uriToPrefix.get(c.namespaceURI);
495
+ if (!prefix) continue;
496
+ const bucket = meta[prefix] ??= {};
497
+ const elements = bucket.elements ??= {};
498
+ const list = elements[c.localName] ??= [];
499
+ list.push(collectNamespacedElement(c, uriToPrefix));
500
+ }
501
+ return Object.keys(meta).length > 0 ? meta : void 0;
502
+ }
503
+ function collectNamespacedElement(el, uriToPrefix) {
504
+ const result = { attrs: {} };
505
+ for (let i = 0; i < el.attributes.length; i++) {
506
+ const a = el.attributes[i];
507
+ if (a.name.startsWith("xmlns")) continue;
508
+ result.attrs[a.localName] = a.value;
509
+ }
510
+ let hasChildElements = false;
511
+ for (let i = 0; i < el.children.length; i++) {
512
+ const c = el.children[i];
513
+ if (!c.namespaceURI) continue;
514
+ const prefix = uriToPrefix.get(c.namespaceURI);
515
+ if (!prefix) continue;
516
+ hasChildElements = true;
517
+ const children = result.children ??= {};
518
+ const list = children[c.localName] ??= [];
519
+ list.push(collectNamespacedElement(c, uriToPrefix));
520
+ }
521
+ if (!hasChildElements && el.textContent != null && el.textContent.trim() !== "") {
522
+ result.text = el.textContent;
523
+ }
524
+ return result;
525
+ }
526
+ function collectElementMeta(el, uriToPrefix) {
527
+ if (uriToPrefix.size === 0) return void 0;
528
+ const meta = {};
529
+ for (let i = 0; i < el.attributes.length; i++) {
530
+ const a = el.attributes[i];
531
+ if (!a.namespaceURI) continue;
532
+ const prefix = uriToPrefix.get(a.namespaceURI);
533
+ if (!prefix) continue;
534
+ const bucket = meta[prefix] ??= {};
535
+ (bucket.attrs ??= {})[a.localName] = a.value;
536
+ }
537
+ for (let i = 0; i < el.children.length; i++) {
538
+ const c = el.children[i];
539
+ if (!c.namespaceURI) continue;
540
+ const prefix = uriToPrefix.get(c.namespaceURI);
541
+ if (!prefix) continue;
542
+ const bucket = meta[prefix] ??= {};
543
+ const elements = bucket.elements ??= {};
544
+ const list = elements[c.localName] ??= [];
545
+ list.push(collectNamespacedElement(c, uriToPrefix));
546
+ }
547
+ return Object.keys(meta).length > 0 ? meta : void 0;
548
+ }
549
+ function parseChildren(parent, ctm, style, gradients, onWarn, uriToPrefix) {
550
+ const out = [];
551
+ for (let i = 0; i < parent.children.length; i++) {
552
+ const el = parent.children[i];
553
+ const ns = el.namespaceURI;
554
+ if (ns && ns !== "http://www.w3.org/2000/svg") continue;
555
+ const node = parseElement(el, ctm, style, gradients, onWarn, uriToPrefix);
556
+ if (node) {
557
+ if (Array.isArray(node)) out.push(...node);
558
+ else out.push(node);
559
+ }
560
+ }
561
+ return out;
562
+ }
563
+ function parseElement(el, ctm, style, gradients, onWarn, uriToPrefix) {
564
+ const tag = el.tagName.toLowerCase();
565
+ if (IGNORED_TAGS.has(tag)) return null;
566
+ if (tag === "g") {
567
+ const local = parseTransform(el.getAttribute("transform"), onWarn);
568
+ const childCtm = multiply(ctm, local);
569
+ const childStyle = deriveStyle(style, el);
570
+ const children = parseChildren(el, childCtm, childStyle, gradients, onWarn, uriToPrefix);
571
+ const opacity2 = readOpacityAttr(el, "opacity");
572
+ const group = { kind: "group", children };
573
+ if (opacity2 != null) group.opacity = opacity2;
574
+ const meta2 = collectElementMeta(el, uriToPrefix);
575
+ if (meta2) group.meta = meta2;
576
+ return group;
577
+ }
578
+ if (SUPPORTED_GROUP_TAGS.has(tag)) {
579
+ const childStyle = deriveStyle(style, el);
580
+ return parseChildren(el, ctm, childStyle, gradients, onWarn, uriToPrefix);
581
+ }
582
+ if (tag === "text") {
583
+ const textNode = parseTextElement(el, ctm, style, gradients, onWarn);
584
+ if (textNode && !Array.isArray(textNode) && textNode.kind === "text") {
585
+ const meta2 = collectElementMeta(el, uriToPrefix);
586
+ if (meta2) textNode.meta = meta2;
587
+ }
588
+ return textNode;
589
+ }
590
+ if (!SUPPORTED_LEAF_TAGS.has(tag)) {
591
+ onWarn(`unsupported element: <${el.tagName}>`);
592
+ return null;
593
+ }
594
+ const localTransform = parseTransform(el.getAttribute("transform"), onWarn);
595
+ let path = lowerLeaf(el, tag, ctm, onWarn);
596
+ if (!path) return null;
597
+ let rotation;
598
+ if (!isIdentity(localTransform)) {
599
+ const bounds = pathAabb(path);
600
+ const cx = bounds.x + bounds.width / 2;
601
+ const cy = bounds.y + bounds.height / 2;
602
+ const angle = decomposeRotation(localTransform, cx, cy);
603
+ if (angle != null) {
604
+ rotation = angle;
605
+ } else {
606
+ path = transformPath(path, localTransform);
607
+ const rotComp = rotationComponent(localTransform);
608
+ if (Math.abs(rotComp) > 1e-4) {
609
+ onWarn(`leaf transform has a rotational component that can't be cleanly stored as rotation; baked into geometry (may not round-trip identically)`);
610
+ }
611
+ }
612
+ }
613
+ const leafStyle = deriveStyle(style, el);
614
+ const fill = readPaint(leafStyle, "fill", "#000000", gradients, onWarn);
615
+ const stroke = readStroke(leafStyle, gradients, onWarn);
616
+ const opacity = readOpacityAttr(el, "opacity");
617
+ const fillRuleRaw = leafStyle["fill-rule"] ?? null;
618
+ if (fillRuleRaw === "evenodd" && path.kind === "polygon") {
619
+ path = { ...path, fillRule: "evenodd" };
620
+ }
621
+ const node = { kind: "path", path, fill };
622
+ if (stroke) node.stroke = stroke;
623
+ if (opacity != null) node.opacity = opacity;
624
+ if (rotation != null) node.rotation = rotation;
625
+ if (tag === "line" && (leafStyle["fill"] ?? null) == null) {
626
+ node.fill = { kind: "none" };
627
+ }
628
+ if (tag === "polyline" && !el.hasAttribute("fill")) ;
629
+ const meta = collectElementMeta(el, uriToPrefix);
630
+ if (meta) node.meta = meta;
631
+ return node;
632
+ }
633
+ function lowerLeaf(el, tag, m, onWarn) {
634
+ const num = (name, fallback = 0) => {
635
+ const v = el.getAttribute(name);
636
+ if (v == null) return fallback;
637
+ const n = parseFloat(v);
638
+ return Number.isFinite(n) ? n : fallback;
639
+ };
640
+ if (tag === "rect") {
641
+ const x = num("x", 0);
642
+ const y = num("y", 0);
643
+ const w = num("width", 0);
644
+ const h = num("height", 0);
645
+ const rx = num("rx", 0);
646
+ const ry = num("ry", 0);
647
+ return rectElementToPath(x, y, w, h, rx, ry, m);
648
+ }
649
+ if (tag === "circle") return circleToPath(num("cx"), num("cy"), num("r"), m);
650
+ if (tag === "ellipse") return ellipseToPath(num("cx"), num("cy"), num("rx"), num("ry"), m);
651
+ if (tag === "line") return lineToPath(num("x1"), num("y1"), num("x2"), num("y2"), m);
652
+ if (tag === "polyline") return polylineToPath(parsePoints(el.getAttribute("points") ?? ""), m);
653
+ if (tag === "polygon") return polygonToPath(parsePoints(el.getAttribute("points") ?? ""), m);
654
+ if (tag === "path") {
655
+ const d = el.getAttribute("d") ?? "";
656
+ if (!d.trim()) {
657
+ onWarn("<path> with empty d=");
658
+ return null;
659
+ }
660
+ const raw = pathFromD(d, onWarn);
661
+ const transformed = transformPath(raw, m);
662
+ if (transformed.kind === "polygon") {
663
+ const rect = polygonToRectIfAxisAligned(transformed);
664
+ if (rect) return rect;
665
+ }
666
+ return transformed;
667
+ }
668
+ return null;
669
+ }
670
+ function polygonToRectIfAxisAligned(p) {
671
+ const cmds = p.commands;
672
+ if (cmds.length !== 5) return null;
673
+ if (cmds[0] !== PATH_M) return null;
674
+ if (cmds[1] !== PATH_L || cmds[2] !== PATH_L || cmds[3] !== PATH_L) return null;
675
+ if (cmds[4] !== PATH_Z) return null;
676
+ const c = p.coords;
677
+ if (c.length !== 8) return null;
678
+ const x0 = c[0], y0 = c[1];
679
+ const x1 = c[2], y1 = c[3];
680
+ const x2 = c[4], y2 = c[5];
681
+ const x3 = c[6], y3 = c[7];
682
+ if (y0 !== y1 || x1 !== x2 || y2 !== y3 || x3 !== x0) return null;
683
+ const x = Math.min(x0, x1);
684
+ const y = Math.min(y0, y2);
685
+ const width = Math.abs(x1 - x0);
686
+ const height = Math.abs(y2 - y1);
687
+ if (width === 0 || height === 0) return null;
688
+ return { kind: "rect", x, y, width, height };
689
+ }
690
+ function pathAabb(path) {
691
+ if (path.kind === "rect") {
692
+ return { x: path.x, y: path.y, width: path.width, height: path.height };
693
+ }
694
+ return boundsOfPath(path);
695
+ }
696
+ function readPaint(style, attr, defaultColor, gradients, onWarn) {
697
+ const raw = resolveCurrentColor(style[attr] ?? null, style);
698
+ const opacityRaw = style[`${attr}-opacity`] ?? null;
699
+ const opacity = opacityRaw != null ? clamp01(parseFloat(opacityRaw)) : void 0;
700
+ if (raw == null) {
701
+ if (attr === "stroke") return { kind: "none" };
702
+ const out2 = { kind: "solid", color: defaultColor };
703
+ if (opacity != null) out2.opacity = opacity;
704
+ return out2;
705
+ }
706
+ const parsed = parsePaintAttr(raw);
707
+ if (!parsed) {
708
+ onWarn(`unrecognized ${attr} value: ${raw}`);
709
+ return { kind: "solid", color: defaultColor };
710
+ }
711
+ if (parsed.kind === "none") return { kind: "none" };
712
+ if (parsed.kind === "ref") {
713
+ const paint = gradients.get(parsed.id);
714
+ if (!paint) {
715
+ onWarn(`${attr} references unknown gradient #${parsed.id}`);
716
+ return { kind: "solid", color: defaultColor };
717
+ }
718
+ return { kind: "gradient", paint };
719
+ }
720
+ const out = { kind: "solid", color: parsed.color };
721
+ const a = opacity ?? (parsed.alpha < 1 ? parsed.alpha : void 0);
722
+ if (a != null) out.opacity = a;
723
+ return out;
724
+ }
725
+ function readStroke(style, gradients, onWarn) {
726
+ const inheritedStroke = style["stroke"] ?? null;
727
+ const inheritedWidth = style["stroke-width"] ?? null;
728
+ if (inheritedStroke == null && inheritedWidth == null) return void 0;
729
+ const paint = readPaint(style, "stroke", "#000000", gradients, onWarn);
730
+ if (paint.kind === "none") return void 0;
731
+ const width = inheritedWidth != null ? parseFloat(inheritedWidth) : 1;
732
+ const stroke = { paint, width };
733
+ const opacityRaw = style["stroke-opacity"] ?? null;
734
+ if (opacityRaw != null) {
735
+ const a = clamp01(parseFloat(opacityRaw));
736
+ if (Number.isFinite(a)) stroke.opacity = a;
737
+ }
738
+ const cap = style["stroke-linecap"] ?? null;
739
+ if (cap === "butt" || cap === "round" || cap === "square") {
740
+ stroke.cap = cap;
741
+ } else if (cap != null) {
742
+ onWarn(`unsupported stroke-linecap: ${cap}`);
743
+ }
744
+ const join = style["stroke-linejoin"] ?? null;
745
+ if (join === "miter" || join === "round" || join === "bevel") {
746
+ stroke.join = join;
747
+ } else if (join === "arcs" || join === "miter-clip") {
748
+ onWarn(`stroke-linejoin "${join}" not supported; falling back to miter`);
749
+ stroke.join = "miter";
750
+ } else if (join != null) {
751
+ onWarn(`unsupported stroke-linejoin: ${join}`);
752
+ }
753
+ const dashAttr = style["stroke-dasharray"] ?? null;
754
+ if (dashAttr != null && dashAttr.trim() !== "" && dashAttr.trim() !== "none") {
755
+ const parsed = parseDashArray(dashAttr);
756
+ if (parsed) stroke.dash = parsed;
757
+ else onWarn(`unrecognized stroke-dasharray: ${dashAttr}`);
758
+ }
759
+ const miterAttr = style["stroke-miterlimit"] ?? null;
760
+ if (miterAttr != null) {
761
+ const m = parseFloat(miterAttr);
762
+ if (Number.isFinite(m) && m >= 1) stroke.miterLimit = m;
763
+ else onWarn(`unrecognized stroke-miterlimit: ${miterAttr}`);
764
+ }
765
+ return stroke;
766
+ }
767
+ function parseDashArray(s) {
768
+ const tokens = s.trim().split(/[\s,]+/).filter(Boolean);
769
+ if (tokens.length === 0) return null;
770
+ const nums = [];
771
+ for (const t of tokens) {
772
+ const n = parseFloat(t);
773
+ if (!Number.isFinite(n) || n < 0) return null;
774
+ nums.push(n);
775
+ }
776
+ return nums.length % 2 === 1 ? [...nums, ...nums] : nums;
777
+ }
778
+ function readOpacityAttr(el, name) {
779
+ const raw = el.getAttribute(name);
780
+ if (raw == null) return void 0;
781
+ const n = parseFloat(raw);
782
+ return Number.isFinite(n) ? clamp01(n) : void 0;
783
+ }
784
+ function clamp01(n) {
785
+ return n < 0 ? 0 : n > 1 ? 1 : n;
786
+ }
787
+ function parseTextElement(el, ctm, style, gradients, onWarn) {
788
+ const num = (raw, fallback) => {
789
+ if (raw == null) return fallback;
790
+ const n = parseFloat(raw);
791
+ return Number.isFinite(n) ? n : fallback;
792
+ };
793
+ const localTransform = parseTransform(el.getAttribute("transform"), onWarn);
794
+ const m = ctm;
795
+ const rawX = num(el.getAttribute("x"), 0);
796
+ const rawY = num(el.getAttribute("y"), 0);
797
+ const ax = m[0] * rawX + m[2] * rawY + m[4];
798
+ const ay = m[1] * rawX + m[3] * rawY + m[5];
799
+ const leafStyle = deriveStyle(style, el);
800
+ const textStyle = readTextStyle(leafStyle, el, gradients, onWarn);
801
+ const fontSize = textStyle.fontSize ?? 16;
802
+ const lineHeight = textStyle.lineHeight ?? 1.2;
803
+ const dominantBaseline = el.getAttribute("dominant-baseline");
804
+ const explicitTopAnchor = dominantBaseline === "text-before-edge" || dominantBaseline === "hanging";
805
+ const topY = explicitTopAnchor ? ay : ay - fontSize;
806
+ const runs = [];
807
+ let plain = "";
808
+ for (let i = 0; i < el.childNodes.length; i++) {
809
+ const child = el.childNodes[i];
810
+ if (child.nodeType === 3) {
811
+ const t = child.textContent ?? "";
812
+ if (!t) continue;
813
+ runs.push({ text: t });
814
+ plain += t;
815
+ } else if (child.nodeType === 1) {
816
+ const sp = child;
817
+ if (sp.tagName.toLowerCase() !== "tspan") {
818
+ onWarn(`<text> child <${sp.tagName}> not supported; flattening text content`);
819
+ const t = sp.textContent ?? "";
820
+ if (t) {
821
+ runs.push({ text: t });
822
+ plain += t;
823
+ }
824
+ continue;
825
+ }
826
+ const run = readTspanRun(sp, gradients, leafStyle);
827
+ runs.push(run);
828
+ plain += run.text;
829
+ }
830
+ }
831
+ const dataW = num(el.getAttribute("data-weasel-width"), NaN);
832
+ const dataH = num(el.getAttribute("data-weasel-height"), NaN);
833
+ const width = Number.isFinite(dataW) ? dataW : 99999;
834
+ const lines = (plain.match(/\n/g)?.length ?? 0) + 1;
835
+ const height = Number.isFinite(dataH) ? dataH : fontSize * lineHeight * lines;
836
+ const opacity = readOpacityAttr(el, "opacity");
837
+ const node = {
838
+ kind: "text",
839
+ x: topY === ay ? ax : ax,
840
+ // x is unchanged by the baseline shift
841
+ y: topY,
842
+ width,
843
+ height,
844
+ text: plain
845
+ };
846
+ const hasStyling = runs.some(
847
+ (r) => r.bold || r.italic || r.fontFamily || r.fontSize != null || r.fill && ("color" in r.fill || "fill" in r.fill)
848
+ );
849
+ if (hasStyling) node.runs = runs;
850
+ if (Object.keys(textStyle).length > 0) node.style = textStyle;
851
+ if (opacity != null) node.opacity = opacity;
852
+ if (!isIdentity(localTransform)) {
853
+ const cx = node.x + node.width / 2;
854
+ const cy = node.y + node.height / 2;
855
+ const angle = decomposeRotation(localTransform, cx, cy);
856
+ if (angle != null) {
857
+ node.rotation = angle;
858
+ } else {
859
+ const rotComp = rotationComponent(localTransform);
860
+ if (Math.abs(rotComp) > 1e-4) {
861
+ onWarn(`<text> transform has a rotational component that can't be cleanly stored as rotation; dropped (may not round-trip identically)`);
862
+ }
863
+ }
864
+ }
865
+ return node;
866
+ }
867
+ function readTspanRun(el, gradients, style) {
868
+ const text = el.textContent ?? "";
869
+ const run = { text };
870
+ const fw = ownProp(el, "font-weight");
871
+ if (fw === "bold" || fw === "700" || fw === "bolder") run.bold = true;
872
+ const fs = ownProp(el, "font-style");
873
+ if (fs === "italic" || fs === "oblique") run.italic = true;
874
+ const ff = ownProp(el, "font-family");
875
+ if (ff) run.fontFamily = ff;
876
+ const sz = ownProp(el, "font-size");
877
+ if (sz != null) {
878
+ const n = parseFloat(sz);
879
+ if (Number.isFinite(n)) run.fontSize = n;
880
+ }
881
+ const tspanStyle = deriveStyle(style, el);
882
+ const fillAttr = resolveCurrentColor(ownProp(el, "fill"), tspanStyle);
883
+ if (fillAttr) {
884
+ const parsed = parsePaintAttr(fillAttr);
885
+ if (parsed?.kind === "solid") {
886
+ run.fill = { fill: "solid", color: parsed.color };
887
+ } else if (parsed?.kind === "ref") {
888
+ const paint = gradients.get(parsed.id);
889
+ if (paint) run.fill = paint;
890
+ }
891
+ }
892
+ return run;
893
+ }
894
+ function readTextStyle(style, el, gradients, onWarn) {
895
+ const out = {};
896
+ const sz = style["font-size"];
897
+ if (sz != null) {
898
+ const n = parseFloat(sz);
899
+ if (Number.isFinite(n)) out.fontSize = n;
900
+ }
901
+ const ff = style["font-family"];
902
+ if (ff) out.fontFamily = ff;
903
+ const fw = style["font-weight"];
904
+ if (fw != null) {
905
+ const n = parseFloat(fw);
906
+ out.fontWeight = Number.isFinite(n) ? n : fw;
907
+ }
908
+ const fs = style["font-style"];
909
+ if (fs === "italic" || fs === "normal") out.fontStyle = fs;
910
+ const anchor = style["text-anchor"];
911
+ if (anchor === "start") out.align = "left";
912
+ else if (anchor === "middle") out.align = "center";
913
+ else if (anchor === "end") out.align = "right";
914
+ const fillRaw = resolveCurrentColor(style["fill"] ?? null, style);
915
+ if (fillRaw) {
916
+ const parsed = parsePaintAttr(fillRaw);
917
+ if (parsed?.kind === "solid") {
918
+ out.fill = { fill: "solid", color: parsed.color };
919
+ } else if (parsed?.kind === "ref") {
920
+ const paint = gradients.get(parsed.id);
921
+ if (paint) out.fill = paint;
922
+ }
923
+ }
924
+ if (el.hasAttribute("stroke")) {
925
+ onWarn('<text stroke="..."> not supported on text; ignoring');
926
+ }
927
+ return out;
928
+ }
929
+ function serializePathD(path) {
930
+ if (path.kind === "rect") {
931
+ const { x, y, width, height } = path;
932
+ return `M${trimNumber(x)} ${trimNumber(y)}h${trimNumber(width)}v${trimNumber(height)}h${trimNumber(-width)}Z`;
933
+ }
934
+ const cmds = path.commands;
935
+ const xs = path.coords;
936
+ const out = [];
937
+ let ci = 0;
938
+ for (let i = 0; i < cmds.length; i++) {
939
+ const c = cmds[i];
940
+ if (c === PATH_M) {
941
+ out.push(`M${trimNumber(xs[ci])} ${trimNumber(xs[ci + 1])}`);
942
+ ci += 2;
943
+ } else if (c === PATH_L) {
944
+ out.push(`L${trimNumber(xs[ci])} ${trimNumber(xs[ci + 1])}`);
945
+ ci += 2;
946
+ } else if (c === PATH_C) {
947
+ out.push(
948
+ `C${trimNumber(xs[ci])} ${trimNumber(xs[ci + 1])} ${trimNumber(xs[ci + 2])} ${trimNumber(xs[ci + 3])} ${trimNumber(xs[ci + 4])} ${trimNumber(xs[ci + 5])}`
949
+ );
950
+ ci += 6;
951
+ } else if (c === PATH_Q) {
952
+ out.push(
953
+ `Q${trimNumber(xs[ci])} ${trimNumber(xs[ci + 1])} ${trimNumber(xs[ci + 2])} ${trimNumber(xs[ci + 3])}`
954
+ );
955
+ ci += 4;
956
+ } else if (c === PATH_Z) {
957
+ out.push("Z");
958
+ }
959
+ }
960
+ return out.join("");
961
+ }
962
+
963
+ // src/serialize.ts
964
+ function serializeSvg(nodes, opts = {}) {
965
+ const registry = new GradientRegistry();
966
+ registerGradients(nodes, registry);
967
+ const bounds = opts.viewBox ?? computeBounds(nodes);
968
+ const vb = `${trimNumber(bounds.x)} ${trimNumber(bounds.y)} ${trimNumber(bounds.width)} ${trimNumber(bounds.height)}`;
969
+ const namespaces = opts.namespaces ?? {};
970
+ const rootAttrs = [`xmlns="http://www.w3.org/2000/svg"`];
971
+ for (const [prefix, uri] of Object.entries(namespaces)) {
972
+ rootAttrs.push(`xmlns:${prefix}="${escapeAttr(uri)}"`);
973
+ }
974
+ rootAttrs.push(`viewBox="${vb}"`);
975
+ if (opts.width != null) rootAttrs.push(`width="${trimNumber(opts.width)}"`);
976
+ if (opts.height != null) rootAttrs.push(`height="${trimNumber(opts.height)}"`);
977
+ if (opts.documentMeta) {
978
+ for (const prefix of Object.keys(namespaces)) {
979
+ const bucket = opts.documentMeta[prefix];
980
+ if (!bucket?.attrs) continue;
981
+ for (const [name, value] of Object.entries(bucket.attrs)) {
982
+ rootAttrs.push(`${prefix}:${name}="${escapeAttr(value)}"`);
983
+ }
984
+ }
985
+ }
986
+ let docMetaXml = "";
987
+ if (opts.documentMeta) {
988
+ for (const prefix of Object.keys(namespaces)) {
989
+ const bucket = opts.documentMeta[prefix];
990
+ if (!bucket?.elements) continue;
991
+ for (const [localName, list] of Object.entries(bucket.elements)) {
992
+ for (const el of list) docMetaXml += namespacedElementXml(prefix, localName, el);
993
+ }
994
+ }
995
+ }
996
+ const defsXml = registry.toDefsXml();
997
+ const bodyXml = nodes.map((n) => nodeXml(n, registry, namespaces)).join("");
998
+ const titleXml = opts.title && opts.title.length > 0 ? `<title>${escapeText(opts.title)}</title>` : "";
999
+ return `<svg ${rootAttrs.join(" ")}>${titleXml}${defsXml}${docMetaXml}${bodyXml}</svg>`;
1000
+ }
1001
+ function namespacedElementXml(prefix, localName, el) {
1002
+ const attrs = [];
1003
+ for (const [name, value] of Object.entries(el.attrs)) {
1004
+ attrs.push(`${name}="${escapeAttr(value)}"`);
1005
+ }
1006
+ const head = attrs.length > 0 ? `<${prefix}:${localName} ${attrs.join(" ")}>` : `<${prefix}:${localName}>`;
1007
+ let body = "";
1008
+ if (el.children) {
1009
+ for (const [childName, list] of Object.entries(el.children)) {
1010
+ for (const child of list) body += namespacedElementXml(prefix, childName, child);
1011
+ }
1012
+ } else if (el.text != null) {
1013
+ body = escapeText(el.text);
1014
+ }
1015
+ return `${head}${body}</${prefix}:${localName}>`;
1016
+ }
1017
+ function metaAttrsXml(meta, namespaces) {
1018
+ if (!meta) return "";
1019
+ const parts = [];
1020
+ for (const prefix of Object.keys(namespaces)) {
1021
+ const bucket = meta[prefix];
1022
+ if (!bucket?.attrs) continue;
1023
+ for (const [name, value] of Object.entries(bucket.attrs)) {
1024
+ parts.push(`${prefix}:${name}="${escapeAttr(value)}"`);
1025
+ }
1026
+ }
1027
+ return parts.length > 0 ? ` ${parts.join(" ")}` : "";
1028
+ }
1029
+ function metaElementsXml(meta, namespaces) {
1030
+ if (!meta) return "";
1031
+ let out = "";
1032
+ for (const prefix of Object.keys(namespaces)) {
1033
+ const bucket = meta[prefix];
1034
+ if (!bucket?.elements) continue;
1035
+ for (const [localName, list] of Object.entries(bucket.elements)) {
1036
+ for (const el of list) out += namespacedElementXml(prefix, localName, el);
1037
+ }
1038
+ }
1039
+ return out;
1040
+ }
1041
+ function registerGradients(nodes, registry) {
1042
+ for (const n of nodes) {
1043
+ if (n.kind === "group") {
1044
+ registerGradients(n.children, registry);
1045
+ } else if (n.kind === "path") {
1046
+ if (n.fill.kind === "gradient") registry.register(n.fill.paint);
1047
+ if (n.stroke && n.stroke.paint.kind === "gradient") registry.register(n.stroke.paint.paint);
1048
+ } else if (n.kind === "text") {
1049
+ const styleFill = n.style?.fill;
1050
+ if (styleFill && !("color" in styleFill)) {
1051
+ registry.register(styleFill);
1052
+ }
1053
+ }
1054
+ }
1055
+ }
1056
+ function nodeXml(node, registry, namespaces) {
1057
+ if (node.kind === "group") return groupXml(node, registry, namespaces);
1058
+ if (node.kind === "text") return textXml(node, registry, namespaces);
1059
+ return pathXml(node, registry, namespaces);
1060
+ }
1061
+ function groupXml(node, registry, namespaces) {
1062
+ const attrs = [];
1063
+ if (node.transform) {
1064
+ const m = formatMatrix(node.transform);
1065
+ if (m) attrs.push(`transform="${m}"`);
1066
+ }
1067
+ if (node.opacity != null && node.opacity !== 1) {
1068
+ attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1069
+ }
1070
+ const metaAttrs = metaAttrsXml(node.meta, namespaces);
1071
+ const head = attrs.length > 0 ? `<g ${attrs.join(" ")}${metaAttrs}>` : `<g${metaAttrs}>`;
1072
+ const body = node.children.map((c) => nodeXml(c, registry, namespaces)).join("");
1073
+ return `${head}${body}${metaElementsXml(node.meta, namespaces)}</g>`;
1074
+ }
1075
+ function pathXml(node, registry, namespaces) {
1076
+ const attrs = [`d="${serializePathD(node.path)}"`];
1077
+ const fillAttrs = paintAttrs(node.fill, "fill", registry);
1078
+ for (const a of fillAttrs) attrs.push(a);
1079
+ if (node.path.kind === "polygon" && node.path.fillRule === "evenodd") {
1080
+ attrs.push(`fill-rule="evenodd"`);
1081
+ }
1082
+ if (node.stroke) {
1083
+ const strokeAttrs = strokeAttrsFor(node.stroke, registry);
1084
+ for (const a of strokeAttrs) attrs.push(a);
1085
+ } else {
1086
+ attrs.push('stroke="none"');
1087
+ }
1088
+ if (node.opacity != null && node.opacity !== 1) {
1089
+ attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1090
+ }
1091
+ if (node.rotation != null && node.rotation !== 0) {
1092
+ const b = pathBounds(node.path);
1093
+ const cx = b.minX + (b.maxX - b.minX) / 2;
1094
+ const cy = b.minY + (b.maxY - b.minY) / 2;
1095
+ const deg = node.rotation * 180 / Math.PI;
1096
+ attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
1097
+ }
1098
+ const metaAttrs = metaAttrsXml(node.meta, namespaces);
1099
+ const metaEls = metaElementsXml(node.meta, namespaces);
1100
+ if (metaEls) {
1101
+ return `<path ${attrs.join(" ")}${metaAttrs}>${metaEls}</path>`;
1102
+ }
1103
+ return `<path ${attrs.join(" ")}${metaAttrs}/>`;
1104
+ }
1105
+ function paintAttrs(paint, name, registry) {
1106
+ if (paint.kind === "none") return [`${name}="none"`];
1107
+ if (paint.kind === "solid") {
1108
+ const out = [`${name}="${paint.color}"`];
1109
+ if (paint.opacity != null && paint.opacity !== 1) {
1110
+ out.push(`${name}-opacity="${trimNumber(paint.opacity)}"`);
1111
+ }
1112
+ return out;
1113
+ }
1114
+ const id = registry.register(paint.paint);
1115
+ return [`${name}="url(#${id})"`];
1116
+ }
1117
+ function strokeAttrsFor(stroke, registry) {
1118
+ const attrs = paintAttrs(stroke.paint, "stroke", registry);
1119
+ attrs.push(`stroke-width="${trimNumber(stroke.width)}"`);
1120
+ if (stroke.opacity != null && stroke.opacity !== 1) {
1121
+ attrs.push(`stroke-opacity="${trimNumber(stroke.opacity)}"`);
1122
+ }
1123
+ if (stroke.cap) {
1124
+ attrs.push(`stroke-linecap="${stroke.cap}"`);
1125
+ }
1126
+ if (stroke.join) {
1127
+ attrs.push(`stroke-linejoin="${stroke.join}"`);
1128
+ }
1129
+ if (stroke.dash && stroke.dash.length > 0) {
1130
+ attrs.push(`stroke-dasharray="${stroke.dash.map(trimNumber).join(" ")}"`);
1131
+ }
1132
+ if (stroke.miterLimit != null) {
1133
+ attrs.push(`stroke-miterlimit="${trimNumber(stroke.miterLimit)}"`);
1134
+ }
1135
+ return attrs;
1136
+ }
1137
+ function computeBounds(nodes) {
1138
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1139
+ const visit = (n) => {
1140
+ if (n.kind === "group") {
1141
+ n.children.forEach(visit);
1142
+ return;
1143
+ }
1144
+ const b = n.kind === "text" ? { minX: n.x, minY: n.y, maxX: n.x + n.width, maxY: n.y + n.height } : pathBounds(n.path);
1145
+ if (b.minX < minX) minX = b.minX;
1146
+ if (b.minY < minY) minY = b.minY;
1147
+ if (b.maxX > maxX) maxX = b.maxX;
1148
+ if (b.maxY > maxY) maxY = b.maxY;
1149
+ };
1150
+ nodes.forEach(visit);
1151
+ if (!Number.isFinite(minX)) {
1152
+ return { x: 0, y: 0, width: 0, height: 0 };
1153
+ }
1154
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1155
+ }
1156
+ function textXml(node, registry, namespaces) {
1157
+ const attrs = [
1158
+ `x="${trimNumber(node.x)}"`,
1159
+ `y="${trimNumber(node.y)}"`,
1160
+ `dominant-baseline="text-before-edge"`,
1161
+ `data-weasel-width="${trimNumber(node.width)}"`,
1162
+ `data-weasel-height="${trimNumber(node.height)}"`
1163
+ ];
1164
+ const style = node.style;
1165
+ if (style?.fontSize != null) attrs.push(`font-size="${trimNumber(style.fontSize)}"`);
1166
+ if (style?.fontFamily) attrs.push(`font-family="${escapeAttr(style.fontFamily)}"`);
1167
+ if (style?.fontWeight != null) attrs.push(`font-weight="${String(style.fontWeight)}"`);
1168
+ if (style?.fontStyle && style.fontStyle !== "normal") attrs.push(`font-style="${style.fontStyle}"`);
1169
+ if (style?.align && style.align !== "left") {
1170
+ const anchor = style.align === "center" ? "middle" : "end";
1171
+ attrs.push(`text-anchor="${anchor}"`);
1172
+ }
1173
+ if (style?.fill) {
1174
+ if ("color" in style.fill) {
1175
+ attrs.push(`fill="${style.fill.color}"`);
1176
+ if (style.fill.opacity != null && style.fill.opacity !== 1) {
1177
+ attrs.push(`fill-opacity="${trimNumber(style.fill.opacity)}"`);
1178
+ }
1179
+ } else {
1180
+ const id = registry.register(style.fill);
1181
+ attrs.push(`fill="url(#${id})"`);
1182
+ }
1183
+ }
1184
+ if (node.opacity != null && node.opacity !== 1) {
1185
+ attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1186
+ }
1187
+ if (node.rotation != null && node.rotation !== 0) {
1188
+ const cx = node.x + node.width / 2;
1189
+ const cy = node.y + node.height / 2;
1190
+ const deg = node.rotation * 180 / Math.PI;
1191
+ attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
1192
+ }
1193
+ const body = node.runs && node.runs.length > 0 ? node.runs.map((r) => runXml(r, registry)).join("") : escapeText(node.text);
1194
+ const metaAttrs = metaAttrsXml(node.meta, namespaces);
1195
+ const metaEls = metaElementsXml(node.meta, namespaces);
1196
+ return `<text ${attrs.join(" ")}${metaAttrs}>${body}${metaEls}</text>`;
1197
+ }
1198
+ function runXml(run, registry) {
1199
+ const attrs = [];
1200
+ if (run.bold) attrs.push(`font-weight="700"`);
1201
+ if (run.italic) attrs.push(`font-style="italic"`);
1202
+ if (run.fontFamily) attrs.push(`font-family="${escapeAttr(run.fontFamily)}"`);
1203
+ if (run.fontSize != null) attrs.push(`font-size="${trimNumber(run.fontSize)}"`);
1204
+ if (run.fill) {
1205
+ if ("color" in run.fill) {
1206
+ attrs.push(`fill="${run.fill.color}"`);
1207
+ } else {
1208
+ const id = registry.register(run.fill);
1209
+ attrs.push(`fill="url(#${id})"`);
1210
+ }
1211
+ }
1212
+ const head = attrs.length > 0 ? `<tspan ${attrs.join(" ")}>` : "<tspan>";
1213
+ return `${head}${escapeText(run.text)}</tspan>`;
1214
+ }
1215
+ function escapeAttr(s) {
1216
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
1217
+ }
1218
+ function escapeText(s) {
1219
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1220
+ }
1221
+ function pathBounds(path) {
1222
+ if (path.kind === "rect") {
1223
+ return {
1224
+ minX: path.x,
1225
+ minY: path.y,
1226
+ maxX: path.x + path.width,
1227
+ maxY: path.y + path.height
1228
+ };
1229
+ }
1230
+ const b = boundsOfPath(path);
1231
+ return { minX: b.x, minY: b.y, maxX: b.x + b.width, maxY: b.y + b.height };
1232
+ }
1233
+ var CASCADE_OFFSET_PX = 24;
1234
+ var VIEWPORT_FIT = 0.9;
1235
+ var GRADIENT_FALLBACK = "#888888";
1236
+ function readFileText(file) {
1237
+ return new Promise((resolve, reject) => {
1238
+ const reader = new FileReader();
1239
+ reader.onload = () => resolve(String(reader.result ?? ""));
1240
+ reader.onerror = () => reject(reader.error);
1241
+ reader.readAsText(file);
1242
+ });
1243
+ }
1244
+ var svgIdCounter = 0;
1245
+ function freshSvgNodeId() {
1246
+ return `n${(svgIdCounter++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1247
+ }
1248
+ function colorFromPaint(paint, context) {
1249
+ if (!paint) return void 0;
1250
+ if (paint.kind === "none") return "none";
1251
+ if (paint.kind === "solid") return paint.color;
1252
+ dwarn("ingest", `svg unpack: gradient ${context} flattened to ${GRADIENT_FALLBACK}`);
1253
+ return GRADIENT_FALLBACK;
1254
+ }
1255
+ function svgNodesToKitDrafts(nodes, nextId) {
1256
+ const drafts = [];
1257
+ const visit = (n, parentId) => {
1258
+ if (n.kind === "group") {
1259
+ const draft = {
1260
+ kind: "container",
1261
+ id: nextId(),
1262
+ parentId,
1263
+ pose: { x: 0, y: 0, width: 0, height: 0 }
1264
+ };
1265
+ drafts.push(draft);
1266
+ let acc = null;
1267
+ for (const c of n.children) {
1268
+ const b2 = visit(c, draft.id);
1269
+ if (b2) acc = acc ? unionRect(acc, b2) : b2;
1270
+ }
1271
+ if (!acc) {
1272
+ drafts.splice(drafts.indexOf(draft), 1);
1273
+ return null;
1274
+ }
1275
+ draft.pose = acc;
1276
+ return acc;
1277
+ }
1278
+ if (n.kind === "text") {
1279
+ const pose2 = { x: n.x, y: n.y, width: n.width, height: n.height };
1280
+ if (n.rotation) pose2.rotation = n.rotation;
1281
+ drafts.push({
1282
+ kind: "leaf",
1283
+ id: nextId(),
1284
+ parentId,
1285
+ pose: pose2,
1286
+ data: { text: n.text, ...n.style ? { style: n.style } : {} }
1287
+ });
1288
+ return pose2;
1289
+ }
1290
+ const b = n.path.kind === "rect" ? { x: n.path.x, y: n.path.y, width: n.path.width, height: n.path.height } : boundsOfPath(n.path);
1291
+ const pose = { x: b.x, y: b.y, width: b.width, height: b.height };
1292
+ if (n.rotation) pose.rotation = n.rotation;
1293
+ const fill = colorFromPaint(n.fill, "fill");
1294
+ const stroke = n.stroke ? colorFromPaint(n.stroke.paint, "stroke") : void 0;
1295
+ drafts.push({
1296
+ kind: "leaf",
1297
+ id: nextId(),
1298
+ parentId,
1299
+ pose,
1300
+ data: {
1301
+ path: n.path,
1302
+ ...fill !== void 0 ? { fill } : {},
1303
+ ...stroke !== void 0 && stroke !== "none" ? { stroke, strokeWidth: n.stroke.width } : {}
1304
+ }
1305
+ });
1306
+ return pose;
1307
+ };
1308
+ for (const n of nodes) visit(n, null);
1309
+ return drafts;
1310
+ }
1311
+ function unionRect(a, b) {
1312
+ const minX = Math.min(a.x, b.x);
1313
+ const minY = Math.min(a.y, b.y);
1314
+ const maxX = Math.max(a.x + a.width, b.x + b.width);
1315
+ const maxY = Math.max(a.y + a.height, b.y + b.height);
1316
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1317
+ }
1318
+ async function unpackSvgFiles(files, ctx) {
1319
+ const layer = ctx.scene.layers[0]?.id ?? "default";
1320
+ let index = 0;
1321
+ for (const file of files) {
1322
+ try {
1323
+ const text = await readFileText(file);
1324
+ const parsed = parseSvg(text);
1325
+ for (const w of parsed.warnings) dwarn("ingest", `svg "${file.name}":`, w);
1326
+ let drafts = svgNodesToKitDrafts(parsed.nodes, freshSvgNodeId);
1327
+ if (drafts.length === 0) {
1328
+ console.warn(`weasel ingest: svg "${file.name}" parsed to no drawable nodes`);
1329
+ continue;
1330
+ }
1331
+ const roots = drafts.filter((d) => d.parentId === null);
1332
+ if (roots.length > 1) {
1333
+ const wrapperId = freshSvgNodeId();
1334
+ const union2 = roots.map((d) => d.pose).reduce(unionRect);
1335
+ drafts = [
1336
+ { kind: "container", id: wrapperId, parentId: null, pose: union2 },
1337
+ ...drafts.map((d) => d.parentId === null ? { ...d, parentId: wrapperId } : d)
1338
+ ];
1339
+ }
1340
+ const union = drafts.filter((d) => d.parentId === null).map((d) => d.pose).reduce(unionRect);
1341
+ const view = ctx.viewportWorldRect();
1342
+ const scale = Math.min(
1343
+ 1,
1344
+ union.width > 0 ? view.width * VIEWPORT_FIT / union.width : 1,
1345
+ union.height > 0 ? view.height * VIEWPORT_FIT / union.height : 1
1346
+ );
1347
+ const target = ctx.point ?? {
1348
+ x: view.x + view.width / 2,
1349
+ y: view.y + view.height / 2
1350
+ };
1351
+ const offset = index * CASCADE_OFFSET_PX;
1352
+ const cx = union.x + union.width / 2;
1353
+ const cy = union.y + union.height / 2;
1354
+ const place = (p) => ({
1355
+ ...p,
1356
+ x: target.x + (p.x - cx) * scale + offset,
1357
+ y: target.y + (p.y - cy) * scale + offset,
1358
+ width: p.width * scale,
1359
+ height: p.height * scale
1360
+ });
1361
+ const ops = drafts.map(
1362
+ (d) => createInsertOp({
1363
+ node: {
1364
+ id: d.id,
1365
+ kind: d.kind,
1366
+ layer,
1367
+ pose: place(d.pose),
1368
+ data: d.kind === "leaf" ? d.data : {},
1369
+ parent: d.parentId
1370
+ },
1371
+ label: "Insert SVG"
1372
+ })
1373
+ );
1374
+ ctx.applyOps(ops, "Insert SVG");
1375
+ index++;
1376
+ } catch (err) {
1377
+ console.warn(`weasel ingest: svg "${file.name}" failed to parse`, err);
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ export { IDENTITY_MATRIX, parseSvg, serializeSvg, svgNodesToKitDrafts, unpackSvgFiles };
1383
+ //# sourceMappingURL=index.js.map
1384
+ //# sourceMappingURL=index.js.map