@emasoft/svg-matrix 1.3.2 → 1.3.5

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,6 +1,6 @@
1
1
  {
2
- "version": "1.3.2",
3
- "buildTime": "2026-01-10T15:48:31.859Z",
2
+ "version": "1.3.5",
3
+ "buildTime": "2026-01-25T12:44:03.807Z",
4
4
  "bundles": {
5
5
  "esm": [
6
6
  {
@@ -11,14 +11,14 @@
11
11
  },
12
12
  {
13
13
  "name": "svg-toolbox.min.js",
14
- "size": 577127,
15
- "gzipSize": 152269,
14
+ "size": 578044,
15
+ "gzipSize": 152616,
16
16
  "description": "ESM bundle for bundlers/Node.js (includes decimal.js)"
17
17
  },
18
18
  {
19
19
  "name": "svgm.min.js",
20
- "size": 602429,
21
- "gzipSize": 159416,
20
+ "size": 603346,
21
+ "gzipSize": 159757,
22
22
  "description": "ESM bundle for bundlers/Node.js (includes decimal.js)"
23
23
  }
24
24
  ],
@@ -31,8 +31,8 @@
31
31
  },
32
32
  {
33
33
  "name": "svg-toolbox.global.min.js",
34
- "size": 577256,
35
- "gzipSize": 151638,
34
+ "size": 578173,
35
+ "gzipSize": 151976,
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.2",
3
+ "version": "1.3.5",
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",
@@ -384,6 +384,12 @@ export function optimizeAnimationValues(values, precision = 3) {
384
384
  );
385
385
  }
386
386
 
387
+ // Preserve data URIs exactly (they contain semicolons that shouldn't be split)
388
+ // Data URIs have format: data:[<mediatype>][;base64],<data>
389
+ if (values.startsWith("data:")) {
390
+ return values;
391
+ }
392
+
387
393
  // Split by semicolon
388
394
  const parts = values.split(";");
389
395
 
@@ -398,6 +404,29 @@ export function optimizeAnimationValues(values, precision = 3) {
398
404
  return trimmed;
399
405
  }
400
406
 
407
+ // Preserve data URIs exactly (e.g., data:image/jpeg;base64,...)
408
+ if (trimmed.startsWith("data:")) {
409
+ return trimmed;
410
+ }
411
+
412
+ // Preserve color functions (rgb, rgba, hsl, hsla, etc.) exactly
413
+ // These contain parentheses and commas that should not be modified
414
+ if (/^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab|color)\s*\(/i.test(trimmed)) {
415
+ return trimmed;
416
+ }
417
+
418
+ // Preserve SVG path data (starts with path command M, m, L, l, etc.)
419
+ // Path data format: "M 26.5,32.5 L 33.5,32.5 C 40,50 60,70 80,90 Z"
420
+ if (/^[MmZzLlHhVvCcSsQqTtAa][\s\d.,+-]+/.test(trimmed)) {
421
+ return trimmed;
422
+ }
423
+
424
+ // Preserve font-family values (comma-separated font names with possible quotes)
425
+ // Pattern: word or quoted string, followed by comma and more words/quotes
426
+ if (/^(['"]?[\w\s-]+['"]?,\s*)+['"]?[\w\s-]+['"]?$/.test(trimmed)) {
427
+ return trimmed;
428
+ }
429
+
401
430
  // Try to parse as numbers (could be space-separated like "0 0" for translate)
402
431
  const nums = trimmed.split(/[\s,]+/).filter((n) => n); // Filter empty strings
403
432
  const optimizedNums = nums.map((n) => {
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.2
8
+ * @version 1.3.5
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.2";
138
+ export const VERSION = "1.3.5";
139
139
 
140
140
  /**
141
141
  * Default precision for path output (decimal places)
@@ -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
  }
@@ -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;
@@ -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.2
8
+ * @version 1.3.5
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.2";
35
+ export const VERSION = "1.3.5";
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.2
8
+ * @version 1.3.5
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.2";
37
+ export const VERSION = "1.3.5";
38
38
 
39
39
  /**
40
40
  * Default export for browser global (window.SVGToolbox)
@@ -266,6 +266,7 @@ const SVG11_ATTRIBUTES = new Set([
266
266
  "filter",
267
267
  "filterRes",
268
268
  "filterUnits",
269
+ "filterPrimitiveUnits",
269
270
  "flood-color",
270
271
  "flood-opacity",
271
272
  "font-family",
@@ -14171,6 +14172,7 @@ export const fixInvalidSVG = createOperation((doc, options = {}) => {
14171
14172
  maskUnits: ["userSpaceOnUse", "objectBoundingBox"],
14172
14173
  maskContentUnits: ["userSpaceOnUse", "objectBoundingBox"],
14173
14174
  filterUnits: ["userSpaceOnUse", "objectBoundingBox"],
14175
+ filterPrimitiveUnits: ["userSpaceOnUse", "objectBoundingBox"],
14174
14176
  primitiveUnits: ["userSpaceOnUse", "objectBoundingBox"],
14175
14177
  markerUnits: ["strokeWidth", "userSpaceOnUse"],
14176
14178
  // Other
@@ -16143,11 +16145,20 @@ export async function validateSVGAsync(input, options = {}) {
16143
16145
  // - title/desc (text content for accessibility - not SVG structure)
16144
16146
  // - metadata (can contain arbitrary XML metadata like Dublin Core, RDF)
16145
16147
  // - Namespaced elements (e.g., d:testDescription, rdf:RDF)
16148
+ // - Elements in non-SVG namespaces (e.g., <foo xmlns="http://example.org/foo">)
16146
16149
  const isNamespacedElement = tagName.includes(":");
16150
+ const isNonSvgNamespace =
16151
+ el.namespaceURI && el.namespaceURI !== "http://www.w3.org/2000/svg";
16152
+ // Check for explicit xmlns attribute declaring a non-SVG namespace
16153
+ const xmlns = el.getAttribute ? el.getAttribute("xmlns") : null;
16154
+ const hasNonSvgXmlns =
16155
+ xmlns && xmlns !== "http://www.w3.org/2000/svg";
16147
16156
  const inNonSvgContext =
16148
16157
  insideNonSvgContext ||
16149
16158
  NON_SVG_CONTEXT_ELEMENTS.has(tagLower) ||
16150
- isNamespacedElement;
16159
+ isNamespacedElement ||
16160
+ isNonSvgNamespace ||
16161
+ hasNonSvgXmlns;
16151
16162
 
16152
16163
  // Skip validation entirely if we're in non-SVG context
16153
16164
  if (!inNonSvgContext) {
@@ -16206,11 +16217,20 @@ export async function validateSVGAsync(input, options = {}) {
16206
16217
  // - title/desc (text content elements)
16207
16218
  // - metadata (arbitrary XML metadata content)
16208
16219
  // - Namespaced elements (e.g., d:testDescription, rdf:RDF)
16220
+ // - Elements in non-SVG namespaces (e.g., <foo xmlns="http://example.org/foo">)
16209
16221
  const isNamespacedElement = el.tagName.includes(":");
16222
+ const isNonSvgNamespace =
16223
+ el.namespaceURI && el.namespaceURI !== "http://www.w3.org/2000/svg";
16224
+ // Check for explicit xmlns attribute declaring a non-SVG namespace
16225
+ const xmlns = el.getAttribute ? el.getAttribute("xmlns") : null;
16226
+ const hasNonSvgXmlns =
16227
+ xmlns && xmlns !== "http://www.w3.org/2000/svg";
16210
16228
  const inNonSvgContext =
16211
16229
  insideNonSvgContext ||
16212
16230
  NON_SVG_CONTEXT_ELEMENTS.has(tagName) ||
16213
- isNamespacedElement;
16231
+ isNamespacedElement ||
16232
+ isNonSvgNamespace ||
16233
+ hasNonSvgXmlns;
16214
16234
 
16215
16235
  // Skip attribute validation entirely if we're in non-SVG context
16216
16236
  if (!inNonSvgContext) {
@@ -16446,6 +16466,7 @@ export async function validateSVGAsync(input, options = {}) {
16446
16466
  maskUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16447
16467
  maskContentUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16448
16468
  filterUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16469
+ filterPrimitiveUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16449
16470
  primitiveUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16450
16471
  patternUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
16451
16472
  patternContentUnits: new Set(["userSpaceOnUse", "objectBoundingBox"]),
@@ -151,6 +151,7 @@ export const SVG11_ATTRIBUTES = new Set([
151
151
  "filter",
152
152
  "filterres",
153
153
  "filterunits",
154
+ "filterprimitiveunits",
154
155
  "flood-color",
155
156
  "flood-opacity",
156
157
  "font-family",
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.2
8
+ * @version 1.3.5
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.2";
52
+ export const VERSION = "1.3.5";
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