@emasoft/svg-matrix 1.0.27 → 1.0.29

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.
Files changed (46) hide show
  1. package/README.md +325 -0
  2. package/bin/svg-matrix.js +994 -378
  3. package/bin/svglinter.cjs +4172 -433
  4. package/bin/svgm.js +744 -184
  5. package/package.json +16 -4
  6. package/src/animation-references.js +71 -52
  7. package/src/arc-length.js +160 -96
  8. package/src/bezier-analysis.js +257 -117
  9. package/src/bezier-intersections.js +411 -148
  10. package/src/browser-verify.js +240 -100
  11. package/src/clip-path-resolver.js +350 -142
  12. package/src/convert-path-data.js +279 -134
  13. package/src/css-specificity.js +78 -70
  14. package/src/flatten-pipeline.js +751 -263
  15. package/src/geometry-to-path.js +511 -182
  16. package/src/index.js +191 -46
  17. package/src/inkscape-support.js +404 -0
  18. package/src/marker-resolver.js +278 -164
  19. package/src/mask-resolver.js +209 -98
  20. package/src/matrix.js +147 -67
  21. package/src/mesh-gradient.js +187 -96
  22. package/src/off-canvas-detection.js +201 -104
  23. package/src/path-analysis.js +187 -107
  24. package/src/path-data-plugins.js +628 -167
  25. package/src/path-simplification.js +0 -1
  26. package/src/pattern-resolver.js +125 -88
  27. package/src/polygon-clip.js +111 -66
  28. package/src/svg-boolean-ops.js +194 -118
  29. package/src/svg-collections.js +48 -19
  30. package/src/svg-flatten.js +282 -164
  31. package/src/svg-parser.js +427 -200
  32. package/src/svg-rendering-context.js +147 -104
  33. package/src/svg-toolbox.js +16411 -3298
  34. package/src/svg2-polyfills.js +114 -245
  35. package/src/transform-decomposition.js +46 -41
  36. package/src/transform-optimization.js +89 -68
  37. package/src/transforms2d.js +49 -16
  38. package/src/transforms3d.js +58 -22
  39. package/src/use-symbol-resolver.js +150 -110
  40. package/src/vector.js +67 -15
  41. package/src/vendor/README.md +110 -0
  42. package/src/vendor/inkscape-hatch-polyfill.js +401 -0
  43. package/src/vendor/inkscape-hatch-polyfill.min.js +8 -0
  44. package/src/vendor/inkscape-mesh-polyfill.js +843 -0
  45. package/src/vendor/inkscape-mesh-polyfill.min.js +8 -0
  46. package/src/verification.js +288 -124
package/bin/svg-matrix.js CHANGED
@@ -14,16 +14,29 @@
14
14
  * @license MIT
15
15
  */
16
16
 
17
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, appendFileSync, unlinkSync, openSync, readSync, closeSync } from 'fs';
18
- import { join, dirname, basename, extname, resolve, isAbsolute } from 'path';
17
+ import {
18
+ readFileSync,
19
+ writeFileSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ readdirSync,
23
+ statSync,
24
+ appendFileSync,
25
+ unlinkSync,
26
+ openSync,
27
+ readSync,
28
+ closeSync,
29
+ } from "fs";
30
+ import { join, dirname, basename, extname, resolve, isAbsolute } from "path";
19
31
 
20
32
  // Import library modules
21
- import * as SVGFlatten from '../src/svg-flatten.js';
22
- import * as GeometryToPath from '../src/geometry-to-path.js';
23
- import * as FlattenPipeline from '../src/flatten-pipeline.js';
24
- import { VERSION } from '../src/index.js';
25
- import * as SVGToolbox from '../src/svg-toolbox.js';
26
- import { parseSVG, serializeSVG } from '../src/svg-parser.js';
33
+ import * as SVGFlatten from "../src/svg-flatten.js";
34
+ import * as GeometryToPath from "../src/geometry-to-path.js";
35
+ import * as FlattenPipeline from "../src/flatten-pipeline.js";
36
+ import { VERSION } from "../src/index.js";
37
+ import * as SVGToolbox from "../src/svg-toolbox.js";
38
+ import { parseSVG, serializeSVG } from "../src/svg-parser.js";
39
+ import { setPolyfillMinification } from "../src/svg2-polyfills.js";
27
40
 
28
41
  // ============================================================================
29
42
  // CONSTANTS
@@ -52,7 +65,7 @@ const CONSTANTS = {
52
65
  EXIT_INTERRUPTED: 130, // 128 + SIGINT(2)
53
66
 
54
67
  // SVG file extensions recognized
55
- SVG_EXTENSIONS: ['.svg', '.svgz'],
68
+ SVG_EXTENSIONS: [".svg", ".svgz"],
56
69
 
57
70
  // SVG header pattern for validation
58
71
  SVG_HEADER_PATTERN: /<svg[\s>]/i,
@@ -95,7 +108,7 @@ let currentOutputFile = null; // Track for cleanup on interrupt
95
108
  */
96
109
 
97
110
  const DEFAULT_CONFIG = {
98
- command: 'help',
111
+ command: "help",
99
112
  inputs: [],
100
113
  output: null,
101
114
  listFile: null,
@@ -106,22 +119,23 @@ const DEFAULT_CONFIG = {
106
119
  recursive: false,
107
120
  overwrite: false,
108
121
  dryRun: false,
109
- showCommandHelp: false, // Track if --help was requested for a specific command
122
+ showCommandHelp: false, // Track if --help was requested for a specific command
110
123
  // Full flatten options - all enabled by default for TRUE flattening
111
- transformOnly: false, // If true, skip all resolvers (legacy behavior)
112
- resolveClipPaths: true, // Apply clipPath boolean intersection
113
- resolveMasks: true, // Convert masks to clipped geometry
114
- resolveUse: true, // Expand use/symbol elements inline
115
- resolveMarkers: true, // Instantiate markers as path geometry
116
- resolvePatterns: true, // Expand pattern fills to tiled geometry
117
- bakeGradients: true, // Bake gradientTransform into gradient coords
124
+ transformOnly: false, // If true, skip all resolvers (legacy behavior)
125
+ resolveClipPaths: true, // Apply clipPath boolean intersection
126
+ resolveMasks: true, // Convert masks to clipped geometry
127
+ resolveUse: true, // Expand use/symbol elements inline
128
+ resolveMarkers: true, // Instantiate markers as path geometry
129
+ resolvePatterns: true, // Expand pattern fills to tiled geometry
130
+ bakeGradients: true, // Bake gradientTransform into gradient coords
118
131
  // NOTE: Verification is ALWAYS enabled - precision is non-negotiable
119
132
  // E2E verification precision controls
120
- clipSegments: 64, // Polygon samples for clip operations (higher = more precise)
121
- bezierArcs: 8, // Bezier arcs for circles/ellipses (multiple of 4; 8=π/4 optimal)
122
- e2eTolerance: '1e-10', // E2E verification tolerance (tighter with more segments)
123
- preserveVendor: false, // If true, preserve vendor-prefixed properties and editor namespaces
124
- preserveNamespaces: [], // Array of namespace prefixes to preserve (e.g., ['inkscape', 'sodipodi'])
133
+ clipSegments: 64, // Polygon samples for clip operations (higher = more precise)
134
+ bezierArcs: 8, // Bezier arcs for circles/ellipses (multiple of 4; 8=π/4 optimal)
135
+ e2eTolerance: "1e-10", // E2E verification tolerance (tighter with more segments)
136
+ preserveVendor: false, // If true, preserve vendor-prefixed properties and editor namespaces
137
+ preserveNamespaces: [], // Array of namespace prefixes to preserve (e.g., ['inkscape', 'sodipodi'])
138
+ svg2Polyfills: false, // If true, inject JavaScript polyfills for SVG 2 features
125
139
  };
126
140
 
127
141
  /** @type {CLIConfig} */
@@ -133,18 +147,36 @@ let config = { ...DEFAULT_CONFIG };
133
147
 
134
148
  const LogLevel = { ERROR: 0, WARN: 1, INFO: 2, DEBUG: 3 };
135
149
 
150
+ /**
151
+ * Get current log level based on config flags.
152
+ * @returns {number} Log level (ERROR, WARN, INFO, or DEBUG)
153
+ */
136
154
  function getLogLevel() {
137
155
  if (config.quiet) return LogLevel.ERROR;
138
156
  if (config.verbose) return LogLevel.DEBUG;
139
157
  return LogLevel.INFO;
140
158
  }
141
159
 
142
- const colors = process.env.NO_COLOR !== undefined ? {
143
- reset: '', red: '', yellow: '', green: '', cyan: '', dim: '', bright: '',
144
- } : {
145
- reset: '\x1b[0m', red: '\x1b[31m', yellow: '\x1b[33m',
146
- green: '\x1b[32m', cyan: '\x1b[36m', dim: '\x1b[2m', bright: '\x1b[1m',
147
- };
160
+ const colors =
161
+ process.env.NO_COLOR !== undefined
162
+ ? {
163
+ reset: "",
164
+ red: "",
165
+ yellow: "",
166
+ green: "",
167
+ cyan: "",
168
+ dim: "",
169
+ bright: "",
170
+ }
171
+ : {
172
+ reset: "\x1b[0m",
173
+ red: "\x1b[31m",
174
+ yellow: "\x1b[33m",
175
+ green: "\x1b[32m",
176
+ cyan: "\x1b[36m",
177
+ dim: "\x1b[2m",
178
+ bright: "\x1b[1m",
179
+ };
148
180
 
149
181
  // ============================================================================
150
182
  // SIGNAL HANDLING (handlers)
@@ -153,24 +185,37 @@ const colors = process.env.NO_COLOR !== undefined ? {
153
185
  // won't be properly released. The isShuttingDown flag prevents duplicate
154
186
  // cleanup attempts during signal cascades.
155
187
  // ============================================================================
188
+ /**
189
+ * Handle graceful shutdown on SIGINT/SIGTERM signals.
190
+ * @param {string} signal - Signal name (SIGINT or SIGTERM)
191
+ * @returns {void}
192
+ */
156
193
  function handleGracefulExit(signal) {
157
194
  // Why: Prevent duplicate cleanup if multiple signals arrive quickly
158
195
  if (isShuttingDown) return;
159
196
  isShuttingDown = true;
160
197
 
161
- console.log(`\n${colors.yellow}Received ${signal}, cleaning up...${colors.reset}`);
198
+ console.log(
199
+ `\n${colors.yellow}Received ${signal}, cleaning up...${colors.reset}`,
200
+ );
162
201
 
163
202
  // Why: Remove partial output file if interrupt occurred during processing
164
203
  if (currentOutputFile && existsSync(currentOutputFile)) {
165
204
  try {
166
205
  unlinkSync(currentOutputFile);
167
- console.log(`${colors.dim}Removed partial output: ${basename(currentOutputFile)}${colors.reset}`);
168
- } catch { /* ignore cleanup errors */ }
206
+ console.log(
207
+ `${colors.dim}Removed partial output: ${basename(currentOutputFile)}${colors.reset}`,
208
+ );
209
+ } catch {
210
+ /* ignore cleanup errors */
211
+ }
169
212
  }
170
213
 
171
214
  // Log the interruption for debugging
172
215
  if (config.logFile) {
173
- writeToLogFile(`INTERRUPTED: Received ${signal} while processing ${currentInputFile || 'unknown'}`);
216
+ writeToLogFile(
217
+ `INTERRUPTED: Received ${signal} while processing ${currentInputFile || "unknown"}`,
218
+ );
174
219
  }
175
220
 
176
221
  // Why: Give async operations time to complete, but don't hang indefinitely
@@ -180,44 +225,80 @@ function handleGracefulExit(signal) {
180
225
  }
181
226
 
182
227
  // Register signal handlers - must be done before any async operations
183
- process.on('SIGINT', () => handleGracefulExit('SIGINT')); // Ctrl+C
184
- process.on('SIGTERM', () => handleGracefulExit('SIGTERM')); // kill command
228
+ process.on("SIGINT", () => handleGracefulExit("SIGINT")); // Ctrl+C
229
+ process.on("SIGTERM", () => handleGracefulExit("SIGTERM")); // kill command
185
230
  // Note: SIGTERM is not fully supported on Windows. On Windows, SIGINT (Ctrl+C) works,
186
231
  // but SIGTERM requires special handling or third-party libraries. This is acceptable
187
232
  // for a CLI tool as Ctrl+C is the primary interrupt mechanism on all platforms.
188
233
 
234
+ /**
235
+ * Write message to log file with timestamp.
236
+ * @param {string} message - Message to log
237
+ * @returns {void}
238
+ */
189
239
  function writeToLogFile(message) {
190
240
  if (config.logFile) {
191
241
  try {
192
242
  const timestamp = new Date().toISOString();
193
- const cleanMessage = message.replace(/\x1b\[[0-9;]*m/g, '');
243
+ // eslint-disable-next-line no-control-regex -- ANSI escape codes are intentional for log stripping
244
+ const cleanMessage = message.replace(/\x1b\[[0-9;]*m/g, "");
194
245
  appendFileSync(config.logFile, `[${timestamp}] ${cleanMessage}\n`);
195
- } catch { /* ignore */ }
246
+ } catch {
247
+ /* ignore */
248
+ }
196
249
  }
197
250
  }
198
251
 
252
+ /**
253
+ * Log error message to console and file.
254
+ * @param {string} msg - Error message
255
+ * @returns {void}
256
+ */
199
257
  function logError(msg) {
200
258
  console.error(`${colors.red}ERROR:${colors.reset} ${msg}`);
201
259
  writeToLogFile(`ERROR: ${msg}`);
202
260
  }
203
261
 
262
+ /**
263
+ * Log warning message to console and file.
264
+ * @param {string} msg - Warning message
265
+ * @returns {void}
266
+ */
204
267
  function logWarn(msg) {
205
- if (getLogLevel() >= LogLevel.WARN) console.warn(`${colors.yellow}WARN:${colors.reset} ${msg}`);
268
+ if (getLogLevel() >= LogLevel.WARN)
269
+ console.warn(`${colors.yellow}WARN:${colors.reset} ${msg}`);
206
270
  writeToLogFile(`WARN: ${msg}`);
207
271
  }
208
272
 
273
+ /**
274
+ * Log info message to console and file.
275
+ * @param {string} msg - Info message
276
+ * @returns {void}
277
+ */
209
278
  function logInfo(msg) {
210
279
  if (getLogLevel() >= LogLevel.INFO) console.log(msg);
211
280
  writeToLogFile(`INFO: ${msg}`);
212
281
  }
213
282
 
283
+ /**
284
+ * Log debug message to console and file.
285
+ * @param {string} msg - Debug message
286
+ * @returns {void}
287
+ */
214
288
  function logDebug(msg) {
215
- if (getLogLevel() >= LogLevel.DEBUG) console.log(`${colors.dim}DEBUG: ${msg}${colors.reset}`);
289
+ if (getLogLevel() >= LogLevel.DEBUG)
290
+ console.log(`${colors.dim}DEBUG: ${msg}${colors.reset}`);
216
291
  writeToLogFile(`DEBUG: ${msg}`);
217
292
  }
218
293
 
294
+ /**
295
+ * Log success message to console and file.
296
+ * @param {string} msg - Success message
297
+ * @returns {void}
298
+ */
219
299
  function logSuccess(msg) {
220
- if (getLogLevel() >= LogLevel.INFO) console.log(`${colors.green}OK${colors.reset} ${msg}`);
300
+ if (getLogLevel() >= LogLevel.INFO)
301
+ console.log(`${colors.green}OK${colors.reset} ${msg}`);
221
302
  writeToLogFile(`SUCCESS: ${msg}`);
222
303
  }
223
304
 
@@ -227,27 +308,38 @@ function logSuccess(msg) {
227
308
  // the operation is still running and how far along it is. Without this,
228
309
  // users may think the program is frozen and kill it prematurely.
229
310
  // ============================================================================
311
+ /**
312
+ * Display progress bar for batch operations.
313
+ * @param {number} current - Current file number
314
+ * @param {number} total - Total number of files
315
+ * @param {string} filename - Current filename being processed
316
+ * @returns {void}
317
+ */
230
318
  function showProgress(current, total, filename) {
231
319
  // Why: Don't show progress in quiet mode or when there's only one file
232
320
  if (config.quiet || total <= 1) return;
233
321
 
234
322
  // Why: In verbose mode, newline before progress to avoid overwriting debug output
235
323
  if (config.verbose && current > 1) {
236
- process.stdout.write('\n');
324
+ process.stdout.write("\n");
237
325
  }
238
326
 
239
327
  const percent = Math.round((current / total) * 100);
240
- const bar = '█'.repeat(Math.floor(percent / 5)) + '░'.repeat(20 - Math.floor(percent / 5));
328
+ const bar =
329
+ "█".repeat(Math.floor(percent / 5)) +
330
+ "░".repeat(20 - Math.floor(percent / 5));
241
331
 
242
332
  // Why: Use \r to overwrite the same line, keeping terminal clean
243
- process.stdout.write(`\r${colors.cyan}[${bar}]${colors.reset} ${percent}% (${current}/${total}) ${basename(filename)}`);
333
+ process.stdout.write(
334
+ `\r${colors.cyan}[${bar}]${colors.reset} ${percent}% (${current}/${total}) ${basename(filename)}`,
335
+ );
244
336
 
245
337
  // Why: Clear to end of line to remove any leftover characters from longer filenames
246
- process.stdout.write('\x1b[K');
338
+ process.stdout.write("\x1b[K");
247
339
 
248
340
  // Why: Print newline when complete so next output starts on new line
249
341
  if (current === total) {
250
- process.stdout.write('\n');
342
+ process.stdout.write("\n");
251
343
  }
252
344
  }
253
345
 
@@ -256,24 +348,32 @@ function showProgress(current, total, filename) {
256
348
  // Why: Fail fast on invalid input. Processing non-SVG files wastes time and
257
349
  // may produce confusing errors. Size limits prevent memory exhaustion.
258
350
  // ============================================================================
351
+ /**
352
+ * Validate SVG file size and format.
353
+ * @param {string} filePath - Path to SVG file
354
+ * @returns {boolean} True if valid
355
+ * @throws {Error} If file is invalid or too large
356
+ */
259
357
  function validateSvgFile(filePath) {
260
358
  const stats = statSync(filePath);
261
359
 
262
360
  // Why: Prevent memory exhaustion from huge files
263
361
  if (stats.size > CONSTANTS.MAX_FILE_SIZE_BYTES) {
264
- throw new Error(`File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB). Max: ${CONSTANTS.MAX_FILE_SIZE_BYTES / 1024 / 1024}MB`);
362
+ throw new Error(
363
+ `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB). Max: ${CONSTANTS.MAX_FILE_SIZE_BYTES / 1024 / 1024}MB`,
364
+ );
265
365
  }
266
366
 
267
367
  // Why: Read only first 1KB to check header - don't load entire file
268
- const fd = openSync(filePath, 'r');
368
+ const fd = openSync(filePath, "r");
269
369
  const buffer = Buffer.alloc(1024);
270
370
  readSync(fd, buffer, 0, 1024, 0);
271
371
  closeSync(fd);
272
- const header = buffer.toString('utf8');
372
+ const header = buffer.toString("utf8");
273
373
 
274
374
  // Why: SVG files must have an <svg> element - if not, it's not a valid SVG
275
375
  if (!CONSTANTS.SVG_HEADER_PATTERN.test(header)) {
276
- throw new Error('Not a valid SVG file (missing <svg> element)');
376
+ throw new Error("Not a valid SVG file (missing <svg> element)");
277
377
  }
278
378
 
279
379
  return true;
@@ -285,18 +385,28 @@ function validateSvgFile(filePath) {
285
385
  // shares) may appear to write successfully but fail to persist data.
286
386
  // Verification catches this immediately rather than discovering corruption later.
287
387
  // ============================================================================
288
- function verifyWriteSuccess(filePath, expectedContent) {
388
+ /**
389
+ * Verify file was written correctly by comparing content.
390
+ * @param {string} filePath - Path to file
391
+ * @param {string} expectedContent - Expected file content
392
+ * @returns {boolean} True if verification passed
393
+ * @throws {Error} If verification failed
394
+ * @private
395
+ */
396
+ function _verifyWriteSuccess(filePath, expectedContent) {
289
397
  // Why: Read back what was written and compare
290
- const actualContent = readFileSync(filePath, 'utf8');
398
+ const actualContent = readFileSync(filePath, "utf8");
291
399
 
292
400
  // Why: Compare lengths first (fast), then content if needed
293
401
  if (actualContent.length !== expectedContent.length) {
294
- throw new Error(`Write verification failed: size mismatch (expected ${expectedContent.length}, got ${actualContent.length})`);
402
+ throw new Error(
403
+ `Write verification failed: size mismatch (expected ${expectedContent.length}, got ${actualContent.length})`,
404
+ );
295
405
  }
296
406
 
297
407
  // Why: Full content comparison to catch bit flips or encoding issues
298
408
  if (actualContent !== expectedContent) {
299
- throw new Error('Write verification failed: content mismatch');
409
+ throw new Error("Write verification failed: content mismatch");
300
410
  }
301
411
 
302
412
  return true;
@@ -308,9 +418,15 @@ function verifyWriteSuccess(filePath, expectedContent) {
308
418
  // the bug or fix the issue themselves. This generates a timestamped log file
309
419
  // with full context about what was being processed when the crash occurred.
310
420
  // ============================================================================
421
+ /**
422
+ * Generate crash report with full context for debugging.
423
+ * @param {Error} error - Error that caused the crash
424
+ * @param {Object} context - Additional context information
425
+ * @returns {string|null} Path to crash log file, or null if write failed
426
+ */
311
427
  function generateCrashLog(error, context = {}) {
312
- const crashDir = join(process.cwd(), '.svg-matrix-crashes');
313
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
428
+ const crashDir = join(process.cwd(), ".svg-matrix-crashes");
429
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
314
430
  const crashFile = join(crashDir, `crash-${timestamp}.log`);
315
431
 
316
432
  try {
@@ -334,10 +450,10 @@ Stack:
334
450
  ${error.stack}
335
451
 
336
452
  Config:
337
- ${JSON.stringify({ ...config, logFile: config.logFile ? '[redacted]' : null }, null, 2)}
453
+ ${JSON.stringify({ ...config, logFile: config.logFile ? "[redacted]" : null }, null, 2)}
338
454
  `;
339
455
 
340
- writeFileSync(crashFile, crashContent, 'utf8');
456
+ writeFileSync(crashFile, crashContent, "utf8");
341
457
  logError(`Crash log written to: ${crashFile}`);
342
458
  return crashFile;
343
459
  } catch (e) {
@@ -351,15 +467,57 @@ ${JSON.stringify({ ...config, logFile: config.logFile ? '[redacted]' : null }, n
351
467
  // PATH UTILITIES
352
468
  // ============================================================================
353
469
 
354
- function normalizePath(p) { return p.replace(/\\/g, '/'); }
470
+ /**
471
+ * Normalize path separators to forward slashes.
472
+ * @param {string} p - Path to normalize
473
+ * @returns {string} Normalized path
474
+ */
475
+ function normalizePath(p) {
476
+ return p.replace(/\\/g, "/");
477
+ }
355
478
 
479
+ /**
480
+ * Resolve path to absolute path with normalized separators.
481
+ * @param {string} p - Path to resolve
482
+ * @returns {string} Absolute normalized path
483
+ */
356
484
  function resolvePath(p) {
357
- return isAbsolute(p) ? normalizePath(p) : normalizePath(resolve(process.cwd(), p));
485
+ return isAbsolute(p)
486
+ ? normalizePath(p)
487
+ : normalizePath(resolve(process.cwd(), p));
358
488
  }
359
489
 
360
- function isDir(p) { try { return statSync(p).isDirectory(); } catch { return false; } }
361
- function isFile(p) { try { return statSync(p).isFile(); } catch { return false; } }
490
+ /**
491
+ * Check if path is a directory.
492
+ * @param {string} p - Path to check
493
+ * @returns {boolean} True if directory exists
494
+ */
495
+ function isDir(p) {
496
+ try {
497
+ return statSync(p).isDirectory();
498
+ } catch {
499
+ return false;
500
+ }
501
+ }
362
502
 
503
+ /**
504
+ * Check if path is a file.
505
+ * @param {string} p - Path to check
506
+ * @returns {boolean} True if file exists
507
+ */
508
+ function isFile(p) {
509
+ try {
510
+ return statSync(p).isFile();
511
+ } catch {
512
+ return false;
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Ensure directory exists, creating it if necessary.
518
+ * @param {string} dir - Directory path
519
+ * @returns {void}
520
+ */
363
521
  function ensureDir(dir) {
364
522
  if (!existsSync(dir)) {
365
523
  mkdirSync(dir, { recursive: true });
@@ -367,6 +525,12 @@ function ensureDir(dir) {
367
525
  }
368
526
  }
369
527
 
528
+ /**
529
+ * Get all SVG files in directory.
530
+ * @param {string} dir - Directory path
531
+ * @param {boolean} recursive - Whether to search recursively
532
+ * @returns {string[]} Array of SVG file paths
533
+ */
370
534
  function getSvgFiles(dir, recursive = false) {
371
535
  const files = [];
372
536
  function scan(d) {
@@ -374,22 +538,32 @@ function getSvgFiles(dir, recursive = false) {
374
538
  const fullPath = join(d, entry.name);
375
539
  if (entry.isDirectory() && recursive) scan(fullPath);
376
540
  // Why: Support both .svg and .svgz as defined in CONSTANTS.SVG_EXTENSIONS
377
- else if (entry.isFile() && CONSTANTS.SVG_EXTENSIONS.includes(extname(entry.name).toLowerCase())) files.push(normalizePath(fullPath));
541
+ else if (
542
+ entry.isFile() &&
543
+ CONSTANTS.SVG_EXTENSIONS.includes(extname(entry.name).toLowerCase())
544
+ )
545
+ files.push(normalizePath(fullPath));
378
546
  }
379
547
  }
380
548
  scan(dir);
381
549
  return files;
382
550
  }
383
551
 
552
+ /**
553
+ * Parse file list from text file.
554
+ * @param {string} listPath - Path to list file
555
+ * @returns {string[]} Array of resolved file paths
556
+ */
384
557
  function parseFileList(listPath) {
385
- const content = readFileSync(listPath, 'utf8');
558
+ const content = readFileSync(listPath, "utf8");
386
559
  const files = [];
387
560
  for (const line of content.split(/\r?\n/)) {
388
561
  const trimmed = line.trim();
389
- if (!trimmed || trimmed.startsWith('#')) continue;
562
+ if (!trimmed || trimmed.startsWith("#")) continue;
390
563
  const resolved = resolvePath(trimmed);
391
564
  if (isFile(resolved)) files.push(resolved);
392
- else if (isDir(resolved)) files.push(...getSvgFiles(resolved, config.recursive));
565
+ else if (isDir(resolved))
566
+ files.push(...getSvgFiles(resolved, config.recursive));
393
567
  else logWarn(`File not found: ${trimmed}`);
394
568
  }
395
569
  return files;
@@ -411,7 +585,7 @@ function parseFileList(listPath) {
411
585
  */
412
586
  function extractNumericAttr(attrs, attrName, defaultValue = 0) {
413
587
  // Why: Use word boundary \b to avoid matching 'rx' when looking for 'x'
414
- const regex = new RegExp(`\\b${attrName}\\s*=\\s*["']([^"']+)["']`, 'i');
588
+ const regex = new RegExp(`\\b${attrName}\\s*=\\s*["']([^"']+)["']`, "i");
415
589
  const match = attrs.match(regex);
416
590
  return match ? parseFloat(match[1]) : defaultValue;
417
591
  }
@@ -425,45 +599,58 @@ function extractNumericAttr(attrs, attrName, defaultValue = 0) {
425
599
  */
426
600
  function extractShapeAsPath(shapeType, attrs, precision) {
427
601
  switch (shapeType) {
428
- case 'rect': {
429
- const x = extractNumericAttr(attrs, 'x');
430
- const y = extractNumericAttr(attrs, 'y');
431
- const w = extractNumericAttr(attrs, 'width');
432
- const h = extractNumericAttr(attrs, 'height');
433
- const rx = extractNumericAttr(attrs, 'rx');
434
- const ry = extractNumericAttr(attrs, 'ry', rx); // ry defaults to rx per SVG spec
602
+ case "rect": {
603
+ const x = extractNumericAttr(attrs, "x");
604
+ const y = extractNumericAttr(attrs, "y");
605
+ const w = extractNumericAttr(attrs, "width");
606
+ const h = extractNumericAttr(attrs, "height");
607
+ const rx = extractNumericAttr(attrs, "rx");
608
+ const ry = extractNumericAttr(attrs, "ry", rx); // ry defaults to rx per SVG spec
435
609
  if (w <= 0 || h <= 0) return null;
436
- return GeometryToPath.rectToPathData(x, y, w, h, rx, ry, false, precision);
610
+ return GeometryToPath.rectToPathData(
611
+ x,
612
+ y,
613
+ w,
614
+ h,
615
+ rx,
616
+ ry,
617
+ false,
618
+ precision,
619
+ );
437
620
  }
438
- case 'circle': {
439
- const cx = extractNumericAttr(attrs, 'cx');
440
- const cy = extractNumericAttr(attrs, 'cy');
441
- const r = extractNumericAttr(attrs, 'r');
621
+ case "circle": {
622
+ const cx = extractNumericAttr(attrs, "cx");
623
+ const cy = extractNumericAttr(attrs, "cy");
624
+ const r = extractNumericAttr(attrs, "r");
442
625
  if (r <= 0) return null;
443
626
  return GeometryToPath.circleToPathData(cx, cy, r, precision);
444
627
  }
445
- case 'ellipse': {
446
- const cx = extractNumericAttr(attrs, 'cx');
447
- const cy = extractNumericAttr(attrs, 'cy');
448
- const rx = extractNumericAttr(attrs, 'rx');
449
- const ry = extractNumericAttr(attrs, 'ry');
628
+ case "ellipse": {
629
+ const cx = extractNumericAttr(attrs, "cx");
630
+ const cy = extractNumericAttr(attrs, "cy");
631
+ const rx = extractNumericAttr(attrs, "rx");
632
+ const ry = extractNumericAttr(attrs, "ry");
450
633
  if (rx <= 0 || ry <= 0) return null;
451
634
  return GeometryToPath.ellipseToPathData(cx, cy, rx, ry, precision);
452
635
  }
453
- case 'line': {
454
- const x1 = extractNumericAttr(attrs, 'x1');
455
- const y1 = extractNumericAttr(attrs, 'y1');
456
- const x2 = extractNumericAttr(attrs, 'x2');
457
- const y2 = extractNumericAttr(attrs, 'y2');
636
+ case "line": {
637
+ const x1 = extractNumericAttr(attrs, "x1");
638
+ const y1 = extractNumericAttr(attrs, "y1");
639
+ const x2 = extractNumericAttr(attrs, "x2");
640
+ const y2 = extractNumericAttr(attrs, "y2");
458
641
  return GeometryToPath.lineToPathData(x1, y1, x2, y2, precision);
459
642
  }
460
- case 'polygon': {
643
+ case "polygon": {
461
644
  const points = attrs.match(/points\s*=\s*["']([^"']+)["']/)?.[1];
462
- return points ? GeometryToPath.polygonToPathData(points, precision) : null;
645
+ return points
646
+ ? GeometryToPath.polygonToPathData(points, precision)
647
+ : null;
463
648
  }
464
- case 'polyline': {
649
+ case "polyline": {
465
650
  const points = attrs.match(/points\s*=\s*["']([^"']+)["']/)?.[1];
466
- return points ? GeometryToPath.polylineToPathData(points, precision) : null;
651
+ return points
652
+ ? GeometryToPath.polylineToPathData(points, precision)
653
+ : null;
467
654
  }
468
655
  default:
469
656
  return null;
@@ -477,12 +664,12 @@ function extractShapeAsPath(shapeType, attrs, precision) {
477
664
  */
478
665
  function getShapeSpecificAttrs(shapeType) {
479
666
  const attrMap = {
480
- rect: ['x', 'y', 'width', 'height', 'rx', 'ry'],
481
- circle: ['cx', 'cy', 'r'],
482
- ellipse: ['cx', 'cy', 'rx', 'ry'],
483
- line: ['x1', 'y1', 'x2', 'y2'],
484
- polygon: ['points'],
485
- polyline: ['points'],
667
+ rect: ["x", "y", "width", "height", "rx", "ry"],
668
+ circle: ["cx", "cy", "r"],
669
+ ellipse: ["cx", "cy", "rx", "ry"],
670
+ line: ["x1", "y1", "x2", "y2"],
671
+ polygon: ["points"],
672
+ polyline: ["points"],
486
673
  };
487
674
  return attrMap[shapeType] || [];
488
675
  }
@@ -496,7 +683,10 @@ function getShapeSpecificAttrs(shapeType) {
496
683
  function removeShapeAttrs(attrs, attrsToRemove) {
497
684
  let result = attrs;
498
685
  for (const attrName of attrsToRemove) {
499
- result = result.replace(new RegExp(`\\b${attrName}\\s*=\\s*["'][^"']*["']`, 'gi'), '');
686
+ result = result.replace(
687
+ new RegExp(`\\b${attrName}\\s*=\\s*["'][^"']*["']`, "gi"),
688
+ "",
689
+ );
500
690
  }
501
691
  return result.trim();
502
692
  }
@@ -510,39 +700,74 @@ function removeShapeAttrs(attrs, attrsToRemove) {
510
700
  // ============================================================================
511
701
 
512
702
  // Box drawing helpers
703
+ /**
704
+ * Check if terminal supports Unicode box drawing characters.
705
+ * @returns {boolean} True if Unicode is supported
706
+ */
513
707
  const supportsUnicode = () => {
514
- if (process.platform === 'win32') {
515
- return !!(process.env.WT_SESSION || process.env.CHCP === '65001' ||
516
- process.env.ConEmuANSI === 'ON' || process.env.TERM_PROGRAM);
708
+ if (process.platform === "win32") {
709
+ return !!(
710
+ process.env.WT_SESSION ||
711
+ process.env.CHCP === "65001" ||
712
+ process.env.ConEmuANSI === "ON" ||
713
+ process.env.TERM_PROGRAM
714
+ );
517
715
  }
518
716
  return true;
519
717
  };
520
718
 
521
719
  const B = supportsUnicode()
522
- ? { tl: '', tr: '', bl: '', br: '', h: '', v: '', dot: '', arr: '' }
523
- : { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|', dot: '*', arr: '->' };
720
+ ? { tl: "", tr: "", bl: "", br: "", h: "", v: "", dot: "", arr: "" }
721
+ : { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|", dot: "*", arr: "->" };
524
722
 
723
+ /**
724
+ * Strip ANSI color codes from string.
725
+ * @param {string} s - String with ANSI codes
726
+ * @returns {string} String without ANSI codes
727
+ */
525
728
  function stripAnsi(s) {
526
- return s.replace(/\x1b\[[0-9;]*m/g, '');
729
+ // eslint-disable-next-line no-control-regex -- ANSI escape codes are intentional for terminal color stripping
730
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
527
731
  }
528
732
 
733
+ /**
734
+ * Create formatted box line with border.
735
+ * @param {string} content - Content to display
736
+ * @param {number} width - Box width
737
+ * @returns {string} Formatted line
738
+ */
529
739
  function boxLine(content, width = 70) {
530
740
  const visible = stripAnsi(content).length;
531
741
  const padding = Math.max(0, width - visible - 2);
532
- return `${colors.cyan}${B.v}${colors.reset} ${content}${' '.repeat(padding)}${colors.cyan}${B.v}${colors.reset}`;
742
+ return `${colors.cyan}${B.v}${colors.reset} ${content}${" ".repeat(padding)}${colors.cyan}${B.v}${colors.reset}`;
533
743
  }
534
744
 
745
+ /**
746
+ * Create formatted box header with title.
747
+ * @param {string} title - Header title
748
+ * @param {number} width - Box width
749
+ * @returns {string} Formatted header
750
+ */
535
751
  function boxHeader(title, width = 70) {
536
- const hr = B.h.repeat(width);
752
+ const _hr = B.h.repeat(width); // Reserved for future use (horizontal rule)
537
753
  const visible = stripAnsi(title).length;
538
754
  const padding = Math.max(0, width - visible - 2);
539
- return `${colors.cyan}${B.v}${colors.reset} ${colors.bright}${title}${colors.reset}${' '.repeat(padding)}${colors.cyan}${B.v}${colors.reset}`;
755
+ return `${colors.cyan}${B.v}${colors.reset} ${colors.bright}${title}${colors.reset}${" ".repeat(padding)}${colors.cyan}${B.v}${colors.reset}`;
540
756
  }
541
757
 
758
+ /**
759
+ * Create horizontal divider line.
760
+ * @param {number} width - Box width
761
+ * @returns {string} Divider line
762
+ */
542
763
  function boxDivider(width = 70) {
543
764
  return `${colors.cyan}${B.v}${B.h.repeat(width)}${B.v}${colors.reset}`;
544
765
  }
545
766
 
767
+ /**
768
+ * Display main help message.
769
+ * @returns {void}
770
+ */
546
771
  function showHelp() {
547
772
  const W = 72;
548
773
  const hr = B.h.repeat(W);
@@ -552,36 +777,36 @@ ${colors.cyan}${B.tl}${hr}${B.tr}${colors.reset}
552
777
  ${boxLine(`${colors.bright}@emasoft/svg-matrix${colors.reset} v${VERSION}`, W)}
553
778
  ${boxLine(`${colors.dim}Arbitrary-precision SVG transforms with decimal.js${colors.reset}`, W)}
554
779
  ${boxDivider(W)}
555
- ${boxLine('', W)}
556
- ${boxHeader('USAGE', W)}
557
- ${boxLine('', W)}
780
+ ${boxLine("", W)}
781
+ ${boxHeader("USAGE", W)}
782
+ ${boxLine("", W)}
558
783
  ${boxLine(` svg-matrix <command> [options] <input> [-o <output>]`, W)}
559
- ${boxLine('', W)}
784
+ ${boxLine("", W)}
560
785
  ${boxDivider(W)}
561
- ${boxLine('', W)}
562
- ${boxHeader('COMMANDS', W)}
563
- ${boxLine('', W)}
786
+ ${boxLine("", W)}
787
+ ${boxHeader("COMMANDS", W)}
788
+ ${boxLine("", W)}
564
789
  ${boxLine(` ${colors.green}flatten${colors.reset} TRUE flatten: resolve ALL transform dependencies`, W)}
565
790
  ${boxLine(` ${colors.dim}Bakes transforms, applies clipPaths, expands use/markers${colors.reset}`, W)}
566
- ${boxLine('', W)}
791
+ ${boxLine("", W)}
567
792
  ${boxLine(` ${colors.green}convert${colors.reset} Convert shapes (rect, circle, ellipse, line) to paths`, W)}
568
793
  ${boxLine(` ${colors.dim}Preserves all style attributes${colors.reset}`, W)}
569
- ${boxLine('', W)}
794
+ ${boxLine("", W)}
570
795
  ${boxLine(` ${colors.green}normalize${colors.reset} Convert all paths to absolute cubic Bezier curves`, W)}
571
796
  ${boxLine(` ${colors.dim}Ideal for animation and path morphing${colors.reset}`, W)}
572
- ${boxLine('', W)}
797
+ ${boxLine("", W)}
573
798
  ${boxLine(` ${colors.green}info${colors.reset} Show SVG file information and element counts`, W)}
574
- ${boxLine('', W)}
799
+ ${boxLine("", W)}
575
800
  ${boxLine(` ${colors.green}test-toolbox${colors.reset} Test all svg-toolbox functions on an SVG file`, W)}
576
801
  ${boxLine(` ${colors.dim}Creates timestamped folder with all processed versions${colors.reset}`, W)}
577
- ${boxLine('', W)}
802
+ ${boxLine("", W)}
578
803
  ${boxLine(` ${colors.green}help${colors.reset} Show this help (or: svg-matrix <command> --help)`, W)}
579
804
  ${boxLine(` ${colors.green}version${colors.reset} Show version number`, W)}
580
- ${boxLine('', W)}
805
+ ${boxLine("", W)}
581
806
  ${boxDivider(W)}
582
- ${boxLine('', W)}
583
- ${boxHeader('GLOBAL OPTIONS', W)}
584
- ${boxLine('', W)}
807
+ ${boxLine("", W)}
808
+ ${boxHeader("GLOBAL OPTIONS", W)}
809
+ ${boxLine("", W)}
585
810
  ${boxLine(` ${colors.dim}-o, --output <path>${colors.reset} Output file or directory`, W)}
586
811
  ${boxLine(` ${colors.dim}-l, --list <file>${colors.reset} Read input files from text file`, W)}
587
812
  ${boxLine(` ${colors.dim}-r, --recursive${colors.reset} Process directories recursively`, W)}
@@ -591,11 +816,11 @@ ${boxLine(` ${colors.dim}-n, --dry-run${colors.reset} Show what would
591
816
  ${boxLine(` ${colors.dim}-q, --quiet${colors.reset} Suppress all output except errors`, W)}
592
817
  ${boxLine(` ${colors.dim}-v, --verbose${colors.reset} Enable verbose/debug output`, W)}
593
818
  ${boxLine(` ${colors.dim}--log-file <path>${colors.reset} Write log to file`, W)}
594
- ${boxLine('', W)}
819
+ ${boxLine("", W)}
595
820
  ${boxDivider(W)}
596
- ${boxLine('', W)}
597
- ${boxHeader('FLATTEN OPTIONS', W)}
598
- ${boxLine('', W)}
821
+ ${boxLine("", W)}
822
+ ${boxHeader("FLATTEN OPTIONS", W)}
823
+ ${boxLine("", W)}
599
824
  ${boxLine(` ${colors.dim}--transform-only${colors.reset} Only flatten transforms (skip resolvers)`, W)}
600
825
  ${boxLine(` ${colors.dim}--no-clip-paths${colors.reset} Skip clipPath boolean operations`, W)}
601
826
  ${boxLine(` ${colors.dim}--no-masks${colors.reset} Skip mask to clip conversion`, W)}
@@ -607,52 +832,59 @@ ${boxLine(` ${colors.dim}--preserve-vendor${colors.reset} Keep vendor pre
607
832
  ${boxLine(` ${colors.dim}(inkscape, sodipodi, -webkit-*, etc.)${colors.reset}`, W)}
608
833
  ${boxLine(` ${colors.dim}--preserve-ns <list>${colors.reset} Preserve specific namespaces (comma-separated)`, W)}
609
834
  ${boxLine(` ${colors.dim}Example: --preserve-ns inkscape,sodipodi${colors.reset}`, W)}
610
- ${boxLine('', W)}
835
+ ${boxLine(` ${colors.dim}--svg2-polyfills${colors.reset} Inject JavaScript polyfills for SVG 2 features`, W)}
836
+ ${boxLine(` ${colors.dim}--no-minify-polyfills${colors.reset} Use full (non-minified) polyfills for debugging`, W)}
837
+ ${boxLine("", W)}
611
838
  ${boxDivider(W)}
612
- ${boxLine('', W)}
613
- ${boxHeader('PRECISION OPTIONS', W)}
614
- ${boxLine('', W)}
839
+ ${boxLine("", W)}
840
+ ${boxHeader("PRECISION OPTIONS", W)}
841
+ ${boxLine("", W)}
615
842
  ${boxLine(` ${colors.dim}--clip-segments <n>${colors.reset} Polygon samples for clipping (default: 64)`, W)}
616
843
  ${boxLine(` ${colors.dim}64=balanced, 128=high, 256=very high${colors.reset}`, W)}
617
- ${boxLine('', W)}
844
+ ${boxLine("", W)}
618
845
  ${boxLine(` ${colors.dim}--bezier-arcs <n>${colors.reset} Bezier arcs for curves (default: 8)`, W)}
619
846
  ${boxLine(` ${colors.dim}Must be multiple of 4. Error rates:${colors.reset}`, W)}
620
847
  ${boxLine(` ${colors.dim}8: 0.0004%, 16: 0.000007%, 64: 0.00000001%${colors.reset}`, W)}
621
- ${boxLine('', W)}
848
+ ${boxLine("", W)}
622
849
  ${boxLine(` ${colors.dim}--e2e-tolerance <exp>${colors.reset} Verification tolerance (default: 1e-10)`, W)}
623
850
  ${boxLine(` ${colors.dim}Examples: 1e-8, 1e-10, 1e-12, 1e-14${colors.reset}`, W)}
624
- ${boxLine('', W)}
851
+ ${boxLine("", W)}
625
852
  ${boxLine(` ${colors.yellow}${B.dot} Mathematical verification is ALWAYS enabled${colors.reset}`, W)}
626
853
  ${boxLine(` ${colors.yellow}${B.dot} Precision is non-negotiable in this library${colors.reset}`, W)}
627
- ${boxLine('', W)}
854
+ ${boxLine("", W)}
628
855
  ${boxDivider(W)}
629
- ${boxLine('', W)}
630
- ${boxHeader('EXAMPLES', W)}
631
- ${boxLine('', W)}
856
+ ${boxLine("", W)}
857
+ ${boxHeader("EXAMPLES", W)}
858
+ ${boxLine("", W)}
632
859
  ${boxLine(` ${colors.green}svg-matrix flatten${colors.reset} input.svg -o output.svg`, W)}
633
860
  ${boxLine(` ${colors.green}svg-matrix flatten${colors.reset} ./svgs/ -o ./out/ --transform-only`, W)}
634
861
  ${boxLine(` ${colors.green}svg-matrix flatten${colors.reset} --list files.txt -o ./out/ --no-patterns`, W)}
635
862
  ${boxLine(` ${colors.green}svg-matrix flatten${colors.reset} input.svg -o out.svg --preserve-ns inkscape,sodipodi`, W)}
636
863
  ${boxLine(` ${colors.green}svg-matrix convert${colors.reset} input.svg -o output.svg -p 10`, W)}
637
864
  ${boxLine(` ${colors.green}svg-matrix info${colors.reset} input.svg`, W)}
638
- ${boxLine('', W)}
865
+ ${boxLine("", W)}
639
866
  ${boxDivider(W)}
640
- ${boxLine('', W)}
641
- ${boxHeader('JAVASCRIPT API', W)}
642
- ${boxLine('', W)}
867
+ ${boxLine("", W)}
868
+ ${boxHeader("JAVASCRIPT API", W)}
869
+ ${boxLine("", W)}
643
870
  ${boxLine(` import { Matrix, Vector, Transforms2D } from '@emasoft/svg-matrix'`, W)}
644
- ${boxLine('', W)}
871
+ ${boxLine("", W)}
645
872
  ${boxLine(` ${colors.green}${B.dot} Matrix${colors.reset} Arbitrary-precision matrix operations`, W)}
646
873
  ${boxLine(` ${colors.green}${B.dot} Vector${colors.reset} High-precision vector math`, W)}
647
874
  ${boxLine(` ${colors.green}${B.dot} Transforms2D${colors.reset} rotate, scale, translate, skew, reflect`, W)}
648
875
  ${boxLine(` ${colors.green}${B.dot} Transforms3D${colors.reset} 3D affine transformations`, W)}
649
- ${boxLine('', W)}
876
+ ${boxLine("", W)}
650
877
  ${colors.cyan}${B.bl}${hr}${B.br}${colors.reset}
651
878
 
652
879
  ${colors.cyan}Docs:${colors.reset} https://github.com/Emasoft/SVG-MATRIX#readme
653
880
  `);
654
881
  }
655
882
 
883
+ /**
884
+ * Display command-specific help message.
885
+ * @param {string} command - Command name
886
+ * @returns {void}
887
+ */
656
888
  function showCommandHelp(command) {
657
889
  const W = 72;
658
890
  const hr = B.h.repeat(W);
@@ -662,12 +894,12 @@ function showCommandHelp(command) {
662
894
  ${colors.cyan}${B.tl}${hr}${B.tr}${colors.reset}
663
895
  ${boxLine(`${colors.bright}svg-matrix flatten${colors.reset} - TRUE SVG Flattening`, W)}
664
896
  ${boxDivider(W)}
665
- ${boxLine('', W)}
897
+ ${boxLine("", W)}
666
898
  ${boxLine(`Resolves ALL transform dependencies to produce a "flat" SVG where`, W)}
667
899
  ${boxLine(`every coordinate is in the root coordinate system.`, W)}
668
- ${boxLine('', W)}
669
- ${boxHeader('WHAT IT DOES', W)}
670
- ${boxLine('', W)}
900
+ ${boxLine("", W)}
901
+ ${boxHeader("WHAT IT DOES", W)}
902
+ ${boxLine("", W)}
671
903
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Bakes transform attributes into path coordinates`, W)}
672
904
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Applies clipPath as boolean intersection operations`, W)}
673
905
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Converts masks to equivalent clipped geometry`, W)}
@@ -675,110 +907,110 @@ ${boxLine(` ${colors.green}${B.dot}${colors.reset} Expands <use> and <symbol> r
675
907
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Instantiates markers as actual path geometry`, W)}
676
908
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Expands pattern fills to tiled geometry`, W)}
677
909
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Bakes gradientTransform into gradient coordinates`, W)}
678
- ${boxLine('', W)}
679
- ${boxHeader('USAGE', W)}
680
- ${boxLine('', W)}
910
+ ${boxLine("", W)}
911
+ ${boxHeader("USAGE", W)}
912
+ ${boxLine("", W)}
681
913
  ${boxLine(` svg-matrix flatten <input> [options]`, W)}
682
- ${boxLine('', W)}
683
- ${boxHeader('EXAMPLES', W)}
684
- ${boxLine('', W)}
914
+ ${boxLine("", W)}
915
+ ${boxHeader("EXAMPLES", W)}
916
+ ${boxLine("", W)}
685
917
  ${boxLine(` ${colors.dim}# Full flatten (all resolvers enabled)${colors.reset}`, W)}
686
918
  ${boxLine(` svg-matrix flatten input.svg -o output.svg`, W)}
687
- ${boxLine('', W)}
919
+ ${boxLine("", W)}
688
920
  ${boxLine(` ${colors.dim}# Transform-only mode (legacy, faster)${colors.reset}`, W)}
689
921
  ${boxLine(` svg-matrix flatten input.svg -o out.svg --transform-only`, W)}
690
- ${boxLine('', W)}
922
+ ${boxLine("", W)}
691
923
  ${boxLine(` ${colors.dim}# High-precision mode for complex curves${colors.reset}`, W)}
692
924
  ${boxLine(` svg-matrix flatten in.svg -o out.svg --clip-segments 128`, W)}
693
- ${boxLine('', W)}
925
+ ${boxLine("", W)}
694
926
  ${boxLine(` ${colors.dim}# Skip specific resolvers${colors.reset}`, W)}
695
927
  ${boxLine(` svg-matrix flatten in.svg -o out.svg --no-patterns --no-markers`, W)}
696
- ${boxLine('', W)}
928
+ ${boxLine("", W)}
697
929
  ${colors.cyan}${B.bl}${hr}${B.br}${colors.reset}
698
930
  `,
699
931
  convert: `
700
932
  ${colors.cyan}${B.tl}${hr}${B.tr}${colors.reset}
701
933
  ${boxLine(`${colors.bright}svg-matrix convert${colors.reset} - Shape to Path Conversion`, W)}
702
934
  ${boxDivider(W)}
703
- ${boxLine('', W)}
935
+ ${boxLine("", W)}
704
936
  ${boxLine(`Converts all basic shapes to <path> elements while preserving`, W)}
705
937
  ${boxLine(`all style and presentation attributes.`, W)}
706
- ${boxLine('', W)}
707
- ${boxHeader('SUPPORTED SHAPES', W)}
708
- ${boxLine('', W)}
938
+ ${boxLine("", W)}
939
+ ${boxHeader("SUPPORTED SHAPES", W)}
940
+ ${boxLine("", W)}
709
941
  ${boxLine(` ${colors.green}${B.dot} rect${colors.reset} ${B.arr} path (with optional rounded corners)`, W)}
710
942
  ${boxLine(` ${colors.green}${B.dot} circle${colors.reset} ${B.arr} path (4 cubic Bezier arcs)`, W)}
711
943
  ${boxLine(` ${colors.green}${B.dot} ellipse${colors.reset} ${B.arr} path (4 cubic Bezier arcs)`, W)}
712
944
  ${boxLine(` ${colors.green}${B.dot} line${colors.reset} ${B.arr} path (M...L command)`, W)}
713
945
  ${boxLine(` ${colors.green}${B.dot} polygon${colors.reset} ${B.arr} path (closed polyline)`, W)}
714
946
  ${boxLine(` ${colors.green}${B.dot} polyline${colors.reset} ${B.arr} path (open polyline)`, W)}
715
- ${boxLine('', W)}
716
- ${boxHeader('USAGE', W)}
717
- ${boxLine('', W)}
947
+ ${boxLine("", W)}
948
+ ${boxHeader("USAGE", W)}
949
+ ${boxLine("", W)}
718
950
  ${boxLine(` svg-matrix convert <input> -o <output> [-p <precision>]`, W)}
719
- ${boxLine('', W)}
720
- ${boxHeader('EXAMPLE', W)}
721
- ${boxLine('', W)}
951
+ ${boxLine("", W)}
952
+ ${boxHeader("EXAMPLE", W)}
953
+ ${boxLine("", W)}
722
954
  ${boxLine(` svg-matrix convert shapes.svg -o paths.svg --precision 8`, W)}
723
- ${boxLine('', W)}
955
+ ${boxLine("", W)}
724
956
  ${colors.cyan}${B.bl}${hr}${B.br}${colors.reset}
725
957
  `,
726
958
  normalize: `
727
959
  ${colors.cyan}${B.tl}${hr}${B.tr}${colors.reset}
728
960
  ${boxLine(`${colors.bright}svg-matrix normalize${colors.reset} - Path Normalization`, W)}
729
961
  ${boxDivider(W)}
730
- ${boxLine('', W)}
962
+ ${boxLine("", W)}
731
963
  ${boxLine(`Converts all path commands to absolute cubic Bezier curves.`, W)}
732
964
  ${boxLine(`Ideal for path morphing, animation, and consistent processing.`, W)}
733
- ${boxLine('', W)}
734
- ${boxHeader('WHAT IT DOES', W)}
735
- ${boxLine('', W)}
965
+ ${boxLine("", W)}
966
+ ${boxHeader("WHAT IT DOES", W)}
967
+ ${boxLine("", W)}
736
968
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Converts relative commands (m,l,c,s,q,t,a) to absolute`, W)}
737
969
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Converts all curves to cubic Beziers (C commands)`, W)}
738
970
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Expands shorthand (S,T) to full curves`, W)}
739
971
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Converts arcs (A) to cubic Bezier approximations`, W)}
740
972
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Converts lines (L,H,V) to degenerate cubics`, W)}
741
- ${boxLine('', W)}
742
- ${boxHeader('OUTPUT FORMAT', W)}
743
- ${boxLine('', W)}
973
+ ${boxLine("", W)}
974
+ ${boxHeader("OUTPUT FORMAT", W)}
975
+ ${boxLine("", W)}
744
976
  ${boxLine(` All paths become: M x y C x1 y1 x2 y2 x y C ... Z`, W)}
745
- ${boxLine('', W)}
746
- ${boxHeader('USAGE', W)}
747
- ${boxLine('', W)}
977
+ ${boxLine("", W)}
978
+ ${boxHeader("USAGE", W)}
979
+ ${boxLine("", W)}
748
980
  ${boxLine(` svg-matrix normalize <input> -o <output>`, W)}
749
- ${boxLine('', W)}
750
- ${boxHeader('EXAMPLE', W)}
751
- ${boxLine('', W)}
981
+ ${boxLine("", W)}
982
+ ${boxHeader("EXAMPLE", W)}
983
+ ${boxLine("", W)}
752
984
  ${boxLine(` svg-matrix normalize complex.svg -o normalized.svg`, W)}
753
- ${boxLine('', W)}
985
+ ${boxLine("", W)}
754
986
  ${colors.cyan}${B.bl}${hr}${B.br}${colors.reset}
755
987
  `,
756
988
  info: `
757
989
  ${colors.cyan}${B.tl}${hr}${B.tr}${colors.reset}
758
990
  ${boxLine(`${colors.bright}svg-matrix info${colors.reset} - SVG File Information`, W)}
759
991
  ${boxDivider(W)}
760
- ${boxLine('', W)}
992
+ ${boxLine("", W)}
761
993
  ${boxLine(`Displays detailed information about an SVG file including`, W)}
762
994
  ${boxLine(`dimensions, element counts, and structure analysis.`, W)}
763
- ${boxLine('', W)}
764
- ${boxHeader('INFORMATION SHOWN', W)}
765
- ${boxLine('', W)}
995
+ ${boxLine("", W)}
996
+ ${boxHeader("INFORMATION SHOWN", W)}
997
+ ${boxLine("", W)}
766
998
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} File path and size`, W)}
767
999
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Dimensions (viewBox, width, height)`, W)}
768
1000
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Element counts (paths, shapes, groups)`, W)}
769
1001
  ${boxLine(` ${colors.green}${B.dot}${colors.reset} Transform attribute count`, W)}
770
- ${boxLine('', W)}
771
- ${boxHeader('USAGE', W)}
772
- ${boxLine('', W)}
1002
+ ${boxLine("", W)}
1003
+ ${boxHeader("USAGE", W)}
1004
+ ${boxLine("", W)}
773
1005
  ${boxLine(` svg-matrix info <input>`, W)}
774
1006
  ${boxLine(` svg-matrix info <folder> -r ${colors.dim}# recursive${colors.reset}`, W)}
775
- ${boxLine('', W)}
776
- ${boxHeader('EXAMPLE', W)}
777
- ${boxLine('', W)}
1007
+ ${boxLine("", W)}
1008
+ ${boxHeader("EXAMPLE", W)}
1009
+ ${boxLine("", W)}
778
1010
  ${boxLine(` svg-matrix info logo.svg`, W)}
779
- ${boxLine('', W)}
1011
+ ${boxLine("", W)}
780
1012
  ${colors.cyan}${B.bl}${hr}${B.br}${colors.reset}
781
- `
1013
+ `,
782
1014
  };
783
1015
 
784
1016
  if (commandHelp[command]) {
@@ -788,7 +1020,13 @@ ${colors.cyan}${B.bl}${hr}${B.br}${colors.reset}
788
1020
  }
789
1021
  }
790
1022
 
791
- function showVersion() { console.log(`@emasoft/svg-matrix v${VERSION}`); }
1023
+ /**
1024
+ * Display version number.
1025
+ * @returns {void}
1026
+ */
1027
+ function showVersion() {
1028
+ console.log(`@emasoft/svg-matrix v${VERSION}`);
1029
+ }
792
1030
 
793
1031
  /**
794
1032
  * Extract transform attribute value from element attributes string.
@@ -806,7 +1044,7 @@ function extractTransform(attrs) {
806
1044
  * @returns {string} Attributes without transform
807
1045
  */
808
1046
  function removeTransform(attrs) {
809
- return attrs.replace(/\s*transform\s*=\s*["'][^"']*["']/gi, '');
1047
+ return attrs.replace(/\s*transform\s*=\s*["'][^"']*["']/gi, "");
810
1048
  }
811
1049
 
812
1050
  /**
@@ -850,7 +1088,7 @@ function replacePathD(attrs, newD) {
850
1088
  function processFlatten(inputPath, outputPath) {
851
1089
  try {
852
1090
  logDebug(`Processing: ${inputPath}`);
853
- const svgContent = readFileSync(inputPath, 'utf8');
1091
+ const svgContent = readFileSync(inputPath, "utf8");
854
1092
 
855
1093
  // Use legacy transform-only mode if requested
856
1094
  if (config.transformOnly) {
@@ -861,9 +1099,9 @@ function processFlatten(inputPath, outputPath) {
861
1099
  const pipelineOptions = {
862
1100
  precision: config.precision,
863
1101
  curveSegments: 20,
864
- clipSegments: config.clipSegments, // Higher segments for clip accuracy (default 64)
865
- bezierArcs: config.bezierArcs, // Bezier arcs for circles/ellipses (default 16)
866
- e2eTolerance: config.e2eTolerance, // Configurable E2E tolerance (default 1e-10)
1102
+ clipSegments: config.clipSegments, // Higher segments for clip accuracy (default 64)
1103
+ bezierArcs: config.bezierArcs, // Bezier arcs for circles/ellipses (default 8)
1104
+ e2eTolerance: config.e2eTolerance, // Configurable E2E tolerance (default 1e-10)
867
1105
  resolveUse: config.resolveUse,
868
1106
  resolveMarkers: config.resolveMarkers,
869
1107
  resolvePatterns: config.resolvePatterns,
@@ -873,26 +1111,35 @@ function processFlatten(inputPath, outputPath) {
873
1111
  bakeGradients: config.bakeGradients,
874
1112
  removeUnusedDefs: true,
875
1113
  preserveNamespaces: config.preserveNamespaces, // Pass namespace preservation to pipeline
1114
+ svg2Polyfills: config.svg2Polyfills, // Pass SVG 2 polyfills flag to pipeline
876
1115
  // NOTE: Verification is ALWAYS enabled - precision is non-negotiable
877
1116
  };
878
1117
 
879
1118
  // Run the full flatten pipeline
880
- const { svg: flattenedSvg, stats } = FlattenPipeline.flattenSVG(svgContent, pipelineOptions);
1119
+ const { svg: flattenedSvg, stats } = FlattenPipeline.flattenSVG(
1120
+ svgContent,
1121
+ pipelineOptions,
1122
+ );
881
1123
 
882
1124
  // Report statistics
883
1125
  const parts = [];
884
- if (stats.transformsFlattened > 0) parts.push(`${stats.transformsFlattened} transforms`);
1126
+ if (stats.transformsFlattened > 0)
1127
+ parts.push(`${stats.transformsFlattened} transforms`);
885
1128
  if (stats.useResolved > 0) parts.push(`${stats.useResolved} use`);
886
- if (stats.markersResolved > 0) parts.push(`${stats.markersResolved} markers`);
887
- if (stats.patternsResolved > 0) parts.push(`${stats.patternsResolved} patterns`);
1129
+ if (stats.markersResolved > 0)
1130
+ parts.push(`${stats.markersResolved} markers`);
1131
+ if (stats.patternsResolved > 0)
1132
+ parts.push(`${stats.patternsResolved} patterns`);
888
1133
  if (stats.masksResolved > 0) parts.push(`${stats.masksResolved} masks`);
889
- if (stats.clipPathsApplied > 0) parts.push(`${stats.clipPathsApplied} clipPaths`);
890
- if (stats.gradientsProcessed > 0) parts.push(`${stats.gradientsProcessed} gradients`);
1134
+ if (stats.clipPathsApplied > 0)
1135
+ parts.push(`${stats.clipPathsApplied} clipPaths`);
1136
+ if (stats.gradientsProcessed > 0)
1137
+ parts.push(`${stats.gradientsProcessed} gradients`);
891
1138
 
892
1139
  if (parts.length > 0) {
893
- logInfo(`Flattened: ${parts.join(', ')}`);
1140
+ logInfo(`Flattened: ${parts.join(", ")}`);
894
1141
  } else {
895
- logInfo('No transform dependencies found');
1142
+ logInfo("No transform dependencies found");
896
1143
  }
897
1144
 
898
1145
  // Report verification results (ALWAYS - precision is non-negotiable)
@@ -908,28 +1155,46 @@ function processFlatten(inputPath, outputPath) {
908
1155
  // Show detailed results in verbose mode
909
1156
  if (config.verbose) {
910
1157
  if (v.matrices.length > 0) {
911
- logDebug(` Matrix verifications: ${v.matrices.filter(m => m.valid).length}/${v.matrices.length} passed`);
1158
+ logDebug(
1159
+ ` Matrix verifications: ${v.matrices.filter((m) => m.valid).length}/${v.matrices.length} passed`,
1160
+ );
912
1161
  }
913
1162
  if (v.transforms.length > 0) {
914
- logDebug(` Transform round-trips: ${v.transforms.filter(t => t.valid).length}/${v.transforms.length} passed`);
1163
+ logDebug(
1164
+ ` Transform round-trips: ${v.transforms.filter((t) => t.valid).length}/${v.transforms.length} passed`,
1165
+ );
915
1166
  }
916
1167
  if (v.polygons.length > 0) {
917
- logDebug(` Polygon intersections: ${v.polygons.filter(p => p.valid).length}/${v.polygons.length} passed`);
1168
+ logDebug(
1169
+ ` Polygon intersections: ${v.polygons.filter((p) => p.valid).length}/${v.polygons.length} passed`,
1170
+ );
918
1171
  }
919
1172
  if (v.gradients.length > 0) {
920
- logDebug(` Gradient transforms: ${v.gradients.filter(g => g.valid).length}/${v.gradients.length} passed`);
1173
+ logDebug(
1174
+ ` Gradient transforms: ${v.gradients.filter((g) => g.valid).length}/${v.gradients.length} passed`,
1175
+ );
921
1176
  }
922
1177
  if (v.e2e && v.e2e.length > 0) {
923
- logDebug(` E2E area conservation: ${v.e2e.filter(e => e.valid).length}/${v.e2e.length} passed`);
1178
+ logDebug(
1179
+ ` E2E area conservation: ${v.e2e.filter((e) => e.valid).length}/${v.e2e.length} passed`,
1180
+ );
924
1181
  }
925
1182
  }
926
1183
 
927
1184
  // Always show failed verifications (not just in verbose mode)
928
- const allVerifications = [...v.matrices, ...v.transforms, ...v.polygons, ...v.gradients, ...(v.e2e || [])];
929
- const failed = allVerifications.filter(vr => !vr.valid);
1185
+ const allVerifications = [
1186
+ ...v.matrices,
1187
+ ...v.transforms,
1188
+ ...v.polygons,
1189
+ ...v.gradients,
1190
+ ...(v.e2e || []),
1191
+ ];
1192
+ const failed = allVerifications.filter((vr) => !vr.valid);
930
1193
  if (failed.length > 0) {
931
1194
  for (const f of failed.slice(0, 3)) {
932
- logError(`${colors.red}VERIFICATION FAILED:${colors.reset} ${f.message}`);
1195
+ logError(
1196
+ `${colors.red}VERIFICATION FAILED:${colors.reset} ${f.message}`,
1197
+ );
933
1198
  }
934
1199
  if (failed.length > 3) {
935
1200
  logError(`...and ${failed.length - 3} more failed verifications`);
@@ -950,7 +1215,7 @@ function processFlatten(inputPath, outputPath) {
950
1215
 
951
1216
  if (!config.dryRun) {
952
1217
  ensureDir(dirname(outputPath));
953
- writeFileSync(outputPath, flattenedSvg, 'utf8');
1218
+ writeFileSync(outputPath, flattenedSvg, "utf8");
954
1219
  }
955
1220
  logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
956
1221
  return true;
@@ -983,12 +1248,14 @@ function processFlattenLegacy(inputPath, outputPath, svgContent) {
983
1248
 
984
1249
  try {
985
1250
  const ctm = SVGFlatten.parseTransformAttribute(transform);
986
- const transformedD = SVGFlatten.transformPathData(pathD, ctm, { precision: config.precision });
1251
+ const transformedD = SVGFlatten.transformPathData(pathD, ctm, {
1252
+ precision: config.precision,
1253
+ });
987
1254
  const newAttrs = removeTransform(replacePathD(attrs, transformedD));
988
1255
  transformCount++;
989
1256
  pathCount++;
990
1257
  logDebug(`Flattened path transform: ${transform}`);
991
- return `<path ${newAttrs.trim()}${match.endsWith('/>') ? '/>' : '>'}`;
1258
+ return `<path ${newAttrs.trim()}${match.endsWith("/>") ? "/>" : ">"}`;
992
1259
  } catch (e) {
993
1260
  logWarn(`Failed to flatten path: ${e.message}`);
994
1261
  return match;
@@ -996,10 +1263,17 @@ function processFlattenLegacy(inputPath, outputPath, svgContent) {
996
1263
  });
997
1264
 
998
1265
  // Step 2: Convert shapes with transforms to flattened paths
999
- const shapeTypes = ['rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline'];
1266
+ const shapeTypes = [
1267
+ "rect",
1268
+ "circle",
1269
+ "ellipse",
1270
+ "line",
1271
+ "polygon",
1272
+ "polyline",
1273
+ ];
1000
1274
 
1001
1275
  for (const shapeType of shapeTypes) {
1002
- const shapeRegex = new RegExp(`<${shapeType}([^>]*)\\/>`, 'gi');
1276
+ const shapeRegex = new RegExp(`<${shapeType}([^>]*)\\/>`, "gi");
1003
1277
 
1004
1278
  result = result.replace(shapeRegex, (match, attrs) => {
1005
1279
  const transform = extractTransform(attrs);
@@ -1014,13 +1288,18 @@ function processFlattenLegacy(inputPath, outputPath, svgContent) {
1014
1288
  }
1015
1289
 
1016
1290
  const ctm = SVGFlatten.parseTransformAttribute(transform);
1017
- const transformedD = SVGFlatten.transformPathData(pathD, ctm, { precision: config.precision });
1291
+ const transformedD = SVGFlatten.transformPathData(pathD, ctm, {
1292
+ precision: config.precision,
1293
+ });
1018
1294
  const attrsToRemove = getShapeSpecificAttrs(shapeType);
1019
- const styleAttrs = removeShapeAttrs(removeTransform(attrs), attrsToRemove);
1295
+ const styleAttrs = removeShapeAttrs(
1296
+ removeTransform(attrs),
1297
+ attrsToRemove,
1298
+ );
1020
1299
  transformCount++;
1021
1300
  shapeCount++;
1022
1301
  logDebug(`Flattened ${shapeType} transform: ${transform}`);
1023
- return `<path d="${transformedD}"${styleAttrs ? ' ' + styleAttrs : ''}/>`;
1302
+ return `<path d="${transformedD}"${styleAttrs ? " " + styleAttrs : ""}/>`;
1024
1303
  } catch (e) {
1025
1304
  logWarn(`Failed to flatten ${shapeType}: ${e.message}`);
1026
1305
  return match;
@@ -1041,33 +1320,47 @@ function processFlattenLegacy(inputPath, outputPath, svgContent) {
1041
1320
  let modifiedContent = content;
1042
1321
  let childrenModified = false;
1043
1322
 
1044
- modifiedContent = modifiedContent.replace(/<path\s+([^>]*?)\s*\/?>/gi, (pathMatch, pathAttrs) => {
1045
- const pathD = extractPathD(pathAttrs);
1046
- if (!pathD) return pathMatch;
1323
+ modifiedContent = modifiedContent.replace(
1324
+ /<path\s+([^>]*?)\s*\/?>/gi,
1325
+ (pathMatch, pathAttrs) => {
1326
+ const pathD = extractPathD(pathAttrs);
1327
+ if (!pathD) return pathMatch;
1047
1328
 
1048
- try {
1049
- const childTransform = extractTransform(pathAttrs);
1050
- let combinedCtm = groupCtm;
1329
+ try {
1330
+ const childTransform = extractTransform(pathAttrs);
1331
+ let combinedCtm = groupCtm;
1051
1332
 
1052
- if (childTransform) {
1053
- const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
1054
- combinedCtm = groupCtm.mul(childCtm);
1055
- }
1333
+ if (childTransform) {
1334
+ const childCtm =
1335
+ SVGFlatten.parseTransformAttribute(childTransform);
1336
+ combinedCtm = groupCtm.mul(childCtm);
1337
+ }
1056
1338
 
1057
- const transformedD = SVGFlatten.transformPathData(pathD, combinedCtm, { precision: config.precision });
1058
- const newAttrs = removeTransform(replacePathD(pathAttrs, transformedD));
1059
- childrenModified = true;
1060
- transformCount++;
1061
- return `<path ${newAttrs.trim()}${pathMatch.endsWith('/>') ? '/>' : '>'}`;
1062
- } catch (e) {
1063
- logWarn(`Failed to apply group transform to path: ${e.message}`);
1064
- return pathMatch;
1065
- }
1066
- });
1339
+ const transformedD = SVGFlatten.transformPathData(
1340
+ pathD,
1341
+ combinedCtm,
1342
+ { precision: config.precision },
1343
+ );
1344
+ const newAttrs = removeTransform(
1345
+ replacePathD(pathAttrs, transformedD),
1346
+ );
1347
+ childrenModified = true;
1348
+ transformCount++;
1349
+ return `<path ${newAttrs.trim()}${pathMatch.endsWith("/>") ? "/>" : ">"}`;
1350
+ } catch (e) {
1351
+ logWarn(
1352
+ `Failed to apply group transform to path: ${e.message}`,
1353
+ );
1354
+ return pathMatch;
1355
+ }
1356
+ },
1357
+ );
1067
1358
 
1068
1359
  if (childrenModified) {
1069
1360
  const newGAttrs = removeTransform(gAttrs);
1070
- logDebug(`Propagated group transform to children: ${groupTransform}`);
1361
+ logDebug(
1362
+ `Propagated group transform to children: ${groupTransform}`,
1363
+ );
1071
1364
  return `<g${newGAttrs}>${modifiedContent}</g>`;
1072
1365
  }
1073
1366
  return match;
@@ -1075,7 +1368,7 @@ function processFlattenLegacy(inputPath, outputPath, svgContent) {
1075
1368
  logWarn(`Failed to process group: ${e.message}`);
1076
1369
  return match;
1077
1370
  }
1078
- }
1371
+ },
1079
1372
  );
1080
1373
 
1081
1374
  if (result === beforeResult) {
@@ -1084,31 +1377,50 @@ function processFlattenLegacy(inputPath, outputPath, svgContent) {
1084
1377
  groupIterations++;
1085
1378
  }
1086
1379
 
1087
- logInfo(`Flattened ${transformCount} transforms (${pathCount} paths, ${shapeCount} shapes) [legacy mode]`);
1380
+ logInfo(
1381
+ `Flattened ${transformCount} transforms (${pathCount} paths, ${shapeCount} shapes) [legacy mode]`,
1382
+ );
1088
1383
 
1089
1384
  if (!config.dryRun) {
1090
1385
  ensureDir(dirname(outputPath));
1091
- writeFileSync(outputPath, result, 'utf8');
1386
+ writeFileSync(outputPath, result, "utf8");
1092
1387
  }
1093
1388
  logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
1094
1389
  return true;
1095
1390
  }
1096
1391
 
1392
+ /**
1393
+ * Convert all basic shapes to paths.
1394
+ * @param {string} inputPath - Input file path
1395
+ * @param {string} outputPath - Output file path
1396
+ * @returns {boolean} True if successful
1397
+ */
1097
1398
  function processConvert(inputPath, outputPath) {
1098
1399
  try {
1099
1400
  logDebug(`Converting: ${inputPath}`);
1100
- let result = readFileSync(inputPath, 'utf8');
1401
+ let result = readFileSync(inputPath, "utf8");
1101
1402
 
1102
1403
  // Convert all shape types to paths
1103
- const shapeTypes = ['rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline'];
1404
+ const shapeTypes = [
1405
+ "rect",
1406
+ "circle",
1407
+ "ellipse",
1408
+ "line",
1409
+ "polygon",
1410
+ "polyline",
1411
+ ];
1104
1412
 
1105
1413
  for (const shapeType of shapeTypes) {
1106
- const shapeRegex = new RegExp(`<${shapeType}([^>]*)\\/>`, 'gi');
1414
+ const shapeRegex = new RegExp(`<${shapeType}([^>]*)\\/>`, "gi");
1107
1415
 
1108
1416
  result = result.replace(shapeRegex, (match, attrs) => {
1109
1417
  try {
1110
1418
  // Extract shape as path using helper
1111
- const pathData = extractShapeAsPath(shapeType, attrs, config.precision);
1419
+ const pathData = extractShapeAsPath(
1420
+ shapeType,
1421
+ attrs,
1422
+ config.precision,
1423
+ );
1112
1424
 
1113
1425
  if (!pathData) {
1114
1426
  return match; // Couldn't convert to path
@@ -1118,7 +1430,7 @@ function processConvert(inputPath, outputPath) {
1118
1430
  const attrsToRemove = getShapeSpecificAttrs(shapeType);
1119
1431
  const otherAttrs = removeShapeAttrs(attrs, attrsToRemove);
1120
1432
 
1121
- return `<path d="${pathData}"${otherAttrs ? ' ' + otherAttrs : ''}/>`;
1433
+ return `<path d="${pathData}"${otherAttrs ? " " + otherAttrs : ""}/>`;
1122
1434
  } catch (e) {
1123
1435
  logWarn(`Failed to convert ${shapeType}: ${e.message}`);
1124
1436
  return match;
@@ -1128,7 +1440,7 @@ function processConvert(inputPath, outputPath) {
1128
1440
 
1129
1441
  if (!config.dryRun) {
1130
1442
  ensureDir(dirname(outputPath));
1131
- writeFileSync(outputPath, result, 'utf8');
1443
+ writeFileSync(outputPath, result, "utf8");
1132
1444
  }
1133
1445
  logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
1134
1446
  return true;
@@ -1138,21 +1450,29 @@ function processConvert(inputPath, outputPath) {
1138
1450
  }
1139
1451
  }
1140
1452
 
1453
+ /**
1454
+ * Normalize all path commands to absolute cubic Beziers.
1455
+ * @param {string} inputPath - Input file path
1456
+ * @param {string} outputPath - Output file path
1457
+ * @returns {boolean} True if successful
1458
+ */
1141
1459
  function processNormalize(inputPath, outputPath) {
1142
1460
  try {
1143
1461
  logDebug(`Normalizing: ${inputPath}`);
1144
- let result = readFileSync(inputPath, 'utf8');
1462
+ let result = readFileSync(inputPath, "utf8");
1145
1463
 
1146
1464
  result = result.replace(/d\s*=\s*["']([^"']+)["']/gi, (match, pathData) => {
1147
1465
  try {
1148
1466
  const normalized = GeometryToPath.pathToCubics(pathData);
1149
1467
  return `d="${normalized}"`;
1150
- } catch { return match; }
1468
+ } catch {
1469
+ return match;
1470
+ }
1151
1471
  });
1152
1472
 
1153
1473
  if (!config.dryRun) {
1154
1474
  ensureDir(dirname(outputPath));
1155
- writeFileSync(outputPath, result, 'utf8');
1475
+ writeFileSync(outputPath, result, "utf8");
1156
1476
  }
1157
1477
  logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
1158
1478
  return true;
@@ -1162,12 +1482,19 @@ function processNormalize(inputPath, outputPath) {
1162
1482
  }
1163
1483
  }
1164
1484
 
1485
+ /**
1486
+ * Display information about SVG file.
1487
+ * @param {string} inputPath - Input file path
1488
+ * @returns {boolean} True if successful
1489
+ */
1165
1490
  function processInfo(inputPath) {
1166
1491
  try {
1167
- const svg = readFileSync(inputPath, 'utf8');
1168
- const vb = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i)?.[1] || 'not set';
1169
- const w = svg.match(/<svg[^>]*\swidth\s*=\s*["']([^"']+)["']/i)?.[1] || 'not set';
1170
- const h = svg.match(/<svg[^>]*\sheight\s*=\s*["']([^"']+)["']/i)?.[1] || 'not set';
1492
+ const svg = readFileSync(inputPath, "utf8");
1493
+ const vb = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i)?.[1] || "not set";
1494
+ const w =
1495
+ svg.match(/<svg[^>]*\swidth\s*=\s*["']([^"']+)["']/i)?.[1] || "not set";
1496
+ const h =
1497
+ svg.match(/<svg[^>]*\sheight\s*=\s*["']([^"']+)["']/i)?.[1] || "not set";
1171
1498
 
1172
1499
  console.log(`
1173
1500
  ${colors.cyan}File:${colors.reset} ${inputPath}
@@ -1194,41 +1521,139 @@ ${colors.bright}Elements:${colors.reset}
1194
1521
 
1195
1522
  // All testable functions from svg-toolbox.js organized by category
1196
1523
  const TOOLBOX_FUNCTIONS = {
1197
- cleanup: ['cleanupIds', 'cleanupNumericValues', 'cleanupListOfValues', 'cleanupAttributes', 'cleanupEnableBackground'],
1198
- remove: ['removeUnknownsAndDefaults', 'removeNonInheritableGroupAttrs', 'removeUselessDefs', 'removeHiddenElements', 'removeEmptyText', 'removeEmptyContainers', 'removeDoctype', 'removeXMLProcInst', 'removeComments', 'removeMetadata', 'removeTitle', 'removeDesc', 'removeEditorsNSData', 'removeEmptyAttrs', 'removeViewBox', 'removeXMLNS', 'removeRasterImages', 'removeScriptElement', 'removeStyleElement', 'removeXlink', 'removeDimensions', 'removeAttrs', 'removeElementsByAttr', 'removeAttributesBySelector', 'removeOffCanvasPath'],
1199
- convert: ['convertShapesToPath', 'convertPathData', 'convertTransform', 'convertColors', 'convertStyleToAttrs', 'convertEllipseToCircle'],
1200
- collapse: ['collapseGroups', 'mergePaths'],
1201
- move: ['moveGroupAttrsToElems', 'moveElemsAttrsToGroup'],
1202
- style: ['minifyStyles', 'inlineStyles'],
1203
- sort: ['sortAttrs', 'sortDefsChildren'],
1204
- pathOptimization: ['optimizePaths', 'simplifyPaths', 'simplifyPath', 'reusePaths'],
1205
- flatten: ['flattenClipPaths', 'flattenMasks', 'flattenGradients', 'flattenPatterns', 'flattenFilters', 'flattenUseElements', 'flattenAll'],
1206
- transform: ['decomposeTransform'],
1207
- other: ['addAttributesToSVGElement', 'addClassesToSVGElement', 'prefixIds', 'optimizeAnimationTiming'],
1208
- validation: ['validateXML', 'validateSVG', 'fixInvalidSvg'],
1209
- detection: ['detectCollisions', 'measureDistance'],
1524
+ cleanup: [
1525
+ "cleanupIds",
1526
+ "cleanupNumericValues",
1527
+ "cleanupListOfValues",
1528
+ "cleanupAttributes",
1529
+ "cleanupEnableBackground",
1530
+ ],
1531
+ remove: [
1532
+ "removeUnknownsAndDefaults",
1533
+ "removeNonInheritableGroupAttrs",
1534
+ "removeUselessDefs",
1535
+ "removeHiddenElements",
1536
+ "removeEmptyText",
1537
+ "removeEmptyContainers",
1538
+ "removeDoctype",
1539
+ "removeXMLProcInst",
1540
+ "removeComments",
1541
+ "removeMetadata",
1542
+ "removeTitle",
1543
+ "removeDesc",
1544
+ "removeEditorsNSData",
1545
+ "removeEmptyAttrs",
1546
+ "removeViewBox",
1547
+ "removeXMLNS",
1548
+ "removeRasterImages",
1549
+ "removeScriptElement",
1550
+ "removeStyleElement",
1551
+ "removeXlink",
1552
+ "removeDimensions",
1553
+ "removeAttrs",
1554
+ "removeElementsByAttr",
1555
+ "removeAttributesBySelector",
1556
+ "removeOffCanvasPath",
1557
+ ],
1558
+ convert: [
1559
+ "convertShapesToPath",
1560
+ "convertPathData",
1561
+ "convertTransform",
1562
+ "convertColors",
1563
+ "convertStyleToAttrs",
1564
+ "convertEllipseToCircle",
1565
+ ],
1566
+ collapse: ["collapseGroups", "mergePaths"],
1567
+ move: ["moveGroupAttrsToElems", "moveElemsAttrsToGroup"],
1568
+ style: ["minifyStyles", "inlineStyles"],
1569
+ sort: ["sortAttrs", "sortDefsChildren"],
1570
+ pathOptimization: [
1571
+ "optimizePaths",
1572
+ "simplifyPaths",
1573
+ "simplifyPath",
1574
+ "reusePaths",
1575
+ ],
1576
+ flatten: [
1577
+ "flattenClipPaths",
1578
+ "flattenMasks",
1579
+ "flattenGradients",
1580
+ "flattenPatterns",
1581
+ "flattenFilters",
1582
+ "flattenUseElements",
1583
+ "flattenAll",
1584
+ ],
1585
+ transform: ["decomposeTransform"],
1586
+ other: [
1587
+ "addAttributesToSVGElement",
1588
+ "addClassesToSVGElement",
1589
+ "prefixIds",
1590
+ "optimizeAnimationTiming",
1591
+ ],
1592
+ validation: ["validateXML", "validateSVG", "fixInvalidSvg"],
1593
+ detection: ["detectCollisions", "measureDistance"],
1210
1594
  };
1211
1595
 
1212
- const SKIP_TOOLBOX_FUNCTIONS = ['imageToPath', 'detectCollisions', 'measureDistance'];
1596
+ const SKIP_TOOLBOX_FUNCTIONS = [
1597
+ "imageToPath",
1598
+ "detectCollisions",
1599
+ "measureDistance",
1600
+ ];
1213
1601
 
1602
+ /**
1603
+ * Generate timestamp string for file naming.
1604
+ * @returns {string} Timestamp in YYYYMMDD_HHMMSS format
1605
+ */
1214
1606
  function getTimestamp() {
1215
1607
  const now = new Date();
1216
- const pad = (n) => String(n).padStart(2, '0');
1608
+ const pad = (n) => String(n).padStart(2, "0");
1217
1609
  return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
1218
1610
  }
1219
1611
 
1220
- async function testToolboxFunction(fnName, originalContent, originalSize, outputDir) {
1221
- const result = { name: fnName, status: 'unknown', outputSize: 0, sizeDiff: 0, sizeDiffPercent: 0, error: null, outputFile: null, timeMs: 0 };
1612
+ /**
1613
+ * Test single svg-toolbox function on SVG content.
1614
+ * @param {string} fnName - Function name to test
1615
+ * @param {string} originalContent - Original SVG content
1616
+ * @param {number} originalSize - Original content size in bytes
1617
+ * @param {string} outputDir - Output directory for results
1618
+ * @returns {Promise<Object>} Test result with status and metrics
1619
+ */
1620
+ async function testToolboxFunction(
1621
+ fnName,
1622
+ originalContent,
1623
+ originalSize,
1624
+ outputDir,
1625
+ ) {
1626
+ const result = {
1627
+ name: fnName,
1628
+ status: "unknown",
1629
+ outputSize: 0,
1630
+ sizeDiff: 0,
1631
+ sizeDiffPercent: 0,
1632
+ error: null,
1633
+ outputFile: null,
1634
+ timeMs: 0,
1635
+ };
1222
1636
 
1223
1637
  try {
1224
1638
  const fn = SVGToolbox[fnName];
1225
- if (!fn) { result.status = 'not_found'; result.error = `Function not found`; return result; }
1226
- if (SKIP_TOOLBOX_FUNCTIONS.includes(fnName)) { result.status = 'skipped'; result.error = 'Requires special handling'; return result; }
1639
+ if (!fn) {
1640
+ result.status = "not_found";
1641
+ result.error = `Function not found`;
1642
+ return result;
1643
+ }
1644
+ if (SKIP_TOOLBOX_FUNCTIONS.includes(fnName)) {
1645
+ result.status = "skipped";
1646
+ result.error = "Requires special handling";
1647
+ return result;
1648
+ }
1227
1649
 
1228
1650
  const startTime = Date.now();
1229
1651
  const doc = parseSVG(originalContent);
1230
1652
  // Pass preserveVendor and preserveNamespaces options from config to toolbox functions
1231
- await fn(doc, { preserveVendor: config.preserveVendor, preserveNamespaces: config.preserveNamespaces });
1653
+ await fn(doc, {
1654
+ preserveVendor: config.preserveVendor,
1655
+ preserveNamespaces: config.preserveNamespaces,
1656
+ });
1232
1657
  const output = serializeSVG(doc);
1233
1658
  const outputSize = Buffer.byteLength(output);
1234
1659
  result.timeMs = Date.now() - startTime;
@@ -1236,24 +1661,40 @@ async function testToolboxFunction(fnName, originalContent, originalSize, output
1236
1661
  const outputFile = join(outputDir, `${fnName}.svg`);
1237
1662
  writeFileSync(outputFile, output);
1238
1663
 
1239
- result.status = 'success';
1664
+ result.status = "success";
1240
1665
  result.outputSize = outputSize;
1241
1666
  result.sizeDiff = outputSize - originalSize;
1242
- result.sizeDiffPercent = ((outputSize - originalSize) / originalSize * 100).toFixed(1);
1667
+ result.sizeDiffPercent = (
1668
+ ((outputSize - originalSize) / originalSize) *
1669
+ 100
1670
+ ).toFixed(1);
1243
1671
  result.outputFile = outputFile;
1244
1672
 
1245
- if (output.length < 100) { result.status = 'warning'; result.error = 'Output suspiciously small'; }
1673
+ if (output.length < 100) {
1674
+ result.status = "warning";
1675
+ result.error = "Output suspiciously small";
1676
+ }
1246
1677
  } catch (err) {
1247
- result.status = 'error';
1678
+ result.status = "error";
1248
1679
  result.error = err.message;
1249
1680
  }
1250
1681
  return result;
1251
1682
  }
1252
1683
 
1684
+ /**
1685
+ * Process test-toolbox command to test all svg-toolbox functions.
1686
+ * @returns {Promise<void>}
1687
+ */
1253
1688
  async function processTestToolbox() {
1254
1689
  const files = gatherInputFiles();
1255
- if (files.length === 0) { logError('No input file specified'); process.exit(CONSTANTS.EXIT_ERROR); }
1256
- if (files.length > 1) { logError('test-toolbox only accepts one input file'); process.exit(CONSTANTS.EXIT_ERROR); }
1690
+ if (files.length === 0) {
1691
+ logError("No input file specified");
1692
+ process.exit(CONSTANTS.EXIT_ERROR);
1693
+ }
1694
+ if (files.length > 1) {
1695
+ logError("test-toolbox only accepts one input file");
1696
+ process.exit(CONSTANTS.EXIT_ERROR);
1697
+ }
1257
1698
 
1258
1699
  const inputFile = files[0];
1259
1700
  validateSvgFile(inputFile);
@@ -1265,11 +1706,11 @@ async function processTestToolbox() {
1265
1706
  const outputDir = join(outputBase, outputDirName);
1266
1707
  mkdirSync(outputDir, { recursive: true });
1267
1708
 
1268
- console.log('\n' + '='.repeat(70));
1709
+ console.log("\n" + "=".repeat(70));
1269
1710
  console.log(`${colors.bright}SVG-TOOLBOX FUNCTION TEST${colors.reset}`);
1270
- console.log('='.repeat(70) + '\n');
1711
+ console.log("=".repeat(70) + "\n");
1271
1712
 
1272
- const originalContent = readFileSync(inputFile, 'utf8');
1713
+ const originalContent = readFileSync(inputFile, "utf8");
1273
1714
  const originalSize = Buffer.byteLength(originalContent);
1274
1715
 
1275
1716
  logInfo(`Input: ${colors.cyan}${inputFile}${colors.reset}`);
@@ -1277,24 +1718,39 @@ async function processTestToolbox() {
1277
1718
  logInfo(`Output: ${colors.cyan}${outputDir}/${colors.reset}\n`);
1278
1719
 
1279
1720
  const allResults = [];
1280
- let total = 0, success = 0, errors = 0, skipped = 0;
1721
+ let total = 0,
1722
+ success = 0,
1723
+ errors = 0,
1724
+ skipped = 0;
1281
1725
 
1282
1726
  for (const [category, functions] of Object.entries(TOOLBOX_FUNCTIONS)) {
1283
- console.log(`\n${colors.bright}--- ${category.toUpperCase()} ---${colors.reset}`);
1727
+ console.log(
1728
+ `\n${colors.bright}--- ${category.toUpperCase()} ---${colors.reset}`,
1729
+ );
1284
1730
  for (const fnName of functions) {
1285
1731
  total++;
1286
1732
  process.stdout.write(` Testing ${fnName.padEnd(30)}... `);
1287
- const result = await testToolboxFunction(fnName, originalContent, originalSize, outputDir);
1733
+ const result = await testToolboxFunction(
1734
+ fnName,
1735
+ originalContent,
1736
+ originalSize,
1737
+ outputDir,
1738
+ );
1288
1739
  allResults.push({ category, ...result });
1289
1740
 
1290
- if (result.status === 'success') {
1741
+ if (result.status === "success") {
1291
1742
  success++;
1292
- const sizeStr = result.sizeDiff >= 0 ? `+${result.sizeDiffPercent}%` : `${result.sizeDiffPercent}%`;
1293
- console.log(`${colors.green}OK${colors.reset} (${sizeStr}, ${result.timeMs}ms)`);
1294
- } else if (result.status === 'skipped' || result.status === 'not_found') {
1743
+ const sizeStr =
1744
+ result.sizeDiff >= 0
1745
+ ? `+${result.sizeDiffPercent}%`
1746
+ : `${result.sizeDiffPercent}%`;
1747
+ console.log(
1748
+ `${colors.green}OK${colors.reset} (${sizeStr}, ${result.timeMs}ms)`,
1749
+ );
1750
+ } else if (result.status === "skipped" || result.status === "not_found") {
1295
1751
  skipped++;
1296
1752
  console.log(`${colors.yellow}SKIP${colors.reset}: ${result.error}`);
1297
- } else if (result.status === 'warning') {
1753
+ } else if (result.status === "warning") {
1298
1754
  success++;
1299
1755
  console.log(`${colors.yellow}WARN${colors.reset}: ${result.error}`);
1300
1756
  } else {
@@ -1304,41 +1760,60 @@ async function processTestToolbox() {
1304
1760
  }
1305
1761
  }
1306
1762
 
1307
- console.log('\n' + '='.repeat(70));
1763
+ console.log("\n" + "=".repeat(70));
1308
1764
  console.log(`${colors.bright}SUMMARY${colors.reset}`);
1309
- console.log('='.repeat(70));
1310
- console.log(`Total: ${total} ${colors.green}Success: ${success}${colors.reset} ${colors.red}Errors: ${errors}${colors.reset} ${colors.yellow}Skipped: ${skipped}${colors.reset}`);
1765
+ console.log("=".repeat(70));
1766
+ console.log(
1767
+ `Total: ${total} ${colors.green}Success: ${success}${colors.reset} ${colors.red}Errors: ${errors}${colors.reset} ${colors.yellow}Skipped: ${skipped}${colors.reset}`,
1768
+ );
1311
1769
 
1312
- console.log('\n' + '-'.repeat(70));
1770
+ console.log("\n" + "-".repeat(70));
1313
1771
  console.log(`${colors.bright}TOP SIZE REDUCERS${colors.reset}`);
1314
- console.log('-'.repeat(70));
1315
-
1316
- const successResults = allResults.filter(r => r.status === 'success' || r.status === 'warning').sort((a, b) => a.sizeDiff - b.sizeDiff).slice(0, 10);
1317
- console.log('\nFunction Output Size Diff');
1318
- console.log('-'.repeat(60));
1772
+ console.log("-".repeat(70));
1773
+
1774
+ const successResults = allResults
1775
+ .filter((r) => r.status === "success" || r.status === "warning")
1776
+ .sort((a, b) => a.sizeDiff - b.sizeDiff)
1777
+ .slice(0, 10);
1778
+ console.log("\nFunction Output Size Diff");
1779
+ console.log("-".repeat(60));
1319
1780
  for (const r of successResults) {
1320
1781
  const name = r.name.padEnd(38);
1321
- const size = ((r.outputSize / 1024 / 1024).toFixed(2) + ' MB').padStart(10);
1322
- const diff = (r.sizeDiff >= 0 ? '+' : '') + r.sizeDiffPercent + '%';
1782
+ const size = ((r.outputSize / 1024 / 1024).toFixed(2) + " MB").padStart(10);
1783
+ const diff = (r.sizeDiff >= 0 ? "+" : "") + r.sizeDiffPercent + "%";
1323
1784
  console.log(`${name} ${size} ${diff}`);
1324
1785
  }
1325
1786
 
1326
1787
  if (errors > 0) {
1327
- console.log('\n' + '-'.repeat(70));
1788
+ console.log("\n" + "-".repeat(70));
1328
1789
  console.log(`${colors.red}${colors.bright}ERROR DETAILS${colors.reset}`);
1329
- console.log('-'.repeat(70));
1330
- for (const r of allResults.filter(r => r.status === 'error')) {
1790
+ console.log("-".repeat(70));
1791
+ for (const r of allResults.filter((item) => item.status === "error")) {
1331
1792
  console.log(`\n${colors.red}${r.name}:${colors.reset} ${r.error}`);
1332
1793
  }
1333
1794
  }
1334
1795
 
1335
- const resultsFile = join(outputDir, '_test-results.json');
1336
- writeFileSync(resultsFile, JSON.stringify({ timestamp: new Date().toISOString(), inputFile, originalSize, outputDir, summary: { total, success, errors, skipped }, results: allResults }, null, 2));
1337
-
1338
- console.log('\n' + '='.repeat(70));
1796
+ const resultsFile = join(outputDir, "_test-results.json");
1797
+ writeFileSync(
1798
+ resultsFile,
1799
+ JSON.stringify(
1800
+ {
1801
+ timestamp: new Date().toISOString(),
1802
+ inputFile,
1803
+ originalSize,
1804
+ outputDir,
1805
+ summary: { total, success, errors, skipped },
1806
+ results: allResults,
1807
+ },
1808
+ null,
1809
+ 2,
1810
+ ),
1811
+ );
1812
+
1813
+ console.log("\n" + "=".repeat(70));
1339
1814
  logInfo(`Results: ${colors.cyan}${resultsFile}${colors.reset}`);
1340
1815
  logInfo(`Output: ${colors.cyan}${outputDir}${colors.reset}`);
1341
- console.log('='.repeat(70) + '\n');
1816
+ console.log("=".repeat(70) + "\n");
1342
1817
 
1343
1818
  if (errors > 0) process.exit(CONSTANTS.EXIT_ERROR);
1344
1819
  }
@@ -1347,83 +1822,163 @@ async function processTestToolbox() {
1347
1822
  // ARGUMENT PARSING
1348
1823
  // ============================================================================
1349
1824
 
1825
+ /**
1826
+ * Parse command-line arguments.
1827
+ * @param {string[]} args - Command-line arguments
1828
+ * @returns {CLIConfig} Parsed configuration object
1829
+ */
1350
1830
  function parseArgs(args) {
1351
1831
  const cfg = { ...DEFAULT_CONFIG };
1352
1832
  const inputs = [];
1353
1833
  let i = 0;
1354
1834
 
1355
1835
  while (i < args.length) {
1356
- const arg = args[i];
1836
+ let arg = args[i];
1837
+ let argValue = null;
1838
+
1839
+ // Handle --arg=value format
1840
+ if (arg.includes("=") && arg.startsWith("--")) {
1841
+ const eqIdx = arg.indexOf("=");
1842
+ argValue = arg.substring(eqIdx + 1);
1843
+ arg = arg.substring(0, eqIdx);
1844
+ }
1845
+
1357
1846
  switch (arg) {
1358
- case '-o': case '--output': cfg.output = args[++i]; break;
1359
- case '-l': case '--list': cfg.listFile = args[++i]; break;
1360
- case '-r': case '--recursive': cfg.recursive = true; break;
1361
- case '-p': case '--precision': {
1847
+ case "-o":
1848
+ case "--output":
1849
+ cfg.output = args[++i];
1850
+ break;
1851
+ case "-l":
1852
+ case "--list":
1853
+ cfg.listFile = args[++i];
1854
+ break;
1855
+ case "-r":
1856
+ case "--recursive":
1857
+ cfg.recursive = true;
1858
+ break;
1859
+ case "-p":
1860
+ case "--precision": {
1362
1861
  const precision = parseInt(args[++i], 10);
1363
- if (isNaN(precision) || precision < CONSTANTS.MIN_PRECISION || precision > CONSTANTS.MAX_PRECISION) {
1364
- logError(`Precision must be between ${CONSTANTS.MIN_PRECISION} and ${CONSTANTS.MAX_PRECISION}`);
1862
+ if (
1863
+ isNaN(precision) ||
1864
+ precision < CONSTANTS.MIN_PRECISION ||
1865
+ precision > CONSTANTS.MAX_PRECISION
1866
+ ) {
1867
+ logError(
1868
+ `Precision must be between ${CONSTANTS.MIN_PRECISION} and ${CONSTANTS.MAX_PRECISION}`,
1869
+ );
1365
1870
  process.exit(CONSTANTS.EXIT_ERROR);
1366
1871
  }
1367
1872
  cfg.precision = precision;
1368
1873
  break;
1369
1874
  }
1370
- case '-f': case '--force': cfg.overwrite = true; break;
1371
- case '-n': case '--dry-run': cfg.dryRun = true; break;
1372
- case '-q': case '--quiet': cfg.quiet = true; break;
1373
- case '-v': case '--verbose': cfg.verbose = true; break;
1374
- case '--log-file': cfg.logFile = args[++i]; break;
1375
- case '-h': case '--help':
1875
+ case "-f":
1876
+ case "--force":
1877
+ cfg.overwrite = true;
1878
+ break;
1879
+ case "-n":
1880
+ case "--dry-run":
1881
+ cfg.dryRun = true;
1882
+ break;
1883
+ case "-q":
1884
+ case "--quiet":
1885
+ cfg.quiet = true;
1886
+ break;
1887
+ case "-v":
1888
+ case "--verbose":
1889
+ cfg.verbose = true;
1890
+ break;
1891
+ case "--log-file":
1892
+ cfg.logFile = args[++i];
1893
+ break;
1894
+ case "-h":
1895
+ case "--help":
1376
1896
  // If a command is already set (not 'help'), show command-specific help
1377
- if (cfg.command !== 'help' && ['flatten', 'convert', 'normalize', 'info', 'test-toolbox'].includes(cfg.command)) {
1897
+ if (
1898
+ cfg.command !== "help" &&
1899
+ ["flatten", "convert", "normalize", "info", "test-toolbox"].includes(
1900
+ cfg.command,
1901
+ )
1902
+ ) {
1378
1903
  cfg.showCommandHelp = true;
1379
1904
  } else {
1380
- cfg.command = 'help';
1905
+ cfg.command = "help";
1381
1906
  }
1382
1907
  break;
1383
- case '--version': cfg.command = 'version'; break;
1908
+ case "--version":
1909
+ cfg.command = "version";
1910
+ break;
1384
1911
  // Full flatten pipeline options
1385
- case '--transform-only': cfg.transformOnly = true; break;
1386
- case '--no-clip-paths': cfg.resolveClipPaths = false; break;
1387
- case '--no-masks': cfg.resolveMasks = false; break;
1388
- case '--no-use': cfg.resolveUse = false; break;
1389
- case '--no-markers': cfg.resolveMarkers = false; break;
1390
- case '--no-patterns': cfg.resolvePatterns = false; break;
1391
- case '--no-gradients': cfg.bakeGradients = false; break;
1912
+ case "--transform-only":
1913
+ cfg.transformOnly = true;
1914
+ break;
1915
+ case "--no-clip-paths":
1916
+ cfg.resolveClipPaths = false;
1917
+ break;
1918
+ case "--no-masks":
1919
+ cfg.resolveMasks = false;
1920
+ break;
1921
+ case "--no-use":
1922
+ cfg.resolveUse = false;
1923
+ break;
1924
+ case "--no-markers":
1925
+ cfg.resolveMarkers = false;
1926
+ break;
1927
+ case "--no-patterns":
1928
+ cfg.resolvePatterns = false;
1929
+ break;
1930
+ case "--no-gradients":
1931
+ cfg.bakeGradients = false;
1932
+ break;
1392
1933
  // Vendor preservation option
1393
- case '--preserve-vendor': cfg.preserveVendor = true; break;
1934
+ case "--preserve-vendor":
1935
+ cfg.preserveVendor = true;
1936
+ break;
1394
1937
  // Namespace preservation option (comma-separated list)
1395
- case '--preserve-ns': {
1396
- const namespaces = args[++i];
1938
+ case "--preserve-ns": {
1939
+ const namespaces = argValue || args[++i];
1397
1940
  if (!namespaces) {
1398
- logError('--preserve-ns requires a comma-separated list of namespaces');
1941
+ logError(
1942
+ "--preserve-ns requires a comma-separated list of namespaces",
1943
+ );
1399
1944
  process.exit(CONSTANTS.EXIT_ERROR);
1400
1945
  }
1401
- cfg.preserveNamespaces = namespaces.split(',').map(ns => ns.trim()).filter(ns => ns.length > 0);
1946
+ cfg.preserveNamespaces = namespaces
1947
+ .split(",")
1948
+ .map((ns) => ns.trim().toLowerCase())
1949
+ .filter((ns) => ns.length > 0);
1402
1950
  break;
1403
1951
  }
1952
+ case "--svg2-polyfills":
1953
+ cfg.svg2Polyfills = true;
1954
+ break;
1955
+ case "--no-minify-polyfills":
1956
+ cfg.noMinifyPolyfills = true;
1957
+ setPolyfillMinification(false);
1958
+ break;
1404
1959
  // E2E verification precision options
1405
- case '--clip-segments': {
1960
+ case "--clip-segments": {
1406
1961
  const segs = parseInt(args[++i], 10);
1407
1962
  if (isNaN(segs) || segs < 8 || segs > 512) {
1408
- logError('clip-segments must be between 8 and 512');
1963
+ logError("clip-segments must be between 8 and 512");
1409
1964
  process.exit(CONSTANTS.EXIT_ERROR);
1410
1965
  }
1411
1966
  cfg.clipSegments = segs;
1412
1967
  break;
1413
1968
  }
1414
- case '--bezier-arcs': {
1969
+ case "--bezier-arcs": {
1415
1970
  const arcs = parseInt(args[++i], 10);
1416
1971
  if (isNaN(arcs) || arcs < 4 || arcs > 128) {
1417
- logError('bezier-arcs must be between 4 and 128');
1972
+ logError("bezier-arcs must be between 4 and 128");
1418
1973
  process.exit(CONSTANTS.EXIT_ERROR);
1419
1974
  }
1420
1975
  cfg.bezierArcs = arcs;
1421
1976
  break;
1422
1977
  }
1423
- case '--e2e-tolerance': {
1978
+ case "--e2e-tolerance": {
1424
1979
  const tol = args[++i];
1425
1980
  if (!/^1e-\d+$/.test(tol)) {
1426
- logError('e2e-tolerance must be in format 1e-N (e.g., 1e-10, 1e-12)');
1981
+ logError("e2e-tolerance must be in format 1e-N (e.g., 1e-10, 1e-12)");
1427
1982
  process.exit(CONSTANTS.EXIT_ERROR);
1428
1983
  }
1429
1984
  cfg.e2eTolerance = tol;
@@ -1431,8 +1986,22 @@ function parseArgs(args) {
1431
1986
  }
1432
1987
  // NOTE: --verify removed - verification is ALWAYS enabled
1433
1988
  default:
1434
- if (arg.startsWith('-')) { logError(`Unknown option: ${arg}`); process.exit(CONSTANTS.EXIT_ERROR); }
1435
- if (['flatten', 'convert', 'normalize', 'info', 'test-toolbox', 'help', 'version'].includes(arg) && cfg.command === 'help') {
1989
+ if (arg.startsWith("-")) {
1990
+ logError(`Unknown option: ${arg}`);
1991
+ process.exit(CONSTANTS.EXIT_ERROR);
1992
+ }
1993
+ if (
1994
+ [
1995
+ "flatten",
1996
+ "convert",
1997
+ "normalize",
1998
+ "info",
1999
+ "test-toolbox",
2000
+ "help",
2001
+ "version",
2002
+ ].includes(arg) &&
2003
+ cfg.command === "help"
2004
+ ) {
1436
2005
  cfg.command = arg;
1437
2006
  } else {
1438
2007
  inputs.push(arg);
@@ -1444,27 +2013,40 @@ function parseArgs(args) {
1444
2013
  return cfg;
1445
2014
  }
1446
2015
 
2016
+ /**
2017
+ * Gather input files from config inputs and list file.
2018
+ * @returns {string[]} Array of input file paths
2019
+ */
1447
2020
  function gatherInputFiles() {
1448
2021
  const files = [];
1449
2022
  if (config.listFile) {
1450
2023
  const listPath = resolvePath(config.listFile);
1451
2024
  // Why: Use CONSTANTS.EXIT_ERROR for consistency with all other error exits
1452
- if (!isFile(listPath)) { logError(`List file not found: ${config.listFile}`); process.exit(CONSTANTS.EXIT_ERROR); }
2025
+ if (!isFile(listPath)) {
2026
+ logError(`List file not found: ${config.listFile}`);
2027
+ process.exit(CONSTANTS.EXIT_ERROR);
2028
+ }
1453
2029
  files.push(...parseFileList(listPath));
1454
2030
  }
1455
2031
  for (const input of config.inputs) {
1456
2032
  const resolved = resolvePath(input);
1457
2033
  if (isFile(resolved)) files.push(resolved);
1458
- else if (isDir(resolved)) files.push(...getSvgFiles(resolved, config.recursive));
2034
+ else if (isDir(resolved))
2035
+ files.push(...getSvgFiles(resolved, config.recursive));
1459
2036
  else logWarn(`Input not found: ${input}`);
1460
2037
  }
1461
2038
  return files;
1462
2039
  }
1463
2040
 
2041
+ /**
2042
+ * Get output path for input file based on config.
2043
+ * @param {string} inputPath - Input file path
2044
+ * @returns {string} Output file path
2045
+ */
1464
2046
  function getOutputPath(inputPath) {
1465
2047
  if (!config.output) {
1466
2048
  const dir = dirname(inputPath);
1467
- const base = basename(inputPath, '.svg');
2049
+ const base = basename(inputPath, ".svg");
1468
2050
  return join(dir, `${base}-processed.svg`);
1469
2051
  }
1470
2052
  const output = resolvePath(config.output);
@@ -1478,10 +2060,17 @@ function getOutputPath(inputPath) {
1478
2060
  // MAIN
1479
2061
  // ============================================================================
1480
2062
 
2063
+ /**
2064
+ * Main entry point for CLI.
2065
+ * @returns {Promise<void>}
2066
+ */
1481
2067
  async function main() {
1482
2068
  try {
1483
2069
  const args = process.argv.slice(2);
1484
- if (args.length === 0) { showHelp(); process.exit(CONSTANTS.EXIT_SUCCESS); }
2070
+ if (args.length === 0) {
2071
+ showHelp();
2072
+ process.exit(CONSTANTS.EXIT_SUCCESS);
2073
+ }
1485
2074
 
1486
2075
  config = parseArgs(args);
1487
2076
 
@@ -1495,25 +2084,40 @@ async function main() {
1495
2084
  try {
1496
2085
  if (existsSync(config.logFile)) unlinkSync(config.logFile);
1497
2086
  writeToLogFile(`=== svg-matrix v${VERSION} ===`);
1498
- } catch (e) { logWarn(`Could not init log: ${e.message}`); }
2087
+ } catch (e) {
2088
+ logWarn(`Could not init log: ${e.message}`);
2089
+ }
1499
2090
  }
1500
2091
 
1501
2092
  switch (config.command) {
1502
- case 'help': showHelp(); break;
1503
- case 'version': showVersion(); break;
1504
- case 'info': {
2093
+ case "help":
2094
+ showHelp();
2095
+ break;
2096
+ case "version":
2097
+ showVersion();
2098
+ break;
2099
+ case "info": {
1505
2100
  const files = gatherInputFiles();
1506
- if (files.length === 0) { logError('No input files'); process.exit(CONSTANTS.EXIT_ERROR); }
2101
+ if (files.length === 0) {
2102
+ logError("No input files");
2103
+ process.exit(CONSTANTS.EXIT_ERROR);
2104
+ }
1507
2105
  for (const f of files) processInfo(f);
1508
2106
  break;
1509
2107
  }
1510
- case 'flatten': case 'convert': case 'normalize': {
2108
+ case "flatten":
2109
+ case "convert":
2110
+ case "normalize": {
1511
2111
  const files = gatherInputFiles();
1512
- if (files.length === 0) { logError('No input files'); process.exit(CONSTANTS.EXIT_ERROR); }
2112
+ if (files.length === 0) {
2113
+ logError("No input files");
2114
+ process.exit(CONSTANTS.EXIT_ERROR);
2115
+ }
1513
2116
  logInfo(`Processing ${files.length} file(s)...`);
1514
- if (config.dryRun) logInfo('(dry run)');
2117
+ if (config.dryRun) logInfo("(dry run)");
1515
2118
 
1516
- let ok = 0, fail = 0;
2119
+ let ok = 0,
2120
+ fail = 0;
1517
2121
  for (let i = 0; i < files.length; i++) {
1518
2122
  const f = files[i];
1519
2123
  currentInputFile = f; // Track for crash log
@@ -1532,8 +2136,12 @@ async function main() {
1532
2136
  // Why: Track output file for cleanup on interrupt
1533
2137
  currentOutputFile = out;
1534
2138
 
1535
- const fn = config.command === 'flatten' ? processFlatten :
1536
- config.command === 'convert' ? processConvert : processNormalize;
2139
+ const fn =
2140
+ config.command === "flatten"
2141
+ ? processFlatten
2142
+ : config.command === "convert"
2143
+ ? processConvert
2144
+ : processNormalize;
1537
2145
 
1538
2146
  if (fn(f, out)) {
1539
2147
  // Verify write if not dry run
@@ -1542,9 +2150,9 @@ async function main() {
1542
2150
  // 1. Full verification requires double memory (storing content before write)
1543
2151
  // 2. Empty file is the most common silent failure mode
1544
2152
  // 3. Full content already written, re-reading only to check existence/size
1545
- const written = readFileSync(out, 'utf8');
2153
+ const written = readFileSync(out, "utf8");
1546
2154
  if (written.length === 0) {
1547
- throw new Error('Output file is empty after write');
2155
+ throw new Error("Output file is empty after write");
1548
2156
  }
1549
2157
  }
1550
2158
  // Why: Clear output file tracker after successful processing
@@ -1560,12 +2168,14 @@ async function main() {
1560
2168
  }
1561
2169
  currentInputFile = null;
1562
2170
  currentOutputFile = null;
1563
- logInfo(`\n${colors.bright}Done:${colors.reset} ${ok} ok, ${fail} failed`);
2171
+ logInfo(
2172
+ `\n${colors.bright}Done:${colors.reset} ${ok} ok, ${fail} failed`,
2173
+ );
1564
2174
  if (config.logFile) logInfo(`Log: ${config.logFile}`);
1565
2175
  if (fail > 0) process.exit(CONSTANTS.EXIT_ERROR);
1566
2176
  break;
1567
2177
  }
1568
- case 'test-toolbox': {
2178
+ case "test-toolbox": {
1569
2179
  await processTestToolbox();
1570
2180
  break;
1571
2181
  }
@@ -1577,7 +2187,7 @@ async function main() {
1577
2187
  } catch (error) {
1578
2188
  generateCrashLog(error, {
1579
2189
  currentFile: currentInputFile,
1580
- args: process.argv.slice(2)
2190
+ args: process.argv.slice(2),
1581
2191
  });
1582
2192
  logError(`Fatal error: ${error.message}`);
1583
2193
  process.exit(CONSTANTS.EXIT_ERROR);
@@ -1585,11 +2195,17 @@ async function main() {
1585
2195
  }
1586
2196
 
1587
2197
  // Why: Catch unhandled promise rejections which would otherwise cause silent failures
1588
- process.on('unhandledRejection', (reason, promise) => {
2198
+ process.on("unhandledRejection", (reason, _promise) => {
1589
2199
  const error = reason instanceof Error ? reason : new Error(String(reason));
1590
- generateCrashLog(error, { type: 'unhandledRejection', currentFile: currentInputFile });
2200
+ generateCrashLog(error, {
2201
+ type: "unhandledRejection",
2202
+ currentFile: currentInputFile,
2203
+ });
1591
2204
  logError(`Unhandled rejection: ${error.message}`);
1592
2205
  process.exit(CONSTANTS.EXIT_ERROR);
1593
2206
  });
1594
2207
 
1595
- main().catch((e) => { logError(`Error: ${e.message}`); process.exit(CONSTANTS.EXIT_ERROR); });
2208
+ main().catch((e) => {
2209
+ logError(`Error: ${e.message}`);
2210
+ process.exit(CONSTANTS.EXIT_ERROR);
2211
+ });