@emasoft/svg-matrix 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emasoft/svg-matrix",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "Arbitrary-precision matrix, vector and affine transformation library for JavaScript using decimal.js",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -9,6 +9,8 @@
9
9
  "test:browser": "node test/browser-verify.mjs",
10
10
  "test:playwright": "node test/playwright-diagnose.js",
11
11
  "ci-test": "npm ci && npm test",
12
+ "lint": "node bin/svglinter.cjs",
13
+ "lint:fix": "node bin/svglinter.cjs --fix",
12
14
  "version:sync": "node scripts/version-sync.js",
13
15
  "version:check": "node scripts/version-sync.js --check",
14
16
  "preversion": "npm run version:sync",
@@ -18,7 +20,7 @@
18
20
  },
19
21
  "repository": {
20
22
  "type": "git",
21
- "url": "https://github.com/Emasoft/SVG-MATRIX.git"
23
+ "url": "git+https://github.com/Emasoft/SVG-MATRIX.git"
22
24
  },
23
25
  "keywords": [
24
26
  "matrix",
@@ -52,7 +54,8 @@
52
54
  "url": "https://github.com/Emasoft/SVG-MATRIX/issues"
53
55
  },
54
56
  "bin": {
55
- "svg-matrix": "./bin/svg-matrix.js"
57
+ "svg-matrix": "bin/svg-matrix.js",
58
+ "svglinter": "bin/svglinter.cjs"
56
59
  },
57
60
  "engines": {
58
61
  "node": ">=24.0.0"
@@ -74,5 +77,8 @@
74
77
  "playwright": {
75
78
  "optional": true
76
79
  }
80
+ },
81
+ "devDependencies": {
82
+ "svgo": "^4.0.0"
77
83
  }
78
84
  }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Animation Timing Optimization for SVG
3
+ *
4
+ * Optimizes SMIL animation timing attributes without breaking animations:
5
+ *
6
+ * 1. keySplines optimization:
7
+ * - Remove leading zeros (0.4 -> .4)
8
+ * - Remove trailing zeros (0.500 -> .5)
9
+ * - Precision reduction with configurable decimal places
10
+ * - Detect linear splines (0 0 1 1) and simplify calcMode
11
+ *
12
+ * 2. keyTimes optimization:
13
+ * - Remove redundant precision
14
+ * - Normalize separator spacing
15
+ *
16
+ * 3. values optimization:
17
+ * - Numeric precision reduction for numeric values
18
+ * - Preserve ID references (#frame1;#frame2) exactly
19
+ *
20
+ * @module animation-optimization
21
+ */
22
+
23
+ import Decimal from 'decimal.js';
24
+
25
+ // Configure Decimal for high precision internally
26
+ Decimal.set({ precision: 20, rounding: Decimal.ROUND_HALF_UP });
27
+
28
+ /**
29
+ * Standard easing curves that can be recognized
30
+ * Format: [x1, y1, x2, y2] control points
31
+ */
32
+ export const STANDARD_EASINGS = {
33
+ linear: [0, 0, 1, 1],
34
+ ease: [0.25, 0.1, 0.25, 1],
35
+ 'ease-in': [0.42, 0, 1, 1],
36
+ 'ease-out': [0, 0, 0.58, 1],
37
+ 'ease-in-out': [0.42, 0, 0.58, 1],
38
+ };
39
+
40
+ /**
41
+ * Format a number with optimal precision (no trailing zeros, no leading zero for decimals < 1)
42
+ * @param {number|string} value - Number to format
43
+ * @param {number} precision - Maximum decimal places (default: 3)
44
+ * @returns {string} Optimized number string
45
+ */
46
+ export function formatSplineValue(value, precision = 3) {
47
+ const num = new Decimal(value);
48
+
49
+ // Round to precision
50
+ const rounded = num.toDecimalPlaces(precision);
51
+
52
+ // Convert to string
53
+ let str = rounded.toString();
54
+
55
+ // Remove trailing zeros after decimal point
56
+ if (str.includes('.')) {
57
+ str = str.replace(/\.?0+$/, '');
58
+ }
59
+
60
+ // Remove leading zero for values between -1 and 1 (exclusive)
61
+ if (str.startsWith('0.')) {
62
+ str = str.substring(1); // "0.5" -> ".5"
63
+ } else if (str.startsWith('-0.')) {
64
+ str = '-' + str.substring(2); // "-0.5" -> "-.5"
65
+ }
66
+
67
+ // Handle edge case: ".0" should be "0"
68
+ if (str === '' || str === '.') str = '0';
69
+
70
+ return str;
71
+ }
72
+
73
+ /**
74
+ * Parse a keySplines attribute value into array of spline arrays
75
+ * Each spline has 4 values: [x1, y1, x2, y2]
76
+ * @param {string} keySplines - keySplines attribute value
77
+ * @returns {number[][]} Array of [x1, y1, x2, y2] arrays
78
+ */
79
+ export function parseKeySplines(keySplines) {
80
+ if (!keySplines || typeof keySplines !== 'string') return [];
81
+
82
+ // Split by semicolon to get individual splines
83
+ const splines = keySplines.split(';').map(s => s.trim()).filter(s => s);
84
+
85
+ return splines.map(spline => {
86
+ // Split by whitespace or comma to get control points
87
+ const values = spline.split(/[\s,]+/).map(v => parseFloat(v));
88
+ return values;
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Serialize splines array back to keySplines attribute format
94
+ * @param {number[][]} splines - Array of [x1, y1, x2, y2] arrays
95
+ * @param {number} precision - Maximum decimal places
96
+ * @returns {string} keySplines attribute value
97
+ */
98
+ export function serializeKeySplines(splines, precision = 3) {
99
+ return splines.map(spline => {
100
+ return spline.map(v => formatSplineValue(v, precision)).join(' ');
101
+ }).join('; ');
102
+ }
103
+
104
+ /**
105
+ * Check if a spline is effectively linear (0 0 1 1)
106
+ * @param {number[]} spline - [x1, y1, x2, y2] control points
107
+ * @param {number} tolerance - Comparison tolerance (default: 0.001)
108
+ * @returns {boolean} True if spline is linear
109
+ */
110
+ export function isLinearSpline(spline, tolerance = 0.001) {
111
+ if (!spline || spline.length !== 4) return false;
112
+
113
+ const [x1, y1, x2, y2] = spline;
114
+ return (
115
+ Math.abs(x1) < tolerance &&
116
+ Math.abs(y1) < tolerance &&
117
+ Math.abs(x2 - 1) < tolerance &&
118
+ Math.abs(y2 - 1) < tolerance
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Check if all splines in a keySplines value are linear
124
+ * @param {string} keySplines - keySplines attribute value
125
+ * @returns {boolean} True if all splines are linear
126
+ */
127
+ export function areAllSplinesLinear(keySplines) {
128
+ const splines = parseKeySplines(keySplines);
129
+ if (splines.length === 0) return false;
130
+ // Must wrap in arrow function to avoid .every() passing index as tolerance
131
+ return splines.every(s => isLinearSpline(s));
132
+ }
133
+
134
+ /**
135
+ * Identify if a spline matches a standard CSS easing
136
+ * @param {number[]} spline - [x1, y1, x2, y2] control points
137
+ * @param {number} tolerance - Comparison tolerance
138
+ * @returns {string|null} Easing name or null if not standard
139
+ */
140
+ export function identifyStandardEasing(spline, tolerance = 0.01) {
141
+ if (!spline || spline.length !== 4) return null;
142
+
143
+ for (const [name, standard] of Object.entries(STANDARD_EASINGS)) {
144
+ const matches = spline.every((val, i) => Math.abs(val - standard[i]) < tolerance);
145
+ if (matches) return name;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * Optimize a keySplines attribute value
152
+ * @param {string} keySplines - Original keySplines value
153
+ * @param {Object} options - Optimization options
154
+ * @param {number} options.precision - Max decimal places (default: 3)
155
+ * @param {boolean} options.removeLinear - If true and all splines are linear, return null (default: true)
156
+ * @returns {{value: string|null, allLinear: boolean, standardEasings: string[]}}
157
+ */
158
+ export function optimizeKeySplines(keySplines, options = {}) {
159
+ const precision = options.precision ?? 3;
160
+ const removeLinear = options.removeLinear !== false;
161
+
162
+ const splines = parseKeySplines(keySplines);
163
+
164
+ if (splines.length === 0) {
165
+ return { value: null, allLinear: false, standardEasings: [] };
166
+ }
167
+
168
+ // Check for all linear splines (wrap to avoid .every() passing index as tolerance)
169
+ const allLinear = splines.every(s => isLinearSpline(s));
170
+
171
+ // Identify standard easings
172
+ const standardEasings = splines.map(s => identifyStandardEasing(s)).filter(Boolean);
173
+
174
+ // If all linear and removeLinear is true, suggest removing keySplines
175
+ if (allLinear && removeLinear) {
176
+ return { value: null, allLinear: true, standardEasings };
177
+ }
178
+
179
+ // Optimize each spline value with precision
180
+ const optimized = serializeKeySplines(splines, precision);
181
+
182
+ return { value: optimized, allLinear, standardEasings };
183
+ }
184
+
185
+ /**
186
+ * Parse keyTimes attribute value
187
+ * @param {string} keyTimes - keyTimes attribute value
188
+ * @returns {number[]} Array of time values
189
+ */
190
+ export function parseKeyTimes(keyTimes) {
191
+ if (!keyTimes || typeof keyTimes !== 'string') return [];
192
+ return keyTimes.split(';').map(s => parseFloat(s.trim())).filter(v => !isNaN(v));
193
+ }
194
+
195
+ /**
196
+ * Serialize keyTimes array back to attribute format
197
+ * @param {number[]} times - Array of time values
198
+ * @param {number} precision - Maximum decimal places
199
+ * @returns {string} keyTimes attribute value
200
+ */
201
+ export function serializeKeyTimes(times, precision = 3) {
202
+ return times.map(t => formatSplineValue(t, precision)).join('; ');
203
+ }
204
+
205
+ /**
206
+ * Optimize keyTimes attribute value
207
+ * @param {string} keyTimes - Original keyTimes value
208
+ * @param {number} precision - Max decimal places (default: 3)
209
+ * @returns {string} Optimized keyTimes value
210
+ */
211
+ export function optimizeKeyTimes(keyTimes, precision = 3) {
212
+ const times = parseKeyTimes(keyTimes);
213
+ if (times.length === 0) return keyTimes;
214
+ return serializeKeyTimes(times, precision);
215
+ }
216
+
217
+ /**
218
+ * Optimize numeric values in animation values attribute
219
+ * Preserves ID references (#id) exactly
220
+ * @param {string} values - values attribute
221
+ * @param {number} precision - Max decimal places for numbers
222
+ * @returns {string} Optimized values
223
+ */
224
+ export function optimizeAnimationValues(values, precision = 3) {
225
+ if (!values || typeof values !== 'string') return values;
226
+
227
+ // Split by semicolon
228
+ const parts = values.split(';');
229
+
230
+ const optimized = parts.map(part => {
231
+ const trimmed = part.trim();
232
+
233
+ // Preserve ID references exactly
234
+ if (trimmed.startsWith('#') || trimmed.includes('url(')) {
235
+ return trimmed;
236
+ }
237
+
238
+ // Try to parse as numbers (could be space-separated like "0 0" for translate)
239
+ const nums = trimmed.split(/[\s,]+/);
240
+ const optimizedNums = nums.map(n => {
241
+ const num = parseFloat(n);
242
+ if (isNaN(num)) return n; // Not a number, preserve as-is
243
+ return formatSplineValue(num, precision);
244
+ });
245
+
246
+ return optimizedNums.join(' ');
247
+ });
248
+
249
+ return optimized.join('; ');
250
+ }
251
+
252
+ /**
253
+ * Optimize all animation timing attributes on an element
254
+ * @param {Element} el - SVG element (animate, animateTransform, etc.)
255
+ * @param {Object} options - Optimization options
256
+ * @returns {{modified: boolean, changes: string[]}}
257
+ */
258
+ export function optimizeElementTiming(el, options = {}) {
259
+ const precision = options.precision ?? 3;
260
+ const removeLinearSplines = options.removeLinearSplines !== false;
261
+ const optimizeValues = options.optimizeValues !== false;
262
+
263
+ const changes = [];
264
+ let modified = false;
265
+
266
+ // Optimize keySplines
267
+ const keySplines = el.getAttribute('keySplines');
268
+ if (keySplines) {
269
+ const result = optimizeKeySplines(keySplines, { precision, removeLinear: removeLinearSplines });
270
+
271
+ if (result.allLinear && removeLinearSplines) {
272
+ // All splines are linear - can simplify to calcMode="linear"
273
+ const calcMode = el.getAttribute('calcMode');
274
+ if (calcMode === 'spline') {
275
+ el.setAttribute('calcMode', 'linear');
276
+ el.removeAttribute('keySplines');
277
+ changes.push('Converted linear splines to calcMode="linear"');
278
+ modified = true;
279
+ }
280
+ } else if (result.value && result.value !== keySplines) {
281
+ el.setAttribute('keySplines', result.value);
282
+ changes.push(`keySplines: "${keySplines}" -> "${result.value}"`);
283
+ modified = true;
284
+ }
285
+ }
286
+
287
+ // Optimize keyTimes
288
+ const keyTimes = el.getAttribute('keyTimes');
289
+ if (keyTimes) {
290
+ const optimized = optimizeKeyTimes(keyTimes, precision);
291
+ if (optimized !== keyTimes) {
292
+ el.setAttribute('keyTimes', optimized);
293
+ changes.push(`keyTimes: "${keyTimes}" -> "${optimized}"`);
294
+ modified = true;
295
+ }
296
+ }
297
+
298
+ // Optimize values (only numeric, preserve ID refs)
299
+ if (optimizeValues) {
300
+ const values = el.getAttribute('values');
301
+ if (values && !values.includes('#')) {
302
+ // Only optimize if no ID references
303
+ const optimized = optimizeAnimationValues(values, precision);
304
+ if (optimized !== values) {
305
+ el.setAttribute('values', optimized);
306
+ changes.push(`values: "${values}" -> "${optimized}"`);
307
+ modified = true;
308
+ }
309
+ }
310
+ }
311
+
312
+ // Optimize from/to
313
+ for (const attr of ['from', 'to', 'by']) {
314
+ const val = el.getAttribute(attr);
315
+ if (val && !val.includes('#')) {
316
+ const optimized = optimizeAnimationValues(val, precision);
317
+ if (optimized !== val) {
318
+ el.setAttribute(attr, optimized);
319
+ changes.push(`${attr}: "${val}" -> "${optimized}"`);
320
+ modified = true;
321
+ }
322
+ }
323
+ }
324
+
325
+ return { modified, changes };
326
+ }
327
+
328
+ /**
329
+ * Animation elements that can have timing attributes
330
+ * Note: all lowercase to match svg-parser tagName normalization
331
+ */
332
+ export const ANIMATION_ELEMENTS = ['animate', 'animatetransform', 'animatemotion', 'animatecolor', 'set'];
333
+
334
+ /**
335
+ * Optimize all animation timing in an SVG document
336
+ * @param {Element} root - SVG root element
337
+ * @param {Object} options - Optimization options
338
+ * @returns {{elementsModified: number, totalChanges: number, details: Array}}
339
+ */
340
+ export function optimizeDocumentAnimationTiming(root, options = {}) {
341
+ let elementsModified = 0;
342
+ let totalChanges = 0;
343
+ const details = [];
344
+
345
+ const processElement = (el) => {
346
+ const tagName = el.tagName?.toLowerCase();
347
+
348
+ if (ANIMATION_ELEMENTS.includes(tagName)) {
349
+ const result = optimizeElementTiming(el, options);
350
+ if (result.modified) {
351
+ elementsModified++;
352
+ totalChanges += result.changes.length;
353
+ details.push({
354
+ element: tagName,
355
+ id: el.getAttribute('id') || null,
356
+ changes: result.changes
357
+ });
358
+ }
359
+ }
360
+
361
+ for (const child of el.children || []) {
362
+ processElement(child);
363
+ }
364
+ };
365
+
366
+ processElement(root);
367
+
368
+ return { elementsModified, totalChanges, details };
369
+ }
370
+
371
+ export default {
372
+ // Core functions
373
+ formatSplineValue,
374
+ parseKeySplines,
375
+ serializeKeySplines,
376
+ parseKeyTimes,
377
+ serializeKeyTimes,
378
+
379
+ // Analysis
380
+ isLinearSpline,
381
+ areAllSplinesLinear,
382
+ identifyStandardEasing,
383
+ STANDARD_EASINGS,
384
+
385
+ // Optimization
386
+ optimizeKeySplines,
387
+ optimizeKeyTimes,
388
+ optimizeAnimationValues,
389
+ optimizeElementTiming,
390
+ optimizeDocumentAnimationTiming,
391
+
392
+ // Constants
393
+ ANIMATION_ELEMENTS,
394
+ };