@weasel-js/svg 1.5.0 → 1.5.1

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,4 +1,4 @@
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';
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
2
  import { invert } from '@weasel-js/geom';
3
3
  import { tileGeometry } from '@weasel-js/core/patterns-builtin';
4
4
 
@@ -242,8 +242,18 @@ function parsePaintAttr(raw) {
242
242
  if (s === "currentColor" || s === "currentcolor") {
243
243
  return { kind: "solid", color: "#000000", alpha: 1 };
244
244
  }
245
- const urlMatch = /^url\(\s*#([^)\s]+)\s*\)/.exec(s);
246
- if (urlMatch) return { kind: "ref", id: urlMatch[1] };
245
+ const urlMatch = /^url\(\s*#([^)\s]+)\s*\)(.*)$/.exec(s);
246
+ if (urlMatch) {
247
+ const out = { kind: "ref", id: urlMatch[1] };
248
+ const rest = urlMatch[2].trim();
249
+ if (rest !== "" && rest !== "none") {
250
+ const fallback = parsePaintAttr(rest);
251
+ if (fallback?.kind === "solid") {
252
+ out.fallback = { color: fallback.color, alpha: fallback.alpha };
253
+ }
254
+ }
255
+ return out;
256
+ }
247
257
  try {
248
258
  const [r, g, b, a] = parseColor(s);
249
259
  return { kind: "solid", color: rgbaToHex([r, g, b]), alpha: a };
@@ -387,13 +397,45 @@ function serializePathD(path) {
387
397
  }
388
398
 
389
399
  // src/gradients.ts
390
- var GRADIENT_TAGS = /* @__PURE__ */ new Set(["lineargradient", "radialgradient"]);
400
+ var WEASEL_NS = "urn:weasel-js:svg";
401
+ var WEASEL_NS_PREFIX = "wzl";
402
+ function hasNativeSvgForm(paint) {
403
+ const kind = paint.fill ?? "solid";
404
+ return kind === "solid" || kind === "linear-gradient" || kind === "radial-gradient" || kind === "pattern";
405
+ }
406
+ var INTERPOLATE_SPACES = /* @__PURE__ */ new Set(["rgb", "oklab", "oklch"]);
407
+ function interpolateAttr(space) {
408
+ if (!space || space === "rgb") return "";
409
+ return ` ${WEASEL_NS_PREFIX}:interpolate="${space}"`;
410
+ }
411
+ function readInterpolate(el, elements) {
412
+ const raw = inheritedAttr(el, elements, `${WEASEL_NS_PREFIX}:interpolate`) ?? el.getAttributeNS(WEASEL_NS, "interpolate");
413
+ if (raw === "oklab" || raw === "oklch") return raw;
414
+ return void 0;
415
+ }
416
+ function usesInterpolateAttr(paint) {
417
+ const space = paint.interpolate;
418
+ return space != null && space !== "rgb" && INTERPOLATE_SPACES.has(space);
419
+ }
420
+ function localName(tag) {
421
+ const i = tag.indexOf(":");
422
+ return (i < 0 ? tag : tag.slice(i + 1)).toLowerCase();
423
+ }
424
+ var GRADIENT_TAGS = /* @__PURE__ */ new Set([
425
+ "lineargradient",
426
+ "radialgradient",
427
+ "conicgradient",
428
+ `${WEASEL_NS_PREFIX}:conicgradient`,
429
+ "meshgradient",
430
+ `${WEASEL_NS_PREFIX}:meshgradient`
431
+ ]);
391
432
  var XLINK_NS = "http://www.w3.org/1999/xlink";
392
433
  function collectGradients(svg, onWarn) {
393
434
  const out = /* @__PURE__ */ new Map();
394
435
  const elements = collectElementsByTag(svg, GRADIENT_TAGS);
395
436
  for (const [id, el] of elements) {
396
- const paint = el.tagName.toLowerCase() === "lineargradient" ? readLinearGradient(el, elements, onWarn) : readRadialGradient(el, elements, onWarn);
437
+ const tag = localName(el.tagName);
438
+ const paint = tag === "lineargradient" ? readLinearGradient(el, elements, onWarn) : tag === "conicgradient" ? readConicGradient(el, elements, onWarn) : tag === "meshgradient" ? readMeshGradient(el, onWarn) : readRadialGradient(el, elements, onWarn);
397
439
  if (paint) out.set(id, paint);
398
440
  }
399
441
  warnUnsupportedDefsChildren(svg, onWarn);
@@ -407,7 +449,8 @@ function warnUnsupportedDefsChildren(svg, onWarn) {
407
449
  for (let i = 0; i < root.children.length; i++) {
408
450
  const child = root.children[i];
409
451
  const tag = child.tagName.toLowerCase();
410
- if (GRADIENT_TAGS.has(tag) || tag === "pattern" || tag === "marker") continue;
452
+ if (GRADIENT_TAGS.has(tag) || GRADIENT_TAGS.has(localName(tag))) continue;
453
+ if (tag === "pattern" || tag === "marker" || tag === "clippath") continue;
411
454
  onWarn(`unsupported <defs> child: <${child.tagName}>`);
412
455
  }
413
456
  }
@@ -489,7 +532,8 @@ function readLinearGradient(el, elements, onWarn) {
489
532
  y: num(inheritedAttr(el, elements, "y2"), 0)
490
533
  },
491
534
  stops: inheritedStops(el, elements, onWarn),
492
- units: readGradientUnits(el, elements)
535
+ units: readGradientUnits(el, elements),
536
+ interpolate: readInterpolate(el, elements)
493
537
  };
494
538
  }
495
539
  function readRadialGradient(el, elements, onWarn) {
@@ -502,7 +546,48 @@ function readRadialGradient(el, elements, onWarn) {
502
546
  },
503
547
  radius: num(inheritedAttr(el, elements, "r"), 0.5),
504
548
  stops: inheritedStops(el, elements, onWarn),
505
- units: readGradientUnits(el, elements)
549
+ units: readGradientUnits(el, elements),
550
+ interpolate: readInterpolate(el, elements)
551
+ };
552
+ }
553
+ function readConicGradient(el, elements, onWarn) {
554
+ warnGradientTransform(el, elements, onWarn);
555
+ return {
556
+ fill: "conic-gradient",
557
+ center: {
558
+ x: num(inheritedAttr(el, elements, "cx"), 0.5),
559
+ y: num(inheritedAttr(el, elements, "cy"), 0.5)
560
+ },
561
+ angle: num(inheritedAttr(el, elements, "angle"), 0),
562
+ stops: inheritedStops(el, elements, onWarn),
563
+ units: readGradientUnits(el, elements),
564
+ interpolate: readInterpolate(el, elements)
565
+ };
566
+ }
567
+ function readMeshGradient(el, onWarn) {
568
+ const patches = [];
569
+ for (let i = 0; i < el.children.length; i++) {
570
+ const child = el.children[i];
571
+ if (localName(child.tagName) !== "patch") continue;
572
+ const points = (child.getAttribute("points") ?? "").trim().split(/\s+/).filter(Boolean).map((pair) => {
573
+ const [x, y] = pair.split(",").map(Number);
574
+ return { x, y };
575
+ });
576
+ const colors = (child.getAttribute("colors") ?? "").trim().split(/\s+/).filter(Boolean);
577
+ const sane = (points.length === 12 || points.length === 16) && points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) && colors.length === 4;
578
+ if (!sane) {
579
+ onWarn?.(`<${WEASEL_NS_PREFIX}:patch> with ${points.length} points and ${colors.length} colors \u2014 dropped`);
580
+ continue;
581
+ }
582
+ patches.push({ points, colors });
583
+ }
584
+ if (patches.length === 0) return null;
585
+ const space = el.getAttribute("interpolate") ?? el.getAttributeNS(WEASEL_NS, "interpolate");
586
+ return {
587
+ fill: "mesh-gradient",
588
+ patches,
589
+ units: el.getAttribute("gradientUnits") === "objectBoundingBox" ? "bounds" : "world",
590
+ ...space === "oklab" || space === "oklch" ? { interpolate: space } : {}
506
591
  };
507
592
  }
508
593
  var PaintServerRegistry = class {
@@ -511,11 +596,14 @@ var PaintServerRegistry = class {
511
596
  counter = 0;
512
597
  // Marker keys referenced by any stroke, in first-use order. The `<defs>`
513
598
  // 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.
599
+ // `marker-end="url(#id)"` fragment back as the marker key directly when the
600
+ // registry knows it, so a synthetic id would come back as a stranger.
517
601
  markerKeys = [];
518
602
  markerKeySet = /* @__PURE__ */ new Set();
603
+ // Clip paths, in first-use order. Identity, not value: two groups clipped by
604
+ // equal outlines get a def each, which costs bytes and misleads nobody.
605
+ clips = [];
606
+ clipIds = /* @__PURE__ */ new Map();
519
607
  register(paint) {
520
608
  const existing = this.byPaint.get(paint);
521
609
  if (existing) return existing;
@@ -524,6 +612,23 @@ var PaintServerRegistry = class {
524
612
  this.order.push(paint);
525
613
  return id;
526
614
  }
615
+ /**
616
+ * The `fill` / `stroke` attribute value referencing this paint server: a
617
+ * `url(#id)` reference, plus the fallback paint SVG's own grammar allows
618
+ * after one for any kind SVG cannot express. A renderer that cannot resolve
619
+ * the reference takes the fallback instead of dropping the fill, which is
620
+ * the difference between a flat shape and a vanished one.
621
+ */
622
+ ref(paint) {
623
+ const id = this.register(paint);
624
+ if (hasNativeSvgForm(paint)) return `url(#${id})`;
625
+ return `url(#${id}) ${paintFallback(paint)}`;
626
+ }
627
+ /** Whether any registered paint serializes outside SVG's own vocabulary, so
628
+ * the root has to declare {@link WEASEL_NS}. */
629
+ usesPrivateNamespace() {
630
+ return this.order.some((paint) => !hasNativeSvgForm(paint) || usesInterpolateAttr(paint));
631
+ }
527
632
  /** The `<defs>` id for a marker key — the key itself, minting nothing.
528
633
  * `undefined` when nothing is registered under `key`, so a caller emits no
529
634
  * attribute at all rather than a `url(#…)` pointing at a def that will
@@ -536,9 +641,21 @@ var PaintServerRegistry = class {
536
641
  }
537
642
  return key;
538
643
  }
539
- /** Emit `<defs>...</defs>` XML for every registered paint server and marker. */
644
+ /** Mint (or recall) the `<defs>` id for a clip outline. */
645
+ clipId(path) {
646
+ const existing = this.clipIds.get(path);
647
+ if (existing) return existing;
648
+ const id = `clip${this.clips.length}`;
649
+ this.clipIds.set(path, id);
650
+ this.clips.push(path);
651
+ return id;
652
+ }
653
+ /** Emit `<defs>...</defs>` XML for every registered paint server, marker
654
+ * and clip path. */
540
655
  toDefsXml(onWarn) {
541
- if (this.order.length === 0 && this.markerKeys.length === 0) return "";
656
+ if (this.order.length === 0 && this.markerKeys.length === 0 && this.clips.length === 0) {
657
+ return "";
658
+ }
542
659
  const parts = ["<defs>"];
543
660
  for (const paint of this.order) {
544
661
  parts.push(paintServerXml(this.byPaint.get(paint), paint, onWarn));
@@ -546,18 +663,33 @@ var PaintServerRegistry = class {
546
663
  for (const key of this.markerKeys) {
547
664
  const entry = getMarker(key);
548
665
  if (!entry) continue;
549
- parts.push(entry.toSvg ? entry.toSvg(key, entry) : defaultMarkerXml(key, entry));
666
+ parts.push(entry.toSvg ? entry.toSvg(key, entry) : defaultMarkerXml(key, entry, onWarn));
667
+ }
668
+ for (const path of this.clips) {
669
+ parts.push(
670
+ `<clipPath id="${this.clipIds.get(path)}"><path d="${serializePathD(path)}"/></clipPath>`
671
+ );
550
672
  }
551
673
  parts.push("</defs>");
552
674
  return parts.join("");
553
675
  }
554
676
  };
555
- function defaultMarkerXml(id, entry) {
677
+ function defaultMarkerXml(id, entry, onWarn) {
556
678
  const path = entry.path({ size: 1, stroke: { paint: { fill: "solid", color: "#000" } } });
557
679
  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>`;
680
+ const fill = markerPaintAttrs("fill", entry.fill, id, onWarn);
681
+ const outline = entry.outline ? ` ${markerPaintAttrs("stroke", entry.outline.paint, id, onWarn)} stroke-width="${trimNumber(entry.outline.width)}" stroke-linecap="round" stroke-linejoin="round"` : "";
682
+ const orient = typeof entry.orient === "number" ? trimNumber(entry.orient * 180 / Math.PI) : "auto-start-reverse";
683
+ return `<marker id="${id}" markerUnits="strokeWidth" markerWidth="8" markerHeight="8" refX="0" refY="0" orient="${orient}" overflow="visible"><path d="${d}" ${fill}${outline}/></marker>`;
684
+ }
685
+ function markerPaintAttrs(attr, paint, id, onWarn) {
686
+ if (paint === "none") return `${attr}="none"`;
687
+ if (paint === void 0 || paint === "line") return `${attr}="context-stroke"`;
688
+ if ("color" in paint && (paint.fill === void 0 || paint.fill === "solid")) {
689
+ return paint.opacity != null && paint.opacity !== 1 ? `${attr}="${paint.color}" ${attr}-opacity="${trimNumber(paint.opacity)}"` : `${attr}="${paint.color}"`;
690
+ }
691
+ onWarn?.(`marker "${id}" ${attr} is a paint server, which a <marker> def cannot reference here; written as the line's paint`);
692
+ return `${attr}="context-stroke"`;
561
693
  }
562
694
  function paintServerXml(id, paint, onWarn) {
563
695
  if (paint.fill === "pattern") return patternXml(id, paint, onWarn);
@@ -574,14 +706,25 @@ function gradientUnitsAttr(units) {
574
706
  function gradientXml(id, paint) {
575
707
  if (paint.fill === "linear-gradient") {
576
708
  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>`;
709
+ 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>`;
710
+ }
711
+ if (paint.fill === "conic-gradient") {
712
+ const stops = paint.stops.map(stopXml).join("");
713
+ const P = WEASEL_NS_PREFIX;
714
+ 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
715
  }
579
716
  if (paint.fill === "radial-gradient") {
580
717
  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>`;
718
+ 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
719
  }
583
720
  return "";
584
721
  }
722
+ function paintFallback(paint) {
723
+ const color = getPaintKind(paint.fill)?.colorOf(paint);
724
+ if (!color) return "none";
725
+ const packed = /^#([0-9a-f]{6})[0-9a-f]{2}$/i.exec(color);
726
+ return packed ? `#${packed[1].toLowerCase()}` : color;
727
+ }
585
728
  function stopXml(s) {
586
729
  const m = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(s.color);
587
730
  if (m) {
@@ -661,6 +804,7 @@ var IGNORED_TAGS = /* @__PURE__ */ new Set([
661
804
  "radialgradient",
662
805
  "pattern",
663
806
  "marker",
807
+ "clippath",
664
808
  "title",
665
809
  "desc",
666
810
  "metadata"
@@ -693,8 +837,11 @@ function parseSvg(svg, opts = {}) {
693
837
  const gradients = collectGradients(root, onWarn);
694
838
  for (const [id, paint] of collectPatterns(root, onWarn)) gradients.set(id, paint);
695
839
  const rootStyle = deriveStyle(EMPTY_STYLE, root);
696
- const nodes = parseChildren(root, IDENTITY_MATRIX, rootStyle, gradients, onWarn, uriToPrefix);
840
+ const clips = collectClipPaths(root, onWarn);
841
+ const nodes = parseChildren(root, IDENTITY_MATRIX, rootStyle, gradients, clips, onWarn, uriToPrefix);
842
+ const markers = ingestMarkers(root, nodes, gradients, onWarn);
697
843
  const result = { nodes, warnings };
844
+ if (markers.length > 0) result.markers = markers;
698
845
  if (documentMeta) result.documentMeta = documentMeta;
699
846
  const viewBox = parseViewBoxAttr(root.getAttribute("viewBox"));
700
847
  if (viewBox) result.viewBox = viewBox;
@@ -796,13 +943,53 @@ function collectElementMeta(el, uriToPrefix) {
796
943
  }
797
944
  return Object.keys(meta).length > 0 ? meta : void 0;
798
945
  }
799
- function parseChildren(parent, ctm, style, gradients, onWarn, uriToPrefix) {
946
+ function collectClipPaths(root, onWarn) {
947
+ const out = /* @__PURE__ */ new Map();
948
+ for (const [id, el] of collectElementsByTag(root, /* @__PURE__ */ new Set(["clippath"]))) {
949
+ const units = el.getAttribute("clipPathUnits");
950
+ if (units && units !== "userSpaceOnUse") {
951
+ onWarn(`<clipPath id="${id}"> uses clipPathUnits="${units}", which is not modeled`);
952
+ continue;
953
+ }
954
+ const shapes = [];
955
+ for (let i = 0; i < el.children.length; i++) {
956
+ if (SUPPORTED_LEAF_TAGS.has(el.children[i].tagName.toLowerCase())) shapes.push(el.children[i]);
957
+ }
958
+ if (shapes.length !== 1) {
959
+ onWarn(
960
+ `<clipPath id="${id}"> holds ${shapes.length} shapes the kit can lower; only a single-shape clip is modeled, so it clips nothing`
961
+ );
962
+ continue;
963
+ }
964
+ const shape = shapes[0];
965
+ const local = parseTransform(shape.getAttribute("transform"), onWarn);
966
+ const path = lowerLeaf(shape, shape.tagName.toLowerCase(), local, onWarn);
967
+ if (path) out.set(id, path);
968
+ }
969
+ return out;
970
+ }
971
+ function clipFor(el, ctm, clips, onWarn) {
972
+ const attr = el.getAttribute("clip-path");
973
+ if (!attr || attr === "none") return void 0;
974
+ const ref = /^url\(\s*#([^)\s]+)\s*\)$/.exec(attr.trim());
975
+ if (!ref) {
976
+ onWarn(`clip-path="${attr}" is not a url(#id) reference; the clip is dropped`);
977
+ return void 0;
978
+ }
979
+ const path = clips.get(ref[1]);
980
+ if (!path) {
981
+ onWarn(`clip-path references #${ref[1]}, which no <clipPath> in the document defines`);
982
+ return void 0;
983
+ }
984
+ return transformPath(path, ctm);
985
+ }
986
+ function parseChildren(parent, ctm, style, gradients, clips, onWarn, uriToPrefix) {
800
987
  const out = [];
801
988
  for (let i = 0; i < parent.children.length; i++) {
802
989
  const el = parent.children[i];
803
990
  const ns = el.namespaceURI;
804
991
  if (ns && ns !== "http://www.w3.org/2000/svg") continue;
805
- const node = parseElement(el, ctm, style, gradients, onWarn, uriToPrefix);
992
+ const node = parseElement(el, ctm, style, gradients, clips, onWarn, uriToPrefix);
806
993
  if (node) {
807
994
  if (Array.isArray(node)) out.push(...node);
808
995
  else out.push(node);
@@ -810,16 +997,26 @@ function parseChildren(parent, ctm, style, gradients, onWarn, uriToPrefix) {
810
997
  }
811
998
  return out;
812
999
  }
813
- function parseElement(el, ctm, style, gradients, onWarn, uriToPrefix) {
1000
+ function parseElement(el, ctm, style, gradients, clips, onWarn, uriToPrefix) {
814
1001
  const tag = el.tagName.toLowerCase();
815
1002
  if (IGNORED_TAGS.has(tag)) return null;
1003
+ if (tag === "g" && el.hasAttribute("data-weasel-image")) {
1004
+ const image = parseCroppedImage(el, ctm, onWarn);
1005
+ if (image) {
1006
+ const meta2 = collectElementMeta(el, uriToPrefix);
1007
+ if (meta2) image.meta = meta2;
1008
+ return image;
1009
+ }
1010
+ }
816
1011
  if (tag === "g") {
817
1012
  const local = parseTransform(el.getAttribute("transform"), onWarn);
818
1013
  const childCtm = multiply(ctm, local);
819
1014
  const childStyle = deriveStyle(style, el);
820
- const children = parseChildren(el, childCtm, childStyle, gradients, onWarn, uriToPrefix);
1015
+ const children = parseChildren(el, childCtm, childStyle, gradients, clips, onWarn, uriToPrefix);
821
1016
  const opacity2 = readOpacityAttr(el, "opacity");
822
1017
  const group = { kind: "group", children };
1018
+ const clip = clipFor(el, childCtm, clips, onWarn);
1019
+ if (clip) group.clip = clip;
823
1020
  if (opacity2 != null) group.opacity = opacity2;
824
1021
  const meta2 = collectElementMeta(el, uriToPrefix);
825
1022
  if (meta2) group.meta = meta2;
@@ -833,7 +1030,7 @@ function parseElement(el, ctm, style, gradients, onWarn, uriToPrefix) {
833
1030
  if (el.hasAttribute("viewBox")) {
834
1031
  onWarn("nested <svg> viewBox is not modeled; its children are not rescaled");
835
1032
  }
836
- return parseChildren(el, multiply(ctm, offset), childStyle, gradients, onWarn, uriToPrefix);
1033
+ return parseChildren(el, multiply(ctm, offset), childStyle, gradients, clips, onWarn, uriToPrefix);
837
1034
  }
838
1035
  if (tag === "text") {
839
1036
  const textNode = parseTextElement(el, ctm, style, gradients, onWarn);
@@ -978,6 +1175,12 @@ function readPaint(style, attr, defaultColor, gradients, onWarn) {
978
1175
  if (parsed.kind === "ref") {
979
1176
  const paint = gradients.get(parsed.id);
980
1177
  if (!paint) {
1178
+ if (parsed.fallback) {
1179
+ const flat = { kind: "solid", color: parsed.fallback.color };
1180
+ const a2 = opacity ?? (parsed.fallback.alpha < 1 ? parsed.fallback.alpha : void 0);
1181
+ if (a2 != null) flat.opacity = a2;
1182
+ return flat;
1183
+ }
981
1184
  onWarn(`${attr} references unknown gradient #${parsed.id}`);
982
1185
  return { kind: "solid", color: defaultColor };
983
1186
  }
@@ -1034,12 +1237,7 @@ function readStroke(style, gradients, onWarn, nonScaling = false) {
1034
1237
  ["marker-end", "markerEnd"]
1035
1238
  ]) {
1036
1239
  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;
1240
+ if (id !== void 0) stroke[field] = id;
1043
1241
  }
1044
1242
  return stroke;
1045
1243
  }
@@ -1155,20 +1353,30 @@ function parseRunFontSize(raw, onWarn) {
1155
1353
  }
1156
1354
  var XLINK_NS2 = "http://www.w3.org/1999/xlink";
1157
1355
  function parseImageElement(el, ctm, onWarn) {
1158
- const href = el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS2, "href") ?? el.getAttribute("xlink:href");
1356
+ const href = imageHref(el);
1159
1357
  if (!href) {
1160
1358
  onWarn("<image> without href; dropped");
1161
1359
  return null;
1162
1360
  }
1361
+ const node = imageNodeAt(el, el, href, ctm, onWarn);
1362
+ if (el.hasAttribute("preserveAspectRatio") && el.getAttribute("preserveAspectRatio") !== "none") {
1363
+ onWarn("<image> preserveAspectRatio is not modeled; the box is taken literally");
1364
+ }
1365
+ return node;
1366
+ }
1367
+ function imageHref(el) {
1368
+ return el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS2, "href") ?? el.getAttribute("xlink:href");
1369
+ }
1370
+ function imageNodeAt(boxEl, ownerEl, href, ctm, onWarn) {
1163
1371
  const num2 = (raw, fallback) => {
1164
1372
  if (raw == null) return fallback;
1165
1373
  const n = parseFloat(raw);
1166
1374
  return Number.isFinite(n) ? n : fallback;
1167
1375
  };
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);
1376
+ const rawX = num2(boxEl.getAttribute("x"), 0);
1377
+ const rawY = num2(boxEl.getAttribute("y"), 0);
1378
+ const rawW = num2(boxEl.getAttribute("width"), 0);
1379
+ const rawH = num2(boxEl.getAttribute("height"), 0);
1172
1380
  const px = (x, y) => [ctm[0] * x + ctm[2] * y + ctm[4], ctm[1] * x + ctm[3] * y + ctm[5]];
1173
1381
  const [x0, y0] = px(rawX, rawY);
1174
1382
  const [x1, y1] = px(rawX + rawW, rawY + rawH);
@@ -1180,9 +1388,9 @@ function parseImageElement(el, ctm, onWarn) {
1180
1388
  width: Math.abs(x1 - x0),
1181
1389
  height: Math.abs(y1 - y0)
1182
1390
  };
1183
- const opacity = readOpacityAttr(el, "opacity");
1391
+ const opacity = readOpacityAttr(ownerEl, "opacity");
1184
1392
  if (opacity != null) node.opacity = opacity;
1185
- const localTransform = parseTransform(el.getAttribute("transform"), onWarn);
1393
+ const localTransform = parseTransform(ownerEl.getAttribute("transform"), onWarn);
1186
1394
  if (!isIdentity(localTransform)) {
1187
1395
  const worldLocal = rebaseTransform(ctm, localTransform);
1188
1396
  const cx = node.x + node.width / 2;
@@ -1199,9 +1407,21 @@ function parseImageElement(el, ctm, onWarn) {
1199
1407
  }
1200
1408
  }
1201
1409
  }
1202
- if (el.hasAttribute("preserveAspectRatio") && el.getAttribute("preserveAspectRatio") !== "none") {
1203
- onWarn("<image> preserveAspectRatio is not modeled; the box is taken literally");
1204
- }
1410
+ return node;
1411
+ }
1412
+ function parseCroppedImage(g, ctm, onWarn) {
1413
+ const viewport = [...g.children].find((c) => c.tagName.toLowerCase() === "svg");
1414
+ const img = viewport && [...viewport.children].find((c) => c.tagName.toLowerCase() === "image");
1415
+ const href = img && imageHref(img);
1416
+ const source = viewport && parseViewBoxAttr(viewport.getAttribute("viewBox"));
1417
+ if (!img || !href || !source) return null;
1418
+ const node = imageNodeAt(viewport, g, href, ctm, onWarn);
1419
+ if (source.x !== 0 || source.y !== 0 || source.width !== 1 || source.height !== 1) {
1420
+ node.source = source;
1421
+ }
1422
+ const flip = parseTransform(img.getAttribute("transform"), onWarn);
1423
+ if (flip[0] < 0) node.flipX = true;
1424
+ if (flip[3] < 0) node.flipY = true;
1205
1425
  return node;
1206
1426
  }
1207
1427
  var XML_NS = "http://www.w3.org/XML/1998/namespace";
@@ -1268,7 +1488,7 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
1268
1488
  const leafStyle = deriveStyle(style, el);
1269
1489
  const textStyle = readTextStyle(leafStyle, onWarn);
1270
1490
  const textPaint = readTextPaint(leafStyle, gradients, onWarn, isNonScalingStroke(el));
1271
- const fontSize = textStyle.fontSize ?? 16;
1491
+ const fontSize = resolveScreenLength(textStyle.fontSize ?? 16, 1);
1272
1492
  const lineHeight = textStyle.lineHeight ?? 1.2;
1273
1493
  const dominantBaseline = el.getAttribute("dominant-baseline");
1274
1494
  const explicitTopAnchor = dominantBaseline === "text-before-edge" || dominantBaseline === "hanging";
@@ -1315,6 +1535,8 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
1315
1535
  );
1316
1536
  if (hasStyling) node.runs = runs;
1317
1537
  if (el.getAttribute("data-weasel-wrap") === "true") textStyle.wrap = true;
1538
+ const va = el.getAttribute("data-weasel-vertical-align");
1539
+ if (va === "center" || va === "bottom") node.verticalAlign = va;
1318
1540
  if (Object.keys(textStyle).length > 0) node.style = textStyle;
1319
1541
  if (textPaint.fill !== void 0) node.fill = textPaint.fill;
1320
1542
  if (textPaint.stroke != null) node.stroke = textPaint.stroke;
@@ -1376,6 +1598,7 @@ function readTspanRun(el, gradients, style, onWarn) {
1376
1598
  } else if (parsed?.kind === "ref") {
1377
1599
  const paint = gradients.get(parsed.id);
1378
1600
  if (paint) run.fill = paint;
1601
+ else if (parsed.fallback) run.fill = { fill: "solid", color: parsed.fallback.color };
1379
1602
  }
1380
1603
  }
1381
1604
  const stroke = coreStroke(readStroke(ownStrokeStyle(el), gradients, onWarn, isNonScalingStroke(el)));
@@ -1425,6 +1648,9 @@ function readTextPaint(style, gradients, onWarn, nonScalingStroke = false) {
1425
1648
  } else if (parsed?.kind === "ref") {
1426
1649
  const paint = gradients.get(parsed.id);
1427
1650
  if (paint) out.fill = paint;
1651
+ else if (parsed.fallback) {
1652
+ out.fill = { fill: "solid", color: parsed.fallback.color };
1653
+ }
1428
1654
  } else if (parsed?.kind === "none") {
1429
1655
  out.fill = null;
1430
1656
  }
@@ -1433,6 +1659,196 @@ function readTextPaint(style, gradients, onWarn, nonScalingStroke = false) {
1433
1659
  if (stroke) out.stroke = stroke;
1434
1660
  return out;
1435
1661
  }
1662
+ var MARKER_FIELDS = [
1663
+ ["markerStart", "start", "marker-start"],
1664
+ ["markerMid", "mid", "marker-mid"],
1665
+ ["markerEnd", "end", "marker-end"]
1666
+ ];
1667
+ function ingestMarkers(root, nodes, gradients, onWarn) {
1668
+ const defs = /* @__PURE__ */ new Map();
1669
+ const all = root.getElementsByTagName("marker");
1670
+ for (let i = 0; i < all.length; i++) {
1671
+ const id = all[i].getAttribute("id");
1672
+ if (id && !defs.has(id)) defs.set(id, all[i]);
1673
+ }
1674
+ const minted = /* @__PURE__ */ new Map();
1675
+ const resolve = (stroke, width) => {
1676
+ const w = width === void 0 ? 1 : typeof width === "number" ? width : width.px;
1677
+ for (const [field, role, attr] of MARKER_FIELDS) {
1678
+ const id = stroke[field];
1679
+ if (typeof id !== "string" || getMarker(id) !== void 0) continue;
1680
+ const el = defs.get(id);
1681
+ if (!el) {
1682
+ onWarn(`${attr} references #${id}, which is neither a kit marker nor defined in the document`);
1683
+ delete stroke[field];
1684
+ continue;
1685
+ }
1686
+ const cacheKey = `${id}\0${role}\0${w}`;
1687
+ if (!minted.has(cacheKey)) minted.set(cacheKey, markerEntryFrom(el, id, role, w, gradients, onWarn));
1688
+ const entry = minted.get(cacheKey);
1689
+ if (entry) stroke[field] = entry.id;
1690
+ else delete stroke[field];
1691
+ }
1692
+ };
1693
+ const visit = (n) => {
1694
+ if (n.kind === "group") n.children.forEach(visit);
1695
+ else if (n.kind === "path" && n.stroke) resolve(n.stroke, n.stroke.width);
1696
+ else if (n.kind === "text") {
1697
+ if (n.stroke) resolve(n.stroke, n.stroke.width);
1698
+ for (const run of n.runs ?? []) {
1699
+ if (run.stroke) resolve(run.stroke, run.stroke.width ?? n.stroke?.width);
1700
+ }
1701
+ }
1702
+ };
1703
+ nodes.forEach(visit);
1704
+ const out = /* @__PURE__ */ new Map();
1705
+ for (const entry of minted.values()) if (entry) out.set(entry.id, entry);
1706
+ return [...out.values()];
1707
+ }
1708
+ function styleAt(el) {
1709
+ const chain = [];
1710
+ for (let e = el; e; e = e.parentElement) chain.unshift(e);
1711
+ return chain.reduce((style, e) => deriveStyle(style, e), EMPTY_STYLE);
1712
+ }
1713
+ function markerRef(raw, lo, span) {
1714
+ const v = raw?.trim();
1715
+ if (v === "left" || v === "top") return lo;
1716
+ if (v === "center") return lo + span / 2;
1717
+ if (v === "right" || v === "bottom") return lo + span;
1718
+ const n = v ? parseFloat(v) : 0;
1719
+ return Number.isFinite(n) ? n : 0;
1720
+ }
1721
+ function markerOrient(raw) {
1722
+ const v = raw?.trim() ?? "";
1723
+ if (v === "auto") return { orient: "auto", reverseAtStart: true };
1724
+ if (v === "auto-start-reverse") return { orient: "auto", reverseAtStart: false };
1725
+ const m = /^(-?[\d.]+(?:e-?\d+)?)(deg|rad|grad|turn)?$/i.exec(v);
1726
+ if (!m) return { orient: 0, reverseAtStart: false };
1727
+ const n = parseFloat(m[1]);
1728
+ const unit = (m[2] ?? "deg").toLowerCase();
1729
+ const rad = unit === "rad" ? n : unit === "grad" ? n * Math.PI / 200 : unit === "turn" ? n * 2 * Math.PI : n * Math.PI / 180;
1730
+ return { orient: rad, reverseAtStart: false };
1731
+ }
1732
+ function markerPaint(style, attr, gradients, onWarn) {
1733
+ const raw = style[attr]?.trim();
1734
+ if (raw === "context-stroke") return "line";
1735
+ if (raw === "context-fill") {
1736
+ onWarn(`a marker's ${attr}="context-fill" is read as the line's stroke paint`);
1737
+ return "line";
1738
+ }
1739
+ const paint = readPaint(style, attr, "#000000", gradients, onWarn);
1740
+ if (paint.kind === "none") return "none";
1741
+ if (paint.kind === "gradient") return paint.paint;
1742
+ return paint.opacity != null ? { ...solid(paint.color), opacity: paint.opacity } : solid(paint.color);
1743
+ }
1744
+ function asPolygon(path) {
1745
+ if (path.kind === "polygon") return path;
1746
+ const { x, y, width: w, height: h } = path;
1747
+ return {
1748
+ kind: "polygon",
1749
+ commands: new Uint8Array([PATH_M, PATH_L, PATH_L, PATH_L, PATH_Z]),
1750
+ coords: new Float32Array([x, y, x + w, y, x + w, y + h, x, y + h]),
1751
+ fillRule: "nonzero"
1752
+ };
1753
+ }
1754
+ function shortHash(s) {
1755
+ let h = 2166136261;
1756
+ for (let i = 0; i < s.length; i++) {
1757
+ h ^= s.charCodeAt(i);
1758
+ h = Math.imul(h, 16777619);
1759
+ }
1760
+ return (h >>> 0).toString(36);
1761
+ }
1762
+ function markerEntryFrom(el, id, role, strokeWidth, gradients, onWarn) {
1763
+ const num2 = (name, fallback) => {
1764
+ const n = parseFloat(el.getAttribute(name) ?? "");
1765
+ return Number.isFinite(n) ? n : fallback;
1766
+ };
1767
+ const vb = parseViewBoxAttr(el.getAttribute("viewBox"));
1768
+ let sx = 1, sy = 1;
1769
+ if (vb && vb.width > 0 && vb.height > 0) {
1770
+ sx = num2("markerWidth", 3) / vb.width;
1771
+ sy = num2("markerHeight", 3) / vb.height;
1772
+ const par = el.getAttribute("preserveAspectRatio")?.trim() ?? "";
1773
+ if (!par.startsWith("none")) {
1774
+ const s = par.endsWith("slice") ? Math.max(sx, sy) : Math.min(sx, sy);
1775
+ sx = s;
1776
+ sy = s;
1777
+ }
1778
+ }
1779
+ const refX = markerRef(el.getAttribute("refX"), vb?.x ?? 0, vb?.width ?? 0);
1780
+ const refY = markerRef(el.getAttribute("refY"), vb?.y ?? 0, vb?.height ?? 0);
1781
+ const k = el.getAttribute("markerUnits") === "userSpaceOnUse" && strokeWidth > 0 ? 1 / strokeWidth : 1;
1782
+ const { orient, reverseAtStart } = markerOrient(el.getAttribute("orient"));
1783
+ const turn = role === "start" && reverseAtStart ? -1 : 1;
1784
+ const base = [turn * sx * k, 0, 0, turn * sy * k, -turn * sx * k * refX, -turn * sy * k * refY];
1785
+ const parts = [];
1786
+ const collect = (parent, m, style) => {
1787
+ for (let i = 0; i < parent.children.length; i++) {
1788
+ const child = parent.children[i];
1789
+ const tag = child.tagName.toLowerCase();
1790
+ const cm = multiply(m, parseTransform(child.getAttribute("transform"), onWarn));
1791
+ const cs = deriveStyle(style, child);
1792
+ if (tag === "g") collect(child, cm, cs);
1793
+ else if (SUPPORTED_LEAF_TAGS.has(tag)) {
1794
+ const path = lowerLeaf(child, tag, cm, onWarn);
1795
+ if (path) parts.push({ path: asPolygon(path), style: cs });
1796
+ } else if (!IGNORED_TAGS.has(tag)) {
1797
+ onWarn(`<marker id="${id}"> child <${child.tagName}> is not supported; skipped`);
1798
+ }
1799
+ }
1800
+ };
1801
+ collect(el, base, styleAt(el));
1802
+ if (parts.length === 0) {
1803
+ onWarn(`<marker id="${id}"> draws nothing this kit can read; dropped`);
1804
+ return null;
1805
+ }
1806
+ const first = parts[0].style;
1807
+ const fill = markerPaint(first, "fill", gradients, onWarn);
1808
+ const outlinePaint = markerPaint(first, "stroke", gradients, onWarn);
1809
+ const outlineWidth = parseFloat(first["stroke-width"] ?? "1") * Math.sqrt(sx * sy) * k;
1810
+ const outline = outlinePaint !== "none" && outlineWidth > 0 ? { width: outlineWidth, paint: outlinePaint } : false;
1811
+ if (parts.some((p) => JSON.stringify(p.style) !== JSON.stringify(first))) {
1812
+ onWarn(`<marker id="${id}"> paints its parts differently; all take the first part's paint`);
1813
+ }
1814
+ let commandCount = 0, coordCount = 0;
1815
+ for (const p of parts) {
1816
+ commandCount += p.path.commands.length;
1817
+ coordCount += p.path.coords.length;
1818
+ }
1819
+ const commands = new Uint8Array(commandCount);
1820
+ const coords = new Float32Array(coordCount);
1821
+ let ci = 0, pi = 0;
1822
+ for (const p of parts) {
1823
+ commands.set(p.path.commands, ci);
1824
+ ci += p.path.commands.length;
1825
+ coords.set(p.path.coords, pi);
1826
+ pi += p.path.coords.length;
1827
+ }
1828
+ const fillRule = parts[0].path.fillRule ?? "nonzero";
1829
+ const round = (v) => Math.round(v * 1e4) / 1e4 + 0;
1830
+ const hash = shortHash(JSON.stringify([
1831
+ Array.from(commands),
1832
+ Array.from(coords, round),
1833
+ fillRule,
1834
+ fill,
1835
+ outline,
1836
+ typeof orient === "number" ? round(orient) : orient
1837
+ ]));
1838
+ const key = id.endsWith(`-${hash}`) ? id : `${id}-${hash}`;
1839
+ return {
1840
+ id: key,
1841
+ path: ({ size }) => {
1842
+ const scaled = new Float32Array(coords.length);
1843
+ for (let i = 0; i < coords.length; i++) scaled[i] = coords[i] * size;
1844
+ return { kind: "polygon", commands, coords: scaled, fillRule };
1845
+ },
1846
+ fill,
1847
+ outline,
1848
+ orient,
1849
+ inset: 0
1850
+ };
1851
+ }
1436
1852
  function serializeSvg(nodes, opts = {}) {
1437
1853
  const registry = new PaintServerRegistry();
1438
1854
  registerPaintServers(nodes, registry);
@@ -1440,6 +1856,9 @@ function serializeSvg(nodes, opts = {}) {
1440
1856
  const vb = `${trimNumber(bounds.x)} ${trimNumber(bounds.y)} ${trimNumber(bounds.width)} ${trimNumber(bounds.height)}`;
1441
1857
  const namespaces = opts.namespaces ?? {};
1442
1858
  const rootAttrs = [`xmlns="http://www.w3.org/2000/svg"`];
1859
+ if (registry.usesPrivateNamespace() && namespaces[WEASEL_NS_PREFIX] == null) {
1860
+ rootAttrs.push(`xmlns:${WEASEL_NS_PREFIX}="${WEASEL_NS}"`);
1861
+ }
1443
1862
  for (const [prefix, uri] of Object.entries(namespaces)) {
1444
1863
  rootAttrs.push(`xmlns:${prefix}="${escapeAttr2(uri)}"`);
1445
1864
  }
@@ -1460,22 +1879,28 @@ function serializeSvg(nodes, opts = {}) {
1460
1879
  for (const prefix of Object.keys(namespaces)) {
1461
1880
  const bucket = opts.documentMeta[prefix];
1462
1881
  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);
1882
+ for (const [localName2, list] of Object.entries(bucket.elements)) {
1883
+ for (const el of list) docMetaXml += namespacedElementXml(prefix, localName2, el);
1465
1884
  }
1466
1885
  }
1467
1886
  }
1468
1887
  const defsXml = registry.toDefsXml(opts.onWarn);
1469
- const bodyXml = nodes.map((n) => nodeXml(n, registry, namespaces)).join("");
1888
+ const said = /* @__PURE__ */ new Set();
1889
+ const warn = (m) => {
1890
+ if (!opts.onWarn || said.has(m)) return;
1891
+ said.add(m);
1892
+ opts.onWarn(m);
1893
+ };
1894
+ const bodyXml = nodes.map((n) => nodeXml(n, registry, namespaces, warn)).join("");
1470
1895
  const titleXml = opts.title && opts.title.length > 0 ? `<title>${escapeText(opts.title)}</title>` : "";
1471
1896
  return `<svg ${rootAttrs.join(" ")}>${titleXml}${defsXml}${docMetaXml}${bodyXml}</svg>`;
1472
1897
  }
1473
- function namespacedElementXml(prefix, localName, el) {
1898
+ function namespacedElementXml(prefix, localName2, el) {
1474
1899
  const attrs = [];
1475
1900
  for (const [name, value] of Object.entries(el.attrs)) {
1476
1901
  attrs.push(`${name}="${escapeAttr2(value)}"`);
1477
1902
  }
1478
- const head = attrs.length > 0 ? `<${prefix}:${localName} ${attrs.join(" ")}>` : `<${prefix}:${localName}>`;
1903
+ const head = attrs.length > 0 ? `<${prefix}:${localName2} ${attrs.join(" ")}>` : `<${prefix}:${localName2}>`;
1479
1904
  let body = "";
1480
1905
  if (el.children) {
1481
1906
  for (const [childName, list] of Object.entries(el.children)) {
@@ -1484,7 +1909,7 @@ function namespacedElementXml(prefix, localName, el) {
1484
1909
  } else if (el.text != null) {
1485
1910
  body = escapeText(el.text);
1486
1911
  }
1487
- return `${head}${body}</${prefix}:${localName}>`;
1912
+ return `${head}${body}</${prefix}:${localName2}>`;
1488
1913
  }
1489
1914
  function metaAttrsXml(meta, namespaces) {
1490
1915
  if (!meta) return "";
@@ -1504,8 +1929,8 @@ function metaElementsXml(meta, namespaces) {
1504
1929
  for (const prefix of Object.keys(namespaces)) {
1505
1930
  const bucket = meta[prefix];
1506
1931
  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);
1932
+ for (const [localName2, list] of Object.entries(bucket.elements)) {
1933
+ for (const el of list) out += namespacedElementXml(prefix, localName2, el);
1509
1934
  }
1510
1935
  }
1511
1936
  return out;
@@ -1513,6 +1938,7 @@ function metaElementsXml(meta, namespaces) {
1513
1938
  function registerPaintServers(nodes, registry) {
1514
1939
  for (const n of nodes) {
1515
1940
  if (n.kind === "group") {
1941
+ if (n.clip) registry.clipId(n.clip);
1516
1942
  registerPaintServers(n.children, registry);
1517
1943
  } else if (n.kind === "path") {
1518
1944
  if (n.fill.kind === "gradient") registry.register(n.fill.paint);
@@ -1544,50 +1970,61 @@ function registerMarkers(stroke, registry) {
1544
1970
  if (key) registry.markerId(key);
1545
1971
  }
1546
1972
  }
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);
1973
+ function nodeXml(node, registry, namespaces, warn) {
1974
+ if (node.kind === "group") return groupXml(node, registry, namespaces, warn);
1975
+ if (node.kind === "text") return textXml(node, registry, namespaces, warn);
1550
1976
  if (node.kind === "image") return imageXml(node, namespaces);
1551
- return pathXml(node, registry, namespaces);
1977
+ return pathXml(node, registry, namespaces, warn);
1978
+ }
1979
+ function warnStrokeAlign(align, warn) {
1980
+ if (align && align !== "center") {
1981
+ warn(`a stroke aligned "${align}" of its edge is written centered: SVG has no stroke alignment`);
1982
+ }
1552
1983
  }
1553
1984
  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
- ];
1985
+ const box = `x="${trimNumber(node.x)}" y="${trimNumber(node.y)}" width="${trimNumber(node.width)}" height="${trimNumber(node.height)}"`;
1986
+ const outer = [];
1562
1987
  if (node.opacity != null && node.opacity !== 1) {
1563
- attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1988
+ outer.push(`opacity="${trimNumber(node.opacity)}"`);
1564
1989
  }
1565
1990
  if (node.rotation != null && node.rotation !== 0) {
1566
1991
  const cx = node.x + node.width / 2;
1567
1992
  const cy = node.y + node.height / 2;
1568
1993
  const deg = node.rotation * 180 / Math.PI;
1569
- attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
1994
+ outer.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
1570
1995
  }
1571
1996
  const metaAttrs = metaAttrsXml(node.meta, namespaces);
1572
1997
  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) {
1998
+ const href = `href="${escapeAttr2(node.href)}"`;
1999
+ if (!node.source && !node.flipX && !node.flipY) {
2000
+ const attrs = [href, box, 'preserveAspectRatio="none"', ...outer].join(" ");
2001
+ if (metaEls) return `<image ${attrs}${metaAttrs}>${metaEls}</image>`;
2002
+ return `<image ${attrs}${metaAttrs}/>`;
2003
+ }
2004
+ const src = node.source ?? { x: 0, y: 0, width: 1, height: 1 };
2005
+ const viewBox = [src.x, src.y, src.width, src.height].map(trimNumber).join(" ");
2006
+ 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)})"` : "";
2007
+ const head = ['<g data-weasel-image="true"', ...outer].join(" ");
2008
+ 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>`;
2009
+ }
2010
+ function groupXml(node, registry, namespaces, warn) {
1577
2011
  const attrs = [];
1578
2012
  if (node.transform) {
1579
2013
  const m = formatMatrix(node.transform);
1580
2014
  if (m) attrs.push(`transform="${m}"`);
1581
2015
  }
2016
+ if (node.clip) {
2017
+ attrs.push(`clip-path="url(#${registry.clipId(node.clip)})"`);
2018
+ }
1582
2019
  if (node.opacity != null && node.opacity !== 1) {
1583
2020
  attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1584
2021
  }
1585
2022
  const metaAttrs = metaAttrsXml(node.meta, namespaces);
1586
2023
  const head = attrs.length > 0 ? `<g ${attrs.join(" ")}${metaAttrs}>` : `<g${metaAttrs}>`;
1587
- const body = node.children.map((c) => nodeXml(c, registry, namespaces)).join("");
2024
+ const body = node.children.map((c) => nodeXml(c, registry, namespaces, warn)).join("");
1588
2025
  return `${head}${body}${metaElementsXml(node.meta, namespaces)}</g>`;
1589
2026
  }
1590
- function pathXml(node, registry, namespaces) {
2027
+ function pathXml(node, registry, namespaces, warn) {
1591
2028
  const attrs = [`d="${serializePathD(node.path)}"`];
1592
2029
  const fillAttrs = paintAttrs(node.fill, "fill", registry);
1593
2030
  for (const a of fillAttrs) attrs.push(a);
@@ -1595,7 +2032,7 @@ function pathXml(node, registry, namespaces) {
1595
2032
  attrs.push(`fill-rule="evenodd"`);
1596
2033
  }
1597
2034
  if (node.stroke) {
1598
- const strokeAttrs = strokeAttrsFor(node.stroke, registry);
2035
+ const strokeAttrs = strokeAttrsFor(node.stroke, registry, warn);
1599
2036
  for (const a of strokeAttrs) attrs.push(a);
1600
2037
  } else {
1601
2038
  attrs.push('stroke="none"');
@@ -1626,18 +2063,18 @@ function paintAttrs(paint, name, registry, includeOpacity = true) {
1626
2063
  }
1627
2064
  return out;
1628
2065
  }
1629
- const id = registry.register(paint.paint);
1630
- return [`${name}="url(#${id})"`];
2066
+ return [`${name}="${registry.ref(paint.paint)}"`];
1631
2067
  }
1632
2068
  function strokeWidthAttrs(width) {
1633
2069
  if (typeof width === "number") return [`stroke-width="${trimNumber(width)}"`];
1634
2070
  return [`stroke-width="${trimNumber(width.px)}"`, 'vector-effect="non-scaling-stroke"'];
1635
2071
  }
1636
- function coreStrokeAttrs(stroke, registry) {
2072
+ function coreStrokeAttrs(stroke, registry, warn) {
1637
2073
  const paint = stroke?.paint;
1638
2074
  if (!stroke || !paint) return [];
1639
2075
  const width = stroke.width ?? 1;
1640
2076
  if (!((typeof width === "object" ? width.px : width) > 0)) return [];
2077
+ warnStrokeAlign(stroke.align, warn);
1641
2078
  const attrs = [];
1642
2079
  if ("color" in paint) {
1643
2080
  attrs.push(`stroke="${paint.color}"`);
@@ -1645,7 +2082,7 @@ function coreStrokeAttrs(stroke, registry) {
1645
2082
  attrs.push(`stroke-opacity="${trimNumber(paint.opacity)}"`);
1646
2083
  }
1647
2084
  } else {
1648
- attrs.push(`stroke="url(#${registry.register(paint)})"`);
2085
+ attrs.push(`stroke="${registry.ref(paint)}"`);
1649
2086
  }
1650
2087
  attrs.push(...strokeWidthAttrs(width));
1651
2088
  if (stroke.cap) attrs.push(`stroke-linecap="${stroke.cap}"`);
@@ -1668,7 +2105,8 @@ function coreStrokeAttrs(stroke, registry) {
1668
2105
  }
1669
2106
  return attrs;
1670
2107
  }
1671
- function strokeAttrsFor(stroke, registry) {
2108
+ function strokeAttrsFor(stroke, registry, warn) {
2109
+ warnStrokeAlign(stroke.align, warn);
1672
2110
  const attrs = paintAttrs(stroke.paint, "stroke", registry, false);
1673
2111
  attrs.push(...strokeWidthAttrs(stroke.width));
1674
2112
  const opacity = stroke.opacity ?? (stroke.paint.kind === "solid" ? stroke.paint.opacity : void 0);
@@ -1735,7 +2173,7 @@ function rotateAboutCenter(ctm, b, angle) {
1735
2173
  const s = Math.sin(angle);
1736
2174
  return multiply(ctm, [c, s, -s, c, cx - cx * c + cy * s, cy - cx * s - cy * c]);
1737
2175
  }
1738
- function textXml(node, registry, namespaces) {
2176
+ function textXml(node, registry, namespaces, warn) {
1739
2177
  const attrs = [
1740
2178
  `x="${trimNumber(node.x + anchorOffset(node.style, node.width))}"`,
1741
2179
  `y="${trimNumber(node.y)}"`,
@@ -1747,7 +2185,7 @@ function textXml(node, registry, namespaces) {
1747
2185
  `data-weasel-height="${trimNumber(node.height)}"`
1748
2186
  ];
1749
2187
  const style = node.style;
1750
- if (style?.fontSize != null) attrs.push(`font-size="${trimNumber(style.fontSize)}"`);
2188
+ if (style?.fontSize != null) attrs.push(`font-size="${trimNumber(resolveScreenLength(style.fontSize, 1))}"`);
1751
2189
  if (style?.fontFamily) attrs.push(`font-family="${escapeAttr2(style.fontFamily)}"`);
1752
2190
  if (style?.fontWeight != null) attrs.push(`font-weight="${String(style.fontWeight)}"`);
1753
2191
  if (style?.fontStyle && style.fontStyle !== "normal") attrs.push(`font-style="${style.fontStyle}"`);
@@ -1759,11 +2197,18 @@ function textXml(node, registry, namespaces) {
1759
2197
  if (anchor !== "start") attrs.push(`text-anchor="${anchor}"`);
1760
2198
  }
1761
2199
  if (style?.letterSpacing != null && style.letterSpacing !== 0) {
1762
- attrs.push(`letter-spacing="${trimNumber(style.letterSpacing)}"`);
2200
+ attrs.push(`letter-spacing="${trimNumber(resolveScreenLength(style.letterSpacing, 1))}"`);
1763
2201
  }
1764
2202
  const decoration = textDecorationValue(style?.underline, style?.strikethrough, style?.overline);
1765
2203
  if (decoration) attrs.push(`text-decoration="${decoration}"`);
1766
- if (style?.wrap) attrs.push('data-weasel-wrap="true"');
2204
+ if (style?.wrap) {
2205
+ attrs.push('data-weasel-wrap="true"');
2206
+ 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)");
2207
+ }
2208
+ if (node.verticalAlign && node.verticalAlign !== "top") {
2209
+ attrs.push(`data-weasel-vertical-align="${node.verticalAlign}"`);
2210
+ 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)`);
2211
+ }
1767
2212
  if (node.fill === null) {
1768
2213
  attrs.push('fill="none"');
1769
2214
  } else if (node.fill) {
@@ -1773,11 +2218,10 @@ function textXml(node, registry, namespaces) {
1773
2218
  attrs.push(`fill-opacity="${trimNumber(node.fill.opacity)}"`);
1774
2219
  }
1775
2220
  } else {
1776
- const id = registry.register(node.fill);
1777
- attrs.push(`fill="url(#${id})"`);
2221
+ attrs.push(`fill="${registry.ref(node.fill)}"`);
1778
2222
  }
1779
2223
  }
1780
- for (const a of coreStrokeAttrs(node.stroke, registry)) attrs.push(a);
2224
+ for (const a of coreStrokeAttrs(node.stroke, registry, warn)) attrs.push(a);
1781
2225
  if (node.opacity != null && node.opacity !== 1) {
1782
2226
  attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1783
2227
  }
@@ -1787,19 +2231,19 @@ function textXml(node, registry, namespaces) {
1787
2231
  const deg = node.rotation * 180 / Math.PI;
1788
2232
  attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
1789
2233
  }
1790
- const body = node.runs && node.runs.length > 0 ? node.runs.map((r) => runXml(r, registry)).join("") : escapeText(node.text);
2234
+ const body = node.runs && node.runs.length > 0 ? node.runs.map((r) => runXml(r, registry, warn)).join("") : escapeText(node.text);
1791
2235
  const metaAttrs = metaAttrsXml(node.meta, namespaces);
1792
2236
  const metaEls = metaElementsXml(node.meta, namespaces);
1793
2237
  return `<text ${attrs.join(" ")}${metaAttrs}>${body}${metaEls}</text>`;
1794
2238
  }
1795
- function runXml(run, registry) {
2239
+ function runXml(run, registry, warn) {
1796
2240
  const attrs = [];
1797
2241
  if (run.bold) attrs.push(`font-weight="700"`);
1798
2242
  if (run.italic) attrs.push(`font-style="italic"`);
1799
2243
  if (run.fontFamily) attrs.push(`font-family="${escapeAttr2(run.fontFamily)}"`);
1800
2244
  const presetScale = run.script != null && run.baselineShift != null ? SCRIPT_METRICS[run.script].size : void 0;
1801
2245
  const scale = run.fontScale ?? presetScale;
1802
- if (run.fontSize != null) attrs.push(`font-size="${trimNumber(run.fontSize)}"`);
2246
+ if (run.fontSize != null) attrs.push(`font-size="${trimNumber(resolveScreenLength(run.fontSize, 1))}"`);
1803
2247
  else if (scale != null) attrs.push(`font-size="${trimNumber(scale * 100)}%"`);
1804
2248
  if (run.baselineShift != null) {
1805
2249
  attrs.push(`baseline-shift="${trimNumber(run.baselineShift * 100)}%"`);
@@ -1807,7 +2251,7 @@ function runXml(run, registry) {
1807
2251
  attrs.push(`baseline-shift="${run.script}"`);
1808
2252
  }
1809
2253
  if (run.letterSpacing != null) {
1810
- attrs.push(`letter-spacing="${trimNumber(run.letterSpacing)}"`);
2254
+ attrs.push(`letter-spacing="${trimNumber(resolveScreenLength(run.letterSpacing, 1))}"`);
1811
2255
  }
1812
2256
  const runDecoration = textDecorationValue(run.underline, run.strikethrough, run.overline);
1813
2257
  if (runDecoration) attrs.push(`text-decoration="${runDecoration}"`);
@@ -1815,11 +2259,10 @@ function runXml(run, registry) {
1815
2259
  if ("color" in run.fill) {
1816
2260
  attrs.push(`fill="${run.fill.color}"`);
1817
2261
  } else {
1818
- const id = registry.register(run.fill);
1819
- attrs.push(`fill="url(#${id})"`);
2262
+ attrs.push(`fill="${registry.ref(run.fill)}"`);
1820
2263
  }
1821
2264
  }
1822
- for (const a of coreStrokeAttrs(run.stroke, registry)) attrs.push(a);
2265
+ for (const a of coreStrokeAttrs(run.stroke, registry, warn)) attrs.push(a);
1823
2266
  const head = attrs.length > 0 ? `<tspan ${attrs.join(" ")}>` : "<tspan>";
1824
2267
  return `${head}${escapeText(run.text)}</tspan>`;
1825
2268
  }
@@ -1946,6 +2389,7 @@ function svgNodesToKitDrafts(nodes, nextId) {
1946
2389
  data: {
1947
2390
  text: n.text,
1948
2391
  ...n.style ? { style: n.style } : {},
2392
+ ...n.verticalAlign ? { verticalAlign: n.verticalAlign } : {},
1949
2393
  ...n.runs ? { runs: n.runs.map((r) => runInBoxFrame(r, box)) } : {},
1950
2394
  // `!== undefined`, not a truthiness test: `null` is the document
1951
2395
  // saying `fill="none"`, and absent takes the painter's default.
@@ -1996,13 +2440,14 @@ function svgNodesToKitDrafts(nodes, nextId) {
1996
2440
  function scaleTextData(data, scale) {
1997
2441
  if (scale === 1 || typeof data.text !== "string") return data;
1998
2442
  const style = data.style ?? {};
2443
+ const scaleSize = (v) => typeof v === "number" ? v * scale : v;
1999
2444
  const runs = data.runs;
2000
2445
  return {
2001
2446
  ...data,
2002
2447
  ...runs ? {
2003
- runs: runs.map((r) => r.fontSize !== void 0 ? { ...r, fontSize: r.fontSize * scale } : r)
2448
+ runs: runs.map((r) => r.fontSize !== void 0 ? { ...r, fontSize: scaleSize(r.fontSize) } : r)
2004
2449
  } : {},
2005
- style: { ...style, fontSize: resolveTextStyle(style).fontSize * scale }
2450
+ style: { ...style, fontSize: scaleSize(style.fontSize ?? resolveTextStyle(style).fontSize) }
2006
2451
  };
2007
2452
  }
2008
2453
  function unionRect(a, b) {
@@ -2020,6 +2465,7 @@ async function unpackSvgFiles(files, ctx) {
2020
2465
  const text = await readFileText(file);
2021
2466
  const parsed = parseSvg(text);
2022
2467
  for (const w of parsed.warnings) dwarn("ingest", `svg "${file.name}":`, w);
2468
+ for (const m of parsed.markers ?? []) if (getMarker(m.id) === void 0) registerMarker(m);
2023
2469
  let drafts = svgNodesToKitDrafts(parsed.nodes, freshSvgNodeId);
2024
2470
  if (drafts.length === 0) {
2025
2471
  console.warn(`weasel ingest: svg "${file.name}" parsed to no drawable nodes`);