@emasoft/svg-matrix 1.0.4 → 1.0.6

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.
@@ -4,6 +4,30 @@
4
4
  * Parses SVG transform attributes, builds CTM (Current Transform Matrix) for each element,
5
5
  * and can flatten all transforms by applying them directly to coordinates.
6
6
  *
7
+ * ## Key Concepts
8
+ *
9
+ * ### CTM (Current Transform Matrix)
10
+ * The CTM is the cumulative transformation matrix from the root SVG viewport to an element.
11
+ * It is built by multiplying all transformation matrices from ancestors in order:
12
+ * CTM = viewport_transform × parent_group_transform × element_transform
13
+ *
14
+ * ### SVG Coordinate Systems
15
+ * - **Viewport coordinates**: Physical pixels on screen (e.g., width="800" height="600")
16
+ * - **viewBox coordinates**: User space coordinates defined by viewBox attribute
17
+ * - **User coordinates**: The coordinate system after applying all transforms
18
+ * - **objectBoundingBox**: Normalized (0,0) to (1,1) coordinate space of an element's bounding box
19
+ *
20
+ * ### Transform Application Order
21
+ * Transforms in SVG are applied right-to-left (matrix multiplication order):
22
+ * transform="translate(10,20) rotate(45)" means: first rotate, then translate
23
+ * This is equivalent to: T × R where T is translation and R is rotation
24
+ *
25
+ * ### viewBox to Viewport Mapping
26
+ * The viewBox attribute defines a rectangle in user space that maps to the viewport:
27
+ * - viewBox="minX minY width height" defines the user space rectangle
28
+ * - preserveAspectRatio controls how scaling and alignment occur
29
+ * - The transformation is: translate(viewport_offset) × scale(uniform_or_nonuniform) × translate(-minX, -minY)
30
+ *
7
31
  * @module svg-flatten
8
32
  */
9
33
 
@@ -14,13 +38,1128 @@ import * as Transforms2D from './transforms2d.js';
14
38
  // Set high precision for all calculations
15
39
  Decimal.set({ precision: 80 });
16
40
 
41
+ // ============================================================================
42
+ // viewBox and preserveAspectRatio Parsing
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Parse an SVG viewBox attribute into its component values.
47
+ *
48
+ * The viewBox defines the user space coordinate system for the SVG viewport.
49
+ * It specifies a rectangle in user space that should be mapped to the bounds
50
+ * of the viewport.
51
+ *
52
+ * @param {string} viewBoxStr - viewBox attribute value in format "minX minY width height"
53
+ * Values can be space or comma separated
54
+ * @returns {{minX: Decimal, minY: Decimal, width: Decimal, height: Decimal}|null}
55
+ * Parsed viewBox object with Decimal precision, or null if invalid/empty
56
+ *
57
+ * @example
58
+ * // Parse a standard viewBox
59
+ * const vb = parseViewBox("0 0 100 100");
60
+ * // Returns: { minX: Decimal(0), minY: Decimal(0), width: Decimal(100), height: Decimal(100) }
61
+ *
62
+ * @example
63
+ * // Comma-separated values also work
64
+ * const vb = parseViewBox("0,0,800,600");
65
+ *
66
+ * @example
67
+ * // Non-zero origin for panning/zooming
68
+ * const vb = parseViewBox("-50 -50 200 200");
69
+ * // Shows region from (-50,-50) to (150,150) in user space
70
+ */
71
+ export function parseViewBox(viewBoxStr) {
72
+ if (!viewBoxStr || viewBoxStr.trim() === '') {
73
+ return null;
74
+ }
75
+
76
+ const parts = viewBoxStr.trim().split(/[\s,]+/).map(s => new Decimal(s));
77
+ if (parts.length !== 4) {
78
+ console.warn(`Invalid viewBox: ${viewBoxStr}`);
79
+ return null;
80
+ }
81
+
82
+ return {
83
+ minX: parts[0],
84
+ minY: parts[1],
85
+ width: parts[2],
86
+ height: parts[3]
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Parse an SVG preserveAspectRatio attribute.
92
+ *
93
+ * The preserveAspectRatio attribute controls how an element's viewBox is fitted
94
+ * to the viewport when aspect ratios don't match. It consists of:
95
+ * - defer: Only applies to <image> elements (optional)
96
+ * - align: One of 9 alignment values (xMin/xMid/xMax + YMin/YMid/YMax) or "none"
97
+ * - meetOrSlice: "meet" (fit entirely, letterbox) or "slice" (fill, crop)
98
+ *
99
+ * Format: "[defer] <align> [<meetOrSlice>]"
100
+ *
101
+ * @param {string} parStr - preserveAspectRatio attribute value
102
+ * @returns {{defer: boolean, align: string, meetOrSlice: string}}
103
+ * Object with parsed components, defaults to {defer: false, align: 'xMidYMid', meetOrSlice: 'meet'}
104
+ *
105
+ * @example
106
+ * // Default centered scaling to fit
107
+ * const par = parsePreserveAspectRatio("xMidYMid meet");
108
+ * // Returns: { defer: false, align: 'xMidYMid', meetOrSlice: 'meet' }
109
+ *
110
+ * @example
111
+ * // Scale to fill, cropping if necessary
112
+ * const par = parsePreserveAspectRatio("xMidYMid slice");
113
+ *
114
+ * @example
115
+ * // No uniform scaling, stretch to fill
116
+ * const par = parsePreserveAspectRatio("none");
117
+ * // Returns: { defer: false, align: 'none', meetOrSlice: 'meet' }
118
+ *
119
+ * @example
120
+ * // Align to top-left corner
121
+ * const par = parsePreserveAspectRatio("xMinYMin meet");
122
+ */
123
+ export function parsePreserveAspectRatio(parStr) {
124
+ const result = {
125
+ defer: false,
126
+ align: 'xMidYMid', // default
127
+ meetOrSlice: 'meet' // default
128
+ };
129
+
130
+ if (!parStr || parStr.trim() === '') {
131
+ return result;
132
+ }
133
+
134
+ const parts = parStr.trim().split(/\s+/);
135
+ let idx = 0;
136
+
137
+ // Check for 'defer' (only applies to <image>)
138
+ if (parts[idx] === 'defer') {
139
+ result.defer = true;
140
+ idx++;
141
+ }
142
+
143
+ // Alignment value
144
+ if (parts[idx]) {
145
+ result.align = parts[idx];
146
+ idx++;
147
+ }
148
+
149
+ // meetOrSlice
150
+ if (parts[idx]) {
151
+ result.meetOrSlice = parts[idx].toLowerCase();
152
+ }
153
+
154
+ return result;
155
+ }
156
+
157
+ /**
158
+ * Compute the transformation matrix from viewBox coordinates to viewport.
159
+ *
160
+ * Implements the SVG 2 algorithm for viewBox + preserveAspectRatio mapping.
161
+ * This transformation converts user space coordinates (defined by viewBox)
162
+ * to viewport coordinates (actual pixels).
163
+ *
164
+ * The transformation consists of three steps:
165
+ * 1. Translate by (-minX, -minY) to move viewBox origin to (0, 0)
166
+ * 2. Scale uniformly or non-uniformly based on preserveAspectRatio
167
+ * 3. Translate for alignment within the viewport
168
+ *
169
+ * Algorithm details:
170
+ * - If align="none": Non-uniform scaling (may distort aspect ratio)
171
+ * scaleX = viewportWidth / viewBoxWidth
172
+ * scaleY = viewportHeight / viewBoxHeight
173
+ *
174
+ * - If meetOrSlice="meet": Use min(scaleX, scaleY) - content fits entirely (letterbox)
175
+ * - If meetOrSlice="slice": Use max(scaleX, scaleY) - viewport fills entirely (crop)
176
+ *
177
+ * - Alignment (xMin/xMid/xMax, YMin/YMid/YMax) determines offset:
178
+ * xMin: left aligned (offset = 0)
179
+ * xMid: center aligned (offset = (viewport - scaled) / 2)
180
+ * xMax: right aligned (offset = viewport - scaled)
181
+ *
182
+ * @param {Object} viewBox - Parsed viewBox {minX, minY, width, height}
183
+ * @param {number|Decimal} viewportWidth - Viewport width in pixels
184
+ * @param {number|Decimal} viewportHeight - Viewport height in pixels
185
+ * @param {Object} [par=null] - Parsed preserveAspectRatio {align, meetOrSlice}.
186
+ * Defaults to {align: 'xMidYMid', meetOrSlice: 'meet'} if null
187
+ * @returns {Matrix} 3x3 transformation matrix that maps viewBox to viewport
188
+ *
189
+ * @example
190
+ * // Map viewBox "0 0 100 100" to 800x600 viewport with default centering
191
+ * const vb = parseViewBox("0 0 100 100");
192
+ * const matrix = computeViewBoxTransform(vb, 800, 600);
193
+ * // Uniform scale of 6 (min(800/100, 600/100)), centered
194
+ *
195
+ * @example
196
+ * // Stretch to fill without preserving aspect ratio
197
+ * const vb = parseViewBox("0 0 100 50");
198
+ * const par = parsePreserveAspectRatio("none");
199
+ * const matrix = computeViewBoxTransform(vb, 800, 600, par);
200
+ * // scaleX=8, scaleY=12 (different scales)
201
+ *
202
+ * @example
203
+ * // Slice (zoom to fill, crop overflow)
204
+ * const vb = parseViewBox("0 0 100 100");
205
+ * const par = parsePreserveAspectRatio("xMidYMid slice");
206
+ * const matrix = computeViewBoxTransform(vb, 800, 400, par);
207
+ * // Uniform scale of 8 (max(800/100, 400/100)), centered, top/bottom cropped
208
+ */
209
+ export function computeViewBoxTransform(viewBox, viewportWidth, viewportHeight, par = null) {
210
+ const D = x => new Decimal(x);
211
+
212
+ if (!viewBox) {
213
+ return Matrix.identity(3);
214
+ }
215
+
216
+ const vbX = viewBox.minX;
217
+ const vbY = viewBox.minY;
218
+ const vbW = viewBox.width;
219
+ const vbH = viewBox.height;
220
+ const vpW = D(viewportWidth);
221
+ const vpH = D(viewportHeight);
222
+
223
+ // Default preserveAspectRatio
224
+ if (!par) {
225
+ par = { align: 'xMidYMid', meetOrSlice: 'meet' };
226
+ }
227
+
228
+ // Handle 'none' - stretch to fill
229
+ if (par.align === 'none') {
230
+ const scaleX = vpW.div(vbW);
231
+ const scaleY = vpH.div(vbH);
232
+ // translate(-minX, -minY) then scale
233
+ const translateM = Transforms2D.translation(vbX.neg(), vbY.neg());
234
+ const scaleM = Transforms2D.scale(scaleX, scaleY);
235
+ return scaleM.mul(translateM);
236
+ }
237
+
238
+ // Compute uniform scale factor
239
+ let scaleX = vpW.div(vbW);
240
+ let scaleY = vpH.div(vbH);
241
+ let scale;
242
+
243
+ if (par.meetOrSlice === 'slice') {
244
+ // Use larger scale (content may overflow)
245
+ scale = Decimal.max(scaleX, scaleY);
246
+ } else {
247
+ // 'meet' - use smaller scale (content fits entirely)
248
+ scale = Decimal.min(scaleX, scaleY);
249
+ }
250
+
251
+ // Compute translation for alignment
252
+ const scaledW = vbW.mul(scale);
253
+ const scaledH = vbH.mul(scale);
254
+
255
+ let translateX = D(0);
256
+ let translateY = D(0);
257
+
258
+ // Parse alignment string (e.g., 'xMidYMid', 'xMinYMax')
259
+ const align = par.align;
260
+
261
+ // X alignment
262
+ if (align.includes('xMid')) {
263
+ translateX = vpW.minus(scaledW).div(2);
264
+ } else if (align.includes('xMax')) {
265
+ translateX = vpW.minus(scaledW);
266
+ }
267
+ // xMin is default (translateX = 0)
268
+
269
+ // Y alignment
270
+ if (align.includes('YMid')) {
271
+ translateY = vpH.minus(scaledH).div(2);
272
+ } else if (align.includes('YMax')) {
273
+ translateY = vpH.minus(scaledH);
274
+ }
275
+ // YMin is default (translateY = 0)
276
+
277
+ // Build the transform: translate(translateX, translateY) scale(scale) translate(-minX, -minY)
278
+ // Applied right-to-left: first translate by -minX,-minY, then scale, then translate for alignment
279
+ const translateMinM = Transforms2D.translation(vbX.neg(), vbY.neg());
280
+ const scaleM = Transforms2D.scale(scale, scale);
281
+ const translateAlignM = Transforms2D.translation(translateX, translateY);
282
+
283
+ return translateAlignM.mul(scaleM).mul(translateMinM);
284
+ }
285
+
286
+ /**
287
+ * Represents an SVG viewport with its coordinate system parameters.
288
+ *
289
+ * An SVG viewport establishes a new coordinate system. Each <svg> element
290
+ * creates a viewport that can have:
291
+ * - Physical dimensions (width, height)
292
+ * - User space coordinates (viewBox)
293
+ * - Aspect ratio preservation rules (preserveAspectRatio)
294
+ * - Additional transformations (transform attribute)
295
+ *
296
+ * Nested <svg> elements create nested viewports, each with their own
297
+ * coordinate system transformation that contributes to the final CTM.
298
+ *
299
+ * @class SVGViewport
300
+ */
301
+ export class SVGViewport {
302
+ /**
303
+ * Create an SVG viewport.
304
+ *
305
+ * @param {number|Decimal} width - Viewport width in pixels or user units
306
+ * @param {number|Decimal} height - Viewport height in pixels or user units
307
+ * @param {string|null} [viewBox=null] - viewBox attribute value (e.g., "0 0 100 100")
308
+ * @param {string|null} [preserveAspectRatio=null] - preserveAspectRatio attribute value
309
+ * Defaults to "xMidYMid meet" per SVG spec
310
+ * @param {string|null} [transform=null] - transform attribute value (e.g., "rotate(45)")
311
+ *
312
+ * @example
313
+ * // Simple viewport without viewBox
314
+ * const viewport = new SVGViewport(800, 600);
315
+ *
316
+ * @example
317
+ * // Viewport with viewBox for scalable graphics
318
+ * const viewport = new SVGViewport(800, 600, "0 0 100 100");
319
+ * // Maps user space (0,0)-(100,100) to viewport (0,0)-(800,600)
320
+ *
321
+ * @example
322
+ * // Viewport with custom aspect ratio and transform
323
+ * const viewport = new SVGViewport(
324
+ * 800, 600,
325
+ * "0 0 100 50",
326
+ * "xMinYMin slice",
327
+ * "rotate(45 50 25)"
328
+ * );
329
+ */
330
+ constructor(width, height, viewBox = null, preserveAspectRatio = null, transform = null) {
331
+ this.width = new Decimal(width);
332
+ this.height = new Decimal(height);
333
+ this.viewBox = viewBox ? parseViewBox(viewBox) : null;
334
+ this.preserveAspectRatio = parsePreserveAspectRatio(preserveAspectRatio);
335
+ this.transform = transform;
336
+ }
337
+
338
+ /**
339
+ * Compute the transformation matrix for this viewport.
340
+ *
341
+ * Combines viewBox mapping and transform attribute into a single matrix.
342
+ * The viewBox transform (if present) is applied first, then the transform attribute.
343
+ *
344
+ * Order of operations:
345
+ * 1. viewBox transform (maps user space to viewport)
346
+ * 2. transform attribute (additional transformations)
347
+ *
348
+ * @returns {Matrix} 3x3 transformation matrix for this viewport
349
+ *
350
+ * @example
351
+ * const viewport = new SVGViewport(800, 600, "0 0 100 100", null, "rotate(45)");
352
+ * const matrix = viewport.getTransformMatrix();
353
+ * // First scales 100x100 user space to 800x600, then rotates 45 degrees
354
+ */
355
+ getTransformMatrix() {
356
+ let result = Matrix.identity(3);
357
+
358
+ // Apply viewBox transform first (if present)
359
+ if (this.viewBox) {
360
+ const vbTransform = computeViewBoxTransform(
361
+ this.viewBox,
362
+ this.width,
363
+ this.height,
364
+ this.preserveAspectRatio
365
+ );
366
+ result = result.mul(vbTransform);
367
+ }
368
+
369
+ // Then apply the transform attribute (if present)
370
+ if (this.transform) {
371
+ const transformMatrix = parseTransformAttribute(this.transform);
372
+ result = result.mul(transformMatrix);
373
+ }
374
+
375
+ return result;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Build the complete CTM (Current Transform Matrix) including viewports, viewBox transforms,
381
+ * and element transforms from the root to a target element.
382
+ *
383
+ * The CTM represents the cumulative effect of all coordinate system transformations
384
+ * from the outermost SVG viewport down to a specific element. This is essential for:
385
+ * - Converting element coordinates to screen/viewport coordinates
386
+ * - Flattening nested transformations into a single matrix
387
+ * - Computing the actual rendered position of SVG elements
388
+ *
389
+ * The hierarchy array describes the path from root to element. Each entry is processed
390
+ * in order, and its transformation matrix is multiplied into the accumulating CTM.
391
+ *
392
+ * Transformation order (right-to-left in matrix multiplication):
393
+ * CTM = root_viewport × parent_group × ... × element_transform
394
+ *
395
+ * @param {Array} hierarchy - Array of objects describing the hierarchy from root to element.
396
+ * Each object can be:
397
+ * - {type: 'svg', width, height, viewBox?, preserveAspectRatio?, transform?} - SVG viewport
398
+ * - {type: 'g', transform?} - Group element with optional transform
399
+ * - {type: 'element', transform?} - Terminal element with optional transform
400
+ * - Or simply a transform string (backwards compatibility - treated as element transform)
401
+ * @returns {Matrix} Combined CTM as 3x3 matrix representing all transformations from root to element
402
+ *
403
+ * @example
404
+ * // Build CTM for a circle inside a transformed group inside an SVG with viewBox
405
+ * const hierarchy = [
406
+ * { type: 'svg', width: 800, height: 600, viewBox: "0 0 100 100" },
407
+ * { type: 'g', transform: "translate(10, 20)" },
408
+ * { type: 'element', transform: "scale(2)" }
409
+ * ];
410
+ * const ctm = buildFullCTM(hierarchy);
411
+ * // CTM = viewBox_transform × translate(10,20) × scale(2)
412
+ *
413
+ * @example
414
+ * // Backwards compatible usage with transform strings
415
+ * const hierarchy = ["translate(10, 20)", "rotate(45)", "scale(2)"];
416
+ * const ctm = buildFullCTM(hierarchy);
417
+ *
418
+ * @example
419
+ * // Nested SVG viewports
420
+ * const hierarchy = [
421
+ * { type: 'svg', width: 1000, height: 1000, viewBox: "0 0 100 100" },
422
+ * { type: 'svg', width: 50, height: 50, viewBox: "0 0 10 10" },
423
+ * { type: 'element', transform: "rotate(45 5 5)" }
424
+ * ];
425
+ * const ctm = buildFullCTM(hierarchy);
426
+ * // Combines two viewBox transforms and a rotation
427
+ */
428
+ export function buildFullCTM(hierarchy) {
429
+ let ctm = Matrix.identity(3);
430
+
431
+ for (const item of hierarchy) {
432
+ if (typeof item === 'string') {
433
+ // Backwards compatibility: treat string as transform attribute
434
+ if (item) {
435
+ const matrix = parseTransformAttribute(item);
436
+ ctm = ctm.mul(matrix);
437
+ }
438
+ } else if (item.type === 'svg') {
439
+ // SVG viewport with potential viewBox
440
+ const viewport = new SVGViewport(
441
+ item.width,
442
+ item.height,
443
+ item.viewBox || null,
444
+ item.preserveAspectRatio || null,
445
+ item.transform || null
446
+ );
447
+ ctm = ctm.mul(viewport.getTransformMatrix());
448
+ } else if (item.type === 'g' || item.type === 'element') {
449
+ // Group or element with optional transform
450
+ if (item.transform) {
451
+ const matrix = parseTransformAttribute(item.transform);
452
+ ctm = ctm.mul(matrix);
453
+ }
454
+ }
455
+ }
456
+
457
+ return ctm;
458
+ }
459
+
460
+ // ============================================================================
461
+ // Unit and Percentage Resolution
462
+ // ============================================================================
463
+
464
+ /**
465
+ * Resolve a length value that may include CSS units or percentages to user units.
466
+ *
467
+ * SVG supports various length units that need to be converted to user units (typically pixels).
468
+ * This function handles:
469
+ * - Percentages: Relative to a reference size (e.g., viewport width/height)
470
+ * - Absolute units: px, pt, pc, in, cm, mm
471
+ * - Font-relative units: em, rem (assumes 16px font size)
472
+ * - Unitless numbers: Treated as user units (px)
473
+ *
474
+ * Unit conversion formulas:
475
+ * - 1in = dpi px (default 96dpi)
476
+ * - 1cm = dpi/2.54 px
477
+ * - 1mm = dpi/25.4 px
478
+ * - 1pt = dpi/72 px (1/72 of an inch)
479
+ * - 1pc = dpi/6 px (12 points)
480
+ * - 1em = 16px (assumes default font size)
481
+ * - 1rem = 16px (assumes default root font size)
482
+ *
483
+ * @param {string|number} value - Length value with optional unit (e.g., "50%", "10px", "5em", 100)
484
+ * @param {Decimal} referenceSize - Reference size for percentage resolution (e.g., viewport width)
485
+ * @param {number} [dpi=96] - DPI (dots per inch) for absolute unit conversion. Default is 96 (CSS standard)
486
+ * @returns {Decimal} Resolved length in user units (px equivalent)
487
+ *
488
+ * @example
489
+ * // Percentage of viewport width
490
+ * const width = resolveLength("50%", new Decimal(800));
491
+ * // Returns: Decimal(400) // 50% of 800
492
+ *
493
+ * @example
494
+ * // Absolute units
495
+ * const len = resolveLength("1in", new Decimal(0), 96);
496
+ * // Returns: Decimal(96) // 1 inch = 96 pixels at 96 DPI
497
+ *
498
+ * @example
499
+ * // Unitless number
500
+ * const len = resolveLength(100, new Decimal(0));
501
+ * // Returns: Decimal(100)
502
+ *
503
+ * @example
504
+ * // Font-relative units
505
+ * const len = resolveLength("2em", new Decimal(0));
506
+ * // Returns: Decimal(32) // 2 × 16px
507
+ */
508
+ export function resolveLength(value, referenceSize, dpi = 96) {
509
+ const D = x => new Decimal(x);
510
+
511
+ if (typeof value === 'number') {
512
+ return D(value);
513
+ }
514
+
515
+ const str = String(value).trim();
516
+
517
+ // Percentage
518
+ if (str.endsWith('%')) {
519
+ const pct = D(str.slice(0, -1));
520
+ return pct.div(100).mul(referenceSize);
521
+ }
522
+
523
+ // Extract numeric value and unit
524
+ const match = str.match(/^([+-]?[\d.]+(?:e[+-]?\d+)?)(.*)?$/i);
525
+ if (!match) {
526
+ return D(0);
527
+ }
528
+
529
+ const num = D(match[1]);
530
+ const unit = (match[2] || '').toLowerCase().trim();
531
+
532
+ // Convert to user units (px)
533
+ switch (unit) {
534
+ case '':
535
+ case 'px':
536
+ return num;
537
+ case 'em':
538
+ return num.mul(16); // Assume 16px font-size
539
+ case 'rem':
540
+ return num.mul(16);
541
+ case 'pt':
542
+ return num.mul(dpi).div(72);
543
+ case 'pc':
544
+ return num.mul(dpi).div(6);
545
+ case 'in':
546
+ return num.mul(dpi);
547
+ case 'cm':
548
+ return num.mul(dpi).div(2.54);
549
+ case 'mm':
550
+ return num.mul(dpi).div(25.4);
551
+ default:
552
+ return num; // Unknown unit, treat as px
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Resolve percentage values for x/width (relative to viewport width)
558
+ * and y/height (relative to viewport height).
559
+ *
560
+ * @param {string|number} xOrWidth - X coordinate or width value
561
+ * @param {string|number} yOrHeight - Y coordinate or height value
562
+ * @param {Decimal} viewportWidth - Viewport width for reference
563
+ * @param {Decimal} viewportHeight - Viewport height for reference
564
+ * @returns {{x: Decimal, y: Decimal}} Resolved coordinates
565
+ */
566
+ export function resolvePercentages(xOrWidth, yOrHeight, viewportWidth, viewportHeight) {
567
+ return {
568
+ x: resolveLength(xOrWidth, viewportWidth),
569
+ y: resolveLength(yOrHeight, viewportHeight)
570
+ };
571
+ }
572
+
573
+ /**
574
+ * Compute the normalized diagonal for resolving percentages that
575
+ * aren't clearly x or y oriented (per SVG spec).
576
+ *
577
+ * Some SVG attributes (like gradient radii, stroke width when using objectBoundingBox)
578
+ * use percentage values that aren't tied to width or height specifically. For these,
579
+ * the SVG specification defines a "normalized diagonal" as the reference.
580
+ *
581
+ * Formula: sqrt(width² + height²) / sqrt(2)
582
+ *
583
+ * This represents the diagonal of the viewport, normalized by sqrt(2) to provide
584
+ * a reasonable middle ground between width and height for square viewports.
585
+ *
586
+ * @param {Decimal} width - Viewport width
587
+ * @param {Decimal} height - Viewport height
588
+ * @returns {Decimal} Normalized diagonal length
589
+ *
590
+ * @example
591
+ * // Square viewport
592
+ * const diag = normalizedDiagonal(new Decimal(100), new Decimal(100));
593
+ * // Returns: Decimal(100) // sqrt(100² + 100²) / sqrt(2) = sqrt(20000) / sqrt(2) = 100
594
+ *
595
+ * @example
596
+ * // Rectangular viewport
597
+ * const diag = normalizedDiagonal(new Decimal(800), new Decimal(600));
598
+ * // Returns: Decimal(707.106...) // sqrt(800² + 600²) / sqrt(2)
599
+ */
600
+ export function normalizedDiagonal(width, height) {
601
+ const w = new Decimal(width);
602
+ const h = new Decimal(height);
603
+ const sqrt2 = Decimal.sqrt(2);
604
+ return Decimal.sqrt(w.mul(w).plus(h.mul(h))).div(sqrt2);
605
+ }
606
+
607
+ // ============================================================================
608
+ // Object Bounding Box Transform
609
+ // ============================================================================
610
+
611
+ /**
612
+ * Create a transformation matrix for objectBoundingBox coordinates.
613
+ *
614
+ * The objectBoundingBox coordinate system is a normalized (0,0) to (1,1) space
615
+ * relative to an element's bounding box. This is commonly used for:
616
+ * - Gradient coordinates (gradientUnits="objectBoundingBox")
617
+ * - Pattern coordinates (patternUnits="objectBoundingBox")
618
+ * - Clip path coordinates (clipPathUnits="objectBoundingBox")
619
+ *
620
+ * The transformation maps:
621
+ * - (0, 0) → (bboxX, bboxY) - Top-left corner of bounding box
622
+ * - (1, 1) → (bboxX + bboxWidth, bboxY + bboxHeight) - Bottom-right corner
623
+ * - (0.5, 0.5) → (bboxX + bboxWidth/2, bboxY + bboxHeight/2) - Center
624
+ *
625
+ * Transform: T = translate(bboxX, bboxY) × scale(bboxWidth, bboxHeight)
626
+ *
627
+ * @param {number|Decimal} bboxX - Bounding box X coordinate (left edge)
628
+ * @param {number|Decimal} bboxY - Bounding box Y coordinate (top edge)
629
+ * @param {number|Decimal} bboxWidth - Bounding box width
630
+ * @param {number|Decimal} bboxHeight - Bounding box height
631
+ * @returns {Matrix} 3x3 transformation matrix from objectBoundingBox to user space
632
+ *
633
+ * @example
634
+ * // Transform for a rectangle at (100, 50) with size 200x150
635
+ * const matrix = objectBoundingBoxTransform(100, 50, 200, 150);
636
+ * // Point (0.5, 0.5) in objectBoundingBox → (200, 125) in user space (center)
637
+ *
638
+ * @example
639
+ * // Apply to gradient coordinates
640
+ * const bbox = { x: 0, y: 0, width: 100, height: 100 };
641
+ * const transform = objectBoundingBoxTransform(bbox.x, bbox.y, bbox.width, bbox.height);
642
+ * // Gradient with x1="0" y1="0" x2="1" y2="1" spans from (0,0) to (100,100)
643
+ */
644
+ export function objectBoundingBoxTransform(bboxX, bboxY, bboxWidth, bboxHeight) {
645
+ const D = x => new Decimal(x);
646
+ // Transform: scale(bboxWidth, bboxHeight) then translate(bboxX, bboxY)
647
+ const scaleM = Transforms2D.scale(bboxWidth, bboxHeight);
648
+ const translateM = Transforms2D.translation(bboxX, bboxY);
649
+ return translateM.mul(scaleM);
650
+ }
651
+
652
+ // ============================================================================
653
+ // Shape to Path Conversion
654
+ // ============================================================================
655
+
656
+ /**
657
+ * Convert a circle to path data.
658
+ *
659
+ * Circles are converted to path form using 4 cubic Bézier curves with the
660
+ * kappa constant (≈0.5522847498) for mathematical accuracy. This is necessary
661
+ * when flattening transforms, as circles may become ellipses under non-uniform scaling.
662
+ *
663
+ * The circle is approximated by four cubic Bézier segments, each spanning 90 degrees.
664
+ * This provides excellent visual accuracy (error < 0.02% of radius).
665
+ *
666
+ * @param {number|Decimal} cx - Center X coordinate
667
+ * @param {number|Decimal} cy - Center Y coordinate
668
+ * @param {number|Decimal} r - Radius
669
+ * @returns {string} Path data string (M, C commands with Z to close)
670
+ *
671
+ * @example
672
+ * const pathData = circleToPath(50, 50, 25);
673
+ * // Returns: "M 75.000000 50.000000 C 75.000000 63.807119 63.807119 75.000000 ..."
674
+ * // Represents a circle centered at (50, 50) with radius 25
675
+ */
676
+ export function circleToPath(cx, cy, r) {
677
+ return ellipseToPath(cx, cy, r, r);
678
+ }
679
+
680
+ /**
681
+ * Convert an ellipse to path data.
682
+ *
683
+ * Ellipses are converted to path form using 4 cubic Bézier curves with the
684
+ * magic kappa constant for accurate approximation of circular arcs.
685
+ *
686
+ * The kappa constant (κ ≈ 0.5522847498307936) is derived from:
687
+ * κ = 4 × (√2 - 1) / 3
688
+ *
689
+ * This value ensures the control points of a cubic Bézier curve closely approximate
690
+ * a circular arc of 90 degrees. For ellipses, kappa is scaled by rx and ry.
691
+ *
692
+ * The four curves start at the rightmost point and proceed counterclockwise,
693
+ * creating a closed elliptical path with excellent visual accuracy.
694
+ *
695
+ * @param {number|Decimal} cx - Center X coordinate
696
+ * @param {number|Decimal} cy - Center Y coordinate
697
+ * @param {number|Decimal} rx - X radius (horizontal semi-axis)
698
+ * @param {number|Decimal} ry - Y radius (vertical semi-axis)
699
+ * @returns {string} Path data string (M, C commands with Z to close)
700
+ *
701
+ * @example
702
+ * const pathData = ellipseToPath(100, 100, 50, 30);
703
+ * // Creates an ellipse centered at (100, 100) with width 100 and height 60
704
+ *
705
+ * @example
706
+ * // Circle as special case of ellipse
707
+ * const pathData = ellipseToPath(50, 50, 25, 25);
708
+ * // Equivalent to circleToPath(50, 50, 25)
709
+ */
710
+ export function ellipseToPath(cx, cy, rx, ry) {
711
+ const D = x => new Decimal(x);
712
+ const cxD = D(cx), cyD = D(cy), rxD = D(rx), ryD = D(ry);
713
+
714
+ // Kappa for bezier approximation of circle/ellipse: 4 * (sqrt(2) - 1) / 3
715
+ const kappa = D('0.5522847498307936');
716
+ const kx = rxD.mul(kappa);
717
+ const ky = ryD.mul(kappa);
718
+
719
+ // Four bezier curves forming the ellipse
720
+ // Start at (cx + rx, cy) and go counterclockwise
721
+ const x1 = cxD.plus(rxD), y1 = cyD;
722
+ const x2 = cxD, y2 = cyD.minus(ryD);
723
+ const x3 = cxD.minus(rxD), y3 = cyD;
724
+ const x4 = cxD, y4 = cyD.plus(ryD);
725
+
726
+ return [
727
+ `M ${x1.toFixed(6)} ${y1.toFixed(6)}`,
728
+ `C ${x1.toFixed(6)} ${y1.minus(ky).toFixed(6)} ${x2.plus(kx).toFixed(6)} ${y2.toFixed(6)} ${x2.toFixed(6)} ${y2.toFixed(6)}`,
729
+ `C ${x2.minus(kx).toFixed(6)} ${y2.toFixed(6)} ${x3.toFixed(6)} ${y3.minus(ky).toFixed(6)} ${x3.toFixed(6)} ${y3.toFixed(6)}`,
730
+ `C ${x3.toFixed(6)} ${y3.plus(ky).toFixed(6)} ${x4.minus(kx).toFixed(6)} ${y4.toFixed(6)} ${x4.toFixed(6)} ${y4.toFixed(6)}`,
731
+ `C ${x4.plus(kx).toFixed(6)} ${y4.toFixed(6)} ${x1.toFixed(6)} ${y1.plus(ky).toFixed(6)} ${x1.toFixed(6)} ${y1.toFixed(6)}`,
732
+ 'Z'
733
+ ].join(' ');
734
+ }
735
+
736
+ /**
737
+ * Convert a rectangle to path data.
738
+ *
739
+ * Rectangles can have optional rounded corners specified by rx (X radius)
740
+ * and ry (Y radius). The SVG spec has specific rules for corner radius clamping:
741
+ * - rx and ry are clamped to half the rectangle's width and height respectively
742
+ * - If ry is not specified, it defaults to rx
743
+ * - If both are 0, a simple rectangular path is generated
744
+ * - If either is non-zero, arcs are used for corners
745
+ *
746
+ * Corner radius auto-adjustment (per SVG spec):
747
+ * - If rx > width/2, rx is reduced to width/2
748
+ * - If ry > height/2, ry is reduced to height/2
749
+ *
750
+ * @param {number|Decimal} x - X position (top-left corner)
751
+ * @param {number|Decimal} y - Y position (top-left corner)
752
+ * @param {number|Decimal} width - Width of rectangle
753
+ * @param {number|Decimal} height - Height of rectangle
754
+ * @param {number|Decimal} [rx=0] - X corner radius (horizontal)
755
+ * @param {number|Decimal} [ry=null] - Y corner radius (vertical). If null, uses rx value
756
+ * @returns {string} Path data string
757
+ *
758
+ * @example
759
+ * // Simple rectangle with no rounded corners
760
+ * const path = rectToPath(10, 10, 100, 50);
761
+ * // Returns: "M 10.000000 10.000000 L 110.000000 10.000000 ..."
762
+ *
763
+ * @example
764
+ * // Rounded rectangle with uniform corner radius
765
+ * const path = rectToPath(10, 10, 100, 50, 5);
766
+ * // Creates rectangle with 5px rounded corners
767
+ *
768
+ * @example
769
+ * // Rounded rectangle with elliptical corners
770
+ * const path = rectToPath(10, 10, 100, 50, 10, 5);
771
+ * // Corners have rx=10, ry=5 (wider than tall)
772
+ *
773
+ * @example
774
+ * // Auto-clamping of corner radii
775
+ * const path = rectToPath(0, 0, 20, 20, 15);
776
+ * // rx is clamped to 10 (half of 20)
777
+ */
778
+ export function rectToPath(x, y, width, height, rx = 0, ry = null) {
779
+ const D = n => new Decimal(n);
780
+ const xD = D(x), yD = D(y), wD = D(width), hD = D(height);
781
+ let rxD = D(rx);
782
+ let ryD = ry !== null ? D(ry) : rxD;
783
+
784
+ // Clamp radii to half dimensions
785
+ const halfW = wD.div(2);
786
+ const halfH = hD.div(2);
787
+ if (rxD.gt(halfW)) rxD = halfW;
788
+ if (ryD.gt(halfH)) ryD = halfH;
789
+
790
+ const hasRoundedCorners = rxD.gt(0) || ryD.gt(0);
791
+
792
+ if (!hasRoundedCorners) {
793
+ // Simple rectangle
794
+ return [
795
+ `M ${xD.toFixed(6)} ${yD.toFixed(6)}`,
796
+ `L ${xD.plus(wD).toFixed(6)} ${yD.toFixed(6)}`,
797
+ `L ${xD.plus(wD).toFixed(6)} ${yD.plus(hD).toFixed(6)}`,
798
+ `L ${xD.toFixed(6)} ${yD.plus(hD).toFixed(6)}`,
799
+ 'Z'
800
+ ].join(' ');
801
+ }
802
+
803
+ // Rounded rectangle using arcs
804
+ return [
805
+ `M ${xD.plus(rxD).toFixed(6)} ${yD.toFixed(6)}`,
806
+ `L ${xD.plus(wD).minus(rxD).toFixed(6)} ${yD.toFixed(6)}`,
807
+ `A ${rxD.toFixed(6)} ${ryD.toFixed(6)} 0 0 1 ${xD.plus(wD).toFixed(6)} ${yD.plus(ryD).toFixed(6)}`,
808
+ `L ${xD.plus(wD).toFixed(6)} ${yD.plus(hD).minus(ryD).toFixed(6)}`,
809
+ `A ${rxD.toFixed(6)} ${ryD.toFixed(6)} 0 0 1 ${xD.plus(wD).minus(rxD).toFixed(6)} ${yD.plus(hD).toFixed(6)}`,
810
+ `L ${xD.plus(rxD).toFixed(6)} ${yD.plus(hD).toFixed(6)}`,
811
+ `A ${rxD.toFixed(6)} ${ryD.toFixed(6)} 0 0 1 ${xD.toFixed(6)} ${yD.plus(hD).minus(ryD).toFixed(6)}`,
812
+ `L ${xD.toFixed(6)} ${yD.plus(ryD).toFixed(6)}`,
813
+ `A ${rxD.toFixed(6)} ${ryD.toFixed(6)} 0 0 1 ${xD.plus(rxD).toFixed(6)} ${yD.toFixed(6)}`
814
+ ].join(' ');
815
+ }
816
+
817
+ /**
818
+ * Convert a line to path data.
819
+ *
820
+ * @param {number|Decimal} x1 - Start X
821
+ * @param {number|Decimal} y1 - Start Y
822
+ * @param {number|Decimal} x2 - End X
823
+ * @param {number|Decimal} y2 - End Y
824
+ * @returns {string} Path data string
825
+ */
826
+ export function lineToPath(x1, y1, x2, y2) {
827
+ const D = n => new Decimal(n);
828
+ return `M ${D(x1).toFixed(6)} ${D(y1).toFixed(6)} L ${D(x2).toFixed(6)} ${D(y2).toFixed(6)}`;
829
+ }
830
+
831
+ /**
832
+ * Convert a polygon to path data.
833
+ *
834
+ * Polygons are closed shapes defined by a series of connected points.
835
+ * The path automatically closes from the last point back to the first (Z command).
836
+ *
837
+ * The points attribute format is flexible (per SVG spec):
838
+ * - Comma-separated: "x1,y1,x2,y2,x3,y3"
839
+ * - Space-separated: "x1 y1 x2 y2 x3 y3"
840
+ * - Mixed: "x1,y1 x2,y2 x3,y3"
841
+ * - Array of pairs: [[x1,y1], [x2,y2], [x3,y3]]
842
+ * - Flat array: [x1, y1, x2, y2, x3, y3]
843
+ *
844
+ * @param {string|Array} points - Points as "x1,y1 x2,y2 ..." or [[x1,y1], [x2,y2], ...]
845
+ * @returns {string} Path data string (M, L commands with Z to close)
846
+ *
847
+ * @example
848
+ * // Triangle from string
849
+ * const path = polygonToPath("0,0 100,0 50,86.6");
850
+ * // Returns: "M 0 0 L 100 0 L 50 86.6 Z"
851
+ *
852
+ * @example
853
+ * // Square from array
854
+ * const path = polygonToPath([[0,0], [100,0], [100,100], [0,100]]);
855
+ * // Returns: "M 0 0 L 100 0 L 100 100 L 0 100 Z"
856
+ */
857
+ export function polygonToPath(points) {
858
+ const pairs = parsePointPairs(points);
859
+ if (pairs.length === 0) return '';
860
+ let d = `M ${pairs[0][0]} ${pairs[0][1]}`;
861
+ for (let i = 1; i < pairs.length; i++) {
862
+ d += ` L ${pairs[i][0]} ${pairs[i][1]}`;
863
+ }
864
+ return d + ' Z';
865
+ }
866
+
867
+ /**
868
+ * Convert a polyline to path data.
869
+ *
870
+ * Polylines are similar to polygons but are NOT automatically closed.
871
+ * They represent a series of connected line segments.
872
+ *
873
+ * The difference between polygon and polyline:
874
+ * - Polygon: Automatically closes (Z command at end)
875
+ * - Polyline: Remains open (no Z command)
876
+ *
877
+ * @param {string|Array} points - Points as "x1,y1 x2,y2 ..." or [[x1,y1], [x2,y2], ...]
878
+ * @returns {string} Path data string (M, L commands without Z)
879
+ *
880
+ * @example
881
+ * // Open path (not closed)
882
+ * const path = polylineToPath("0,0 50,50 100,0");
883
+ * // Returns: "M 0 0 L 50 50 L 100 0" (no Z)
884
+ *
885
+ * @example
886
+ * // Zigzag line
887
+ * const path = polylineToPath([[0,0], [10,10], [20,0], [30,10], [40,0]]);
888
+ * // Creates an open zigzag pattern
889
+ */
890
+ export function polylineToPath(points) {
891
+ const pairs = parsePointPairs(points);
892
+ if (pairs.length === 0) return '';
893
+ let d = `M ${pairs[0][0]} ${pairs[0][1]}`;
894
+ for (let i = 1; i < pairs.length; i++) {
895
+ d += ` L ${pairs[i][0]} ${pairs[i][1]}`;
896
+ }
897
+ return d;
898
+ }
899
+
900
+ /**
901
+ * Parse points attribute from polygon/polyline into coordinate pairs.
902
+ *
903
+ * SVG polygon and polyline elements use a points attribute with space or comma
904
+ * separated coordinate values. This helper parses that format into pairs.
905
+ *
906
+ * Accepted formats:
907
+ * - Space separated: "10 20 30 40 50 60"
908
+ * - Comma separated: "10,20 30,40 50,60"
909
+ * - Mixed: "10,20, 30,40, 50,60"
910
+ * - Array format: [[10,20], [30,40], [50,60]] or [10, 20, 30, 40, 50, 60]
911
+ *
912
+ * @private
913
+ * @param {string|Array} points - Points attribute value or array
914
+ * @returns {Array<[string, string]>} Array of [x, y] coordinate pairs as strings
915
+ */
916
+ function parsePointPairs(points) {
917
+ let coords;
918
+ if (Array.isArray(points)) {
919
+ coords = points.flat().map(n => new Decimal(n).toFixed(6));
920
+ } else {
921
+ coords = points.trim().split(/[\s,]+/).map(s => new Decimal(s).toFixed(6));
922
+ }
923
+ const pairs = [];
924
+ for (let i = 0; i < coords.length - 1; i += 2) {
925
+ pairs.push([coords[i], coords[i + 1]]);
926
+ }
927
+ return pairs;
928
+ }
929
+
930
+ // ============================================================================
931
+ // Arc Transformation (mathematically correct)
932
+ // ============================================================================
933
+
934
+ /**
935
+ * Transform an elliptical arc under an affine transformation matrix.
936
+ *
937
+ * This is one of the most mathematically complex operations in SVG flattening.
938
+ * Elliptical arcs (the 'A' command in SVG paths) are defined by:
939
+ * - Radii (rx, ry)
940
+ * - X-axis rotation (angle)
941
+ * - Arc flags (large-arc-flag, sweep-flag)
942
+ * - Endpoint (x, y)
943
+ *
944
+ * When an arc is transformed by a matrix:
945
+ * 1. The endpoint is transformed by standard matrix multiplication
946
+ * 2. The ellipse radii and rotation are transformed using eigenvalue decomposition
947
+ * 3. If the matrix has negative determinant (reflection), the sweep direction flips
948
+ *
949
+ * Mathematical approach (based on lean-svg algorithm):
950
+ * - Transform the ellipse's principal axes (X and Y directions scaled by rx, ry)
951
+ * - Construct implicit ellipse equation: Ax² + Bxy + Cy² = 1
952
+ * - Compute eigenvalues to find new radii
953
+ * - Compute eigenvector rotation angle
954
+ * - Check determinant to determine if sweep flips
955
+ *
956
+ * Why this is necessary:
957
+ * - Non-uniform scaling changes the arc's shape
958
+ * - Rotation changes the arc's orientation
959
+ * - Reflection flips the arc's direction
960
+ *
961
+ * @param {number} rx - X radius of the ellipse
962
+ * @param {number} ry - Y radius of the ellipse
963
+ * @param {number} xAxisRotation - Rotation of ellipse's X-axis in degrees (0-360)
964
+ * @param {number} largeArcFlag - Large arc flag: 0 (shorter arc) or 1 (longer arc)
965
+ * @param {number} sweepFlag - Sweep direction: 0 (counter-clockwise) or 1 (clockwise)
966
+ * @param {number} x - End point X coordinate (in current coordinate system)
967
+ * @param {number} y - End point Y coordinate (in current coordinate system)
968
+ * @param {Matrix} matrix - 3x3 affine transformation matrix to apply
969
+ * @returns {Object} Transformed arc parameters:
970
+ * - rx: new X radius
971
+ * - ry: new Y radius
972
+ * - xAxisRotation: new rotation angle in degrees [0, 180)
973
+ * - largeArcFlag: preserved (0 or 1)
974
+ * - sweepFlag: possibly flipped (0 or 1) if matrix has negative determinant
975
+ * - x: transformed endpoint X
976
+ * - y: transformed endpoint Y
977
+ *
978
+ * @example
979
+ * // Arc transformed by uniform scaling
980
+ * const matrix = Transforms2D.scale(2, 2);
981
+ * const arc = transformArc(10, 10, 0, 0, 1, 100, 0, matrix);
982
+ * // Result: rx=20, ry=20, rotation unchanged, endpoint at (200, 0)
983
+ *
984
+ * @example
985
+ * // Arc transformed by non-uniform scaling (becomes more elliptical)
986
+ * const matrix = Transforms2D.scale(2, 1);
987
+ * const arc = transformArc(10, 10, 0, 0, 1, 100, 0, matrix);
988
+ * // Result: rx=20, ry=10, rotation unchanged, endpoint at (200, 0)
989
+ *
990
+ * @example
991
+ * // Arc transformed by rotation
992
+ * const matrix = Transforms2D.rotate(Math.PI / 4); // 45 degrees
993
+ * const arc = transformArc(20, 10, 0, 0, 1, 100, 0, matrix);
994
+ * // Result: radii unchanged, rotation=45°, endpoint rotated
995
+ *
996
+ * @example
997
+ * // Arc transformed by reflection (sweepFlag flips)
998
+ * const matrix = Transforms2D.scale(-1, 1); // Flip horizontally
999
+ * const arc = transformArc(10, 10, 0, 0, 1, 100, 0, matrix);
1000
+ * // Result: sweepFlag flipped from 1 to 0 (direction reversed)
1001
+ */
1002
+ export function transformArc(rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y, matrix) {
1003
+ const D = n => new Decimal(n);
1004
+ const NEAR_ZERO = D('1e-16');
1005
+
1006
+ // Get matrix components
1007
+ const a = matrix.data[0][0];
1008
+ const b = matrix.data[1][0];
1009
+ const c = matrix.data[0][1];
1010
+ const d = matrix.data[1][1];
1011
+ const e = matrix.data[0][2];
1012
+ const f = matrix.data[1][2];
1013
+
1014
+ // Transform the endpoint
1015
+ const xD = D(x), yD = D(y);
1016
+ const newX = a.mul(xD).plus(c.mul(yD)).plus(e);
1017
+ const newY = b.mul(xD).plus(d.mul(yD)).plus(f);
1018
+
1019
+ // Convert rotation to radians
1020
+ const rotRad = D(xAxisRotation).mul(D(Math.PI)).div(180);
1021
+ const sinRot = Decimal.sin(rotRad);
1022
+ const cosRot = Decimal.cos(rotRad);
1023
+
1024
+ const rxD = D(rx), ryD = D(ry);
1025
+
1026
+ // Transform the ellipse axes using the algorithm from lean-svg
1027
+ // m0, m1 represent the transformed X-axis direction of the ellipse
1028
+ // m2, m3 represent the transformed Y-axis direction of the ellipse
1029
+ const m0 = a.mul(rxD).mul(cosRot).plus(c.mul(rxD).mul(sinRot));
1030
+ const m1 = b.mul(rxD).mul(cosRot).plus(d.mul(rxD).mul(sinRot));
1031
+ const m2 = a.mul(ryD.neg()).mul(sinRot).plus(c.mul(ryD).mul(cosRot));
1032
+ const m3 = b.mul(ryD.neg()).mul(sinRot).plus(d.mul(ryD).mul(cosRot));
1033
+
1034
+ // Compute A, B, C coefficients for the implicit ellipse equation
1035
+ const A = m0.mul(m0).plus(m2.mul(m2));
1036
+ const C = m1.mul(m1).plus(m3.mul(m3));
1037
+ const B = m0.mul(m1).plus(m2.mul(m3)).mul(2);
1038
+
1039
+ const AC = A.minus(C);
1040
+
1041
+ // Compute new rotation angle and radii using eigenvalue decomposition
1042
+ let newRotRad;
1043
+ let A2, C2;
1044
+
1045
+ if (B.abs().lt(NEAR_ZERO)) {
1046
+ // Already axis-aligned
1047
+ newRotRad = D(0);
1048
+ A2 = A;
1049
+ C2 = C;
1050
+ } else if (AC.abs().lt(NEAR_ZERO)) {
1051
+ // 45 degree case
1052
+ A2 = A.plus(B.mul('0.5'));
1053
+ C2 = A.minus(B.mul('0.5'));
1054
+ newRotRad = D(Math.PI).div(4);
1055
+ } else {
1056
+ // General case - compute eigenvalues
1057
+ const K = D(1).plus(B.mul(B).div(AC.mul(AC))).sqrt();
1058
+ A2 = A.plus(C).plus(K.mul(AC)).div(2);
1059
+ C2 = A.plus(C).minus(K.mul(AC)).div(2);
1060
+ newRotRad = Decimal.atan(B.div(AC)).div(2);
1061
+ }
1062
+
1063
+ // Compute new radii as sqrt of eigenvalues (not 1/sqrt)
1064
+ if (A2.lt(0)) A2 = D(0);
1065
+ if (C2.lt(0)) C2 = D(0);
1066
+
1067
+ let newRx = A2.sqrt();
1068
+ let newRy = C2.sqrt();
1069
+
1070
+ // Swap based on which axis is larger
1071
+ if (AC.lte(0)) {
1072
+ const temp = newRx;
1073
+ newRx = newRy;
1074
+ newRy = temp;
1075
+ }
1076
+
1077
+ // Ensure rx >= ry (convention)
1078
+ if (newRy.gt(newRx)) {
1079
+ const temp = newRx;
1080
+ newRx = newRy;
1081
+ newRy = temp;
1082
+ newRotRad = newRotRad.plus(D(Math.PI).div(2));
1083
+ }
1084
+
1085
+ // Check if matrix flips orientation (negative determinant)
1086
+ const det = a.mul(d).minus(b.mul(c));
1087
+ let newSweepFlag = sweepFlag;
1088
+ if (det.lt(0)) {
1089
+ // Flip sweep direction
1090
+ newSweepFlag = sweepFlag ? 0 : 1;
1091
+ }
1092
+
1093
+ // Convert rotation back to degrees and normalize to [0, 180)
1094
+ let newRotDeg = newRotRad.mul(180).div(D(Math.PI));
1095
+ while (newRotDeg.lt(0)) newRotDeg = newRotDeg.plus(180);
1096
+ while (newRotDeg.gte(180)) newRotDeg = newRotDeg.minus(180);
1097
+
1098
+ return {
1099
+ rx: newRx.toNumber(),
1100
+ ry: newRy.toNumber(),
1101
+ xAxisRotation: newRotDeg.toNumber(),
1102
+ largeArcFlag: largeArcFlag,
1103
+ sweepFlag: newSweepFlag,
1104
+ x: newX.toNumber(),
1105
+ y: newY.toNumber()
1106
+ };
1107
+ }
1108
+
1109
+ // ============================================================================
1110
+ // Transform Parsing (existing code)
1111
+ // ============================================================================
1112
+
17
1113
  /**
18
1114
  * Parse a single SVG transform function and return a 3x3 matrix.
19
- * Supports: translate, scale, rotate, skewX, skewY, matrix
20
1115
  *
21
- * @param {string} func - Transform function name
22
- * @param {number[]} args - Numeric arguments
23
- * @returns {Matrix} 3x3 transformation matrix
1116
+ * SVG supports six transform functions, each mapped to a specific matrix form:
1117
+ *
1118
+ * 1. **translate(tx [ty])**: Move by (tx, ty). If ty omitted, ty=0
1119
+ * Matrix: [[1, 0, tx], [0, 1, ty], [0, 0, 1]]
1120
+ *
1121
+ * 2. **scale(sx [sy])**: Scale by (sx, sy). If sy omitted, sy=sx (uniform scaling)
1122
+ * Matrix: [[sx, 0, 0], [0, sy, 0], [0, 0, 1]]
1123
+ *
1124
+ * 3. **rotate(angle [cx cy])**: Rotate by angle (degrees) around origin or (cx, cy)
1125
+ * - Around origin: [[cos(θ), -sin(θ), 0], [sin(θ), cos(θ), 0], [0, 0, 1]]
1126
+ * - Around point: translate(cx, cy) × rotate(θ) × translate(-cx, -cy)
1127
+ *
1128
+ * 4. **skewX(angle)**: Skew along X-axis by angle (degrees)
1129
+ * Matrix: [[1, tan(θ), 0], [0, 1, 0], [0, 0, 1]]
1130
+ *
1131
+ * 5. **skewY(angle)**: Skew along Y-axis by angle (degrees)
1132
+ * Matrix: [[1, 0, 0], [tan(θ), 1, 0], [0, 0, 1]]
1133
+ *
1134
+ * 6. **matrix(a, b, c, d, e, f)**: Direct matrix specification
1135
+ * Matrix: [[a, c, e], [b, d, f], [0, 0, 1]]
1136
+ * Represents: [x', y'] = [a×x + c×y + e, b×x + d×y + f]
1137
+ *
1138
+ * Note: All angles are in degrees (SVG convention), converted to radians internally.
1139
+ *
1140
+ * @param {string} func - Transform function name (case-insensitive)
1141
+ * @param {number[]} args - Numeric arguments for the transform function
1142
+ * @returns {Matrix} 3x3 transformation matrix in homogeneous coordinates
1143
+ *
1144
+ * @example
1145
+ * // Simple translation
1146
+ * const m = parseTransformFunction('translate', [10, 20]);
1147
+ * // Returns matrix that moves points right 10, down 20
1148
+ *
1149
+ * @example
1150
+ * // Rotation around a point
1151
+ * const m = parseTransformFunction('rotate', [45, 100, 100]);
1152
+ * // Rotates 45° around point (100, 100)
1153
+ *
1154
+ * @example
1155
+ * // Skew
1156
+ * const m = parseTransformFunction('skewX', [30]);
1157
+ * // Skews along X-axis by 30 degrees (tan(30°) ≈ 0.577)
1158
+ *
1159
+ * @example
1160
+ * // Direct matrix form
1161
+ * const m = parseTransformFunction('matrix', [1, 0, 0, 1, 50, 50]);
1162
+ * // Translation by (50, 50) specified in matrix form
24
1163
  */
25
1164
  export function parseTransformFunction(func, args) {
26
1165
  const D = x => new Decimal(x);
@@ -86,10 +1225,46 @@ export function parseTransformFunction(func, args) {
86
1225
 
87
1226
  /**
88
1227
  * Parse an SVG transform attribute string into a combined matrix.
89
- * Handles multiple transforms: "translate(10,20) rotate(45) scale(2)"
1228
+ *
1229
+ * The transform attribute can contain multiple transform functions that are
1230
+ * applied in left-to-right order (as they appear in the string). However,
1231
+ * in terms of matrix multiplication, this is right-to-left application.
1232
+ *
1233
+ * For example: transform="translate(10,20) rotate(45)"
1234
+ * - Reading order: first translate, then rotate
1235
+ * - Execution: point is rotated first, then translated
1236
+ * - Matrix: M = T × R (multiply translate matrix by rotate matrix)
1237
+ * - Application: p' = M × p = T × R × p (rotate p, then translate result)
1238
+ *
1239
+ * This function parses the string, extracts each transform function with its
1240
+ * arguments, converts each to a matrix, and multiplies them in order.
1241
+ *
1242
+ * Supported transform syntax:
1243
+ * - Functions: translate, scale, rotate, skewX, skewY, matrix
1244
+ * - Separators: Comma or whitespace between arguments
1245
+ * - Multiple transforms: Space-separated functions
90
1246
  *
91
1247
  * @param {string} transformStr - SVG transform attribute value
92
- * @returns {Matrix} Combined 3x3 transformation matrix
1248
+ * e.g., "translate(10,20) rotate(45) scale(2)"
1249
+ * @returns {Matrix} Combined 3x3 transformation matrix representing all transforms
1250
+ *
1251
+ * @example
1252
+ * // Single transform
1253
+ * const m = parseTransformAttribute("translate(10, 20)");
1254
+ *
1255
+ * @example
1256
+ * // Multiple transforms - applied left to right
1257
+ * const m = parseTransformAttribute("translate(50, 50) rotate(45) scale(2)");
1258
+ * // First scale by 2, then rotate 45°, then translate by (50, 50)
1259
+ *
1260
+ * @example
1261
+ * // Complex example with rotation around a point
1262
+ * const m = parseTransformAttribute("translate(100, 100) rotate(45, 50, 50) translate(-100, -100)");
1263
+ *
1264
+ * @example
1265
+ * // Empty or invalid transform returns identity matrix
1266
+ * const m = parseTransformAttribute("");
1267
+ * // Returns: Identity matrix (no transformation)
93
1268
  */
94
1269
  export function parseTransformAttribute(transformStr) {
95
1270
  if (!transformStr || transformStr.trim() === '') {
@@ -122,8 +1297,30 @@ export function parseTransformAttribute(transformStr) {
122
1297
  /**
123
1298
  * Build the CTM (Current Transform Matrix) for an element by walking up its ancestry.
124
1299
  *
125
- * @param {Object[]} transformStack - Array of transform strings from root to element
1300
+ * This is a simplified version of buildFullCTM that only handles transform attribute
1301
+ * strings (no viewBox or viewport handling). It multiplies all transform matrices
1302
+ * from root to element in sequence.
1303
+ *
1304
+ * Use buildFullCTM() for complete CTM calculation including viewports.
1305
+ * Use this function for simple cases with only transform attributes.
1306
+ *
1307
+ * @param {string[]} transformStack - Array of transform strings from root to element
1308
+ * Each string is parsed as an SVG transform attribute
126
1309
  * @returns {Matrix} Combined CTM as 3x3 matrix
1310
+ *
1311
+ * @example
1312
+ * // Build CTM from nested transforms
1313
+ * const ctm = buildCTM([
1314
+ * "translate(100, 100)",
1315
+ * "rotate(45)",
1316
+ * "scale(2)"
1317
+ * ]);
1318
+ * // Equivalent to: translate(100,100) × rotate(45) × scale(2)
1319
+ *
1320
+ * @example
1321
+ * // Empty stack returns identity
1322
+ * const ctm = buildCTM([]);
1323
+ * // Returns: Identity matrix
127
1324
  */
128
1325
  export function buildCTM(transformStack) {
129
1326
  let ctm = Matrix.identity(3);
@@ -139,12 +1336,40 @@ export function buildCTM(transformStack) {
139
1336
  }
140
1337
 
141
1338
  /**
142
- * Apply a CTM to a 2D point.
1339
+ * Apply a CTM (Current Transform Matrix) to a 2D point.
1340
+ *
1341
+ * Transforms a point using homogeneous coordinates and perspective division.
1342
+ * For affine transformations (which SVG uses), this simplifies to:
1343
+ * x' = a×x + c×y + e
1344
+ * y' = b×x + d×y + f
1345
+ *
1346
+ * Where the matrix is:
1347
+ * [[a, c, e],
1348
+ * [b, d, f],
1349
+ * [0, 0, 1]]
143
1350
  *
144
1351
  * @param {Matrix} ctm - 3x3 transformation matrix
145
1352
  * @param {number|string|Decimal} x - X coordinate
146
1353
  * @param {number|string|Decimal} y - Y coordinate
147
- * @returns {{x: Decimal, y: Decimal}} Transformed coordinates
1354
+ * @returns {{x: Decimal, y: Decimal}} Transformed coordinates as Decimal objects
1355
+ *
1356
+ * @example
1357
+ * // Apply translation
1358
+ * const ctm = Transforms2D.translation(10, 20);
1359
+ * const point = applyToPoint(ctm, 5, 5);
1360
+ * // Result: { x: Decimal(15), y: Decimal(25) }
1361
+ *
1362
+ * @example
1363
+ * // Apply rotation around origin
1364
+ * const ctm = Transforms2D.rotate(Math.PI / 2); // 90 degrees
1365
+ * const point = applyToPoint(ctm, 1, 0);
1366
+ * // Result: { x: Decimal(0), y: Decimal(1) } (approximately)
1367
+ *
1368
+ * @example
1369
+ * // Apply complex transform
1370
+ * const ctm = buildCTM(["translate(50, 50)", "rotate(45)", "scale(2)"]);
1371
+ * const point = applyToPoint(ctm, 10, 10);
1372
+ * // Point transformed through all operations
148
1373
  */
149
1374
  export function applyToPoint(ctm, x, y) {
150
1375
  const [tx, ty] = Transforms2D.applyTransform(ctm, x, y);
@@ -154,9 +1379,43 @@ export function applyToPoint(ctm, x, y) {
154
1379
  /**
155
1380
  * Convert a CTM back to SVG matrix() notation.
156
1381
  *
1382
+ * Extracts the 2D affine transformation components from a 3x3 matrix
1383
+ * and formats them as an SVG matrix() transform function.
1384
+ *
1385
+ * The SVG matrix() function has 6 parameters: matrix(a, b, c, d, e, f)
1386
+ * which map to the 3x3 matrix:
1387
+ * [[a, c, e],
1388
+ * [b, d, f],
1389
+ * [0, 0, 1]]
1390
+ *
1391
+ * This represents the transformation:
1392
+ * x' = a×x + c×y + e
1393
+ * y' = b×x + d×y + f
1394
+ *
1395
+ * Note: SVG uses column vectors, so the matrix is organized differently
1396
+ * than typical row-major notation.
1397
+ *
157
1398
  * @param {Matrix} ctm - 3x3 transformation matrix
158
- * @param {number} [precision=6] - Decimal places for output
159
- * @returns {string} SVG matrix transform string
1399
+ * @param {number} [precision=6] - Decimal places for output numbers
1400
+ * @returns {string} SVG matrix transform string (e.g., "matrix(1, 0, 0, 1, 10, 20)")
1401
+ *
1402
+ * @example
1403
+ * // Convert translation matrix
1404
+ * const matrix = Transforms2D.translation(10, 20);
1405
+ * const svg = toSVGMatrix(matrix);
1406
+ * // Returns: "matrix(1.000000, 0.000000, 0.000000, 1.000000, 10.000000, 20.000000)"
1407
+ *
1408
+ * @example
1409
+ * // Convert with custom precision
1410
+ * const matrix = Transforms2D.scale(2, 3);
1411
+ * const svg = toSVGMatrix(matrix, 2);
1412
+ * // Returns: "matrix(2.00, 0.00, 0.00, 3.00, 0.00, 0.00)"
1413
+ *
1414
+ * @example
1415
+ * // Convert complex transform back to SVG
1416
+ * const ctm = buildCTM(["translate(50, 50)", "rotate(45)", "scale(2)"]);
1417
+ * const svg = toSVGMatrix(ctm);
1418
+ * // Returns single matrix() function representing all transforms
160
1419
  */
161
1420
  export function toSVGMatrix(ctm, precision = 6) {
162
1421
  const a = ctm.data[0][0].toFixed(precision);
@@ -172,9 +1431,44 @@ export function toSVGMatrix(ctm, precision = 6) {
172
1431
  /**
173
1432
  * Check if a matrix is effectively the identity matrix.
174
1433
  *
1434
+ * The identity matrix represents "no transformation" and has the form:
1435
+ * [[1, 0, 0],
1436
+ * [0, 1, 0],
1437
+ * [0, 0, 1]]
1438
+ *
1439
+ * Due to floating-point arithmetic, exact equality is unreliable.
1440
+ * This function uses a tolerance-based comparison to handle rounding errors.
1441
+ *
1442
+ * This is useful for:
1443
+ * - Optimizing SVG output (skip identity transforms)
1444
+ * - Detecting when transforms cancel out
1445
+ * - Validation and testing
1446
+ *
175
1447
  * @param {Matrix} m - 3x3 matrix to check
176
- * @param {string} [tolerance='1e-10'] - Tolerance for comparison
1448
+ * @param {string} [tolerance='1e-10'] - Tolerance for element-wise comparison (as Decimal string)
177
1449
  * @returns {boolean} True if matrix is identity within tolerance
1450
+ *
1451
+ * @example
1452
+ * // Check identity matrix
1453
+ * const identity = Matrix.identity(3);
1454
+ * const result = isIdentity(identity);
1455
+ * // Returns: true
1456
+ *
1457
+ * @example
1458
+ * // Check with rounding errors
1459
+ * const almostIdentity = Matrix.from([
1460
+ * [new Decimal('1.0000000001'), new Decimal(0), new Decimal(0)],
1461
+ * [new Decimal(0), new Decimal('0.9999999999'), new Decimal(0)],
1462
+ * [new Decimal(0), new Decimal(0), new Decimal(1)]
1463
+ * ]);
1464
+ * const result = isIdentity(almostIdentity, '1e-8');
1465
+ * // Returns: true (within tolerance)
1466
+ *
1467
+ * @example
1468
+ * // Check non-identity matrix
1469
+ * const translation = Transforms2D.translation(10, 20);
1470
+ * const result = isIdentity(translation);
1471
+ * // Returns: false
178
1472
  */
179
1473
  export function isIdentity(m, tolerance = '1e-10') {
180
1474
  const identity = Matrix.identity(3);
@@ -182,93 +1476,236 @@ export function isIdentity(m, tolerance = '1e-10') {
182
1476
  }
183
1477
 
184
1478
  /**
185
- * Transform path data coordinates using a CTM.
186
- * Handles M, L, C, Q, S, T, A, Z commands (absolute only for now).
1479
+ * Transform path data coordinates using a CTM (Current Transform Matrix).
187
1480
  *
188
- * @param {string} pathData - SVG path d attribute
189
- * @param {Matrix} ctm - 3x3 transformation matrix
190
- * @returns {string} Transformed path data
1481
+ * This function applies a transformation matrix to all coordinates in an SVG path,
1482
+ * handling the full complexity of SVG path syntax including:
1483
+ * - Absolute commands (M, L, H, V, C, S, Q, T, A, Z)
1484
+ * - Relative commands (m, l, h, v, c, s, q, t, a, z)
1485
+ * - Implicit line-to commands after moveto
1486
+ * - Proper arc transformation with radii and rotation adjustment
1487
+ *
1488
+ * Path command handling:
1489
+ * - **M/m (moveto)**: Transform endpoint, update current position and subpath start
1490
+ * - **L/l (lineto)**: Transform endpoint, update current position
1491
+ * - **H/h (horizontal line)**: Converted to L (may gain Y component after transform)
1492
+ * - **V/v (vertical line)**: Converted to L (may gain X component after transform)
1493
+ * - **C/c (cubic Bézier)**: Transform all 3 points (2 control points + endpoint)
1494
+ * - **S/s (smooth cubic)**: Transform control point and endpoint
1495
+ * - **Q/q (quadratic Bézier)**: Transform control point and endpoint
1496
+ * - **T/t (smooth quadratic)**: Transform endpoint only
1497
+ * - **A/a (elliptical arc)**: Use transformArc() for proper ellipse transformation
1498
+ * - **Z/z (closepath)**: Reset position to subpath start
1499
+ *
1500
+ * Relative command conversion:
1501
+ * Relative commands (lowercase) are converted to absolute coordinates before transformation,
1502
+ * then optionally converted back to absolute in the output (controlled by toAbsolute option).
1503
+ *
1504
+ * @param {string} pathData - SVG path d attribute (e.g., "M 10 10 L 20 20 Z")
1505
+ * @param {Matrix} ctm - 3x3 transformation matrix to apply
1506
+ * @param {Object} [options={}] - Transformation options
1507
+ * @param {boolean} [options.toAbsolute=true] - Convert all commands to absolute coordinates
1508
+ * @param {number} [options.precision=6] - Decimal precision for output coordinates
1509
+ * @returns {string} Transformed path data with same structure but transformed coordinates
1510
+ *
1511
+ * @example
1512
+ * // Transform a simple path
1513
+ * const path = "M 0 0 L 100 0 L 100 100 Z";
1514
+ * const matrix = Transforms2D.scale(2, 2);
1515
+ * const transformed = transformPathData(path, matrix);
1516
+ * // Result: "M 0.000000 0.000000 L 200.000000 0.000000 L 200.000000 200.000000 Z"
1517
+ *
1518
+ * @example
1519
+ * // Transform path with curves
1520
+ * const path = "M 10 10 C 20 20, 40 20, 50 10";
1521
+ * const matrix = Transforms2D.rotate(Math.PI / 2); // 90 degrees
1522
+ * const transformed = transformPathData(path, matrix);
1523
+ * // All points rotated 90° counterclockwise
1524
+ *
1525
+ * @example
1526
+ * // Transform path with arcs (complex case)
1527
+ * const path = "M 50 50 A 25 25 0 0 1 100 50";
1528
+ * const matrix = Transforms2D.scale(2, 1); // Non-uniform scaling
1529
+ * const transformed = transformPathData(path, matrix);
1530
+ * // Arc radii adjusted: rx=50, ry=25, endpoint at (200, 50)
1531
+ *
1532
+ * @example
1533
+ * // Preserve relative commands (when toAbsolute=false)
1534
+ * const path = "m 10 10 l 20 0 l 0 20 z";
1535
+ * const matrix = Transforms2D.translation(50, 50);
1536
+ * const transformed = transformPathData(path, matrix, { toAbsolute: false });
1537
+ * // Relative commands preserved in output
191
1538
  */
192
- export function transformPathData(pathData, ctm) {
193
- // Simple regex-based path parser for common commands
1539
+ export function transformPathData(pathData, ctm, options = {}) {
1540
+ const { toAbsolute = true, precision = 6 } = options;
1541
+ const D = x => new Decimal(x);
1542
+
1543
+ // Parse path into commands
1544
+ const commands = parsePathCommands(pathData);
194
1545
  const result = [];
195
- const commandRegex = /([MLHVCSQTAZ])([^MLHVCSQTAZ]*)/gi;
196
- let match;
197
1546
 
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));
1547
+ // Track current position for relative commands
1548
+ let curX = D(0), curY = D(0);
1549
+ let subpathStartX = D(0), subpathStartY = D(0);
205
1550
 
1551
+ for (const { cmd, args } of commands) {
1552
+ const isRelative = cmd === cmd.toLowerCase();
206
1553
  const cmdUpper = cmd.toUpperCase();
207
1554
 
208
1555
  switch (cmdUpper) {
209
- case 'M':
210
- case 'L':
211
- case 'T': {
212
- // Pairs of coordinates
1556
+ case 'M': {
213
1557
  const transformed = [];
214
1558
  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));
1559
+ let x = D(args[i]), y = D(args[i + 1]);
1560
+ if (isRelative) { x = x.plus(curX); y = y.plus(curY); }
1561
+
1562
+ const pt = applyToPoint(ctm, x, y);
1563
+ transformed.push(pt.x.toFixed(precision), pt.y.toFixed(precision));
1564
+
1565
+ curX = x; curY = y;
1566
+ if (i === 0) { subpathStartX = x; subpathStartY = y; }
217
1567
  }
218
- result.push(cmd + ' ' + transformed.join(' '));
1568
+ result.push((toAbsolute ? 'M' : cmd) + ' ' + transformed.join(' '));
1569
+ break;
1570
+ }
1571
+
1572
+ case 'L': {
1573
+ const transformed = [];
1574
+ for (let i = 0; i < args.length; i += 2) {
1575
+ let x = D(args[i]), y = D(args[i + 1]);
1576
+ if (isRelative) { x = x.plus(curX); y = y.plus(curY); }
1577
+
1578
+ const pt = applyToPoint(ctm, x, y);
1579
+ transformed.push(pt.x.toFixed(precision), pt.y.toFixed(precision));
1580
+
1581
+ curX = x; curY = y;
1582
+ }
1583
+ result.push((toAbsolute ? 'L' : cmd) + ' ' + transformed.join(' '));
219
1584
  break;
220
1585
  }
221
1586
 
222
1587
  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));
1588
+ // Horizontal line becomes L after transform (may have Y component)
1589
+ let x = D(args[0]);
1590
+ if (isRelative) { x = x.plus(curX); }
1591
+ const y = curY;
1592
+
1593
+ const pt = applyToPoint(ctm, x, y);
1594
+ result.push('L ' + pt.x.toFixed(precision) + ' ' + pt.y.toFixed(precision));
1595
+
1596
+ curX = x;
226
1597
  break;
227
1598
  }
228
1599
 
229
1600
  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));
1601
+ // Vertical line becomes L after transform (may have X component)
1602
+ const x = curX;
1603
+ let y = D(args[0]);
1604
+ if (isRelative) { y = y.plus(curY); }
1605
+
1606
+ const pt = applyToPoint(ctm, x, y);
1607
+ result.push('L ' + pt.x.toFixed(precision) + ' ' + pt.y.toFixed(precision));
1608
+
1609
+ curY = y;
233
1610
  break;
234
1611
  }
235
1612
 
236
1613
  case 'C': {
237
- // Cubic bezier: 3 pairs of coordinates
238
1614
  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));
1615
+ for (let i = 0; i < args.length; i += 6) {
1616
+ let x1 = D(args[i]), y1 = D(args[i + 1]);
1617
+ let x2 = D(args[i + 2]), y2 = D(args[i + 3]);
1618
+ let x = D(args[i + 4]), y = D(args[i + 5]);
1619
+
1620
+ if (isRelative) {
1621
+ x1 = x1.plus(curX); y1 = y1.plus(curY);
1622
+ x2 = x2.plus(curX); y2 = y2.plus(curY);
1623
+ x = x.plus(curX); y = y.plus(curY);
1624
+ }
1625
+
1626
+ const p1 = applyToPoint(ctm, x1, y1);
1627
+ const p2 = applyToPoint(ctm, x2, y2);
1628
+ const p = applyToPoint(ctm, x, y);
1629
+
1630
+ transformed.push(
1631
+ p1.x.toFixed(precision), p1.y.toFixed(precision),
1632
+ p2.x.toFixed(precision), p2.y.toFixed(precision),
1633
+ p.x.toFixed(precision), p.y.toFixed(precision)
1634
+ );
1635
+
1636
+ curX = x; curY = y;
242
1637
  }
243
- result.push('C ' + transformed.join(' '));
1638
+ result.push((toAbsolute ? 'C' : cmd) + ' ' + transformed.join(' '));
244
1639
  break;
245
1640
  }
246
1641
 
247
1642
  case 'S': {
248
- // Smooth cubic: 2 pairs of coordinates
249
1643
  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));
1644
+ for (let i = 0; i < args.length; i += 4) {
1645
+ let x2 = D(args[i]), y2 = D(args[i + 1]);
1646
+ let x = D(args[i + 2]), y = D(args[i + 3]);
1647
+
1648
+ if (isRelative) {
1649
+ x2 = x2.plus(curX); y2 = y2.plus(curY);
1650
+ x = x.plus(curX); y = y.plus(curY);
1651
+ }
1652
+
1653
+ const p2 = applyToPoint(ctm, x2, y2);
1654
+ const p = applyToPoint(ctm, x, y);
1655
+
1656
+ transformed.push(
1657
+ p2.x.toFixed(precision), p2.y.toFixed(precision),
1658
+ p.x.toFixed(precision), p.y.toFixed(precision)
1659
+ );
1660
+
1661
+ curX = x; curY = y;
253
1662
  }
254
- result.push('S ' + transformed.join(' '));
1663
+ result.push((toAbsolute ? 'S' : cmd) + ' ' + transformed.join(' '));
255
1664
  break;
256
1665
  }
257
1666
 
258
1667
  case 'Q': {
259
- // Quadratic bezier: 2 pairs of coordinates
1668
+ const transformed = [];
1669
+ for (let i = 0; i < args.length; i += 4) {
1670
+ let x1 = D(args[i]), y1 = D(args[i + 1]);
1671
+ let x = D(args[i + 2]), y = D(args[i + 3]);
1672
+
1673
+ if (isRelative) {
1674
+ x1 = x1.plus(curX); y1 = y1.plus(curY);
1675
+ x = x.plus(curX); y = y.plus(curY);
1676
+ }
1677
+
1678
+ const p1 = applyToPoint(ctm, x1, y1);
1679
+ const p = applyToPoint(ctm, x, y);
1680
+
1681
+ transformed.push(
1682
+ p1.x.toFixed(precision), p1.y.toFixed(precision),
1683
+ p.x.toFixed(precision), p.y.toFixed(precision)
1684
+ );
1685
+
1686
+ curX = x; curY = y;
1687
+ }
1688
+ result.push((toAbsolute ? 'Q' : cmd) + ' ' + transformed.join(' '));
1689
+ break;
1690
+ }
1691
+
1692
+ case 'T': {
260
1693
  const transformed = [];
261
1694
  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));
1695
+ let x = D(args[i]), y = D(args[i + 1]);
1696
+ if (isRelative) { x = x.plus(curX); y = y.plus(curY); }
1697
+
1698
+ const pt = applyToPoint(ctm, x, y);
1699
+ transformed.push(pt.x.toFixed(precision), pt.y.toFixed(precision));
1700
+
1701
+ curX = x; curY = y;
264
1702
  }
265
- result.push('Q ' + transformed.join(' '));
1703
+ result.push((toAbsolute ? 'T' : cmd) + ' ' + transformed.join(' '));
266
1704
  break;
267
1705
  }
268
1706
 
269
1707
  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)
1708
+ // Use proper arc transformation
272
1709
  const transformed = [];
273
1710
  for (let i = 0; i < args.length; i += 7) {
274
1711
  const rx = args[i];
@@ -276,37 +1713,38 @@ export function transformPathData(pathData, ctm) {
276
1713
  const rotation = args[i + 2];
277
1714
  const largeArc = args[i + 3];
278
1715
  const sweep = args[i + 4];
279
- const x = args[i + 5];
280
- const y = args[i + 6];
1716
+ let x = D(args[i + 5]), y = D(args[i + 6]);
281
1717
 
282
- const { x: tx, y: ty } = applyToPoint(ctm, x, y);
1718
+ if (isRelative) { x = x.plus(curX); y = y.plus(curY); }
283
1719
 
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);
1720
+ const arc = transformArc(rx, ry, rotation, largeArc, sweep, x.toNumber(), y.toNumber(), ctm);
287
1721
 
288
1722
  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)
1723
+ arc.rx.toFixed(precision),
1724
+ arc.ry.toFixed(precision),
1725
+ arc.xAxisRotation.toFixed(precision),
1726
+ arc.largeArcFlag,
1727
+ arc.sweepFlag,
1728
+ arc.x.toFixed(precision),
1729
+ arc.y.toFixed(precision)
296
1730
  );
1731
+
1732
+ curX = x; curY = y;
297
1733
  }
298
- result.push('A ' + transformed.join(' '));
1734
+ result.push((toAbsolute ? 'A' : cmd) + ' ' + transformed.join(' '));
299
1735
  break;
300
1736
  }
301
1737
 
302
1738
  case 'Z': {
303
1739
  result.push('Z');
1740
+ curX = subpathStartX;
1741
+ curY = subpathStartY;
304
1742
  break;
305
1743
  }
306
1744
 
307
1745
  default:
308
1746
  // Keep unknown commands as-is
309
- result.push(cmd + ' ' + argsStr);
1747
+ result.push(cmd + ' ' + args.join(' '));
310
1748
  }
311
1749
  }
312
1750
 
@@ -314,16 +1752,117 @@ export function transformPathData(pathData, ctm) {
314
1752
  }
315
1753
 
316
1754
  /**
317
- * Information about precision comparison between float and Decimal.
1755
+ * Parse SVG path data into command/args pairs.
1756
+ *
1757
+ * Extracts individual path commands with their numeric arguments from
1758
+ * SVG path data strings. Handles all valid SVG path command letters
1759
+ * and properly separates arguments.
1760
+ *
1761
+ * Path command letters (case-sensitive):
1762
+ * - M/m: moveto
1763
+ * - L/l: lineto
1764
+ * - H/h: horizontal lineto
1765
+ * - V/v: vertical lineto
1766
+ * - C/c: cubic Bézier curve
1767
+ * - S/s: smooth cubic Bézier
1768
+ * - Q/q: quadratic Bézier curve
1769
+ * - T/t: smooth quadratic Bézier
1770
+ * - A/a: elliptical arc
1771
+ * - Z/z: closepath
1772
+ *
1773
+ * Uppercase = absolute coordinates, lowercase = relative coordinates
1774
+ *
1775
+ * @private
1776
+ * @param {string} pathData - SVG path d attribute value
1777
+ * @returns {Array<{cmd: string, args: number[]}>} Array of command objects with args
1778
+ *
1779
+ * @example
1780
+ * parsePathCommands("M 10 20 L 30 40 Z")
1781
+ * // Returns: [
1782
+ * // { cmd: 'M', args: [10, 20] },
1783
+ * // { cmd: 'L', args: [30, 40] },
1784
+ * // { cmd: 'Z', args: [] }
1785
+ * // ]
1786
+ */
1787
+ function parsePathCommands(pathData) {
1788
+ const commands = [];
1789
+ const commandRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
1790
+ let match;
1791
+
1792
+ while ((match = commandRegex.exec(pathData)) !== null) {
1793
+ const cmd = match[1];
1794
+ const argsStr = match[2].trim();
1795
+ const args = argsStr.length > 0
1796
+ ? argsStr.split(/[\s,]+/).filter(s => s.length > 0).map(s => parseFloat(s))
1797
+ : [];
1798
+ commands.push({ cmd, args });
1799
+ }
1800
+
1801
+ return commands;
1802
+ }
1803
+
1804
+ /**
1805
+ * Information about precision comparison between standard JavaScript floats and Decimal.js.
1806
+ *
1807
+ * This library uses arbitrary-precision arithmetic via Decimal.js to avoid the
1808
+ * accumulation of floating-point errors common in SVG transform operations.
1809
+ *
1810
+ * Precision metrics (measured from benchmark):
1811
+ * - **floatErrorGIS**: Error with large coordinates (1e6+ scale) - significant!
1812
+ * Example: 10 becomes 9.9999998808 after GIS-scale round-trip (error: 1.69e-7)
1813
+ *
1814
+ * - **floatErrorTypical**: Error with typical SVG hierarchy (6 levels)
1815
+ * Example: 10 becomes 10.000000000000114 (error: 1.14e-13, sub-pixel)
1816
+ *
1817
+ * - **decimalPrecision**: Number of significant digits maintained by Decimal.js (80)
1818
+ *
1819
+ * - **typicalRoundTripError**: Error after round-trip conversion with Decimal.js
1820
+ * Approximately 1e-77 to 0, effectively zero for practical purposes
1821
+ *
1822
+ * Why this matters for SVG:
1823
+ * - Transform matrices multiply, accumulating errors
1824
+ * - Large coordinates (GIS, CAD) amplify precision loss significantly
1825
+ * - Nested SVG elements create deep transform hierarchies
1826
+ * - High precision ensures exact coordinate preservation
1827
+ *
1828
+ * @constant {Object}
1829
+ * @property {number} floatErrorGIS - Float error with large coordinates (1.69e-7)
1830
+ * @property {number} floatErrorTypical - Float error with typical SVG (1.14e-13)
1831
+ * @property {number} decimalPrecision - Decimal.js precision in significant digits (80)
1832
+ * @property {string} typicalRoundTripError - Round-trip error with Decimal ('1e-77')
1833
+ * @property {string} improvementFactorGIS - Improvement for GIS/CAD ('1e+93')
318
1834
  */
319
1835
  export const PRECISION_INFO = {
320
- floatError: 0.0143, // Typical error: 10 -> 9.9857
1836
+ floatErrorGIS: 1.69e-7, // Error with 1e6+ scale coordinates
1837
+ floatErrorTypical: 1.14e-13, // Error with typical 6-level SVG hierarchy
321
1838
  decimalPrecision: 80,
322
- typicalRoundTripError: '2e-79',
323
- improvementFactor: '1.43e+77'
1839
+ typicalRoundTripError: '1e-77',
1840
+ improvementFactorGIS: '1e+93'
324
1841
  };
325
1842
 
326
1843
  export default {
1844
+ // viewBox and preserveAspectRatio
1845
+ parseViewBox,
1846
+ parsePreserveAspectRatio,
1847
+ computeViewBoxTransform,
1848
+ SVGViewport,
1849
+ buildFullCTM,
1850
+ // Unit resolution
1851
+ resolveLength,
1852
+ resolvePercentages,
1853
+ normalizedDiagonal,
1854
+ // Object bounding box
1855
+ objectBoundingBoxTransform,
1856
+ // Shape to path conversion
1857
+ circleToPath,
1858
+ ellipseToPath,
1859
+ rectToPath,
1860
+ lineToPath,
1861
+ polygonToPath,
1862
+ polylineToPath,
1863
+ // Arc transformation
1864
+ transformArc,
1865
+ // Transform parsing
327
1866
  parseTransformFunction,
328
1867
  parseTransformAttribute,
329
1868
  buildCTM,