@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.
@@ -0,0 +1,928 @@
1
+ /**
2
+ * Path Data Plugins - Individual SVGO-style optimization functions
3
+ *
4
+ * Each function performs ONE specific optimization on path data.
5
+ * This mirrors SVGO's plugin architecture where each plugin does exactly one thing.
6
+ *
7
+ * ALL CURVE CONVERSIONS ARE MATHEMATICALLY VERIFIED:
8
+ * - Curves are sampled at multiple t values (0.1, 0.2, ..., 0.9)
9
+ * - Actual curve points are compared, NOT control points
10
+ * - Conversion only happens if ALL sampled points are within tolerance
11
+ * - This guarantees visual fidelity within the specified tolerance
12
+ *
13
+ * PLUGIN CATEGORIES:
14
+ *
15
+ * ENCODING ONLY (No geometry changes):
16
+ * - removeLeadingZero - 0.5 -> .5
17
+ * - negativeExtraSpace - uses negative as delimiter
18
+ * - convertToRelative - absolute -> relative commands
19
+ * - convertToAbsolute - relative -> absolute commands
20
+ * - lineShorthands - L -> H/V when horizontal/vertical
21
+ * - convertToZ - final L to start -> z
22
+ * - collapseRepeated - removes redundant command letters
23
+ * - floatPrecision - rounds numbers
24
+ * - arcShorthands - normalizes arc angles
25
+ *
26
+ * CURVE SIMPLIFICATION (Verified within tolerance):
27
+ * - straightCurves - C -> L when curve is within tolerance of line
28
+ * - convertCubicToQuadratic - C -> Q when curves match within tolerance
29
+ * - convertCubicToSmooth - C -> S when control point is reflection
30
+ * - convertQuadraticToSmooth - Q -> T when control point is reflection
31
+ * - removeUselessCommands - removes zero-length segments
32
+ *
33
+ * @module path-data-plugins
34
+ */
35
+
36
+ import { parsePath, serializePath, toAbsolute, toRelative, formatNumber } from './convert-path-data.js';
37
+
38
+ // ============================================================================
39
+ // PLUGIN: removeLeadingZero
40
+ // Removes leading zeros from decimal numbers (0.5 -> .5)
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Remove leading zeros from path numbers.
45
+ * Example: "M 0.5 0.25" -> "M .5 .25"
46
+ * @param {string} d - Path d attribute
47
+ * @param {number} precision - Decimal precision
48
+ * @returns {string} Optimized path
49
+ */
50
+ export function removeLeadingZero(d, precision = 3) {
51
+ const commands = parsePath(d);
52
+ if (commands.length === 0) return d;
53
+
54
+ // Format numbers with leading zero removal
55
+ const formatted = commands.map(cmd => ({
56
+ command: cmd.command,
57
+ args: cmd.args.map(n => {
58
+ let str = formatNumber(n, precision);
59
+ // formatNumber already handles leading zero removal
60
+ return parseFloat(str);
61
+ })
62
+ }));
63
+
64
+ return serializePath(formatted, precision);
65
+ }
66
+
67
+ // ============================================================================
68
+ // PLUGIN: negativeExtraSpace
69
+ // Uses negative sign as delimiter (saves space between numbers)
70
+ // ============================================================================
71
+
72
+ /**
73
+ * Use negative sign as delimiter between numbers.
74
+ * Example: "M 10 -5" -> "M10-5"
75
+ * @param {string} d - Path d attribute
76
+ * @param {number} precision - Decimal precision
77
+ * @returns {string} Optimized path
78
+ */
79
+ export function negativeExtraSpace(d, precision = 3) {
80
+ const commands = parsePath(d);
81
+ if (commands.length === 0) return d;
82
+
83
+ // serializePath already handles this optimization
84
+ return serializePath(commands, precision);
85
+ }
86
+
87
+ // ============================================================================
88
+ // PLUGIN: convertToRelative
89
+ // Converts all commands to relative form
90
+ // ============================================================================
91
+
92
+ /**
93
+ * Convert all path commands to relative form.
94
+ * @param {string} d - Path d attribute
95
+ * @param {number} precision - Decimal precision
96
+ * @returns {string} Path with relative commands
97
+ */
98
+ export function convertToRelative(d, precision = 3) {
99
+ const commands = parsePath(d);
100
+ if (commands.length === 0) return d;
101
+
102
+ let cx = 0, cy = 0;
103
+ let startX = 0, startY = 0;
104
+ const result = [];
105
+
106
+ for (const cmd of commands) {
107
+ // Convert to relative
108
+ const rel = toRelative(cmd, cx, cy);
109
+ result.push(rel);
110
+
111
+ // Update position using absolute form
112
+ const abs = toAbsolute(cmd, cx, cy);
113
+ switch (abs.command) {
114
+ case 'M': cx = abs.args[0]; cy = abs.args[1]; startX = cx; startY = cy; break;
115
+ case 'L': case 'T': cx = abs.args[0]; cy = abs.args[1]; break;
116
+ case 'H': cx = abs.args[0]; break;
117
+ case 'V': cy = abs.args[0]; break;
118
+ case 'C': cx = abs.args[4]; cy = abs.args[5]; break;
119
+ case 'S': case 'Q': cx = abs.args[2]; cy = abs.args[3]; break;
120
+ case 'A': cx = abs.args[5]; cy = abs.args[6]; break;
121
+ case 'Z': cx = startX; cy = startY; break;
122
+ }
123
+ }
124
+
125
+ return serializePath(result, precision);
126
+ }
127
+
128
+ // ============================================================================
129
+ // PLUGIN: convertToAbsolute
130
+ // Converts all commands to absolute form
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Convert all path commands to absolute form.
135
+ * @param {string} d - Path d attribute
136
+ * @param {number} precision - Decimal precision
137
+ * @returns {string} Path with absolute commands
138
+ */
139
+ export function convertToAbsolute(d, precision = 3) {
140
+ const commands = parsePath(d);
141
+ if (commands.length === 0) return d;
142
+
143
+ let cx = 0, cy = 0;
144
+ let startX = 0, startY = 0;
145
+ const result = [];
146
+
147
+ for (const cmd of commands) {
148
+ // Convert to absolute
149
+ const abs = toAbsolute(cmd, cx, cy);
150
+ result.push(abs);
151
+
152
+ // Update position
153
+ switch (abs.command) {
154
+ case 'M': cx = abs.args[0]; cy = abs.args[1]; startX = cx; startY = cy; break;
155
+ case 'L': case 'T': cx = abs.args[0]; cy = abs.args[1]; break;
156
+ case 'H': cx = abs.args[0]; break;
157
+ case 'V': cy = abs.args[0]; break;
158
+ case 'C': cx = abs.args[4]; cy = abs.args[5]; break;
159
+ case 'S': case 'Q': cx = abs.args[2]; cy = abs.args[3]; break;
160
+ case 'A': cx = abs.args[5]; cy = abs.args[6]; break;
161
+ case 'Z': cx = startX; cy = startY; break;
162
+ }
163
+ }
164
+
165
+ return serializePath(result, precision);
166
+ }
167
+
168
+ // ============================================================================
169
+ // PLUGIN: lineShorthands
170
+ // Converts L commands to H or V when applicable
171
+ // ============================================================================
172
+
173
+ /**
174
+ * Convert L commands to H (horizontal) or V (vertical) when applicable.
175
+ * Example: "L 100 50" when y unchanged -> "H 100"
176
+ * @param {string} d - Path d attribute
177
+ * @param {number} tolerance - Tolerance for detecting horizontal/vertical
178
+ * @param {number} precision - Decimal precision
179
+ * @returns {string} Optimized path
180
+ */
181
+ export function lineShorthands(d, tolerance = 1e-6, precision = 3) {
182
+ const commands = parsePath(d);
183
+ if (commands.length === 0) return d;
184
+
185
+ let cx = 0, cy = 0;
186
+ let startX = 0, startY = 0;
187
+ const result = [];
188
+
189
+ for (const cmd of commands) {
190
+ let newCmd = cmd;
191
+
192
+ if (cmd.command === 'L' || cmd.command === 'l') {
193
+ const isAbs = cmd.command === 'L';
194
+ const endX = isAbs ? cmd.args[0] : cx + cmd.args[0];
195
+ const endY = isAbs ? cmd.args[1] : cy + cmd.args[1];
196
+
197
+ if (Math.abs(endY - cy) < tolerance) {
198
+ // Horizontal line
199
+ newCmd = isAbs
200
+ ? { command: 'H', args: [endX] }
201
+ : { command: 'h', args: [endX - cx] };
202
+ } else if (Math.abs(endX - cx) < tolerance) {
203
+ // Vertical line
204
+ newCmd = isAbs
205
+ ? { command: 'V', args: [endY] }
206
+ : { command: 'v', args: [endY - cy] };
207
+ }
208
+ }
209
+
210
+ result.push(newCmd);
211
+
212
+ // Update position
213
+ const abs = toAbsolute(newCmd, cx, cy);
214
+ switch (abs.command) {
215
+ case 'M': cx = abs.args[0]; cy = abs.args[1]; startX = cx; startY = cy; break;
216
+ case 'L': case 'T': cx = abs.args[0]; cy = abs.args[1]; break;
217
+ case 'H': cx = abs.args[0]; break;
218
+ case 'V': cy = abs.args[0]; break;
219
+ case 'C': cx = abs.args[4]; cy = abs.args[5]; break;
220
+ case 'S': case 'Q': cx = abs.args[2]; cy = abs.args[3]; break;
221
+ case 'A': cx = abs.args[5]; cy = abs.args[6]; break;
222
+ case 'Z': cx = startX; cy = startY; break;
223
+ }
224
+ }
225
+
226
+ return serializePath(result, precision);
227
+ }
228
+
229
+ // ============================================================================
230
+ // PLUGIN: convertToZ
231
+ // Converts final L command to Z when it returns to subpath start
232
+ // ============================================================================
233
+
234
+ /**
235
+ * Convert final line command to Z when it closes the path.
236
+ * Example: "M 0 0 L 10 10 L 0 0" -> "M 0 0 L 10 10 z"
237
+ * @param {string} d - Path d attribute
238
+ * @param {number} tolerance - Tolerance for detecting closure
239
+ * @param {number} precision - Decimal precision
240
+ * @returns {string} Optimized path
241
+ */
242
+ export function convertToZ(d, tolerance = 1e-6, precision = 3) {
243
+ const commands = parsePath(d);
244
+ if (commands.length === 0) return d;
245
+
246
+ let cx = 0, cy = 0;
247
+ let startX = 0, startY = 0;
248
+ const result = [];
249
+
250
+ for (let i = 0; i < commands.length; i++) {
251
+ const cmd = commands[i];
252
+ let newCmd = cmd;
253
+
254
+ // Check if this L command goes back to start
255
+ if (cmd.command === 'L' || cmd.command === 'l') {
256
+ const isAbs = cmd.command === 'L';
257
+ const endX = isAbs ? cmd.args[0] : cx + cmd.args[0];
258
+ const endY = isAbs ? cmd.args[1] : cy + cmd.args[1];
259
+
260
+ if (Math.abs(endX - startX) < tolerance && Math.abs(endY - startY) < tolerance) {
261
+ // This line closes the path
262
+ newCmd = { command: 'z', args: [] };
263
+ }
264
+ }
265
+
266
+ result.push(newCmd);
267
+
268
+ // Update position
269
+ const abs = toAbsolute(newCmd, cx, cy);
270
+ switch (abs.command) {
271
+ case 'M': cx = abs.args[0]; cy = abs.args[1]; startX = cx; startY = cy; break;
272
+ case 'L': case 'T': cx = abs.args[0]; cy = abs.args[1]; break;
273
+ case 'H': cx = abs.args[0]; break;
274
+ case 'V': cy = abs.args[0]; break;
275
+ case 'C': cx = abs.args[4]; cy = abs.args[5]; break;
276
+ case 'S': case 'Q': cx = abs.args[2]; cy = abs.args[3]; break;
277
+ case 'A': cx = abs.args[5]; cy = abs.args[6]; break;
278
+ case 'Z': cx = startX; cy = startY; break;
279
+ }
280
+ }
281
+
282
+ return serializePath(result, precision);
283
+ }
284
+
285
+ // ============================================================================
286
+ // BEZIER CURVE UTILITIES - Proper mathematical evaluation and distance
287
+ // ============================================================================
288
+
289
+ /**
290
+ * Evaluate a cubic Bezier curve at parameter t.
291
+ * B(t) = (1-t)^3*P0 + 3*(1-t)^2*t*P1 + 3*(1-t)*t^2*P2 + t^3*P3
292
+ */
293
+ function cubicBezierPoint(t, p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y) {
294
+ const mt = 1 - t;
295
+ const mt2 = mt * mt;
296
+ const mt3 = mt2 * mt;
297
+ const t2 = t * t;
298
+ const t3 = t2 * t;
299
+ return {
300
+ x: mt3 * p0x + 3 * mt2 * t * p1x + 3 * mt * t2 * p2x + t3 * p3x,
301
+ y: mt3 * p0y + 3 * mt2 * t * p1y + 3 * mt * t2 * p2y + t3 * p3y
302
+ };
303
+ }
304
+
305
+ /**
306
+ * First derivative of cubic Bezier at t.
307
+ * B'(t) = 3(1-t)^2(P1-P0) + 6(1-t)t(P2-P1) + 3t^2(P3-P2)
308
+ */
309
+ function cubicBezierDeriv1(t, p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y) {
310
+ const mt = 1 - t;
311
+ const mt2 = mt * mt;
312
+ const t2 = t * t;
313
+ return {
314
+ x: 3 * mt2 * (p1x - p0x) + 6 * mt * t * (p2x - p1x) + 3 * t2 * (p3x - p2x),
315
+ y: 3 * mt2 * (p1y - p0y) + 6 * mt * t * (p2y - p1y) + 3 * t2 * (p3y - p2y)
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Second derivative of cubic Bezier at t.
321
+ * B''(t) = 6(1-t)(P2-2P1+P0) + 6t(P3-2P2+P1)
322
+ */
323
+ function cubicBezierDeriv2(t, p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y) {
324
+ const mt = 1 - t;
325
+ return {
326
+ x: 6 * mt * (p2x - 2 * p1x + p0x) + 6 * t * (p3x - 2 * p2x + p1x),
327
+ y: 6 * mt * (p2y - 2 * p1y + p0y) + 6 * t * (p3y - 2 * p2y + p1y)
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Find closest t on cubic Bezier to point p using Newton's method.
333
+ * Minimizes |B(t) - p|^2 by finding root of d/dt |B(t)-p|^2 = 2(B(t)-p)·B'(t) = 0
334
+ */
335
+ function closestTOnCubicBezier(px, py, p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, tInit = 0.5) {
336
+ let t = Math.max(0, Math.min(1, tInit));
337
+
338
+ for (let iter = 0; iter < 10; iter++) {
339
+ const Q = cubicBezierPoint(t, p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y);
340
+ const Q1 = cubicBezierDeriv1(t, p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y);
341
+ const Q2 = cubicBezierDeriv2(t, p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y);
342
+
343
+ const diffX = Q.x - px;
344
+ const diffY = Q.y - py;
345
+
346
+ // f(t) = (B(t) - p) · B'(t)
347
+ const f = diffX * Q1.x + diffY * Q1.y;
348
+ // f'(t) = B'(t)·B'(t) + (B(t)-p)·B''(t)
349
+ const fp = Q1.x * Q1.x + Q1.y * Q1.y + diffX * Q2.x + diffY * Q2.y;
350
+
351
+ if (Math.abs(fp) < 1e-12) break;
352
+
353
+ const tNext = Math.max(0, Math.min(1, t - f / fp));
354
+ if (Math.abs(tNext - t) < 1e-9) { t = tNext; break; }
355
+ t = tNext;
356
+ }
357
+
358
+ return t;
359
+ }
360
+
361
+ /**
362
+ * Calculate distance from point to line segment (for straight curve check).
363
+ */
364
+ function pointToLineDistance(px, py, x0, y0, x1, y1) {
365
+ const dx = x1 - x0;
366
+ const dy = y1 - y0;
367
+ const lengthSq = dx * dx + dy * dy;
368
+
369
+ if (lengthSq < 1e-10) {
370
+ return Math.sqrt((px - x0) ** 2 + (py - y0) ** 2);
371
+ }
372
+
373
+ const t = Math.max(0, Math.min(1, ((px - x0) * dx + (py - y0) * dy) / lengthSq));
374
+ const projX = x0 + t * dx;
375
+ const projY = y0 + t * dy;
376
+
377
+ return Math.sqrt((px - projX) ** 2 + (py - projY) ** 2);
378
+ }
379
+
380
+ /**
381
+ * Compute maximum error between cubic Bezier and a line segment.
382
+ * Uses Newton's method to find closest points + midpoint checks to catch bulges.
383
+ *
384
+ * @returns {number} Maximum distance from any curve point to the line
385
+ */
386
+ function maxErrorCurveToLine(p0x, p0y, cp1x, cp1y, cp2x, cp2y, p3x, p3y) {
387
+ let maxErr = 0;
388
+
389
+ // Sample at regular t intervals and find closest point on line
390
+ const samples = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
391
+ for (const t of samples) {
392
+ const pt = cubicBezierPoint(t, p0x, p0y, cp1x, cp1y, cp2x, cp2y, p3x, p3y);
393
+ const dist = pointToLineDistance(pt.x, pt.y, p0x, p0y, p3x, p3y);
394
+ if (dist > maxErr) maxErr = dist;
395
+ }
396
+
397
+ // Check midpoints between samples to catch bulges
398
+ for (let i = 0; i < samples.length - 1; i++) {
399
+ const tMid = (samples[i] + samples[i + 1]) / 2;
400
+ const pt = cubicBezierPoint(tMid, p0x, p0y, cp1x, cp1y, cp2x, cp2y, p3x, p3y);
401
+ const dist = pointToLineDistance(pt.x, pt.y, p0x, p0y, p3x, p3y);
402
+ if (dist > maxErr) maxErr = dist;
403
+ }
404
+
405
+ // Also check t=0.05 and t=0.95 (near endpoints where bulges can hide)
406
+ for (const t of [0.05, 0.95]) {
407
+ const pt = cubicBezierPoint(t, p0x, p0y, cp1x, cp1y, cp2x, cp2y, p3x, p3y);
408
+ const dist = pointToLineDistance(pt.x, pt.y, p0x, p0y, p3x, p3y);
409
+ if (dist > maxErr) maxErr = dist;
410
+ }
411
+
412
+ return maxErr;
413
+ }
414
+
415
+ // ============================================================================
416
+ // PLUGIN: straightCurves
417
+ // Converts cubic bezier curves to lines when effectively straight
418
+ // ============================================================================
419
+
420
+ /**
421
+ * Check if a cubic bezier is effectively a straight line.
422
+ * Uses comprehensive sampling + midpoint checks to find actual max deviation.
423
+ */
424
+ function isCurveStraight(x0, y0, cp1x, cp1y, cp2x, cp2y, x3, y3, tolerance) {
425
+ const maxError = maxErrorCurveToLine(x0, y0, cp1x, cp1y, cp2x, cp2y, x3, y3);
426
+ return maxError <= tolerance;
427
+ }
428
+
429
+ /**
430
+ * Convert cubic bezier curves to lines when effectively straight.
431
+ * Example: "C 0 0 10 10 10 10" -> "L 10 10" if control points are on the line
432
+ * @param {string} d - Path d attribute
433
+ * @param {number} tolerance - Maximum deviation to consider straight
434
+ * @param {number} precision - Decimal precision
435
+ * @returns {string} Optimized path
436
+ */
437
+ export function straightCurves(d, tolerance = 0.5, precision = 3) {
438
+ const commands = parsePath(d);
439
+ if (commands.length === 0) return d;
440
+
441
+ let cx = 0, cy = 0;
442
+ let startX = 0, startY = 0;
443
+ const result = [];
444
+
445
+ for (const cmd of commands) {
446
+ let newCmd = cmd;
447
+
448
+ if (cmd.command === 'C' || cmd.command === 'c') {
449
+ const isAbs = cmd.command === 'C';
450
+ const cp1x = isAbs ? cmd.args[0] : cx + cmd.args[0];
451
+ const cp1y = isAbs ? cmd.args[1] : cy + cmd.args[1];
452
+ const cp2x = isAbs ? cmd.args[2] : cx + cmd.args[2];
453
+ const cp2y = isAbs ? cmd.args[3] : cy + cmd.args[3];
454
+ const endX = isAbs ? cmd.args[4] : cx + cmd.args[4];
455
+ const endY = isAbs ? cmd.args[5] : cy + cmd.args[5];
456
+
457
+ if (isCurveStraight(cx, cy, cp1x, cp1y, cp2x, cp2y, endX, endY, tolerance)) {
458
+ newCmd = isAbs
459
+ ? { command: 'L', args: [endX, endY] }
460
+ : { command: 'l', args: [endX - cx, endY - cy] };
461
+ }
462
+ }
463
+
464
+ result.push(newCmd);
465
+
466
+ // Update position
467
+ const abs = toAbsolute(newCmd, cx, cy);
468
+ switch (abs.command) {
469
+ case 'M': cx = abs.args[0]; cy = abs.args[1]; startX = cx; startY = cy; break;
470
+ case 'L': case 'T': cx = abs.args[0]; cy = abs.args[1]; break;
471
+ case 'H': cx = abs.args[0]; break;
472
+ case 'V': cy = abs.args[0]; break;
473
+ case 'C': cx = abs.args[4]; cy = abs.args[5]; break;
474
+ case 'S': case 'Q': cx = abs.args[2]; cy = abs.args[3]; break;
475
+ case 'A': cx = abs.args[5]; cy = abs.args[6]; break;
476
+ case 'Z': cx = startX; cy = startY; break;
477
+ }
478
+ }
479
+
480
+ return serializePath(result, precision);
481
+ }
482
+
483
+ // ============================================================================
484
+ // PLUGIN: collapseRepeated
485
+ // Removes redundant command letters when same command repeats
486
+ // ============================================================================
487
+
488
+ /**
489
+ * Collapse repeated command letters.
490
+ * Example: "L 10 10 L 20 20" -> "L 10 10 20 20"
491
+ * Note: This is handled by serializePath internally.
492
+ * @param {string} d - Path d attribute
493
+ * @param {number} precision - Decimal precision
494
+ * @returns {string} Optimized path
495
+ */
496
+ export function collapseRepeated(d, precision = 3) {
497
+ const commands = parsePath(d);
498
+ if (commands.length === 0) return d;
499
+
500
+ // serializePath already collapses repeated commands
501
+ return serializePath(commands, precision);
502
+ }
503
+
504
+ // ============================================================================
505
+ // PLUGIN: floatPrecision
506
+ // Rounds all numbers to specified precision
507
+ // ============================================================================
508
+
509
+ /**
510
+ * Round all path numbers to specified precision.
511
+ * Example (precision=2): "M 10.12345 20.6789" -> "M 10.12 20.68"
512
+ * @param {string} d - Path d attribute
513
+ * @param {number} precision - Decimal places to keep
514
+ * @returns {string} Path with rounded numbers
515
+ */
516
+ export function floatPrecision(d, precision = 3) {
517
+ const commands = parsePath(d);
518
+ if (commands.length === 0) return d;
519
+
520
+ const factor = Math.pow(10, precision);
521
+ const rounded = commands.map(cmd => ({
522
+ command: cmd.command,
523
+ args: cmd.args.map(n => Math.round(n * factor) / factor)
524
+ }));
525
+
526
+ return serializePath(rounded, precision);
527
+ }
528
+
529
+ // ============================================================================
530
+ // PLUGIN: removeUselessCommands
531
+ // Removes commands that have no effect (zero-length lines, etc.)
532
+ // ============================================================================
533
+
534
+ /**
535
+ * Remove commands that have no visual effect.
536
+ * Example: "M 10 10 L 10 10" -> "M 10 10" (removes zero-length line)
537
+ * @param {string} d - Path d attribute
538
+ * @param {number} tolerance - Tolerance for detecting zero-length
539
+ * @param {number} precision - Decimal precision
540
+ * @returns {string} Optimized path
541
+ */
542
+ export function removeUselessCommands(d, tolerance = 1e-6, precision = 3) {
543
+ const commands = parsePath(d);
544
+ if (commands.length === 0) return d;
545
+
546
+ let cx = 0, cy = 0;
547
+ let startX = 0, startY = 0;
548
+ const result = [];
549
+
550
+ for (const cmd of commands) {
551
+ const abs = toAbsolute(cmd, cx, cy);
552
+ let keep = true;
553
+
554
+ // Check for zero-length commands
555
+ switch (abs.command) {
556
+ case 'L': case 'T':
557
+ if (Math.abs(abs.args[0] - cx) < tolerance &&
558
+ Math.abs(abs.args[1] - cy) < tolerance) {
559
+ keep = false;
560
+ }
561
+ break;
562
+ case 'H':
563
+ if (Math.abs(abs.args[0] - cx) < tolerance) {
564
+ keep = false;
565
+ }
566
+ break;
567
+ case 'V':
568
+ if (Math.abs(abs.args[0] - cy) < tolerance) {
569
+ keep = false;
570
+ }
571
+ break;
572
+ case 'C':
573
+ // Zero-length curve where all points are the same
574
+ if (Math.abs(abs.args[4] - cx) < tolerance &&
575
+ Math.abs(abs.args[5] - cy) < tolerance &&
576
+ Math.abs(abs.args[0] - cx) < tolerance &&
577
+ Math.abs(abs.args[1] - cy) < tolerance &&
578
+ Math.abs(abs.args[2] - cx) < tolerance &&
579
+ Math.abs(abs.args[3] - cy) < tolerance) {
580
+ keep = false;
581
+ }
582
+ break;
583
+ }
584
+
585
+ if (keep) {
586
+ result.push(cmd);
587
+
588
+ // Update position
589
+ switch (abs.command) {
590
+ case 'M': cx = abs.args[0]; cy = abs.args[1]; startX = cx; startY = cy; break;
591
+ case 'L': case 'T': cx = abs.args[0]; cy = abs.args[1]; break;
592
+ case 'H': cx = abs.args[0]; break;
593
+ case 'V': cy = abs.args[0]; break;
594
+ case 'C': cx = abs.args[4]; cy = abs.args[5]; break;
595
+ case 'S': case 'Q': cx = abs.args[2]; cy = abs.args[3]; break;
596
+ case 'A': cx = abs.args[5]; cy = abs.args[6]; break;
597
+ case 'Z': cx = startX; cy = startY; break;
598
+ }
599
+ }
600
+ }
601
+
602
+ return serializePath(result, precision);
603
+ }
604
+
605
+ // ============================================================================
606
+ // PLUGIN: convertCubicToQuadratic
607
+ // Converts cubic bezier to quadratic when possible (saves 2 parameters)
608
+ // ============================================================================
609
+
610
+ /**
611
+ * Evaluate a quadratic Bezier curve at parameter t.
612
+ * B(t) = (1-t)^2*P0 + 2*(1-t)*t*P1 + t^2*P2
613
+ */
614
+ function quadraticBezierPoint(t, p0x, p0y, p1x, p1y, p2x, p2y) {
615
+ const mt = 1 - t;
616
+ const mt2 = mt * mt;
617
+ const t2 = t * t;
618
+ return {
619
+ x: mt2 * p0x + 2 * mt * t * p1x + t2 * p2x,
620
+ y: mt2 * p0y + 2 * mt * t * p1y + t2 * p2y
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Compute maximum error between cubic Bezier and quadratic Bezier.
626
+ * Samples both curves and checks midpoints to find actual max deviation.
627
+ *
628
+ * @returns {number} Maximum distance between corresponding points on both curves
629
+ */
630
+ function maxErrorCubicToQuadratic(p0x, p0y, cp1x, cp1y, cp2x, cp2y, p3x, p3y, qx, qy) {
631
+ let maxErr = 0;
632
+
633
+ // Dense sampling including midpoints
634
+ const samples = [0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5,
635
+ 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95];
636
+
637
+ for (const t of samples) {
638
+ // Point on original cubic
639
+ const cubic = cubicBezierPoint(t, p0x, p0y, cp1x, cp1y, cp2x, cp2y, p3x, p3y);
640
+ // Point on proposed quadratic
641
+ const quad = quadraticBezierPoint(t, p0x, p0y, qx, qy, p3x, p3y);
642
+
643
+ const dist = Math.sqrt((cubic.x - quad.x) ** 2 + (cubic.y - quad.y) ** 2);
644
+ if (dist > maxErr) maxErr = dist;
645
+ }
646
+
647
+ return maxErr;
648
+ }
649
+
650
+ /**
651
+ * Check if a cubic bezier can be approximated by a quadratic.
652
+ * VERIFIED by comparing actual curve points with comprehensive sampling.
653
+ *
654
+ * @returns {{cpx: number, cpy: number} | null} Quadratic control point or null
655
+ */
656
+ function cubicToQuadraticControlPoint(x0, y0, cp1x, cp1y, cp2x, cp2y, x3, y3, tolerance) {
657
+ // Calculate the best-fit quadratic control point
658
+ // For a cubic to be exactly representable as quadratic:
659
+ // Q = (3*(P1 + P2) - P0 - P3) / 4
660
+ const qx = (3 * (cp1x + cp2x) - x0 - x3) / 4;
661
+ const qy = (3 * (cp1y + cp2y) - y0 - y3) / 4;
662
+
663
+ // VERIFY by computing actual max error between the curves
664
+ const maxError = maxErrorCubicToQuadratic(x0, y0, cp1x, cp1y, cp2x, cp2y, x3, y3, qx, qy);
665
+
666
+ if (maxError <= tolerance) {
667
+ return { cpx: qx, cpy: qy };
668
+ }
669
+
670
+ return null; // Curves deviate too much - cannot convert
671
+ }
672
+
673
+ /**
674
+ * Convert cubic bezier curves to quadratic when possible.
675
+ * Example: "C 6.67 0 13.33 10 20 10" -> "Q 10 5 20 10" (if approximation valid)
676
+ * @param {string} d - Path d attribute
677
+ * @param {number} tolerance - Maximum deviation for conversion
678
+ * @param {number} precision - Decimal precision
679
+ * @returns {string} Optimized path
680
+ */
681
+ export function convertCubicToQuadratic(d, tolerance = 0.5, precision = 3) {
682
+ const commands = parsePath(d);
683
+ if (commands.length === 0) return d;
684
+
685
+ let cx = 0, cy = 0;
686
+ let startX = 0, startY = 0;
687
+ const result = [];
688
+
689
+ for (const cmd of commands) {
690
+ let newCmd = cmd;
691
+
692
+ if (cmd.command === 'C' || cmd.command === 'c') {
693
+ const isAbs = cmd.command === 'C';
694
+ const cp1x = isAbs ? cmd.args[0] : cx + cmd.args[0];
695
+ const cp1y = isAbs ? cmd.args[1] : cy + cmd.args[1];
696
+ const cp2x = isAbs ? cmd.args[2] : cx + cmd.args[2];
697
+ const cp2y = isAbs ? cmd.args[3] : cy + cmd.args[3];
698
+ const endX = isAbs ? cmd.args[4] : cx + cmd.args[4];
699
+ const endY = isAbs ? cmd.args[5] : cy + cmd.args[5];
700
+
701
+ const quadCP = cubicToQuadraticControlPoint(cx, cy, cp1x, cp1y, cp2x, cp2y, endX, endY, tolerance);
702
+
703
+ if (quadCP) {
704
+ newCmd = isAbs
705
+ ? { command: 'Q', args: [quadCP.cpx, quadCP.cpy, endX, endY] }
706
+ : { command: 'q', args: [quadCP.cpx - cx, quadCP.cpy - cy, endX - cx, endY - cy] };
707
+ }
708
+ }
709
+
710
+ result.push(newCmd);
711
+
712
+ // Update position
713
+ const abs = toAbsolute(newCmd, cx, cy);
714
+ switch (abs.command) {
715
+ case 'M': cx = abs.args[0]; cy = abs.args[1]; startX = cx; startY = cy; break;
716
+ case 'L': case 'T': cx = abs.args[0]; cy = abs.args[1]; break;
717
+ case 'H': cx = abs.args[0]; break;
718
+ case 'V': cy = abs.args[0]; break;
719
+ case 'C': cx = abs.args[4]; cy = abs.args[5]; break;
720
+ case 'S': case 'Q': cx = abs.args[2]; cy = abs.args[3]; break;
721
+ case 'A': cx = abs.args[5]; cy = abs.args[6]; break;
722
+ case 'Z': cx = startX; cy = startY; break;
723
+ }
724
+ }
725
+
726
+ return serializePath(result, precision);
727
+ }
728
+
729
+ // ============================================================================
730
+ // PLUGIN: convertQuadraticToSmooth
731
+ // Converts Q commands to T when control point is reflection of previous
732
+ // ============================================================================
733
+
734
+ /**
735
+ * Convert quadratic bezier to smooth shorthand (T) when possible.
736
+ * Example: "Q 10 5 20 10 Q 30 15 40 10" -> "Q 10 5 20 10 T 40 10" (if cp is reflection)
737
+ * @param {string} d - Path d attribute
738
+ * @param {number} tolerance - Tolerance for detecting reflection
739
+ * @param {number} precision - Decimal precision
740
+ * @returns {string} Optimized path
741
+ */
742
+ export function convertQuadraticToSmooth(d, tolerance = 1e-6, precision = 3) {
743
+ const commands = parsePath(d);
744
+ if (commands.length === 0) return d;
745
+
746
+ let cx = 0, cy = 0;
747
+ let startX = 0, startY = 0;
748
+ let lastQcpX = null, lastQcpY = null;
749
+ const result = [];
750
+
751
+ for (const cmd of commands) {
752
+ let newCmd = cmd;
753
+ const abs = toAbsolute(cmd, cx, cy);
754
+
755
+ if (abs.command === 'Q' && lastQcpX !== null) {
756
+ // Calculate reflected control point
757
+ const reflectedCpX = 2 * cx - lastQcpX;
758
+ const reflectedCpY = 2 * cy - lastQcpY;
759
+
760
+ // Check if current control point matches reflection
761
+ if (Math.abs(abs.args[0] - reflectedCpX) < tolerance &&
762
+ Math.abs(abs.args[1] - reflectedCpY) < tolerance) {
763
+ // Can use T command
764
+ const isAbs = cmd.command === 'Q';
765
+ newCmd = isAbs
766
+ ? { command: 'T', args: [abs.args[2], abs.args[3]] }
767
+ : { command: 't', args: [abs.args[2] - cx, abs.args[3] - cy] };
768
+ }
769
+ }
770
+
771
+ result.push(newCmd);
772
+
773
+ // Track control points for reflection
774
+ if (abs.command === 'Q') {
775
+ lastQcpX = abs.args[0];
776
+ lastQcpY = abs.args[1];
777
+ } else if (abs.command === 'T') {
778
+ // T uses reflected control point
779
+ lastQcpX = 2 * cx - lastQcpX;
780
+ lastQcpY = 2 * cy - lastQcpY;
781
+ } else {
782
+ lastQcpX = null;
783
+ lastQcpY = null;
784
+ }
785
+
786
+ // Update position
787
+ switch (abs.command) {
788
+ case 'M': cx = abs.args[0]; cy = abs.args[1]; startX = cx; startY = cy; break;
789
+ case 'L': case 'T': cx = abs.args[0]; cy = abs.args[1]; break;
790
+ case 'H': cx = abs.args[0]; break;
791
+ case 'V': cy = abs.args[0]; break;
792
+ case 'C': cx = abs.args[4]; cy = abs.args[5]; break;
793
+ case 'S': case 'Q': cx = abs.args[2]; cy = abs.args[3]; break;
794
+ case 'A': cx = abs.args[5]; cy = abs.args[6]; break;
795
+ case 'Z': cx = startX; cy = startY; break;
796
+ }
797
+ }
798
+
799
+ return serializePath(result, precision);
800
+ }
801
+
802
+ // ============================================================================
803
+ // PLUGIN: convertCubicToSmooth
804
+ // Converts C commands to S when first control point is reflection of previous
805
+ // ============================================================================
806
+
807
+ /**
808
+ * Convert cubic bezier to smooth shorthand (S) when possible.
809
+ * Example: "C 0 10 10 10 20 0 C 30 -10 40 0 50 0" -> "C 0 10 10 10 20 0 S 40 0 50 0"
810
+ * @param {string} d - Path d attribute
811
+ * @param {number} tolerance - Tolerance for detecting reflection
812
+ * @param {number} precision - Decimal precision
813
+ * @returns {string} Optimized path
814
+ */
815
+ export function convertCubicToSmooth(d, tolerance = 1e-6, precision = 3) {
816
+ const commands = parsePath(d);
817
+ if (commands.length === 0) return d;
818
+
819
+ let cx = 0, cy = 0;
820
+ let startX = 0, startY = 0;
821
+ let lastCcp2X = null, lastCcp2Y = null;
822
+ const result = [];
823
+
824
+ for (const cmd of commands) {
825
+ let newCmd = cmd;
826
+ const abs = toAbsolute(cmd, cx, cy);
827
+
828
+ if (abs.command === 'C' && lastCcp2X !== null) {
829
+ // Calculate reflected control point
830
+ const reflectedCpX = 2 * cx - lastCcp2X;
831
+ const reflectedCpY = 2 * cy - lastCcp2Y;
832
+
833
+ // Check if first control point matches reflection
834
+ if (Math.abs(abs.args[0] - reflectedCpX) < tolerance &&
835
+ Math.abs(abs.args[1] - reflectedCpY) < tolerance) {
836
+ // Can use S command
837
+ const isAbs = cmd.command === 'C';
838
+ newCmd = isAbs
839
+ ? { command: 'S', args: [abs.args[2], abs.args[3], abs.args[4], abs.args[5]] }
840
+ : { command: 's', args: [abs.args[2] - cx, abs.args[3] - cy, abs.args[4] - cx, abs.args[5] - cy] };
841
+ }
842
+ }
843
+
844
+ result.push(newCmd);
845
+
846
+ // Track control points for reflection
847
+ if (abs.command === 'C') {
848
+ lastCcp2X = abs.args[2];
849
+ lastCcp2Y = abs.args[3];
850
+ } else if (abs.command === 'S') {
851
+ // S uses its control point as the second cubic control point
852
+ lastCcp2X = abs.args[0];
853
+ lastCcp2Y = abs.args[1];
854
+ } else {
855
+ lastCcp2X = null;
856
+ lastCcp2Y = null;
857
+ }
858
+
859
+ // Update position
860
+ switch (abs.command) {
861
+ case 'M': cx = abs.args[0]; cy = abs.args[1]; startX = cx; startY = cy; break;
862
+ case 'L': case 'T': cx = abs.args[0]; cy = abs.args[1]; break;
863
+ case 'H': cx = abs.args[0]; break;
864
+ case 'V': cy = abs.args[0]; break;
865
+ case 'C': cx = abs.args[4]; cy = abs.args[5]; break;
866
+ case 'S': case 'Q': cx = abs.args[2]; cy = abs.args[3]; break;
867
+ case 'A': cx = abs.args[5]; cy = abs.args[6]; break;
868
+ case 'Z': cx = startX; cy = startY; break;
869
+ }
870
+ }
871
+
872
+ return serializePath(result, precision);
873
+ }
874
+
875
+ // ============================================================================
876
+ // PLUGIN: arcShorthands
877
+ // Optimizes arc parameters (flag compression, etc.)
878
+ // ============================================================================
879
+
880
+ /**
881
+ * Optimize arc command parameters.
882
+ * - Normalize angle to 0-360 range
883
+ * - Use smaller arc when equivalent
884
+ * @param {string} d - Path d attribute
885
+ * @param {number} precision - Decimal precision
886
+ * @returns {string} Optimized path
887
+ */
888
+ export function arcShorthands(d, precision = 3) {
889
+ const commands = parsePath(d);
890
+ if (commands.length === 0) return d;
891
+
892
+ const result = commands.map(cmd => {
893
+ if (cmd.command === 'A' || cmd.command === 'a') {
894
+ const args = [...cmd.args];
895
+ // Normalize rotation angle to 0-360
896
+ args[2] = ((args[2] % 360) + 360) % 360;
897
+ // If rx == ry, rotation doesn't matter, set to 0
898
+ if (Math.abs(args[0] - args[1]) < 1e-6) {
899
+ args[2] = 0;
900
+ }
901
+ return { command: cmd.command, args };
902
+ }
903
+ return cmd;
904
+ });
905
+
906
+ return serializePath(result, precision);
907
+ }
908
+
909
+ // ============================================================================
910
+ // Export all plugins
911
+ // ============================================================================
912
+
913
+ export default {
914
+ removeLeadingZero,
915
+ negativeExtraSpace,
916
+ convertToRelative,
917
+ convertToAbsolute,
918
+ lineShorthands,
919
+ convertToZ,
920
+ straightCurves,
921
+ collapseRepeated,
922
+ floatPrecision,
923
+ removeUselessCommands,
924
+ convertCubicToQuadratic,
925
+ convertQuadraticToSmooth,
926
+ convertCubicToSmooth,
927
+ arcShorthands
928
+ };