@emasoft/svg-matrix 1.0.2 → 1.0.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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * SVG Transform Flattening Utility
3
+ *
4
+ * Parses SVG transform attributes, builds CTM (Current Transform Matrix) for each element,
5
+ * and can flatten all transforms by applying them directly to coordinates.
6
+ *
7
+ * @module svg-flatten
8
+ */
9
+
10
+ import Decimal from 'decimal.js';
11
+ import { Matrix } from './matrix.js';
12
+ import * as Transforms2D from './transforms2d.js';
13
+
14
+ // Set high precision for all calculations
15
+ Decimal.set({ precision: 80 });
16
+
17
+ /**
18
+ * Parse a single SVG transform function and return a 3x3 matrix.
19
+ * Supports: translate, scale, rotate, skewX, skewY, matrix
20
+ *
21
+ * @param {string} func - Transform function name
22
+ * @param {number[]} args - Numeric arguments
23
+ * @returns {Matrix} 3x3 transformation matrix
24
+ */
25
+ export function parseTransformFunction(func, args) {
26
+ const D = x => new Decimal(x);
27
+
28
+ switch (func.toLowerCase()) {
29
+ case 'translate': {
30
+ const tx = args[0] || 0;
31
+ const ty = args[1] || 0;
32
+ return Transforms2D.translation(tx, ty);
33
+ }
34
+
35
+ case 'scale': {
36
+ const sx = args[0] || 1;
37
+ const sy = args[1] !== undefined ? args[1] : sx;
38
+ return Transforms2D.scale(sx, sy);
39
+ }
40
+
41
+ case 'rotate': {
42
+ // SVG rotate is in degrees, can have optional cx, cy
43
+ const angleDeg = args[0] || 0;
44
+ const angleRad = D(angleDeg).mul(D(Math.PI)).div(180);
45
+
46
+ if (args.length >= 3) {
47
+ // rotate(angle, cx, cy) - rotation around point
48
+ const cx = args[1];
49
+ const cy = args[2];
50
+ return Transforms2D.rotateAroundPoint(angleRad, cx, cy);
51
+ }
52
+ return Transforms2D.rotate(angleRad);
53
+ }
54
+
55
+ case 'skewx': {
56
+ const angleDeg = args[0] || 0;
57
+ const angleRad = D(angleDeg).mul(D(Math.PI)).div(180);
58
+ const tanVal = Decimal.tan(angleRad);
59
+ return Transforms2D.skew(tanVal, 0);
60
+ }
61
+
62
+ case 'skewy': {
63
+ const angleDeg = args[0] || 0;
64
+ const angleRad = D(angleDeg).mul(D(Math.PI)).div(180);
65
+ const tanVal = Decimal.tan(angleRad);
66
+ return Transforms2D.skew(0, tanVal);
67
+ }
68
+
69
+ case 'matrix': {
70
+ // matrix(a, b, c, d, e, f) -> | a c e |
71
+ // | b d f |
72
+ // | 0 0 1 |
73
+ const [a, b, c, d, e, f] = args.map(x => D(x || 0));
74
+ return Matrix.from([
75
+ [a, c, e],
76
+ [b, d, f],
77
+ [D(0), D(0), D(1)]
78
+ ]);
79
+ }
80
+
81
+ default:
82
+ console.warn(`Unknown transform function: ${func}`);
83
+ return Matrix.identity(3);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Parse an SVG transform attribute string into a combined matrix.
89
+ * Handles multiple transforms: "translate(10,20) rotate(45) scale(2)"
90
+ *
91
+ * @param {string} transformStr - SVG transform attribute value
92
+ * @returns {Matrix} Combined 3x3 transformation matrix
93
+ */
94
+ export function parseTransformAttribute(transformStr) {
95
+ if (!transformStr || transformStr.trim() === '') {
96
+ return Matrix.identity(3);
97
+ }
98
+
99
+ // Regex to match transform functions: name(args)
100
+ const transformRegex = /(\w+)\s*\(([^)]*)\)/g;
101
+ let match;
102
+ let result = Matrix.identity(3);
103
+
104
+ while ((match = transformRegex.exec(transformStr)) !== null) {
105
+ const func = match[1];
106
+ const argsStr = match[2];
107
+
108
+ // Parse arguments (comma or space separated)
109
+ const args = argsStr
110
+ .split(/[\s,]+/)
111
+ .filter(s => s.length > 0)
112
+ .map(s => parseFloat(s));
113
+
114
+ const matrix = parseTransformFunction(func, args);
115
+ // Transforms are applied left-to-right in SVG, so we multiply in order
116
+ result = result.mul(matrix);
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Build the CTM (Current Transform Matrix) for an element by walking up its ancestry.
124
+ *
125
+ * @param {Object[]} transformStack - Array of transform strings from root to element
126
+ * @returns {Matrix} Combined CTM as 3x3 matrix
127
+ */
128
+ export function buildCTM(transformStack) {
129
+ let ctm = Matrix.identity(3);
130
+
131
+ for (const transformStr of transformStack) {
132
+ if (transformStr) {
133
+ const matrix = parseTransformAttribute(transformStr);
134
+ ctm = ctm.mul(matrix);
135
+ }
136
+ }
137
+
138
+ return ctm;
139
+ }
140
+
141
+ /**
142
+ * Apply a CTM to a 2D point.
143
+ *
144
+ * @param {Matrix} ctm - 3x3 transformation matrix
145
+ * @param {number|string|Decimal} x - X coordinate
146
+ * @param {number|string|Decimal} y - Y coordinate
147
+ * @returns {{x: Decimal, y: Decimal}} Transformed coordinates
148
+ */
149
+ export function applyToPoint(ctm, x, y) {
150
+ const [tx, ty] = Transforms2D.applyTransform(ctm, x, y);
151
+ return { x: tx, y: ty };
152
+ }
153
+
154
+ /**
155
+ * Convert a CTM back to SVG matrix() notation.
156
+ *
157
+ * @param {Matrix} ctm - 3x3 transformation matrix
158
+ * @param {number} [precision=6] - Decimal places for output
159
+ * @returns {string} SVG matrix transform string
160
+ */
161
+ export function toSVGMatrix(ctm, precision = 6) {
162
+ const a = ctm.data[0][0].toFixed(precision);
163
+ const b = ctm.data[1][0].toFixed(precision);
164
+ const c = ctm.data[0][1].toFixed(precision);
165
+ const d = ctm.data[1][1].toFixed(precision);
166
+ const e = ctm.data[0][2].toFixed(precision);
167
+ const f = ctm.data[1][2].toFixed(precision);
168
+
169
+ return `matrix(${a}, ${b}, ${c}, ${d}, ${e}, ${f})`;
170
+ }
171
+
172
+ /**
173
+ * Check if a matrix is effectively the identity matrix.
174
+ *
175
+ * @param {Matrix} m - 3x3 matrix to check
176
+ * @param {string} [tolerance='1e-10'] - Tolerance for comparison
177
+ * @returns {boolean} True if matrix is identity within tolerance
178
+ */
179
+ export function isIdentity(m, tolerance = '1e-10') {
180
+ const identity = Matrix.identity(3);
181
+ return m.equals(identity, tolerance);
182
+ }
183
+
184
+ /**
185
+ * Transform path data coordinates using a CTM.
186
+ * Handles M, L, C, Q, S, T, A, Z commands (absolute only for now).
187
+ *
188
+ * @param {string} pathData - SVG path d attribute
189
+ * @param {Matrix} ctm - 3x3 transformation matrix
190
+ * @returns {string} Transformed path data
191
+ */
192
+ export function transformPathData(pathData, ctm) {
193
+ // Simple regex-based path parser for common commands
194
+ const result = [];
195
+ const commandRegex = /([MLHVCSQTAZ])([^MLHVCSQTAZ]*)/gi;
196
+ let match;
197
+
198
+ while ((match = commandRegex.exec(pathData)) !== null) {
199
+ const cmd = match[1];
200
+ const argsStr = match[2].trim();
201
+ const args = argsStr
202
+ .split(/[\s,]+/)
203
+ .filter(s => s.length > 0)
204
+ .map(s => parseFloat(s));
205
+
206
+ const cmdUpper = cmd.toUpperCase();
207
+
208
+ switch (cmdUpper) {
209
+ case 'M':
210
+ case 'L':
211
+ case 'T': {
212
+ // Pairs of coordinates
213
+ const transformed = [];
214
+ for (let i = 0; i < args.length; i += 2) {
215
+ const { x, y } = applyToPoint(ctm, args[i], args[i + 1]);
216
+ transformed.push(x.toFixed(6), y.toFixed(6));
217
+ }
218
+ result.push(cmd + ' ' + transformed.join(' '));
219
+ break;
220
+ }
221
+
222
+ case 'H': {
223
+ // Horizontal line - becomes L after transform
224
+ const { x, y } = applyToPoint(ctm, args[0], 0);
225
+ result.push('L ' + x.toFixed(6) + ' ' + y.toFixed(6));
226
+ break;
227
+ }
228
+
229
+ case 'V': {
230
+ // Vertical line - becomes L after transform
231
+ const { x, y } = applyToPoint(ctm, 0, args[0]);
232
+ result.push('L ' + x.toFixed(6) + ' ' + y.toFixed(6));
233
+ break;
234
+ }
235
+
236
+ case 'C': {
237
+ // Cubic bezier: 3 pairs of coordinates
238
+ const transformed = [];
239
+ for (let i = 0; i < args.length; i += 2) {
240
+ const { x, y } = applyToPoint(ctm, args[i], args[i + 1]);
241
+ transformed.push(x.toFixed(6), y.toFixed(6));
242
+ }
243
+ result.push('C ' + transformed.join(' '));
244
+ break;
245
+ }
246
+
247
+ case 'S': {
248
+ // Smooth cubic: 2 pairs of coordinates
249
+ const transformed = [];
250
+ for (let i = 0; i < args.length; i += 2) {
251
+ const { x, y } = applyToPoint(ctm, args[i], args[i + 1]);
252
+ transformed.push(x.toFixed(6), y.toFixed(6));
253
+ }
254
+ result.push('S ' + transformed.join(' '));
255
+ break;
256
+ }
257
+
258
+ case 'Q': {
259
+ // Quadratic bezier: 2 pairs of coordinates
260
+ const transformed = [];
261
+ for (let i = 0; i < args.length; i += 2) {
262
+ const { x, y } = applyToPoint(ctm, args[i], args[i + 1]);
263
+ transformed.push(x.toFixed(6), y.toFixed(6));
264
+ }
265
+ result.push('Q ' + transformed.join(' '));
266
+ break;
267
+ }
268
+
269
+ case 'A': {
270
+ // Arc: rx ry x-axis-rotation large-arc-flag sweep-flag x y
271
+ // Transform end point, scale radii (approximate for non-uniform scale)
272
+ const transformed = [];
273
+ for (let i = 0; i < args.length; i += 7) {
274
+ const rx = args[i];
275
+ const ry = args[i + 1];
276
+ const rotation = args[i + 2];
277
+ const largeArc = args[i + 3];
278
+ const sweep = args[i + 4];
279
+ const x = args[i + 5];
280
+ const y = args[i + 6];
281
+
282
+ const { x: tx, y: ty } = applyToPoint(ctm, x, y);
283
+
284
+ // Scale radii approximately (doesn't handle rotation correctly for skew)
285
+ const scaleX = ctm.data[0][0].abs().plus(ctm.data[0][1].abs()).div(2);
286
+ const scaleY = ctm.data[1][0].abs().plus(ctm.data[1][1].abs()).div(2);
287
+
288
+ transformed.push(
289
+ (rx * scaleX.toNumber()).toFixed(6),
290
+ (ry * scaleY.toNumber()).toFixed(6),
291
+ rotation,
292
+ largeArc,
293
+ sweep,
294
+ tx.toFixed(6),
295
+ ty.toFixed(6)
296
+ );
297
+ }
298
+ result.push('A ' + transformed.join(' '));
299
+ break;
300
+ }
301
+
302
+ case 'Z': {
303
+ result.push('Z');
304
+ break;
305
+ }
306
+
307
+ default:
308
+ // Keep unknown commands as-is
309
+ result.push(cmd + ' ' + argsStr);
310
+ }
311
+ }
312
+
313
+ return result.join(' ');
314
+ }
315
+
316
+ /**
317
+ * Information about precision comparison between float and Decimal.
318
+ */
319
+ export const PRECISION_INFO = {
320
+ floatError: 0.0143, // Typical error: 10 -> 9.9857
321
+ decimalPrecision: 80,
322
+ typicalRoundTripError: '2e-79',
323
+ improvementFactor: '1.43e+77'
324
+ };
325
+
326
+ export default {
327
+ parseTransformFunction,
328
+ parseTransformAttribute,
329
+ buildCTM,
330
+ applyToPoint,
331
+ toSVGMatrix,
332
+ isIdentity,
333
+ transformPathData,
334
+ PRECISION_INFO
335
+ };
@@ -1,7 +1,28 @@
1
1
  import Decimal from 'decimal.js';
2
2
  import { Matrix } from './matrix.js';
3
+
4
+ /**
5
+ * Helper to convert any numeric input to Decimal.
6
+ * @param {number|string|Decimal} x - The value to convert
7
+ * @returns {Decimal} The Decimal representation
8
+ */
3
9
  const D = x => (x instanceof Decimal ? x : new Decimal(x));
4
10
 
11
+ /**
12
+ * 2D Affine Transforms using 3x3 homogeneous matrices.
13
+ *
14
+ * All transforms return 3x3 Matrix objects that can be composed via multiplication.
15
+ * Transform composition is right-to-left: T.mul(R).mul(S) applies S first, then R, then T.
16
+ *
17
+ * @module Transforms2D
18
+ */
19
+
20
+ /**
21
+ * Create a 2D translation matrix.
22
+ * @param {number|string|Decimal} tx - Translation in X direction
23
+ * @param {number|string|Decimal} ty - Translation in Y direction
24
+ * @returns {Matrix} 3x3 translation matrix
25
+ */
5
26
  export function translation(tx, ty) {
6
27
  return Matrix.from([
7
28
  [new Decimal(1), new Decimal(0), D(tx)],
@@ -10,6 +31,12 @@ export function translation(tx, ty) {
10
31
  ]);
11
32
  }
12
33
 
34
+ /**
35
+ * Create a 2D scaling matrix.
36
+ * @param {number|string|Decimal} sx - Scale factor in X direction
37
+ * @param {number|string|Decimal} [sy=sx] - Scale factor in Y direction (defaults to sx for uniform scaling)
38
+ * @returns {Matrix} 3x3 scaling matrix
39
+ */
13
40
  export function scale(sx, sy = null) {
14
41
  if (sy === null) sy = sx;
15
42
  return Matrix.from([
@@ -19,6 +46,17 @@ export function scale(sx, sy = null) {
19
46
  ]);
20
47
  }
21
48
 
49
+ /**
50
+ * Create a 2D rotation matrix (counterclockwise around origin).
51
+ *
52
+ * Uses standard rotation matrix:
53
+ * | cos(θ) -sin(θ) 0 |
54
+ * | sin(θ) cos(θ) 0 |
55
+ * | 0 0 1 |
56
+ *
57
+ * @param {number|string|Decimal} theta - Rotation angle in radians
58
+ * @returns {Matrix} 3x3 rotation matrix
59
+ */
22
60
  export function rotate(theta) {
23
61
  const t = D(theta);
24
62
  const c = new Decimal(Math.cos(t.toNumber()));
@@ -30,10 +68,32 @@ export function rotate(theta) {
30
68
  ]);
31
69
  }
32
70
 
71
+ /**
72
+ * Create a 2D rotation matrix around a specific point.
73
+ * Equivalent to: translate(px, py) × rotate(theta) × translate(-px, -py)
74
+ *
75
+ * @param {number|string|Decimal} theta - Rotation angle in radians
76
+ * @param {number|string|Decimal} px - X coordinate of rotation center
77
+ * @param {number|string|Decimal} py - Y coordinate of rotation center
78
+ * @returns {Matrix} 3x3 rotation matrix around point (px, py)
79
+ */
33
80
  export function rotateAroundPoint(theta, px, py) {
34
- return translation(px, py).mul(rotate(theta)).mul(translation(new Decimal(px).neg(), new Decimal(py).neg()));
81
+ const pxD = D(px);
82
+ const pyD = D(py);
83
+ return translation(pxD, pyD).mul(rotate(theta)).mul(translation(pxD.negated(), pyD.negated()));
35
84
  }
36
85
 
86
+ /**
87
+ * Create a 2D skew (shear) matrix.
88
+ *
89
+ * | 1 ax 0 |
90
+ * | ay 1 0 |
91
+ * | 0 0 1 |
92
+ *
93
+ * @param {number|string|Decimal} ax - Skew factor in X direction (affects X based on Y)
94
+ * @param {number|string|Decimal} ay - Skew factor in Y direction (affects Y based on X)
95
+ * @returns {Matrix} 3x3 skew matrix
96
+ */
37
97
  export function skew(ax, ay) {
38
98
  return Matrix.from([
39
99
  [new Decimal(1), D(ax), new Decimal(0)],
@@ -42,6 +102,17 @@ export function skew(ax, ay) {
42
102
  ]);
43
103
  }
44
104
 
105
+ /**
106
+ * Create a stretch matrix along a specified axis direction.
107
+ * Stretches by factor k along the unit vector (ux, uy).
108
+ *
109
+ * The axis should be normalized (ux² + uy² = 1), but this is not enforced.
110
+ *
111
+ * @param {number|string|Decimal} ux - X component of axis direction (unit vector)
112
+ * @param {number|string|Decimal} uy - Y component of axis direction (unit vector)
113
+ * @param {number|string|Decimal} k - Stretch factor along the axis
114
+ * @returns {Matrix} 3x3 stretch matrix
115
+ */
45
116
  export function stretchAlongAxis(ux, uy, k) {
46
117
  const uxD = D(ux), uyD = D(uy), kD = D(k);
47
118
  const one = new Decimal(1);
@@ -57,9 +128,56 @@ export function stretchAlongAxis(ux, uy, k) {
57
128
  ]);
58
129
  }
59
130
 
131
+ /**
132
+ * Apply a 2D transform matrix to a point.
133
+ * Uses homogeneous coordinates with perspective division.
134
+ *
135
+ * @param {Matrix} M - 3x3 transformation matrix
136
+ * @param {number|string|Decimal} x - X coordinate of point
137
+ * @param {number|string|Decimal} y - Y coordinate of point
138
+ * @returns {Decimal[]} Transformed point as [x', y'] array of Decimals
139
+ */
60
140
  export function applyTransform(M, x, y) {
61
141
  const P = Matrix.from([[D(x)], [D(y)], [new Decimal(1)]]);
62
142
  const R = M.mul(P);
63
143
  const rx = R.data[0][0], ry = R.data[1][0], rw = R.data[2][0];
144
+ // Perspective division (for affine transforms, rw is always 1)
64
145
  return [rx.div(rw), ry.div(rw)];
65
- }
146
+ }
147
+
148
+ /**
149
+ * Create a reflection matrix across the X axis (flips Y).
150
+ * @returns {Matrix} 3x3 reflection matrix
151
+ */
152
+ export function reflectX() {
153
+ return Matrix.from([
154
+ [new Decimal(1), new Decimal(0), new Decimal(0)],
155
+ [new Decimal(0), new Decimal(-1), new Decimal(0)],
156
+ [new Decimal(0), new Decimal(0), new Decimal(1)]
157
+ ]);
158
+ }
159
+
160
+ /**
161
+ * Create a reflection matrix across the Y axis (flips X).
162
+ * @returns {Matrix} 3x3 reflection matrix
163
+ */
164
+ export function reflectY() {
165
+ return Matrix.from([
166
+ [new Decimal(-1), new Decimal(0), new Decimal(0)],
167
+ [new Decimal(0), new Decimal(1), new Decimal(0)],
168
+ [new Decimal(0), new Decimal(0), new Decimal(1)]
169
+ ]);
170
+ }
171
+
172
+ /**
173
+ * Create a reflection matrix across the origin (flips both X and Y).
174
+ * Equivalent to rotation by π radians.
175
+ * @returns {Matrix} 3x3 reflection matrix
176
+ */
177
+ export function reflectOrigin() {
178
+ return Matrix.from([
179
+ [new Decimal(-1), new Decimal(0), new Decimal(0)],
180
+ [new Decimal(0), new Decimal(-1), new Decimal(0)],
181
+ [new Decimal(0), new Decimal(0), new Decimal(1)]
182
+ ]);
183
+ }