@emasoft/svg-matrix 1.0.5 → 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
 
@@ -19,10 +43,30 @@ Decimal.set({ precision: 80 });
19
43
  // ============================================================================
20
44
 
21
45
  /**
22
- * Parse an SVG viewBox attribute.
46
+ * Parse an SVG viewBox attribute into its component values.
23
47
  *
24
- * @param {string} viewBoxStr - viewBox attribute value "minX minY width height"
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
25
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
26
70
  */
27
71
  export function parseViewBox(viewBoxStr) {
28
72
  if (!viewBoxStr || viewBoxStr.trim() === '') {
@@ -45,10 +89,36 @@ export function parseViewBox(viewBoxStr) {
45
89
 
46
90
  /**
47
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
+ *
48
99
  * Format: "[defer] <align> [<meetOrSlice>]"
49
100
  *
50
101
  * @param {string} parStr - preserveAspectRatio attribute value
51
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");
52
122
  */
53
123
  export function parsePreserveAspectRatio(parStr) {
54
124
  const result = {
@@ -86,13 +156,55 @@ export function parsePreserveAspectRatio(parStr) {
86
156
 
87
157
  /**
88
158
  * Compute the transformation matrix from viewBox coordinates to viewport.
89
- * Implements the SVG 2 algorithm for viewBox + preserveAspectRatio.
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)
90
181
  *
91
182
  * @param {Object} viewBox - Parsed viewBox {minX, minY, width, height}
92
183
  * @param {number|Decimal} viewportWidth - Viewport width in pixels
93
184
  * @param {number|Decimal} viewportHeight - Viewport height in pixels
94
- * @param {Object} par - Parsed preserveAspectRatio {align, meetOrSlice}
95
- * @returns {Matrix} 3x3 transformation matrix
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
96
208
  */
97
209
  export function computeViewBoxTransform(viewBox, viewportWidth, viewportHeight, par = null) {
98
210
  const D = x => new Decimal(x);
@@ -173,15 +285,47 @@ export function computeViewBoxTransform(viewBox, viewportWidth, viewportHeight,
173
285
 
174
286
  /**
175
287
  * Represents an SVG viewport with its coordinate system parameters.
176
- * Used for building the full CTM through nested viewports.
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
177
300
  */
178
301
  export class SVGViewport {
179
302
  /**
180
- * @param {number|Decimal} width - Viewport width
181
- * @param {number|Decimal} height - Viewport height
182
- * @param {string|null} viewBox - viewBox attribute value
183
- * @param {string|null} preserveAspectRatio - preserveAspectRatio attribute value
184
- * @param {string|null} transform - transform attribute value
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
+ * );
185
329
  */
186
330
  constructor(width, height, viewBox = null, preserveAspectRatio = null, transform = null) {
187
331
  this.width = new Decimal(width);
@@ -193,7 +337,20 @@ export class SVGViewport {
193
337
 
194
338
  /**
195
339
  * Compute the transformation matrix for this viewport.
196
- * @returns {Matrix} 3x3 transformation matrix
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
197
354
  */
198
355
  getTransformMatrix() {
199
356
  let result = Matrix.identity(3);
@@ -220,15 +377,53 @@ export class SVGViewport {
220
377
  }
221
378
 
222
379
  /**
223
- * Build the complete CTM including viewports, viewBox transforms, and element transforms.
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
224
394
  *
225
395
  * @param {Array} hierarchy - Array of objects describing the hierarchy from root to element.
226
396
  * Each object can be:
227
- * - {type: 'svg', width, height, viewBox?, preserveAspectRatio?, transform?}
228
- * - {type: 'g', transform?}
229
- * - {type: 'element', transform?}
230
- * Or simply a transform string for backwards compatibility
231
- * @returns {Matrix} Combined CTM as 3x3 matrix
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
232
427
  */
233
428
  export function buildFullCTM(hierarchy) {
234
429
  let ctm = Matrix.identity(3);
@@ -267,12 +462,48 @@ export function buildFullCTM(hierarchy) {
267
462
  // ============================================================================
268
463
 
269
464
  /**
270
- * Resolve a length value that may include units or percentages.
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)
271
482
  *
272
- * @param {string|number} value - Length value (e.g., "50%", "10px", "5em", 100)
273
- * @param {Decimal} referenceSize - Reference size for percentage resolution
274
- * @param {number} [dpi=96] - DPI for absolute unit conversion
275
- * @returns {Decimal} Resolved length in user units (px)
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
276
507
  */
277
508
  export function resolveLength(value, referenceSize, dpi = 96) {
278
509
  const D = x => new Decimal(x);
@@ -342,11 +573,29 @@ export function resolvePercentages(xOrWidth, yOrHeight, viewportWidth, viewportH
342
573
  /**
343
574
  * Compute the normalized diagonal for resolving percentages that
344
575
  * aren't clearly x or y oriented (per SVG spec).
345
- * Formula: sqrt(width^2 + height^2) / sqrt(2)
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.
346
585
  *
347
586
  * @param {Decimal} width - Viewport width
348
587
  * @param {Decimal} height - Viewport height
349
- * @returns {Decimal} Normalized diagonal
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)
350
599
  */
351
600
  export function normalizedDiagonal(width, height) {
352
601
  const w = new Decimal(width);
@@ -361,13 +610,36 @@ export function normalizedDiagonal(width, height) {
361
610
 
362
611
  /**
363
612
  * Create a transformation matrix for objectBoundingBox coordinates.
364
- * Maps (0,0) to (bboxX, bboxY) and (1,1) to (bboxX+bboxW, bboxY+bboxH).
365
613
  *
366
- * @param {number|Decimal} bboxX - Bounding box X
367
- * @param {number|Decimal} bboxY - Bounding box Y
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)
368
629
  * @param {number|Decimal} bboxWidth - Bounding box width
369
630
  * @param {number|Decimal} bboxHeight - Bounding box height
370
- * @returns {Matrix} 3x3 transformation matrix
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)
371
643
  */
372
644
  export function objectBoundingBoxTransform(bboxX, bboxY, bboxWidth, bboxHeight) {
373
645
  const D = x => new Decimal(x);
@@ -377,17 +649,517 @@ export function objectBoundingBoxTransform(bboxX, bboxY, bboxWidth, bboxHeight)
377
649
  return translateM.mul(scaleM);
378
650
  }
379
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
+
380
1109
  // ============================================================================
381
1110
  // Transform Parsing (existing code)
382
1111
  // ============================================================================
383
1112
 
384
1113
  /**
385
1114
  * Parse a single SVG transform function and return a 3x3 matrix.
386
- * Supports: translate, scale, rotate, skewX, skewY, matrix
387
1115
  *
388
- * @param {string} func - Transform function name
389
- * @param {number[]} args - Numeric arguments
390
- * @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
391
1163
  */
392
1164
  export function parseTransformFunction(func, args) {
393
1165
  const D = x => new Decimal(x);
@@ -453,10 +1225,46 @@ export function parseTransformFunction(func, args) {
453
1225
 
454
1226
  /**
455
1227
  * Parse an SVG transform attribute string into a combined matrix.
456
- * 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
457
1246
  *
458
1247
  * @param {string} transformStr - SVG transform attribute value
459
- * @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)
460
1268
  */
461
1269
  export function parseTransformAttribute(transformStr) {
462
1270
  if (!transformStr || transformStr.trim() === '') {
@@ -489,8 +1297,30 @@ export function parseTransformAttribute(transformStr) {
489
1297
  /**
490
1298
  * Build the CTM (Current Transform Matrix) for an element by walking up its ancestry.
491
1299
  *
492
- * @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
493
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
494
1324
  */
495
1325
  export function buildCTM(transformStack) {
496
1326
  let ctm = Matrix.identity(3);
@@ -506,12 +1336,40 @@ export function buildCTM(transformStack) {
506
1336
  }
507
1337
 
508
1338
  /**
509
- * 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]]
510
1350
  *
511
1351
  * @param {Matrix} ctm - 3x3 transformation matrix
512
1352
  * @param {number|string|Decimal} x - X coordinate
513
1353
  * @param {number|string|Decimal} y - Y coordinate
514
- * @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
515
1373
  */
516
1374
  export function applyToPoint(ctm, x, y) {
517
1375
  const [tx, ty] = Transforms2D.applyTransform(ctm, x, y);
@@ -521,9 +1379,43 @@ export function applyToPoint(ctm, x, y) {
521
1379
  /**
522
1380
  * Convert a CTM back to SVG matrix() notation.
523
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
+ *
524
1398
  * @param {Matrix} ctm - 3x3 transformation matrix
525
- * @param {number} [precision=6] - Decimal places for output
526
- * @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
527
1419
  */
528
1420
  export function toSVGMatrix(ctm, precision = 6) {
529
1421
  const a = ctm.data[0][0].toFixed(precision);
@@ -539,9 +1431,44 @@ export function toSVGMatrix(ctm, precision = 6) {
539
1431
  /**
540
1432
  * Check if a matrix is effectively the identity matrix.
541
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
+ *
542
1447
  * @param {Matrix} m - 3x3 matrix to check
543
- * @param {string} [tolerance='1e-10'] - Tolerance for comparison
1448
+ * @param {string} [tolerance='1e-10'] - Tolerance for element-wise comparison (as Decimal string)
544
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
545
1472
  */
546
1473
  export function isIdentity(m, tolerance = '1e-10') {
547
1474
  const identity = Matrix.identity(3);
@@ -549,93 +1476,236 @@ export function isIdentity(m, tolerance = '1e-10') {
549
1476
  }
550
1477
 
551
1478
  /**
552
- * Transform path data coordinates using a CTM.
553
- * 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).
554
1480
  *
555
- * @param {string} pathData - SVG path d attribute
556
- * @param {Matrix} ctm - 3x3 transformation matrix
557
- * @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
558
1538
  */
559
- export function transformPathData(pathData, ctm) {
560
- // 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);
561
1545
  const result = [];
562
- const commandRegex = /([MLHVCSQTAZ])([^MLHVCSQTAZ]*)/gi;
563
- let match;
564
1546
 
565
- while ((match = commandRegex.exec(pathData)) !== null) {
566
- const cmd = match[1];
567
- const argsStr = match[2].trim();
568
- const args = argsStr
569
- .split(/[\s,]+/)
570
- .filter(s => s.length > 0)
571
- .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);
572
1550
 
1551
+ for (const { cmd, args } of commands) {
1552
+ const isRelative = cmd === cmd.toLowerCase();
573
1553
  const cmdUpper = cmd.toUpperCase();
574
1554
 
575
1555
  switch (cmdUpper) {
576
- case 'M':
577
- case 'L':
578
- case 'T': {
579
- // Pairs of coordinates
1556
+ case 'M': {
1557
+ const transformed = [];
1558
+ for (let i = 0; i < args.length; i += 2) {
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; }
1567
+ }
1568
+ result.push((toAbsolute ? 'M' : cmd) + ' ' + transformed.join(' '));
1569
+ break;
1570
+ }
1571
+
1572
+ case 'L': {
580
1573
  const transformed = [];
581
1574
  for (let i = 0; i < args.length; i += 2) {
582
- const { x, y } = applyToPoint(ctm, args[i], args[i + 1]);
583
- transformed.push(x.toFixed(6), y.toFixed(6));
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;
584
1582
  }
585
- result.push(cmd + ' ' + transformed.join(' '));
1583
+ result.push((toAbsolute ? 'L' : cmd) + ' ' + transformed.join(' '));
586
1584
  break;
587
1585
  }
588
1586
 
589
1587
  case 'H': {
590
- // Horizontal line - becomes L after transform
591
- const { x, y } = applyToPoint(ctm, args[0], 0);
592
- 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;
593
1597
  break;
594
1598
  }
595
1599
 
596
1600
  case 'V': {
597
- // Vertical line - becomes L after transform
598
- const { x, y } = applyToPoint(ctm, 0, args[0]);
599
- 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;
600
1610
  break;
601
1611
  }
602
1612
 
603
1613
  case 'C': {
604
- // Cubic bezier: 3 pairs of coordinates
605
1614
  const transformed = [];
606
- for (let i = 0; i < args.length; i += 2) {
607
- const { x, y } = applyToPoint(ctm, args[i], args[i + 1]);
608
- 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;
609
1637
  }
610
- result.push('C ' + transformed.join(' '));
1638
+ result.push((toAbsolute ? 'C' : cmd) + ' ' + transformed.join(' '));
611
1639
  break;
612
1640
  }
613
1641
 
614
1642
  case 'S': {
615
- // Smooth cubic: 2 pairs of coordinates
616
1643
  const transformed = [];
617
- for (let i = 0; i < args.length; i += 2) {
618
- const { x, y } = applyToPoint(ctm, args[i], args[i + 1]);
619
- 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;
620
1662
  }
621
- result.push('S ' + transformed.join(' '));
1663
+ result.push((toAbsolute ? 'S' : cmd) + ' ' + transformed.join(' '));
622
1664
  break;
623
1665
  }
624
1666
 
625
1667
  case 'Q': {
626
- // 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': {
627
1693
  const transformed = [];
628
1694
  for (let i = 0; i < args.length; i += 2) {
629
- const { x, y } = applyToPoint(ctm, args[i], args[i + 1]);
630
- 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;
631
1702
  }
632
- result.push('Q ' + transformed.join(' '));
1703
+ result.push((toAbsolute ? 'T' : cmd) + ' ' + transformed.join(' '));
633
1704
  break;
634
1705
  }
635
1706
 
636
1707
  case 'A': {
637
- // Arc: rx ry x-axis-rotation large-arc-flag sweep-flag x y
638
- // Transform end point, scale radii (approximate for non-uniform scale)
1708
+ // Use proper arc transformation
639
1709
  const transformed = [];
640
1710
  for (let i = 0; i < args.length; i += 7) {
641
1711
  const rx = args[i];
@@ -643,37 +1713,38 @@ export function transformPathData(pathData, ctm) {
643
1713
  const rotation = args[i + 2];
644
1714
  const largeArc = args[i + 3];
645
1715
  const sweep = args[i + 4];
646
- const x = args[i + 5];
647
- const y = args[i + 6];
1716
+ let x = D(args[i + 5]), y = D(args[i + 6]);
648
1717
 
649
- const { x: tx, y: ty } = applyToPoint(ctm, x, y);
1718
+ if (isRelative) { x = x.plus(curX); y = y.plus(curY); }
650
1719
 
651
- // Scale radii approximately (doesn't handle rotation correctly for skew)
652
- const scaleX = ctm.data[0][0].abs().plus(ctm.data[0][1].abs()).div(2);
653
- 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);
654
1721
 
655
1722
  transformed.push(
656
- (rx * scaleX.toNumber()).toFixed(6),
657
- (ry * scaleY.toNumber()).toFixed(6),
658
- rotation,
659
- largeArc,
660
- sweep,
661
- tx.toFixed(6),
662
- 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)
663
1730
  );
1731
+
1732
+ curX = x; curY = y;
664
1733
  }
665
- result.push('A ' + transformed.join(' '));
1734
+ result.push((toAbsolute ? 'A' : cmd) + ' ' + transformed.join(' '));
666
1735
  break;
667
1736
  }
668
1737
 
669
1738
  case 'Z': {
670
1739
  result.push('Z');
1740
+ curX = subpathStartX;
1741
+ curY = subpathStartY;
671
1742
  break;
672
1743
  }
673
1744
 
674
1745
  default:
675
1746
  // Keep unknown commands as-is
676
- result.push(cmd + ' ' + argsStr);
1747
+ result.push(cmd + ' ' + args.join(' '));
677
1748
  }
678
1749
  }
679
1750
 
@@ -681,13 +1752,92 @@ export function transformPathData(pathData, ctm) {
681
1752
  }
682
1753
 
683
1754
  /**
684
- * 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')
685
1834
  */
686
1835
  export const PRECISION_INFO = {
687
- 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
688
1838
  decimalPrecision: 80,
689
- typicalRoundTripError: '2e-79',
690
- improvementFactor: '1.43e+77'
1839
+ typicalRoundTripError: '1e-77',
1840
+ improvementFactorGIS: '1e+93'
691
1841
  };
692
1842
 
693
1843
  export default {
@@ -703,6 +1853,15 @@ export default {
703
1853
  normalizedDiagonal,
704
1854
  // Object bounding box
705
1855
  objectBoundingBoxTransform,
1856
+ // Shape to path conversion
1857
+ circleToPath,
1858
+ ellipseToPath,
1859
+ rectToPath,
1860
+ lineToPath,
1861
+ polygonToPath,
1862
+ polylineToPath,
1863
+ // Arc transformation
1864
+ transformArc,
706
1865
  // Transform parsing
707
1866
  parseTransformFunction,
708
1867
  parseTransformAttribute,