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