@emasoft/svg-matrix 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1000 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @fileoverview CLI tool for @emasoft/svg-matrix
4
+ * Provides command-line interface for SVG processing operations.
5
+ *
6
+ * Features:
7
+ * - Single file processing
8
+ * - Batch processing from folders
9
+ * - Batch processing from file lists (txt)
10
+ * - Configurable logging (--quiet, --verbose, --log-file)
11
+ * - Cross-platform path handling
12
+ *
13
+ * @module bin/svg-matrix
14
+ * @license MIT
15
+ */
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';
19
+
20
+ // Import library modules
21
+ import * as SVGFlatten from '../src/svg-flatten.js';
22
+ import * as GeometryToPath from '../src/geometry-to-path.js';
23
+ import { VERSION } from '../src/index.js';
24
+
25
+ // ============================================================================
26
+ // CONSTANTS
27
+ // Why: Centralize all magic numbers and configuration defaults in one place.
28
+ // This makes the code easier to maintain and prevents inconsistent values.
29
+ // Never duplicate these values elsewhere - always reference this object.
30
+ // ============================================================================
31
+ const CONSTANTS = {
32
+ // Precision defaults for Decimal.js operations
33
+ DEFAULT_PRECISION: 6,
34
+ MAX_PRECISION: 50,
35
+ MIN_PRECISION: 1,
36
+
37
+ // File size limits to prevent memory issues
38
+ MAX_FILE_SIZE_BYTES: 50 * 1024 * 1024, // 50MB - larger files need streaming
39
+
40
+ // Iteration limits to prevent infinite loops
41
+ MAX_GROUP_ITERATIONS: 10, // Prevent infinite loop in group transform propagation
42
+
43
+ // Process timeouts
44
+ GRACEFUL_EXIT_TIMEOUT_MS: 1000, // Time to wait for cleanup before forced exit
45
+
46
+ // Exit codes - standard Unix conventions
47
+ EXIT_SUCCESS: 0,
48
+ EXIT_ERROR: 1,
49
+ EXIT_INTERRUPTED: 130, // 128 + SIGINT(2)
50
+
51
+ // SVG file extensions recognized
52
+ SVG_EXTENSIONS: ['.svg', '.svgz'],
53
+
54
+ // SVG header pattern for validation
55
+ SVG_HEADER_PATTERN: /<svg[\s>]/i,
56
+ };
57
+
58
+ // ============================================================================
59
+ // SIGNAL HANDLING (global state)
60
+ // Why: Track shutdown state and current file for crash logs.
61
+ // The signal handlers are registered later, after colors are defined.
62
+ // ============================================================================
63
+ let isShuttingDown = false;
64
+ let currentInputFile = null; // Track for crash log
65
+ let currentOutputFile = null; // Track for cleanup on interrupt
66
+
67
+ // ============================================================================
68
+ // CONFIGURATION
69
+ // ============================================================================
70
+
71
+ /**
72
+ * @typedef {Object} CLIConfig
73
+ * @property {string} command - Command to run
74
+ * @property {string[]} inputs - Input files/folders
75
+ * @property {string} output - Output file/folder
76
+ * @property {string|null} listFile - File containing list of inputs
77
+ * @property {boolean} quiet - Suppress all output
78
+ * @property {boolean} verbose - Enable verbose logging
79
+ * @property {string|null} logFile - Path to log file
80
+ * @property {number} precision - Decimal precision for output
81
+ * @property {boolean} recursive - Process folders recursively
82
+ * @property {boolean} overwrite - Overwrite existing files
83
+ * @property {boolean} dryRun - Show what would be done without doing it
84
+ */
85
+
86
+ const DEFAULT_CONFIG = {
87
+ command: 'help',
88
+ inputs: [],
89
+ output: null,
90
+ listFile: null,
91
+ quiet: false,
92
+ verbose: false,
93
+ logFile: null,
94
+ precision: CONSTANTS.DEFAULT_PRECISION,
95
+ recursive: false,
96
+ overwrite: false,
97
+ dryRun: false,
98
+ };
99
+
100
+ /** @type {CLIConfig} */
101
+ let config = { ...DEFAULT_CONFIG };
102
+
103
+ // ============================================================================
104
+ // LOGGING
105
+ // ============================================================================
106
+
107
+ const LogLevel = { ERROR: 0, WARN: 1, INFO: 2, DEBUG: 3 };
108
+
109
+ function getLogLevel() {
110
+ if (config.quiet) return LogLevel.ERROR;
111
+ if (config.verbose) return LogLevel.DEBUG;
112
+ return LogLevel.INFO;
113
+ }
114
+
115
+ const colors = process.env.NO_COLOR !== undefined ? {
116
+ reset: '', red: '', yellow: '', green: '', cyan: '', dim: '', bright: '',
117
+ } : {
118
+ reset: '\x1b[0m', red: '\x1b[31m', yellow: '\x1b[33m',
119
+ green: '\x1b[32m', cyan: '\x1b[36m', dim: '\x1b[2m', bright: '\x1b[1m',
120
+ };
121
+
122
+ // ============================================================================
123
+ // SIGNAL HANDLING (handlers)
124
+ // Why: Ensure graceful cleanup when user presses Ctrl+C or when the process
125
+ // is terminated. Without this, partial files may be left behind and resources
126
+ // won't be properly released. The isShuttingDown flag prevents duplicate
127
+ // cleanup attempts during signal cascades.
128
+ // ============================================================================
129
+ function handleGracefulExit(signal) {
130
+ // Why: Prevent duplicate cleanup if multiple signals arrive quickly
131
+ if (isShuttingDown) return;
132
+ isShuttingDown = true;
133
+
134
+ console.log(`\n${colors.yellow}Received ${signal}, cleaning up...${colors.reset}`);
135
+
136
+ // Why: Remove partial output file if interrupt occurred during processing
137
+ if (currentOutputFile && existsSync(currentOutputFile)) {
138
+ try {
139
+ unlinkSync(currentOutputFile);
140
+ console.log(`${colors.dim}Removed partial output: ${basename(currentOutputFile)}${colors.reset}`);
141
+ } catch { /* ignore cleanup errors */ }
142
+ }
143
+
144
+ // Log the interruption for debugging
145
+ if (config.logFile) {
146
+ writeToLogFile(`INTERRUPTED: Received ${signal} while processing ${currentInputFile || 'unknown'}`);
147
+ }
148
+
149
+ // Why: Give async operations time to complete, but don't hang indefinitely
150
+ setTimeout(() => {
151
+ process.exit(CONSTANTS.EXIT_INTERRUPTED);
152
+ }, CONSTANTS.GRACEFUL_EXIT_TIMEOUT_MS);
153
+ }
154
+
155
+ // Register signal handlers - must be done before any async operations
156
+ process.on('SIGINT', () => handleGracefulExit('SIGINT')); // Ctrl+C
157
+ process.on('SIGTERM', () => handleGracefulExit('SIGTERM')); // kill command
158
+ // Note: SIGTERM is not fully supported on Windows. On Windows, SIGINT (Ctrl+C) works,
159
+ // but SIGTERM requires special handling or third-party libraries. This is acceptable
160
+ // for a CLI tool as Ctrl+C is the primary interrupt mechanism on all platforms.
161
+
162
+ function writeToLogFile(message) {
163
+ if (config.logFile) {
164
+ try {
165
+ const timestamp = new Date().toISOString();
166
+ const cleanMessage = message.replace(/\x1b\[[0-9;]*m/g, '');
167
+ appendFileSync(config.logFile, `[${timestamp}] ${cleanMessage}\n`);
168
+ } catch { /* ignore */ }
169
+ }
170
+ }
171
+
172
+ function logError(msg) {
173
+ console.error(`${colors.red}ERROR:${colors.reset} ${msg}`);
174
+ writeToLogFile(`ERROR: ${msg}`);
175
+ }
176
+
177
+ function logWarn(msg) {
178
+ if (getLogLevel() >= LogLevel.WARN) console.warn(`${colors.yellow}WARN:${colors.reset} ${msg}`);
179
+ writeToLogFile(`WARN: ${msg}`);
180
+ }
181
+
182
+ function logInfo(msg) {
183
+ if (getLogLevel() >= LogLevel.INFO) console.log(msg);
184
+ writeToLogFile(`INFO: ${msg}`);
185
+ }
186
+
187
+ function logDebug(msg) {
188
+ if (getLogLevel() >= LogLevel.DEBUG) console.log(`${colors.dim}DEBUG: ${msg}${colors.reset}`);
189
+ writeToLogFile(`DEBUG: ${msg}`);
190
+ }
191
+
192
+ function logSuccess(msg) {
193
+ if (getLogLevel() >= LogLevel.INFO) console.log(`${colors.green}OK${colors.reset} ${msg}`);
194
+ writeToLogFile(`SUCCESS: ${msg}`);
195
+ }
196
+
197
+ // ============================================================================
198
+ // PROGRESS INDICATOR
199
+ // Why: Batch operations can take a long time. Users need feedback to know
200
+ // the operation is still running and how far along it is. Without this,
201
+ // users may think the program is frozen and kill it prematurely.
202
+ // ============================================================================
203
+ function showProgress(current, total, filename) {
204
+ // Why: Don't show progress in quiet mode or when there's only one file
205
+ if (config.quiet || total <= 1) return;
206
+
207
+ // Why: In verbose mode, newline before progress to avoid overwriting debug output
208
+ if (config.verbose && current > 1) {
209
+ process.stdout.write('\n');
210
+ }
211
+
212
+ const percent = Math.round((current / total) * 100);
213
+ const bar = '█'.repeat(Math.floor(percent / 5)) + '░'.repeat(20 - Math.floor(percent / 5));
214
+
215
+ // Why: Use \r to overwrite the same line, keeping terminal clean
216
+ process.stdout.write(`\r${colors.cyan}[${bar}]${colors.reset} ${percent}% (${current}/${total}) ${basename(filename)}`);
217
+
218
+ // Why: Clear to end of line to remove any leftover characters from longer filenames
219
+ process.stdout.write('\x1b[K');
220
+
221
+ // Why: Print newline when complete so next output starts on new line
222
+ if (current === total) {
223
+ process.stdout.write('\n');
224
+ }
225
+ }
226
+
227
+ // ============================================================================
228
+ // FILE VALIDATION
229
+ // Why: Fail fast on invalid input. Processing non-SVG files wastes time and
230
+ // may produce confusing errors. Size limits prevent memory exhaustion.
231
+ // ============================================================================
232
+ function validateSvgFile(filePath) {
233
+ const stats = statSync(filePath);
234
+
235
+ // Why: Prevent memory exhaustion from huge files
236
+ if (stats.size > CONSTANTS.MAX_FILE_SIZE_BYTES) {
237
+ throw new Error(`File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB). Max: ${CONSTANTS.MAX_FILE_SIZE_BYTES / 1024 / 1024}MB`);
238
+ }
239
+
240
+ // Why: Read only first 1KB to check header - don't load entire file
241
+ const fd = openSync(filePath, 'r');
242
+ const buffer = Buffer.alloc(1024);
243
+ readSync(fd, buffer, 0, 1024, 0);
244
+ closeSync(fd);
245
+ const header = buffer.toString('utf8');
246
+
247
+ // Why: SVG files must have an <svg> element - if not, it's not a valid SVG
248
+ if (!CONSTANTS.SVG_HEADER_PATTERN.test(header)) {
249
+ throw new Error('Not a valid SVG file (missing <svg> element)');
250
+ }
251
+
252
+ return true;
253
+ }
254
+
255
+ // ============================================================================
256
+ // WRITE VERIFICATION
257
+ // Why: Detect silent write failures. Some filesystems (especially network
258
+ // shares) may appear to write successfully but fail to persist data.
259
+ // Verification catches this immediately rather than discovering corruption later.
260
+ // ============================================================================
261
+ function verifyWriteSuccess(filePath, expectedContent) {
262
+ // Why: Read back what was written and compare
263
+ const actualContent = readFileSync(filePath, 'utf8');
264
+
265
+ // Why: Compare lengths first (fast), then content if needed
266
+ if (actualContent.length !== expectedContent.length) {
267
+ throw new Error(`Write verification failed: size mismatch (expected ${expectedContent.length}, got ${actualContent.length})`);
268
+ }
269
+
270
+ // Why: Full content comparison to catch bit flips or encoding issues
271
+ if (actualContent !== expectedContent) {
272
+ throw new Error('Write verification failed: content mismatch');
273
+ }
274
+
275
+ return true;
276
+ }
277
+
278
+ // ============================================================================
279
+ // CRASH LOG
280
+ // Why: When the program crashes, users need detailed information to report
281
+ // the bug or fix the issue themselves. This generates a timestamped log file
282
+ // with full context about what was being processed when the crash occurred.
283
+ // ============================================================================
284
+ function generateCrashLog(error, context = {}) {
285
+ const crashDir = join(process.cwd(), '.svg-matrix-crashes');
286
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
287
+ const crashFile = join(crashDir, `crash-${timestamp}.log`);
288
+
289
+ try {
290
+ ensureDir(crashDir);
291
+
292
+ const crashContent = `SVG-MATRIX CRASH REPORT
293
+ ========================
294
+ Timestamp: ${new Date().toISOString()}
295
+ Version: ${VERSION}
296
+ Node: ${process.version}
297
+ Platform: ${process.platform} ${process.arch}
298
+ Command: ${config.command}
299
+
300
+ Context:
301
+ ${JSON.stringify(context, null, 2)}
302
+
303
+ Error:
304
+ ${error.name}: ${error.message}
305
+
306
+ Stack:
307
+ ${error.stack}
308
+
309
+ Config:
310
+ ${JSON.stringify({ ...config, logFile: config.logFile ? '[redacted]' : null }, null, 2)}
311
+ `;
312
+
313
+ writeFileSync(crashFile, crashContent, 'utf8');
314
+ logError(`Crash log written to: ${crashFile}`);
315
+ return crashFile;
316
+ } catch (e) {
317
+ // Why: Don't throw from error handler - just log and continue
318
+ logError(`Failed to write crash log: ${e.message}`);
319
+ return null;
320
+ }
321
+ }
322
+
323
+ // ============================================================================
324
+ // PATH UTILITIES
325
+ // ============================================================================
326
+
327
+ function normalizePath(p) { return p.replace(/\\/g, '/'); }
328
+
329
+ function resolvePath(p) {
330
+ return isAbsolute(p) ? normalizePath(p) : normalizePath(resolve(process.cwd(), p));
331
+ }
332
+
333
+ function isDir(p) { try { return statSync(p).isDirectory(); } catch { return false; } }
334
+ function isFile(p) { try { return statSync(p).isFile(); } catch { return false; } }
335
+
336
+ function ensureDir(dir) {
337
+ if (!existsSync(dir)) {
338
+ mkdirSync(dir, { recursive: true });
339
+ logDebug(`Created directory: ${dir}`);
340
+ }
341
+ }
342
+
343
+ function getSvgFiles(dir, recursive = false) {
344
+ const files = [];
345
+ function scan(d) {
346
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
347
+ const fullPath = join(d, entry.name);
348
+ if (entry.isDirectory() && recursive) scan(fullPath);
349
+ // Why: Support both .svg and .svgz as defined in CONSTANTS.SVG_EXTENSIONS
350
+ else if (entry.isFile() && CONSTANTS.SVG_EXTENSIONS.includes(extname(entry.name).toLowerCase())) files.push(normalizePath(fullPath));
351
+ }
352
+ }
353
+ scan(dir);
354
+ return files;
355
+ }
356
+
357
+ function parseFileList(listPath) {
358
+ const content = readFileSync(listPath, 'utf8');
359
+ const files = [];
360
+ for (const line of content.split(/\r?\n/)) {
361
+ const trimmed = line.trim();
362
+ if (!trimmed || trimmed.startsWith('#')) continue;
363
+ const resolved = resolvePath(trimmed);
364
+ if (isFile(resolved)) files.push(resolved);
365
+ else if (isDir(resolved)) files.push(...getSvgFiles(resolved, config.recursive));
366
+ else logWarn(`File not found: ${trimmed}`);
367
+ }
368
+ return files;
369
+ }
370
+
371
+ // ============================================================================
372
+ // SHAPE EXTRACTION HELPERS
373
+ // Why: The same attribute extraction patterns are repeated for each shape type.
374
+ // Consolidating them reduces code duplication (DRY principle) and ensures
375
+ // consistent behavior across all commands. Fix bugs in one place, not many.
376
+ // ============================================================================
377
+
378
+ /**
379
+ * Extract numeric attribute from element attributes string.
380
+ * @param {string} attrs - Element attributes string
381
+ * @param {string} attrName - Attribute name to extract
382
+ * @param {number} defaultValue - Default if not found
383
+ * @returns {number} Parsed value or default
384
+ */
385
+ function extractNumericAttr(attrs, attrName, defaultValue = 0) {
386
+ // Why: Use word boundary \b to avoid matching 'rx' when looking for 'x'
387
+ const regex = new RegExp(`\\b${attrName}\\s*=\\s*["']([^"']+)["']`, 'i');
388
+ const match = attrs.match(regex);
389
+ return match ? parseFloat(match[1]) : defaultValue;
390
+ }
391
+
392
+ /**
393
+ * Extract shape geometry as path data.
394
+ * @param {string} shapeType - Shape element type (rect, circle, etc.)
395
+ * @param {string} attrs - Element attributes string
396
+ * @param {number} precision - Decimal precision
397
+ * @returns {string|null} Path data or null if extraction failed
398
+ */
399
+ function extractShapeAsPath(shapeType, attrs, precision) {
400
+ switch (shapeType) {
401
+ case 'rect': {
402
+ const x = extractNumericAttr(attrs, 'x');
403
+ const y = extractNumericAttr(attrs, 'y');
404
+ const w = extractNumericAttr(attrs, 'width');
405
+ const h = extractNumericAttr(attrs, 'height');
406
+ const rx = extractNumericAttr(attrs, 'rx');
407
+ const ry = extractNumericAttr(attrs, 'ry', rx); // ry defaults to rx per SVG spec
408
+ if (w <= 0 || h <= 0) return null;
409
+ return GeometryToPath.rectToPathData(x, y, w, h, rx, ry, false, precision);
410
+ }
411
+ case 'circle': {
412
+ const cx = extractNumericAttr(attrs, 'cx');
413
+ const cy = extractNumericAttr(attrs, 'cy');
414
+ const r = extractNumericAttr(attrs, 'r');
415
+ if (r <= 0) return null;
416
+ return GeometryToPath.circleToPathData(cx, cy, r, precision);
417
+ }
418
+ case 'ellipse': {
419
+ const cx = extractNumericAttr(attrs, 'cx');
420
+ const cy = extractNumericAttr(attrs, 'cy');
421
+ const rx = extractNumericAttr(attrs, 'rx');
422
+ const ry = extractNumericAttr(attrs, 'ry');
423
+ if (rx <= 0 || ry <= 0) return null;
424
+ return GeometryToPath.ellipseToPathData(cx, cy, rx, ry, precision);
425
+ }
426
+ case 'line': {
427
+ const x1 = extractNumericAttr(attrs, 'x1');
428
+ const y1 = extractNumericAttr(attrs, 'y1');
429
+ const x2 = extractNumericAttr(attrs, 'x2');
430
+ const y2 = extractNumericAttr(attrs, 'y2');
431
+ return GeometryToPath.lineToPathData(x1, y1, x2, y2, precision);
432
+ }
433
+ case 'polygon': {
434
+ const points = attrs.match(/points\s*=\s*["']([^"']+)["']/)?.[1];
435
+ return points ? GeometryToPath.polygonToPathData(points, precision) : null;
436
+ }
437
+ case 'polyline': {
438
+ const points = attrs.match(/points\s*=\s*["']([^"']+)["']/)?.[1];
439
+ return points ? GeometryToPath.polylineToPathData(points, precision) : null;
440
+ }
441
+ default:
442
+ return null;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Get attributes to remove when converting shape to path.
448
+ * @param {string} shapeType - Shape element type
449
+ * @returns {string[]} Attribute names to remove
450
+ */
451
+ function getShapeSpecificAttrs(shapeType) {
452
+ const attrMap = {
453
+ rect: ['x', 'y', 'width', 'height', 'rx', 'ry'],
454
+ circle: ['cx', 'cy', 'r'],
455
+ ellipse: ['cx', 'cy', 'rx', 'ry'],
456
+ line: ['x1', 'y1', 'x2', 'y2'],
457
+ polygon: ['points'],
458
+ polyline: ['points'],
459
+ };
460
+ return attrMap[shapeType] || [];
461
+ }
462
+
463
+ /**
464
+ * Remove shape-specific attributes from element attributes string.
465
+ * @param {string} attrs - Element attributes string
466
+ * @param {string[]} attrsToRemove - Attribute names to remove
467
+ * @returns {string} Cleaned attributes
468
+ */
469
+ function removeShapeAttrs(attrs, attrsToRemove) {
470
+ let result = attrs;
471
+ for (const attrName of attrsToRemove) {
472
+ result = result.replace(new RegExp(`\\b${attrName}\\s*=\\s*["'][^"']*["']`, 'gi'), '');
473
+ }
474
+ return result.trim();
475
+ }
476
+
477
+ // ============================================================================
478
+ // COMMANDS
479
+ // ============================================================================
480
+
481
+ function showHelp() {
482
+ console.log(`
483
+ ${colors.cyan}${colors.bright}@emasoft/svg-matrix${colors.reset} v${VERSION}
484
+ High-precision SVG matrix and transformation CLI
485
+
486
+ ${colors.bright}USAGE:${colors.reset}
487
+ svg-matrix <command> [options] <input> [-o <output>]
488
+
489
+ ${colors.bright}COMMANDS:${colors.reset}
490
+ flatten Flatten SVG transforms into path data
491
+ convert Convert shapes (rect, circle, etc.) to paths
492
+ normalize Convert paths to absolute cubic Bezier curves
493
+ info Show SVG file information
494
+ help Show this help message
495
+ version Show version number
496
+
497
+ ${colors.bright}OPTIONS:${colors.reset}
498
+ -o, --output <path> Output file or directory
499
+ -l, --list <file> Read input files from text file
500
+ -r, --recursive Process directories recursively
501
+ -p, --precision <n> Decimal precision (default: 6)
502
+ -f, --force Overwrite existing output files
503
+ -n, --dry-run Show what would be done
504
+ -q, --quiet Suppress all output except errors
505
+ -v, --verbose Enable verbose/debug output
506
+ --log-file <path> Write log to file
507
+ -h, --help Show help
508
+
509
+ ${colors.bright}EXAMPLES:${colors.reset}
510
+ svg-matrix flatten input.svg -o output.svg
511
+ svg-matrix flatten ./svgs/ -o ./output/
512
+ svg-matrix flatten --list files.txt -o ./output/
513
+ svg-matrix convert input.svg -o output.svg --precision 10
514
+ svg-matrix info input.svg
515
+
516
+ ${colors.bright}FILE LIST FORMAT:${colors.reset}
517
+ One path per line. Lines starting with # are comments.
518
+
519
+ ${colors.bright}DOCUMENTATION:${colors.reset}
520
+ https://github.com/Emasoft/SVG-MATRIX#readme
521
+ `);
522
+ }
523
+
524
+ function showVersion() { console.log(`@emasoft/svg-matrix v${VERSION}`); }
525
+
526
+ /**
527
+ * Extract transform attribute value from element attributes string.
528
+ * @param {string} attrs - Element attributes string
529
+ * @returns {string|null} Transform value or null
530
+ */
531
+ function extractTransform(attrs) {
532
+ const match = attrs.match(/transform\s*=\s*["']([^"']+)["']/i);
533
+ return match ? match[1] : null;
534
+ }
535
+
536
+ /**
537
+ * Remove transform attribute from element attributes string.
538
+ * @param {string} attrs - Element attributes string
539
+ * @returns {string} Attributes without transform
540
+ */
541
+ function removeTransform(attrs) {
542
+ return attrs.replace(/\s*transform\s*=\s*["'][^"']*["']/gi, '');
543
+ }
544
+
545
+ /**
546
+ * Extract path d attribute value.
547
+ * @param {string} attrs - Element attributes string
548
+ * @returns {string|null} Path data or null
549
+ */
550
+ function extractPathD(attrs) {
551
+ const match = attrs.match(/\bd\s*=\s*["']([^"']+)["']/i);
552
+ return match ? match[1] : null;
553
+ }
554
+
555
+ /**
556
+ * Replace path d attribute value.
557
+ * @param {string} attrs - Element attributes string
558
+ * @param {string} newD - New path data
559
+ * @returns {string} Updated attributes
560
+ */
561
+ function replacePathD(attrs, newD) {
562
+ return attrs.replace(/(\bd\s*=\s*["'])[^"']+["']/i, `$1${newD}"`);
563
+ }
564
+
565
+ /**
566
+ * Flatten transforms in SVG content by baking transforms into path data.
567
+ * Handles: path elements, shape elements (converted to paths), and nested groups.
568
+ * @param {string} inputPath - Input file path
569
+ * @param {string} outputPath - Output file path
570
+ * @returns {boolean} True if successful
571
+ */
572
+ function processFlatten(inputPath, outputPath) {
573
+ try {
574
+ logDebug(`Processing: ${inputPath}`);
575
+ let result = readFileSync(inputPath, 'utf8');
576
+ let transformCount = 0;
577
+ let pathCount = 0;
578
+ let shapeCount = 0;
579
+
580
+ // Step 1: Flatten transforms on path elements
581
+ // Note: regex captures attrs without the closing /> or >
582
+ result = result.replace(/<path\s+([^>]*?)\s*\/?>/gi, (match, attrs) => {
583
+ const transform = extractTransform(attrs);
584
+ const pathD = extractPathD(attrs);
585
+
586
+ if (!transform || !pathD) {
587
+ return match; // No transform or no path data, skip
588
+ }
589
+
590
+ try {
591
+ // Parse the transform and build CTM
592
+ const ctm = SVGFlatten.parseTransformAttribute(transform);
593
+ // Transform the path data
594
+ const transformedD = SVGFlatten.transformPathData(pathD, ctm, { precision: config.precision });
595
+ // Remove transform and update path data
596
+ const newAttrs = removeTransform(replacePathD(attrs, transformedD));
597
+ transformCount++;
598
+ pathCount++;
599
+ logDebug(`Flattened path transform: ${transform}`);
600
+ return `<path ${newAttrs.trim()}${match.endsWith('/>') ? '/>' : '>'}`;
601
+ } catch (e) {
602
+ logWarn(`Failed to flatten path: ${e.message}`);
603
+ return match;
604
+ }
605
+ });
606
+
607
+ // Step 2: Convert shapes with transforms to flattened paths
608
+ const shapeTypes = ['rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline'];
609
+
610
+ for (const shapeType of shapeTypes) {
611
+ const shapeRegex = new RegExp(`<${shapeType}([^>]*)\\/>`, 'gi');
612
+
613
+ result = result.replace(shapeRegex, (match, attrs) => {
614
+ const transform = extractTransform(attrs);
615
+ if (!transform) {
616
+ return match; // No transform, skip
617
+ }
618
+
619
+ try {
620
+ // Extract shape attributes and convert to path using helper
621
+ const pathD = extractShapeAsPath(shapeType, attrs, config.precision);
622
+
623
+ if (!pathD) {
624
+ return match; // Couldn't convert to path
625
+ }
626
+
627
+ // Parse the transform and build CTM
628
+ const ctm = SVGFlatten.parseTransformAttribute(transform);
629
+ // Transform the path data
630
+ const transformedD = SVGFlatten.transformPathData(pathD, ctm, { precision: config.precision });
631
+ // Build new path element, preserving style attributes
632
+ const attrsToRemove = getShapeSpecificAttrs(shapeType);
633
+ const styleAttrs = removeShapeAttrs(removeTransform(attrs), attrsToRemove);
634
+ transformCount++;
635
+ shapeCount++;
636
+ logDebug(`Flattened ${shapeType} transform: ${transform}`);
637
+ return `<path d="${transformedD}"${styleAttrs ? ' ' + styleAttrs : ''}/>`;
638
+ } catch (e) {
639
+ logWarn(`Failed to flatten ${shapeType}: ${e.message}`);
640
+ return match;
641
+ }
642
+ });
643
+ }
644
+
645
+ // Step 3: Handle group transforms by propagating to children
646
+ // This is a simplified approach - for full support, we'd need DOM parsing
647
+ // For now, we handle the case where a <g> has a transform and contains paths/shapes
648
+ let groupIterations = 0;
649
+
650
+ while (groupIterations < CONSTANTS.MAX_GROUP_ITERATIONS) {
651
+ const beforeResult = result;
652
+
653
+ // Find groups with transforms
654
+ result = result.replace(
655
+ /<g([^>]*transform\s*=\s*["']([^"']+)["'][^>]*)>([\s\S]*?)<\/g>/gi,
656
+ (match, gAttrs, groupTransform, content) => {
657
+ try {
658
+ const groupCtm = SVGFlatten.parseTransformAttribute(groupTransform);
659
+ let modifiedContent = content;
660
+ let childrenModified = false;
661
+
662
+ // Apply group transform to child paths
663
+ modifiedContent = modifiedContent.replace(/<path\s+([^>]*?)\s*\/?>/gi, (pathMatch, pathAttrs) => {
664
+ const pathD = extractPathD(pathAttrs);
665
+ if (!pathD) return pathMatch;
666
+
667
+ try {
668
+ const childTransform = extractTransform(pathAttrs);
669
+ let combinedCtm = groupCtm;
670
+
671
+ // If child has its own transform, compose them
672
+ if (childTransform) {
673
+ const childCtm = SVGFlatten.parseTransformAttribute(childTransform);
674
+ combinedCtm = groupCtm.mul(childCtm);
675
+ }
676
+
677
+ const transformedD = SVGFlatten.transformPathData(pathD, combinedCtm, { precision: config.precision });
678
+ const newAttrs = removeTransform(replacePathD(pathAttrs, transformedD));
679
+ childrenModified = true;
680
+ transformCount++;
681
+ return `<path ${newAttrs.trim()}${pathMatch.endsWith('/>') ? '/>' : '>'}`;
682
+ } catch (e) {
683
+ logWarn(`Failed to apply group transform to path: ${e.message}`);
684
+ return pathMatch;
685
+ }
686
+ });
687
+
688
+ if (childrenModified) {
689
+ // Remove transform from group
690
+ const newGAttrs = removeTransform(gAttrs);
691
+ logDebug(`Propagated group transform to children: ${groupTransform}`);
692
+ return `<g${newGAttrs}>${modifiedContent}</g>`;
693
+ }
694
+ return match;
695
+ } catch (e) {
696
+ logWarn(`Failed to process group: ${e.message}`);
697
+ return match;
698
+ }
699
+ }
700
+ );
701
+
702
+ // Check if anything changed
703
+ if (result === beforeResult) {
704
+ break;
705
+ }
706
+ groupIterations++;
707
+ }
708
+
709
+ logInfo(`Flattened ${transformCount} transforms (${pathCount} paths, ${shapeCount} shapes)`);
710
+
711
+ if (!config.dryRun) {
712
+ ensureDir(dirname(outputPath));
713
+ writeFileSync(outputPath, result, 'utf8');
714
+ }
715
+ logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
716
+ return true;
717
+ } catch (error) {
718
+ logError(`Failed: ${inputPath}: ${error.message}`);
719
+ return false;
720
+ }
721
+ }
722
+
723
+ function processConvert(inputPath, outputPath) {
724
+ try {
725
+ logDebug(`Converting: ${inputPath}`);
726
+ let result = readFileSync(inputPath, 'utf8');
727
+
728
+ // Convert all shape types to paths
729
+ const shapeTypes = ['rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline'];
730
+
731
+ for (const shapeType of shapeTypes) {
732
+ const shapeRegex = new RegExp(`<${shapeType}([^>]*)\\/>`, 'gi');
733
+
734
+ result = result.replace(shapeRegex, (match, attrs) => {
735
+ try {
736
+ // Extract shape as path using helper
737
+ const pathData = extractShapeAsPath(shapeType, attrs, config.precision);
738
+
739
+ if (!pathData) {
740
+ return match; // Couldn't convert to path
741
+ }
742
+
743
+ // Remove shape-specific attributes, keep style/presentation attributes
744
+ const attrsToRemove = getShapeSpecificAttrs(shapeType);
745
+ const otherAttrs = removeShapeAttrs(attrs, attrsToRemove);
746
+
747
+ return `<path d="${pathData}"${otherAttrs ? ' ' + otherAttrs : ''}/>`;
748
+ } catch (e) {
749
+ logWarn(`Failed to convert ${shapeType}: ${e.message}`);
750
+ return match;
751
+ }
752
+ });
753
+ }
754
+
755
+ if (!config.dryRun) {
756
+ ensureDir(dirname(outputPath));
757
+ writeFileSync(outputPath, result, 'utf8');
758
+ }
759
+ logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
760
+ return true;
761
+ } catch (error) {
762
+ logError(`Failed: ${inputPath}: ${error.message}`);
763
+ return false;
764
+ }
765
+ }
766
+
767
+ function processNormalize(inputPath, outputPath) {
768
+ try {
769
+ logDebug(`Normalizing: ${inputPath}`);
770
+ let result = readFileSync(inputPath, 'utf8');
771
+
772
+ result = result.replace(/d\s*=\s*["']([^"']+)["']/gi, (match, pathData) => {
773
+ try {
774
+ const normalized = GeometryToPath.pathToCubics(pathData);
775
+ return `d="${normalized}"`;
776
+ } catch { return match; }
777
+ });
778
+
779
+ if (!config.dryRun) {
780
+ ensureDir(dirname(outputPath));
781
+ writeFileSync(outputPath, result, 'utf8');
782
+ }
783
+ logSuccess(`${basename(inputPath)} -> ${basename(outputPath)}`);
784
+ return true;
785
+ } catch (error) {
786
+ logError(`Failed: ${inputPath}: ${error.message}`);
787
+ return false;
788
+ }
789
+ }
790
+
791
+ function processInfo(inputPath) {
792
+ try {
793
+ const svg = readFileSync(inputPath, 'utf8');
794
+ const vb = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i)?.[1] || 'not set';
795
+ const w = svg.match(/<svg[^>]*\swidth\s*=\s*["']([^"']+)["']/i)?.[1] || 'not set';
796
+ const h = svg.match(/<svg[^>]*\sheight\s*=\s*["']([^"']+)["']/i)?.[1] || 'not set';
797
+
798
+ console.log(`
799
+ ${colors.cyan}File:${colors.reset} ${inputPath}
800
+ ${colors.cyan}Size:${colors.reset} ${(svg.length / 1024).toFixed(2)} KB
801
+ ${colors.bright}Dimensions:${colors.reset} viewBox=${vb}, width=${w}, height=${h}
802
+ ${colors.bright}Elements:${colors.reset}
803
+ paths: ${(svg.match(/<path/gi) || []).length}
804
+ rects: ${(svg.match(/<rect/gi) || []).length}
805
+ circles: ${(svg.match(/<circle/gi) || []).length}
806
+ ellipses: ${(svg.match(/<ellipse/gi) || []).length}
807
+ groups: ${(svg.match(/<g[\s>]/gi) || []).length}
808
+ transforms: ${(svg.match(/transform\s*=/gi) || []).length}
809
+ `);
810
+ return true;
811
+ } catch (error) {
812
+ logError(`Failed: ${inputPath}: ${error.message}`);
813
+ return false;
814
+ }
815
+ }
816
+
817
+ // ============================================================================
818
+ // ARGUMENT PARSING
819
+ // ============================================================================
820
+
821
+ function parseArgs(args) {
822
+ const cfg = { ...DEFAULT_CONFIG };
823
+ const inputs = [];
824
+ let i = 0;
825
+
826
+ while (i < args.length) {
827
+ const arg = args[i];
828
+ switch (arg) {
829
+ case '-o': case '--output': cfg.output = args[++i]; break;
830
+ case '-l': case '--list': cfg.listFile = args[++i]; break;
831
+ case '-r': case '--recursive': cfg.recursive = true; break;
832
+ case '-p': case '--precision': {
833
+ const precision = parseInt(args[++i], 10);
834
+ if (isNaN(precision) || precision < CONSTANTS.MIN_PRECISION || precision > CONSTANTS.MAX_PRECISION) {
835
+ logError(`Precision must be between ${CONSTANTS.MIN_PRECISION} and ${CONSTANTS.MAX_PRECISION}`);
836
+ process.exit(CONSTANTS.EXIT_ERROR);
837
+ }
838
+ cfg.precision = precision;
839
+ break;
840
+ }
841
+ case '-f': case '--force': cfg.overwrite = true; break;
842
+ case '-n': case '--dry-run': cfg.dryRun = true; break;
843
+ case '-q': case '--quiet': cfg.quiet = true; break;
844
+ case '-v': case '--verbose': cfg.verbose = true; break;
845
+ case '--log-file': cfg.logFile = args[++i]; break;
846
+ case '-h': case '--help': cfg.command = 'help'; break;
847
+ case '--version': cfg.command = 'version'; break;
848
+ default:
849
+ if (arg.startsWith('-')) { logError(`Unknown option: ${arg}`); process.exit(CONSTANTS.EXIT_ERROR); }
850
+ if (['flatten', 'convert', 'normalize', 'info', 'help', 'version'].includes(arg) && cfg.command === 'help') {
851
+ cfg.command = arg;
852
+ } else {
853
+ inputs.push(arg);
854
+ }
855
+ }
856
+ i++;
857
+ }
858
+ cfg.inputs = inputs;
859
+ return cfg;
860
+ }
861
+
862
+ function gatherInputFiles() {
863
+ const files = [];
864
+ if (config.listFile) {
865
+ const listPath = resolvePath(config.listFile);
866
+ // Why: Use CONSTANTS.EXIT_ERROR for consistency with all other error exits
867
+ if (!isFile(listPath)) { logError(`List file not found: ${config.listFile}`); process.exit(CONSTANTS.EXIT_ERROR); }
868
+ files.push(...parseFileList(listPath));
869
+ }
870
+ for (const input of config.inputs) {
871
+ const resolved = resolvePath(input);
872
+ if (isFile(resolved)) files.push(resolved);
873
+ else if (isDir(resolved)) files.push(...getSvgFiles(resolved, config.recursive));
874
+ else logWarn(`Input not found: ${input}`);
875
+ }
876
+ return files;
877
+ }
878
+
879
+ function getOutputPath(inputPath) {
880
+ if (!config.output) {
881
+ const dir = dirname(inputPath);
882
+ const base = basename(inputPath, '.svg');
883
+ return join(dir, `${base}-processed.svg`);
884
+ }
885
+ const output = resolvePath(config.output);
886
+ if (config.inputs.length > 1 || config.listFile || isDir(output)) {
887
+ return join(output, basename(inputPath));
888
+ }
889
+ return output;
890
+ }
891
+
892
+ // ============================================================================
893
+ // MAIN
894
+ // ============================================================================
895
+
896
+ async function main() {
897
+ try {
898
+ const args = process.argv.slice(2);
899
+ if (args.length === 0) { showHelp(); process.exit(CONSTANTS.EXIT_SUCCESS); }
900
+
901
+ config = parseArgs(args);
902
+
903
+ if (config.logFile) {
904
+ try {
905
+ if (existsSync(config.logFile)) unlinkSync(config.logFile);
906
+ writeToLogFile(`=== svg-matrix v${VERSION} ===`);
907
+ } catch (e) { logWarn(`Could not init log: ${e.message}`); }
908
+ }
909
+
910
+ switch (config.command) {
911
+ case 'help': showHelp(); break;
912
+ case 'version': showVersion(); break;
913
+ case 'info': {
914
+ const files = gatherInputFiles();
915
+ if (files.length === 0) { logError('No input files'); process.exit(CONSTANTS.EXIT_ERROR); }
916
+ for (const f of files) processInfo(f);
917
+ break;
918
+ }
919
+ case 'flatten': case 'convert': case 'normalize': {
920
+ const files = gatherInputFiles();
921
+ if (files.length === 0) { logError('No input files'); process.exit(CONSTANTS.EXIT_ERROR); }
922
+ logInfo(`Processing ${files.length} file(s)...`);
923
+ if (config.dryRun) logInfo('(dry run)');
924
+
925
+ let ok = 0, fail = 0;
926
+ for (let i = 0; i < files.length; i++) {
927
+ const f = files[i];
928
+ currentInputFile = f; // Track for crash log
929
+ showProgress(i + 1, files.length, f);
930
+
931
+ try {
932
+ // Validate input file
933
+ validateSvgFile(f);
934
+
935
+ const out = getOutputPath(f);
936
+ if (!config.overwrite && !config.dryRun && isFile(out)) {
937
+ logWarn(`Skip ${basename(f)}: exists (use -f)`);
938
+ continue;
939
+ }
940
+
941
+ // Why: Track output file for cleanup on interrupt
942
+ currentOutputFile = out;
943
+
944
+ const fn = config.command === 'flatten' ? processFlatten :
945
+ config.command === 'convert' ? processConvert : processNormalize;
946
+
947
+ if (fn(f, out)) {
948
+ // Verify write if not dry run
949
+ if (!config.dryRun) {
950
+ // Why: Simple empty check instead of full verifyWriteSuccess because:
951
+ // 1. Full verification requires double memory (storing content before write)
952
+ // 2. Empty file is the most common silent failure mode
953
+ // 3. Full content already written, re-reading only to check existence/size
954
+ const written = readFileSync(out, 'utf8');
955
+ if (written.length === 0) {
956
+ throw new Error('Output file is empty after write');
957
+ }
958
+ }
959
+ // Why: Clear output file tracker after successful processing
960
+ currentOutputFile = null;
961
+ ok++;
962
+ } else {
963
+ fail++;
964
+ }
965
+ } catch (error) {
966
+ logError(`Failed to process ${basename(f)}: ${error.message}`);
967
+ fail++;
968
+ }
969
+ }
970
+ currentInputFile = null;
971
+ currentOutputFile = null;
972
+ logInfo(`\n${colors.bright}Done:${colors.reset} ${ok} ok, ${fail} failed`);
973
+ if (config.logFile) logInfo(`Log: ${config.logFile}`);
974
+ if (fail > 0) process.exit(CONSTANTS.EXIT_ERROR);
975
+ break;
976
+ }
977
+ default:
978
+ logError(`Unknown command: ${config.command}`);
979
+ showHelp();
980
+ process.exit(CONSTANTS.EXIT_ERROR);
981
+ }
982
+ } catch (error) {
983
+ generateCrashLog(error, {
984
+ currentFile: currentInputFile,
985
+ args: process.argv.slice(2)
986
+ });
987
+ logError(`Fatal error: ${error.message}`);
988
+ process.exit(CONSTANTS.EXIT_ERROR);
989
+ }
990
+ }
991
+
992
+ // Why: Catch unhandled promise rejections which would otherwise cause silent failures
993
+ process.on('unhandledRejection', (reason, promise) => {
994
+ const error = reason instanceof Error ? reason : new Error(String(reason));
995
+ generateCrashLog(error, { type: 'unhandledRejection', currentFile: currentInputFile });
996
+ logError(`Unhandled rejection: ${error.message}`);
997
+ process.exit(CONSTANTS.EXIT_ERROR);
998
+ });
999
+
1000
+ main().catch((e) => { logError(`Error: ${e.message}`); process.exit(CONSTANTS.EXIT_ERROR); });