@weasel-js/svg 1.0.3 → 1.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.
package/dist/index.js CHANGED
@@ -78,6 +78,16 @@ function parseTransform(attr, onWarn) {
78
78
  }
79
79
  return result;
80
80
  }
81
+ function invertMatrix(m) {
82
+ const det = m[0] * m[3] - m[1] * m[2];
83
+ if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
84
+ const a = m[3] / det, b = -m[1] / det, c = -m[2] / det, d = m[0] / det;
85
+ return [a, b, c, d, -(a * m[4] + c * m[5]), -(b * m[4] + d * m[5])];
86
+ }
87
+ function rebaseTransform(ctm, local) {
88
+ const inv = invertMatrix(ctm);
89
+ return inv ? multiply(multiply(ctm, local), inv) : local;
90
+ }
81
91
  function decomposeRotation(m, cx, cy, eps = 1e-4) {
82
92
  const [a, b, c, d, e, f] = m;
83
93
  if (Math.abs(a - d) > eps) return null;
@@ -243,6 +253,22 @@ function parsePaintAttr(raw) {
243
253
  return null;
244
254
  }
245
255
  }
256
+
257
+ // src/elements.ts
258
+ function collectElementsByTag(root, tags) {
259
+ const out = /* @__PURE__ */ new Map();
260
+ const consider = (el) => {
261
+ if (!tags.has(el.tagName.toLowerCase())) return;
262
+ const id = el.getAttribute("id");
263
+ if (id && !out.has(id)) out.set(id, el);
264
+ };
265
+ consider(root);
266
+ const all = root.getElementsByTagName("*");
267
+ for (let i = 0; i < all.length; i++) consider(all[i]);
268
+ return out;
269
+ }
270
+
271
+ // src/patterns.ts
246
272
  function patternSpecOf(paint) {
247
273
  return "tile" in paint.pattern ? paint.pattern : null;
248
274
  }
@@ -295,20 +321,13 @@ function shapeXml(shape) {
295
321
  }
296
322
  function collectPatterns(svg, onWarn) {
297
323
  const out = /* @__PURE__ */ new Map();
298
- const defs = svg.getElementsByTagName("defs");
299
- for (let d = 0; d < defs.length; d++) {
300
- const root = defs[d];
301
- for (let i = 0; i < root.children.length; i++) {
302
- const child = root.children[i];
303
- if (child.tagName.toLowerCase() !== "pattern") continue;
304
- const id = child.getAttribute("id");
305
- if (!id) continue;
306
- const paint = readPattern(child, onWarn);
307
- if (paint) out.set(id, paint);
308
- }
324
+ for (const [id, el] of collectElementsByTag(svg, PATTERN_TAGS)) {
325
+ const paint = readPattern(el, onWarn);
326
+ if (paint) out.set(id, paint);
309
327
  }
310
328
  return out;
311
329
  }
330
+ var PATTERN_TAGS = /* @__PURE__ */ new Set(["pattern"]);
312
331
  function readPattern(el, onWarn) {
313
332
  const raw = el.getAttribute("data-weasel-tile");
314
333
  if (!raw) {
@@ -337,41 +356,69 @@ function escapeAttr(s) {
337
356
  }
338
357
 
339
358
  // src/gradients.ts
359
+ var GRADIENT_TAGS = /* @__PURE__ */ new Set(["lineargradient", "radialgradient"]);
360
+ var XLINK_NS = "http://www.w3.org/1999/xlink";
340
361
  function collectGradients(svg, onWarn) {
341
362
  const out = /* @__PURE__ */ new Map();
363
+ const elements = collectElementsByTag(svg, GRADIENT_TAGS);
364
+ for (const [id, el] of elements) {
365
+ const paint = el.tagName.toLowerCase() === "lineargradient" ? readLinearGradient(el, elements, onWarn) : readRadialGradient(el, elements, onWarn);
366
+ if (paint) out.set(id, paint);
367
+ }
368
+ warnUnsupportedDefsChildren(svg, onWarn);
369
+ return out;
370
+ }
371
+ function warnUnsupportedDefsChildren(svg, onWarn) {
372
+ if (!onWarn) return;
342
373
  const defs = svg.getElementsByTagName("defs");
343
374
  for (let d = 0; d < defs.length; d++) {
344
375
  const root = defs[d];
345
376
  for (let i = 0; i < root.children.length; i++) {
346
377
  const child = root.children[i];
347
378
  const tag = child.tagName.toLowerCase();
348
- const id = child.getAttribute("id");
349
- if (!id) continue;
350
- if (tag === "lineargradient") {
351
- const paint = readLinearGradient(child, onWarn);
352
- if (paint) out.set(id, paint);
353
- } else if (tag === "radialgradient") {
354
- const paint = readRadialGradient(child, onWarn);
355
- if (paint) out.set(id, paint);
356
- } else if (tag !== "pattern") {
357
- onWarn?.(`unsupported <defs> child: <${child.tagName}>`);
358
- }
379
+ if (GRADIENT_TAGS.has(tag) || tag === "pattern") continue;
380
+ onWarn(`unsupported <defs> child: <${child.tagName}>`);
359
381
  }
360
382
  }
361
- return out;
383
+ }
384
+ function hrefTarget(el, elements) {
385
+ const raw = el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS, "href") ?? el.getAttribute("xlink:href");
386
+ if (!raw || !raw.startsWith("#")) return null;
387
+ return elements.get(raw.slice(1)) ?? null;
388
+ }
389
+ function inheritedAttr(el, elements, name, seen = /* @__PURE__ */ new Set()) {
390
+ if (el.hasAttribute(name)) return el.getAttribute(name);
391
+ seen.add(el);
392
+ const ref = hrefTarget(el, elements);
393
+ if (!ref || seen.has(ref)) return null;
394
+ return inheritedAttr(ref, elements, name, seen);
395
+ }
396
+ function inheritedStops(el, elements, onWarn, seen = /* @__PURE__ */ new Set()) {
397
+ const own = readStops(el, onWarn);
398
+ if (own.length > 0) return own;
399
+ seen.add(el);
400
+ const ref = hrefTarget(el, elements);
401
+ if (!ref || seen.has(ref)) return own;
402
+ return inheritedStops(ref, elements, onWarn, seen);
403
+ }
404
+ function parseRatio(raw) {
405
+ const n = parseFloat(raw);
406
+ if (!Number.isFinite(n)) return NaN;
407
+ return raw.trimEnd().endsWith("%") ? n / 100 : n;
362
408
  }
363
409
  function readStops(el, onWarn) {
364
410
  const stops = [];
365
411
  for (let i = 0; i < el.children.length; i++) {
366
412
  const c = el.children[i];
367
413
  if (c.tagName.toLowerCase() !== "stop") continue;
368
- const offsetRaw = c.getAttribute("offset") ?? "0";
369
- const offset = offsetRaw.endsWith("%") ? parseFloat(offsetRaw) / 100 : parseFloat(offsetRaw);
414
+ const offsetRaw = parseRatio(c.getAttribute("offset") ?? "0");
415
+ const offset = Number.isFinite(offsetRaw) ? offsetRaw : 0;
370
416
  const colorAttr = c.getAttribute("stop-color") ?? "#000000";
371
417
  const parsed = parsePaintAttr(colorAttr);
372
418
  if (parsed && parsed.kind === "solid") {
373
419
  const opacityAttr = c.getAttribute("stop-opacity");
374
- const alpha = opacityAttr != null ? parseFloat(opacityAttr) : parsed.alpha;
420
+ const own = opacityAttr != null ? parseRatio(opacityAttr) : NaN;
421
+ const alpha = Number.isFinite(own) ? own : parsed.alpha;
375
422
  stops.push({ offset, color: alpha < 1 ? applyAlpha(parsed.color, alpha) : parsed.color });
376
423
  } else {
377
424
  onWarn?.(`gradient stop has unrecognized stop-color: ${colorAttr}`);
@@ -384,34 +431,47 @@ function applyAlpha(hex, alpha) {
384
431
  const a = Math.max(0, Math.min(255, Math.round(alpha * 255)));
385
432
  return `${hex}${a.toString(16).padStart(2, "0")}`;
386
433
  }
387
- function readGradientUnits(el) {
388
- return el.getAttribute("gradientUnits") === "userSpaceOnUse" ? "world" : "bounds";
434
+ function readGradientUnits(el, elements) {
435
+ return inheritedAttr(el, elements, "gradientUnits") === "userSpaceOnUse" ? "world" : "bounds";
389
436
  }
390
- function readLinearGradient(el, onWarn) {
391
- const x1 = parseFloat(el.getAttribute("x1") ?? "0");
392
- const y1 = parseFloat(el.getAttribute("y1") ?? "0");
393
- const x2 = parseFloat(el.getAttribute("x2") ?? "1");
394
- const y2 = parseFloat(el.getAttribute("y2") ?? "0");
395
- const stops = readStops(el, onWarn);
437
+ function warnGradientTransform(el, elements, onWarn) {
438
+ const t = inheritedAttr(el, elements, "gradientTransform");
439
+ if (t != null && t.trim() !== "") {
440
+ onWarn?.(`gradientTransform="${t}" is not modeled; the gradient paints untransformed`);
441
+ }
442
+ }
443
+ function num(raw, fallback) {
444
+ if (raw == null) return fallback;
445
+ const n = parseRatio(raw);
446
+ return Number.isFinite(n) ? n : fallback;
447
+ }
448
+ function readLinearGradient(el, elements, onWarn) {
449
+ warnGradientTransform(el, elements, onWarn);
396
450
  return {
397
451
  fill: "linear-gradient",
398
- from: { x: x1, y: y1 },
399
- to: { x: x2, y: y2 },
400
- stops,
401
- units: readGradientUnits(el)
452
+ from: {
453
+ x: num(inheritedAttr(el, elements, "x1"), 0),
454
+ y: num(inheritedAttr(el, elements, "y1"), 0)
455
+ },
456
+ to: {
457
+ x: num(inheritedAttr(el, elements, "x2"), 1),
458
+ y: num(inheritedAttr(el, elements, "y2"), 0)
459
+ },
460
+ stops: inheritedStops(el, elements, onWarn),
461
+ units: readGradientUnits(el, elements)
402
462
  };
403
463
  }
404
- function readRadialGradient(el, onWarn) {
405
- const cx = parseFloat(el.getAttribute("cx") ?? "0.5");
406
- const cy = parseFloat(el.getAttribute("cy") ?? "0.5");
407
- const r = parseFloat(el.getAttribute("r") ?? "0.5");
408
- const stops = readStops(el, onWarn);
464
+ function readRadialGradient(el, elements, onWarn) {
465
+ warnGradientTransform(el, elements, onWarn);
409
466
  return {
410
467
  fill: "radial-gradient",
411
- center: { x: cx, y: cy },
412
- radius: r,
413
- stops,
414
- units: readGradientUnits(el)
468
+ center: {
469
+ x: num(inheritedAttr(el, elements, "cx"), 0.5),
470
+ y: num(inheritedAttr(el, elements, "cy"), 0.5)
471
+ },
472
+ radius: num(inheritedAttr(el, elements, "r"), 0.5),
473
+ stops: inheritedStops(el, elements, onWarn),
474
+ units: readGradientUnits(el, elements)
415
475
  };
416
476
  }
417
477
  var PaintServerRegistry = class {
@@ -523,7 +583,15 @@ var SUPPORTED_LEAF_TAGS = /* @__PURE__ */ new Set([
523
583
  "path"
524
584
  ]);
525
585
  var SUPPORTED_GROUP_TAGS = /* @__PURE__ */ new Set(["g", "svg"]);
526
- var IGNORED_TAGS = /* @__PURE__ */ new Set(["defs", "linearGradient", "radialGradient", "title", "desc", "metadata"]);
586
+ var IGNORED_TAGS = /* @__PURE__ */ new Set([
587
+ "defs",
588
+ "lineargradient",
589
+ "radialgradient",
590
+ "pattern",
591
+ "title",
592
+ "desc",
593
+ "metadata"
594
+ ]);
527
595
  function parseSvg(svg, opts = {}) {
528
596
  const warnings = [];
529
597
  const onWarn = (m) => {
@@ -557,16 +625,10 @@ function parseSvg(svg, opts = {}) {
557
625
  if (documentMeta) result.documentMeta = documentMeta;
558
626
  const viewBox = parseViewBoxAttr(root.getAttribute("viewBox"));
559
627
  if (viewBox) result.viewBox = viewBox;
560
- const widthAttr = root.getAttribute("width");
561
- if (widthAttr != null) {
562
- const n = parseFloat(widthAttr);
563
- if (Number.isFinite(n)) result.width = n;
564
- }
565
- const heightAttr = root.getAttribute("height");
566
- if (heightAttr != null) {
567
- const n = parseFloat(heightAttr);
568
- if (Number.isFinite(n)) result.height = n;
569
- }
628
+ const width = parseRootLength(root.getAttribute("width"), "width", onWarn);
629
+ if (width != null) result.width = width;
630
+ const height = parseRootLength(root.getAttribute("height"), "height", onWarn);
631
+ if (height != null) result.height = height;
570
632
  for (let i = 0; i < root.children.length; i++) {
571
633
  const c = root.children[i];
572
634
  if (c.tagName.toLowerCase() === "title") {
@@ -584,6 +646,14 @@ function parseViewBoxAttr(raw) {
584
646
  if (nums.some((n) => !Number.isFinite(n))) return void 0;
585
647
  return { x: nums[0], y: nums[1], width: nums[2], height: nums[3] };
586
648
  }
649
+ function parseRootLength(raw, name, onWarn) {
650
+ if (raw == null) return void 0;
651
+ const n = parseFloat(raw);
652
+ if (!Number.isFinite(n)) return void 0;
653
+ const unit = /^\s*[-+]?[\d.eE+-]+\s*([a-z%]+)\s*$/i.exec(raw)?.[1];
654
+ if (unit) onWarn(`<svg ${name}="${raw}"> unit "${unit}" is not converted; read as ${n}`);
655
+ return n;
656
+ }
587
657
  function collectDocumentMeta(root, uriToPrefix) {
588
658
  if (uriToPrefix.size === 0) return void 0;
589
659
  const meta = {};
@@ -684,7 +754,13 @@ function parseElement(el, ctm, style, gradients, onWarn, uriToPrefix) {
684
754
  }
685
755
  if (SUPPORTED_GROUP_TAGS.has(tag)) {
686
756
  const childStyle = deriveStyle(style, el);
687
- return parseChildren(el, ctm, childStyle, gradients, onWarn, uriToPrefix);
757
+ const x = parseFloat(el.getAttribute("x") ?? "0");
758
+ const y = parseFloat(el.getAttribute("y") ?? "0");
759
+ const offset = [1, 0, 0, 1, Number.isFinite(x) ? x : 0, Number.isFinite(y) ? y : 0];
760
+ if (el.hasAttribute("viewBox")) {
761
+ onWarn("nested <svg> viewBox is not modeled; its children are not rescaled");
762
+ }
763
+ return parseChildren(el, multiply(ctm, offset), childStyle, gradients, onWarn, uriToPrefix);
688
764
  }
689
765
  if (tag === "text") {
690
766
  const textNode = parseTextElement(el, ctm, style, gradients, onWarn);
@@ -711,15 +787,16 @@ function parseElement(el, ctm, style, gradients, onWarn, uriToPrefix) {
711
787
  if (!path) return null;
712
788
  let rotation;
713
789
  if (!isIdentity(localTransform)) {
790
+ const worldLocal = rebaseTransform(ctm, localTransform);
714
791
  const bounds = pathAabb(path);
715
792
  const cx = bounds.x + bounds.width / 2;
716
793
  const cy = bounds.y + bounds.height / 2;
717
- const angle = decomposeRotation(localTransform, cx, cy);
794
+ const angle = decomposeRotation(worldLocal, cx, cy);
718
795
  if (angle != null) {
719
796
  rotation = angle;
720
797
  } else {
721
- path = transformPath(path, localTransform);
722
- const rotComp = rotationComponent(localTransform);
798
+ path = transformPath(path, worldLocal);
799
+ const rotComp = rotationComponent(worldLocal);
723
800
  if (Math.abs(rotComp) > 1e-4) {
724
801
  onWarn(`leaf transform has a rotational component that can't be cleanly stored as rotation; baked into geometry (may not round-trip identically)`);
725
802
  }
@@ -740,30 +817,29 @@ function parseElement(el, ctm, style, gradients, onWarn, uriToPrefix) {
740
817
  if (tag === "line" && (leafStyle["fill"] ?? null) == null) {
741
818
  node.fill = { kind: "none" };
742
819
  }
743
- if (tag === "polyline" && !el.hasAttribute("fill")) ;
744
820
  const meta = collectElementMeta(el, uriToPrefix);
745
821
  if (meta) node.meta = meta;
746
822
  return node;
747
823
  }
748
824
  function lowerLeaf(el, tag, m, onWarn) {
749
- const num = (name, fallback = 0) => {
825
+ const num2 = (name, fallback = 0) => {
750
826
  const v = el.getAttribute(name);
751
827
  if (v == null) return fallback;
752
828
  const n = parseFloat(v);
753
829
  return Number.isFinite(n) ? n : fallback;
754
830
  };
755
831
  if (tag === "rect") {
756
- const x = num("x", 0);
757
- const y = num("y", 0);
758
- const w = num("width", 0);
759
- const h = num("height", 0);
760
- const rx = num("rx", 0);
761
- const ry = num("ry", 0);
832
+ const x = num2("x", 0);
833
+ const y = num2("y", 0);
834
+ const w = num2("width", 0);
835
+ const h = num2("height", 0);
836
+ const rx = num2("rx", 0);
837
+ const ry = num2("ry", 0);
762
838
  return rectElementToPath(x, y, w, h, rx, ry, m);
763
839
  }
764
- if (tag === "circle") return circleToPath(num("cx"), num("cy"), num("r"), m);
765
- if (tag === "ellipse") return ellipseToPath(num("cx"), num("cy"), num("rx"), num("ry"), m);
766
- if (tag === "line") return lineToPath(num("x1"), num("y1"), num("x2"), num("y2"), m);
840
+ if (tag === "circle") return circleToPath(num2("cx"), num2("cy"), num2("r"), m);
841
+ if (tag === "ellipse") return ellipseToPath(num2("cx"), num2("cy"), num2("rx"), num2("ry"), m);
842
+ if (tag === "line") return lineToPath(num2("x1"), num2("y1"), num2("x2"), num2("y2"), m);
767
843
  if (tag === "polyline") return polylineToPath(parsePoints(el.getAttribute("points") ?? ""), m);
768
844
  if (tag === "polygon") return polygonToPath(parsePoints(el.getAttribute("points") ?? ""), m);
769
845
  if (tag === "path") {
@@ -925,7 +1001,7 @@ function parseDashArray(s) {
925
1001
  return nums.length % 2 === 1 ? [...nums, ...nums] : nums;
926
1002
  }
927
1003
  function readOpacityAttr(el, name) {
928
- const raw = el.getAttribute(name);
1004
+ const raw = ownProp(el, name);
929
1005
  if (raw == null) return void 0;
930
1006
  const n = parseFloat(raw);
931
1007
  return Number.isFinite(n) ? clamp01(n) : void 0;
@@ -950,22 +1026,22 @@ function parseTextDecoration(raw) {
950
1026
  if (tokens.includes("line-through")) out.strikethrough = true;
951
1027
  return out;
952
1028
  }
953
- var XLINK_NS = "http://www.w3.org/1999/xlink";
1029
+ var XLINK_NS2 = "http://www.w3.org/1999/xlink";
954
1030
  function parseImageElement(el, ctm, onWarn) {
955
- const href = el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS, "href") ?? el.getAttribute("xlink:href");
1031
+ const href = el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS2, "href") ?? el.getAttribute("xlink:href");
956
1032
  if (!href) {
957
1033
  onWarn("<image> without href; dropped");
958
1034
  return null;
959
1035
  }
960
- const num = (raw, fallback) => {
1036
+ const num2 = (raw, fallback) => {
961
1037
  if (raw == null) return fallback;
962
1038
  const n = parseFloat(raw);
963
1039
  return Number.isFinite(n) ? n : fallback;
964
1040
  };
965
- const rawX = num(el.getAttribute("x"), 0);
966
- const rawY = num(el.getAttribute("y"), 0);
967
- const rawW = num(el.getAttribute("width"), 0);
968
- const rawH = num(el.getAttribute("height"), 0);
1041
+ const rawX = num2(el.getAttribute("x"), 0);
1042
+ const rawY = num2(el.getAttribute("y"), 0);
1043
+ const rawW = num2(el.getAttribute("width"), 0);
1044
+ const rawH = num2(el.getAttribute("height"), 0);
969
1045
  const px = (x, y) => [ctm[0] * x + ctm[2] * y + ctm[4], ctm[1] * x + ctm[3] * y + ctm[5]];
970
1046
  const [x0, y0] = px(rawX, rawY);
971
1047
  const [x1, y1] = px(rawX + rawW, rawY + rawH);
@@ -981,13 +1057,14 @@ function parseImageElement(el, ctm, onWarn) {
981
1057
  if (opacity != null) node.opacity = opacity;
982
1058
  const localTransform = parseTransform(el.getAttribute("transform"), onWarn);
983
1059
  if (!isIdentity(localTransform)) {
1060
+ const worldLocal = rebaseTransform(ctm, localTransform);
984
1061
  const cx = node.x + node.width / 2;
985
1062
  const cy = node.y + node.height / 2;
986
- const angle = decomposeRotation(localTransform, cx, cy);
1063
+ const angle = decomposeRotation(worldLocal, cx, cy);
987
1064
  if (angle != null) {
988
1065
  node.rotation = angle;
989
1066
  } else {
990
- const rotComp = rotationComponent(localTransform);
1067
+ const rotComp = rotationComponent(worldLocal);
991
1068
  if (Math.abs(rotComp) > 1e-4) {
992
1069
  onWarn("<image> transform has a rotational component that can't be cleanly stored as rotation; dropped (may not round-trip identically)");
993
1070
  }
@@ -998,16 +1075,52 @@ function parseImageElement(el, ctm, onWarn) {
998
1075
  }
999
1076
  return node;
1000
1077
  }
1078
+ var XML_NS = "http://www.w3.org/XML/1998/namespace";
1079
+ function preservesSpace(el) {
1080
+ for (let e = el; e; e = e.parentElement) {
1081
+ const v = e.getAttributeNS(XML_NS, "space") ?? e.getAttribute("xml:space");
1082
+ if (v === "preserve") return true;
1083
+ if (v === "default") return false;
1084
+ }
1085
+ return false;
1086
+ }
1087
+ function collapseRunWhitespace(runs) {
1088
+ let atStart = true;
1089
+ let lastWasSpace = false;
1090
+ for (const run of runs) {
1091
+ let out = "";
1092
+ for (const ch of run.text.replace(/\n/g, "").replace(/[\t\r]/g, " ")) {
1093
+ if (ch === " ") {
1094
+ if (atStart || lastWasSpace) continue;
1095
+ out += " ";
1096
+ lastWasSpace = true;
1097
+ continue;
1098
+ }
1099
+ out += ch;
1100
+ lastWasSpace = false;
1101
+ atStart = false;
1102
+ }
1103
+ run.text = out;
1104
+ }
1105
+ for (let i = runs.length - 1; i >= 0; i--) {
1106
+ if (runs[i].text === "") continue;
1107
+ if (runs[i].text.endsWith(" ")) runs[i].text = runs[i].text.slice(0, -1);
1108
+ break;
1109
+ }
1110
+ for (let i = runs.length - 1; i >= 0; i--) {
1111
+ if (runs[i].text === "") runs.splice(i, 1);
1112
+ }
1113
+ }
1001
1114
  function parseTextElement(el, ctm, style, gradients, onWarn) {
1002
- const num = (raw, fallback) => {
1115
+ const num2 = (raw, fallback) => {
1003
1116
  if (raw == null) return fallback;
1004
1117
  const n = parseFloat(raw);
1005
1118
  return Number.isFinite(n) ? n : fallback;
1006
1119
  };
1007
1120
  const localTransform = parseTransform(el.getAttribute("transform"), onWarn);
1008
1121
  const m = ctm;
1009
- const rawX = num(el.getAttribute("x"), 0);
1010
- const rawY = num(el.getAttribute("y"), 0);
1122
+ const rawX = num2(el.getAttribute("x"), 0);
1123
+ const rawY = num2(el.getAttribute("y"), 0);
1011
1124
  const ax = m[0] * rawX + m[2] * rawY + m[4];
1012
1125
  const ay = m[1] * rawX + m[3] * rawY + m[5];
1013
1126
  const leafStyle = deriveStyle(style, el);
@@ -1018,40 +1131,35 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
1018
1131
  const explicitTopAnchor = dominantBaseline === "text-before-edge" || dominantBaseline === "hanging";
1019
1132
  const topY = explicitTopAnchor ? ay : ay - fontSize;
1020
1133
  const runs = [];
1021
- let plain = "";
1022
1134
  for (let i = 0; i < el.childNodes.length; i++) {
1023
1135
  const child = el.childNodes[i];
1024
1136
  if (child.nodeType === 3) {
1025
1137
  const t = child.textContent ?? "";
1026
1138
  if (!t) continue;
1027
1139
  runs.push({ text: t });
1028
- plain += t;
1029
1140
  } else if (child.nodeType === 1) {
1030
1141
  const sp = child;
1031
1142
  if (sp.tagName.toLowerCase() !== "tspan") {
1032
1143
  onWarn(`<text> child <${sp.tagName}> not supported; flattening text content`);
1033
1144
  const t = sp.textContent ?? "";
1034
- if (t) {
1035
- runs.push({ text: t });
1036
- plain += t;
1037
- }
1145
+ if (t) runs.push({ text: t });
1038
1146
  continue;
1039
1147
  }
1040
1148
  const run = readTspanRun(sp, gradients, leafStyle, onWarn);
1041
1149
  runs.push(run);
1042
- plain += run.text;
1043
1150
  }
1044
1151
  }
1045
- const dataW = num(el.getAttribute("data-weasel-width"), NaN);
1046
- const dataH = num(el.getAttribute("data-weasel-height"), NaN);
1152
+ if (!preservesSpace(el)) collapseRunWhitespace(runs);
1153
+ const plain = runs.map((r) => r.text).join("");
1154
+ const dataW = num2(el.getAttribute("data-weasel-width"), NaN);
1155
+ const dataH = num2(el.getAttribute("data-weasel-height"), NaN);
1047
1156
  const width = Number.isFinite(dataW) ? dataW : UNBOUNDED_TEXT_WIDTH;
1048
1157
  const lines = (plain.match(/\n/g)?.length ?? 0) + 1;
1049
1158
  const height = Number.isFinite(dataH) ? dataH : fontSize * lineHeight * lines;
1050
1159
  const opacity = readOpacityAttr(el, "opacity");
1051
1160
  const node = {
1052
1161
  kind: "text",
1053
- x: topY === ay ? ax : ax,
1054
- // x is unchanged by the baseline shift
1162
+ x: ax,
1055
1163
  y: topY,
1056
1164
  width,
1057
1165
  height,
@@ -1064,13 +1172,14 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
1064
1172
  if (Object.keys(textStyle).length > 0) node.style = textStyle;
1065
1173
  if (opacity != null) node.opacity = opacity;
1066
1174
  if (!isIdentity(localTransform)) {
1175
+ const worldLocal = rebaseTransform(ctm, localTransform);
1067
1176
  const cx = node.x + node.width / 2;
1068
1177
  const cy = node.y + node.height / 2;
1069
- const angle = decomposeRotation(localTransform, cx, cy);
1178
+ const angle = decomposeRotation(worldLocal, cx, cy);
1070
1179
  if (angle != null) {
1071
1180
  node.rotation = angle;
1072
1181
  } else {
1073
- const rotComp = rotationComponent(localTransform);
1182
+ const rotComp = rotationComponent(worldLocal);
1074
1183
  if (Math.abs(rotComp) > 1e-4) {
1075
1184
  onWarn(`<text> transform has a rotational component that can't be cleanly stored as rotation; dropped (may not round-trip identically)`);
1076
1185
  }
@@ -1362,11 +1471,11 @@ function pathXml(node, registry, namespaces) {
1362
1471
  }
1363
1472
  return `<path ${attrs.join(" ")}${metaAttrs}/>`;
1364
1473
  }
1365
- function paintAttrs(paint, name, registry) {
1474
+ function paintAttrs(paint, name, registry, includeOpacity = true) {
1366
1475
  if (paint.kind === "none") return [`${name}="none"`];
1367
1476
  if (paint.kind === "solid") {
1368
1477
  const out = [`${name}="${paint.color}"`];
1369
- if (paint.opacity != null && paint.opacity !== 1) {
1478
+ if (includeOpacity && paint.opacity != null && paint.opacity !== 1) {
1370
1479
  out.push(`${name}-opacity="${trimNumber(paint.opacity)}"`);
1371
1480
  }
1372
1481
  return out;
@@ -1397,10 +1506,11 @@ function coreStrokeAttrs(stroke, registry) {
1397
1506
  return attrs;
1398
1507
  }
1399
1508
  function strokeAttrsFor(stroke, registry) {
1400
- const attrs = paintAttrs(stroke.paint, "stroke", registry);
1509
+ const attrs = paintAttrs(stroke.paint, "stroke", registry, false);
1401
1510
  attrs.push(`stroke-width="${trimNumber(stroke.width)}"`);
1402
- if (stroke.opacity != null && stroke.opacity !== 1) {
1403
- attrs.push(`stroke-opacity="${trimNumber(stroke.opacity)}"`);
1511
+ const opacity = stroke.opacity ?? (stroke.paint.kind === "solid" ? stroke.paint.opacity : void 0);
1512
+ if (opacity != null && opacity !== 1) {
1513
+ attrs.push(`stroke-opacity="${trimNumber(opacity)}"`);
1404
1514
  }
1405
1515
  if (stroke.cap) {
1406
1516
  attrs.push(`stroke-linecap="${stroke.cap}"`);
@@ -1418,28 +1528,47 @@ function strokeAttrsFor(stroke, registry) {
1418
1528
  }
1419
1529
  function computeBounds(nodes) {
1420
1530
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1421
- const visit = (n) => {
1531
+ const visit = (n, ctm) => {
1422
1532
  if (n.kind === "group") {
1423
- n.children.forEach(visit);
1533
+ const childCtm = n.transform ? multiply(ctm, n.transform) : ctm;
1534
+ n.children.forEach((c) => visit(c, childCtm));
1424
1535
  return;
1425
1536
  }
1426
1537
  const b = n.kind === "text" || n.kind === "image" ? { minX: n.x, minY: n.y, maxX: n.x + n.width, maxY: n.y + n.height } : pathBounds(n.path);
1427
- if (b.minX < minX) minX = b.minX;
1428
- if (b.minY < minY) minY = b.minY;
1429
- if (b.maxX > maxX) maxX = b.maxX;
1430
- if (b.maxY > maxY) maxY = b.maxY;
1538
+ const local = n.rotation ? rotateAboutCenter(ctm, b, n.rotation) : ctm;
1539
+ for (const [x, y] of corners(b)) {
1540
+ const px = local[0] * x + local[2] * y + local[4];
1541
+ const py = local[1] * x + local[3] * y + local[5];
1542
+ if (px < minX) minX = px;
1543
+ if (py < minY) minY = py;
1544
+ if (px > maxX) maxX = px;
1545
+ if (py > maxY) maxY = py;
1546
+ }
1431
1547
  };
1432
- nodes.forEach(visit);
1548
+ nodes.forEach((n) => visit(n, IDENTITY_MATRIX));
1433
1549
  if (!Number.isFinite(minX)) {
1434
1550
  return { x: 0, y: 0, width: 0, height: 0 };
1435
1551
  }
1436
1552
  return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1437
1553
  }
1554
+ function corners(b) {
1555
+ return [[b.minX, b.minY], [b.maxX, b.minY], [b.maxX, b.maxY], [b.minX, b.maxY]];
1556
+ }
1557
+ function rotateAboutCenter(ctm, b, angle) {
1558
+ const cx = (b.minX + b.maxX) / 2;
1559
+ const cy = (b.minY + b.maxY) / 2;
1560
+ const c = Math.cos(angle);
1561
+ const s = Math.sin(angle);
1562
+ return multiply(ctm, [c, s, -s, c, cx - cx * c + cy * s, cy - cx * s - cy * c]);
1563
+ }
1438
1564
  function textXml(node, registry, namespaces) {
1439
1565
  const attrs = [
1440
1566
  `x="${trimNumber(node.x)}"`,
1441
1567
  `y="${trimNumber(node.y)}"`,
1442
1568
  `dominant-baseline="text-before-edge"`,
1569
+ // The runs model stores real line breaks; SVG's default whitespace
1570
+ // handling would collapse them (and any indentation) away on re-import.
1571
+ `xml:space="preserve"`,
1443
1572
  `data-weasel-width="${trimNumber(node.width)}"`,
1444
1573
  `data-weasel-height="${trimNumber(node.height)}"`
1445
1574
  ];