@weasel-js/svg 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,21 +1,9 @@
1
- import { pathFromD, PATH_M, PATH_L, PATH_Z, boundsOfPath, getMarker, SCRIPT_METRICS, dwarn, createInsertOp, PathBuilder, parseColor, rgbaToHex, resolveTextStyle, layoutRuns, resolveRuns, resolveAlign, PATH_C, PATH_Q, solid, fillToBoundsFrame, getPaintKind } from '@weasel-js/core';
2
- import { invert } from '@weasel-js/geom';
1
+ import { pathFromD, PATH_M, PATH_L, PATH_Z, boundsOfPath, resolveScreenLength, SCRIPT_METRICS, dwarn, getMarker, registerMarker, createInsertOp, PathBuilder, parseColor, rgbaToHex, resolveTextStyle, layoutRuns, resolveRuns, resolveAlign, PATH_C, PATH_Q, solid, fillToBoundsFrame, getPaintKind } from '@weasel-js/core';
2
+ import { identity, multiply, invert } from '@weasel-js/geom';
3
3
  import { tileGeometry } from '@weasel-js/core/patterns-builtin';
4
4
 
5
5
  // src/parse.ts
6
-
7
- // src/types.ts
8
- var IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0];
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
- }
6
+ var IDENTITY_MATRIX = identity();
19
7
  function applyMatrix(m, x, y) {
20
8
  return { x: m[0] * x + m[2] * y + m[4], y: m[1] * x + m[3] * y + m[5] };
21
9
  }
@@ -77,10 +65,9 @@ function parseTransform(attr, onWarn) {
77
65
  return result;
78
66
  }
79
67
  function rebaseTransform(ctm, local) {
80
- const inv = invert([...ctm]);
68
+ const inv = invert(ctm);
81
69
  if (!inv) return null;
82
- const [a, b, c, d, e, f] = inv;
83
- return multiply(multiply(ctm, local), [a, b, c, d, e, f]);
70
+ return multiply(multiply(ctm, local), inv);
84
71
  }
85
72
  function decomposeRotation(m, cx, cy, eps = 1e-4) {
86
73
  const [a, b, c, d, e, f] = m;
@@ -242,8 +229,18 @@ function parsePaintAttr(raw) {
242
229
  if (s === "currentColor" || s === "currentcolor") {
243
230
  return { kind: "solid", color: "#000000", alpha: 1 };
244
231
  }
245
- const urlMatch = /^url\(\s*#([^)\s]+)\s*\)/.exec(s);
246
- if (urlMatch) return { kind: "ref", id: urlMatch[1] };
232
+ const urlMatch = /^url\(\s*#([^)\s]+)\s*\)(.*)$/.exec(s);
233
+ if (urlMatch) {
234
+ const out = { kind: "ref", id: urlMatch[1] };
235
+ const rest = urlMatch[2].trim();
236
+ if (rest !== "" && rest !== "none") {
237
+ const fallback = parsePaintAttr(rest);
238
+ if (fallback?.kind === "solid") {
239
+ out.fallback = { color: fallback.color, alpha: fallback.alpha };
240
+ }
241
+ }
242
+ return out;
243
+ }
247
244
  try {
248
245
  const [r, g, b, a] = parseColor(s);
249
246
  return { kind: "solid", color: rgbaToHex([r, g, b]), alpha: a };
@@ -387,13 +384,45 @@ function serializePathD(path) {
387
384
  }
388
385
 
389
386
  // src/gradients.ts
390
- var GRADIENT_TAGS = /* @__PURE__ */ new Set(["lineargradient", "radialgradient"]);
387
+ var WEASEL_NS = "urn:weasel-js:svg";
388
+ var WEASEL_NS_PREFIX = "wzl";
389
+ function hasNativeSvgForm(paint) {
390
+ const kind = paint.fill ?? "solid";
391
+ return kind === "solid" || kind === "linear-gradient" || kind === "radial-gradient" || kind === "pattern";
392
+ }
393
+ var INTERPOLATE_SPACES = /* @__PURE__ */ new Set(["rgb", "oklab", "oklch"]);
394
+ function interpolateAttr(space) {
395
+ if (!space || space === "rgb") return "";
396
+ return ` ${WEASEL_NS_PREFIX}:interpolate="${space}"`;
397
+ }
398
+ function readInterpolate(el, elements) {
399
+ const raw = inheritedAttr(el, elements, `${WEASEL_NS_PREFIX}:interpolate`) ?? el.getAttributeNS(WEASEL_NS, "interpolate");
400
+ if (raw === "oklab" || raw === "oklch") return raw;
401
+ return void 0;
402
+ }
403
+ function usesInterpolateAttr(paint) {
404
+ const space = paint.interpolate;
405
+ return space != null && space !== "rgb" && INTERPOLATE_SPACES.has(space);
406
+ }
407
+ function localName(tag) {
408
+ const i = tag.indexOf(":");
409
+ return (i < 0 ? tag : tag.slice(i + 1)).toLowerCase();
410
+ }
411
+ var GRADIENT_TAGS = /* @__PURE__ */ new Set([
412
+ "lineargradient",
413
+ "radialgradient",
414
+ "conicgradient",
415
+ `${WEASEL_NS_PREFIX}:conicgradient`,
416
+ "meshgradient",
417
+ `${WEASEL_NS_PREFIX}:meshgradient`
418
+ ]);
391
419
  var XLINK_NS = "http://www.w3.org/1999/xlink";
392
420
  function collectGradients(svg, onWarn) {
393
421
  const out = /* @__PURE__ */ new Map();
394
422
  const elements = collectElementsByTag(svg, GRADIENT_TAGS);
395
423
  for (const [id, el] of elements) {
396
- const paint = el.tagName.toLowerCase() === "lineargradient" ? readLinearGradient(el, elements, onWarn) : readRadialGradient(el, elements, onWarn);
424
+ const tag = localName(el.tagName);
425
+ const paint = tag === "lineargradient" ? readLinearGradient(el, elements, onWarn) : tag === "conicgradient" ? readConicGradient(el, elements, onWarn) : tag === "meshgradient" ? readMeshGradient(el, onWarn) : readRadialGradient(el, elements, onWarn);
397
426
  if (paint) out.set(id, paint);
398
427
  }
399
428
  warnUnsupportedDefsChildren(svg, onWarn);
@@ -407,7 +436,8 @@ function warnUnsupportedDefsChildren(svg, onWarn) {
407
436
  for (let i = 0; i < root.children.length; i++) {
408
437
  const child = root.children[i];
409
438
  const tag = child.tagName.toLowerCase();
410
- if (GRADIENT_TAGS.has(tag) || tag === "pattern" || tag === "marker") continue;
439
+ if (GRADIENT_TAGS.has(tag) || GRADIENT_TAGS.has(localName(tag))) continue;
440
+ if (tag === "pattern" || tag === "marker" || tag === "clippath") continue;
411
441
  onWarn(`unsupported <defs> child: <${child.tagName}>`);
412
442
  }
413
443
  }
@@ -489,7 +519,8 @@ function readLinearGradient(el, elements, onWarn) {
489
519
  y: num(inheritedAttr(el, elements, "y2"), 0)
490
520
  },
491
521
  stops: inheritedStops(el, elements, onWarn),
492
- units: readGradientUnits(el, elements)
522
+ units: readGradientUnits(el, elements),
523
+ interpolate: readInterpolate(el, elements)
493
524
  };
494
525
  }
495
526
  function readRadialGradient(el, elements, onWarn) {
@@ -502,7 +533,48 @@ function readRadialGradient(el, elements, onWarn) {
502
533
  },
503
534
  radius: num(inheritedAttr(el, elements, "r"), 0.5),
504
535
  stops: inheritedStops(el, elements, onWarn),
505
- units: readGradientUnits(el, elements)
536
+ units: readGradientUnits(el, elements),
537
+ interpolate: readInterpolate(el, elements)
538
+ };
539
+ }
540
+ function readConicGradient(el, elements, onWarn) {
541
+ warnGradientTransform(el, elements, onWarn);
542
+ return {
543
+ fill: "conic-gradient",
544
+ center: {
545
+ x: num(inheritedAttr(el, elements, "cx"), 0.5),
546
+ y: num(inheritedAttr(el, elements, "cy"), 0.5)
547
+ },
548
+ angle: num(inheritedAttr(el, elements, "angle"), 0),
549
+ stops: inheritedStops(el, elements, onWarn),
550
+ units: readGradientUnits(el, elements),
551
+ interpolate: readInterpolate(el, elements)
552
+ };
553
+ }
554
+ function readMeshGradient(el, onWarn) {
555
+ const patches = [];
556
+ for (let i = 0; i < el.children.length; i++) {
557
+ const child = el.children[i];
558
+ if (localName(child.tagName) !== "patch") continue;
559
+ const points = (child.getAttribute("points") ?? "").trim().split(/\s+/).filter(Boolean).map((pair) => {
560
+ const [x, y] = pair.split(",").map(Number);
561
+ return { x, y };
562
+ });
563
+ const colors = (child.getAttribute("colors") ?? "").trim().split(/\s+/).filter(Boolean);
564
+ const sane = (points.length === 12 || points.length === 16) && points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) && colors.length === 4;
565
+ if (!sane) {
566
+ onWarn?.(`<${WEASEL_NS_PREFIX}:patch> with ${points.length} points and ${colors.length} colors \u2014 dropped`);
567
+ continue;
568
+ }
569
+ patches.push({ points, colors });
570
+ }
571
+ if (patches.length === 0) return null;
572
+ const space = el.getAttribute("interpolate") ?? el.getAttributeNS(WEASEL_NS, "interpolate");
573
+ return {
574
+ fill: "mesh-gradient",
575
+ patches,
576
+ units: el.getAttribute("gradientUnits") === "objectBoundingBox" ? "bounds" : "world",
577
+ ...space === "oklab" || space === "oklch" ? { interpolate: space } : {}
506
578
  };
507
579
  }
508
580
  var PaintServerRegistry = class {
@@ -511,11 +583,14 @@ var PaintServerRegistry = class {
511
583
  counter = 0;
512
584
  // Marker keys referenced by any stroke, in first-use order. The `<defs>`
513
585
  // id is the marker key itself, not a minted counter id: `parseSvg` reads a
514
- // `marker-end="url(#id)"` fragment back as the marker key directly (it
515
- // never inspects the `<marker>` element), so a synthetic id would come
516
- // back unresolvable and warn on round-trip.
586
+ // `marker-end="url(#id)"` fragment back as the marker key directly when the
587
+ // registry knows it, so a synthetic id would come back as a stranger.
517
588
  markerKeys = [];
518
589
  markerKeySet = /* @__PURE__ */ new Set();
590
+ // Clip paths, in first-use order. Identity, not value: two groups clipped by
591
+ // equal outlines get a def each, which costs bytes and misleads nobody.
592
+ clips = [];
593
+ clipIds = /* @__PURE__ */ new Map();
519
594
  register(paint) {
520
595
  const existing = this.byPaint.get(paint);
521
596
  if (existing) return existing;
@@ -524,6 +599,23 @@ var PaintServerRegistry = class {
524
599
  this.order.push(paint);
525
600
  return id;
526
601
  }
602
+ /**
603
+ * The `fill` / `stroke` attribute value referencing this paint server: a
604
+ * `url(#id)` reference, plus the fallback paint SVG's own grammar allows
605
+ * after one for any kind SVG cannot express. A renderer that cannot resolve
606
+ * the reference takes the fallback instead of dropping the fill, which is
607
+ * the difference between a flat shape and a vanished one.
608
+ */
609
+ ref(paint) {
610
+ const id = this.register(paint);
611
+ if (hasNativeSvgForm(paint)) return `url(#${id})`;
612
+ return `url(#${id}) ${paintFallback(paint)}`;
613
+ }
614
+ /** Whether any registered paint serializes outside SVG's own vocabulary, so
615
+ * the root has to declare {@link WEASEL_NS}. */
616
+ usesPrivateNamespace() {
617
+ return this.order.some((paint) => !hasNativeSvgForm(paint) || usesInterpolateAttr(paint));
618
+ }
527
619
  /** The `<defs>` id for a marker key — the key itself, minting nothing.
528
620
  * `undefined` when nothing is registered under `key`, so a caller emits no
529
621
  * attribute at all rather than a `url(#…)` pointing at a def that will
@@ -536,9 +628,21 @@ var PaintServerRegistry = class {
536
628
  }
537
629
  return key;
538
630
  }
539
- /** Emit `<defs>...</defs>` XML for every registered paint server and marker. */
631
+ /** Mint (or recall) the `<defs>` id for a clip outline. */
632
+ clipId(path) {
633
+ const existing = this.clipIds.get(path);
634
+ if (existing) return existing;
635
+ const id = `clip${this.clips.length}`;
636
+ this.clipIds.set(path, id);
637
+ this.clips.push(path);
638
+ return id;
639
+ }
640
+ /** Emit `<defs>...</defs>` XML for every registered paint server, marker
641
+ * and clip path. */
540
642
  toDefsXml(onWarn) {
541
- if (this.order.length === 0 && this.markerKeys.length === 0) return "";
643
+ if (this.order.length === 0 && this.markerKeys.length === 0 && this.clips.length === 0) {
644
+ return "";
645
+ }
542
646
  const parts = ["<defs>"];
543
647
  for (const paint of this.order) {
544
648
  parts.push(paintServerXml(this.byPaint.get(paint), paint, onWarn));
@@ -546,18 +650,33 @@ var PaintServerRegistry = class {
546
650
  for (const key of this.markerKeys) {
547
651
  const entry = getMarker(key);
548
652
  if (!entry) continue;
549
- parts.push(entry.toSvg ? entry.toSvg(key, entry) : defaultMarkerXml(key, entry));
653
+ parts.push(entry.toSvg ? entry.toSvg(key, entry) : defaultMarkerXml(key, entry, onWarn));
654
+ }
655
+ for (const path of this.clips) {
656
+ parts.push(
657
+ `<clipPath id="${this.clipIds.get(path)}"><path d="${serializePathD(path)}"/></clipPath>`
658
+ );
550
659
  }
551
660
  parts.push("</defs>");
552
661
  return parts.join("");
553
662
  }
554
663
  };
555
- function defaultMarkerXml(id, entry) {
664
+ function defaultMarkerXml(id, entry, onWarn) {
556
665
  const path = entry.path({ size: 1, stroke: { paint: { fill: "solid", color: "#000" } } });
557
666
  const d = serializePathD(path);
558
- const fill = entry.fill === "none" ? "none" : "context-stroke";
559
- const outline = entry.outline ? ` stroke="context-stroke" stroke-width="${entry.outline.width}" stroke-linecap="round" stroke-linejoin="round"` : "";
560
- return `<marker id="${id}" markerUnits="strokeWidth" markerWidth="8" markerHeight="8" refX="0" refY="0" orient="auto" overflow="visible"><path d="${d}" fill="${fill}"${outline}/></marker>`;
667
+ const fill = markerPaintAttrs("fill", entry.fill, id, onWarn);
668
+ const outline = entry.outline ? ` ${markerPaintAttrs("stroke", entry.outline.paint, id, onWarn)} stroke-width="${trimNumber(entry.outline.width)}" stroke-linecap="round" stroke-linejoin="round"` : "";
669
+ const orient = typeof entry.orient === "number" ? trimNumber(entry.orient * 180 / Math.PI) : "auto-start-reverse";
670
+ return `<marker id="${id}" markerUnits="strokeWidth" markerWidth="8" markerHeight="8" refX="0" refY="0" orient="${orient}" overflow="visible"><path d="${d}" ${fill}${outline}/></marker>`;
671
+ }
672
+ function markerPaintAttrs(attr, paint, id, onWarn) {
673
+ if (paint === "none") return `${attr}="none"`;
674
+ if (paint === void 0 || paint === "line") return `${attr}="context-stroke"`;
675
+ if ("color" in paint && (paint.fill === void 0 || paint.fill === "solid")) {
676
+ return paint.opacity != null && paint.opacity !== 1 ? `${attr}="${paint.color}" ${attr}-opacity="${trimNumber(paint.opacity)}"` : `${attr}="${paint.color}"`;
677
+ }
678
+ onWarn?.(`marker "${id}" ${attr} is a paint server, which a <marker> def cannot reference here; written as the line's paint`);
679
+ return `${attr}="context-stroke"`;
561
680
  }
562
681
  function paintServerXml(id, paint, onWarn) {
563
682
  if (paint.fill === "pattern") return patternXml(id, paint, onWarn);
@@ -574,14 +693,25 @@ function gradientUnitsAttr(units) {
574
693
  function gradientXml(id, paint) {
575
694
  if (paint.fill === "linear-gradient") {
576
695
  const stops = paint.stops.map(stopXml).join("");
577
- return `<linearGradient id="${id}" gradientUnits="${gradientUnitsAttr(paint.units)}" x1="${trimNumber(paint.from.x)}" y1="${trimNumber(paint.from.y)}" x2="${trimNumber(paint.to.x)}" y2="${trimNumber(paint.to.y)}">${stops}</linearGradient>`;
696
+ return `<linearGradient id="${id}" gradientUnits="${gradientUnitsAttr(paint.units)}" x1="${trimNumber(paint.from.x)}" y1="${trimNumber(paint.from.y)}" x2="${trimNumber(paint.to.x)}" y2="${trimNumber(paint.to.y)}"${interpolateAttr(paint.interpolate)}>${stops}</linearGradient>`;
697
+ }
698
+ if (paint.fill === "conic-gradient") {
699
+ const stops = paint.stops.map(stopXml).join("");
700
+ const P = WEASEL_NS_PREFIX;
701
+ return `<${P}:conicGradient id="${id}" gradientUnits="${gradientUnitsAttr(paint.units)}" cx="${trimNumber(paint.center.x)}" cy="${trimNumber(paint.center.y)}" angle="${trimNumber(paint.angle)}"${interpolateAttr(paint.interpolate)}>${stops}</${P}:conicGradient>`;
578
702
  }
579
703
  if (paint.fill === "radial-gradient") {
580
704
  const stops = paint.stops.map(stopXml).join("");
581
- return `<radialGradient id="${id}" gradientUnits="${gradientUnitsAttr(paint.units)}" cx="${trimNumber(paint.center.x)}" cy="${trimNumber(paint.center.y)}" r="${trimNumber(paint.radius)}">${stops}</radialGradient>`;
705
+ return `<radialGradient id="${id}" gradientUnits="${gradientUnitsAttr(paint.units)}" cx="${trimNumber(paint.center.x)}" cy="${trimNumber(paint.center.y)}" r="${trimNumber(paint.radius)}"${interpolateAttr(paint.interpolate)}>${stops}</radialGradient>`;
582
706
  }
583
707
  return "";
584
708
  }
709
+ function paintFallback(paint) {
710
+ const color = getPaintKind(paint.fill)?.colorOf(paint);
711
+ if (!color) return "none";
712
+ const packed = /^#([0-9a-f]{6})[0-9a-f]{2}$/i.exec(color);
713
+ return packed ? `#${packed[1].toLowerCase()}` : color;
714
+ }
585
715
  function stopXml(s) {
586
716
  const m = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(s.color);
587
717
  if (m) {
@@ -661,6 +791,7 @@ var IGNORED_TAGS = /* @__PURE__ */ new Set([
661
791
  "radialgradient",
662
792
  "pattern",
663
793
  "marker",
794
+ "clippath",
664
795
  "title",
665
796
  "desc",
666
797
  "metadata"
@@ -693,8 +824,11 @@ function parseSvg(svg, opts = {}) {
693
824
  const gradients = collectGradients(root, onWarn);
694
825
  for (const [id, paint] of collectPatterns(root, onWarn)) gradients.set(id, paint);
695
826
  const rootStyle = deriveStyle(EMPTY_STYLE, root);
696
- const nodes = parseChildren(root, IDENTITY_MATRIX, rootStyle, gradients, onWarn, uriToPrefix);
827
+ const clips = collectClipPaths(root, onWarn);
828
+ const nodes = parseChildren(root, IDENTITY_MATRIX, rootStyle, gradients, clips, onWarn, uriToPrefix);
829
+ const markers = ingestMarkers(root, nodes, gradients, onWarn);
697
830
  const result = { nodes, warnings };
831
+ if (markers.length > 0) result.markers = markers;
698
832
  if (documentMeta) result.documentMeta = documentMeta;
699
833
  const viewBox = parseViewBoxAttr(root.getAttribute("viewBox"));
700
834
  if (viewBox) result.viewBox = viewBox;
@@ -796,13 +930,53 @@ function collectElementMeta(el, uriToPrefix) {
796
930
  }
797
931
  return Object.keys(meta).length > 0 ? meta : void 0;
798
932
  }
799
- function parseChildren(parent, ctm, style, gradients, onWarn, uriToPrefix) {
933
+ function collectClipPaths(root, onWarn) {
934
+ const out = /* @__PURE__ */ new Map();
935
+ for (const [id, el] of collectElementsByTag(root, /* @__PURE__ */ new Set(["clippath"]))) {
936
+ const units = el.getAttribute("clipPathUnits");
937
+ if (units && units !== "userSpaceOnUse") {
938
+ onWarn(`<clipPath id="${id}"> uses clipPathUnits="${units}", which is not modeled`);
939
+ continue;
940
+ }
941
+ const shapes = [];
942
+ for (let i = 0; i < el.children.length; i++) {
943
+ if (SUPPORTED_LEAF_TAGS.has(el.children[i].tagName.toLowerCase())) shapes.push(el.children[i]);
944
+ }
945
+ if (shapes.length !== 1) {
946
+ onWarn(
947
+ `<clipPath id="${id}"> holds ${shapes.length} shapes the kit can lower; only a single-shape clip is modeled, so it clips nothing`
948
+ );
949
+ continue;
950
+ }
951
+ const shape = shapes[0];
952
+ const local = parseTransform(shape.getAttribute("transform"), onWarn);
953
+ const path = lowerLeaf(shape, shape.tagName.toLowerCase(), local, onWarn);
954
+ if (path) out.set(id, path);
955
+ }
956
+ return out;
957
+ }
958
+ function clipFor(el, ctm, clips, onWarn) {
959
+ const attr = el.getAttribute("clip-path");
960
+ if (!attr || attr === "none") return void 0;
961
+ const ref = /^url\(\s*#([^)\s]+)\s*\)$/.exec(attr.trim());
962
+ if (!ref) {
963
+ onWarn(`clip-path="${attr}" is not a url(#id) reference; the clip is dropped`);
964
+ return void 0;
965
+ }
966
+ const path = clips.get(ref[1]);
967
+ if (!path) {
968
+ onWarn(`clip-path references #${ref[1]}, which no <clipPath> in the document defines`);
969
+ return void 0;
970
+ }
971
+ return transformPath(path, ctm);
972
+ }
973
+ function parseChildren(parent, ctm, style, gradients, clips, onWarn, uriToPrefix) {
800
974
  const out = [];
801
975
  for (let i = 0; i < parent.children.length; i++) {
802
976
  const el = parent.children[i];
803
977
  const ns = el.namespaceURI;
804
978
  if (ns && ns !== "http://www.w3.org/2000/svg") continue;
805
- const node = parseElement(el, ctm, style, gradients, onWarn, uriToPrefix);
979
+ const node = parseElement(el, ctm, style, gradients, clips, onWarn, uriToPrefix);
806
980
  if (node) {
807
981
  if (Array.isArray(node)) out.push(...node);
808
982
  else out.push(node);
@@ -810,16 +984,26 @@ function parseChildren(parent, ctm, style, gradients, onWarn, uriToPrefix) {
810
984
  }
811
985
  return out;
812
986
  }
813
- function parseElement(el, ctm, style, gradients, onWarn, uriToPrefix) {
987
+ function parseElement(el, ctm, style, gradients, clips, onWarn, uriToPrefix) {
814
988
  const tag = el.tagName.toLowerCase();
815
989
  if (IGNORED_TAGS.has(tag)) return null;
990
+ if (tag === "g" && el.hasAttribute("data-weasel-image")) {
991
+ const image = parseCroppedImage(el, ctm, onWarn);
992
+ if (image) {
993
+ const meta2 = collectElementMeta(el, uriToPrefix);
994
+ if (meta2) image.meta = meta2;
995
+ return image;
996
+ }
997
+ }
816
998
  if (tag === "g") {
817
999
  const local = parseTransform(el.getAttribute("transform"), onWarn);
818
1000
  const childCtm = multiply(ctm, local);
819
1001
  const childStyle = deriveStyle(style, el);
820
- const children = parseChildren(el, childCtm, childStyle, gradients, onWarn, uriToPrefix);
1002
+ const children = parseChildren(el, childCtm, childStyle, gradients, clips, onWarn, uriToPrefix);
821
1003
  const opacity2 = readOpacityAttr(el, "opacity");
822
1004
  const group = { kind: "group", children };
1005
+ const clip = clipFor(el, childCtm, clips, onWarn);
1006
+ if (clip) group.clip = clip;
823
1007
  if (opacity2 != null) group.opacity = opacity2;
824
1008
  const meta2 = collectElementMeta(el, uriToPrefix);
825
1009
  if (meta2) group.meta = meta2;
@@ -833,7 +1017,7 @@ function parseElement(el, ctm, style, gradients, onWarn, uriToPrefix) {
833
1017
  if (el.hasAttribute("viewBox")) {
834
1018
  onWarn("nested <svg> viewBox is not modeled; its children are not rescaled");
835
1019
  }
836
- return parseChildren(el, multiply(ctm, offset), childStyle, gradients, onWarn, uriToPrefix);
1020
+ return parseChildren(el, multiply(ctm, offset), childStyle, gradients, clips, onWarn, uriToPrefix);
837
1021
  }
838
1022
  if (tag === "text") {
839
1023
  const textNode = parseTextElement(el, ctm, style, gradients, onWarn);
@@ -978,6 +1162,12 @@ function readPaint(style, attr, defaultColor, gradients, onWarn) {
978
1162
  if (parsed.kind === "ref") {
979
1163
  const paint = gradients.get(parsed.id);
980
1164
  if (!paint) {
1165
+ if (parsed.fallback) {
1166
+ const flat = { kind: "solid", color: parsed.fallback.color };
1167
+ const a2 = opacity ?? (parsed.fallback.alpha < 1 ? parsed.fallback.alpha : void 0);
1168
+ if (a2 != null) flat.opacity = a2;
1169
+ return flat;
1170
+ }
981
1171
  onWarn(`${attr} references unknown gradient #${parsed.id}`);
982
1172
  return { kind: "solid", color: defaultColor };
983
1173
  }
@@ -1034,12 +1224,7 @@ function readStroke(style, gradients, onWarn, nonScaling = false) {
1034
1224
  ["marker-end", "markerEnd"]
1035
1225
  ]) {
1036
1226
  const id = parseMarkerRef(style[attr] ?? null);
1037
- if (id === void 0) continue;
1038
- if (getMarker(id) === void 0) {
1039
- onWarn(`${attr} references a marker this kit has no entry for: #${id}`);
1040
- continue;
1041
- }
1042
- stroke[field] = id;
1227
+ if (id !== void 0) stroke[field] = id;
1043
1228
  }
1044
1229
  return stroke;
1045
1230
  }
@@ -1155,20 +1340,30 @@ function parseRunFontSize(raw, onWarn) {
1155
1340
  }
1156
1341
  var XLINK_NS2 = "http://www.w3.org/1999/xlink";
1157
1342
  function parseImageElement(el, ctm, onWarn) {
1158
- const href = el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS2, "href") ?? el.getAttribute("xlink:href");
1343
+ const href = imageHref(el);
1159
1344
  if (!href) {
1160
1345
  onWarn("<image> without href; dropped");
1161
1346
  return null;
1162
1347
  }
1348
+ const node = imageNodeAt(el, el, href, ctm, onWarn);
1349
+ if (el.hasAttribute("preserveAspectRatio") && el.getAttribute("preserveAspectRatio") !== "none") {
1350
+ onWarn("<image> preserveAspectRatio is not modeled; the box is taken literally");
1351
+ }
1352
+ return node;
1353
+ }
1354
+ function imageHref(el) {
1355
+ return el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS2, "href") ?? el.getAttribute("xlink:href");
1356
+ }
1357
+ function imageNodeAt(boxEl, ownerEl, href, ctm, onWarn) {
1163
1358
  const num2 = (raw, fallback) => {
1164
1359
  if (raw == null) return fallback;
1165
1360
  const n = parseFloat(raw);
1166
1361
  return Number.isFinite(n) ? n : fallback;
1167
1362
  };
1168
- const rawX = num2(el.getAttribute("x"), 0);
1169
- const rawY = num2(el.getAttribute("y"), 0);
1170
- const rawW = num2(el.getAttribute("width"), 0);
1171
- const rawH = num2(el.getAttribute("height"), 0);
1363
+ const rawX = num2(boxEl.getAttribute("x"), 0);
1364
+ const rawY = num2(boxEl.getAttribute("y"), 0);
1365
+ const rawW = num2(boxEl.getAttribute("width"), 0);
1366
+ const rawH = num2(boxEl.getAttribute("height"), 0);
1172
1367
  const px = (x, y) => [ctm[0] * x + ctm[2] * y + ctm[4], ctm[1] * x + ctm[3] * y + ctm[5]];
1173
1368
  const [x0, y0] = px(rawX, rawY);
1174
1369
  const [x1, y1] = px(rawX + rawW, rawY + rawH);
@@ -1180,9 +1375,9 @@ function parseImageElement(el, ctm, onWarn) {
1180
1375
  width: Math.abs(x1 - x0),
1181
1376
  height: Math.abs(y1 - y0)
1182
1377
  };
1183
- const opacity = readOpacityAttr(el, "opacity");
1378
+ const opacity = readOpacityAttr(ownerEl, "opacity");
1184
1379
  if (opacity != null) node.opacity = opacity;
1185
- const localTransform = parseTransform(el.getAttribute("transform"), onWarn);
1380
+ const localTransform = parseTransform(ownerEl.getAttribute("transform"), onWarn);
1186
1381
  if (!isIdentity(localTransform)) {
1187
1382
  const worldLocal = rebaseTransform(ctm, localTransform);
1188
1383
  const cx = node.x + node.width / 2;
@@ -1199,9 +1394,21 @@ function parseImageElement(el, ctm, onWarn) {
1199
1394
  }
1200
1395
  }
1201
1396
  }
1202
- if (el.hasAttribute("preserveAspectRatio") && el.getAttribute("preserveAspectRatio") !== "none") {
1203
- onWarn("<image> preserveAspectRatio is not modeled; the box is taken literally");
1204
- }
1397
+ return node;
1398
+ }
1399
+ function parseCroppedImage(g, ctm, onWarn) {
1400
+ const viewport = [...g.children].find((c) => c.tagName.toLowerCase() === "svg");
1401
+ const img = viewport && [...viewport.children].find((c) => c.tagName.toLowerCase() === "image");
1402
+ const href = img && imageHref(img);
1403
+ const source = viewport && parseViewBoxAttr(viewport.getAttribute("viewBox"));
1404
+ if (!img || !href || !source) return null;
1405
+ const node = imageNodeAt(viewport, g, href, ctm, onWarn);
1406
+ if (source.x !== 0 || source.y !== 0 || source.width !== 1 || source.height !== 1) {
1407
+ node.source = source;
1408
+ }
1409
+ const flip = parseTransform(img.getAttribute("transform"), onWarn);
1410
+ if (flip[0] < 0) node.flipX = true;
1411
+ if (flip[3] < 0) node.flipY = true;
1205
1412
  return node;
1206
1413
  }
1207
1414
  var XML_NS = "http://www.w3.org/XML/1998/namespace";
@@ -1268,7 +1475,7 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
1268
1475
  const leafStyle = deriveStyle(style, el);
1269
1476
  const textStyle = readTextStyle(leafStyle, onWarn);
1270
1477
  const textPaint = readTextPaint(leafStyle, gradients, onWarn, isNonScalingStroke(el));
1271
- const fontSize = textStyle.fontSize ?? 16;
1478
+ const fontSize = resolveScreenLength(textStyle.fontSize ?? 16, 1);
1272
1479
  const lineHeight = textStyle.lineHeight ?? 1.2;
1273
1480
  const dominantBaseline = el.getAttribute("dominant-baseline");
1274
1481
  const explicitTopAnchor = dominantBaseline === "text-before-edge" || dominantBaseline === "hanging";
@@ -1315,6 +1522,8 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
1315
1522
  );
1316
1523
  if (hasStyling) node.runs = runs;
1317
1524
  if (el.getAttribute("data-weasel-wrap") === "true") textStyle.wrap = true;
1525
+ const va = el.getAttribute("data-weasel-vertical-align");
1526
+ if (va === "center" || va === "bottom") node.verticalAlign = va;
1318
1527
  if (Object.keys(textStyle).length > 0) node.style = textStyle;
1319
1528
  if (textPaint.fill !== void 0) node.fill = textPaint.fill;
1320
1529
  if (textPaint.stroke != null) node.stroke = textPaint.stroke;
@@ -1376,6 +1585,7 @@ function readTspanRun(el, gradients, style, onWarn) {
1376
1585
  } else if (parsed?.kind === "ref") {
1377
1586
  const paint = gradients.get(parsed.id);
1378
1587
  if (paint) run.fill = paint;
1588
+ else if (parsed.fallback) run.fill = { fill: "solid", color: parsed.fallback.color };
1379
1589
  }
1380
1590
  }
1381
1591
  const stroke = coreStroke(readStroke(ownStrokeStyle(el), gradients, onWarn, isNonScalingStroke(el)));
@@ -1425,6 +1635,9 @@ function readTextPaint(style, gradients, onWarn, nonScalingStroke = false) {
1425
1635
  } else if (parsed?.kind === "ref") {
1426
1636
  const paint = gradients.get(parsed.id);
1427
1637
  if (paint) out.fill = paint;
1638
+ else if (parsed.fallback) {
1639
+ out.fill = { fill: "solid", color: parsed.fallback.color };
1640
+ }
1428
1641
  } else if (parsed?.kind === "none") {
1429
1642
  out.fill = null;
1430
1643
  }
@@ -1433,6 +1646,196 @@ function readTextPaint(style, gradients, onWarn, nonScalingStroke = false) {
1433
1646
  if (stroke) out.stroke = stroke;
1434
1647
  return out;
1435
1648
  }
1649
+ var MARKER_FIELDS = [
1650
+ ["markerStart", "start", "marker-start"],
1651
+ ["markerMid", "mid", "marker-mid"],
1652
+ ["markerEnd", "end", "marker-end"]
1653
+ ];
1654
+ function ingestMarkers(root, nodes, gradients, onWarn) {
1655
+ const defs = /* @__PURE__ */ new Map();
1656
+ const all = root.getElementsByTagName("marker");
1657
+ for (let i = 0; i < all.length; i++) {
1658
+ const id = all[i].getAttribute("id");
1659
+ if (id && !defs.has(id)) defs.set(id, all[i]);
1660
+ }
1661
+ const minted = /* @__PURE__ */ new Map();
1662
+ const resolve = (stroke, width) => {
1663
+ const w = width === void 0 ? 1 : typeof width === "number" ? width : width.px;
1664
+ for (const [field, role, attr] of MARKER_FIELDS) {
1665
+ const id = stroke[field];
1666
+ if (typeof id !== "string" || getMarker(id) !== void 0) continue;
1667
+ const el = defs.get(id);
1668
+ if (!el) {
1669
+ onWarn(`${attr} references #${id}, which is neither a kit marker nor defined in the document`);
1670
+ delete stroke[field];
1671
+ continue;
1672
+ }
1673
+ const cacheKey = `${id}\0${role}\0${w}`;
1674
+ if (!minted.has(cacheKey)) minted.set(cacheKey, markerEntryFrom(el, id, role, w, gradients, onWarn));
1675
+ const entry = minted.get(cacheKey);
1676
+ if (entry) stroke[field] = entry.id;
1677
+ else delete stroke[field];
1678
+ }
1679
+ };
1680
+ const visit = (n) => {
1681
+ if (n.kind === "group") n.children.forEach(visit);
1682
+ else if (n.kind === "path" && n.stroke) resolve(n.stroke, n.stroke.width);
1683
+ else if (n.kind === "text") {
1684
+ if (n.stroke) resolve(n.stroke, n.stroke.width);
1685
+ for (const run of n.runs ?? []) {
1686
+ if (run.stroke) resolve(run.stroke, run.stroke.width ?? n.stroke?.width);
1687
+ }
1688
+ }
1689
+ };
1690
+ nodes.forEach(visit);
1691
+ const out = /* @__PURE__ */ new Map();
1692
+ for (const entry of minted.values()) if (entry) out.set(entry.id, entry);
1693
+ return [...out.values()];
1694
+ }
1695
+ function styleAt(el) {
1696
+ const chain = [];
1697
+ for (let e = el; e; e = e.parentElement) chain.unshift(e);
1698
+ return chain.reduce((style, e) => deriveStyle(style, e), EMPTY_STYLE);
1699
+ }
1700
+ function markerRef(raw, lo, span) {
1701
+ const v = raw?.trim();
1702
+ if (v === "left" || v === "top") return lo;
1703
+ if (v === "center") return lo + span / 2;
1704
+ if (v === "right" || v === "bottom") return lo + span;
1705
+ const n = v ? parseFloat(v) : 0;
1706
+ return Number.isFinite(n) ? n : 0;
1707
+ }
1708
+ function markerOrient(raw) {
1709
+ const v = raw?.trim() ?? "";
1710
+ if (v === "auto") return { orient: "auto", reverseAtStart: true };
1711
+ if (v === "auto-start-reverse") return { orient: "auto", reverseAtStart: false };
1712
+ const m = /^(-?[\d.]+(?:e-?\d+)?)(deg|rad|grad|turn)?$/i.exec(v);
1713
+ if (!m) return { orient: 0, reverseAtStart: false };
1714
+ const n = parseFloat(m[1]);
1715
+ const unit = (m[2] ?? "deg").toLowerCase();
1716
+ const rad = unit === "rad" ? n : unit === "grad" ? n * Math.PI / 200 : unit === "turn" ? n * 2 * Math.PI : n * Math.PI / 180;
1717
+ return { orient: rad, reverseAtStart: false };
1718
+ }
1719
+ function markerPaint(style, attr, gradients, onWarn) {
1720
+ const raw = style[attr]?.trim();
1721
+ if (raw === "context-stroke") return "line";
1722
+ if (raw === "context-fill") {
1723
+ onWarn(`a marker's ${attr}="context-fill" is read as the line's stroke paint`);
1724
+ return "line";
1725
+ }
1726
+ const paint = readPaint(style, attr, "#000000", gradients, onWarn);
1727
+ if (paint.kind === "none") return "none";
1728
+ if (paint.kind === "gradient") return paint.paint;
1729
+ return paint.opacity != null ? { ...solid(paint.color), opacity: paint.opacity } : solid(paint.color);
1730
+ }
1731
+ function asPolygon(path) {
1732
+ if (path.kind === "polygon") return path;
1733
+ const { x, y, width: w, height: h } = path;
1734
+ return {
1735
+ kind: "polygon",
1736
+ commands: new Uint8Array([PATH_M, PATH_L, PATH_L, PATH_L, PATH_Z]),
1737
+ coords: new Float32Array([x, y, x + w, y, x + w, y + h, x, y + h]),
1738
+ fillRule: "nonzero"
1739
+ };
1740
+ }
1741
+ function shortHash(s) {
1742
+ let h = 2166136261;
1743
+ for (let i = 0; i < s.length; i++) {
1744
+ h ^= s.charCodeAt(i);
1745
+ h = Math.imul(h, 16777619);
1746
+ }
1747
+ return (h >>> 0).toString(36);
1748
+ }
1749
+ function markerEntryFrom(el, id, role, strokeWidth, gradients, onWarn) {
1750
+ const num2 = (name, fallback) => {
1751
+ const n = parseFloat(el.getAttribute(name) ?? "");
1752
+ return Number.isFinite(n) ? n : fallback;
1753
+ };
1754
+ const vb = parseViewBoxAttr(el.getAttribute("viewBox"));
1755
+ let sx = 1, sy = 1;
1756
+ if (vb && vb.width > 0 && vb.height > 0) {
1757
+ sx = num2("markerWidth", 3) / vb.width;
1758
+ sy = num2("markerHeight", 3) / vb.height;
1759
+ const par = el.getAttribute("preserveAspectRatio")?.trim() ?? "";
1760
+ if (!par.startsWith("none")) {
1761
+ const s = par.endsWith("slice") ? Math.max(sx, sy) : Math.min(sx, sy);
1762
+ sx = s;
1763
+ sy = s;
1764
+ }
1765
+ }
1766
+ const refX = markerRef(el.getAttribute("refX"), vb?.x ?? 0, vb?.width ?? 0);
1767
+ const refY = markerRef(el.getAttribute("refY"), vb?.y ?? 0, vb?.height ?? 0);
1768
+ const k = el.getAttribute("markerUnits") === "userSpaceOnUse" && strokeWidth > 0 ? 1 / strokeWidth : 1;
1769
+ const { orient, reverseAtStart } = markerOrient(el.getAttribute("orient"));
1770
+ const turn = role === "start" && reverseAtStart ? -1 : 1;
1771
+ const base = [turn * sx * k, 0, 0, turn * sy * k, -turn * sx * k * refX, -turn * sy * k * refY];
1772
+ const parts = [];
1773
+ const collect = (parent, m, style) => {
1774
+ for (let i = 0; i < parent.children.length; i++) {
1775
+ const child = parent.children[i];
1776
+ const tag = child.tagName.toLowerCase();
1777
+ const cm = multiply(m, parseTransform(child.getAttribute("transform"), onWarn));
1778
+ const cs = deriveStyle(style, child);
1779
+ if (tag === "g") collect(child, cm, cs);
1780
+ else if (SUPPORTED_LEAF_TAGS.has(tag)) {
1781
+ const path = lowerLeaf(child, tag, cm, onWarn);
1782
+ if (path) parts.push({ path: asPolygon(path), style: cs });
1783
+ } else if (!IGNORED_TAGS.has(tag)) {
1784
+ onWarn(`<marker id="${id}"> child <${child.tagName}> is not supported; skipped`);
1785
+ }
1786
+ }
1787
+ };
1788
+ collect(el, base, styleAt(el));
1789
+ if (parts.length === 0) {
1790
+ onWarn(`<marker id="${id}"> draws nothing this kit can read; dropped`);
1791
+ return null;
1792
+ }
1793
+ const first = parts[0].style;
1794
+ const fill = markerPaint(first, "fill", gradients, onWarn);
1795
+ const outlinePaint = markerPaint(first, "stroke", gradients, onWarn);
1796
+ const outlineWidth = parseFloat(first["stroke-width"] ?? "1") * Math.sqrt(sx * sy) * k;
1797
+ const outline = outlinePaint !== "none" && outlineWidth > 0 ? { width: outlineWidth, paint: outlinePaint } : false;
1798
+ if (parts.some((p) => JSON.stringify(p.style) !== JSON.stringify(first))) {
1799
+ onWarn(`<marker id="${id}"> paints its parts differently; all take the first part's paint`);
1800
+ }
1801
+ let commandCount = 0, coordCount = 0;
1802
+ for (const p of parts) {
1803
+ commandCount += p.path.commands.length;
1804
+ coordCount += p.path.coords.length;
1805
+ }
1806
+ const commands = new Uint8Array(commandCount);
1807
+ const coords = new Float32Array(coordCount);
1808
+ let ci = 0, pi = 0;
1809
+ for (const p of parts) {
1810
+ commands.set(p.path.commands, ci);
1811
+ ci += p.path.commands.length;
1812
+ coords.set(p.path.coords, pi);
1813
+ pi += p.path.coords.length;
1814
+ }
1815
+ const fillRule = parts[0].path.fillRule ?? "nonzero";
1816
+ const round = (v) => Math.round(v * 1e4) / 1e4 + 0;
1817
+ const hash = shortHash(JSON.stringify([
1818
+ Array.from(commands),
1819
+ Array.from(coords, round),
1820
+ fillRule,
1821
+ fill,
1822
+ outline,
1823
+ typeof orient === "number" ? round(orient) : orient
1824
+ ]));
1825
+ const key = id.endsWith(`-${hash}`) ? id : `${id}-${hash}`;
1826
+ return {
1827
+ id: key,
1828
+ path: ({ size }) => {
1829
+ const scaled = new Float32Array(coords.length);
1830
+ for (let i = 0; i < coords.length; i++) scaled[i] = coords[i] * size;
1831
+ return { kind: "polygon", commands, coords: scaled, fillRule };
1832
+ },
1833
+ fill,
1834
+ outline,
1835
+ orient,
1836
+ inset: 0
1837
+ };
1838
+ }
1436
1839
  function serializeSvg(nodes, opts = {}) {
1437
1840
  const registry = new PaintServerRegistry();
1438
1841
  registerPaintServers(nodes, registry);
@@ -1440,6 +1843,9 @@ function serializeSvg(nodes, opts = {}) {
1440
1843
  const vb = `${trimNumber(bounds.x)} ${trimNumber(bounds.y)} ${trimNumber(bounds.width)} ${trimNumber(bounds.height)}`;
1441
1844
  const namespaces = opts.namespaces ?? {};
1442
1845
  const rootAttrs = [`xmlns="http://www.w3.org/2000/svg"`];
1846
+ if (registry.usesPrivateNamespace() && namespaces[WEASEL_NS_PREFIX] == null) {
1847
+ rootAttrs.push(`xmlns:${WEASEL_NS_PREFIX}="${WEASEL_NS}"`);
1848
+ }
1443
1849
  for (const [prefix, uri] of Object.entries(namespaces)) {
1444
1850
  rootAttrs.push(`xmlns:${prefix}="${escapeAttr2(uri)}"`);
1445
1851
  }
@@ -1460,22 +1866,28 @@ function serializeSvg(nodes, opts = {}) {
1460
1866
  for (const prefix of Object.keys(namespaces)) {
1461
1867
  const bucket = opts.documentMeta[prefix];
1462
1868
  if (!bucket?.elements) continue;
1463
- for (const [localName, list] of Object.entries(bucket.elements)) {
1464
- for (const el of list) docMetaXml += namespacedElementXml(prefix, localName, el);
1869
+ for (const [localName2, list] of Object.entries(bucket.elements)) {
1870
+ for (const el of list) docMetaXml += namespacedElementXml(prefix, localName2, el);
1465
1871
  }
1466
1872
  }
1467
1873
  }
1468
1874
  const defsXml = registry.toDefsXml(opts.onWarn);
1469
- const bodyXml = nodes.map((n) => nodeXml(n, registry, namespaces)).join("");
1875
+ const said = /* @__PURE__ */ new Set();
1876
+ const warn = (m) => {
1877
+ if (!opts.onWarn || said.has(m)) return;
1878
+ said.add(m);
1879
+ opts.onWarn(m);
1880
+ };
1881
+ const bodyXml = nodes.map((n) => nodeXml(n, registry, namespaces, warn)).join("");
1470
1882
  const titleXml = opts.title && opts.title.length > 0 ? `<title>${escapeText(opts.title)}</title>` : "";
1471
1883
  return `<svg ${rootAttrs.join(" ")}>${titleXml}${defsXml}${docMetaXml}${bodyXml}</svg>`;
1472
1884
  }
1473
- function namespacedElementXml(prefix, localName, el) {
1885
+ function namespacedElementXml(prefix, localName2, el) {
1474
1886
  const attrs = [];
1475
1887
  for (const [name, value] of Object.entries(el.attrs)) {
1476
1888
  attrs.push(`${name}="${escapeAttr2(value)}"`);
1477
1889
  }
1478
- const head = attrs.length > 0 ? `<${prefix}:${localName} ${attrs.join(" ")}>` : `<${prefix}:${localName}>`;
1890
+ const head = attrs.length > 0 ? `<${prefix}:${localName2} ${attrs.join(" ")}>` : `<${prefix}:${localName2}>`;
1479
1891
  let body = "";
1480
1892
  if (el.children) {
1481
1893
  for (const [childName, list] of Object.entries(el.children)) {
@@ -1484,7 +1896,7 @@ function namespacedElementXml(prefix, localName, el) {
1484
1896
  } else if (el.text != null) {
1485
1897
  body = escapeText(el.text);
1486
1898
  }
1487
- return `${head}${body}</${prefix}:${localName}>`;
1899
+ return `${head}${body}</${prefix}:${localName2}>`;
1488
1900
  }
1489
1901
  function metaAttrsXml(meta, namespaces) {
1490
1902
  if (!meta) return "";
@@ -1504,8 +1916,8 @@ function metaElementsXml(meta, namespaces) {
1504
1916
  for (const prefix of Object.keys(namespaces)) {
1505
1917
  const bucket = meta[prefix];
1506
1918
  if (!bucket?.elements) continue;
1507
- for (const [localName, list] of Object.entries(bucket.elements)) {
1508
- for (const el of list) out += namespacedElementXml(prefix, localName, el);
1919
+ for (const [localName2, list] of Object.entries(bucket.elements)) {
1920
+ for (const el of list) out += namespacedElementXml(prefix, localName2, el);
1509
1921
  }
1510
1922
  }
1511
1923
  return out;
@@ -1513,6 +1925,7 @@ function metaElementsXml(meta, namespaces) {
1513
1925
  function registerPaintServers(nodes, registry) {
1514
1926
  for (const n of nodes) {
1515
1927
  if (n.kind === "group") {
1928
+ if (n.clip) registry.clipId(n.clip);
1516
1929
  registerPaintServers(n.children, registry);
1517
1930
  } else if (n.kind === "path") {
1518
1931
  if (n.fill.kind === "gradient") registry.register(n.fill.paint);
@@ -1544,50 +1957,61 @@ function registerMarkers(stroke, registry) {
1544
1957
  if (key) registry.markerId(key);
1545
1958
  }
1546
1959
  }
1547
- function nodeXml(node, registry, namespaces) {
1548
- if (node.kind === "group") return groupXml(node, registry, namespaces);
1549
- if (node.kind === "text") return textXml(node, registry, namespaces);
1960
+ function nodeXml(node, registry, namespaces, warn) {
1961
+ if (node.kind === "group") return groupXml(node, registry, namespaces, warn);
1962
+ if (node.kind === "text") return textXml(node, registry, namespaces, warn);
1550
1963
  if (node.kind === "image") return imageXml(node, namespaces);
1551
- return pathXml(node, registry, namespaces);
1964
+ return pathXml(node, registry, namespaces, warn);
1965
+ }
1966
+ function warnStrokeAlign(align, warn) {
1967
+ if (align && align !== "center") {
1968
+ warn(`a stroke aligned "${align}" of its edge is written centered: SVG has no stroke alignment`);
1969
+ }
1552
1970
  }
1553
1971
  function imageXml(node, namespaces) {
1554
- const attrs = [
1555
- `href="${escapeAttr2(node.href)}"`,
1556
- `x="${trimNumber(node.x)}"`,
1557
- `y="${trimNumber(node.y)}"`,
1558
- `width="${trimNumber(node.width)}"`,
1559
- `height="${trimNumber(node.height)}"`,
1560
- `preserveAspectRatio="none"`
1561
- ];
1972
+ const box = `x="${trimNumber(node.x)}" y="${trimNumber(node.y)}" width="${trimNumber(node.width)}" height="${trimNumber(node.height)}"`;
1973
+ const outer = [];
1562
1974
  if (node.opacity != null && node.opacity !== 1) {
1563
- attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1975
+ outer.push(`opacity="${trimNumber(node.opacity)}"`);
1564
1976
  }
1565
1977
  if (node.rotation != null && node.rotation !== 0) {
1566
1978
  const cx = node.x + node.width / 2;
1567
1979
  const cy = node.y + node.height / 2;
1568
1980
  const deg = node.rotation * 180 / Math.PI;
1569
- attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
1981
+ outer.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
1570
1982
  }
1571
1983
  const metaAttrs = metaAttrsXml(node.meta, namespaces);
1572
1984
  const metaEls = metaElementsXml(node.meta, namespaces);
1573
- if (metaEls) return `<image ${attrs.join(" ")}${metaAttrs}>${metaEls}</image>`;
1574
- return `<image ${attrs.join(" ")}${metaAttrs}/>`;
1575
- }
1576
- function groupXml(node, registry, namespaces) {
1985
+ const href = `href="${escapeAttr2(node.href)}"`;
1986
+ if (!node.source && !node.flipX && !node.flipY) {
1987
+ const attrs = [href, box, 'preserveAspectRatio="none"', ...outer].join(" ");
1988
+ if (metaEls) return `<image ${attrs}${metaAttrs}>${metaEls}</image>`;
1989
+ return `<image ${attrs}${metaAttrs}/>`;
1990
+ }
1991
+ const src = node.source ?? { x: 0, y: 0, width: 1, height: 1 };
1992
+ const viewBox = [src.x, src.y, src.width, src.height].map(trimNumber).join(" ");
1993
+ const flip = node.flipX || node.flipY ? ` transform="matrix(${node.flipX ? -1 : 1} 0 0 ${node.flipY ? -1 : 1} ${trimNumber(node.flipX ? 2 * src.x + src.width : 0)} ${trimNumber(node.flipY ? 2 * src.y + src.height : 0)})"` : "";
1994
+ const head = ['<g data-weasel-image="true"', ...outer].join(" ");
1995
+ return `${head}${metaAttrs}><svg ${box} viewBox="${viewBox}" preserveAspectRatio="none"><image ${href} x="0" y="0" width="1" height="1" preserveAspectRatio="none"${flip}/></svg>${metaEls}</g>`;
1996
+ }
1997
+ function groupXml(node, registry, namespaces, warn) {
1577
1998
  const attrs = [];
1578
1999
  if (node.transform) {
1579
2000
  const m = formatMatrix(node.transform);
1580
2001
  if (m) attrs.push(`transform="${m}"`);
1581
2002
  }
2003
+ if (node.clip) {
2004
+ attrs.push(`clip-path="url(#${registry.clipId(node.clip)})"`);
2005
+ }
1582
2006
  if (node.opacity != null && node.opacity !== 1) {
1583
2007
  attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1584
2008
  }
1585
2009
  const metaAttrs = metaAttrsXml(node.meta, namespaces);
1586
2010
  const head = attrs.length > 0 ? `<g ${attrs.join(" ")}${metaAttrs}>` : `<g${metaAttrs}>`;
1587
- const body = node.children.map((c) => nodeXml(c, registry, namespaces)).join("");
2011
+ const body = node.children.map((c) => nodeXml(c, registry, namespaces, warn)).join("");
1588
2012
  return `${head}${body}${metaElementsXml(node.meta, namespaces)}</g>`;
1589
2013
  }
1590
- function pathXml(node, registry, namespaces) {
2014
+ function pathXml(node, registry, namespaces, warn) {
1591
2015
  const attrs = [`d="${serializePathD(node.path)}"`];
1592
2016
  const fillAttrs = paintAttrs(node.fill, "fill", registry);
1593
2017
  for (const a of fillAttrs) attrs.push(a);
@@ -1595,7 +2019,7 @@ function pathXml(node, registry, namespaces) {
1595
2019
  attrs.push(`fill-rule="evenodd"`);
1596
2020
  }
1597
2021
  if (node.stroke) {
1598
- const strokeAttrs = strokeAttrsFor(node.stroke, registry);
2022
+ const strokeAttrs = strokeAttrsFor(node.stroke, registry, warn);
1599
2023
  for (const a of strokeAttrs) attrs.push(a);
1600
2024
  } else {
1601
2025
  attrs.push('stroke="none"');
@@ -1626,18 +2050,18 @@ function paintAttrs(paint, name, registry, includeOpacity = true) {
1626
2050
  }
1627
2051
  return out;
1628
2052
  }
1629
- const id = registry.register(paint.paint);
1630
- return [`${name}="url(#${id})"`];
2053
+ return [`${name}="${registry.ref(paint.paint)}"`];
1631
2054
  }
1632
2055
  function strokeWidthAttrs(width) {
1633
2056
  if (typeof width === "number") return [`stroke-width="${trimNumber(width)}"`];
1634
2057
  return [`stroke-width="${trimNumber(width.px)}"`, 'vector-effect="non-scaling-stroke"'];
1635
2058
  }
1636
- function coreStrokeAttrs(stroke, registry) {
2059
+ function coreStrokeAttrs(stroke, registry, warn) {
1637
2060
  const paint = stroke?.paint;
1638
2061
  if (!stroke || !paint) return [];
1639
2062
  const width = stroke.width ?? 1;
1640
2063
  if (!((typeof width === "object" ? width.px : width) > 0)) return [];
2064
+ warnStrokeAlign(stroke.align, warn);
1641
2065
  const attrs = [];
1642
2066
  if ("color" in paint) {
1643
2067
  attrs.push(`stroke="${paint.color}"`);
@@ -1645,7 +2069,7 @@ function coreStrokeAttrs(stroke, registry) {
1645
2069
  attrs.push(`stroke-opacity="${trimNumber(paint.opacity)}"`);
1646
2070
  }
1647
2071
  } else {
1648
- attrs.push(`stroke="url(#${registry.register(paint)})"`);
2072
+ attrs.push(`stroke="${registry.ref(paint)}"`);
1649
2073
  }
1650
2074
  attrs.push(...strokeWidthAttrs(width));
1651
2075
  if (stroke.cap) attrs.push(`stroke-linecap="${stroke.cap}"`);
@@ -1668,7 +2092,8 @@ function coreStrokeAttrs(stroke, registry) {
1668
2092
  }
1669
2093
  return attrs;
1670
2094
  }
1671
- function strokeAttrsFor(stroke, registry) {
2095
+ function strokeAttrsFor(stroke, registry, warn) {
2096
+ warnStrokeAlign(stroke.align, warn);
1672
2097
  const attrs = paintAttrs(stroke.paint, "stroke", registry, false);
1673
2098
  attrs.push(...strokeWidthAttrs(stroke.width));
1674
2099
  const opacity = stroke.opacity ?? (stroke.paint.kind === "solid" ? stroke.paint.opacity : void 0);
@@ -1735,7 +2160,7 @@ function rotateAboutCenter(ctm, b, angle) {
1735
2160
  const s = Math.sin(angle);
1736
2161
  return multiply(ctm, [c, s, -s, c, cx - cx * c + cy * s, cy - cx * s - cy * c]);
1737
2162
  }
1738
- function textXml(node, registry, namespaces) {
2163
+ function textXml(node, registry, namespaces, warn) {
1739
2164
  const attrs = [
1740
2165
  `x="${trimNumber(node.x + anchorOffset(node.style, node.width))}"`,
1741
2166
  `y="${trimNumber(node.y)}"`,
@@ -1747,7 +2172,7 @@ function textXml(node, registry, namespaces) {
1747
2172
  `data-weasel-height="${trimNumber(node.height)}"`
1748
2173
  ];
1749
2174
  const style = node.style;
1750
- if (style?.fontSize != null) attrs.push(`font-size="${trimNumber(style.fontSize)}"`);
2175
+ if (style?.fontSize != null) attrs.push(`font-size="${trimNumber(resolveScreenLength(style.fontSize, 1))}"`);
1751
2176
  if (style?.fontFamily) attrs.push(`font-family="${escapeAttr2(style.fontFamily)}"`);
1752
2177
  if (style?.fontWeight != null) attrs.push(`font-weight="${String(style.fontWeight)}"`);
1753
2178
  if (style?.fontStyle && style.fontStyle !== "normal") attrs.push(`font-style="${style.fontStyle}"`);
@@ -1759,11 +2184,18 @@ function textXml(node, registry, namespaces) {
1759
2184
  if (anchor !== "start") attrs.push(`text-anchor="${anchor}"`);
1760
2185
  }
1761
2186
  if (style?.letterSpacing != null && style.letterSpacing !== 0) {
1762
- attrs.push(`letter-spacing="${trimNumber(style.letterSpacing)}"`);
2187
+ attrs.push(`letter-spacing="${trimNumber(resolveScreenLength(style.letterSpacing, 1))}"`);
1763
2188
  }
1764
2189
  const decoration = textDecorationValue(style?.underline, style?.strikethrough, style?.overline);
1765
2190
  if (decoration) attrs.push(`text-decoration="${decoration}"`);
1766
- if (style?.wrap) attrs.push('data-weasel-wrap="true"');
2191
+ if (style?.wrap) {
2192
+ attrs.push('data-weasel-wrap="true"');
2193
+ warn("text that wraps at its box width is written unwrapped: SVG text does not wrap, so other readers draw one line per hard break (weasel reads the wrap back)");
2194
+ }
2195
+ if (node.verticalAlign && node.verticalAlign !== "top") {
2196
+ attrs.push(`data-weasel-vertical-align="${node.verticalAlign}"`);
2197
+ warn(`text aligned to the ${node.verticalAlign} of its box is written top-aligned: SVG text has no box, so other readers draw it at the top (weasel reads the alignment back)`);
2198
+ }
1767
2199
  if (node.fill === null) {
1768
2200
  attrs.push('fill="none"');
1769
2201
  } else if (node.fill) {
@@ -1773,11 +2205,10 @@ function textXml(node, registry, namespaces) {
1773
2205
  attrs.push(`fill-opacity="${trimNumber(node.fill.opacity)}"`);
1774
2206
  }
1775
2207
  } else {
1776
- const id = registry.register(node.fill);
1777
- attrs.push(`fill="url(#${id})"`);
2208
+ attrs.push(`fill="${registry.ref(node.fill)}"`);
1778
2209
  }
1779
2210
  }
1780
- for (const a of coreStrokeAttrs(node.stroke, registry)) attrs.push(a);
2211
+ for (const a of coreStrokeAttrs(node.stroke, registry, warn)) attrs.push(a);
1781
2212
  if (node.opacity != null && node.opacity !== 1) {
1782
2213
  attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1783
2214
  }
@@ -1787,19 +2218,19 @@ function textXml(node, registry, namespaces) {
1787
2218
  const deg = node.rotation * 180 / Math.PI;
1788
2219
  attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
1789
2220
  }
1790
- const body = node.runs && node.runs.length > 0 ? node.runs.map((r) => runXml(r, registry)).join("") : escapeText(node.text);
2221
+ const body = node.runs && node.runs.length > 0 ? node.runs.map((r) => runXml(r, registry, warn)).join("") : escapeText(node.text);
1791
2222
  const metaAttrs = metaAttrsXml(node.meta, namespaces);
1792
2223
  const metaEls = metaElementsXml(node.meta, namespaces);
1793
2224
  return `<text ${attrs.join(" ")}${metaAttrs}>${body}${metaEls}</text>`;
1794
2225
  }
1795
- function runXml(run, registry) {
2226
+ function runXml(run, registry, warn) {
1796
2227
  const attrs = [];
1797
2228
  if (run.bold) attrs.push(`font-weight="700"`);
1798
2229
  if (run.italic) attrs.push(`font-style="italic"`);
1799
2230
  if (run.fontFamily) attrs.push(`font-family="${escapeAttr2(run.fontFamily)}"`);
1800
2231
  const presetScale = run.script != null && run.baselineShift != null ? SCRIPT_METRICS[run.script].size : void 0;
1801
2232
  const scale = run.fontScale ?? presetScale;
1802
- if (run.fontSize != null) attrs.push(`font-size="${trimNumber(run.fontSize)}"`);
2233
+ if (run.fontSize != null) attrs.push(`font-size="${trimNumber(resolveScreenLength(run.fontSize, 1))}"`);
1803
2234
  else if (scale != null) attrs.push(`font-size="${trimNumber(scale * 100)}%"`);
1804
2235
  if (run.baselineShift != null) {
1805
2236
  attrs.push(`baseline-shift="${trimNumber(run.baselineShift * 100)}%"`);
@@ -1807,7 +2238,7 @@ function runXml(run, registry) {
1807
2238
  attrs.push(`baseline-shift="${run.script}"`);
1808
2239
  }
1809
2240
  if (run.letterSpacing != null) {
1810
- attrs.push(`letter-spacing="${trimNumber(run.letterSpacing)}"`);
2241
+ attrs.push(`letter-spacing="${trimNumber(resolveScreenLength(run.letterSpacing, 1))}"`);
1811
2242
  }
1812
2243
  const runDecoration = textDecorationValue(run.underline, run.strikethrough, run.overline);
1813
2244
  if (runDecoration) attrs.push(`text-decoration="${runDecoration}"`);
@@ -1815,11 +2246,10 @@ function runXml(run, registry) {
1815
2246
  if ("color" in run.fill) {
1816
2247
  attrs.push(`fill="${run.fill.color}"`);
1817
2248
  } else {
1818
- const id = registry.register(run.fill);
1819
- attrs.push(`fill="url(#${id})"`);
2249
+ attrs.push(`fill="${registry.ref(run.fill)}"`);
1820
2250
  }
1821
2251
  }
1822
- for (const a of coreStrokeAttrs(run.stroke, registry)) attrs.push(a);
2252
+ for (const a of coreStrokeAttrs(run.stroke, registry, warn)) attrs.push(a);
1823
2253
  const head = attrs.length > 0 ? `<tspan ${attrs.join(" ")}>` : "<tspan>";
1824
2254
  return `${head}${escapeText(run.text)}</tspan>`;
1825
2255
  }
@@ -1946,6 +2376,7 @@ function svgNodesToKitDrafts(nodes, nextId) {
1946
2376
  data: {
1947
2377
  text: n.text,
1948
2378
  ...n.style ? { style: n.style } : {},
2379
+ ...n.verticalAlign ? { verticalAlign: n.verticalAlign } : {},
1949
2380
  ...n.runs ? { runs: n.runs.map((r) => runInBoxFrame(r, box)) } : {},
1950
2381
  // `!== undefined`, not a truthiness test: `null` is the document
1951
2382
  // saying `fill="none"`, and absent takes the painter's default.
@@ -1996,13 +2427,14 @@ function svgNodesToKitDrafts(nodes, nextId) {
1996
2427
  function scaleTextData(data, scale) {
1997
2428
  if (scale === 1 || typeof data.text !== "string") return data;
1998
2429
  const style = data.style ?? {};
2430
+ const scaleSize = (v) => typeof v === "number" ? v * scale : v;
1999
2431
  const runs = data.runs;
2000
2432
  return {
2001
2433
  ...data,
2002
2434
  ...runs ? {
2003
- runs: runs.map((r) => r.fontSize !== void 0 ? { ...r, fontSize: r.fontSize * scale } : r)
2435
+ runs: runs.map((r) => r.fontSize !== void 0 ? { ...r, fontSize: scaleSize(r.fontSize) } : r)
2004
2436
  } : {},
2005
- style: { ...style, fontSize: resolveTextStyle(style).fontSize * scale }
2437
+ style: { ...style, fontSize: scaleSize(style.fontSize ?? resolveTextStyle(style).fontSize) }
2006
2438
  };
2007
2439
  }
2008
2440
  function unionRect(a, b) {
@@ -2020,6 +2452,7 @@ async function unpackSvgFiles(files, ctx) {
2020
2452
  const text = await readFileText(file);
2021
2453
  const parsed = parseSvg(text);
2022
2454
  for (const w of parsed.warnings) dwarn("ingest", `svg "${file.name}":`, w);
2455
+ for (const m of parsed.markers ?? []) if (getMarker(m.id) === void 0) registerMarker(m);
2023
2456
  let drafts = svgNodesToKitDrafts(parsed.nodes, freshSvgNodeId);
2024
2457
  if (drafts.length === 0) {
2025
2458
  console.warn(`weasel ingest: svg "${file.name}" parsed to no drawable nodes`);