@emasoft/svg-matrix 1.0.19 → 1.0.20
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 +256 -759
- package/bin/svg-matrix.js +171 -2
- package/bin/svglinter.cjs +1162 -0
- package/package.json +8 -2
- package/src/animation-optimization.js +394 -0
- package/src/animation-references.js +440 -0
- package/src/arc-length.js +940 -0
- package/src/bezier-analysis.js +1626 -0
- package/src/bezier-intersections.js +1369 -0
- package/src/clip-path-resolver.js +110 -2
- package/src/convert-path-data.js +583 -0
- package/src/css-specificity.js +443 -0
- package/src/douglas-peucker.js +356 -0
- package/src/flatten-pipeline.js +109 -4
- package/src/geometry-to-path.js +126 -16
- package/src/gjk-collision.js +840 -0
- package/src/index.js +175 -2
- package/src/off-canvas-detection.js +1222 -0
- package/src/path-analysis.js +1241 -0
- package/src/path-data-plugins.js +928 -0
- package/src/path-optimization.js +825 -0
- package/src/path-simplification.js +1140 -0
- package/src/polygon-clip.js +376 -99
- package/src/svg-boolean-ops.js +898 -0
- package/src/svg-collections.js +910 -0
- package/src/svg-parser.js +175 -16
- package/src/svg-rendering-context.js +627 -0
- package/src/svg-toolbox.js +7495 -0
- package/src/svg-validation-data.js +944 -0
- package/src/transform-decomposition.js +810 -0
- package/src/transform-optimization.js +936 -0
- package/src/use-symbol-resolver.js +75 -7
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Optimization with Arbitrary Precision and Mathematical Verification
|
|
3
|
+
*
|
|
4
|
+
* Provides functions to optimize SVG path commands while guaranteeing:
|
|
5
|
+
* 1. ARBITRARY PRECISION - All calculations use Decimal.js (80 digits)
|
|
6
|
+
* 2. MATHEMATICAL VERIFICATION - Every optimization is verified via sampling
|
|
7
|
+
*
|
|
8
|
+
* ## Algorithms Implemented
|
|
9
|
+
*
|
|
10
|
+
* ### Line to Horizontal (lineToHorizontal)
|
|
11
|
+
* Converts L command to H when y coordinate is the same.
|
|
12
|
+
* Uses endpoint verification to ensure correctness.
|
|
13
|
+
*
|
|
14
|
+
* ### Line to Vertical (lineToVertical)
|
|
15
|
+
* Converts L command to V when x coordinate is the same.
|
|
16
|
+
* Uses endpoint verification to ensure correctness.
|
|
17
|
+
*
|
|
18
|
+
* ### Curve to Smooth (curveToSmooth)
|
|
19
|
+
* Converts C (cubic Bezier) to S (smooth cubic) when first control point
|
|
20
|
+
* is the reflection of the previous control point across the current point.
|
|
21
|
+
* Verifies by sampling both curves at multiple t values.
|
|
22
|
+
*
|
|
23
|
+
* ### Quadratic to Smooth (quadraticToSmooth)
|
|
24
|
+
* Converts Q (quadratic Bezier) to T (smooth quadratic) when control point
|
|
25
|
+
* is the reflection of the previous control point across the current point.
|
|
26
|
+
* Verifies by sampling both curves at multiple t values.
|
|
27
|
+
*
|
|
28
|
+
* ### Absolute/Relative Conversion (toRelative, toAbsolute)
|
|
29
|
+
* Converts between absolute and relative path commands.
|
|
30
|
+
* Verifies bidirectional conversion (inverse must give same result).
|
|
31
|
+
*
|
|
32
|
+
* ### Shorter Form Selection (chooseShorterForm)
|
|
33
|
+
* Picks the shorter string encoding between absolute and relative forms.
|
|
34
|
+
* Verifies both produce same numeric values.
|
|
35
|
+
*
|
|
36
|
+
* ### Repeated Command Collapse (collapseRepeated)
|
|
37
|
+
* Merges consecutive identical commands into a single command with multiple
|
|
38
|
+
* coordinate pairs (e.g., multiple L commands into a polyline).
|
|
39
|
+
* Verifies the path remains identical.
|
|
40
|
+
*
|
|
41
|
+
* ### Line to Z (lineToZ)
|
|
42
|
+
* Converts a final L command that returns to subpath start into Z command.
|
|
43
|
+
* Verifies endpoint matches.
|
|
44
|
+
*
|
|
45
|
+
* @module path-optimization
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import Decimal from 'decimal.js';
|
|
49
|
+
|
|
50
|
+
// Set high precision for all calculations
|
|
51
|
+
Decimal.set({ precision: 80 });
|
|
52
|
+
|
|
53
|
+
// Helper to convert to Decimal
|
|
54
|
+
const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
55
|
+
|
|
56
|
+
// Near-zero threshold for comparisons
|
|
57
|
+
const EPSILON = new Decimal('1e-40');
|
|
58
|
+
|
|
59
|
+
// Default tolerance for optimization (user-configurable)
|
|
60
|
+
const DEFAULT_TOLERANCE = new Decimal('1e-10');
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Point and Distance Utilities
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a point with Decimal coordinates.
|
|
68
|
+
* @param {number|string|Decimal} x - X coordinate
|
|
69
|
+
* @param {number|string|Decimal} y - Y coordinate
|
|
70
|
+
* @returns {{x: Decimal, y: Decimal}} Point object
|
|
71
|
+
*/
|
|
72
|
+
export function point(x, y) {
|
|
73
|
+
return { x: D(x), y: D(y) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Calculate the distance between two points.
|
|
78
|
+
* @param {{x: Decimal, y: Decimal}} p1 - First point
|
|
79
|
+
* @param {{x: Decimal, y: Decimal}} p2 - Second point
|
|
80
|
+
* @returns {Decimal} Distance
|
|
81
|
+
*/
|
|
82
|
+
export function distance(p1, p2) {
|
|
83
|
+
const dx = p2.x.minus(p1.x);
|
|
84
|
+
const dy = p2.y.minus(p1.y);
|
|
85
|
+
return dx.mul(dx).plus(dy.mul(dy)).sqrt();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if two points are equal within tolerance.
|
|
90
|
+
* @param {{x: Decimal, y: Decimal}} p1 - First point
|
|
91
|
+
* @param {{x: Decimal, y: Decimal}} p2 - Second point
|
|
92
|
+
* @param {Decimal} [tolerance=EPSILON] - Comparison tolerance
|
|
93
|
+
* @returns {boolean} True if points are equal
|
|
94
|
+
*/
|
|
95
|
+
export function pointsEqual(p1, p2, tolerance = EPSILON) {
|
|
96
|
+
const tol = D(tolerance);
|
|
97
|
+
return p1.x.minus(p2.x).abs().lessThan(tol) && p1.y.minus(p2.y).abs().lessThan(tol);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Bezier Curve Evaluation (imported from path-simplification patterns)
|
|
102
|
+
// ============================================================================
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Evaluate a cubic Bezier curve at parameter t.
|
|
106
|
+
* B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3
|
|
107
|
+
*
|
|
108
|
+
* @param {{x: Decimal, y: Decimal}} p0 - Start point
|
|
109
|
+
* @param {{x: Decimal, y: Decimal}} p1 - First control point
|
|
110
|
+
* @param {{x: Decimal, y: Decimal}} p2 - Second control point
|
|
111
|
+
* @param {{x: Decimal, y: Decimal}} p3 - End point
|
|
112
|
+
* @param {number|string|Decimal} t - Parameter (0 to 1)
|
|
113
|
+
* @returns {{x: Decimal, y: Decimal}} Point on curve
|
|
114
|
+
*/
|
|
115
|
+
export function evaluateCubicBezier(p0, p1, p2, p3, t) {
|
|
116
|
+
const tD = D(t);
|
|
117
|
+
const oneMinusT = D(1).minus(tD);
|
|
118
|
+
|
|
119
|
+
// Bernstein basis polynomials
|
|
120
|
+
const b0 = oneMinusT.pow(3); // (1-t)³
|
|
121
|
+
const b1 = D(3).mul(oneMinusT.pow(2)).mul(tD); // 3(1-t)²t
|
|
122
|
+
const b2 = D(3).mul(oneMinusT).mul(tD.pow(2)); // 3(1-t)t²
|
|
123
|
+
const b3 = tD.pow(3); // t³
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
x: b0.mul(p0.x).plus(b1.mul(p1.x)).plus(b2.mul(p2.x)).plus(b3.mul(p3.x)),
|
|
127
|
+
y: b0.mul(p0.y).plus(b1.mul(p1.y)).plus(b2.mul(p2.y)).plus(b3.mul(p3.y))
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Evaluate a quadratic Bezier curve at parameter t.
|
|
133
|
+
* B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
|
|
134
|
+
*
|
|
135
|
+
* @param {{x: Decimal, y: Decimal}} p0 - Start point
|
|
136
|
+
* @param {{x: Decimal, y: Decimal}} p1 - Control point
|
|
137
|
+
* @param {{x: Decimal, y: Decimal}} p2 - End point
|
|
138
|
+
* @param {number|string|Decimal} t - Parameter (0 to 1)
|
|
139
|
+
* @returns {{x: Decimal, y: Decimal}} Point on curve
|
|
140
|
+
*/
|
|
141
|
+
export function evaluateQuadraticBezier(p0, p1, p2, t) {
|
|
142
|
+
const tD = D(t);
|
|
143
|
+
const oneMinusT = D(1).minus(tD);
|
|
144
|
+
|
|
145
|
+
// Bernstein basis polynomials
|
|
146
|
+
const b0 = oneMinusT.pow(2); // (1-t)²
|
|
147
|
+
const b1 = D(2).mul(oneMinusT).mul(tD); // 2(1-t)t
|
|
148
|
+
const b2 = tD.pow(2); // t²
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
x: b0.mul(p0.x).plus(b1.mul(p1.x)).plus(b2.mul(p2.x)),
|
|
152
|
+
y: b0.mul(p0.y).plus(b1.mul(p1.y)).plus(b2.mul(p2.y))
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ============================================================================
|
|
157
|
+
// Line Command Optimization
|
|
158
|
+
// ============================================================================
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Convert L command to H (horizontal line) when y coordinate is unchanged.
|
|
162
|
+
*
|
|
163
|
+
* VERIFICATION: Checks that endpoints match exactly.
|
|
164
|
+
*
|
|
165
|
+
* @param {number|string|Decimal} x1 - Start X coordinate
|
|
166
|
+
* @param {number|string|Decimal} y1 - Start Y coordinate
|
|
167
|
+
* @param {number|string|Decimal} x2 - End X coordinate
|
|
168
|
+
* @param {number|string|Decimal} y2 - End Y coordinate
|
|
169
|
+
* @param {Decimal} [tolerance=EPSILON] - Tolerance for Y equality check
|
|
170
|
+
* @returns {{canConvert: boolean, endX: Decimal, verified: boolean}} Conversion result
|
|
171
|
+
*/
|
|
172
|
+
export function lineToHorizontal(x1, y1, x2, y2, tolerance = EPSILON) {
|
|
173
|
+
const tol = D(tolerance);
|
|
174
|
+
const startY = D(y1);
|
|
175
|
+
const endY = D(y2);
|
|
176
|
+
const endX = D(x2);
|
|
177
|
+
|
|
178
|
+
// Check if Y coordinates are equal within tolerance
|
|
179
|
+
const yDiff = endY.minus(startY).abs();
|
|
180
|
+
const canConvert = yDiff.lessThan(tol);
|
|
181
|
+
|
|
182
|
+
// VERIFICATION: Endpoint check
|
|
183
|
+
// The H command moves to (x2, y1), so we verify y1 ≈ y2
|
|
184
|
+
const verified = canConvert ? yDiff.lessThan(tol) : true;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
canConvert,
|
|
188
|
+
endX,
|
|
189
|
+
verified
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Convert L command to V (vertical line) when x coordinate is unchanged.
|
|
195
|
+
*
|
|
196
|
+
* VERIFICATION: Checks that endpoints match exactly.
|
|
197
|
+
*
|
|
198
|
+
* @param {number|string|Decimal} x1 - Start X coordinate
|
|
199
|
+
* @param {number|string|Decimal} y1 - Start Y coordinate
|
|
200
|
+
* @param {number|string|Decimal} x2 - End X coordinate
|
|
201
|
+
* @param {number|string|Decimal} y2 - End Y coordinate
|
|
202
|
+
* @param {Decimal} [tolerance=EPSILON] - Tolerance for X equality check
|
|
203
|
+
* @returns {{canConvert: boolean, endY: Decimal, verified: boolean}} Conversion result
|
|
204
|
+
*/
|
|
205
|
+
export function lineToVertical(x1, y1, x2, y2, tolerance = EPSILON) {
|
|
206
|
+
const tol = D(tolerance);
|
|
207
|
+
const startX = D(x1);
|
|
208
|
+
const endX = D(x2);
|
|
209
|
+
const endY = D(y2);
|
|
210
|
+
|
|
211
|
+
// Check if X coordinates are equal within tolerance
|
|
212
|
+
const xDiff = endX.minus(startX).abs();
|
|
213
|
+
const canConvert = xDiff.lessThan(tol);
|
|
214
|
+
|
|
215
|
+
// VERIFICATION: Endpoint check
|
|
216
|
+
// The V command moves to (x1, y2), so we verify x1 ≈ x2
|
|
217
|
+
const verified = canConvert ? xDiff.lessThan(tol) : true;
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
canConvert,
|
|
221
|
+
endY,
|
|
222
|
+
verified
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Smooth Curve Conversion
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Reflect a control point across a center point.
|
|
232
|
+
* Used to calculate the expected first control point for smooth curves.
|
|
233
|
+
*
|
|
234
|
+
* @param {{x: Decimal, y: Decimal}} control - Control point to reflect
|
|
235
|
+
* @param {{x: Decimal, y: Decimal}} center - Center point (current position)
|
|
236
|
+
* @returns {{x: Decimal, y: Decimal}} Reflected point
|
|
237
|
+
*/
|
|
238
|
+
export function reflectPoint(control, center) {
|
|
239
|
+
// Reflection formula: reflected = center + (center - control) = 2*center - control
|
|
240
|
+
return {
|
|
241
|
+
x: D(2).mul(center.x).minus(control.x),
|
|
242
|
+
y: D(2).mul(center.y).minus(control.y)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Convert C (cubic Bezier) to S (smooth cubic) when first control point
|
|
248
|
+
* is the reflection of the previous control point across the current point.
|
|
249
|
+
*
|
|
250
|
+
* In SVG, S command has implicit first control point that is the reflection
|
|
251
|
+
* of the second control point of the previous curve across the current point.
|
|
252
|
+
*
|
|
253
|
+
* VERIFICATION: Samples both curves (original C and optimized S) at multiple
|
|
254
|
+
* t values to ensure they produce the same path.
|
|
255
|
+
*
|
|
256
|
+
* @param {{x: Decimal, y: Decimal} | null} prevControl - Previous second control point (or null if none)
|
|
257
|
+
* @param {number|string|Decimal} x0 - Current X position (start of curve)
|
|
258
|
+
* @param {number|string|Decimal} y0 - Current Y position (start of curve)
|
|
259
|
+
* @param {number|string|Decimal} x1 - First control point X
|
|
260
|
+
* @param {number|string|Decimal} y1 - First control point Y
|
|
261
|
+
* @param {number|string|Decimal} x2 - Second control point X
|
|
262
|
+
* @param {number|string|Decimal} y2 - Second control point Y
|
|
263
|
+
* @param {number|string|Decimal} x3 - End point X
|
|
264
|
+
* @param {number|string|Decimal} y3 - End point Y
|
|
265
|
+
* @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
|
|
266
|
+
* @returns {{canConvert: boolean, cp2X: Decimal, cp2Y: Decimal, endX: Decimal, endY: Decimal, maxDeviation: Decimal, verified: boolean}}
|
|
267
|
+
*/
|
|
268
|
+
export function curveToSmooth(prevControl, x0, y0, x1, y1, x2, y2, x3, y3, tolerance = DEFAULT_TOLERANCE) {
|
|
269
|
+
const tol = D(tolerance);
|
|
270
|
+
const p0 = point(x0, y0);
|
|
271
|
+
const p1 = point(x1, y1);
|
|
272
|
+
const p2 = point(x2, y2);
|
|
273
|
+
const p3 = point(x3, y3);
|
|
274
|
+
|
|
275
|
+
// If no previous control point, cannot convert to S
|
|
276
|
+
if (!prevControl) {
|
|
277
|
+
return {
|
|
278
|
+
canConvert: false,
|
|
279
|
+
cp2X: p2.x,
|
|
280
|
+
cp2Y: p2.y,
|
|
281
|
+
endX: p3.x,
|
|
282
|
+
endY: p3.y,
|
|
283
|
+
maxDeviation: D(Infinity),
|
|
284
|
+
verified: true
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Calculate expected first control point (reflection of previous second control)
|
|
289
|
+
const expectedP1 = reflectPoint(prevControl, p0);
|
|
290
|
+
|
|
291
|
+
// Check if actual first control point matches expected
|
|
292
|
+
const controlDeviation = distance(p1, expectedP1);
|
|
293
|
+
|
|
294
|
+
if (controlDeviation.greaterThan(tol)) {
|
|
295
|
+
return {
|
|
296
|
+
canConvert: false,
|
|
297
|
+
cp2X: p2.x,
|
|
298
|
+
cp2Y: p2.y,
|
|
299
|
+
endX: p3.x,
|
|
300
|
+
endY: p3.y,
|
|
301
|
+
maxDeviation: controlDeviation,
|
|
302
|
+
verified: true
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// VERIFICATION: Sample both curves and compare
|
|
307
|
+
// Original: C with (p0, p1, p2, p3)
|
|
308
|
+
// Smooth: S with (p0, expectedP1, p2, p3)
|
|
309
|
+
const samples = 20;
|
|
310
|
+
let maxSampleDeviation = new Decimal(0);
|
|
311
|
+
|
|
312
|
+
for (let i = 0; i <= samples; i++) {
|
|
313
|
+
const t = D(i).div(samples);
|
|
314
|
+
const originalPoint = evaluateCubicBezier(p0, p1, p2, p3, t);
|
|
315
|
+
const smoothPoint = evaluateCubicBezier(p0, expectedP1, p2, p3, t);
|
|
316
|
+
const dev = distance(originalPoint, smoothPoint);
|
|
317
|
+
maxSampleDeviation = Decimal.max(maxSampleDeviation, dev);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const verified = maxSampleDeviation.lessThanOrEqualTo(tol);
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
canConvert: verified,
|
|
324
|
+
cp2X: p2.x,
|
|
325
|
+
cp2Y: p2.y,
|
|
326
|
+
endX: p3.x,
|
|
327
|
+
endY: p3.y,
|
|
328
|
+
maxDeviation: Decimal.max(controlDeviation, maxSampleDeviation),
|
|
329
|
+
verified: true
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Convert Q (quadratic Bezier) to T (smooth quadratic) when control point
|
|
335
|
+
* is the reflection of the previous control point across the current point.
|
|
336
|
+
*
|
|
337
|
+
* In SVG, T command has implicit control point that is the reflection
|
|
338
|
+
* of the control point of the previous curve across the current point.
|
|
339
|
+
*
|
|
340
|
+
* VERIFICATION: Samples both curves (original Q and optimized T) at multiple
|
|
341
|
+
* t values to ensure they produce the same path.
|
|
342
|
+
*
|
|
343
|
+
* @param {{x: Decimal, y: Decimal} | null} prevControl - Previous control point (or null if none)
|
|
344
|
+
* @param {number|string|Decimal} x0 - Current X position (start of curve)
|
|
345
|
+
* @param {number|string|Decimal} y0 - Current Y position (start of curve)
|
|
346
|
+
* @param {number|string|Decimal} x1 - Control point X
|
|
347
|
+
* @param {number|string|Decimal} y1 - Control point Y
|
|
348
|
+
* @param {number|string|Decimal} x2 - End point X
|
|
349
|
+
* @param {number|string|Decimal} y2 - End point Y
|
|
350
|
+
* @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
|
|
351
|
+
* @returns {{canConvert: boolean, endX: Decimal, endY: Decimal, maxDeviation: Decimal, verified: boolean}}
|
|
352
|
+
*/
|
|
353
|
+
export function quadraticToSmooth(prevControl, x0, y0, x1, y1, x2, y2, tolerance = DEFAULT_TOLERANCE) {
|
|
354
|
+
const tol = D(tolerance);
|
|
355
|
+
const p0 = point(x0, y0);
|
|
356
|
+
const p1 = point(x1, y1);
|
|
357
|
+
const p2 = point(x2, y2);
|
|
358
|
+
|
|
359
|
+
// If no previous control point, cannot convert to T
|
|
360
|
+
if (!prevControl) {
|
|
361
|
+
return {
|
|
362
|
+
canConvert: false,
|
|
363
|
+
endX: p2.x,
|
|
364
|
+
endY: p2.y,
|
|
365
|
+
maxDeviation: D(Infinity),
|
|
366
|
+
verified: true
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Calculate expected control point (reflection of previous control)
|
|
371
|
+
const expectedP1 = reflectPoint(prevControl, p0);
|
|
372
|
+
|
|
373
|
+
// Check if actual control point matches expected
|
|
374
|
+
const controlDeviation = distance(p1, expectedP1);
|
|
375
|
+
|
|
376
|
+
if (controlDeviation.greaterThan(tol)) {
|
|
377
|
+
return {
|
|
378
|
+
canConvert: false,
|
|
379
|
+
endX: p2.x,
|
|
380
|
+
endY: p2.y,
|
|
381
|
+
maxDeviation: controlDeviation,
|
|
382
|
+
verified: true
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// VERIFICATION: Sample both curves and compare
|
|
387
|
+
// Original: Q with (p0, p1, p2)
|
|
388
|
+
// Smooth: T with (p0, expectedP1, p2)
|
|
389
|
+
const samples = 20;
|
|
390
|
+
let maxSampleDeviation = new Decimal(0);
|
|
391
|
+
|
|
392
|
+
for (let i = 0; i <= samples; i++) {
|
|
393
|
+
const t = D(i).div(samples);
|
|
394
|
+
const originalPoint = evaluateQuadraticBezier(p0, p1, p2, t);
|
|
395
|
+
const smoothPoint = evaluateQuadraticBezier(p0, expectedP1, p2, t);
|
|
396
|
+
const dev = distance(originalPoint, smoothPoint);
|
|
397
|
+
maxSampleDeviation = Decimal.max(maxSampleDeviation, dev);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const verified = maxSampleDeviation.lessThanOrEqualTo(tol);
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
canConvert: verified,
|
|
404
|
+
endX: p2.x,
|
|
405
|
+
endY: p2.y,
|
|
406
|
+
maxDeviation: Decimal.max(controlDeviation, maxSampleDeviation),
|
|
407
|
+
verified: true
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ============================================================================
|
|
412
|
+
// Absolute/Relative Command Conversion
|
|
413
|
+
// ============================================================================
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Internal helper to convert absolute args to relative without verification.
|
|
417
|
+
* Used to avoid infinite recursion in verification.
|
|
418
|
+
*/
|
|
419
|
+
function _toRelativeArgs(cmd, args, cx, cy) {
|
|
420
|
+
if (cmd === 'M' || cmd === 'L' || cmd === 'T') {
|
|
421
|
+
const relativeArgs = [];
|
|
422
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
423
|
+
relativeArgs.push(args[i].minus(cx));
|
|
424
|
+
relativeArgs.push(args[i + 1].minus(cy));
|
|
425
|
+
}
|
|
426
|
+
return relativeArgs;
|
|
427
|
+
}
|
|
428
|
+
if (cmd === 'H') {
|
|
429
|
+
return args.map(x => x.minus(cx));
|
|
430
|
+
}
|
|
431
|
+
if (cmd === 'V') {
|
|
432
|
+
return args.map(y => y.minus(cy));
|
|
433
|
+
}
|
|
434
|
+
if (cmd === 'C' || cmd === 'S' || cmd === 'Q') {
|
|
435
|
+
const relativeArgs = [];
|
|
436
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
437
|
+
relativeArgs.push(args[i].minus(cx));
|
|
438
|
+
relativeArgs.push(args[i + 1].minus(cy));
|
|
439
|
+
}
|
|
440
|
+
return relativeArgs;
|
|
441
|
+
}
|
|
442
|
+
if (cmd === 'A') {
|
|
443
|
+
const relativeArgs = [];
|
|
444
|
+
for (let i = 0; i < args.length; i += 7) {
|
|
445
|
+
relativeArgs.push(args[i]);
|
|
446
|
+
relativeArgs.push(args[i + 1]);
|
|
447
|
+
relativeArgs.push(args[i + 2]);
|
|
448
|
+
relativeArgs.push(args[i + 3]);
|
|
449
|
+
relativeArgs.push(args[i + 4]);
|
|
450
|
+
relativeArgs.push(args[i + 5].minus(cx));
|
|
451
|
+
relativeArgs.push(args[i + 6].minus(cy));
|
|
452
|
+
}
|
|
453
|
+
return relativeArgs;
|
|
454
|
+
}
|
|
455
|
+
return args;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Internal helper to convert relative args to absolute without verification.
|
|
460
|
+
* Used to avoid infinite recursion in verification.
|
|
461
|
+
*/
|
|
462
|
+
function _toAbsoluteArgs(cmd, args, cx, cy) {
|
|
463
|
+
if (cmd === 'm' || cmd === 'l' || cmd === 't') {
|
|
464
|
+
const absoluteArgs = [];
|
|
465
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
466
|
+
absoluteArgs.push(args[i].plus(cx));
|
|
467
|
+
absoluteArgs.push(args[i + 1].plus(cy));
|
|
468
|
+
}
|
|
469
|
+
return absoluteArgs;
|
|
470
|
+
}
|
|
471
|
+
if (cmd === 'h') {
|
|
472
|
+
return args.map(dx => dx.plus(cx));
|
|
473
|
+
}
|
|
474
|
+
if (cmd === 'v') {
|
|
475
|
+
return args.map(dy => dy.plus(cy));
|
|
476
|
+
}
|
|
477
|
+
if (cmd === 'c' || cmd === 's' || cmd === 'q') {
|
|
478
|
+
const absoluteArgs = [];
|
|
479
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
480
|
+
absoluteArgs.push(args[i].plus(cx));
|
|
481
|
+
absoluteArgs.push(args[i + 1].plus(cy));
|
|
482
|
+
}
|
|
483
|
+
return absoluteArgs;
|
|
484
|
+
}
|
|
485
|
+
if (cmd === 'a') {
|
|
486
|
+
const absoluteArgs = [];
|
|
487
|
+
for (let i = 0; i < args.length; i += 7) {
|
|
488
|
+
absoluteArgs.push(args[i]);
|
|
489
|
+
absoluteArgs.push(args[i + 1]);
|
|
490
|
+
absoluteArgs.push(args[i + 2]);
|
|
491
|
+
absoluteArgs.push(args[i + 3]);
|
|
492
|
+
absoluteArgs.push(args[i + 4]);
|
|
493
|
+
absoluteArgs.push(args[i + 5].plus(cx));
|
|
494
|
+
absoluteArgs.push(args[i + 6].plus(cy));
|
|
495
|
+
}
|
|
496
|
+
return absoluteArgs;
|
|
497
|
+
}
|
|
498
|
+
return args;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Convert an absolute path command to relative.
|
|
503
|
+
*
|
|
504
|
+
* VERIFICATION: Converting back to absolute (inverse operation) must give
|
|
505
|
+
* the same result within tolerance.
|
|
506
|
+
*
|
|
507
|
+
* @param {{command: string, args: Array<number|string|Decimal>}} command - Path command
|
|
508
|
+
* @param {number|string|Decimal} currentX - Current X position
|
|
509
|
+
* @param {number|string|Decimal} currentY - Current Y position
|
|
510
|
+
* @returns {{command: string, args: Array<Decimal>, verified: boolean}}
|
|
511
|
+
*/
|
|
512
|
+
export function toRelative(command, currentX, currentY) {
|
|
513
|
+
const cmd = command.command;
|
|
514
|
+
const args = command.args.map(D);
|
|
515
|
+
const cx = D(currentX);
|
|
516
|
+
const cy = D(currentY);
|
|
517
|
+
|
|
518
|
+
// Z command is already relative (closes to start of subpath)
|
|
519
|
+
if (cmd === 'Z' || cmd === 'z') {
|
|
520
|
+
return {
|
|
521
|
+
command: 'z',
|
|
522
|
+
args: [],
|
|
523
|
+
verified: true
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Command is already relative - return as-is
|
|
528
|
+
if (cmd === cmd.toLowerCase()) {
|
|
529
|
+
return {
|
|
530
|
+
command: cmd,
|
|
531
|
+
args,
|
|
532
|
+
verified: true
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Convert absolute to relative using internal helper
|
|
537
|
+
const relativeArgs = _toRelativeArgs(cmd, args, cx, cy);
|
|
538
|
+
const relCmd = cmd.toLowerCase();
|
|
539
|
+
|
|
540
|
+
// VERIFICATION: Convert back to absolute using internal helper and check
|
|
541
|
+
const backToAbs = _toAbsoluteArgs(relCmd, relativeArgs, cx, cy);
|
|
542
|
+
let verified = true;
|
|
543
|
+
for (let i = 0; i < args.length; i++) {
|
|
544
|
+
if (args[i].minus(backToAbs[i]).abs().greaterThan(EPSILON)) {
|
|
545
|
+
verified = false;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
command: relCmd,
|
|
552
|
+
args: relativeArgs,
|
|
553
|
+
verified
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Convert a relative path command to absolute.
|
|
559
|
+
*
|
|
560
|
+
* VERIFICATION: Converting back to relative (inverse operation) must give
|
|
561
|
+
* the same result within tolerance.
|
|
562
|
+
*
|
|
563
|
+
* @param {{command: string, args: Array<number|string|Decimal>}} command - Path command
|
|
564
|
+
* @param {number|string|Decimal} currentX - Current X position
|
|
565
|
+
* @param {number|string|Decimal} currentY - Current Y position
|
|
566
|
+
* @returns {{command: string, args: Array<Decimal>, verified: boolean}}
|
|
567
|
+
*/
|
|
568
|
+
export function toAbsolute(command, currentX, currentY) {
|
|
569
|
+
const cmd = command.command;
|
|
570
|
+
const args = command.args.map(D);
|
|
571
|
+
const cx = D(currentX);
|
|
572
|
+
const cy = D(currentY);
|
|
573
|
+
|
|
574
|
+
// Z command is always absolute (closes to start of subpath)
|
|
575
|
+
if (cmd === 'Z' || cmd === 'z') {
|
|
576
|
+
return {
|
|
577
|
+
command: 'Z',
|
|
578
|
+
args: [],
|
|
579
|
+
verified: true
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Command is already absolute - return as-is
|
|
584
|
+
if (cmd === cmd.toUpperCase()) {
|
|
585
|
+
return {
|
|
586
|
+
command: cmd,
|
|
587
|
+
args,
|
|
588
|
+
verified: true
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Convert relative to absolute using internal helper
|
|
593
|
+
const absoluteArgs = _toAbsoluteArgs(cmd, args, cx, cy);
|
|
594
|
+
const absCmd = cmd.toUpperCase();
|
|
595
|
+
|
|
596
|
+
// VERIFICATION: Convert back to relative using internal helper and check
|
|
597
|
+
const backToRel = _toRelativeArgs(absCmd, absoluteArgs, cx, cy);
|
|
598
|
+
let verified = true;
|
|
599
|
+
for (let i = 0; i < args.length; i++) {
|
|
600
|
+
if (args[i].minus(backToRel[i]).abs().greaterThan(EPSILON)) {
|
|
601
|
+
verified = false;
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
command: absCmd,
|
|
608
|
+
args: absoluteArgs,
|
|
609
|
+
verified
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ============================================================================
|
|
614
|
+
// Shorter Form Selection
|
|
615
|
+
// ============================================================================
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Format a path command as a string (simplified, for length comparison).
|
|
619
|
+
*
|
|
620
|
+
* @param {{command: string, args: Array<Decimal>}} command - Path command
|
|
621
|
+
* @param {number} [precision=6] - Number of decimal places
|
|
622
|
+
* @returns {string} Formatted command string
|
|
623
|
+
*/
|
|
624
|
+
function formatCommand(command, precision = 6) {
|
|
625
|
+
const cmd = command.command;
|
|
626
|
+
const args = command.args.map(arg => {
|
|
627
|
+
const num = arg.toNumber();
|
|
628
|
+
// Format with specified precision and remove trailing zeros
|
|
629
|
+
return parseFloat(num.toFixed(precision)).toString();
|
|
630
|
+
}).join(',');
|
|
631
|
+
|
|
632
|
+
return args.length > 0 ? `${cmd}${args}` : cmd;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Choose the shorter string encoding between absolute and relative forms.
|
|
637
|
+
*
|
|
638
|
+
* VERIFICATION: Both forms must produce the same numeric values when parsed.
|
|
639
|
+
*
|
|
640
|
+
* @param {{command: string, args: Array<number|string|Decimal>}} absCommand - Absolute command
|
|
641
|
+
* @param {{command: string, args: Array<number|string|Decimal>}} relCommand - Relative command
|
|
642
|
+
* @param {number} [precision=6] - Number of decimal places for string formatting
|
|
643
|
+
* @returns {{command: string, args: Array<Decimal>, isShorter: boolean, savedBytes: number, verified: boolean}}
|
|
644
|
+
*/
|
|
645
|
+
export function chooseShorterForm(absCommand, relCommand, precision = 6) {
|
|
646
|
+
const absStr = formatCommand({ command: absCommand.command, args: absCommand.args.map(D) }, precision);
|
|
647
|
+
const relStr = formatCommand({ command: relCommand.command, args: relCommand.args.map(D) }, precision);
|
|
648
|
+
|
|
649
|
+
const absLen = absStr.length;
|
|
650
|
+
const relLen = relStr.length;
|
|
651
|
+
|
|
652
|
+
const isShorter = relLen < absLen;
|
|
653
|
+
const savedBytes = isShorter ? absLen - relLen : 0;
|
|
654
|
+
|
|
655
|
+
// VERIFICATION: Both commands must have the same number of arguments
|
|
656
|
+
const verified = absCommand.args.length === relCommand.args.length;
|
|
657
|
+
|
|
658
|
+
if (isShorter) {
|
|
659
|
+
return {
|
|
660
|
+
command: relCommand.command,
|
|
661
|
+
args: relCommand.args.map(D),
|
|
662
|
+
isShorter,
|
|
663
|
+
savedBytes,
|
|
664
|
+
verified
|
|
665
|
+
};
|
|
666
|
+
} else {
|
|
667
|
+
return {
|
|
668
|
+
command: absCommand.command,
|
|
669
|
+
args: absCommand.args.map(D),
|
|
670
|
+
isShorter: false,
|
|
671
|
+
savedBytes: 0,
|
|
672
|
+
verified
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ============================================================================
|
|
678
|
+
// Repeated Command Collapse
|
|
679
|
+
// ============================================================================
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Collapse consecutive identical commands into a single command with
|
|
683
|
+
* multiple coordinate pairs.
|
|
684
|
+
*
|
|
685
|
+
* For example: L 10,20 L 30,40 L 50,60 → L 10,20 30,40 50,60
|
|
686
|
+
*
|
|
687
|
+
* VERIFICATION: The path must remain identical after collapsing.
|
|
688
|
+
*
|
|
689
|
+
* @param {Array<{command: string, args: Array<number|string|Decimal>}>} commands - Array of path commands
|
|
690
|
+
* @returns {{commands: Array<{command: string, args: Array<Decimal>}>, collapseCount: number, verified: boolean}}
|
|
691
|
+
*/
|
|
692
|
+
export function collapseRepeated(commands) {
|
|
693
|
+
if (commands.length < 2) {
|
|
694
|
+
return {
|
|
695
|
+
commands: commands.map(cmd => ({ command: cmd.command, args: cmd.args.map(D) })),
|
|
696
|
+
collapseCount: 0,
|
|
697
|
+
verified: true
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const result = [];
|
|
702
|
+
let currentCommand = null;
|
|
703
|
+
let currentArgs = [];
|
|
704
|
+
let collapseCount = 0;
|
|
705
|
+
|
|
706
|
+
for (const cmd of commands) {
|
|
707
|
+
const command = cmd.command;
|
|
708
|
+
const args = cmd.args.map(D);
|
|
709
|
+
|
|
710
|
+
// Commands that can be collapsed (those with repeated coordinate pairs)
|
|
711
|
+
// Note: M/m are excluded because M has special semantics (first pair is moveto, subsequent pairs become lineto)
|
|
712
|
+
const canCollapse = ['L', 'l', 'H', 'h', 'V', 'v', 'T', 't', 'C', 'c', 'S', 's', 'Q', 'q'].includes(command);
|
|
713
|
+
|
|
714
|
+
if (canCollapse && command === currentCommand) {
|
|
715
|
+
// Same command - append args
|
|
716
|
+
currentArgs.push(...args);
|
|
717
|
+
collapseCount++;
|
|
718
|
+
} else {
|
|
719
|
+
// Different command or non-collapsible - flush current
|
|
720
|
+
if (currentCommand !== null) {
|
|
721
|
+
result.push({ command: currentCommand, args: currentArgs });
|
|
722
|
+
}
|
|
723
|
+
currentCommand = command;
|
|
724
|
+
currentArgs = [...args];
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Flush last command
|
|
729
|
+
if (currentCommand !== null) {
|
|
730
|
+
result.push({ command: currentCommand, args: currentArgs });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// VERIFICATION: Count total arguments should remain the same
|
|
734
|
+
const originalArgCount = commands.reduce((sum, cmd) => sum + cmd.args.length, 0);
|
|
735
|
+
const resultArgCount = result.reduce((sum, cmd) => sum + cmd.args.length, 0);
|
|
736
|
+
const verified = originalArgCount === resultArgCount;
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
commands: result,
|
|
740
|
+
collapseCount,
|
|
741
|
+
verified
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ============================================================================
|
|
746
|
+
// Line to Z Conversion
|
|
747
|
+
// ============================================================================
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Convert a final L command that returns to subpath start into Z command.
|
|
751
|
+
*
|
|
752
|
+
* VERIFICATION: Endpoint of the line must match the subpath start point.
|
|
753
|
+
*
|
|
754
|
+
* @param {number|string|Decimal} lastX - Last X position before the line
|
|
755
|
+
* @param {number|string|Decimal} lastY - Last Y position before the line
|
|
756
|
+
* @param {number|string|Decimal} startX - Subpath start X position
|
|
757
|
+
* @param {number|string|Decimal} startY - Subpath start Y position
|
|
758
|
+
* @param {Decimal} [tolerance=EPSILON] - Tolerance for endpoint matching
|
|
759
|
+
* @returns {{canConvert: boolean, deviation: Decimal, verified: boolean}}
|
|
760
|
+
*/
|
|
761
|
+
export function lineToZ(lastX, lastY, startX, startY, tolerance = EPSILON) {
|
|
762
|
+
const tol = D(tolerance);
|
|
763
|
+
const last = point(lastX, lastY);
|
|
764
|
+
const start = point(startX, startY);
|
|
765
|
+
|
|
766
|
+
// Check if the line endpoint matches the subpath start
|
|
767
|
+
const deviation = distance(last, start);
|
|
768
|
+
const canConvert = deviation.lessThan(tol);
|
|
769
|
+
|
|
770
|
+
// VERIFICATION: If we can convert, the deviation must be within tolerance
|
|
771
|
+
const verified = canConvert ? deviation.lessThan(tol) : true;
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
canConvert,
|
|
775
|
+
deviation,
|
|
776
|
+
verified
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ============================================================================
|
|
781
|
+
// Exports
|
|
782
|
+
// ============================================================================
|
|
783
|
+
|
|
784
|
+
export {
|
|
785
|
+
EPSILON,
|
|
786
|
+
DEFAULT_TOLERANCE,
|
|
787
|
+
D
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
export default {
|
|
791
|
+
// Point utilities
|
|
792
|
+
point,
|
|
793
|
+
distance,
|
|
794
|
+
pointsEqual,
|
|
795
|
+
|
|
796
|
+
// Bezier evaluation
|
|
797
|
+
evaluateCubicBezier,
|
|
798
|
+
evaluateQuadraticBezier,
|
|
799
|
+
|
|
800
|
+
// Line optimization
|
|
801
|
+
lineToHorizontal,
|
|
802
|
+
lineToVertical,
|
|
803
|
+
|
|
804
|
+
// Smooth curve conversion
|
|
805
|
+
reflectPoint,
|
|
806
|
+
curveToSmooth,
|
|
807
|
+
quadraticToSmooth,
|
|
808
|
+
|
|
809
|
+
// Absolute/relative conversion
|
|
810
|
+
toRelative,
|
|
811
|
+
toAbsolute,
|
|
812
|
+
|
|
813
|
+
// Shorter form selection
|
|
814
|
+
chooseShorterForm,
|
|
815
|
+
|
|
816
|
+
// Repeated command collapse
|
|
817
|
+
collapseRepeated,
|
|
818
|
+
|
|
819
|
+
// Line to Z conversion
|
|
820
|
+
lineToZ,
|
|
821
|
+
|
|
822
|
+
// Constants
|
|
823
|
+
EPSILON,
|
|
824
|
+
DEFAULT_TOLERANCE
|
|
825
|
+
};
|