@emasoft/svg-matrix 1.3.1 → 1.3.4

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/version.json CHANGED
@@ -1,38 +1,38 @@
1
1
  {
2
- "version": "1.3.1",
3
- "buildTime": "2026-01-09T17:20:51.504Z",
2
+ "version": "1.3.4",
3
+ "buildTime": "2026-01-25T12:44:06.936Z",
4
4
  "bundles": {
5
5
  "esm": [
6
6
  {
7
7
  "name": "svg-matrix.min.js",
8
- "size": 56185,
9
- "gzipSize": 19446,
8
+ "size": 56104,
9
+ "gzipSize": 19413,
10
10
  "description": "ESM bundle for bundlers/Node.js (includes decimal.js)"
11
11
  },
12
12
  {
13
13
  "name": "svg-toolbox.min.js",
14
- "size": 577130,
15
- "gzipSize": 152266,
14
+ "size": 577298,
15
+ "gzipSize": 152361,
16
16
  "description": "ESM bundle for bundlers/Node.js (includes decimal.js)"
17
17
  },
18
18
  {
19
19
  "name": "svgm.min.js",
20
- "size": 602513,
21
- "gzipSize": 159440,
20
+ "size": 602600,
21
+ "gzipSize": 159489,
22
22
  "description": "ESM bundle for bundlers/Node.js (includes decimal.js)"
23
23
  }
24
24
  ],
25
25
  "iife": [
26
26
  {
27
27
  "name": "svg-matrix.global.min.js",
28
- "size": 56568,
29
- "gzipSize": 19669,
28
+ "size": 56487,
29
+ "gzipSize": 19638,
30
30
  "description": "IIFE bundle for browsers via <script> (includes decimal.js)"
31
31
  },
32
32
  {
33
33
  "name": "svg-toolbox.global.min.js",
34
- "size": 577259,
35
- "gzipSize": 151635,
34
+ "size": 577427,
35
+ "gzipSize": 151728,
36
36
  "description": "IIFE bundle for browsers via <script> (includes decimal.js)"
37
37
  },
38
38
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emasoft/svg-matrix",
3
- "version": "1.3.1",
3
+ "version": "1.3.4",
4
4
  "description": "Arbitrary-precision matrix, vector and affine transformation library for JavaScript using decimal.js",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -24,7 +24,7 @@
24
24
  "version:sync": "node scripts/version-sync.js",
25
25
  "version:check": "node scripts/version-sync.js --check",
26
26
  "preversion": "npm run version:sync",
27
- "version": "node scripts/version-sync.js && git add src/index.js package-lock.json",
27
+ "version": "node scripts/version-sync.js && git add src/index.js package-lock.json src/svg-matrix-lib.js src/svg-toolbox-lib.js src/svgm-lib.js",
28
28
  "prepublishOnly": "npm run build && npm run version:check && npm test",
29
29
  "postinstall": "node scripts/postinstall.js || true"
30
30
  },
@@ -172,18 +172,24 @@ function updateLockfileVersion(version) {
172
172
  const filePath = join(ROOT, "package-lock.json");
173
173
  try {
174
174
  const lock = JSON.parse(readFileSync(filePath, "utf8"));
175
- const original = JSON.stringify(lock);
175
+
176
+ // WHY: Compare version values directly instead of stringified JSON
177
+ // The old approach compared compact JSON vs pretty-printed JSON which always differed
178
+ const rootVersionMatch = lock.version === version;
179
+ const packageVersionMatch =
180
+ !lock.packages?.[""] || lock.packages[""].version === version;
181
+
182
+ if (rootVersionMatch && packageVersionMatch) {
183
+ return false; // No update needed
184
+ }
176
185
 
177
186
  lock.version = version;
178
187
  if (lock.packages && lock.packages[""]) {
179
188
  lock.packages[""].version = version;
180
189
  }
181
190
 
182
- const updated = JSON.stringify(lock, null, 2) + "\n";
183
- if (updated !== original) {
184
- writeFileSync(filePath, updated, "utf8");
185
- return true;
186
- }
191
+ writeFileSync(filePath, JSON.stringify(lock, null, 2) + "\n", "utf8");
192
+ return true;
187
193
  } catch {
188
194
  // Lockfile might not exist
189
195
  }
@@ -76,9 +76,10 @@ export function formatSplineValue(value, precision = 3) {
76
76
  // Convert to string
77
77
  let str = rounded.toString();
78
78
 
79
- // Remove trailing zeros after decimal point
79
+ // Remove trailing zeros after decimal point (two-step to avoid "10.0" -> "1" bug)
80
80
  if (str.includes(".")) {
81
- str = str.replace(/\.?0+$/, "");
81
+ str = str.replace(/0+$/, ""); // First remove trailing zeros
82
+ str = str.replace(/\.$/, ""); // Then remove orphan decimal point
82
83
  }
83
84
 
84
85
  // Remove leading zero for values between -1 and 1 (exclusive)
@@ -1589,7 +1589,8 @@ export function verifyCurvature(points, t, tolerance = "1e-10") {
1589
1589
 
1590
1590
  const t1 = Decimal.max(D(0), tD.minus(h));
1591
1591
  const t2 = Decimal.min(D(1), tD.plus(h));
1592
- const _actualH = t2.minus(t1);
1592
+ // NOTE: Actual step size differs at boundaries, but curvature comparison
1593
+ // uses arc length directly rather than parameter difference
1593
1594
 
1594
1595
  const tan1 = bezierTangent(points, t1);
1595
1596
  const tan2 = bezierTangent(points, t2);
@@ -40,6 +40,23 @@ const SINGULARITY_THRESHOLD = "1e-50"; // Below this, Jacobian considered singul
40
40
  const INTERSECTION_VERIFY_FACTOR = 100; // Multiplier for intersection verification
41
41
  const DEDUP_TOLERANCE_FACTOR = 1000; // Multiplier for duplicate detection
42
42
 
43
+ /**
44
+ * Default tolerance for intersection detection.
45
+ * WHY: 1e-10 provides high precision while being practical for real-world use.
46
+ * This is much more lenient than the theoretical 1e-30 but still far more precise
47
+ * than float64 operations (~1e-15).
48
+ * @type {string}
49
+ */
50
+ export const DEFAULT_INTERSECTION_TOLERANCE = "1e-10";
51
+
52
+ /**
53
+ * Tolerance for svgpathtools-compatible behavior.
54
+ * WHY: svgpathtools uses float64 (15 digits) and typically finds intersections
55
+ * with ~4e-7 distance error. This tolerance ensures compatible results.
56
+ * @type {string}
57
+ */
58
+ export const SVGPATHTOOLS_COMPATIBLE_TOLERANCE = "1e-6";
59
+
43
60
  /** Maximum Newton iterations for intersection refinement */
44
61
  const MAX_NEWTON_ITERATIONS = 30;
45
62
 
@@ -382,14 +399,18 @@ function refineBezierLineRoot(bezier, line, t0, t1, tol) {
382
399
  * @param {Array} bezier1 - First Bezier control points [[x,y], ...]
383
400
  * @param {Array} bezier2 - Second Bezier control points [[x,y], ...]
384
401
  * @param {Object} [options] - Options
385
- * @param {string} [options.tolerance='1e-30'] - Intersection tolerance
402
+ * @param {string} [options.tolerance=DEFAULT_INTERSECTION_TOLERANCE] - Intersection tolerance.
403
+ * Use DEFAULT_INTERSECTION_TOLERANCE (1e-10) for high precision,
404
+ * SVGPATHTOOLS_COMPATIBLE_TOLERANCE (1e-6) for svgpathtools-compatible results.
386
405
  * @param {number} [options.maxDepth=50] - Maximum recursion depth
387
406
  * @returns {Array} Intersections [{t1, t2, point, error}]
388
407
  */
389
408
  export function bezierBezierIntersection(bezier1, bezier2, options = {}) {
390
- // WHY: Use named constant as default instead of hardcoded 50 for clarity
391
- const { tolerance = "1e-30", maxDepth = MAX_INTERSECTION_RECURSION_DEPTH } =
392
- options;
409
+ // WHY: Use named constants as defaults for clarity and easy configuration
410
+ const {
411
+ tolerance = DEFAULT_INTERSECTION_TOLERANCE,
412
+ maxDepth = MAX_INTERSECTION_RECURSION_DEPTH,
413
+ } = options;
393
414
 
394
415
  // Input validation
395
416
  if (!bezier1 || !Array.isArray(bezier1) || bezier1.length < 2) {
@@ -573,21 +594,24 @@ function refineIntersection(bez1, bez2, t1, t2, tol) {
573
594
  break;
574
595
  }
575
596
 
576
- // Solve: J * [dt1, dt2]^T = -[fx, fy]^T
577
- // dt1 = (-(-dy2)*(-fx) - (-dx2)*(-fy)) / det = (-dy2*fx + dx2*fy) / det
578
- // dt2 = (dx1*(-fy) - dy1*(-fx)) / det = (-dx1*fy + dy1*fx) / det
597
+ // Solve: J * [dt1, dt2]^T = -[fx, fy]^T using Cramer's rule
598
+ // The Jacobian J = [[dx1, -dx2], [dy1, -dy2]], det(J) = dx2*dy1 - dx1*dy2
599
+ // For J^-1 * [-fx, -fy]^T:
600
+ // dt1 = (dy2*fx - dx2*fy) / det
601
+ // dt2 = (dy1*fx - dx1*fy) / det
602
+ // WHY: This is the correct Cramer's rule solution for the 2x2 linear system.
579
603
 
580
- const dt1 = dy2.neg().times(fx).plus(dx2.times(fy)).div(det);
581
- const dt2 = dx1.neg().times(fy).plus(dy1.times(fx)).div(det);
604
+ const dt1 = dy2.times(fx).minus(dx2.times(fy)).div(det);
605
+ const dt2 = dy1.times(fx).minus(dx1.times(fy)).div(det);
582
606
 
583
607
  currentT1 = currentT1.plus(dt1);
584
608
  currentT2 = currentT2.plus(dt2);
585
609
 
586
610
  // Check for convergence by step size
587
611
  if (dt1.abs().lt(tol) && dt2.abs().lt(tol)) {
588
- // BUGFIX: Compute fresh error value instead of using stale one from previous iteration
589
- // WHY: The `error` variable computed above (line 553) is from before the parameter update,
590
- // so it may not reflect the final accuracy. We need to recompute error for the converged parameters.
612
+ // BUGFIX: Compute fresh error value and verify it's within tolerance
613
+ // WHY: Step size convergence doesn't guarantee point accuracy.
614
+ // We must verify the final error meets the tolerance requirement.
591
615
  const [finalX, finalY] = bezierPoint(bez1, currentT1);
592
616
  const [finalX2, finalY2] = bezierPoint(bez2, currentT2);
593
617
  const finalError = D(finalX)
@@ -595,12 +619,19 @@ function refineIntersection(bez1, bez2, t1, t2, tol) {
595
619
  .pow(2)
596
620
  .plus(D(finalY).minus(D(finalY2)).pow(2))
597
621
  .sqrt();
598
- return {
599
- t1: currentT1,
600
- t2: currentT2,
601
- point: [finalX, finalY],
602
- error: finalError,
603
- };
622
+
623
+ // WHY: Only return if final error is within tolerance.
624
+ // This prevents returning false positives where Newton converged
625
+ // in parameter space but not in point distance space.
626
+ if (finalError.lt(tol)) {
627
+ return {
628
+ t1: currentT1,
629
+ t2: currentT2,
630
+ point: [finalX, finalY],
631
+ error: finalError,
632
+ };
633
+ }
634
+ // Step size converged but error too large - continue iterating
604
635
  }
605
636
  }
606
637
 
@@ -1953,6 +1984,10 @@ export function verifyAllIntersectionFunctions(tolerance = "1e-30") {
1953
1984
  // ============================================================================
1954
1985
 
1955
1986
  export default {
1987
+ // Tolerance constants for configurable precision
1988
+ DEFAULT_INTERSECTION_TOLERANCE,
1989
+ SVGPATHTOOLS_COMPATIBLE_TOLERANCE,
1990
+
1956
1991
  // Line-line
1957
1992
  lineLineIntersection,
1958
1993
 
@@ -12,9 +12,8 @@
12
12
  * @module convert-path-data
13
13
  */
14
14
 
15
- import Decimal from "decimal.js";
16
-
17
- Decimal.set({ precision: 80 });
15
+ // NOTE: This module uses native JavaScript Number/Math for all calculations.
16
+ // For arbitrary-precision needs, see the main svg-matrix library.
18
17
 
19
18
  // SVG path command parameters count
20
19
  const COMMAND_PARAMS = {
@@ -1659,6 +1659,8 @@ function getElementBBox(el) {
1659
1659
 
1660
1660
  return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1661
1661
  } catch {
1662
+ // Error is intentionally swallowed - invalid path data or polygon conversion
1663
+ // failures are expected for some edge cases and should return null
1662
1664
  return null;
1663
1665
  }
1664
1666
  }
@@ -511,8 +511,8 @@ export async function convertToWoff2(fontPath, options = {}) {
511
511
  }
512
512
 
513
513
  try {
514
- // Use fonttools ttx to convert
515
- execFileSync("fonttools", ["ttLib.woff2", "compress", fontPath, "-o", outputPath], {
514
+ // Use fonttools woff2 compress - output is positional argument, not -o flag
515
+ execFileSync("fonttools", ["ttLib.woff2", "compress", fontPath, outputPath], {
516
516
  stdio: "pipe",
517
517
  timeout: 60000,
518
518
  });
package/src/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * SVG path conversion, and 2D/3D affine transformations using Decimal.js.
6
6
  *
7
7
  * @module @emasoft/svg-matrix
8
- * @version 1.3.1
8
+ * @version 1.3.4
9
9
  * @license MIT
10
10
  *
11
11
  * @example
@@ -135,7 +135,7 @@ Decimal.set({ precision: 80 });
135
135
  * Library version
136
136
  * @constant {string}
137
137
  */
138
- export const VERSION = "1.3.1";
138
+ export const VERSION = "1.3.4";
139
139
 
140
140
  /**
141
141
  * Default precision for path output (decimal places)
@@ -528,11 +528,12 @@ export function cloneElement(element) {
528
528
  }
529
529
 
530
530
  // Create proper SVGElement with serialize() method
531
+ // Note: SVGElement constructor requires textContent to be a string, not null
531
532
  const clone = new SVGElement(
532
533
  element.tagName,
533
534
  attrs,
534
535
  clonedChildren,
535
- element.textContent || null,
536
+ element.textContent || "",
536
537
  );
537
538
 
538
539
  return clone;
@@ -378,6 +378,12 @@ export function getMarkerTransform(
378
378
  }
379
379
 
380
380
  // Step 4: Apply viewBox transformation if present
381
+ // BUG FIX: When viewBox has non-zero origin, the refX/refY are in viewBox coordinates.
382
+ // We must convert them to viewport coordinates before applying the ref translation.
383
+ // Transform chain: T_position * R * T(-ref_viewport) * S * T(-viewBox.origin)
384
+ let hasViewBox = false;
385
+ let viewBoxScaleFactor = 1;
386
+
381
387
  if (viewBox) {
382
388
  // Calculate scale factors to fit viewBox into marker viewport
383
389
  const vbWidth = viewBox.width;
@@ -397,30 +403,47 @@ export function getMarkerTransform(
397
403
  // Validate scale factors are finite
398
404
  if (isFinite(scaleFactorX) && isFinite(scaleFactorY)) {
399
405
  // For now, use uniform scaling (can be enhanced with full preserveAspectRatio parsing)
400
- const scaleFactor = Math.min(scaleFactorX, scaleFactorY);
401
-
402
- scaleX *= scaleFactor;
403
- scaleY *= scaleFactor;
406
+ viewBoxScaleFactor = Math.min(scaleFactorX, scaleFactorY);
407
+ hasViewBox = true;
404
408
 
405
- // Translate to account for viewBox origin
406
- const viewBoxTranslate = Transforms2D.translation(
407
- -viewBox.x,
408
- -viewBox.y,
409
- );
410
- transform = transform.mul(viewBoxTranslate);
409
+ scaleX *= viewBoxScaleFactor;
410
+ scaleY *= viewBoxScaleFactor;
411
411
  }
412
412
  }
413
413
  }
414
414
 
415
- // Apply combined scaling
416
- if (scaleX !== 1 || scaleY !== 1) {
417
- const scale = Transforms2D.scale(scaleX, scaleY);
418
- transform = transform.mul(scale);
419
- }
415
+ if (hasViewBox) {
416
+ // When viewBox is present, refX/refY are in viewBox coordinates.
417
+ // Convert to viewport (marker) coordinates by:
418
+ // 1. Subtract viewBox origin to get position relative to viewBox
419
+ // 2. Scale by viewBox scale factor to get position in marker viewport
420
+ const refViewportX = (refX - viewBox.x) * viewBoxScaleFactor;
421
+ const refViewportY = (refY - viewBox.y) * viewBoxScaleFactor;
422
+
423
+ // Apply ref translation in viewport coordinates (BEFORE scaling in the chain)
424
+ const refTranslate = Transforms2D.translation(-refViewportX, -refViewportY);
425
+ transform = transform.mul(refTranslate);
426
+
427
+ // Apply combined scaling
428
+ if (scaleX !== 1 || scaleY !== 1) {
429
+ const scale = Transforms2D.scale(scaleX, scaleY);
430
+ transform = transform.mul(scale);
431
+ }
432
+
433
+ // Apply viewBox origin translation (AFTER scaling, so it scales correctly)
434
+ const viewBoxTranslate = Transforms2D.translation(-viewBox.x, -viewBox.y);
435
+ transform = transform.mul(viewBoxTranslate);
436
+ } else {
437
+ // No viewBox: apply scaling then ref translation in original coordinates
438
+ if (scaleX !== 1 || scaleY !== 1) {
439
+ const scale = Transforms2D.scale(scaleX, scaleY);
440
+ transform = transform.mul(scale);
441
+ }
420
442
 
421
- // Step 5: Translate by -refX, -refY
422
- const refTranslate = Transforms2D.translation(-refX, -refY);
423
- transform = transform.mul(refTranslate);
443
+ // Step 5: Translate by -refX, -refY
444
+ const refTranslate = Transforms2D.translation(-refX, -refY);
445
+ transform = transform.mul(refTranslate);
446
+ }
424
447
 
425
448
  return transform;
426
449
  }
@@ -161,17 +161,26 @@ export function parseColor(colorStr, opacity = 1) {
161
161
  return color(r, g, b, alphaClamped * 255 * opacity);
162
162
  }
163
163
 
164
- // Handle hex colors
164
+ // Handle hex colors: #RGB, #RGBA, #RRGGBB, #RRGGBBAA (CSS Color Level 4)
165
165
  const hexMatch = colorStr.match(/^#([0-9a-f]{3,8})$/i);
166
166
  if (hexMatch) {
167
167
  const hex = hexMatch[1];
168
168
  if (hex.length === 3) {
169
+ // #RGB format - expand each digit to two (e.g., #F00 -> #FF0000)
169
170
  return color(
170
171
  parseInt(hex[0] + hex[0], 16),
171
172
  parseInt(hex[1] + hex[1], 16),
172
173
  parseInt(hex[2] + hex[2], 16),
173
174
  255 * opacity,
174
175
  );
176
+ } else if (hex.length === 4) {
177
+ // #RGBA format (CSS4) - expand each digit to two (e.g., #F00A -> #FF0000AA)
178
+ return color(
179
+ parseInt(hex[0] + hex[0], 16),
180
+ parseInt(hex[1] + hex[1], 16),
181
+ parseInt(hex[2] + hex[2], 16),
182
+ parseInt(hex[3] + hex[3], 16) * opacity,
183
+ );
175
184
  } else if (hex.length === 6) {
176
185
  return color(
177
186
  parseInt(hex.slice(0, 2), 16),
@@ -1564,8 +1564,10 @@ export function clipPathToViewBox(pathCommands, viewBox) {
1564
1564
  continue;
1565
1565
  }
1566
1566
 
1567
- const x = D(cmd.x);
1568
- const y = D(cmd.y);
1567
+ // BUG FIX: Handle relative coordinates (lowercase 'm')
1568
+ const isRelative = cmd.type === cmd.type.toLowerCase();
1569
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
1570
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
1569
1571
 
1570
1572
  // Only add move if point is inside bounds
1571
1573
  if (pointInBBox({ x, y }, bounds)) {
@@ -1587,8 +1589,10 @@ export function clipPathToViewBox(pathCommands, viewBox) {
1587
1589
  continue;
1588
1590
  }
1589
1591
 
1590
- const x = D(cmd.x);
1591
- const y = D(cmd.y);
1592
+ // BUG FIX: Handle relative coordinates (lowercase 'l')
1593
+ const isRelative = cmd.type === cmd.type.toLowerCase();
1594
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
1595
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
1592
1596
 
1593
1597
  // Clip line segment
1594
1598
  const clipped = clipLine(
@@ -1629,7 +1633,9 @@ export function clipPathToViewBox(pathCommands, viewBox) {
1629
1633
  continue;
1630
1634
  }
1631
1635
 
1632
- const x = D(cmd.x);
1636
+ // BUG FIX: Handle relative coordinates (lowercase 'h')
1637
+ const isRelative = cmd.type === cmd.type.toLowerCase();
1638
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
1633
1639
  const clipped = clipLine(
1634
1640
  { x: currentX, y: currentY },
1635
1641
  { x, y: currentY },
@@ -1665,7 +1671,9 @@ export function clipPathToViewBox(pathCommands, viewBox) {
1665
1671
  continue;
1666
1672
  }
1667
1673
 
1668
- const y = D(cmd.y);
1674
+ // BUG FIX: Handle relative coordinates (lowercase 'v')
1675
+ const isRelative = cmd.type === cmd.type.toLowerCase();
1676
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
1669
1677
  const clipped = clipLine(
1670
1678
  { x: currentX, y: currentY },
1671
1679
  { x: currentX, y },
@@ -1708,8 +1716,10 @@ export function clipPathToViewBox(pathCommands, viewBox) {
1708
1716
  // BUG FIX: Sample the curve to better approximate clipping
1709
1717
  // A full implementation would clip curves exactly, but sampling provides
1710
1718
  // a reasonable approximation (WHY: curves can extend outside bounds even if endpoints are inside)
1711
- const x = D(cmd.x);
1712
- const y = D(cmd.y);
1719
+ // BUG FIX: Handle relative coordinates (lowercase 'c', 's', 'q', 't', 'a')
1720
+ const isRelative = cmd.type === cmd.type.toLowerCase();
1721
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
1722
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
1713
1723
 
1714
1724
  // Sample 10 points along the curve path from current to end
1715
1725
  const samples = 10;
@@ -1361,11 +1361,11 @@ export function removeZeroLengthSegments(pathData, tolerance = EPSILON) {
1361
1361
  currentY = D(0);
1362
1362
  let startX = D(0),
1363
1363
  startY = D(0);
1364
- // Track previous control points for S and T commands (reserved for future S/T command handling)
1364
+ // Track previous control points for S and T commands (smooth Bezier continuity)
1365
1365
  let prevCp2X = null,
1366
- prevCp2Y = null; // For S command (cubic)
1366
+ prevCp2Y = null; // For S command (cubic) - tracks 2nd control point of previous C/S
1367
1367
  let prevCpX = null,
1368
- prevCpY = null; // For T command (quadratic)
1368
+ prevCpY = null; // For T command (quadratic) - tracks control point of previous Q/T
1369
1369
 
1370
1370
  for (let idx = 0; idx < pathData.length; idx++) {
1371
1371
  const item = pathData[idx];
@@ -5,7 +5,7 @@
5
5
  * Works in both Node.js and browser environments.
6
6
  *
7
7
  * @module svg-matrix-lib
8
- * @version 1.3.1
8
+ * @version 1.3.4
9
9
  * @license MIT
10
10
  *
11
11
  * @example Browser usage:
@@ -32,7 +32,7 @@ Decimal.set({ precision: 80 });
32
32
  /**
33
33
  * Library version
34
34
  */
35
- export const VERSION = "1.3.1";
35
+ export const VERSION = "1.3.4";
36
36
 
37
37
  // Export core classes
38
38
  export { Decimal, Matrix, Vector };
@@ -5,7 +5,7 @@
5
5
  * Provides 69+ operations for cleaning, optimizing, and transforming SVG files.
6
6
  *
7
7
  * @module svg-toolbox-lib
8
- * @version 1.3.1
8
+ * @version 1.3.4
9
9
  * @license MIT
10
10
  *
11
11
  * @example Browser usage:
@@ -34,7 +34,7 @@ import * as SVGToolboxModule from "./svg-toolbox.js";
34
34
  /**
35
35
  * Library version
36
36
  */
37
- export const VERSION = "1.3.1";
37
+ export const VERSION = "1.3.4";
38
38
 
39
39
  /**
40
40
  * Default export for browser global (window.SVGToolbox)
package/src/svgm-lib.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * comprehensive SVG manipulation (SVGToolbox). Works in Node.js and browser.
6
6
  *
7
7
  * @module svgm-lib
8
- * @version 1.3.1
8
+ * @version 1.3.4
9
9
  * @license MIT
10
10
  *
11
11
  * @example Browser usage:
@@ -49,7 +49,7 @@ Decimal.set({ precision: 80 });
49
49
  /**
50
50
  * Library version
51
51
  */
52
- export const VERSION = "1.3.1";
52
+ export const VERSION = "1.3.4";
53
53
 
54
54
  // Export math classes
55
55
  export { Decimal, Matrix, Vector };
@@ -294,7 +294,15 @@ export function decomposeMatrix(matrix) {
294
294
  }
295
295
 
296
296
  // Calculate rotation angle from first column
297
- const rotation = Decimal.atan2(b, a);
297
+ // BUG FIX: When scaleX is negative (reflection), we must compute rotation from negated
298
+ // column vector to separate the reflection component from the rotation component.
299
+ // Without this fix, X-flip matrix(-1,0,0,1,0,0) gives rotation=π instead of 0.
300
+ let rotation;
301
+ if (scaleX.lessThan(0)) {
302
+ rotation = Decimal.atan2(b.neg(), a.neg());
303
+ } else {
304
+ rotation = Decimal.atan2(b, a);
305
+ }
298
306
 
299
307
  // Calculate skew
300
308
  // After removing rotation and scale, we get the skew
@@ -63,17 +63,9 @@ function normalizeAngle(theta) {
63
63
 
64
64
  // For large angles, normalize to [-2π, 2π] to reduce precision loss
65
65
  // Using modulo with 2π (tau) to find equivalent angle in standard range
66
+ // Note: modulo already guarantees result in (-2π, 2π) exclusive
66
67
  const TWO_PI = 6.283185307179586;
67
- let normalized = tNum % TWO_PI;
68
-
69
- // Ensure result is in [-2π, 2π] range
70
- if (normalized > TWO_PI) {
71
- normalized -= TWO_PI;
72
- } else if (normalized < -TWO_PI) {
73
- normalized += TWO_PI;
74
- }
75
-
76
- return normalized;
68
+ return tNum % TWO_PI;
77
69
  }
78
70
 
79
71
  /**
@@ -229,11 +221,8 @@ export function scale(sx, sy = null, sz = null) {
229
221
  const syD = D(syValue);
230
222
  const szD = D(szValue);
231
223
 
232
- // Warn about zero scale factors creating singular matrices
233
- if (sxD.isZero() || syD.isZero() || szD.isZero()) {
234
- // Zero scale is mathematically valid but creates non-invertible matrix
235
- // We allow it but document the behavior in JSDoc above
236
- }
224
+ // Note: Zero scale factors create singular (non-invertible) matrices
225
+ // This is mathematically valid but cannot be reversed - documented in JSDoc above
237
226
 
238
227
  return Matrix.from([
239
228
  [sxD, new Decimal(0), new Decimal(0), new Decimal(0)],