@emasoft/svg-matrix 1.0.6 → 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.
- package/README.md +85 -0
- package/bin/svg-matrix.js +1000 -0
- package/package.json +12 -2
- package/scripts/bootstrap_repo.sh +99 -0
- package/scripts/postinstall.js +252 -0
- package/src/clip-path-resolver.js +2 -1
- package/src/index.js +15 -1
- package/src/logger.js +302 -0
|
@@ -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); });
|