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