@emasoft/svg-matrix 1.0.18 → 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/scripts/postinstall.js +6 -9
- 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,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
|
+
};
|