@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/svgm.js CHANGED
@@ -13,14 +13,26 @@
13
13
  * @license MIT
14
14
  */
15
15
 
16
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
17
- import { join, dirname, basename, extname, resolve, isAbsolute } from 'path';
16
+ import {
17
+ readFileSync,
18
+ writeFileSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ statSync,
23
+ } from "fs";
24
+ import { join, dirname, basename, extname, resolve, isAbsolute } from "path";
25
+ import yaml from "js-yaml";
18
26
 
19
27
  // Import library modules
20
- import { VERSION } from '../src/index.js';
21
- import * as SVGToolbox from '../src/svg-toolbox.js';
22
- import { parseSVG, serializeSVG } from '../src/svg-parser.js';
23
- import { injectPolyfills, detectSVG2Features } from '../src/svg2-polyfills.js';
28
+ import { VERSION } from "../src/index.js";
29
+ import * as SVGToolbox from "../src/svg-toolbox.js";
30
+ import { parseSVG, serializeSVG } from "../src/svg-parser.js";
31
+ import {
32
+ injectPolyfills,
33
+ detectSVG2Features,
34
+ setPolyfillMinification,
35
+ } from "../src/svg2-polyfills.js";
24
36
 
25
37
  // ============================================================================
26
38
  // CONSTANTS
@@ -32,18 +44,32 @@ const CONSTANTS = {
32
44
  MAX_FILE_SIZE_BYTES: 50 * 1024 * 1024,
33
45
  EXIT_SUCCESS: 0,
34
46
  EXIT_ERROR: 1,
35
- SVG_EXTENSIONS: ['.svg', '.svgz'],
47
+ SVG_EXTENSIONS: [".svg", ".svgz"],
36
48
  };
37
49
 
38
50
  // ============================================================================
39
51
  // COLORS (respects NO_COLOR env)
40
52
  // ============================================================================
41
- const colors = process.env.NO_COLOR !== undefined || process.argv.includes('--no-color') ? {
42
- reset: '', red: '', yellow: '', green: '', cyan: '', dim: '', bright: '',
43
- } : {
44
- reset: '\x1b[0m', red: '\x1b[31m', yellow: '\x1b[33m',
45
- green: '\x1b[32m', cyan: '\x1b[36m', dim: '\x1b[2m', bright: '\x1b[1m',
46
- };
53
+ const colors =
54
+ process.env.NO_COLOR !== undefined || process.argv.includes("--no-color")
55
+ ? {
56
+ reset: "",
57
+ red: "",
58
+ yellow: "",
59
+ green: "",
60
+ cyan: "",
61
+ dim: "",
62
+ bright: "",
63
+ }
64
+ : {
65
+ reset: "\x1b[0m",
66
+ red: "\x1b[31m",
67
+ yellow: "\x1b[33m",
68
+ green: "\x1b[32m",
69
+ cyan: "\x1b[36m",
70
+ dim: "\x1b[2m",
71
+ bright: "\x1b[1m",
72
+ };
47
73
 
48
74
  // ============================================================================
49
75
  // CONFIGURATION
@@ -64,8 +90,24 @@ const DEFAULT_CONFIG = {
64
90
  quiet: false,
65
91
  datauri: null,
66
92
  preserveNamespaces: [],
93
+ preserveVendor: false,
67
94
  svg2Polyfills: false,
68
95
  showPlugins: false,
96
+ configFile: null,
97
+ // Embed options
98
+ embed: false,
99
+ embedImages: false,
100
+ embedExternalSVGs: false,
101
+ embedExternalSVGMode: "extract",
102
+ embedCSS: false,
103
+ embedFonts: false,
104
+ embedScripts: false,
105
+ embedAudio: false,
106
+ embedSubsetFonts: false,
107
+ embedRecursive: false,
108
+ embedMaxRecursionDepth: 10,
109
+ embedTimeout: 30000,
110
+ embedOnMissingResource: "warn",
69
111
  };
70
112
 
71
113
  let config = { ...DEFAULT_CONFIG };
@@ -73,10 +115,20 @@ let config = { ...DEFAULT_CONFIG };
73
115
  // ============================================================================
74
116
  // LOGGING
75
117
  // ============================================================================
118
+ /**
119
+ * Log message to console unless in quiet mode.
120
+ * @param {string} msg - Message to log
121
+ * @returns {void}
122
+ */
76
123
  function log(msg) {
77
124
  if (!config.quiet) console.log(msg);
78
125
  }
79
126
 
127
+ /**
128
+ * Log error message to console.
129
+ * @param {string} msg - Error message
130
+ * @returns {void}
131
+ */
80
132
  function logError(msg) {
81
133
  console.error(`${colors.red}error:${colors.reset} ${msg}`);
82
134
  }
@@ -86,44 +138,98 @@ function logError(msg) {
86
138
  // ============================================================================
87
139
  const OPTIMIZATIONS = [
88
140
  // Cleanup plugins
89
- { name: 'cleanupAttributes', description: 'Remove useless attributes' },
90
- { name: 'cleanupIds', description: 'Remove unused and minify used IDs' },
91
- { name: 'cleanupNumericValues', description: 'Round numeric values, remove default px units' },
92
- { name: 'cleanupListOfValues', description: 'Round list of numeric values' },
93
- { name: 'cleanupEnableBackground', description: 'Remove or fix enable-background attribute' },
141
+ { name: "cleanupAttributes", description: "Remove useless attributes" },
142
+ { name: "cleanupIds", description: "Remove unused and minify used IDs" },
143
+ {
144
+ name: "cleanupNumericValues",
145
+ description: "Round numeric values, remove default px units",
146
+ },
147
+ { name: "cleanupListOfValues", description: "Round list of numeric values" },
148
+ {
149
+ name: "cleanupEnableBackground",
150
+ description: "Remove or fix enable-background attribute",
151
+ },
94
152
  // Remove plugins
95
- { name: 'removeDoctype', description: 'Remove DOCTYPE declaration' },
96
- { name: 'removeXMLProcInst', description: 'Remove XML processing instructions' },
97
- { name: 'removeComments', description: 'Remove comments' },
98
- { name: 'removeMetadata', description: 'Remove <metadata> elements' },
99
- { name: 'removeTitle', description: 'Remove <title> elements (not in default)' },
100
- { name: 'removeDesc', description: 'Remove <desc> elements' },
101
- { name: 'removeEditorsNSData', description: 'Remove editor namespaces, elements, and attributes' },
102
- { name: 'removeEmptyAttrs', description: 'Remove empty attributes' },
103
- { name: 'removeEmptyContainers', description: 'Remove empty container elements' },
104
- { name: 'removeEmptyText', description: 'Remove empty text elements' },
105
- { name: 'removeHiddenElements', description: 'Remove hidden elements' },
106
- { name: 'removeUselessDefs', description: 'Remove unused <defs> content' },
107
- { name: 'removeUnknownsAndDefaults', description: 'Remove unknown elements and default attribute values' },
108
- { name: 'removeNonInheritableGroupAttrs', description: 'Remove non-inheritable presentation attributes from groups' },
153
+ { name: "removeDoctype", description: "Remove DOCTYPE declaration" },
154
+ {
155
+ name: "removeXMLProcInst",
156
+ description: "Remove XML processing instructions",
157
+ },
158
+ { name: "removeComments", description: "Remove comments" },
159
+ { name: "removeMetadata", description: "Remove <metadata> elements" },
160
+ {
161
+ name: "removeTitle",
162
+ description: "Remove <title> elements (not in default)",
163
+ },
164
+ { name: "removeDesc", description: "Remove <desc> elements" },
165
+ {
166
+ name: "removeEditorsNSData",
167
+ description: "Remove editor namespaces, elements, and attributes",
168
+ },
169
+ { name: "removeEmptyAttrs", description: "Remove empty attributes" },
170
+ {
171
+ name: "removeEmptyContainers",
172
+ description: "Remove empty container elements",
173
+ },
174
+ { name: "removeEmptyText", description: "Remove empty text elements" },
175
+ { name: "removeHiddenElements", description: "Remove hidden elements" },
176
+ { name: "removeUselessDefs", description: "Remove unused <defs> content" },
177
+ {
178
+ name: "removeUnknownsAndDefaults",
179
+ description: "Remove unknown elements and default attribute values",
180
+ },
181
+ {
182
+ name: "removeNonInheritableGroupAttrs",
183
+ description: "Remove non-inheritable presentation attributes from groups",
184
+ },
109
185
  // Convert plugins
110
- { name: 'convertShapesToPath', description: 'Convert basic shapes to paths' },
111
- { name: 'convertPathData', description: 'Optimize path data: convert, remove useless, etc.' },
112
- { name: 'convertTransform', description: 'Collapse multiple transforms into one, convert matrices' },
113
- { name: 'convertColors', description: 'Convert color values to shorter form' },
114
- { name: 'convertStyleToAttrs', description: 'Convert style to presentation attributes (not in default)' },
115
- { name: 'convertEllipseToCircle', description: 'Convert ellipse to circle when rx equals ry' },
186
+ { name: "convertShapesToPath", description: "Convert basic shapes to paths" },
187
+ {
188
+ name: "convertPathData",
189
+ description: "Optimize path data: convert, remove useless, etc.",
190
+ },
191
+ {
192
+ name: "convertTransform",
193
+ description: "Collapse multiple transforms into one, convert matrices",
194
+ },
195
+ {
196
+ name: "convertColors",
197
+ description: "Convert color values to shorter form",
198
+ },
199
+ {
200
+ name: "convertStyleToAttrs",
201
+ description: "Convert style to presentation attributes (not in default)",
202
+ },
203
+ {
204
+ name: "convertEllipseToCircle",
205
+ description: "Convert ellipse to circle when rx equals ry",
206
+ },
116
207
  // Structure plugins
117
- { name: 'collapseGroups', description: 'Collapse useless groups' },
118
- { name: 'mergePaths', description: 'Merge multiple paths into one' },
119
- { name: 'moveGroupAttrsToElems', description: 'Move group attributes to contained elements' },
120
- { name: 'moveElemsAttrsToGroup', description: 'Move common element attributes to parent group' },
208
+ { name: "collapseGroups", description: "Collapse useless groups" },
209
+ { name: "mergePaths", description: "Merge multiple paths into one" },
210
+ {
211
+ name: "moveGroupAttrsToElems",
212
+ description: "Move group attributes to contained elements",
213
+ },
214
+ {
215
+ name: "moveElemsAttrsToGroup",
216
+ description: "Move common element attributes to parent group",
217
+ },
121
218
  // Style plugins
122
- { name: 'minifyStyles', description: 'Minify <style> elements content' },
123
- { name: 'inlineStyles', description: 'Inline styles from <style> to element style attributes' },
219
+ { name: "minifyStyles", description: "Minify <style> elements content" },
220
+ {
221
+ name: "inlineStyles",
222
+ description: "Inline styles from <style> to element style attributes",
223
+ },
124
224
  // Sort plugins
125
- { name: 'sortAttrs', description: 'Sort attributes for better gzip compression' },
126
- { name: 'sortDefsChildren', description: 'Sort children of <defs> for better gzip compression' },
225
+ {
226
+ name: "sortAttrs",
227
+ description: "Sort attributes for better gzip compression",
228
+ },
229
+ {
230
+ name: "sortDefsChildren",
231
+ description: "Sort children of <defs> for better gzip compression",
232
+ },
127
233
  ];
128
234
 
129
235
  // ============================================================================
@@ -132,79 +238,131 @@ const OPTIMIZATIONS = [
132
238
  // ============================================================================
133
239
  const DEFAULT_PIPELINE = [
134
240
  // 1-6: Initial cleanup (matching SVGO preset-default order)
135
- 'removeDoctype',
136
- 'removeXMLProcInst',
137
- 'removeComments',
241
+ "removeDoctype",
242
+ "removeXMLProcInst",
243
+ "removeComments",
138
244
  // removeDeprecatedAttrs - not implemented (rarely needed)
139
- 'removeMetadata',
140
- 'removeEditorsNSData',
245
+ "removeMetadata",
246
+ "removeEditorsNSData",
141
247
  // 7-11: Style processing
142
- 'cleanupAttributes',
248
+ "cleanupAttributes",
143
249
  // mergeStyles - not implemented
144
- 'inlineStyles',
145
- 'minifyStyles',
146
- 'cleanupIds',
250
+ "inlineStyles",
251
+ "minifyStyles",
252
+ "cleanupIds",
147
253
  // 12-18: Remove unnecessary elements
148
- 'removeUselessDefs',
149
- 'cleanupNumericValues',
150
- 'convertColors',
151
- 'removeUnknownsAndDefaults',
152
- 'removeNonInheritableGroupAttrs',
254
+ "removeUselessDefs",
255
+ "cleanupNumericValues",
256
+ "convertColors",
257
+ "removeUnknownsAndDefaults",
258
+ "removeNonInheritableGroupAttrs",
153
259
  // removeUselessStrokeAndFill - not implemented
154
- 'cleanupEnableBackground',
155
- 'removeHiddenElements',
156
- 'removeEmptyText',
260
+ "cleanupEnableBackground",
261
+ "removeHiddenElements",
262
+ "removeEmptyText",
157
263
  // 19-27: Convert and optimize
158
264
  // NOTE: convertShapesToPath removed - SVGO only converts when it saves bytes
159
265
  // Our version converts all shapes which often increases size
160
- 'convertEllipseToCircle',
161
- 'moveElemsAttrsToGroup',
162
- 'moveGroupAttrsToElems',
163
- 'collapseGroups',
164
- 'convertPathData',
165
- 'convertTransform',
266
+ "convertEllipseToCircle",
267
+ "moveElemsAttrsToGroup",
268
+ "moveGroupAttrsToElems",
269
+ "collapseGroups",
270
+ "convertPathData",
271
+ "convertTransform",
166
272
  // 28-34: Final cleanup
167
- 'removeEmptyAttrs',
168
- 'removeEmptyContainers',
169
- 'mergePaths',
273
+ "removeEmptyAttrs",
274
+ "removeEmptyContainers",
275
+ "mergePaths",
170
276
  // removeUnusedNS - not implemented
171
- 'sortAttrs',
172
- 'sortDefsChildren',
173
- 'removeDesc',
277
+ "sortAttrs",
278
+ "sortDefsChildren",
279
+ "removeDesc",
174
280
  ];
175
281
 
176
282
  // ============================================================================
177
283
  // PATH UTILITIES
178
284
  // ============================================================================
179
- function normalizePath(p) { return p.replace(/\\/g, '/'); }
285
+ /**
286
+ * Normalize path separators to forward slashes.
287
+ * @param {string} p - Path to normalize
288
+ * @returns {string} Normalized path
289
+ */
290
+ function normalizePath(p) {
291
+ return p.replace(/\\/g, "/");
292
+ }
180
293
 
294
+ /**
295
+ * Resolve path to absolute path with normalized separators.
296
+ * @param {string} p - Path to resolve
297
+ * @returns {string} Absolute normalized path
298
+ */
181
299
  function resolvePath(p) {
182
- return isAbsolute(p) ? normalizePath(p) : normalizePath(resolve(process.cwd(), p));
300
+ return isAbsolute(p)
301
+ ? normalizePath(p)
302
+ : normalizePath(resolve(process.cwd(), p));
183
303
  }
184
304
 
185
- function isDir(p) { try { return statSync(p).isDirectory(); } catch { return false; } }
186
- function isFile(p) { try { return statSync(p).isFile(); } catch { return false; } }
305
+ /**
306
+ * Check if path is a directory.
307
+ * @param {string} p - Path to check
308
+ * @returns {boolean} True if directory exists
309
+ */
310
+ function isDir(p) {
311
+ try {
312
+ return statSync(p).isDirectory();
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
187
317
 
318
+ /**
319
+ * Check if path is a file.
320
+ * @param {string} p - Path to check
321
+ * @returns {boolean} True if file exists
322
+ */
323
+ function isFile(p) {
324
+ try {
325
+ return statSync(p).isFile();
326
+ } catch {
327
+ return false;
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Ensure directory exists, creating it if necessary.
333
+ * @param {string} dir - Directory path
334
+ * @returns {void}
335
+ */
188
336
  function ensureDir(dir) {
189
337
  if (!existsSync(dir)) {
190
338
  mkdirSync(dir, { recursive: true });
191
339
  }
192
340
  }
193
341
 
342
+ /**
343
+ * Get all SVG files in directory with optional exclusion patterns.
344
+ * @param {string} dir - Directory path
345
+ * @param {boolean} recursive - Whether to search recursively
346
+ * @param {string[]} exclude - Array of exclusion patterns
347
+ * @returns {string[]} Array of SVG file paths
348
+ */
194
349
  function getSvgFiles(dir, recursive = false, exclude = []) {
195
350
  const files = [];
196
351
  function scan(d) {
197
352
  for (const entry of readdirSync(d, { withFileTypes: true })) {
198
353
  const fullPath = join(d, entry.name);
199
354
  // Check exclusion patterns
200
- const shouldExclude = exclude.some(pattern => {
355
+ const shouldExclude = exclude.some((pattern) => {
201
356
  const regex = new RegExp(pattern);
202
357
  return regex.test(fullPath) || regex.test(entry.name);
203
358
  });
204
359
  if (shouldExclude) continue;
205
360
 
206
361
  if (entry.isDirectory() && recursive) scan(fullPath);
207
- else if (entry.isFile() && CONSTANTS.SVG_EXTENSIONS.includes(extname(entry.name).toLowerCase())) {
362
+ else if (
363
+ entry.isFile() &&
364
+ CONSTANTS.SVG_EXTENSIONS.includes(extname(entry.name).toLowerCase())
365
+ ) {
208
366
  files.push(normalizePath(fullPath));
209
367
  }
210
368
  }
@@ -216,17 +374,33 @@ function getSvgFiles(dir, recursive = false, exclude = []) {
216
374
  // ============================================================================
217
375
  // SVG OPTIMIZATION
218
376
  // ============================================================================
377
+ /**
378
+ * Optimize SVG content using default pipeline.
379
+ * @param {string} content - SVG content
380
+ * @param {Object} options - Optimization options
381
+ * @returns {Promise<string>} Optimized SVG content
382
+ */
219
383
  async function optimizeSvg(content, options = {}) {
220
384
  const doc = parseSVG(content);
221
385
  const pipeline = DEFAULT_PIPELINE;
222
386
 
387
+ // Detect SVG 2 features BEFORE optimization pipeline removes them
388
+ // (removeUnknownsAndDefaults strips elements not in SVG 1.1 spec)
389
+ let svg2Features = null;
390
+ if (options.svg2Polyfills) {
391
+ svg2Features = detectSVG2Features(doc);
392
+ }
393
+
223
394
  // Run optimization pipeline
224
395
  for (const pluginName of pipeline) {
225
396
  const fn = SVGToolbox[pluginName];
226
- if (fn && typeof fn === 'function') {
397
+ if (fn && typeof fn === "function") {
227
398
  try {
228
- await fn(doc, { precision: options.precision, preserveNamespaces: options.preserveNamespaces });
229
- } catch (e) {
399
+ await fn(doc, {
400
+ precision: options.precision,
401
+ preserveNamespaces: options.preserveNamespaces,
402
+ });
403
+ } catch {
230
404
  // Skip failed optimizations silently
231
405
  }
232
406
  }
@@ -236,21 +410,27 @@ async function optimizeSvg(content, options = {}) {
236
410
  if (options.multipass) {
237
411
  for (const pluginName of pipeline) {
238
412
  const fn = SVGToolbox[pluginName];
239
- if (fn && typeof fn === 'function') {
413
+ if (fn && typeof fn === "function") {
240
414
  try {
241
- await fn(doc, { precision: options.precision, preserveNamespaces: options.preserveNamespaces });
242
- } catch (e) {
415
+ await fn(doc, {
416
+ precision: options.precision,
417
+ preserveNamespaces: options.preserveNamespaces,
418
+ });
419
+ } catch {
243
420
  // Skip failed optimizations silently
244
421
  }
245
422
  }
246
423
  }
247
424
  }
248
425
 
249
- // Inject SVG 2 polyfills if requested
250
- if (options.svg2Polyfills) {
251
- const features = detectSVG2Features(doc);
252
- if (features.meshGradients.length > 0 || features.hatches.length > 0) {
253
- injectPolyfills(doc);
426
+ // Inject SVG 2 polyfills if requested (using pre-detected features)
427
+ if (options.svg2Polyfills && svg2Features) {
428
+ if (
429
+ svg2Features.meshGradients.length > 0 ||
430
+ svg2Features.hatches.length > 0
431
+ ) {
432
+ // Pass pre-detected features since pipeline may have removed SVG2 elements
433
+ injectPolyfills(doc, { features: svg2Features });
254
434
  }
255
435
  }
256
436
 
@@ -264,78 +444,140 @@ async function optimizeSvg(content, options = {}) {
264
444
  }
265
445
 
266
446
  // Handle EOL
267
- if (options.eol === 'crlf') {
268
- result = result.replace(/\n/g, '\r\n');
447
+ if (options.eol === "crlf") {
448
+ result = result.replace(/\n/g, "\r\n");
269
449
  }
270
450
 
271
451
  // Final newline
272
- if (options.finalNewline && !result.endsWith('\n')) {
273
- result += '\n';
452
+ if (options.finalNewline && !result.endsWith("\n")) {
453
+ result += "\n";
274
454
  }
275
455
 
276
456
  return result;
277
457
  }
278
458
 
279
459
  /**
280
- * Minify XML output while keeping it valid
281
- * - KEEPS XML declaration (ensures valid SVG)
282
- * - Remove whitespace between tags
283
- * - Collapse multiple spaces
460
+ * Minify XML output while keeping it valid.
461
+ * KEEPS XML declaration (ensures valid SVG), removes whitespace between tags,
462
+ * and collapses multiple spaces.
463
+ * @param {string} xml - XML content to minify
464
+ * @returns {string} Minified XML
284
465
  */
285
466
  function minifyXml(xml) {
286
- return xml
287
- // Remove newlines and collapse whitespace between tags
288
- .replace(/>\s+</g, '><')
289
- // Remove leading/trailing whitespace
290
- .trim();
467
+ return (
468
+ xml
469
+ // Remove newlines and collapse whitespace between tags
470
+ .replace(/>\s+</g, "><")
471
+ // Remove leading/trailing whitespace
472
+ .trim()
473
+ );
291
474
  }
292
475
 
476
+ /**
477
+ * Prettify XML with indentation.
478
+ * @param {string} xml - XML content to prettify
479
+ * @param {number} indent - Number of spaces per indent level
480
+ * @returns {string} Prettified XML
481
+ */
293
482
  function prettifyXml(xml, indent = 2) {
294
483
  // Simple XML prettifier
295
- const indentStr = ' '.repeat(indent);
296
- let formatted = '';
484
+ const indentStr = " ".repeat(indent);
485
+ let formatted = "";
297
486
  let depth = 0;
298
487
 
299
488
  // Split on tags
300
- xml.replace(/>\s*</g, '>\n<').split('\n').forEach(line => {
301
- line = line.trim();
302
- if (!line) return;
303
-
304
- // Decrease depth for closing tags
305
- if (line.startsWith('</')) {
306
- depth = Math.max(0, depth - 1);
307
- }
308
-
309
- formatted += indentStr.repeat(depth) + line + '\n';
489
+ xml
490
+ .replace(/>\s*</g, ">\n<")
491
+ .split("\n")
492
+ .forEach((originalLine) => {
493
+ const line = originalLine.trim();
494
+ if (!line) return;
495
+
496
+ // Decrease depth for closing tags
497
+ if (line.startsWith("</")) {
498
+ depth = Math.max(0, depth - 1);
499
+ }
310
500
 
311
- // Increase depth for opening tags (not self-closing)
312
- if (line.startsWith('<') && !line.startsWith('</') && !line.startsWith('<?') &&
313
- !line.startsWith('<!') && !line.endsWith('/>') && !line.includes('</')) {
314
- depth++;
315
- }
316
- });
501
+ formatted += indentStr.repeat(depth) + line + "\n";
502
+
503
+ // Increase depth for opening tags (not self-closing)
504
+ if (
505
+ line.startsWith("<") &&
506
+ !line.startsWith("</") &&
507
+ !line.startsWith("<?") &&
508
+ !line.startsWith("<!") &&
509
+ !line.endsWith("/>") &&
510
+ !line.includes("</")
511
+ ) {
512
+ depth++;
513
+ }
514
+ });
317
515
 
318
516
  return formatted.trim();
319
517
  }
320
518
 
519
+ /**
520
+ * Convert SVG content to data URI.
521
+ * @param {string} content - SVG content
522
+ * @param {string} format - Format: 'base64', 'enc', or 'unenc'
523
+ * @returns {string} Data URI string
524
+ */
321
525
  function toDataUri(content, format) {
322
- if (format === 'base64') {
323
- return 'data:image/svg+xml;base64,' + Buffer.from(content).toString('base64');
324
- } else if (format === 'enc') {
325
- return 'data:image/svg+xml,' + encodeURIComponent(content);
526
+ if (format === "base64") {
527
+ return (
528
+ "data:image/svg+xml;base64," + Buffer.from(content).toString("base64")
529
+ );
530
+ } else if (format === "enc") {
531
+ return "data:image/svg+xml," + encodeURIComponent(content);
326
532
  } else {
327
- return 'data:image/svg+xml,' + content;
533
+ return "data:image/svg+xml," + content;
328
534
  }
329
535
  }
330
536
 
331
537
  // ============================================================================
332
538
  // PROCESS FILES
333
539
  // ============================================================================
540
+ /**
541
+ * Process single SVG file with optimization.
542
+ * @param {string} inputPath - Input file path
543
+ * @param {string} outputPath - Output file path
544
+ * @param {Object} options - Processing options
545
+ * @returns {Promise<Object>} Processing result with success status and metrics
546
+ */
334
547
  async function processFile(inputPath, outputPath, options) {
335
548
  try {
336
- const content = readFileSync(inputPath, 'utf8');
549
+ let content = readFileSync(inputPath, "utf8");
337
550
  const originalSize = Buffer.byteLength(content);
338
551
 
552
+ // Apply embedding if enabled
553
+ if (options.embed) {
554
+ const doc = parseSVG(content);
555
+ const embedOptions = {
556
+ images: options.embedImages,
557
+ externalSVGs: options.embedExternalSVGs,
558
+ externalSVGMode: options.embedExternalSVGMode,
559
+ css: options.embedCSS,
560
+ fonts: options.embedFonts,
561
+ scripts: options.embedScripts,
562
+ audio: options.embedAudio,
563
+ subsetFonts: options.embedSubsetFonts,
564
+ recursive: options.embedRecursive,
565
+ maxRecursionDepth: options.embedMaxRecursionDepth,
566
+ timeout: options.embedTimeout,
567
+ onMissingResource: options.embedOnMissingResource,
568
+ baseDir: dirname(inputPath),
569
+ };
570
+
571
+ if (SVGToolbox.embedExternalDependencies) {
572
+ await SVGToolbox.embedExternalDependencies(doc, embedOptions);
573
+ content = serializeSVG(doc);
574
+ } else {
575
+ log(
576
+ `${colors.yellow}Warning:${colors.reset} embedExternalDependencies not available in svg-toolbox`,
577
+ );
578
+ }
579
+ }
580
+
339
581
  const optimized = await optimizeSvg(content, options);
340
582
  const optimizedSize = Buffer.byteLength(optimized);
341
583
 
@@ -344,17 +586,25 @@ async function processFile(inputPath, outputPath, options) {
344
586
  output = toDataUri(optimized, options.datauri);
345
587
  }
346
588
 
347
- if (outputPath === '-') {
589
+ if (outputPath === "-") {
348
590
  process.stdout.write(output);
349
591
  } else {
350
592
  ensureDir(dirname(outputPath));
351
- writeFileSync(outputPath, output, 'utf8');
593
+ writeFileSync(outputPath, output, "utf8");
352
594
  }
353
595
 
354
596
  const savings = originalSize - optimizedSize;
355
597
  const percent = ((savings / originalSize) * 100).toFixed(1);
356
598
 
357
- return { success: true, originalSize, optimizedSize, savings, percent, inputPath, outputPath };
599
+ return {
600
+ success: true,
601
+ originalSize,
602
+ optimizedSize,
603
+ savings,
604
+ percent,
605
+ inputPath,
606
+ outputPath,
607
+ };
358
608
  } catch (error) {
359
609
  return { success: false, error: error.message, inputPath };
360
610
  }
@@ -363,6 +613,10 @@ async function processFile(inputPath, outputPath, options) {
363
613
  // ============================================================================
364
614
  // HELP
365
615
  // ============================================================================
616
+ /**
617
+ * Display help message.
618
+ * @returns {void}
619
+ */
366
620
  function showHelp() {
367
621
  console.log(`Usage: svgm [options] [INPUT...]
368
622
 
@@ -397,156 +651,363 @@ Options:
397
651
  --show-plugins Show available plugins and exit
398
652
  --preserve-ns <NS,...> Preserve vendor namespaces (inkscape, sodipodi,
399
653
  illustrator, figma, etc.). Comma-separated.
654
+ --preserve-vendor Keep all vendor prefixes and editor namespaces
400
655
  --svg2-polyfills Inject JavaScript polyfills for SVG 2 features
401
656
  (mesh gradients, hatches) for browser support
657
+ --no-minify-polyfills Use full (non-minified) polyfills for debugging
402
658
  --no-color Output plain text without color
403
659
  -h, --help Display help for command
404
660
 
661
+ Embed Options:
662
+ --config <path> Load settings from YAML configuration file
663
+ --embed, --embed-all Enable all embedding options
664
+ --embed-images Embed external images as data URIs
665
+ --embed-external-svgs Embed external SVG files
666
+ --embed-svg-mode <mode> Mode for external SVGs: 'extract' or 'full'
667
+ --embed-css Embed external CSS files
668
+ --embed-fonts Embed external font files
669
+ --embed-scripts Embed external JavaScript files
670
+ --embed-audio Embed external audio files
671
+ --embed-subset-fonts Subset fonts to used glyphs only
672
+ --embed-recursive Recursively embed dependencies
673
+ --embed-max-depth <n> Maximum recursion depth (default: 10)
674
+ --embed-timeout <ms> Timeout for external resources (default: 30000)
675
+ --embed-on-missing <mode> Handle missing resources: 'warn', 'fail', 'skip'
676
+
405
677
  Examples:
406
678
  svgm input.svg -o output.svg
407
679
  svgm -f ./icons/ -o ./optimized/
408
680
  svgm input.svg --pretty --indent 4
409
681
  svgm -p 2 --multipass input.svg
682
+ svgm input.svg --embed-all -o output.svg
683
+ svgm input.svg --config svgm.yml -o output.svg
410
684
 
411
685
  Docs: https://github.com/Emasoft/SVG-MATRIX#readme`);
412
686
  }
413
687
 
688
+ /**
689
+ * Display version number.
690
+ * @returns {void}
691
+ */
414
692
  function showVersion() {
415
693
  console.log(VERSION);
416
694
  }
417
695
 
696
+ /**
697
+ * Display available optimization plugins.
698
+ * @returns {void}
699
+ */
418
700
  function showPlugins() {
419
- console.log('\nAvailable optimizations:\n');
701
+ console.log("\nAvailable optimizations:\n");
420
702
  for (const opt of OPTIMIZATIONS) {
421
- console.log(` ${colors.green}${opt.name.padEnd(30)}${colors.reset} ${opt.description}`);
703
+ console.log(
704
+ ` ${colors.green}${opt.name.padEnd(30)}${colors.reset} ${opt.description}`,
705
+ );
422
706
  }
423
707
  console.log(`\nTotal: ${OPTIMIZATIONS.length} optimizations\n`);
424
708
  }
425
709
 
710
+ // ============================================================================
711
+ // CONFIG FILE LOADING
712
+ // ============================================================================
713
+ /**
714
+ * Load and parse configuration file (YAML).
715
+ * @param {string} configPath - Path to config file
716
+ * @returns {Object} Parsed configuration
717
+ */
718
+ function loadConfigFile(configPath) {
719
+ try {
720
+ const absolutePath = resolvePath(configPath);
721
+ if (!existsSync(absolutePath)) {
722
+ logError(`Config file not found: ${configPath}`);
723
+ process.exit(CONSTANTS.EXIT_ERROR);
724
+ }
725
+ const content = readFileSync(absolutePath, "utf8");
726
+ const loadedConfig = yaml.load(content);
727
+
728
+ // Convert YAML config structure to CLI config structure
729
+ const result = {};
730
+
731
+ if (loadedConfig.embed) {
732
+ const embedCfg = loadedConfig.embed;
733
+ if (embedCfg.images !== undefined) result.embedImages = embedCfg.images;
734
+ if (embedCfg.externalSVGs !== undefined)
735
+ result.embedExternalSVGs = embedCfg.externalSVGs;
736
+ if (embedCfg.externalSVGMode !== undefined)
737
+ result.embedExternalSVGMode = embedCfg.externalSVGMode;
738
+ if (embedCfg.css !== undefined) result.embedCSS = embedCfg.css;
739
+ if (embedCfg.fonts !== undefined) result.embedFonts = embedCfg.fonts;
740
+ if (embedCfg.scripts !== undefined)
741
+ result.embedScripts = embedCfg.scripts;
742
+ if (embedCfg.audio !== undefined) result.embedAudio = embedCfg.audio;
743
+ if (embedCfg.subsetFonts !== undefined)
744
+ result.embedSubsetFonts = embedCfg.subsetFonts;
745
+ if (embedCfg.recursive !== undefined)
746
+ result.embedRecursive = embedCfg.recursive;
747
+ if (embedCfg.maxRecursionDepth !== undefined)
748
+ result.embedMaxRecursionDepth = embedCfg.maxRecursionDepth;
749
+ if (embedCfg.timeout !== undefined)
750
+ result.embedTimeout = embedCfg.timeout;
751
+ if (embedCfg.onMissingResource !== undefined)
752
+ result.embedOnMissingResource = embedCfg.onMissingResource;
753
+
754
+ // If any embed option is enabled, set embed flag
755
+ if (
756
+ Object.keys(result).some(
757
+ (key) => key.startsWith("embed") && result[key] === true,
758
+ )
759
+ ) {
760
+ result.embed = true;
761
+ }
762
+ }
763
+
764
+ // Support other config options if present
765
+ if (loadedConfig.precision !== undefined)
766
+ result.precision = loadedConfig.precision;
767
+ if (loadedConfig.multipass !== undefined)
768
+ result.multipass = loadedConfig.multipass;
769
+ if (loadedConfig.pretty !== undefined) result.pretty = loadedConfig.pretty;
770
+ if (loadedConfig.indent !== undefined) result.indent = loadedConfig.indent;
771
+ if (loadedConfig.quiet !== undefined) result.quiet = loadedConfig.quiet;
772
+
773
+ return result;
774
+ } catch (error) {
775
+ logError(`Failed to load config file: ${error.message}`);
776
+ process.exit(CONSTANTS.EXIT_ERROR);
777
+ }
778
+ }
779
+
426
780
  // ============================================================================
427
781
  // ARGUMENT PARSING
428
782
  // ============================================================================
783
+ /**
784
+ * Parse command-line arguments.
785
+ * @param {string[]} args - Command-line arguments
786
+ * @returns {Object} Parsed configuration object
787
+ */
429
788
  function parseArgs(args) {
430
- const cfg = { ...DEFAULT_CONFIG };
789
+ let cfg = { ...DEFAULT_CONFIG };
431
790
  const inputs = [];
432
791
  let i = 0;
433
792
 
434
793
  while (i < args.length) {
435
- const arg = args[i];
794
+ let arg = args[i];
795
+ let argValue = null;
796
+
797
+ // Handle --arg=value format
798
+ if (arg.includes("=") && arg.startsWith("--")) {
799
+ const eqIdx = arg.indexOf("=");
800
+ argValue = arg.substring(eqIdx + 1);
801
+ arg = arg.substring(0, eqIdx);
802
+ }
436
803
 
437
804
  switch (arg) {
438
- case '-v':
439
- case '--version':
805
+ case "-v":
806
+ case "--version":
440
807
  showVersion();
441
808
  process.exit(CONSTANTS.EXIT_SUCCESS);
442
809
  break;
443
810
 
444
- case '-h':
445
- case '--help':
811
+ case "-h":
812
+ case "--help":
446
813
  showHelp();
447
814
  process.exit(CONSTANTS.EXIT_SUCCESS);
448
815
  break;
449
816
 
450
- case '-i':
451
- case '--input':
817
+ case "-i":
818
+ case "--input":
452
819
  i++;
453
- while (i < args.length && !args[i].startsWith('-')) {
820
+ while (i < args.length && !args[i].startsWith("-")) {
454
821
  inputs.push(args[i]);
455
822
  i++;
456
823
  }
457
824
  i--; // Back up one since the while loop went past
458
825
  break;
459
826
 
460
- case '-s':
461
- case '--string':
827
+ case "-s":
828
+ case "--string":
462
829
  cfg.string = args[++i];
463
830
  break;
464
831
 
465
- case '-f':
466
- case '--folder':
832
+ case "-f":
833
+ case "--folder":
467
834
  cfg.folder = args[++i];
468
835
  break;
469
836
 
470
- case '-o':
471
- case '--output':
837
+ case "-o":
838
+ case "--output": {
472
839
  i++;
473
840
  // Collect output(s)
474
841
  const outputs = [];
475
- while (i < args.length && !args[i].startsWith('-')) {
842
+ while (i < args.length && !args[i].startsWith("-")) {
476
843
  outputs.push(args[i]);
477
844
  i++;
478
845
  }
479
846
  i--;
480
847
  cfg.output = outputs.length === 1 ? outputs[0] : outputs;
481
848
  break;
849
+ }
482
850
 
483
- case '-p':
484
- case '--precision':
851
+ case "-p":
852
+ case "--precision":
485
853
  cfg.precision = parseInt(args[++i], 10);
486
854
  break;
487
855
 
488
- case '--datauri':
856
+ case "--datauri":
489
857
  cfg.datauri = args[++i];
490
858
  break;
491
859
 
492
- case '--multipass':
860
+ case "--multipass":
493
861
  cfg.multipass = true;
494
862
  break;
495
863
 
496
- case '--pretty':
864
+ case "--pretty":
497
865
  cfg.pretty = true;
498
866
  break;
499
867
 
500
- case '--indent':
868
+ case "--indent":
501
869
  cfg.indent = parseInt(args[++i], 10);
502
870
  break;
503
871
 
504
- case '--eol':
872
+ case "--eol":
505
873
  cfg.eol = args[++i];
506
874
  break;
507
875
 
508
- case '--final-newline':
876
+ case "--final-newline":
509
877
  cfg.finalNewline = true;
510
878
  break;
511
879
 
512
- case '-r':
513
- case '--recursive':
880
+ case "-r":
881
+ case "--recursive":
514
882
  cfg.recursive = true;
515
883
  break;
516
884
 
517
- case '--exclude':
885
+ case "--exclude":
518
886
  i++;
519
- while (i < args.length && !args[i].startsWith('-')) {
887
+ while (i < args.length && !args[i].startsWith("-")) {
520
888
  cfg.exclude.push(args[i]);
521
889
  i++;
522
890
  }
523
891
  i--;
524
892
  break;
525
893
 
526
- case '-q':
527
- case '--quiet':
894
+ case "-q":
895
+ case "--quiet":
528
896
  cfg.quiet = true;
529
897
  break;
530
898
 
531
- case '--show-plugins':
899
+ case "--show-plugins":
532
900
  cfg.showPlugins = true;
533
901
  break;
534
902
 
535
- case '--preserve-ns':
536
- i++;
537
- cfg.preserveNamespaces = args[i].split(',').map(s => s.trim().toLowerCase());
903
+ case "--preserve-ns":
904
+ {
905
+ const val = argValue || args[++i];
906
+ if (!val) {
907
+ logError(
908
+ "--preserve-ns requires a comma-separated list of namespaces",
909
+ );
910
+ process.exit(1);
911
+ }
912
+ cfg.preserveNamespaces = val
913
+ .split(",")
914
+ .map((s) => s.trim().toLowerCase())
915
+ .filter((s) => s.length > 0);
916
+ }
917
+ break;
918
+
919
+ case "--preserve-vendor":
920
+ cfg.preserveVendor = true;
538
921
  break;
539
922
 
540
- case '--svg2-polyfills':
923
+ case "--svg2-polyfills":
541
924
  cfg.svg2Polyfills = true;
542
925
  break;
543
926
 
544
- case '--no-color':
927
+ case "--no-minify-polyfills":
928
+ cfg.noMinifyPolyfills = true;
929
+ setPolyfillMinification(false);
930
+ break;
931
+
932
+ case "--no-color":
545
933
  // Already handled in colors initialization
546
934
  break;
547
935
 
936
+ case "--config":
937
+ cfg.configFile = args[++i];
938
+ break;
939
+
940
+ case "--embed":
941
+ case "--embed-all":
942
+ cfg.embed = true;
943
+ cfg.embedImages = true;
944
+ cfg.embedExternalSVGs = true;
945
+ cfg.embedCSS = true;
946
+ cfg.embedFonts = true;
947
+ cfg.embedScripts = true;
948
+ cfg.embedAudio = true;
949
+ cfg.embedSubsetFonts = true;
950
+ cfg.embedRecursive = true;
951
+ break;
952
+
953
+ case "--embed-images":
954
+ cfg.embed = true;
955
+ cfg.embedImages = true;
956
+ break;
957
+
958
+ case "--embed-external-svgs":
959
+ cfg.embed = true;
960
+ cfg.embedExternalSVGs = true;
961
+ break;
962
+
963
+ case "--embed-svg-mode":
964
+ cfg.embedExternalSVGMode = args[++i];
965
+ break;
966
+
967
+ case "--embed-css":
968
+ cfg.embed = true;
969
+ cfg.embedCSS = true;
970
+ break;
971
+
972
+ case "--embed-fonts":
973
+ cfg.embed = true;
974
+ cfg.embedFonts = true;
975
+ break;
976
+
977
+ case "--embed-scripts":
978
+ cfg.embed = true;
979
+ cfg.embedScripts = true;
980
+ break;
981
+
982
+ case "--embed-audio":
983
+ cfg.embed = true;
984
+ cfg.embedAudio = true;
985
+ break;
986
+
987
+ case "--embed-subset-fonts":
988
+ cfg.embed = true;
989
+ cfg.embedSubsetFonts = true;
990
+ break;
991
+
992
+ case "--embed-recursive":
993
+ cfg.embed = true;
994
+ cfg.embedRecursive = true;
995
+ break;
996
+
997
+ case "--embed-max-depth":
998
+ cfg.embedMaxRecursionDepth = parseInt(args[++i], 10);
999
+ break;
1000
+
1001
+ case "--embed-timeout":
1002
+ cfg.embedTimeout = parseInt(args[++i], 10);
1003
+ break;
1004
+
1005
+ case "--embed-on-missing":
1006
+ cfg.embedOnMissingResource = args[++i];
1007
+ break;
1008
+
548
1009
  default:
549
- if (arg.startsWith('-')) {
1010
+ if (arg.startsWith("-")) {
550
1011
  logError(`Unknown option: ${arg}`);
551
1012
  process.exit(CONSTANTS.EXIT_ERROR);
552
1013
  }
@@ -556,12 +1017,87 @@ function parseArgs(args) {
556
1017
  }
557
1018
 
558
1019
  cfg.inputs = inputs;
1020
+
1021
+ // Validate numeric arguments
1022
+ if (
1023
+ cfg.precision !== undefined &&
1024
+ (isNaN(cfg.precision) || cfg.precision < 0 || cfg.precision > 20)
1025
+ ) {
1026
+ logError("--precision must be a number between 0 and 20");
1027
+ process.exit(CONSTANTS.EXIT_ERROR);
1028
+ }
1029
+ if (
1030
+ cfg.indent !== undefined &&
1031
+ (isNaN(cfg.indent) || cfg.indent < 0 || cfg.indent > 16)
1032
+ ) {
1033
+ logError("--indent must be a number between 0 and 16");
1034
+ process.exit(CONSTANTS.EXIT_ERROR);
1035
+ }
1036
+ if (
1037
+ cfg.embedMaxRecursionDepth !== undefined &&
1038
+ (isNaN(cfg.embedMaxRecursionDepth) ||
1039
+ cfg.embedMaxRecursionDepth < 1 ||
1040
+ cfg.embedMaxRecursionDepth > 100)
1041
+ ) {
1042
+ logError("--embed-max-depth must be a number between 1 and 100");
1043
+ process.exit(CONSTANTS.EXIT_ERROR);
1044
+ }
1045
+ if (
1046
+ cfg.embedTimeout !== undefined &&
1047
+ (isNaN(cfg.embedTimeout) ||
1048
+ cfg.embedTimeout < 1000 ||
1049
+ cfg.embedTimeout > 300000)
1050
+ ) {
1051
+ logError("--embed-timeout must be a number between 1000 and 300000 (ms)");
1052
+ process.exit(CONSTANTS.EXIT_ERROR);
1053
+ }
1054
+
1055
+ // Validate enum arguments (only check if explicitly set, not null/undefined defaults)
1056
+ const validEol = ["lf", "crlf"];
1057
+ if (cfg.eol != null && !validEol.includes(cfg.eol)) {
1058
+ logError(`--eol must be one of: ${validEol.join(", ")}`);
1059
+ process.exit(CONSTANTS.EXIT_ERROR);
1060
+ }
1061
+ const validDatauri = ["base64", "enc", "unenc"];
1062
+ if (cfg.datauri != null && !validDatauri.includes(cfg.datauri)) {
1063
+ logError(`--datauri must be one of: ${validDatauri.join(", ")}`);
1064
+ process.exit(CONSTANTS.EXIT_ERROR);
1065
+ }
1066
+ const validSvgMode = ["extract", "full"];
1067
+ if (
1068
+ cfg.embedExternalSVGMode != null &&
1069
+ cfg.embedExternalSVGMode !== "extract" &&
1070
+ !validSvgMode.includes(cfg.embedExternalSVGMode)
1071
+ ) {
1072
+ logError(`--embed-svg-mode must be one of: ${validSvgMode.join(", ")}`);
1073
+ process.exit(CONSTANTS.EXIT_ERROR);
1074
+ }
1075
+ const validOnMissing = ["warn", "fail", "skip"];
1076
+ if (
1077
+ cfg.embedOnMissingResource != null &&
1078
+ !validOnMissing.includes(cfg.embedOnMissingResource)
1079
+ ) {
1080
+ logError(`--embed-on-missing must be one of: ${validOnMissing.join(", ")}`);
1081
+ process.exit(CONSTANTS.EXIT_ERROR);
1082
+ }
1083
+
1084
+ // Load config file if specified and merge with CLI options (CLI takes precedence)
1085
+ if (cfg.configFile) {
1086
+ const fileConfig = loadConfigFile(cfg.configFile);
1087
+ // Merge: file config first, then CLI overrides
1088
+ cfg = { ...DEFAULT_CONFIG, ...fileConfig, ...cfg };
1089
+ }
1090
+
559
1091
  return cfg;
560
1092
  }
561
1093
 
562
1094
  // ============================================================================
563
1095
  // MAIN
564
1096
  // ============================================================================
1097
+ /**
1098
+ * Main entry point for CLI.
1099
+ * @returns {Promise<void>}
1100
+ */
565
1101
  async function main() {
566
1102
  const args = process.argv.slice(2);
567
1103
 
@@ -587,15 +1123,32 @@ async function main() {
587
1123
  datauri: config.datauri,
588
1124
  preserveNamespaces: config.preserveNamespaces,
589
1125
  svg2Polyfills: config.svg2Polyfills,
1126
+ noMinifyPolyfills: config.noMinifyPolyfills, // Pass through for pipeline consistency
1127
+ // Embed options
1128
+ embed: config.embed,
1129
+ embedImages: config.embedImages,
1130
+ embedExternalSVGs: config.embedExternalSVGs,
1131
+ embedExternalSVGMode: config.embedExternalSVGMode,
1132
+ embedCSS: config.embedCSS,
1133
+ embedFonts: config.embedFonts,
1134
+ embedScripts: config.embedScripts,
1135
+ embedAudio: config.embedAudio,
1136
+ embedSubsetFonts: config.embedSubsetFonts,
1137
+ embedRecursive: config.embedRecursive,
1138
+ embedMaxRecursionDepth: config.embedMaxRecursionDepth,
1139
+ embedTimeout: config.embedTimeout,
1140
+ embedOnMissingResource: config.embedOnMissingResource,
590
1141
  };
591
1142
 
592
1143
  // Handle string input
593
1144
  if (config.string) {
594
1145
  try {
595
1146
  const result = await optimizeSvg(config.string, options);
596
- const output = config.datauri ? toDataUri(result, config.datauri) : result;
597
- if (config.output && config.output !== '-') {
598
- writeFileSync(config.output, output, 'utf8');
1147
+ const output = config.datauri
1148
+ ? toDataUri(result, config.datauri)
1149
+ : result;
1150
+ if (config.output && config.output !== "-") {
1151
+ writeFileSync(config.output, output, "utf8");
599
1152
  log(`${colors.green}Done!${colors.reset}`);
600
1153
  } else {
601
1154
  process.stdout.write(output);
@@ -621,9 +1174,9 @@ async function main() {
621
1174
 
622
1175
  // Add explicit inputs
623
1176
  for (const input of config.inputs) {
624
- if (input === '-') {
1177
+ if (input === "-") {
625
1178
  // STDIN handling would go here
626
- logError('STDIN not yet supported');
1179
+ logError("STDIN not yet supported");
627
1180
  process.exit(CONSTANTS.EXIT_ERROR);
628
1181
  }
629
1182
  const resolved = resolvePath(input);
@@ -638,7 +1191,7 @@ async function main() {
638
1191
  }
639
1192
 
640
1193
  if (files.length === 0) {
641
- logError('No input files');
1194
+ logError("No input files");
642
1195
  process.exit(CONSTANTS.EXIT_ERROR);
643
1196
  }
644
1197
 
@@ -653,8 +1206,8 @@ async function main() {
653
1206
  let outputPath;
654
1207
 
655
1208
  if (config.output) {
656
- if (config.output === '-') {
657
- outputPath = '-';
1209
+ if (config.output === "-") {
1210
+ outputPath = "-";
658
1211
  } else if (Array.isArray(config.output)) {
659
1212
  outputPath = config.output[i] || config.output[0];
660
1213
  } else if (files.length > 1 || isDir(resolvePath(config.output))) {
@@ -675,8 +1228,10 @@ async function main() {
675
1228
  totalOriginal += result.originalSize;
676
1229
  totalOptimized += result.optimizedSize;
677
1230
 
678
- if (outputPath !== '-') {
679
- log(`${colors.green}${basename(inputPath)}${colors.reset} - ${result.originalSize} B -> ${result.optimizedSize} B (${result.percent}% saved)`);
1231
+ if (outputPath !== "-") {
1232
+ log(
1233
+ `${colors.green}${basename(inputPath)}${colors.reset} - ${result.originalSize} B -> ${result.optimizedSize} B (${result.percent}% saved)`,
1234
+ );
680
1235
  }
681
1236
  } else {
682
1237
  errorCount++;
@@ -687,9 +1242,14 @@ async function main() {
687
1242
  // Summary
688
1243
  if (files.length > 1 && !config.quiet) {
689
1244
  const totalSavings = totalOriginal - totalOptimized;
690
- const totalPercent = totalOriginal > 0 ? ((totalSavings / totalOriginal) * 100).toFixed(1) : 0;
691
- console.log(`\n${colors.bright}Total:${colors.reset} ${successCount} file(s) optimized, ${errorCount} error(s)`);
692
- console.log(`${colors.bright}Savings:${colors.reset} ${totalOriginal} B -> ${totalOptimized} B (${totalPercent}% saved)`);
1245
+ const totalPercent =
1246
+ totalOriginal > 0 ? ((totalSavings / totalOriginal) * 100).toFixed(1) : 0;
1247
+ console.log(
1248
+ `\n${colors.bright}Total:${colors.reset} ${successCount} file(s) optimized, ${errorCount} error(s)`,
1249
+ );
1250
+ console.log(
1251
+ `${colors.bright}Savings:${colors.reset} ${totalOriginal} B -> ${totalOptimized} B (${totalPercent}% saved)`,
1252
+ );
693
1253
  }
694
1254
 
695
1255
  if (errorCount > 0) {