@emasoft/svg-matrix 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +391 -385
- package/bin/svg-matrix.js +1000 -0
- package/package.json +30 -2
- package/scripts/bootstrap_repo.sh +99 -0
- package/scripts/postinstall.js +252 -0
- package/src/browser-verify.js +463 -0
- package/src/clip-path-resolver.js +760 -0
- package/src/geometry-to-path.js +348 -0
- package/src/index.js +427 -6
- package/src/logger.js +302 -0
- package/src/marker-resolver.js +1006 -0
- package/src/mask-resolver.js +1407 -0
- package/src/mesh-gradient.js +1215 -0
- package/src/pattern-resolver.js +844 -0
- package/src/polygon-clip.js +1491 -0
- package/src/svg-flatten.js +1264 -105
- package/src/text-to-path.js +820 -0
- package/src/transforms2d.js +493 -37
- package/src/transforms3d.js +418 -47
- package/src/use-symbol-resolver.js +1126 -0
- package/samples/preserveAspectRatio_SVG.svg +0 -63
- package/samples/test.svg +0 -39
package/src/svg-flatten.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* @param {
|
|
183
|
-
* @param {
|
|
184
|
-
* @param {string|null}
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
367
|
-
*
|
|
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
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
583
|
-
|
|
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
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
598
|
-
const
|
|
599
|
-
|
|
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 +=
|
|
607
|
-
|
|
608
|
-
|
|
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 +=
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
630
|
-
|
|
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('
|
|
1703
|
+
result.push((toAbsolute ? 'T' : cmd) + ' ' + transformed.join(' '));
|
|
633
1704
|
break;
|
|
634
1705
|
}
|
|
635
1706
|
|
|
636
1707
|
case 'A': {
|
|
637
|
-
//
|
|
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
|
-
|
|
647
|
-
const y = args[i + 6];
|
|
1716
|
+
let x = D(args[i + 5]), y = D(args[i + 6]);
|
|
648
1717
|
|
|
649
|
-
|
|
1718
|
+
if (isRelative) { x = x.plus(curX); y = y.plus(curY); }
|
|
650
1719
|
|
|
651
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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 + ' ' +
|
|
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
|
-
*
|
|
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
|
-
|
|
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: '
|
|
690
|
-
|
|
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,
|