@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,583 @@
1
+ /**
2
+ * Convert Path Data - SVGO-equivalent comprehensive path optimizer
3
+ *
4
+ * Applies all path optimizations to minimize path data string length:
5
+ * - Parse path into structured commands
6
+ * - Convert curves to lines when effectively straight
7
+ * - Convert absolute to relative (or vice versa) - pick shorter
8
+ * - Use command shortcuts (L->H/V, C->S, Q->T, L->Z)
9
+ * - Remove leading zeros (0.5 -> .5)
10
+ * - Remove redundant delimiters
11
+ *
12
+ * @module convert-path-data
13
+ */
14
+
15
+ import Decimal from 'decimal.js';
16
+
17
+ Decimal.set({ precision: 80 });
18
+
19
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
20
+
21
+ // SVG path command parameters count
22
+ const COMMAND_PARAMS = {
23
+ M: 2, m: 2, L: 2, l: 2, H: 1, h: 1, V: 1, v: 1,
24
+ C: 6, c: 6, S: 4, s: 4, Q: 4, q: 4, T: 2, t: 2,
25
+ A: 7, a: 7, Z: 0, z: 0
26
+ };
27
+
28
+ /**
29
+ * Parse arc arguments specially - flags are always single 0 or 1 digits
30
+ * Arc format: rx ry x-axis-rotation large-arc-flag sweep-flag x y
31
+ * Flags can be written without separators: "0 01 20" or "0120"
32
+ * BUG FIX #1: Handle compact notation where flags are concatenated with next number
33
+ */
34
+ function parseArcArgs(argsStr) {
35
+ const args = [];
36
+ // Regex to match: number, then optionally flags and more numbers
37
+ // Arc has: rx ry rotation flag flag x y (7 params per arc)
38
+ const numRegex = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g;
39
+
40
+ let pos = 0;
41
+ let arcIndex = 0; // 0-6 for each parameter in an arc
42
+
43
+ while (pos < argsStr.length) {
44
+ // Skip whitespace and commas
45
+ while (pos < argsStr.length && /[\s,]/.test(argsStr[pos])) pos++;
46
+ if (pos >= argsStr.length) break;
47
+
48
+ const paramInArc = arcIndex % 7;
49
+
50
+ if (paramInArc === 3 || paramInArc === 4) {
51
+ // Flags: must be single 0 or 1 (arc flags are always exactly one character)
52
+ // BUG FIX #1: Handle compact notation like "0120" -> "01" (flags) + "20" (next number)
53
+ if (argsStr[pos] === '0' || argsStr[pos] === '1') {
54
+ args.push(argsStr[pos] === '1' ? 1 : 0);
55
+ pos++;
56
+ arcIndex++;
57
+ } else {
58
+ // BUG FIX #1: Arc flags MUST be exactly 0 or 1 - throw error for invalid values
59
+ throw new Error(`Invalid arc flag at position ${pos}: expected 0 or 1, got '${argsStr[pos]}'`);
60
+ }
61
+ } else {
62
+ // Regular number
63
+ numRegex.lastIndex = pos;
64
+ const match = numRegex.exec(argsStr);
65
+ if (match && match.index === pos) {
66
+ args.push(parseFloat(match[0]));
67
+ pos = numRegex.lastIndex;
68
+ arcIndex++;
69
+ } else {
70
+ // No match at current position - skip character
71
+ pos++;
72
+ }
73
+ }
74
+ }
75
+
76
+ return args;
77
+ }
78
+
79
+ /**
80
+ * Parse an SVG path d attribute into structured commands.
81
+ * @param {string} d - Path d attribute value
82
+ * @returns {Array<{command: string, args: number[]}>} Parsed commands
83
+ */
84
+ export function parsePath(d) {
85
+ if (!d || typeof d !== 'string') return [];
86
+
87
+ const commands = [];
88
+ const cmdRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
89
+ let match;
90
+
91
+ while ((match = cmdRegex.exec(d)) !== null) {
92
+ const cmd = match[1];
93
+ const argsStr = match[2].trim();
94
+
95
+ if (cmd === 'Z' || cmd === 'z') {
96
+ commands.push({ command: 'Z', args: [] });
97
+ // BUG FIX #1: Check for implicit M after Z (numbers after Z should start a new subpath)
98
+ const remainingNums = argsStr.match(/-?\d*\.?\d+(?:[eE][+-]?\d+)?/g);
99
+ if (remainingNums && remainingNums.length >= 2) {
100
+ // Implicit M command after Z
101
+ commands.push({
102
+ command: 'M',
103
+ args: [parseFloat(remainingNums[0]), parseFloat(remainingNums[1])]
104
+ });
105
+ // Continue parsing remaining args as implicit L
106
+ for (let i = 2; i + 1 < remainingNums.length; i += 2) {
107
+ commands.push({
108
+ command: 'L',
109
+ args: [parseFloat(remainingNums[i]), parseFloat(remainingNums[i + 1])]
110
+ });
111
+ }
112
+ }
113
+ continue;
114
+ }
115
+
116
+ let nums;
117
+ if (cmd === 'A' || cmd === 'a') {
118
+ // Arc commands need special parsing for flags
119
+ nums = parseArcArgs(argsStr);
120
+
121
+ // BUG FIX #2: Normalize negative arc radii to absolute values per SVG spec Section 8.3.8
122
+ // Process each complete arc (7 parameters)
123
+ for (let i = 0; i < nums.length; i += 7) {
124
+ if (i + 6 < nums.length) {
125
+ nums[i] = Math.abs(nums[i]); // rx
126
+ nums[i + 1] = Math.abs(nums[i + 1]); // ry
127
+ }
128
+ }
129
+ } else {
130
+ // Regular commands - use standard number regex
131
+ const numRegex = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g;
132
+ nums = [];
133
+ let numMatch;
134
+ while ((numMatch = numRegex.exec(argsStr)) !== null) {
135
+ nums.push(parseFloat(numMatch[0]));
136
+ }
137
+ }
138
+
139
+ const paramCount = COMMAND_PARAMS[cmd];
140
+
141
+ if (paramCount === 0 || nums.length === 0) {
142
+ commands.push({ command: cmd, args: [] });
143
+ } else {
144
+ for (let i = 0; i < nums.length; i += paramCount) {
145
+ const args = nums.slice(i, i + paramCount);
146
+ if (args.length === paramCount) {
147
+ const effectiveCmd = (i > 0 && (cmd === 'M' || cmd === 'm'))
148
+ ? (cmd === 'M' ? 'L' : 'l')
149
+ : cmd;
150
+ commands.push({ command: effectiveCmd, args });
151
+ } else if (args.length > 0) {
152
+ // BUG FIX #4: Warn when args are incomplete
153
+ console.warn(`Incomplete ${cmd} command: expected ${paramCount} args, got ${args.length} - remaining args dropped`);
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ return commands;
160
+ }
161
+
162
+ /**
163
+ * Format a number with optimal precision and minimal characters.
164
+ * @param {number} num - Number to format
165
+ * @param {number} precision - Maximum decimal places
166
+ * @returns {string} Formatted number
167
+ */
168
+ export function formatNumber(num, precision = 3) {
169
+ // BUG FIX #3: Handle NaN, Infinity, -Infinity
170
+ if (!isFinite(num)) return '0';
171
+ if (num === 0) return '0';
172
+
173
+ const factor = Math.pow(10, precision);
174
+ const rounded = Math.round(num * factor) / factor;
175
+
176
+ let str = rounded.toFixed(precision);
177
+
178
+ if (str.includes('.')) {
179
+ str = str.replace(/\.?0+$/, '');
180
+ }
181
+
182
+ if (str.startsWith('0.')) {
183
+ str = str.substring(1);
184
+ } else if (str.startsWith('-0.')) {
185
+ str = '-' + str.substring(2);
186
+ }
187
+
188
+ if (str === '' || str === '.' || str === '-.') {
189
+ str = '0';
190
+ }
191
+
192
+ return str;
193
+ }
194
+
195
+ /**
196
+ * Convert a command to absolute form.
197
+ */
198
+ export function toAbsolute(cmd, cx, cy) {
199
+ const { command, args } = cmd;
200
+
201
+ if (command === command.toUpperCase()) return cmd;
202
+
203
+ const absCmd = command.toUpperCase();
204
+ const absArgs = [...args];
205
+
206
+ switch (command) {
207
+ case 'm': case 'l': case 't':
208
+ absArgs[0] += cx; absArgs[1] += cy; break;
209
+ case 'h': absArgs[0] += cx; break;
210
+ case 'v': absArgs[0] += cy; break;
211
+ case 'c':
212
+ absArgs[0] += cx; absArgs[1] += cy;
213
+ absArgs[2] += cx; absArgs[3] += cy;
214
+ absArgs[4] += cx; absArgs[5] += cy; break;
215
+ case 's': case 'q':
216
+ absArgs[0] += cx; absArgs[1] += cy;
217
+ absArgs[2] += cx; absArgs[3] += cy; break;
218
+ case 'a':
219
+ absArgs[5] += cx; absArgs[6] += cy; break;
220
+ }
221
+
222
+ return { command: absCmd, args: absArgs };
223
+ }
224
+
225
+ /**
226
+ * Convert a command to relative form.
227
+ */
228
+ export function toRelative(cmd, cx, cy) {
229
+ const { command, args } = cmd;
230
+
231
+ if (command === command.toLowerCase() && command !== 'z') return cmd;
232
+ if (command === 'Z' || command === 'z') return { command: 'z', args: [] };
233
+
234
+ const relCmd = command.toLowerCase();
235
+ const relArgs = [...args];
236
+
237
+ switch (command) {
238
+ case 'M': case 'L': case 'T':
239
+ relArgs[0] -= cx; relArgs[1] -= cy; break;
240
+ case 'H': relArgs[0] -= cx; break;
241
+ case 'V': relArgs[0] -= cy; break;
242
+ case 'C':
243
+ relArgs[0] -= cx; relArgs[1] -= cy;
244
+ relArgs[2] -= cx; relArgs[3] -= cy;
245
+ relArgs[4] -= cx; relArgs[5] -= cy; break;
246
+ case 'S': case 'Q':
247
+ relArgs[0] -= cx; relArgs[1] -= cy;
248
+ relArgs[2] -= cx; relArgs[3] -= cy; break;
249
+ case 'A':
250
+ relArgs[5] -= cx; relArgs[6] -= cy; break;
251
+ }
252
+
253
+ return { command: relCmd, args: relArgs };
254
+ }
255
+
256
+ /**
257
+ * Convert L command to H or V when applicable.
258
+ */
259
+ export function lineToHV(cmd, cx, cy, tolerance = 1e-6) {
260
+ const { command, args } = cmd;
261
+ if (command !== 'L' && command !== 'l') return null;
262
+
263
+ const isAbs = command === 'L';
264
+ const endX = isAbs ? args[0] : cx + args[0];
265
+ const endY = isAbs ? args[1] : cy + args[1];
266
+
267
+ if (Math.abs(endY - cy) < tolerance) {
268
+ return isAbs ? { command: 'H', args: [endX] } : { command: 'h', args: [endX - cx] };
269
+ }
270
+ if (Math.abs(endX - cx) < tolerance) {
271
+ return isAbs ? { command: 'V', args: [endY] } : { command: 'v', args: [endY - cy] };
272
+ }
273
+ return null;
274
+ }
275
+
276
+ /**
277
+ * Check if a line command returns to subpath start (can use Z).
278
+ */
279
+ export function lineToZ(cmd, cx, cy, startX, startY, tolerance = 1e-6) {
280
+ const { command, args } = cmd;
281
+ if (command !== 'L' && command !== 'l') return false;
282
+
283
+ const isAbs = command === 'L';
284
+ const endX = isAbs ? args[0] : cx + args[0];
285
+ const endY = isAbs ? args[1] : cy + args[1];
286
+
287
+ return Math.abs(endX - startX) < tolerance && Math.abs(endY - startY) < tolerance;
288
+ }
289
+
290
+ /**
291
+ * Check if a cubic bezier is effectively a straight line.
292
+ */
293
+ export function isCurveStraight(x0, y0, cp1x, cp1y, cp2x, cp2y, x3, y3, tolerance = 0.5) {
294
+ const chordLengthSq = (x3 - x0) ** 2 + (y3 - y0) ** 2;
295
+
296
+ if (chordLengthSq < 1e-10) {
297
+ const d1 = Math.sqrt((cp1x - x0) ** 2 + (cp1y - y0) ** 2);
298
+ const d2 = Math.sqrt((cp2x - x0) ** 2 + (cp2y - y0) ** 2);
299
+ return Math.max(d1, d2) < tolerance;
300
+ }
301
+
302
+ const chordLength = Math.sqrt(chordLengthSq);
303
+ const d1 = Math.abs((y3 - y0) * cp1x - (x3 - x0) * cp1y + x3 * y0 - y3 * x0) / chordLength;
304
+ const d2 = Math.abs((y3 - y0) * cp2x - (x3 - x0) * cp2y + x3 * y0 - y3 * x0) / chordLength;
305
+
306
+ return Math.max(d1, d2) < tolerance;
307
+ }
308
+
309
+ /**
310
+ * Convert a straight cubic bezier to a line command.
311
+ */
312
+ export function straightCurveToLine(cmd, cx, cy, tolerance = 0.5) {
313
+ const { command, args } = cmd;
314
+ if (command !== 'C' && command !== 'c') return null;
315
+
316
+ const isAbs = command === 'C';
317
+ const cp1x = isAbs ? args[0] : cx + args[0];
318
+ const cp1y = isAbs ? args[1] : cy + args[1];
319
+ const cp2x = isAbs ? args[2] : cx + args[2];
320
+ const cp2y = isAbs ? args[3] : cy + args[3];
321
+ const endX = isAbs ? args[4] : cx + args[4];
322
+ const endY = isAbs ? args[5] : cy + args[5];
323
+
324
+ if (isCurveStraight(cx, cy, cp1x, cp1y, cp2x, cp2y, endX, endY, tolerance)) {
325
+ return isAbs
326
+ ? { command: 'L', args: [endX, endY] }
327
+ : { command: 'l', args: [endX - cx, endY - cy] };
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * Serialize a single command to string with minimal characters.
334
+ * @param {Object} cmd - The command object
335
+ * @param {string|null} prevCommand - Previous command letter
336
+ * @param {number} precision - Decimal precision
337
+ * @param {boolean} prevLastArgHadDecimal - Whether previous command's last arg had a decimal point
338
+ * @returns {{str: string, lastArgHadDecimal: boolean}} Serialized string and decimal info for next command
339
+ */
340
+ export function serializeCommand(cmd, prevCommand, precision = 3, prevLastArgHadDecimal = false) {
341
+ const { command, args } = cmd;
342
+
343
+ if (command === 'Z' || command === 'z') {
344
+ return { str: 'z', lastArgHadDecimal: false };
345
+ }
346
+
347
+ // SPECIAL HANDLING FOR ARC COMMANDS
348
+ // Arc format: rx ry rotation large-arc-flag sweep-flag x y
349
+ // Per SVG spec: flags MUST be exactly 0 or 1, arc commands CANNOT be implicitly repeated
350
+ if (command === 'A' || command === 'a') {
351
+ const arcArgs = [
352
+ formatNumber(args[0], precision), // rx
353
+ formatNumber(args[1], precision), // ry
354
+ formatNumber(args[2], precision), // rotation
355
+ args[3] ? '1' : '0', // large-arc-flag (FORCE 0/1)
356
+ args[4] ? '1' : '0', // sweep-flag (FORCE 0/1)
357
+ formatNumber(args[5], precision), // x
358
+ formatNumber(args[6], precision) // y
359
+ ].join(' '); // ALWAYS use space delimiters for arcs to avoid invalid double-decimals
360
+
361
+ // Arc commands CANNOT be implicitly repeated - always include command letter
362
+ return {
363
+ str: command + arcArgs,
364
+ lastArgHadDecimal: arcArgs.includes('.')
365
+ };
366
+ }
367
+
368
+ const formattedArgs = args.map(n => formatNumber(n, precision));
369
+
370
+ let argsStr = '';
371
+ for (let i = 0; i < formattedArgs.length; i++) {
372
+ const arg = formattedArgs[i];
373
+ if (i === 0) {
374
+ argsStr = arg;
375
+ } else {
376
+ const prevArg = formattedArgs[i - 1];
377
+ const prevHasDecimal = prevArg.includes('.');
378
+
379
+ if (arg.startsWith('-')) {
380
+ // Negative sign is always a valid delimiter
381
+ argsStr += arg;
382
+ } else if (arg.startsWith('.') && prevHasDecimal) {
383
+ // Decimal point works as delimiter only if prev already has a decimal
384
+ // e.g., "-2.5" + ".3" = "-2.5.3" parses as -2.5 and .3 (number can't have two decimals)
385
+ // but "0" + ".3" = "0.3" would merge into single number 0.3!
386
+ argsStr += arg;
387
+ } else {
388
+ // Need space delimiter
389
+ argsStr += ' ' + arg;
390
+ }
391
+ }
392
+ }
393
+
394
+ // Track whether our last arg has a decimal (for next command's delimiter decision)
395
+ const lastArg = formattedArgs[formattedArgs.length - 1] || '';
396
+ const thisLastArgHadDecimal = lastArg.includes('.');
397
+
398
+ // Decide if we need command letter or can use implicit continuation
399
+ // Arc commands CANNOT be implicitly repeated per SVG spec
400
+ let cmdStr = command;
401
+ if (prevCommand === 'M' && command === 'L') cmdStr = '';
402
+ else if (prevCommand === 'm' && command === 'l') cmdStr = '';
403
+ else if (prevCommand === command && command !== 'A' && command !== 'a') cmdStr = '';
404
+
405
+ if (cmdStr) {
406
+ // Explicit command letter - always safe, no delimiter needed
407
+ return { str: cmdStr + argsStr, lastArgHadDecimal: thisLastArgHadDecimal };
408
+ } else {
409
+ // Implicit command - need to check delimiter between commands
410
+ const firstArg = formattedArgs[0] || '';
411
+
412
+ if (firstArg.startsWith('-')) {
413
+ // Negative sign always works as delimiter
414
+ return { str: argsStr, lastArgHadDecimal: thisLastArgHadDecimal };
415
+ } else if (firstArg.startsWith('.') && prevLastArgHadDecimal) {
416
+ // Decimal point works only if prev command's last arg already had a decimal
417
+ return { str: argsStr, lastArgHadDecimal: thisLastArgHadDecimal };
418
+ } else {
419
+ // Need space delimiter
420
+ return { str: ' ' + argsStr, lastArgHadDecimal: thisLastArgHadDecimal };
421
+ }
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Serialize a complete path to minimal string.
427
+ */
428
+ export function serializePath(commands, precision = 3) {
429
+ if (commands.length === 0) return '';
430
+
431
+ let result = '';
432
+ let prevCommand = null;
433
+ let prevLastArgHadDecimal = false;
434
+
435
+ for (const cmd of commands) {
436
+ const { str, lastArgHadDecimal } = serializeCommand(cmd, prevCommand, precision, prevLastArgHadDecimal);
437
+ result += str;
438
+ prevCommand = cmd.command;
439
+ prevLastArgHadDecimal = lastArgHadDecimal;
440
+ }
441
+
442
+ return result;
443
+ }
444
+
445
+ /**
446
+ * Optimize a path d attribute.
447
+ * @param {string} d - Original path d attribute
448
+ * @param {Object} [options={}] - Optimization options
449
+ * @returns {{d: string, originalLength: number, optimizedLength: number, savings: number}}
450
+ */
451
+ export function convertPathData(d, options = {}) {
452
+ const {
453
+ floatPrecision = 3,
454
+ straightCurves = true,
455
+ lineShorthands = true,
456
+ convertToZ = true,
457
+ utilizeAbsolute = true,
458
+ straightTolerance = 0.5
459
+ } = options;
460
+
461
+ const originalLength = d.length;
462
+ let commands = parsePath(d);
463
+
464
+ if (commands.length === 0) {
465
+ return { d, originalLength, optimizedLength: originalLength, savings: 0 };
466
+ }
467
+
468
+ let cx = 0, cy = 0, startX = 0, startY = 0;
469
+ const optimized = [];
470
+
471
+ for (let i = 0; i < commands.length; i++) {
472
+ let cmd = commands[i];
473
+
474
+ // BUG FIX #3: Convert zero arc radii to line per SVG spec Section 8.3.4
475
+ // When rx or ry is 0, the arc degenerates to a straight line
476
+ if ((cmd.command === 'A' || cmd.command === 'a') && (cmd.args[0] === 0 || cmd.args[1] === 0)) {
477
+ const isAbs = cmd.command === 'A';
478
+ const endX = cmd.args[5];
479
+ const endY = cmd.args[6];
480
+ cmd = isAbs
481
+ ? { command: 'L', args: [endX, endY] }
482
+ : { command: 'l', args: [endX, endY] };
483
+ }
484
+
485
+ // 1. Straight curve to line
486
+ if (straightCurves && (cmd.command === 'C' || cmd.command === 'c')) {
487
+ const lineCmd = straightCurveToLine(cmd, cx, cy, straightTolerance);
488
+ if (lineCmd) cmd = lineCmd;
489
+ }
490
+
491
+ // 2. Line shorthands (L -> H/V)
492
+ if (lineShorthands && (cmd.command === 'L' || cmd.command === 'l')) {
493
+ const hvCmd = lineToHV(cmd, cx, cy);
494
+ if (hvCmd) cmd = hvCmd;
495
+ }
496
+
497
+ // 3. Line to Z
498
+ if (convertToZ && (cmd.command === 'L' || cmd.command === 'l')) {
499
+ if (lineToZ(cmd, cx, cy, startX, startY)) {
500
+ cmd = { command: 'z', args: [] };
501
+ }
502
+ }
503
+
504
+ // 4. Choose shorter form (absolute vs relative)
505
+ if (utilizeAbsolute && cmd.command !== 'Z' && cmd.command !== 'z') {
506
+ const abs = toAbsolute(cmd, cx, cy);
507
+ const rel = toRelative(cmd, cx, cy);
508
+ // Bug fix: serializeCommand returns {str, lastArgHadDecimal} object,
509
+ // so we need to extract .str before comparing lengths
510
+ const absResult = serializeCommand(abs, null, floatPrecision);
511
+ const relResult = serializeCommand(rel, null, floatPrecision);
512
+ cmd = relResult.str.length < absResult.str.length ? rel : abs;
513
+ }
514
+
515
+ optimized.push(cmd);
516
+
517
+ // Update position
518
+ const finalCmd = toAbsolute(cmd, cx, cy);
519
+ switch (finalCmd.command) {
520
+ case 'M': cx = finalCmd.args[0]; cy = finalCmd.args[1]; startX = cx; startY = cy; break;
521
+ case 'L': case 'T': cx = finalCmd.args[0]; cy = finalCmd.args[1]; break;
522
+ case 'H': cx = finalCmd.args[0]; break;
523
+ case 'V': cy = finalCmd.args[0]; break;
524
+ case 'C': cx = finalCmd.args[4]; cy = finalCmd.args[5]; break;
525
+ case 'S': case 'Q': cx = finalCmd.args[2]; cy = finalCmd.args[3]; break;
526
+ case 'A': cx = finalCmd.args[5]; cy = finalCmd.args[6]; break;
527
+ case 'Z': cx = startX; cy = startY; break;
528
+ }
529
+ }
530
+
531
+ const optimizedD = serializePath(optimized, floatPrecision);
532
+
533
+ return {
534
+ d: optimizedD,
535
+ originalLength,
536
+ optimizedLength: optimizedD.length,
537
+ savings: originalLength - optimizedD.length
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Optimize all path elements in an SVG document.
543
+ */
544
+ export function optimizeDocumentPaths(root, options = {}) {
545
+ let pathsOptimized = 0;
546
+ let totalSavings = 0;
547
+ const details = [];
548
+
549
+ const processElement = (el) => {
550
+ const tagName = el.tagName?.toLowerCase();
551
+
552
+ if (tagName === 'path') {
553
+ const d = el.getAttribute('d');
554
+ if (d) {
555
+ const result = convertPathData(d, options);
556
+ if (result.savings > 0) {
557
+ el.setAttribute('d', result.d);
558
+ pathsOptimized++;
559
+ totalSavings += result.savings;
560
+ details.push({
561
+ id: el.getAttribute('id') || null,
562
+ originalLength: result.originalLength,
563
+ optimizedLength: result.optimizedLength,
564
+ savings: result.savings
565
+ });
566
+ }
567
+ }
568
+ }
569
+
570
+ for (const child of el.children || []) {
571
+ processElement(child);
572
+ }
573
+ };
574
+
575
+ processElement(root);
576
+ return { pathsOptimized, totalSavings, details };
577
+ }
578
+
579
+ export default {
580
+ parsePath, formatNumber, toAbsolute, toRelative,
581
+ lineToHV, lineToZ, isCurveStraight, straightCurveToLine,
582
+ serializeCommand, serializePath, convertPathData, optimizeDocumentPaths
583
+ };