@weasel-js/svg 1.0.1 → 1.0.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/README.md CHANGED
@@ -40,6 +40,18 @@ answer, and the alternative is unrepresentable anyway (see above). It only
40
40
  shows up on foreign SVG that sets decoration on a group and cancels it on a
41
41
  child.
42
42
 
43
+ ## Raster images
44
+
45
+ `<image>` parses to an `SvgImageNode` holding the `href` verbatim — an
46
+ external URL or a `data:` URI — plus a box. The package never fetches or
47
+ decodes it, so an external URL round-trips as a reference and resolves only
48
+ when something downstream loads it. `unpackSvgFiles` maps the node onto the
49
+ kit's `kit:image` painter (`data.image.src`), which does load it.
50
+
51
+ `preserveAspectRatio` is not modeled. The box is taken literally on the way
52
+ in (a non-`none` value warns) and written back as `none`, so a source file
53
+ that relied on letterboxing imports stretched.
54
+
43
55
  ## License
44
56
 
45
57
  MIT
package/dist/index.d.ts CHANGED
@@ -121,6 +121,16 @@ interface SvgGroupNode {
121
121
  /** Opaque per-element bag for declared namespaces. See `NamespaceMeta`. */
122
122
  meta?: NamespaceMeta;
123
123
  }
124
+ /**
125
+ * Width given to an external `<text>` that carries no `data-weasel-width`:
126
+ * large enough that line wrapping never fires, since SVG text flows from a
127
+ * baseline and the source says nothing about a box.
128
+ *
129
+ * It is a wrap sentinel, not a measurement. Anything that treats a text
130
+ * node's width as geometry — a union AABB, a fit-clamp — has to recognize it
131
+ * and substitute an estimate, or one text node swamps the whole file.
132
+ */
133
+ declare const UNBOUNDED_TEXT_WIDTH = 99999;
124
134
  /**
125
135
  * Text leaf node. Mirrors the kit's `TextPose` shape — a bounding box plus
126
136
  * text content, optional rich-text `runs` (per-range styling), and an
@@ -132,7 +142,7 @@ interface SvgGroupNode {
132
142
  * box, plus `data-weasel-width` / `data-weasel-height` so weasel's
133
143
  * explicit box dimensions round-trip losslessly. External SVG text
134
144
  * (lacking the data-* attrs) imports with estimated dimensions —
135
- * `width = 99999` (effectively unbounded so wrap doesn't fire) and
145
+ * {@link UNBOUNDED_TEXT_WIDTH} and
136
146
  * `height = fontSize * lineHeight * (lineCount || 1)`.
137
147
  */
138
148
  interface SvgTextNode {
@@ -156,8 +166,32 @@ interface SvgTextNode {
156
166
  /** Opaque per-element bag for declared namespaces. See `NamespaceMeta`. */
157
167
  meta?: NamespaceMeta;
158
168
  }
169
+ /**
170
+ * Raster-image leaf — an SVG `<image>`. `href` is the element's `href` (or
171
+ * legacy `xlink:href`) verbatim: an external URL or a `data:` URI. The
172
+ * package neither fetches nor decodes it, so an external URL round-trips as
173
+ * a reference and only resolves when something downstream loads it.
174
+ *
175
+ * SVG's `preserveAspectRatio` is not modeled; the box is taken literally.
176
+ */
177
+ interface SvgImageNode {
178
+ kind: 'image';
179
+ href: string;
180
+ x: number;
181
+ y: number;
182
+ width: number;
183
+ height: number;
184
+ /** Element-level opacity (`opacity="..."`), 0..1. */
185
+ opacity?: number;
186
+ /** Element-level rotation in **radians**, pivoting around the unrotated
187
+ * AABB center `(x + width/2, y + height/2)`. Emitted as SVG
188
+ * `transform="rotate(angleDegrees cx cy)"`. */
189
+ rotation?: number;
190
+ /** Opaque per-element bag for declared namespaces. See `NamespaceMeta`. */
191
+ meta?: NamespaceMeta;
192
+ }
159
193
  /** Discriminated-union node — the leaf of the public tree. */
160
- type SvgNode = SvgPathNode | SvgGroupNode | SvgTextNode;
194
+ type SvgNode = SvgPathNode | SvgGroupNode | SvgTextNode | SvgImageNode;
161
195
  /** Options for {@link parseSvg}. */
162
196
  interface ParseOptions {
163
197
  /**
@@ -337,4 +371,4 @@ declare function svgNodesToKitDrafts(nodes: readonly SvgNode[], nextId: () => st
337
371
  */
338
372
  declare function unpackSvgFiles(files: File[], ctx: IngestCtx): Promise<void>;
339
373
 
340
- export { IDENTITY_MATRIX, type Matrix, type NamespaceMeta, type NamespacedElement, type ParseOptions, type ParseResult, type SerializeOptions, type SvgDraftBounds, type SvgGroupNode, type SvgNode, type SvgPaint, type SvgPathNode, type SvgSceneDraft, type SvgStroke, type SvgTextNode, parseSvg, serializeSvg, svgNodesToKitDrafts, tilePreviewCssUrl, tilePreviewSvg, unpackSvgFiles };
374
+ export { IDENTITY_MATRIX, type Matrix, type NamespaceMeta, type NamespacedElement, type ParseOptions, type ParseResult, type SerializeOptions, type SvgDraftBounds, type SvgGroupNode, type SvgImageNode, type SvgNode, type SvgPaint, type SvgPathNode, type SvgSceneDraft, type SvgStroke, type SvgTextNode, UNBOUNDED_TEXT_WIDTH, parseSvg, serializeSvg, svgNodesToKitDrafts, tilePreviewCssUrl, tilePreviewSvg, unpackSvgFiles };
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
- import { pathFromD, PATH_M, PATH_L, PATH_Z, boundsOfPath, dwarn, createInsertOp, PathBuilder, parseColor, rgbaToHex, PATH_C, PATH_Q } from '@weasel-js/core';
1
+ import { pathFromD, PATH_M, PATH_L, PATH_Z, boundsOfPath, dwarn, createInsertOp, PathBuilder, parseColor, rgbaToHex, PATH_C, PATH_Q, resolveTextStyle, fillToBoundsFrame } from '@weasel-js/core';
2
2
  import { tileGeometry } from '@weasel-js/core/patterns-builtin';
3
3
 
4
4
  // src/parse.ts
5
5
 
6
6
  // src/types.ts
7
7
  var IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0];
8
+ var UNBOUNDED_TEXT_WIDTH = 99999;
8
9
 
9
10
  // src/transform.ts
10
11
  function multiply(a, b) {
@@ -383,6 +384,9 @@ function applyAlpha(hex, alpha) {
383
384
  const a = Math.max(0, Math.min(255, Math.round(alpha * 255)));
384
385
  return `${hex}${a.toString(16).padStart(2, "0")}`;
385
386
  }
387
+ function readGradientUnits(el) {
388
+ return el.getAttribute("gradientUnits") === "userSpaceOnUse" ? "world" : "bounds";
389
+ }
386
390
  function readLinearGradient(el, onWarn) {
387
391
  const x1 = parseFloat(el.getAttribute("x1") ?? "0");
388
392
  const y1 = parseFloat(el.getAttribute("y1") ?? "0");
@@ -393,7 +397,8 @@ function readLinearGradient(el, onWarn) {
393
397
  fill: "linear-gradient",
394
398
  from: { x: x1, y: y1 },
395
399
  to: { x: x2, y: y2 },
396
- stops
400
+ stops,
401
+ units: readGradientUnits(el)
397
402
  };
398
403
  }
399
404
  function readRadialGradient(el, onWarn) {
@@ -405,7 +410,8 @@ function readRadialGradient(el, onWarn) {
405
410
  fill: "radial-gradient",
406
411
  center: { x: cx, y: cy },
407
412
  radius: r,
408
- stops
413
+ stops,
414
+ units: readGradientUnits(el)
409
415
  };
410
416
  }
411
417
  var PaintServerRegistry = class {
@@ -434,14 +440,17 @@ var PaintServerRegistry = class {
434
440
  return parts.join("");
435
441
  }
436
442
  };
443
+ function gradientUnitsAttr(units) {
444
+ return units === "bounds" ? "objectBoundingBox" : "userSpaceOnUse";
445
+ }
437
446
  function gradientXml(id, paint) {
438
447
  if (paint.fill === "linear-gradient") {
439
448
  const stops = paint.stops.map(stopXml).join("");
440
- return `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${trimNumber(paint.from.x)}" y1="${trimNumber(paint.from.y)}" x2="${trimNumber(paint.to.x)}" y2="${trimNumber(paint.to.y)}">${stops}</linearGradient>`;
449
+ 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>`;
441
450
  }
442
451
  if (paint.fill === "radial-gradient") {
443
452
  const stops = paint.stops.map(stopXml).join("");
444
- return `<radialGradient id="${id}" gradientUnits="userSpaceOnUse" cx="${trimNumber(paint.center.x)}" cy="${trimNumber(paint.center.y)}" r="${trimNumber(paint.radius)}">${stops}</radialGradient>`;
453
+ return `<radialGradient id="${id}" gradientUnits="${gradientUnitsAttr(paint.units)}" cx="${trimNumber(paint.center.x)}" cy="${trimNumber(paint.center.y)}" r="${trimNumber(paint.radius)}">${stops}</radialGradient>`;
445
454
  }
446
455
  return "";
447
456
  }
@@ -685,6 +694,14 @@ function parseElement(el, ctm, style, gradients, onWarn, uriToPrefix) {
685
694
  }
686
695
  return textNode;
687
696
  }
697
+ if (tag === "image") {
698
+ const imageNode = parseImageElement(el, ctm, onWarn);
699
+ if (imageNode) {
700
+ const meta2 = collectElementMeta(el, uriToPrefix);
701
+ if (meta2) imageNode.meta = meta2;
702
+ }
703
+ return imageNode;
704
+ }
688
705
  if (!SUPPORTED_LEAF_TAGS.has(tag)) {
689
706
  onWarn(`unsupported element: <${el.tagName}>`);
690
707
  return null;
@@ -933,6 +950,54 @@ function parseTextDecoration(raw) {
933
950
  if (tokens.includes("line-through")) out.strikethrough = true;
934
951
  return out;
935
952
  }
953
+ var XLINK_NS = "http://www.w3.org/1999/xlink";
954
+ function parseImageElement(el, ctm, onWarn) {
955
+ const href = el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS, "href") ?? el.getAttribute("xlink:href");
956
+ if (!href) {
957
+ onWarn("<image> without href; dropped");
958
+ return null;
959
+ }
960
+ const num = (raw, fallback) => {
961
+ if (raw == null) return fallback;
962
+ const n = parseFloat(raw);
963
+ return Number.isFinite(n) ? n : fallback;
964
+ };
965
+ const rawX = num(el.getAttribute("x"), 0);
966
+ const rawY = num(el.getAttribute("y"), 0);
967
+ const rawW = num(el.getAttribute("width"), 0);
968
+ const rawH = num(el.getAttribute("height"), 0);
969
+ const px = (x, y) => [ctm[0] * x + ctm[2] * y + ctm[4], ctm[1] * x + ctm[3] * y + ctm[5]];
970
+ const [x0, y0] = px(rawX, rawY);
971
+ const [x1, y1] = px(rawX + rawW, rawY + rawH);
972
+ const node = {
973
+ kind: "image",
974
+ href,
975
+ x: Math.min(x0, x1),
976
+ y: Math.min(y0, y1),
977
+ width: Math.abs(x1 - x0),
978
+ height: Math.abs(y1 - y0)
979
+ };
980
+ const opacity = readOpacityAttr(el, "opacity");
981
+ if (opacity != null) node.opacity = opacity;
982
+ const localTransform = parseTransform(el.getAttribute("transform"), onWarn);
983
+ if (!isIdentity(localTransform)) {
984
+ const cx = node.x + node.width / 2;
985
+ const cy = node.y + node.height / 2;
986
+ const angle = decomposeRotation(localTransform, cx, cy);
987
+ if (angle != null) {
988
+ node.rotation = angle;
989
+ } else {
990
+ const rotComp = rotationComponent(localTransform);
991
+ if (Math.abs(rotComp) > 1e-4) {
992
+ onWarn("<image> transform has a rotational component that can't be cleanly stored as rotation; dropped (may not round-trip identically)");
993
+ }
994
+ }
995
+ }
996
+ if (el.hasAttribute("preserveAspectRatio") && el.getAttribute("preserveAspectRatio") !== "none") {
997
+ onWarn("<image> preserveAspectRatio is not modeled; the box is taken literally");
998
+ }
999
+ return node;
1000
+ }
936
1001
  function parseTextElement(el, ctm, style, gradients, onWarn) {
937
1002
  const num = (raw, fallback) => {
938
1003
  if (raw == null) return fallback;
@@ -979,7 +1044,7 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
979
1044
  }
980
1045
  const dataW = num(el.getAttribute("data-weasel-width"), NaN);
981
1046
  const dataH = num(el.getAttribute("data-weasel-height"), NaN);
982
- const width = Number.isFinite(dataW) ? dataW : 99999;
1047
+ const width = Number.isFinite(dataW) ? dataW : UNBOUNDED_TEXT_WIDTH;
983
1048
  const lines = (plain.match(/\n/g)?.length ?? 0) + 1;
984
1049
  const height = Number.isFinite(dataH) ? dataH : fontSize * lineHeight * lines;
985
1050
  const opacity = readOpacityAttr(el, "opacity");
@@ -1227,8 +1292,32 @@ function registerTextPaint(paint, registry) {
1227
1292
  function nodeXml(node, registry, namespaces) {
1228
1293
  if (node.kind === "group") return groupXml(node, registry, namespaces);
1229
1294
  if (node.kind === "text") return textXml(node, registry, namespaces);
1295
+ if (node.kind === "image") return imageXml(node, namespaces);
1230
1296
  return pathXml(node, registry, namespaces);
1231
1297
  }
1298
+ function imageXml(node, namespaces) {
1299
+ const attrs = [
1300
+ `href="${escapeAttr2(node.href)}"`,
1301
+ `x="${trimNumber(node.x)}"`,
1302
+ `y="${trimNumber(node.y)}"`,
1303
+ `width="${trimNumber(node.width)}"`,
1304
+ `height="${trimNumber(node.height)}"`,
1305
+ `preserveAspectRatio="none"`
1306
+ ];
1307
+ if (node.opacity != null && node.opacity !== 1) {
1308
+ attrs.push(`opacity="${trimNumber(node.opacity)}"`);
1309
+ }
1310
+ if (node.rotation != null && node.rotation !== 0) {
1311
+ const cx = node.x + node.width / 2;
1312
+ const cy = node.y + node.height / 2;
1313
+ const deg = node.rotation * 180 / Math.PI;
1314
+ attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
1315
+ }
1316
+ const metaAttrs = metaAttrsXml(node.meta, namespaces);
1317
+ const metaEls = metaElementsXml(node.meta, namespaces);
1318
+ if (metaEls) return `<image ${attrs.join(" ")}${metaAttrs}>${metaEls}</image>`;
1319
+ return `<image ${attrs.join(" ")}${metaAttrs}/>`;
1320
+ }
1232
1321
  function groupXml(node, registry, namespaces) {
1233
1322
  const attrs = [];
1234
1323
  if (node.transform) {
@@ -1334,7 +1423,7 @@ function computeBounds(nodes) {
1334
1423
  n.children.forEach(visit);
1335
1424
  return;
1336
1425
  }
1337
- const b = n.kind === "text" ? { minX: n.x, minY: n.y, maxX: n.x + n.width, maxY: n.y + n.height } : pathBounds(n.path);
1426
+ const b = n.kind === "text" || n.kind === "image" ? { minX: n.x, minY: n.y, maxX: n.x + n.width, maxY: n.y + n.height } : pathBounds(n.path);
1338
1427
  if (b.minX < minX) minX = b.minX;
1339
1428
  if (b.minY < minY) minY = b.minY;
1340
1429
  if (b.maxX > maxX) maxX = b.maxX;
@@ -1456,11 +1545,19 @@ var svgIdCounter = 0;
1456
1545
  function freshSvgNodeId() {
1457
1546
  return `n${(svgIdCounter++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1458
1547
  }
1459
- function colorFromPaint(paint, context) {
1548
+ function fillFromPaint(paint, box) {
1549
+ if (!paint) return void 0;
1550
+ if (paint.kind === "none") return "none";
1551
+ if (paint.kind === "solid") return paint.color;
1552
+ const g = paint.paint;
1553
+ const units = "units" in g ? g.units : void 0;
1554
+ return units === "world" ? fillToBoundsFrame(g, box) : g;
1555
+ }
1556
+ function strokeColorFromPaint(paint) {
1460
1557
  if (!paint) return void 0;
1461
1558
  if (paint.kind === "none") return "none";
1462
1559
  if (paint.kind === "solid") return paint.color;
1463
- dwarn("ingest", `svg unpack: gradient ${context} flattened to ${GRADIENT_FALLBACK}`);
1560
+ dwarn("ingest", `svg unpack: gradient stroke flattened to ${GRADIENT_FALLBACK}`);
1464
1561
  return GRADIENT_FALLBACK;
1465
1562
  }
1466
1563
  function svgNodesToKitDrafts(nodes, nextId) {
@@ -1487,7 +1584,12 @@ function svgNodesToKitDrafts(nodes, nextId) {
1487
1584
  return acc;
1488
1585
  }
1489
1586
  if (n.kind === "text") {
1490
- const pose2 = { x: n.x, y: n.y, width: n.width, height: n.height };
1587
+ const pose2 = {
1588
+ x: n.x,
1589
+ y: n.y,
1590
+ width: textBoxWidth(n),
1591
+ height: n.height
1592
+ };
1491
1593
  if (n.rotation) pose2.rotation = n.rotation;
1492
1594
  drafts.push({
1493
1595
  kind: "leaf",
@@ -1498,11 +1600,28 @@ function svgNodesToKitDrafts(nodes, nextId) {
1498
1600
  });
1499
1601
  return pose2;
1500
1602
  }
1603
+ if (n.kind === "image") {
1604
+ const pose2 = { x: n.x, y: n.y, width: n.width, height: n.height };
1605
+ if (n.rotation) pose2.rotation = n.rotation;
1606
+ drafts.push({
1607
+ kind: "leaf",
1608
+ id: nextId(),
1609
+ parentId,
1610
+ pose: pose2,
1611
+ data: {
1612
+ image: {
1613
+ src: n.href,
1614
+ ...n.opacity != null ? { opacity: n.opacity } : {}
1615
+ }
1616
+ }
1617
+ });
1618
+ return pose2;
1619
+ }
1501
1620
  const b = n.path.kind === "rect" ? { x: n.path.x, y: n.path.y, width: n.path.width, height: n.path.height } : boundsOfPath(n.path);
1502
1621
  const pose = { x: b.x, y: b.y, width: b.width, height: b.height };
1503
1622
  if (n.rotation) pose.rotation = n.rotation;
1504
- const fill = colorFromPaint(n.fill, "fill");
1505
- const stroke = n.stroke ? colorFromPaint(n.stroke.paint, "stroke") : void 0;
1623
+ const fill = fillFromPaint(n.fill, b);
1624
+ const stroke = n.stroke ? strokeColorFromPaint(n.stroke.paint) : void 0;
1506
1625
  drafts.push({
1507
1626
  kind: "leaf",
1508
1627
  id: nextId(),
@@ -1519,6 +1638,21 @@ function svgNodesToKitDrafts(nodes, nextId) {
1519
1638
  for (const n of nodes) visit(n, null);
1520
1639
  return drafts;
1521
1640
  }
1641
+ var ESTIMATED_GLYPH_ADVANCE_EM = 0.6;
1642
+ function textBoxWidth(n) {
1643
+ if (n.width !== UNBOUNDED_TEXT_WIDTH) return n.width;
1644
+ const fontSize = resolveTextStyle(n.style).fontSize;
1645
+ const longest = n.text.split("\n").reduce((max, line) => Math.max(max, line.length), 0);
1646
+ return longest * fontSize * ESTIMATED_GLYPH_ADVANCE_EM;
1647
+ }
1648
+ function scaleTextData(data, scale) {
1649
+ if (scale === 1 || typeof data.text !== "string") return data;
1650
+ const style = data.style ?? {};
1651
+ return {
1652
+ ...data,
1653
+ style: { ...style, fontSize: resolveTextStyle(style).fontSize * scale }
1654
+ };
1655
+ }
1522
1656
  function unionRect(a, b) {
1523
1657
  const minX = Math.min(a.x, b.x);
1524
1658
  const minY = Math.min(a.y, b.y);
@@ -1576,7 +1710,7 @@ async function unpackSvgFiles(files, ctx) {
1576
1710
  kind: d.kind,
1577
1711
  layer,
1578
1712
  pose: place(d.pose),
1579
- data: d.kind === "leaf" ? d.data : {},
1713
+ data: d.kind === "leaf" ? scaleTextData(d.data, scale) : {},
1580
1714
  parent: d.parentId
1581
1715
  },
1582
1716
  label: "Insert SVG"
@@ -1590,6 +1724,6 @@ async function unpackSvgFiles(files, ctx) {
1590
1724
  }
1591
1725
  }
1592
1726
 
1593
- export { IDENTITY_MATRIX, parseSvg, serializeSvg, svgNodesToKitDrafts, tilePreviewCssUrl, tilePreviewSvg, unpackSvgFiles };
1727
+ export { IDENTITY_MATRIX, UNBOUNDED_TEXT_WIDTH, parseSvg, serializeSvg, svgNodesToKitDrafts, tilePreviewCssUrl, tilePreviewSvg, unpackSvgFiles };
1594
1728
  //# sourceMappingURL=index.js.map
1595
1729
  //# sourceMappingURL=index.js.map