@emasoft/svg-matrix 1.0.19 → 1.0.21

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.
@@ -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
+ };