@weasel-js/svg 1.13.0-rc.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { identity, multiply, pathToD, invert, splitByClosure, fillsOpenSubpaths, rotateAboutPoint, hasOpenSubpath, splitSubpaths } from '@weasel-js/geom';
1
+ import { identity, multiply, pathToD, invert, splitByClosure, fillsOpenSubpaths, rotateAboutPoint, hasOpenSubpath, worldCoords, splitSubpaths, joinPaths } from '@weasel-js/geom';
2
2
  export { pathToD as serializePathD } from '@weasel-js/geom';
3
- import { isTileKind, boundsOfPath, transformPath, isSolidPaint, warmFonts, warmPaintKinds, isPaintKindKnown, solid, fillToBoundsFrame, getMarker, registerMarker, dwarn, unionAABB, createInsertOp, fillInPoseFrame, resolveStroke, pathThroughPoseFrame, resolveFill, pathInPoseFrame, poseLinearOf, poseFrameOf, INHERIT, clipOutline, resolveScreenLength, pathFromD, isPatternPaint, resolveTextStyle, strokeToPath, toRuns, resolveRunFace, DEFAULT_TEXT_FILL, parseColor, rgbaToHex, layoutRuns, resolveRuns, resolveAlign, PathBuilder, PATH_M, PATH_L, PATH_Z, scaleVertexWidth, getPaintKind, resolveMarkerSize, paragraphDirection, scriptMetricsFor, vertexHalfWidths, isTilePaint, tileSpecOf } from '@weasel-js/core';
3
+ import { isTileKind, boundsOfPath, transformPath, isSolidPaint, warmFonts, warmPaintKinds, isPaintKindKnown, solid, fillToBoundsFrame, getMarker, registerMarker, dwarn, unionAABB, localizeWorldPoses, createInsertOp, fillInPoseFrame, resolveStroke, pathThroughPoseFrame, resolveFill, pathInPoseFrame, poseLinearOf, poseFrameOf, definesFrame, INHERIT, clipOutline, resolveScreenLength, pathFromD, isPatternPaint, resolveTextStyle, halfLeadingFor, numericWeight, strokeToPath, booleanGroupFields, toRuns, resolveRunFace, derivedDepOf, documentOf, booleanOpOf, DEFAULT_TEXT_FILL, parseColor, rgbaToHex, layoutRuns, resolveRuns, resolveAlign, PathBuilder, PATH_M, PATH_L, PATH_Z, scaleVertexWidth, getPaintKind, resolveMarkerSize, paragraphDirection, scriptMetricsFor, vertexHalfWidths, isImagePatternPaint, isTilePaint, tileSpecOf } from '@weasel-js/core';
4
4
  import { tileGeometry } from '@weasel-js/core/patterns-builtin';
5
5
 
6
6
  // src/types.ts
@@ -1296,9 +1296,153 @@ function collectElementsByTag(root, tags) {
1296
1296
  for (let i = 0; i < all.length; i++) consider(all[i]);
1297
1297
  return out;
1298
1298
  }
1299
+ var near = (v, target) => Math.abs(v - target) <= 1e-9;
1300
+ function placeBox(box, m, stretch) {
1301
+ const [a, b, c, d] = m;
1302
+ const scale = Math.sqrt(Math.abs(a * d - b * c));
1303
+ const eps = 1e-4 * Math.hypot(a, b, c, d);
1304
+ const uniform = Math.abs(a - d) <= eps;
1305
+ if (Math.abs(b) <= eps && Math.abs(c) <= eps && (stretch || uniform && a > 0)) {
1306
+ const p0 = applyMatrix(m, box.x, box.y);
1307
+ const p1 = applyMatrix(m, box.x + box.width, box.y + box.height);
1308
+ return {
1309
+ x: Math.min(p0.x, p1.x),
1310
+ y: Math.min(p0.y, p1.y),
1311
+ width: Math.abs(p1.x - p0.x),
1312
+ height: Math.abs(p1.y - p0.y),
1313
+ rotation: 0,
1314
+ scale,
1315
+ flipX: a < 0,
1316
+ flipY: d < 0
1317
+ };
1318
+ }
1319
+ const p = Math.hypot(a, b);
1320
+ const q = (a * c + b * d) / p;
1321
+ const r = (a * d - b * c) / p;
1322
+ const settle = (v) => Math.abs(v - 1) <= 1e-5 ? 1 : v;
1323
+ const sx = settle(stretch ? p : scale);
1324
+ const sy = settle(stretch ? Math.abs(r) : scale);
1325
+ const flipY = stretch && r < 0;
1326
+ const ry = flipY ? -sy : sy;
1327
+ const linear = [p / sx, 0, q / ry, r / ry];
1328
+ const center = applyMatrix(m, box.x + box.width / 2, box.y + box.height / 2);
1329
+ const width2 = box.width * sx;
1330
+ const height2 = box.height * sy;
1331
+ const placed = {
1332
+ x: center.x - width2 / 2,
1333
+ y: center.y - height2 / 2,
1334
+ width: width2,
1335
+ height: height2,
1336
+ rotation: Math.atan2(b, a),
1337
+ scale: settle(scale),
1338
+ flipX: false,
1339
+ flipY
1340
+ };
1341
+ if (!near(linear[0], 1) || !near(linear[2], 0) || !near(linear[3], 1)) placed.transform = linear;
1342
+ return placed;
1343
+ }
1344
+ function placeNode(node, tag, m, stretch, onWarn) {
1345
+ if (isIdentity(m)) return 1;
1346
+ const cx = node.x + node.width / 2;
1347
+ const cy = node.y + node.height / 2;
1348
+ if (!invert(m)) {
1349
+ onWarn(`<${tag}> sits under a singular transform; kept its size`);
1350
+ const center = applyMatrix(m, cx, cy);
1351
+ node.x = center.x - node.width / 2;
1352
+ node.y = center.y - node.height / 2;
1353
+ return 1;
1354
+ }
1355
+ const turn = decomposeRotation(m, cx, cy);
1356
+ if (turn != null) {
1357
+ node.rotation = turn;
1358
+ return 1;
1359
+ }
1360
+ const placed = placeBox(node, m, stretch);
1361
+ node.x = placed.x;
1362
+ node.y = placed.y;
1363
+ node.width = placed.width;
1364
+ node.height = placed.height;
1365
+ if (Math.abs(placed.rotation) > 1e-9) node.rotation = placed.rotation;
1366
+ if (placed.flipX) node.flipX = !node.flipX;
1367
+ if (placed.flipY) node.flipY = !node.flipY;
1368
+ if (placed.transform) node.transform = placed.transform;
1369
+ return placed.scale;
1370
+ }
1371
+ function boxTransformAttr(node) {
1372
+ if (poseLinearOf(node)) {
1373
+ const frame = poseFrameOf(node);
1374
+ const m = frame && formatMatrix(frame);
1375
+ return m ? `transform="${m}"` : null;
1376
+ }
1377
+ if (!node.rotation) return null;
1378
+ const cx = node.x + node.width / 2;
1379
+ const cy = node.y + node.height / 2;
1380
+ const deg = node.rotation * 180 / Math.PI;
1381
+ return `transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`;
1382
+ }
1383
+
1384
+ // src/viewBox.ts
1385
+ function parseViewBoxAttr(raw) {
1386
+ if (raw == null) return void 0;
1387
+ const parts = raw.trim().split(/[\s,]+/).filter(Boolean);
1388
+ if (parts.length !== 4) return void 0;
1389
+ const nums = parts.map(parseFloat);
1390
+ if (nums.some((n) => !Number.isFinite(n))) return void 0;
1391
+ return { x: nums[0], y: nums[1], width: nums[2], height: nums[3] };
1392
+ }
1393
+
1394
+ // src/readImage.ts
1395
+ var XLINK_NS = "http://www.w3.org/1999/xlink";
1396
+ function parseImageElement(el, ctm, onWarn) {
1397
+ const href = imageHref(el);
1398
+ if (!href) {
1399
+ onWarn("<image> without href; dropped");
1400
+ return null;
1401
+ }
1402
+ const node = imageNodeAt(el, el, href, ctm, onWarn);
1403
+ if (el.hasAttribute("preserveAspectRatio") && el.getAttribute("preserveAspectRatio") !== "none") {
1404
+ onWarn("<image> preserveAspectRatio is not modeled; the box is taken literally");
1405
+ }
1406
+ return node;
1407
+ }
1408
+ function imageHref(el) {
1409
+ return el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS, "href") ?? el.getAttribute("xlink:href");
1410
+ }
1411
+ function imageNodeAt(boxEl, ownerEl, href, ctm, onWarn) {
1412
+ const rawX = readLength(boxEl, "x", "x", 0, onWarn);
1413
+ const rawY = readLength(boxEl, "y", "y", 0, onWarn);
1414
+ const rawW = readLength(boxEl, "width", "x", 0, onWarn);
1415
+ const rawH = readLength(boxEl, "height", "y", 0, onWarn);
1416
+ const node = { kind: "image", href, x: rawX, y: rawY, width: rawW, height: rawH };
1417
+ const opacity = ownValue(ownerEl, "opacity");
1418
+ if (opacity != null) node.opacity = opacity;
1419
+ const localTransform = parseTransform(ownerEl.getAttribute("transform"), onWarn);
1420
+ placeNode(node, "image", multiply(ctm, localTransform), true, onWarn);
1421
+ return node;
1422
+ }
1423
+ function parseCroppedImage(g, ctm, onWarn) {
1424
+ const viewport = [...g.children].find((c) => c.tagName.toLowerCase() === "svg");
1425
+ const img = viewport && [...viewport.children].find((c) => c.tagName.toLowerCase() === "image");
1426
+ const href = img && imageHref(img);
1427
+ const source = viewport && parseViewBoxAttr(viewport.getAttribute("viewBox"));
1428
+ if (!img || !href || !source) return null;
1429
+ const node = imageNodeAt(viewport, g, href, ctm, onWarn);
1430
+ if (source.x !== 0 || source.y !== 0 || source.width !== 1 || source.height !== 1) {
1431
+ node.source = source;
1432
+ }
1433
+ const flip = parseTransform(img.getAttribute("transform"), onWarn);
1434
+ if (flip[0] < 0) toggle(node, "flipX");
1435
+ if (flip[3] < 0) toggle(node, "flipY");
1436
+ return node;
1437
+ }
1438
+ function toggle(node, key) {
1439
+ if (node[key]) delete node[key];
1440
+ else node[key] = true;
1441
+ }
1299
1442
 
1300
1443
  // src/patterns.ts
1301
1444
  function patternXml(id, paint, onWarn) {
1445
+ if (isImagePatternPaint(paint)) return imagePatternXml(id, paint, onWarn);
1302
1446
  if (!isTilePaint(paint)) {
1303
1447
  onWarn?.("texture fill carries a TextureHandle, which has no vector form \u2014 omitted from <defs>");
1304
1448
  return "";
@@ -1306,12 +1450,30 @@ function patternXml(id, paint, onWarn) {
1306
1450
  const spec = tileSpecOf(paint);
1307
1451
  const geometry = tileGeometry(spec);
1308
1452
  const body = geometry.shapes.map(shapeXml).join("");
1453
+ const attrs = [
1454
+ ...patternBoxAttrs(id, geometry.size, geometry.size, paint),
1455
+ `data-weasel-tile="${escapeAttr(JSON.stringify(spec))}"`
1456
+ ];
1457
+ return `<pattern ${attrs.join(" ")}>${body}</pattern>`;
1458
+ }
1459
+ function imagePatternXml(id, paint, onWarn) {
1460
+ const { width: width2, height: height2 } = paint;
1461
+ if (!(width2 > 0) || !(height2 > 0)) {
1462
+ onWarn?.("image fill names no tile width and height, which an SVG pattern needs \u2014 omitted from <defs>");
1463
+ return "";
1464
+ }
1465
+ const w = trimNumber(width2);
1466
+ const h = trimNumber(height2);
1467
+ const body = `<image href="${escapeAttr(paint.src)}" width="${w}" height="${h}" preserveAspectRatio="none"/>`;
1468
+ return `<pattern ${patternBoxAttrs(id, width2, height2, paint).join(" ")}>${body}</pattern>`;
1469
+ }
1470
+ function patternBoxAttrs(id, width2, height2, paint) {
1309
1471
  const origin = paint.origin ?? { x: 0, y: 0 };
1310
1472
  const attrs = [
1311
1473
  `id="${id}"`,
1312
1474
  'patternUnits="userSpaceOnUse"',
1313
- `width="${trimNumber(geometry.size)}"`,
1314
- `height="${trimNumber(geometry.size)}"`
1475
+ `width="${trimNumber(width2)}"`,
1476
+ `height="${trimNumber(height2)}"`
1315
1477
  ];
1316
1478
  const [a, b, c, d] = paint.transform ?? [1, 0, 0, 1];
1317
1479
  const placement = formatMatrix([a, b, c, d, origin.x, origin.y]);
@@ -1320,8 +1482,7 @@ function patternXml(id, paint, onWarn) {
1320
1482
  } else if (origin.x !== 0 || origin.y !== 0) {
1321
1483
  attrs.push(`x="${trimNumber(origin.x)}"`, `y="${trimNumber(origin.y)}"`);
1322
1484
  }
1323
- attrs.push(`data-weasel-tile="${escapeAttr(JSON.stringify(spec))}"`);
1324
- return `<pattern ${attrs.join(" ")}>${body}</pattern>`;
1485
+ return attrs;
1325
1486
  }
1326
1487
  function tilePreviewSvg(spec, background) {
1327
1488
  const geometry = tileGeometry(spec);
@@ -1360,6 +1521,8 @@ function collectPatterns(svg, onWarn) {
1360
1521
  var PATTERN_TAGS = /* @__PURE__ */ new Set(["pattern"]);
1361
1522
  function readPattern(el, onWarn) {
1362
1523
  const raw = el.getAttribute("data-weasel-tile");
1524
+ const image = raw ? null : readImagePattern(el);
1525
+ if (image) return image;
1363
1526
  if (!raw) {
1364
1527
  onWarn?.(`<pattern id="${el.getAttribute("id")}"> has no data-weasel-tile \u2014 unsupported pattern, dropped`);
1365
1528
  return null;
@@ -1375,21 +1538,40 @@ function readPattern(el, onWarn) {
1375
1538
  onWarn?.(`<pattern id="${el.getAttribute("id")}"> data-weasel-tile is not a tile spec \u2014 dropped`);
1376
1539
  return null;
1377
1540
  }
1541
+ return { ...spec, ...readPlacement(el, onWarn) };
1542
+ }
1543
+ function readImagePattern(el) {
1544
+ const children = Array.from(el.children);
1545
+ const img = children[0];
1546
+ if (children.length !== 1 || img.localName !== "image") return null;
1547
+ if (el.getAttribute("patternUnits") !== "userSpaceOnUse") return null;
1548
+ if (el.hasAttribute("viewBox") || el.hasAttribute("patternContentUnits")) return null;
1549
+ const src = imageHref(img);
1550
+ const width2 = parseFloat(el.getAttribute("width") ?? "");
1551
+ const height2 = parseFloat(el.getAttribute("height") ?? "");
1552
+ if (!src || !(width2 > 0) || !(height2 > 0)) return null;
1553
+ const at = (name, fallback) => parseFloat(img.getAttribute(name) ?? String(fallback));
1554
+ if (at("x", 0) !== 0 || at("y", 0) !== 0 || at("width", width2) !== width2 || at("height", height2) !== height2) {
1555
+ return null;
1556
+ }
1557
+ return { kind: "image", src, width: width2, height: height2, ...readPlacement(el) };
1558
+ }
1559
+ function readPlacement(el, onWarn) {
1378
1560
  const x = parseFloat(el.getAttribute("x") ?? "0");
1379
1561
  const y = parseFloat(el.getAttribute("y") ?? "0");
1380
1562
  const m = parseTransform(el.getAttribute("patternTransform"), onWarn);
1381
1563
  const origin = applyMatrix(m, x, y);
1382
- const paint = { ...spec };
1383
- if (origin.x !== 0 || origin.y !== 0) paint.origin = origin;
1384
- if (!isIdentity([m[0], m[1], m[2], m[3], 0, 0])) paint.transform = [m[0], m[1], m[2], m[3]];
1385
- return paint;
1564
+ const placement = {};
1565
+ if (origin.x !== 0 || origin.y !== 0) placement.origin = origin;
1566
+ if (!isIdentity([m[0], m[1], m[2], m[3], 0, 0])) placement.transform = [m[0], m[1], m[2], m[3]];
1567
+ return placement;
1386
1568
  }
1387
1569
 
1388
1570
  // src/gradients.ts
1389
1571
  var WEASEL_NS = "urn:weasel-js:svg";
1390
1572
  var WEASEL_NS_PREFIX = "wzl";
1391
1573
  function nativeSvgKind(kind) {
1392
- return kind === "solid" || kind === "linear-gradient" || kind === "radial-gradient" || kind === "texture" || isTileKind(kind);
1574
+ return kind === "solid" || kind === "linear-gradient" || kind === "radial-gradient" || kind === "texture" || kind === "image" || isTileKind(kind);
1393
1575
  }
1394
1576
  function hasNativeSvgForm(paint) {
1395
1577
  return nativeSvgKind(paint.kind ?? "solid");
@@ -1423,7 +1605,7 @@ var GRADIENT_TAGS = /* @__PURE__ */ new Set([
1423
1605
  "meshgradient",
1424
1606
  `${WEASEL_NS_PREFIX}:meshgradient`
1425
1607
  ]);
1426
- var XLINK_NS = "http://www.w3.org/1999/xlink";
1608
+ var XLINK_NS2 = "http://www.w3.org/1999/xlink";
1427
1609
  function collectGradients(svg, onWarn) {
1428
1610
  const out = /* @__PURE__ */ new Map();
1429
1611
  const elements = collectElementsByTag(svg, GRADIENT_TAGS);
@@ -1450,7 +1632,7 @@ function warnUnsupportedDefsChildren(svg, onWarn) {
1450
1632
  }
1451
1633
  }
1452
1634
  function hrefTarget(el, elements) {
1453
- const raw = el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS, "href") ?? el.getAttribute("xlink:href");
1635
+ const raw = el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS2, "href") ?? el.getAttribute("xlink:href");
1454
1636
  if (!raw || !raw.startsWith("#")) return null;
1455
1637
  return elements.get(raw.slice(1)) ?? null;
1456
1638
  }
@@ -1774,7 +1956,7 @@ function rectElementToPath(x, y, width2, height2, rx, ry, m = IDENTITY_MATRIX) {
1774
1956
  const k = KAPPA * rad;
1775
1957
  const x2 = x + width2;
1776
1958
  const y2 = y + height2;
1777
- const b = new PathBuilder();
1959
+ const b = new PathBuilder().autoOrigin();
1778
1960
  b.moveTo(x + rad, y);
1779
1961
  b.lineTo(x2 - rad, y);
1780
1962
  b.curveTo(x2 - rad + k, y, x2, y + rad - k, x2, y + rad);
@@ -1790,7 +1972,7 @@ function rectElementToPath(x, y, width2, height2, rx, ry, m = IDENTITY_MATRIX) {
1790
1972
  function ellipseToPath(cx, cy, rx, ry, m = IDENTITY_MATRIX) {
1791
1973
  const kx = KAPPA * rx;
1792
1974
  const ky = KAPPA * ry;
1793
- const b = new PathBuilder();
1975
+ const b = new PathBuilder().autoOrigin();
1794
1976
  b.moveTo(cx + rx, cy);
1795
1977
  b.curveTo(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
1796
1978
  b.curveTo(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
@@ -1803,7 +1985,7 @@ function circleToPath(cx, cy, r, m = IDENTITY_MATRIX) {
1803
1985
  return ellipseToPath(cx, cy, r, r, m);
1804
1986
  }
1805
1987
  function lineToPath(x1, y1, x2, y2, m = IDENTITY_MATRIX) {
1806
- const b = new PathBuilder();
1988
+ const b = new PathBuilder().autoOrigin();
1807
1989
  b.moveTo(x1, y1);
1808
1990
  b.lineTo(x2, y2);
1809
1991
  return transformPath(b.build(), m);
@@ -1821,7 +2003,7 @@ function polylineToPath(points, m = IDENTITY_MATRIX) {
1821
2003
  if (points.length === 0) {
1822
2004
  return { kind: "polygon", commands: new Uint8Array(), coords: new Float32Array(), fillRule: "nonzero" };
1823
2005
  }
1824
- const b = new PathBuilder();
2006
+ const b = new PathBuilder().autoOrigin();
1825
2007
  b.moveTo(points[0].x, points[0].y);
1826
2008
  for (let i = 1; i < points.length; i++) b.lineTo(points[i].x, points[i].y);
1827
2009
  return transformPath(b.build(), m);
@@ -1830,7 +2012,7 @@ function polygonToPath(points, m = IDENTITY_MATRIX) {
1830
2012
  if (points.length === 0) {
1831
2013
  return { kind: "polygon", commands: new Uint8Array(), coords: new Float32Array(), fillRule: "nonzero" };
1832
2014
  }
1833
- const b = new PathBuilder();
2015
+ const b = new PathBuilder().autoOrigin();
1834
2016
  b.moveTo(points[0].x, points[0].y);
1835
2017
  for (let i = 1; i < points.length; i++) b.lineTo(points[i].x, points[i].y);
1836
2018
  b.close();
@@ -1894,7 +2076,7 @@ function polygonToRectIfAxisAligned(p) {
1894
2076
  if (cmds[0] !== PATH_M) return null;
1895
2077
  if (cmds[1] !== PATH_L || cmds[2] !== PATH_L || cmds[3] !== PATH_L) return null;
1896
2078
  if (cmds[4] !== PATH_Z) return null;
1897
- const c = p.coords;
2079
+ const c = worldCoords(p);
1898
2080
  if (c.length !== 8) return null;
1899
2081
  const x0 = c[0], y0 = c[1];
1900
2082
  const x1 = c[2], y1 = c[3];
@@ -1908,16 +2090,6 @@ function polygonToRectIfAxisAligned(p) {
1908
2090
  if (width2 === 0 || height2 === 0) return null;
1909
2091
  return { kind: "rect", x, y, width: width2, height: height2 };
1910
2092
  }
1911
-
1912
- // src/viewBox.ts
1913
- function parseViewBoxAttr(raw) {
1914
- if (raw == null) return void 0;
1915
- const parts = raw.trim().split(/[\s,]+/).filter(Boolean);
1916
- if (parts.length !== 4) return void 0;
1917
- const nums = parts.map(parseFloat);
1918
- if (nums.some((n) => !Number.isFinite(n))) return void 0;
1919
- return { x: nums[0], y: nums[1], width: nums[2], height: nums[3] };
1920
- }
1921
2093
  function collectClipPaths(root, onWarn) {
1922
2094
  const out = /* @__PURE__ */ new Map();
1923
2095
  for (const [id, el] of collectElementsByTag(root, /* @__PURE__ */ new Set(["clippath"]))) {
@@ -2157,139 +2329,6 @@ function withVariableWidth(el, stroke) {
2157
2329
  }
2158
2330
  return out;
2159
2331
  }
2160
- var near = (v, target) => Math.abs(v - target) <= 1e-9;
2161
- function placeBox(box, m, stretch) {
2162
- const [a, b, c, d] = m;
2163
- const scale = Math.sqrt(Math.abs(a * d - b * c));
2164
- const eps = 1e-4 * Math.hypot(a, b, c, d);
2165
- const uniform = Math.abs(a - d) <= eps;
2166
- if (Math.abs(b) <= eps && Math.abs(c) <= eps && (stretch || uniform && a > 0)) {
2167
- const p0 = applyMatrix(m, box.x, box.y);
2168
- const p1 = applyMatrix(m, box.x + box.width, box.y + box.height);
2169
- return {
2170
- x: Math.min(p0.x, p1.x),
2171
- y: Math.min(p0.y, p1.y),
2172
- width: Math.abs(p1.x - p0.x),
2173
- height: Math.abs(p1.y - p0.y),
2174
- rotation: 0,
2175
- scale,
2176
- flipX: a < 0,
2177
- flipY: d < 0
2178
- };
2179
- }
2180
- const p = Math.hypot(a, b);
2181
- const q = (a * c + b * d) / p;
2182
- const r = (a * d - b * c) / p;
2183
- const settle = (v) => Math.abs(v - 1) <= 1e-5 ? 1 : v;
2184
- const sx = settle(stretch ? p : scale);
2185
- const sy = settle(stretch ? Math.abs(r) : scale);
2186
- const flipY = stretch && r < 0;
2187
- const ry = flipY ? -sy : sy;
2188
- const linear = [p / sx, 0, q / ry, r / ry];
2189
- const center = applyMatrix(m, box.x + box.width / 2, box.y + box.height / 2);
2190
- const width2 = box.width * sx;
2191
- const height2 = box.height * sy;
2192
- const placed = {
2193
- x: center.x - width2 / 2,
2194
- y: center.y - height2 / 2,
2195
- width: width2,
2196
- height: height2,
2197
- rotation: Math.atan2(b, a),
2198
- scale: settle(scale),
2199
- flipX: false,
2200
- flipY
2201
- };
2202
- if (!near(linear[0], 1) || !near(linear[2], 0) || !near(linear[3], 1)) placed.transform = linear;
2203
- return placed;
2204
- }
2205
- function placeNode(node, tag, m, stretch, onWarn) {
2206
- if (isIdentity(m)) return 1;
2207
- const cx = node.x + node.width / 2;
2208
- const cy = node.y + node.height / 2;
2209
- if (!invert(m)) {
2210
- onWarn(`<${tag}> sits under a singular transform; kept its size`);
2211
- const center = applyMatrix(m, cx, cy);
2212
- node.x = center.x - node.width / 2;
2213
- node.y = center.y - node.height / 2;
2214
- return 1;
2215
- }
2216
- const turn = decomposeRotation(m, cx, cy);
2217
- if (turn != null) {
2218
- node.rotation = turn;
2219
- return 1;
2220
- }
2221
- const placed = placeBox(node, m, stretch);
2222
- node.x = placed.x;
2223
- node.y = placed.y;
2224
- node.width = placed.width;
2225
- node.height = placed.height;
2226
- if (Math.abs(placed.rotation) > 1e-9) node.rotation = placed.rotation;
2227
- if (placed.flipX) node.flipX = !node.flipX;
2228
- if (placed.flipY) node.flipY = !node.flipY;
2229
- if (placed.transform) node.transform = placed.transform;
2230
- return placed.scale;
2231
- }
2232
- function boxTransformAttr(node) {
2233
- if (poseLinearOf(node)) {
2234
- const frame = poseFrameOf(node);
2235
- const m = frame && formatMatrix(frame);
2236
- return m ? `transform="${m}"` : null;
2237
- }
2238
- if (!node.rotation) return null;
2239
- const cx = node.x + node.width / 2;
2240
- const cy = node.y + node.height / 2;
2241
- const deg = node.rotation * 180 / Math.PI;
2242
- return `transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`;
2243
- }
2244
-
2245
- // src/readImage.ts
2246
- var XLINK_NS2 = "http://www.w3.org/1999/xlink";
2247
- function parseImageElement(el, ctm, onWarn) {
2248
- const href = imageHref(el);
2249
- if (!href) {
2250
- onWarn("<image> without href; dropped");
2251
- return null;
2252
- }
2253
- const node = imageNodeAt(el, el, href, ctm, onWarn);
2254
- if (el.hasAttribute("preserveAspectRatio") && el.getAttribute("preserveAspectRatio") !== "none") {
2255
- onWarn("<image> preserveAspectRatio is not modeled; the box is taken literally");
2256
- }
2257
- return node;
2258
- }
2259
- function imageHref(el) {
2260
- return el.getAttribute("href") ?? el.getAttributeNS(XLINK_NS2, "href") ?? el.getAttribute("xlink:href");
2261
- }
2262
- function imageNodeAt(boxEl, ownerEl, href, ctm, onWarn) {
2263
- const rawX = readLength(boxEl, "x", "x", 0, onWarn);
2264
- const rawY = readLength(boxEl, "y", "y", 0, onWarn);
2265
- const rawW = readLength(boxEl, "width", "x", 0, onWarn);
2266
- const rawH = readLength(boxEl, "height", "y", 0, onWarn);
2267
- const node = { kind: "image", href, x: rawX, y: rawY, width: rawW, height: rawH };
2268
- const opacity = ownValue(ownerEl, "opacity");
2269
- if (opacity != null) node.opacity = opacity;
2270
- const localTransform = parseTransform(ownerEl.getAttribute("transform"), onWarn);
2271
- placeNode(node, "image", multiply(ctm, localTransform), true, onWarn);
2272
- return node;
2273
- }
2274
- function parseCroppedImage(g, ctm, onWarn) {
2275
- const viewport = [...g.children].find((c) => c.tagName.toLowerCase() === "svg");
2276
- const img = viewport && [...viewport.children].find((c) => c.tagName.toLowerCase() === "image");
2277
- const href = img && imageHref(img);
2278
- const source = viewport && parseViewBoxAttr(viewport.getAttribute("viewBox"));
2279
- if (!img || !href || !source) return null;
2280
- const node = imageNodeAt(viewport, g, href, ctm, onWarn);
2281
- if (source.x !== 0 || source.y !== 0 || source.width !== 1 || source.height !== 1) {
2282
- node.source = source;
2283
- }
2284
- const flip = parseTransform(img.getAttribute("transform"), onWarn);
2285
- if (flip[0] < 0) toggle(node, "flipX");
2286
- if (flip[3] < 0) toggle(node, "flipY");
2287
- return node;
2288
- }
2289
- function toggle(node, key) {
2290
- if (node[key]) delete node[key];
2291
- else node[key] = true;
2292
- }
2293
2332
  function svgDirection(style, text) {
2294
2333
  return paragraphDirection(text, style?.direction ?? "auto");
2295
2334
  }
@@ -2301,6 +2340,7 @@ function anchorOffset(style, width2, text) {
2301
2340
  // src/tspanLines.ts
2302
2341
  var LINES_ATTR = "data-weasel-lines";
2303
2342
  var LINE_HEIGHT_ATTR = "data-weasel-line-height";
2343
+ var writtenByWeasel = (text) => text.hasAttribute("data-weasel-width");
2304
2344
  var statesLines = (tspan) => tspan.hasAttribute(LINES_ATTR);
2305
2345
  function linesXml(pieces, x, pitch) {
2306
2346
  let out = "";
@@ -2329,25 +2369,55 @@ var DEFAULT_LINE_HEIGHT = 1.2;
2329
2369
  var LineTracker = class {
2330
2370
  constructor(text, onWarn) {
2331
2371
  this.onWarn = onWarn;
2332
- this.y = text.getAttribute("y");
2333
- this.at = readLength(text, "y", "y", 0, onWarn);
2372
+ this.at = this.lineY = readLength(text, "y", "y", 0, onWarn);
2334
2373
  }
2335
2374
  onWarn;
2336
- y;
2337
- /** Where the current line sits, in user units. */
2375
+ /** The current line's baseline, in user units. */
2376
+ lineY;
2377
+ /** Where the pen is, in user units. */
2338
2378
  at;
2339
2379
  pitches = [];
2380
+ /** The first line's baseline, once a glyph has landed on it. */
2381
+ first;
2382
+ /** Whether the text opens with blank lines it states, below the text's `y`. */
2383
+ keepsTop = false;
2384
+ /** How far the pen sits above the current line's baseline, in user units. */
2385
+ get rise() {
2386
+ return this.lineY - this.at;
2387
+ }
2388
+ /** Move the pen to `y`, when given, and then down by `dy`. */
2389
+ move(y, dy) {
2390
+ this.at = (y ?? this.at) + dy;
2391
+ }
2392
+ /** Say that `glyph` sits at the pen. The first glyph that is not white
2393
+ * space settles the first line where it sits, wherever the text's `y` was:
2394
+ * a line is placed by its glyphs. */
2395
+ land(glyph) {
2396
+ if (this.first !== void 0 || /^\s$/u.test(glyph)) return;
2397
+ if (!this.keepsTop) this.lineY = this.at;
2398
+ this.first = this.lineY;
2399
+ }
2400
+ /** The baseline the box's first line sits on: the text's `y` where the text
2401
+ * opens with blank lines it states, otherwise where the first glyph landed.
2402
+ * `undefined` before any glyph has. */
2403
+ get firstBaseline() {
2404
+ return this.keepsTop ? void 0 : this.first;
2405
+ }
2340
2406
  /** How many lines down `tspan` opens a new line, or 0 when it stays on the
2341
- * current one. Call once per tspan, in order. `afterText` is whether any
2342
- * text precedes it: the first line's own placement is not a pitch. */
2343
- startsLine(tspan, afterText) {
2344
- const y = tspan.getAttribute("y");
2345
- const movedY = y != null && y !== this.y;
2346
- if (y != null) this.y = y;
2347
- const from = this.at;
2348
- const dy = readLength(tspan, "dy", "y", 0, this.onWarn);
2349
- this.at = (y != null ? readLength(tspan, "y", "y", from, this.onWarn) : from) + dy;
2350
- if (!tspan.hasAttribute("x") || !(movedY || dy !== 0)) return 0;
2407
+ * current one. A tspan opens one when it has its own `x` and puts the pen
2408
+ * off the line's baseline; without an `x` it only moves its glyphs off the
2409
+ * line. `inherited` is the enclosing `<text>`'s `y` and `dy` for the
2410
+ * tspan's first glyph, used where the tspan has none of its own. Call once
2411
+ * per tspan, in order. `afterText` is whether any text precedes it: the
2412
+ * first line's own placement is not a pitch. */
2413
+ startsLine(tspan, afterText, inherited2 = {}) {
2414
+ const from = this.lineY;
2415
+ const y = tspan.hasAttribute("y") ? readLength(tspan, "y", "y", this.at, this.onWarn) : inherited2.y;
2416
+ const dy = tspan.hasAttribute("dy") ? readLength(tspan, "dy", "y", 0, this.onWarn) : inherited2.dy ?? 0;
2417
+ this.move(y, dy);
2418
+ if (!afterText && this.first === void 0 && statesLines(tspan)) this.keepsTop = true;
2419
+ if (!tspan.hasAttribute("x") || Math.abs(this.at - from) < 1e-9) return 0;
2420
+ this.lineY = this.at;
2351
2421
  const stated = Number(tspan.getAttribute(LINES_ATTR) ?? NaN);
2352
2422
  const lines = Number.isInteger(stated) && stated >= 0 ? stated : 1;
2353
2423
  if (afterText && lines > 0 && this.at > from) this.pitches.push((this.at - from) / lines);
@@ -2355,17 +2425,18 @@ var LineTracker = class {
2355
2425
  }
2356
2426
  /**
2357
2427
  * The line height the lines were pitched at, as a multiple of `fontSize`:
2358
- * `undefined` for a single line, or for lines at the default pitch. The
2359
- * model holds one line height, so uneven pitches give their mean, which
2360
- * keeps the block's height.
2428
+ * `stated` for a single line or for lines pitched as it says, otherwise
2429
+ * `undefined` at the default pitch. The model holds one line height, so
2430
+ * uneven pitches give their mean, which keeps the block's height.
2361
2431
  */
2362
- lineHeight(fontSize) {
2432
+ lineHeight(fontSize, stated) {
2363
2433
  const { pitches } = this;
2364
- if (pitches.length === 0 || !(fontSize > 0)) return void 0;
2434
+ if (pitches.length === 0 || !(fontSize > 0)) return stated;
2365
2435
  const mean = pitches.reduce((sum, p) => sum + p, 0) / pitches.length;
2366
2436
  if (pitches.some((p) => Math.abs(p - mean) > 1e-3 * fontSize)) {
2367
2437
  this.onWarn("<text> lines are pitched unevenly; they take the mean pitch as one line height");
2368
2438
  }
2439
+ if (stated !== void 0 && Math.abs(mean - stated * fontSize) <= 1e-3 * fontSize) return stated;
2369
2440
  const lineHeight = Math.round(mean / fontSize * 1e6) / 1e6;
2370
2441
  return Math.abs(lineHeight - DEFAULT_LINE_HEIGHT) < 1e-6 ? void 0 : lineHeight;
2371
2442
  }
@@ -2374,19 +2445,6 @@ var holdsOnlyText = (el) => !Array.from(el.childNodes).some(
2374
2445
  (child) => child.nodeType === 1
2375
2446
  /* ELEMENT_NODE */
2376
2447
  );
2377
- function warnUnreadPositions(el, onWarn) {
2378
- const tag = el.tagName.toLowerCase();
2379
- for (const name of ["x", "y"]) {
2380
- const raw = el.getAttribute(name)?.trim() ?? "";
2381
- if (/[\s,]/.test(raw)) onWarn(`<${tag} ${name}="${raw}"> positions each glyph; only the first value is read`);
2382
- }
2383
- for (const name of tag === "tspan" || holdsOnlyText(el) ? ["rotate"] : ["dx", "rotate"]) {
2384
- const raw = el.getAttribute(name)?.trim() ?? "";
2385
- if (raw.split(/[\s,]+/).some((v) => parseFloat(v) !== 0 && v !== "")) {
2386
- onWarn(`<${tag} ${name}="${raw}"> is not read; the glyphs keep their own advance and angle`);
2387
- }
2388
- }
2389
- }
2390
2448
  function sameStyle(a, b) {
2391
2449
  const style = (run) => JSON.stringify(
2392
2450
  Object.entries(run).filter(([k, v]) => k !== "text" && v !== void 0).sort(([j], [k]) => j < k ? -1 : 1)
@@ -2478,21 +2536,101 @@ function laidLinesXml(pieces, layout, anchor) {
2478
2536
  out += rangeXml(pieces, offsets, from, to, lead);
2479
2537
  });
2480
2538
  }
2481
- const trailing = breaksIn(text.slice(prevEnd));
2482
- const last = layout.laid.lines[layout.laid.lines.length - 1];
2483
- if (trailing > 0 && last) out += `<tspan ${[px(last.x0), py(last), ...count(trailing)].join(" ")}></tspan>`;
2484
- return out;
2539
+ const trailing = breaksIn(text.slice(prevEnd));
2540
+ const last = layout.laid.lines[layout.laid.lines.length - 1];
2541
+ if (trailing > 0 && last) out += `<tspan ${[px(last.x0), py(last), ...count(trailing)].join(" ")}></tspan>`;
2542
+ return out;
2543
+ }
2544
+
2545
+ // src/textWhitespace.ts
2546
+ var XML_NS = "http://www.w3.org/XML/1998/namespace";
2547
+ function preservesSpace(el) {
2548
+ for (let e = el; e; e = e.parentElement) {
2549
+ const v = e.getAttributeNS(XML_NS, "space") ?? e.getAttribute("xml:space");
2550
+ if (v === "preserve") return true;
2551
+ if (v === "default") return false;
2552
+ }
2553
+ return false;
2554
+ }
2555
+ function collapseRunWhitespace(runs) {
2556
+ let atStart = true;
2557
+ let lastWasSpace = false;
2558
+ for (const run of runs) {
2559
+ let out = "";
2560
+ for (const ch of run.text.replace(/\n/g, "").replace(/[\t\r]/g, " ")) {
2561
+ if (ch === " ") {
2562
+ if (atStart || lastWasSpace) continue;
2563
+ out += " ";
2564
+ lastWasSpace = true;
2565
+ continue;
2566
+ }
2567
+ out += ch;
2568
+ lastWasSpace = false;
2569
+ atStart = false;
2570
+ }
2571
+ run.text = out;
2572
+ }
2573
+ for (let i = runs.length - 1; i >= 0; i--) {
2574
+ if (runs[i].text === "") continue;
2575
+ if (runs[i].text.endsWith(" ")) runs[i].text = runs[i].text.slice(0, -1);
2576
+ break;
2577
+ }
2578
+ for (let i = runs.length - 1; i >= 0; i--) {
2579
+ if (runs[i].text === "") runs.splice(i, 1);
2580
+ }
2485
2581
  }
2582
+
2583
+ // src/glyphShifts.ts
2486
2584
  var COLLAPSIBLE = /^\s|\s$|\s\s|[\n\t\r]/;
2487
- function readDx(el, onWarn) {
2488
- const raw = el.getAttribute("dx")?.trim();
2489
- if (!raw) return null;
2490
- const values = raw.split(/[\s,]+/).map((v) => resolveLength(v, el, "dx", "x", 0, onWarn));
2491
- return values.some((v) => v !== 0) ? values : null;
2585
+ var collapses = (el, text = el.textContent ?? "") => COLLAPSIBLE.test(text) && !preservesSpace(el);
2586
+ function readList(el, name, onWarn) {
2587
+ const raw = el.getAttribute(name)?.trim();
2588
+ if (!raw) return [];
2589
+ const axis = name.endsWith("x") ? "x" : "y";
2590
+ return raw.split(/[\s,]+/).map((v) => resolveLength(v, el, name, axis, 0, onWarn));
2591
+ }
2592
+ function pensOf(line, rtl) {
2593
+ const pens = line.cells.map((c) => c.x);
2594
+ if (rtl) for (const g of line.glyphs) pens[g.cell] = g.x + g.advance;
2595
+ return pens;
2596
+ }
2597
+ function cellsOf(run, style, rtl) {
2598
+ const resolved = resolveTextStyle(style);
2599
+ const { lines } = layoutRuns(resolveRuns([run], resolved), {
2600
+ maxWidth: Infinity,
2601
+ lineHeight: resolved.lineHeight,
2602
+ align: rtl ? "right" : "left",
2603
+ direction: rtl ? "rtl" : "ltr",
2604
+ tabSize: resolved.tabSize
2605
+ });
2606
+ const line = lines[0];
2607
+ if (lines.length !== 1 || !line || line.cells.length !== Array.from(run.text).length) return null;
2608
+ return { cells: line.cells, line, width: line.x1 - line.x0 };
2609
+ }
2610
+ var allRead = (cells, rtl) => cells.every((c) => c.level % 2 === (rtl ? 1 : 0));
2611
+ function penPositions(run, style, rtl) {
2612
+ const laid = cellsOf(run, style, rtl);
2613
+ if (!laid || !(laid.width > 0) || !allRead(laid.cells, rtl)) return null;
2614
+ const pens = pensOf(laid.line, rtl);
2615
+ return pens.map((p) => p - pens[0]);
2616
+ }
2617
+ function foldPositions(lists, run, style, rtl, onWarn) {
2618
+ const { dx, x } = lists;
2619
+ const pens = x.length > 1 ? penPositions(run, style, rtl) : null;
2620
+ if (x.length > 1 && !pens) {
2621
+ onWarn(`<${lists.tag} x="${lists.raw.x?.trim()}"> positions each glyph; only the first value is read`);
2622
+ }
2623
+ const placed = pens ? Math.min(x.length, pens.length) : 0;
2624
+ const out = Array.from({ length: Math.max(dx.length, placed) }, (_, i) => {
2625
+ const own2 = dx[i] ?? 0;
2626
+ if (i === 0 || i >= placed) return own2;
2627
+ return x[i] + own2 - (x[i - 1] + (dx[i - 1] ?? 0)) - (pens[i] - pens[i - 1]);
2628
+ });
2629
+ return out.some((v) => v !== 0) ? out : null;
2492
2630
  }
2493
2631
  var spacingOf = (run, inherited2) => resolveScreenLength(run.letterSpacing ?? inherited2 ?? 0, 1);
2494
- function spread(run, extra, inherited2) {
2495
- const base = spacingOf(run, inherited2);
2632
+ function spread(run, extra, style) {
2633
+ const base = spacingOf(run, style.letterSpacing);
2496
2634
  const out = [];
2497
2635
  let last;
2498
2636
  Array.from(run.text).forEach((glyph, i) => {
@@ -2503,71 +2641,388 @@ function spread(run, extra, inherited2) {
2503
2641
  });
2504
2642
  return out;
2505
2643
  }
2506
- function placeTspanRun(tspan, run, lines, inherited2, onWarn) {
2644
+ function placeRun(lists, run, lines, style, onWarn) {
2507
2645
  const line = lines[lines.length - 1];
2508
- const dx = readDx(tspan, onWarn);
2509
- if (!dx) {
2646
+ const names = ["dx", "x"].filter((n) => n === "dx" ? lists.dx.some((v) => v !== 0) : lists.x.length > 1);
2647
+ const named = `<${lists.tag} ${names.map((n) => `${n}="${lists.raw[n]}"`).join(" ")}>`;
2648
+ if (names.length > 0 && collapses(lists.el, run.text)) {
2649
+ onWarn(`${named} is not read: its text has white space SVG collapses, which moves the glyphs the list names`);
2650
+ line.push(run);
2651
+ return 0;
2652
+ }
2653
+ const rtl = style.direction === "rtl";
2654
+ const shifts = names.length > 0 ? foldPositions(lists, run, style, rtl, onWarn) : null;
2655
+ const laid = shifts && rtl ? cellsOf(run, style, rtl) : null;
2656
+ if (shifts && rtl && !(laid && allRead(laid.cells, rtl))) {
2657
+ onWarn(`${named} is not read: in right-to-left text it is read only where a bidi engine says every glyph reads right to left`);
2510
2658
  line.push(run);
2511
2659
  return 0;
2512
2660
  }
2513
- const raw = tspan.getAttribute("dx");
2514
- const tag = tspan.tagName.toLowerCase();
2515
- if (COLLAPSIBLE.test(run.text)) {
2516
- onWarn(`<${tag} dx="${raw}"> is not read: its text has white space SVG collapses, which moves the glyphs the list names`);
2661
+ if (!shifts) {
2517
2662
  line.push(run);
2518
2663
  return 0;
2519
2664
  }
2665
+ const dx = rtl ? shifts.map((v) => -v) : shifts;
2520
2666
  let lead = 0;
2521
2667
  let before = line.length - 1;
2522
2668
  while (before >= 0 && line[before].text === "") before--;
2523
2669
  if (dx[0] !== 0 && before >= 0) {
2524
2670
  const glyphs = Array.from(line[before].text);
2525
- line.splice(before, 1, ...spread(line[before], [...glyphs.slice(1).map(() => 0), dx[0]], inherited2));
2671
+ line.splice(before, 1, ...spread(line[before], [...glyphs.slice(1).map(() => 0), dx[0]], style));
2526
2672
  } else if (dx[0] !== 0 && lines.length === 1) {
2527
- lead = dx[0];
2673
+ lead = shifts[0];
2528
2674
  } else if (dx[0] !== 0) {
2529
- onWarn(`<${tag} dx="${raw}"> opens a line; the line starts at the box edge, and the rest of the list is read`);
2675
+ onWarn(`${named} opens a line; the line starts at the box edge, and the rest of the list is read`);
2530
2676
  }
2531
- line.push(...spread(run, dx.slice(1), inherited2));
2677
+ line.push(...spread(run, dx.slice(1), style));
2532
2678
  return lead;
2533
2679
  }
2680
+ function placeTspanRun(tspan, run, lines, style, onWarn, inherited2 = { dx: [], raw: null }) {
2681
+ const own2 = inherited2.merged ? [] : readList(tspan, "dx", onWarn);
2682
+ const borrows = own2.length < inherited2.dx.length;
2683
+ const theirs = borrows && own2.length === 0;
2684
+ return placeRun({
2685
+ el: tspan,
2686
+ tag: theirs ? "text" : tspan.tagName.toLowerCase(),
2687
+ dx: borrows ? [...own2, ...inherited2.dx.slice(own2.length)] : own2,
2688
+ x: inherited2.merged ? [] : readList(tspan, "x", onWarn),
2689
+ raw: { dx: theirs ? inherited2.raw : tspan.getAttribute("dx"), x: tspan.getAttribute("x") }
2690
+ }, run, lines, style, onWarn);
2691
+ }
2692
+ function mayOpenLine(tspan, ownX, onWarn) {
2693
+ if (!tspan || ownX.length === 0) return false;
2694
+ return tspan.hasAttribute("y") || (readList(tspan, "dy", onWarn)[0] ?? 0) !== 0;
2695
+ }
2696
+ function measureLine(runs, glyphs, style, rtl) {
2697
+ const resolved = resolveTextStyle(style);
2698
+ const { lines } = layoutRuns(resolveRuns(runs, resolved), {
2699
+ maxWidth: Infinity,
2700
+ lineHeight: resolved.lineHeight,
2701
+ align: rtl ? "right" : "left",
2702
+ direction: rtl ? "rtl" : "ltr",
2703
+ tabSize: resolved.tabSize
2704
+ });
2705
+ const line = lines.length === 1 ? lines[0] : null;
2706
+ if (!line || line.cells.length !== glyphs || !(line.x1 > line.x0)) return "no loaded face measures its glyphs";
2707
+ if (!allRead(line.cells, rtl)) {
2708
+ return "in right-to-left text it is read only where a bidi engine says every glyph reads right to left";
2709
+ }
2710
+ return line;
2711
+ }
2712
+ function foldTextPositions(x, dx, pieces, style, onWarn) {
2713
+ const segments = [];
2714
+ const xs = [];
2715
+ const dxs = [];
2716
+ for (let at2 = 0, k = 0; at2 < x.length && k < pieces.length; k++) {
2717
+ const { tspan, run } = pieces[k];
2718
+ const ownX = tspan ? readList(tspan, "x", onWarn) : [];
2719
+ const ownDx = tspan ? readList(tspan, "dx", onWarn) : [];
2720
+ if (segments.length === 0 || mayOpenLine(tspan, ownX, onWarn)) segments.push({ runs: [], start: xs.length });
2721
+ const n = Array.from(run.text).length;
2722
+ for (let i = 0; i < n; i++) {
2723
+ xs.push(i < ownX.length ? ownX[i] : x[at2 + i]);
2724
+ dxs.push(i < ownDx.length ? ownDx[i] : dx[at2 + i] ?? 0);
2725
+ }
2726
+ segments[segments.length - 1].runs.push(run);
2727
+ at2 += n;
2728
+ }
2729
+ const rtl = style.direction === "rtl";
2730
+ const glyphs = xs.length;
2731
+ const pens = [];
2732
+ for (const [s, { runs, start }] of segments.entries()) {
2733
+ const line = measureLine(runs, (segments[s + 1]?.start ?? glyphs) - start, style, rtl);
2734
+ if (typeof line === "string") return line;
2735
+ pens.push(...pensOf(line, rtl));
2736
+ }
2737
+ const starts = new Set(segments.map((s) => s.start));
2738
+ const at = [];
2739
+ for (let i = 0; i < glyphs; i++) {
2740
+ const own2 = xs[i];
2741
+ at.push(own2 !== void 0 ? own2 + dxs[i] : at[i - 1] + pens[i] - pens[i - 1] + dxs[i]);
2742
+ }
2743
+ const out = Array.from({ length: Math.max(dx.length, glyphs) }, (_, i) => {
2744
+ if (i >= glyphs) return dx[i] ?? 0;
2745
+ if (i === 0) return at[0] - x[0];
2746
+ if (starts.has(i)) return dxs[i];
2747
+ return at[i] - at[i - 1] - (pens[i] - pens[i - 1]);
2748
+ });
2749
+ return { dx: out, reached: glyphs };
2750
+ }
2751
+ var TextShifts = class {
2752
+ constructor(text, onWarn, pieces = [], style = {}) {
2753
+ this.text = text;
2754
+ this.onWarn = onWarn;
2755
+ this.raw = text.getAttribute("dx");
2756
+ const dx = readList(text, "dx", onWarn);
2757
+ const x = readList(text, "x", onWarn);
2758
+ const collapsible = collapses(text);
2759
+ const unread = dx.some((v) => v !== 0) && collapsible;
2760
+ if (unread) {
2761
+ onWarn(`<text dx="${this.raw}"> is not read: its text has white space SVG collapses, which moves the glyphs the list names`);
2762
+ }
2763
+ this.dx = unread ? [] : dx;
2764
+ if (x.length < 2) return;
2765
+ const folded = collapsible ? "its text has white space SVG collapses, which moves the glyphs the list names" : foldTextPositions(x, this.dx, pieces, style, onWarn);
2766
+ if (typeof folded === "string") {
2767
+ onWarn(`<text x="${text.getAttribute("x")?.trim()}"> positions each glyph; only the first value is read: ${folded}`);
2768
+ } else {
2769
+ this.dx = folded.dx;
2770
+ this.merged = folded.reached;
2771
+ }
2772
+ }
2773
+ text;
2774
+ onWarn;
2775
+ dx;
2776
+ raw;
2777
+ at = 0;
2778
+ /** How many glyphs the text's `x` list was merged over with their tspans' own lists. */
2779
+ merged = 0;
2780
+ /** The values over the next `run`'s glyphs, empty when they move none. */
2781
+ take(run) {
2782
+ const n = Array.from(run.text).length;
2783
+ const out = this.dx.slice(this.at, this.at + n);
2784
+ const merged = this.at < this.merged;
2785
+ this.at += n;
2786
+ return { dx: out.some((v) => v !== 0) ? out : [], raw: this.raw, ...merged ? { merged } : {} };
2787
+ }
2788
+ /** Add a bare text run of the `<text>` to `lines`, spaced by its stretch of the list. */
2789
+ place(run, lines, style) {
2790
+ const { dx } = this.take(run);
2791
+ if (dx.length === 0) {
2792
+ lines[lines.length - 1].push(run);
2793
+ return 0;
2794
+ }
2795
+ return placeRun({ el: this.text, tag: "text", dx, x: [], raw: { dx: this.raw, x: null } }, run, lines, style, this.onWarn);
2796
+ }
2797
+ };
2534
2798
 
2535
- // src/textWhitespace.ts
2536
- var XML_NS = "http://www.w3.org/XML/1998/namespace";
2537
- function preservesSpace(el) {
2799
+ // src/glyphTurns.ts
2800
+ function turnsOf(el, onWarn) {
2801
+ const raw = el.getAttribute("rotate")?.trim();
2802
+ if (!raw) return [];
2803
+ const list = raw.split(/[\s,]+/).map(Number);
2804
+ if (list.some((v) => !Number.isFinite(v))) {
2805
+ onWarn(`<${el.tagName.toLowerCase()} rotate="${raw}"> is not read: it is not a list of numbers`);
2806
+ return [];
2807
+ }
2808
+ if (list.some((v) => v !== list[0]) && collapses(el)) {
2809
+ onWarn(`<${el.tagName.toLowerCase()} rotate="${raw}"> is not read: its text has white space SVG collapses, which moves the glyphs the list names`);
2810
+ return [];
2811
+ }
2812
+ return list;
2813
+ }
2814
+ var valueAt = (list, i) => list.length === 0 ? void 0 : list[Math.min(i, list.length - 1)];
2815
+ var TextTurns = class {
2816
+ list;
2817
+ at = 0;
2818
+ constructor(text, onWarn) {
2819
+ this.list = turnsOf(text, onWarn);
2820
+ }
2821
+ /** The turns of the next `n` glyphs, the `own` list of their `<tspan>`
2822
+ * winning where it has one. */
2823
+ take(n, own2 = []) {
2824
+ const out = Array.from({ length: n }, (_, i) => valueAt(own2, i) ?? valueAt(this.list, this.at + i) ?? 0);
2825
+ this.at += n;
2826
+ return out;
2827
+ }
2828
+ };
2829
+ function turnRuns(line, turns) {
2830
+ if (turns.every((t) => t === 0)) return;
2831
+ let start = line.length;
2832
+ let n = turns.length;
2833
+ while (n > 0 && start > 0) n -= Array.from(line[--start].text).length;
2834
+ const out = [];
2835
+ let g = Math.min(n, 0);
2836
+ for (const run of line.slice(start)) {
2837
+ if (run.text === "") {
2838
+ out.push(run);
2839
+ continue;
2840
+ }
2841
+ const { rotate: own2, ...upright } = run;
2842
+ let last;
2843
+ for (const glyph of Array.from(run.text)) {
2844
+ const turn = g < 0 ? own2 ?? 0 : turns[g] ?? 0;
2845
+ g++;
2846
+ if (turn === last) out[out.length - 1].text += glyph;
2847
+ else out.push(turn === 0 ? { ...upright, text: glyph } : { ...upright, text: glyph, rotate: turn });
2848
+ last = turn;
2849
+ }
2850
+ }
2851
+ line.splice(start, line.length - start, ...out);
2852
+ }
2853
+ var NO_MOVES = { y: [], dy: [] };
2854
+ function movesOf(el, onWarn) {
2855
+ const y = readList(el, "y", onWarn);
2856
+ const dy = readList(el, "dy", onWarn);
2857
+ const perGlyph = y.length > 1 || dy.slice(1).some((v) => v !== 0);
2858
+ if (perGlyph && collapses(el)) {
2859
+ const named = ["y", "dy"].filter((n) => el.hasAttribute(n)).map((n) => `${n}="${el.getAttribute(n)}"`);
2860
+ onWarn(`<${el.tagName.toLowerCase()} ${named.join(" ")}> is read only to its first value: its text has white space SVG collapses, which moves the glyphs the list names`);
2861
+ return { y: y.slice(0, 1), dy: dy.slice(0, 1) };
2862
+ }
2863
+ return { y, dy };
2864
+ }
2865
+ var TextMoves = class {
2866
+ moves;
2867
+ at = 0;
2868
+ constructor(text, onWarn) {
2869
+ const { y, dy } = movesOf(text, onWarn);
2870
+ this.moves = { y: [void 0, ...y.slice(1)], dy: [writtenByWeasel(text) ? 0 : dy[0] ?? 0, ...dy.slice(1)] };
2871
+ }
2872
+ /** The values over the next `n` glyphs. */
2873
+ take(n) {
2874
+ const out = { y: this.moves.y.slice(this.at, this.at + n), dy: this.moves.dy.slice(this.at, this.at + n) };
2875
+ this.at += n;
2876
+ return out;
2877
+ }
2878
+ };
2879
+ function risesOver(glyphs, own2, inherited2, tracker, placed) {
2880
+ const out = [];
2881
+ for (let i = 0; i < glyphs.length; i++) {
2882
+ if (i > 0 || !placed) {
2883
+ tracker.move(i < own2.y.length ? own2.y[i] : inherited2.y[i], (i < own2.dy.length ? own2.dy[i] : inherited2.dy[i]) ?? 0);
2884
+ }
2885
+ tracker.land(glyphs[i]);
2886
+ out.push(tracker.rise);
2887
+ }
2888
+ return out;
2889
+ }
2890
+ function ownShift(run, style) {
2891
+ if (run.baselineShift != null) return run.baselineShift;
2892
+ if (!run.script) return 0;
2893
+ const face = resolveRunFace(run, resolveTextStyle(style));
2894
+ return scriptMetricsFor(face.fontFamily, face.fontWeight, face.fontStyle)[run.script].shift;
2895
+ }
2896
+ function raiseRuns(line, rises, fontSize, style) {
2897
+ if (!(fontSize > 0) || rises.every((r) => Math.abs(r) < 1e-9)) return;
2898
+ let start = line.length;
2899
+ for (let n = rises.length; n > 0 && start > 0; ) n -= Array.from(line[--start].text).length;
2900
+ const out = [];
2901
+ let g = 0;
2902
+ for (const run of line.slice(start)) {
2903
+ if (run.text === "") {
2904
+ out.push(run);
2905
+ continue;
2906
+ }
2907
+ let last;
2908
+ for (const glyph of Array.from(run.text)) {
2909
+ const em = Math.round((rises[g++] ?? 0) / fontSize * 1e6) / 1e6;
2910
+ if (em === last) out[out.length - 1].text += glyph;
2911
+ else out.push(em === 0 ? { ...run, text: glyph } : { ...run, text: glyph, baselineShift: ownShift(run, style) + em });
2912
+ last = em;
2913
+ }
2914
+ }
2915
+ line.splice(start, line.length - start, ...out);
2916
+ }
2917
+
2918
+ // src/textLang.ts
2919
+ var XML_NS2 = "http://www.w3.org/XML/1998/namespace";
2920
+ function ownLang(el) {
2921
+ return el.getAttributeNS(XML_NS2, "lang") ?? el.getAttribute("xml:lang") ?? el.getAttribute("lang");
2922
+ }
2923
+ function inheritedLang(el) {
2538
2924
  for (let e = el; e; e = e.parentElement) {
2539
- const v = e.getAttributeNS(XML_NS, "space") ?? e.getAttribute("xml:space");
2540
- if (v === "preserve") return true;
2541
- if (v === "default") return false;
2925
+ const v = ownLang(e);
2926
+ if (v !== null) return v.trim() || void 0;
2542
2927
  }
2543
- return false;
2928
+ return void 0;
2544
2929
  }
2545
- function collapseRunWhitespace(runs) {
2546
- let atStart = true;
2547
- let lastWasSpace = false;
2548
- for (const run of runs) {
2549
- let out = "";
2550
- for (const ch of run.text.replace(/\n/g, "").replace(/[\t\r]/g, " ")) {
2551
- if (ch === " ") {
2552
- if (atStart || lastWasSpace) continue;
2553
- out += " ";
2554
- lastWasSpace = true;
2930
+
2931
+ // src/readRuns.ts
2932
+ var glyphCount = (text) => Array.from(text).length;
2933
+ function readRuns(el, tracker, inheritedShift, textStyle, gradients, leafStyle, onWarn) {
2934
+ const runLines = [[]];
2935
+ const fontSize = resolveScreenLength(textStyle.fontSize ?? 16, 1);
2936
+ const moves = new TextMoves(el, onWarn);
2937
+ const turns = new TextTurns(el, onWarn);
2938
+ const turn = (text, own2) => turnRuns(runLines[runLines.length - 1], turns.take(glyphCount(text), own2));
2939
+ const raise = (text, own2, inherited2, placed) => raiseRuns(runLines[runLines.length - 1], risesOver(Array.from(text), own2, inherited2, tracker, placed), fontSize, textStyle);
2940
+ let leading = 0;
2941
+ let lead = 0;
2942
+ if (holdsOnlyText(el) && (el.hasAttribute("dx") || el.hasAttribute("x"))) {
2943
+ const t = el.textContent ?? "";
2944
+ if (t) {
2945
+ lead = placeTspanRun(el, { text: t, ...inheritedShift }, runLines, textStyle, onWarn);
2946
+ raise(t, NO_MOVES, moves.take(glyphCount(t)), false);
2947
+ turn(t);
2948
+ }
2949
+ } else {
2950
+ const pieces = Array.from(el.childNodes).flatMap((child) => {
2951
+ if (child.nodeType === 1 && child.tagName.toLowerCase() === "tspan") {
2952
+ const sp = child;
2953
+ return [{ tspan: sp, run: readTspanRun(sp, gradients, leafStyle, onWarn) }];
2954
+ }
2955
+ if (child.nodeType === 1) onWarn(`<text> child <${child.tagName}> not supported; flattening text content`);
2956
+ else if (child.nodeType !== 3) return [];
2957
+ const t = child.textContent ?? "";
2958
+ return t ? [{ tspan: null, run: { text: t, ...inheritedShift } }] : [];
2959
+ });
2960
+ const shifts = new TextShifts(el, onWarn, pieces, textStyle);
2961
+ for (const { tspan: sp, run } of pieces) {
2962
+ if (!sp) {
2963
+ lead += shifts.place(run, runLines, textStyle);
2964
+ raise(run.text, NO_MOVES, moves.take(glyphCount(run.text)), false);
2965
+ turn(run.text);
2555
2966
  continue;
2556
2967
  }
2557
- out += ch;
2558
- lastWasSpace = false;
2559
- atStart = false;
2968
+ const inherited2 = moves.take(glyphCount(run.text));
2969
+ const afterText = runLines.some((line) => line.some((r) => r.text.trim() !== ""));
2970
+ const lines = tracker.startsLine(sp, afterText, { y: inherited2.y[0], dy: inherited2.dy[0] });
2971
+ if (runLines.length === 1 && runLines[0].length === 0 && statesLines(sp)) leading = lines;
2972
+ else for (let n = 0; n < lines; n++) runLines.push([]);
2973
+ lead += placeTspanRun(sp, run, runLines, textStyle, onWarn, shifts.take(run));
2974
+ raise(run.text, movesOf(sp, onWarn), inherited2, true);
2975
+ turn(run.text, turnsOf(sp, onWarn));
2560
2976
  }
2561
- run.text = out;
2562
- }
2563
- for (let i = runs.length - 1; i >= 0; i--) {
2564
- if (runs[i].text === "") continue;
2565
- if (runs[i].text.endsWith(" ")) runs[i].text = runs[i].text.slice(0, -1);
2566
- break;
2567
2977
  }
2568
- for (let i = runs.length - 1; i >= 0; i--) {
2569
- if (runs[i].text === "") runs.splice(i, 1);
2978
+ if (!preservesSpace(el)) for (const line of runLines) collapseRunWhitespace(line);
2979
+ return { runs: joinLines(runLines, leading), lead };
2980
+ }
2981
+ function readTspanRun(el, gradients, style, onWarn) {
2982
+ const text = el.textContent ?? "";
2983
+ const run = { text };
2984
+ const fw = ownValue(el, "font-weight", onWarn);
2985
+ if (fw === "bold" || fw === "bolder" || fw === 700) run.bold = true;
2986
+ else if (fw === "normal") run.fontWeight = 400;
2987
+ else if (typeof fw === "number") run.fontWeight = fw;
2988
+ if (ownValue(el, "font-style", onWarn) === "italic") run.italic = true;
2989
+ const ff = ownValue(el, "font-family", onWarn);
2990
+ if (ff) run.fontFamily = ff;
2991
+ const size = ownValue(el, "font-size", onWarn);
2992
+ if (size?.fontSize != null) run.fontSize = size.fontSize;
2993
+ if (size?.fontScale != null) run.fontScale = size.fontScale;
2994
+ const tspanStyle = deriveStyle(style, el);
2995
+ const ls = ownValue(el, "letter-spacing", onWarn, fontSizeOf(tspanStyle));
2996
+ if (ls != null) run.letterSpacing = ls;
2997
+ const shift = ownValue(el, "baseline-shift", onWarn);
2998
+ if (shift?.script != null) run.script = shift.script;
2999
+ if (shift?.baselineShift != null) run.baselineShift = shift.baselineShift;
3000
+ const decoration = ownValue(el, "text-decoration", onWarn);
3001
+ if (decoration?.underline) run.underline = true;
3002
+ if (decoration?.strikethrough) run.strikethrough = true;
3003
+ if (decoration?.overline) run.overline = true;
3004
+ const transform = ownValue(el, "text-transform", onWarn);
3005
+ if (transform) run.textTransform = transform;
3006
+ const variant = ownValue(el, "font-variant", onWarn);
3007
+ if (variant) run.fontVariantCaps = variant;
3008
+ const lang = ownLang(el)?.trim();
3009
+ if (lang) run.lang = lang;
3010
+ const fillAttr = resolveCurrentColor(ownProp(el, "fill"), tspanStyle);
3011
+ if (fillAttr) {
3012
+ const parsed = readProperty("fill", fillAttr);
3013
+ if (parsed?.kind === "none") {
3014
+ run.fill = null;
3015
+ } else if (parsed?.kind === "solid") {
3016
+ run.fill = { paint: { kind: "solid", color: parsed.color } };
3017
+ } else if (parsed?.kind === "ref") {
3018
+ const paint = gradients.get(parsed.id);
3019
+ if (paint) run.fill = { paint };
3020
+ else if (parsed.fallback) run.fill = { paint: { kind: "solid", color: parsed.fallback.color } };
3021
+ }
2570
3022
  }
3023
+ const stroke = coreStroke(readStroke(ownStrokeStyle(el, tspanStyle), gradients, onWarn, isNonScalingStroke(el)));
3024
+ if (stroke) run.stroke = stroke;
3025
+ return run;
2571
3026
  }
2572
3027
 
2573
3028
  // src/fillPaint.ts
@@ -2629,22 +3084,25 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
2629
3084
  return Number.isFinite(n) ? n : fallback;
2630
3085
  };
2631
3086
  const localTransform = parseTransform(el.getAttribute("transform"), onWarn);
2632
- warnUnreadPositions(el, onWarn);
2633
3087
  const ax = readLength(el, "x", "x", 0, onWarn);
2634
3088
  const ay = readLength(el, "y", "y", 0, onWarn);
2635
3089
  const leafStyle = deriveStyle(style, el);
2636
3090
  const textStyle = readTextStyle(leafStyle, onWarn);
3091
+ const lang = inheritedLang(el);
3092
+ if (lang) textStyle.lang = lang;
2637
3093
  const textPaint = readTextPaint(leafStyle, gradients, onWarn, isNonScalingStroke(el));
2638
3094
  const fontSize = resolveScreenLength(textStyle.fontSize ?? 16, 1);
2639
3095
  const dominantBaseline = el.getAttribute("dominant-baseline");
2640
3096
  const laid = el.hasAttribute(LAID_ATTR);
2641
3097
  const explicitTopAnchor = laid || dominantBaseline === "text-before-edge" || dominantBaseline === "hanging";
2642
- const topY = explicitTopAnchor ? ay : ay - fontSize;
2643
3098
  const inheritedShift = ownValue(el, "baseline-shift", onWarn) ?? {};
2644
3099
  const tracker = new LineTracker(el, onWarn);
2645
- const { runs, lead } = readRuns(el, tracker, inheritedShift, textStyle.letterSpacing, gradients, leafStyle, onWarn);
3100
+ const { runs, lead } = readRuns(el, tracker, inheritedShift, textStyle, gradients, leafStyle, onWarn);
2646
3101
  const plain = runs.map((r) => r.text).join("");
2647
- const pitched = laid ? numberAttr(el, LINE_HEIGHT_ATTR) : tracker.lineHeight(fontSize);
3102
+ const firstY = laid ? ay : tracker.firstBaseline ?? ay;
3103
+ const topY = explicitTopAnchor ? firstY : firstY - fontSize;
3104
+ const stated = numberAttr(el, LINE_HEIGHT_ATTR);
3105
+ const pitched = laid ? stated : tracker.lineHeight(fontSize, stated);
2648
3106
  if (pitched !== void 0) textStyle.lineHeight = pitched;
2649
3107
  const lineHeight = textStyle.lineHeight ?? DEFAULT_LINE_HEIGHT;
2650
3108
  const dataW = num2(el.getAttribute("data-weasel-width"), NaN);
@@ -2665,7 +3123,7 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
2665
3123
  (r) => Object.entries(r).some(([k, v]) => k !== "text" && v !== void 0)
2666
3124
  );
2667
3125
  if (hasStyling) node.runs = runs;
2668
- if (textStyle.direction === void 0 && !el.hasAttribute("data-weasel-width")) {
3126
+ if (textStyle.direction === void 0 && !writtenByWeasel(el)) {
2669
3127
  textStyle.direction = "ltr";
2670
3128
  }
2671
3129
  if (el.getAttribute("data-weasel-wrap") === "true") textStyle.wrap = true;
@@ -2681,85 +3139,6 @@ function parseTextElement(el, ctm, style, gradients, onWarn) {
2681
3139
  placeTextNode(node, multiply(ctm, localTransform), onWarn);
2682
3140
  return node;
2683
3141
  }
2684
- function readRuns(el, tracker, inheritedShift, letterSpacing, gradients, leafStyle, onWarn) {
2685
- const runLines = [[]];
2686
- let leading = 0;
2687
- let lead = 0;
2688
- const push = (run) => {
2689
- runLines[runLines.length - 1].push(run);
2690
- };
2691
- if (holdsOnlyText(el) && el.hasAttribute("dx")) {
2692
- const t = el.textContent ?? "";
2693
- if (t) lead = placeTspanRun(el, { text: t, ...inheritedShift }, runLines, letterSpacing, onWarn);
2694
- } else for (let i = 0; i < el.childNodes.length; i++) {
2695
- const child = el.childNodes[i];
2696
- if (child.nodeType === 3) {
2697
- const t = child.textContent ?? "";
2698
- if (!t) continue;
2699
- push({ text: t, ...inheritedShift });
2700
- } else if (child.nodeType === 1) {
2701
- const sp = child;
2702
- if (sp.tagName.toLowerCase() !== "tspan") {
2703
- onWarn(`<text> child <${sp.tagName}> not supported; flattening text content`);
2704
- const t = sp.textContent ?? "";
2705
- if (t) push({ text: t, ...inheritedShift });
2706
- continue;
2707
- }
2708
- warnUnreadPositions(sp, onWarn);
2709
- const afterText = runLines.some((line) => line.some((r) => r.text.trim() !== ""));
2710
- const lines = tracker.startsLine(sp, afterText);
2711
- if (runLines.length === 1 && runLines[0].length === 0 && statesLines(sp)) leading = lines;
2712
- else for (let n = 0; n < lines; n++) runLines.push([]);
2713
- lead += placeTspanRun(sp, readTspanRun(sp, gradients, leafStyle, onWarn), runLines, letterSpacing, onWarn);
2714
- }
2715
- }
2716
- if (!preservesSpace(el)) for (const line of runLines) collapseRunWhitespace(line);
2717
- return { runs: joinLines(runLines, leading), lead };
2718
- }
2719
- function readTspanRun(el, gradients, style, onWarn) {
2720
- const text = el.textContent ?? "";
2721
- const run = { text };
2722
- const fw = ownValue(el, "font-weight", onWarn);
2723
- if (fw === "bold" || fw === "bolder" || fw === 700) run.bold = true;
2724
- else if (fw === "normal") run.fontWeight = 400;
2725
- else if (typeof fw === "number") run.fontWeight = fw;
2726
- if (ownValue(el, "font-style", onWarn) === "italic") run.italic = true;
2727
- const ff = ownValue(el, "font-family", onWarn);
2728
- if (ff) run.fontFamily = ff;
2729
- const size = ownValue(el, "font-size", onWarn);
2730
- if (size?.fontSize != null) run.fontSize = size.fontSize;
2731
- if (size?.fontScale != null) run.fontScale = size.fontScale;
2732
- const tspanStyle = deriveStyle(style, el);
2733
- const ls = ownValue(el, "letter-spacing", onWarn, fontSizeOf(tspanStyle));
2734
- if (ls != null) run.letterSpacing = ls;
2735
- const shift = ownValue(el, "baseline-shift", onWarn);
2736
- if (shift?.script != null) run.script = shift.script;
2737
- if (shift?.baselineShift != null) run.baselineShift = shift.baselineShift;
2738
- const decoration = ownValue(el, "text-decoration", onWarn);
2739
- if (decoration?.underline) run.underline = true;
2740
- if (decoration?.strikethrough) run.strikethrough = true;
2741
- if (decoration?.overline) run.overline = true;
2742
- const transform = ownValue(el, "text-transform", onWarn);
2743
- if (transform) run.textTransform = transform;
2744
- const variant = ownValue(el, "font-variant", onWarn);
2745
- if (variant) run.fontVariantCaps = variant;
2746
- const fillAttr = resolveCurrentColor(ownProp(el, "fill"), tspanStyle);
2747
- if (fillAttr) {
2748
- const parsed = readProperty("fill", fillAttr);
2749
- if (parsed?.kind === "none") {
2750
- run.fill = null;
2751
- } else if (parsed?.kind === "solid") {
2752
- run.fill = { paint: { kind: "solid", color: parsed.color } };
2753
- } else if (parsed?.kind === "ref") {
2754
- const paint = gradients.get(parsed.id);
2755
- if (paint) run.fill = { paint };
2756
- else if (parsed.fallback) run.fill = { paint: { kind: "solid", color: parsed.fallback.color } };
2757
- }
2758
- }
2759
- const stroke = coreStroke(readStroke(ownStrokeStyle(el, tspanStyle), gradients, onWarn, isNonScalingStroke(el)));
2760
- if (stroke) run.stroke = stroke;
2761
- return run;
2762
- }
2763
3142
  function readTextStyle(style, onWarn) {
2764
3143
  const out = {};
2765
3144
  const size = styleValue(style, "font-size", onWarn);
@@ -2982,20 +3361,9 @@ function markerEntryFrom(el, id, role, strokeWidth, gradients, onWarn) {
2982
3361
  if (parts.some((p) => JSON.stringify(p.style) !== JSON.stringify(first))) {
2983
3362
  onWarn(`<marker id="${id}"> paints its parts differently; all take the first part's paint`);
2984
3363
  }
2985
- let commandCount = 0, coordCount = 0;
2986
- for (const p of parts) {
2987
- commandCount += p.path.commands.length;
2988
- coordCount += p.path.coords.length;
2989
- }
2990
- const commands = new Uint8Array(commandCount);
2991
- const coords = new Float32Array(coordCount);
2992
- let ci = 0, pi = 0;
2993
- for (const p of parts) {
2994
- commands.set(p.path.commands, ci);
2995
- ci += p.path.commands.length;
2996
- coords.set(p.path.coords, pi);
2997
- pi += p.path.coords.length;
2998
- }
3364
+ const joined = joinPaths(parts.map((p) => p.path));
3365
+ const commands = joined.commands;
3366
+ const coords = worldCoords(joined);
2999
3367
  const fillRule = parts[0].path.fillRule ?? "nonzero";
3000
3368
  const inset = markerInsetAttr(el);
3001
3369
  const round = (v) => Math.round(v * 1e4) / 1e4 + 0;
@@ -3049,6 +3417,27 @@ function groupPaint(el, from, style, ctm, gradients, onWarn) {
3049
3417
  if (stroke) paint.stroke = strokeThroughMatrix(stroke, ctm);
3050
3418
  return { paint: { ...paint, ...inheritsFrom(el, from) }, children };
3051
3419
  }
3420
+ var BOOLEAN_ATTR = "data-weasel-boolean";
3421
+ var OPS = /* @__PURE__ */ new Set(["union", "intersect", "subtract", "exclude"]);
3422
+ function booleanAttrOf(el) {
3423
+ const op = el.tagName.toLowerCase() === "g" ? el.getAttribute(BOOLEAN_ATTR) : null;
3424
+ return op !== null && OPS.has(op) ? { boolean: op } : {};
3425
+ }
3426
+ function derivedPathIn(tree, id) {
3427
+ if (tree.derivedPathOf) return tree.derivedPathOf(id);
3428
+ if (tree.overrides === void 0) return null;
3429
+ return derivedDepOf(documentOf(tree), id)?.path ?? null;
3430
+ }
3431
+ function asBooleanLeaf(node, path, place) {
3432
+ if (path === null || path.kind === "polygon" && path.commands.length === 0) {
3433
+ return { ...node, data: { ...node.data, path: void 0 } };
3434
+ }
3435
+ return { ...node, data: { ...node.data, path }, pose: place(boundsOfPath(path)) };
3436
+ }
3437
+ function booleanGroupSvg(self, operands, data, paint) {
3438
+ const held = { kind: "group", children: operands, boolean: booleanOpOf(data), hidden: true };
3439
+ return { kind: "group", children: self ? [self, held] : [held], ...paint };
3440
+ }
3052
3441
 
3053
3442
  // src/parse.ts
3054
3443
  var SUPPORTED_GROUP_TAGS = /* @__PURE__ */ new Set(["g", "svg"]);
@@ -3073,10 +3462,7 @@ function parseSvg(svg, opts = {}) {
3073
3462
  }
3074
3463
  bindStylesheets(doc, mediaEnvironmentFor(root, opts.media), onWarn);
3075
3464
  const namespaces = opts.namespaces ?? {};
3076
- const uriToPrefix = /* @__PURE__ */ new Map();
3077
- for (const [prefix, uri] of Object.entries(namespaces)) {
3078
- uriToPrefix.set(uri, prefix);
3079
- }
3465
+ const uriToPrefix = new Map(Object.entries(namespaces).map(([prefix, uri]) => [uri, prefix]));
3080
3466
  const documentMeta = collectDocumentMeta(root, uriToPrefix);
3081
3467
  const gradients = collectGradients(root, onWarn);
3082
3468
  for (const [id, paint] of collectPatterns(root, onWarn)) gradients.set(id, paint);
@@ -3190,7 +3576,7 @@ function parseChildren(parent, ctm, style, from, gradients, clips, onWarn, uriTo
3190
3576
  const ns = el.namespaceURI;
3191
3577
  if (ns && ns !== "http://www.w3.org/2000/svg" || isStrokeOutline(el)) continue;
3192
3578
  const hidden = isHiddenElement(el, style);
3193
- if (hidden && !keepHidden) continue;
3579
+ if (hidden && !keepHidden && booleanAttrOf(el).boolean === void 0) continue;
3194
3580
  const node = unwrapStrokeGroup(el, parseElement(el, ctm, style, from, gradients, clips, onWarn, uriToPrefix, keepHidden));
3195
3581
  if (!node) continue;
3196
3582
  const parsed = Array.isArray(node) ? node : [node];
@@ -3299,7 +3685,7 @@ function parseGroup(el, ctm, style, from, gradients, clips, onWarn, uriToPrefix,
3299
3685
  const paint = groupPaint(el, from, childStyle, childCtm, gradients, onWarn);
3300
3686
  const children = parseChildren(el, childCtm, childStyle, paint.children, gradients, clips, onWarn, uriToPrefix, keepHidden);
3301
3687
  const opacity = ownValue(el, "opacity");
3302
- const group = { kind: "group", children, ...paint.paint };
3688
+ const group = { kind: "group", children, ...paint.paint, ...booleanAttrOf(el) };
3303
3689
  const clip = clipFor(el, childCtm, clips, onWarn);
3304
3690
  if (clip) group.clip = clip;
3305
3691
  if (opacity != null) group.opacity = opacity;
@@ -3410,7 +3796,8 @@ var closedStroke = (stroke) => stroke && capsEnds(stroke) ? withoutEndMarkers(st
3410
3796
  var isClosed = (part) => splitByClosure(part).open === null;
3411
3797
  function samePath(a, b) {
3412
3798
  if (a === b) return true;
3413
- return a.commands.length === b.commands.length && a.coords.length === b.coords.length && a.commands.every((c, i) => c === b.commands[i]) && a.coords.every((v, i) => v === b.coords[i]);
3799
+ const ac = worldCoords(a), bc = worldCoords(b);
3800
+ return a.commands.length === b.commands.length && ac.length === bc.length && a.commands.every((c, i) => c === b.commands[i]) && ac.every((v, i) => v === bc[i]);
3414
3801
  }
3415
3802
  function strokeParts(path, stroke, filled, closed, open) {
3416
3803
  const none = { kind: "none" };
@@ -3598,11 +3985,20 @@ function fitLeafXml(xml, inherits, painted) {
3598
3985
  }
3599
3986
  function textXml(node, registry, namespaces, warn, given) {
3600
3987
  const layout = given && layoutMeasured(given, node.text) ? given : null;
3988
+ const resolved = resolveTextStyle(node.style);
3601
3989
  const x = node.x + anchorOffset(node.style, node.width, node.text);
3990
+ const leading = layout ? void 0 : halfLeadingFor(
3991
+ resolved.fontFamily,
3992
+ numericWeight(resolved.fontWeight),
3993
+ resolved.fontStyle,
3994
+ resolved.fontSize,
3995
+ resolved.lineHeight
3996
+ );
3602
3997
  const attrs = [
3603
3998
  `x="${trimNumber(x)}"`,
3604
3999
  `y="${trimNumber(node.y)}"`,
3605
4000
  layout ? `${LAID_ATTR}="lines"` : `dominant-baseline="text-before-edge"`,
4001
+ ...leading ? [`dy="${trimNumber(leading)}"`] : [],
3606
4002
  // SVG's default whitespace handling would collapse indentation and runs
3607
4003
  // of spaces away on re-import.
3608
4004
  `xml:space="preserve"`,
@@ -3615,6 +4011,7 @@ function textXml(node, registry, namespaces, warn, given) {
3615
4011
  if (style?.fontWeight != null) attrs.push(`font-weight="${escapeAttr(String(style.fontWeight))}"`);
3616
4012
  if (style?.fontStyle && style.fontStyle !== "normal") attrs.push(`font-style="${style.fontStyle}"`);
3617
4013
  if (style?.fontVariantCaps === "small-caps") attrs.push('font-variant="small-caps"');
4014
+ if (style?.lang) attrs.push(`xml:lang="${escapeAttr(style.lang)}"`);
3618
4015
  const direction = svgDirection(style, node.text);
3619
4016
  if (direction === "rtl" || style?.direction === "ltr") attrs.push(`direction="${direction}"`);
3620
4017
  let anchor = "start";
@@ -3642,7 +4039,7 @@ function textXml(node, registry, namespaces, warn, given) {
3642
4039
  attrs.push(`data-weasel-vertical-align="${node.verticalAlign}"`);
3643
4040
  if (!layout) 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)`);
3644
4041
  }
3645
- if (layout && style?.lineHeight != null) attrs.push(`${LINE_HEIGHT_ATTR}="${trimNumber(style.lineHeight)}"`);
4042
+ if (style?.lineHeight != null) attrs.push(`${LINE_HEIGHT_ATTR}="${trimNumber(style.lineHeight)}"`);
3646
4043
  if (node.fill !== void 0) {
3647
4044
  const paint = node.fill?.paint;
3648
4045
  if (!paint) attrs.push('fill="none"');
@@ -3658,7 +4055,6 @@ function textXml(node, registry, namespaces, warn, given) {
3658
4055
  const frame = boxTransformAttr(node);
3659
4056
  if (frame) attrs.push(frame);
3660
4057
  const pieces = node.runs && node.runs.length > 0 ? node.runs.map((r) => ({ text: r.text, attrs: runAttrs(r, style, registry, warn) })) : [{ text: node.text, attrs: null }];
3661
- const resolved = resolveTextStyle(style);
3662
4058
  const body = layout ? laidLinesXml(pieces, layout, { anchor, rtl: direction === "rtl" }) : linesXml(pieces, x, resolved.fontSize * resolved.lineHeight);
3663
4059
  const metaAttrs = metaAttrsXml(node.meta, namespaces);
3664
4060
  const metaEls = metaElementsXml(node.meta, namespaces);
@@ -3676,6 +4072,7 @@ function runAttrs(run, style, registry, warn) {
3676
4072
  if (run.italic) attrs.push(`font-style="italic"`);
3677
4073
  if (run.fontVariantCaps) attrs.push(`font-variant="${run.fontVariantCaps}"`);
3678
4074
  if (run.fontFamily) attrs.push(`font-family="${escapeAttr(run.fontFamily)}"`);
4075
+ if (run.lang) attrs.push(`xml:lang="${escapeAttr(run.lang)}"`);
3679
4076
  const face = scriptSizeFace(run, style);
3680
4077
  const presetScale = face && run.script ? scriptMetricsFor(face.family, face.weight, face.style)[run.script].size : void 0;
3681
4078
  const scale = run.fontScale ?? presetScale;
@@ -3699,6 +4096,7 @@ function runAttrs(run, style, registry, warn) {
3699
4096
  }
3700
4097
  for (const a of coreStrokeAttrs(run.stroke, registry, warn)) attrs.push(a);
3701
4098
  if (run.textTransform) attrs.push(`style="text-transform:${run.textTransform}"`);
4099
+ if (run.rotate) attrs.push(`rotate="${trimNumber(run.rotate)}"`);
3702
4100
  return attrs;
3703
4101
  }
3704
4102
  function textDecorationValue(underline, strikethrough, overline) {
@@ -3869,6 +4267,7 @@ function groupXml(node, registry, namespaces, warn, layoutOf, painted = NOTHING_
3869
4267
  if (node.opacity != null && node.opacity !== 1) {
3870
4268
  attrs.push(`opacity="${trimNumber(node.opacity)}"`);
3871
4269
  }
4270
+ if (node.boolean) attrs.push(`${BOOLEAN_ATTR}="${node.boolean}"`);
3872
4271
  const metaAttrs = metaAttrsXml(node.meta, namespaces);
3873
4272
  const head = attrs.length > 0 ? `<g ${attrs.join(" ")}${metaAttrs}>` : `<g${metaAttrs}>`;
3874
4273
  const body = node.children.map((c) => nodeXml(c, registry, namespaces, warn, layoutOf, below)).join("");
@@ -4030,6 +4429,12 @@ function svgNodesToKitDrafts(input, nextId, options = {}) {
4030
4429
  const takes = takesOf(n, from, k);
4031
4430
  if (n.kind === "group") {
4032
4431
  const [own2, kids, ...more] = n.children;
4432
+ if (own2 && own2.kind !== "group" && kids?.kind === "group" && kids.boolean) {
4433
+ return visitMark(n, own2, kids, more, parentId, k, takes);
4434
+ }
4435
+ if (own2?.kind === "group" && own2.boolean) {
4436
+ return visitMark(n, emptyResultOf(n), own2, [kids, ...more].filter((c) => c !== void 0), parentId, k, takes, true);
4437
+ }
4033
4438
  if (own2 && own2.kind !== "group" && (kids === void 0 || kids.kind === "group") && options.mark?.(n)) {
4034
4439
  if (n.clip) return visit(clippingGroup(n), parentId, inherited2, from);
4035
4440
  return visitMark(n, own2, kids, more, parentId, k, takes);
@@ -4059,25 +4464,29 @@ function svgNodesToKitDrafts(input, nextId, options = {}) {
4059
4464
  const { pose, data } = leafDraft(n, takes, k);
4060
4465
  return pushLeaf(n, parentId, pose, data);
4061
4466
  };
4062
- const visitMark = (n, own2, kids, more, parentId, k, takes) => {
4467
+ const visitMark = (n, own2, kids, more, parentId, k, takes, empty = false) => {
4063
4468
  const inner = groupTakes(n, takes, k);
4064
4469
  const pose = visit(own2, parentId, k, inner);
4065
4470
  const mark = pose === null ? null : drafts.at(-1);
4066
4471
  const under = mark?.id ?? parentId;
4067
4472
  if (mark && n.hidden) mark.hidden = true;
4068
4473
  if (mark && kids?.clip) mark.clip = "outline";
4474
+ if (mark && kids?.boolean) mark.boolean = kids.boolean;
4069
4475
  const poses = [pose];
4070
4476
  if (kids) {
4071
4477
  const at = drafts.length;
4072
4478
  const kk = k * (kids.opacity ?? 1);
4073
4479
  const below = groupTakes(kids, takesOf(kids, inner, kk), kk);
4074
4480
  for (const c of kids.children) poses.push(visit(c, under, kk, below));
4075
- if (kids.hidden) {
4481
+ if (kids.hidden && !kids.boolean) {
4076
4482
  for (const d of drafts.slice(at)) if (d.parentId === under) d.hidden = true;
4077
4483
  }
4078
4484
  }
4079
4485
  for (const c of more) poses.push(visit(c, under, k, inner));
4080
- return unionAABB(poses);
4486
+ if (!empty) return unionAABB(poses);
4487
+ const box = unionAABB(poses.slice(1));
4488
+ if (mark && box) mark.pose = box;
4489
+ return box;
4081
4490
  };
4082
4491
  for (const n of nodes) visit(n, null, 1, NO_TAKES);
4083
4492
  return drafts;
@@ -4153,6 +4562,16 @@ function containerPaint(n, takes, box, k) {
4153
4562
  ...takes.stroke ? { stroke: INHERIT } : stroke ? { stroke: fadeStroke(stroke, k) } : {}
4154
4563
  };
4155
4564
  }
4565
+ function emptyResultOf(n) {
4566
+ const path = { kind: "polygon", commands: new Uint8Array(0), coords: new Float32Array(0), fillRule: "nonzero" };
4567
+ return {
4568
+ kind: "path",
4569
+ path,
4570
+ fill: n.fill ?? { kind: "none" },
4571
+ ...n.stroke ? { stroke: n.stroke } : {},
4572
+ ...n.inherits ? { inherits: n.inherits } : {}
4573
+ };
4574
+ }
4156
4575
  function boxOfPath(path) {
4157
4576
  const b = boundsOfPath(path);
4158
4577
  return { x: b.x, y: b.y, width: b.width, height: b.height };
@@ -4180,6 +4599,10 @@ function scaleLeafData(data, scale) {
4180
4599
  }
4181
4600
  return out;
4182
4601
  }
4602
+ function asBooleanGroup(op, data) {
4603
+ const { path: _path, ...rest } = data;
4604
+ return { ...booleanGroupFields(), data: { ...rest, boolean: op } };
4605
+ }
4183
4606
  async function unpackSvgFiles(files, ctx) {
4184
4607
  const layer = ctx.scene.layers[0]?.id ?? "default";
4185
4608
  let index = 0;
@@ -4223,14 +4646,19 @@ async function unpackSvgFiles(files, ctx) {
4223
4646
  width: p.width * scale,
4224
4647
  height: p.height * scale
4225
4648
  });
4649
+ const placed = localizeWorldPoses(
4650
+ drafts.map((d) => ({ id: d.id, parent: d.parentId, pose: place(d.pose) })),
4651
+ ctx.scene.composition
4652
+ );
4226
4653
  const ops = drafts.map(
4227
- (d) => createInsertOp({
4654
+ (d, i) => createInsertOp({
4228
4655
  node: {
4229
4656
  id: d.id,
4230
4657
  kind: d.kind,
4231
4658
  layer,
4232
- pose: place(d.pose),
4659
+ pose: placed[i].pose,
4233
4660
  data: d.kind === "mark" ? scaleLeafData(d.data, scale) : d.data,
4661
+ ...d.kind === "mark" && d.boolean ? asBooleanGroup(d.boolean, scaleLeafData(d.data, scale)) : {},
4234
4662
  parent: d.parentId,
4235
4663
  ...d.clip ? { clip: d.clip } : {},
4236
4664
  ...d.hidden ? { hidden: true } : {}
@@ -4351,12 +4779,36 @@ function svgTextFromKit(data, pose) {
4351
4779
  }
4352
4780
  function svgNodesFromKit(tree, options = {}) {
4353
4781
  const { include, leaf, group, mark } = options;
4354
- const emit = (id, up) => {
4355
- const node = tree.get(id);
4356
- const out2 = node && (!include || include(id)) ? lower(id, node, up) : null;
4782
+ const compose = tree.composition?.compose;
4783
+ const emit = (id, up, frame) => {
4784
+ const stored = tree.get(id);
4785
+ const node = stored && compose && frame ? { ...stored, pose: compose(frame, stored.pose) } : stored;
4786
+ const own2 = node && definesFrame(node) ? node.pose : frame;
4787
+ const out2 = node && (!include || include(id)) ? lower(id, node, up, own2) : null;
4357
4788
  return out2 && node?.hidden === true ? { ...out2, hidden: true } : out2;
4358
4789
  };
4359
- const lower = (id, node, up) => {
4790
+ const frameAbove = (id) => {
4791
+ if (!compose) return null;
4792
+ const chain = [];
4793
+ const seen = /* @__PURE__ */ new Set([id]);
4794
+ let at = tree.get(id)?.parent ?? null;
4795
+ while (at !== null && !seen.has(at)) {
4796
+ seen.add(at);
4797
+ const n = tree.get(at);
4798
+ if (!n) break;
4799
+ chain.unshift(n);
4800
+ at = n.parent ?? null;
4801
+ }
4802
+ let frame = null;
4803
+ for (const n of chain) {
4804
+ const pose = frame ? compose(frame, n.pose) : n.pose;
4805
+ if (definesFrame(n)) frame = pose;
4806
+ }
4807
+ return frame;
4808
+ };
4809
+ const lower = (id, stored, up, frame) => {
4810
+ const booleans = stored.consumesChildren === true;
4811
+ const node = booleans ? asBooleanLeaf(stored, derivedPathIn(tree, id), (box) => compose && frame ? compose(frame, box) : box) : stored;
4360
4812
  const own2 = node.data;
4361
4813
  const isGroup = node.kind === "group";
4362
4814
  const self = isGroup ? null : markInherits(
@@ -4373,7 +4825,7 @@ function svgNodesFromKit(tree, options = {}) {
4373
4825
  const emitChildren = () => {
4374
4826
  const children = [];
4375
4827
  for (const c of kids) {
4376
- const child = emit(c, below);
4828
+ const child = emit(c, below, frame);
4377
4829
  if (child) children.push(child);
4378
4830
  }
4379
4831
  return children;
@@ -4382,6 +4834,10 @@ function svgNodesFromKit(tree, options = {}) {
4382
4834
  const copies = node.copiesFromPose?.(node.pose, node) ?? [];
4383
4835
  const content = () => ({ kind: "group", children: emitChildren(), ...clip ? { clip } : {} });
4384
4836
  const paint = groupPaintFromKit(own2, below, node.pose);
4837
+ if (booleans) {
4838
+ const g = booleanGroupSvg(self, emitChildren(), own2, paint);
4839
+ return mark ? mark(id, g) : g;
4840
+ }
4385
4841
  const copied = () => copies.map((transform) => ({ kind: "group", transform, children: [content()] }));
4386
4842
  const withCopies = (g) => copies.length === 0 ? g : { kind: "group", children: [g, ...copied()] };
4387
4843
  if (isGroup) {
@@ -4398,7 +4854,7 @@ function svgNodesFromKit(tree, options = {}) {
4398
4854
  };
4399
4855
  const out = [];
4400
4856
  for (const id of options.roots ?? tree.roots) {
4401
- const n = emit(id, NO_UP_PAINT);
4857
+ const n = emit(id, NO_UP_PAINT, frameAbove(id));
4402
4858
  if (n) out.push(n);
4403
4859
  }
4404
4860
  return out;