@emasoft/svg-matrix 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1666 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @fileoverview SVG Font Management CLI
4
+ *
5
+ * Dedicated CLI tool for SVG font operations:
6
+ * - Embed external fonts as base64 data URIs
7
+ * - Extract embedded fonts to files
8
+ * - Apply font replacement maps
9
+ * - Interactive font management mode
10
+ *
11
+ * @module bin/svgfonts
12
+ * @license MIT
13
+ */
14
+
15
+ import {
16
+ readFileSync,
17
+ writeFileSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ statSync,
22
+ realpathSync,
23
+ } from "fs";
24
+ import { join, basename, extname, resolve, isAbsolute } from "path";
25
+ import { createInterface } from "readline";
26
+
27
+ // Import library modules
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 * as FontManager from "../src/font-manager.js";
32
+
33
+ // ============================================================================
34
+ // CONSTANTS
35
+ // ============================================================================
36
+ const CONSTANTS = {
37
+ MAX_FILE_SIZE_BYTES: 50 * 1024 * 1024, // 50MB
38
+ EXIT_SUCCESS: 0,
39
+ EXIT_ERROR: 1,
40
+ SVG_EXTENSIONS: [".svg", ".svgz"],
41
+ DEFAULT_TIMEOUT: 30000,
42
+ DEFAULT_FONTS_DIR: "./fonts",
43
+ };
44
+
45
+ // ============================================================================
46
+ // COLORS (respects NO_COLOR env)
47
+ // ============================================================================
48
+ const colors =
49
+ process.env.NO_COLOR !== undefined || process.argv.includes("--no-color")
50
+ ? {
51
+ reset: "",
52
+ red: "",
53
+ yellow: "",
54
+ green: "",
55
+ cyan: "",
56
+ dim: "",
57
+ bright: "",
58
+ magenta: "",
59
+ }
60
+ : {
61
+ reset: "\x1b[0m",
62
+ red: "\x1b[31m",
63
+ yellow: "\x1b[33m",
64
+ green: "\x1b[32m",
65
+ cyan: "\x1b[36m",
66
+ dim: "\x1b[2m",
67
+ bright: "\x1b[1m",
68
+ magenta: "\x1b[35m",
69
+ };
70
+
71
+ // ============================================================================
72
+ // CONFIGURATION
73
+ // ============================================================================
74
+ const DEFAULT_CONFIG = {
75
+ command: null,
76
+ inputs: [],
77
+ output: null,
78
+ recursive: false,
79
+ quiet: false,
80
+ verbose: false,
81
+ noBackup: false,
82
+ validate: false,
83
+ dryRun: false,
84
+ // Embed options
85
+ subset: true,
86
+ full: false,
87
+ woff2: false,
88
+ source: null, // 'google' | 'local' | 'fontget' | 'fnt'
89
+ timeout: CONSTANTS.DEFAULT_TIMEOUT,
90
+ useCache: true,
91
+ searchAlternatives: false,
92
+ // Replace options
93
+ mapFile: null,
94
+ autoDownload: true,
95
+ // Extract options
96
+ extractDir: CONSTANTS.DEFAULT_FONTS_DIR,
97
+ restoreLinks: false,
98
+ // Template options
99
+ templateOutput: null,
100
+ // Cache options
101
+ cacheAction: "stats", // 'stats' | 'clean' | 'list'
102
+ maxAge: 30, // days
103
+ // Search options
104
+ query: null,
105
+ limit: 10,
106
+ threshold: 0.3,
107
+ };
108
+
109
+ let config = { ...DEFAULT_CONFIG };
110
+
111
+ // ============================================================================
112
+ // LOGGING
113
+ // ============================================================================
114
+ function log(msg) {
115
+ if (!config.quiet) console.log(msg);
116
+ }
117
+
118
+ function logError(msg) {
119
+ console.error(`${colors.red}error:${colors.reset} ${msg}`);
120
+ }
121
+
122
+ function logWarn(msg) {
123
+ if (!config.quiet) console.warn(`${colors.yellow}warn:${colors.reset} ${msg}`);
124
+ }
125
+
126
+ function logSuccess(msg) {
127
+ if (!config.quiet) console.log(`${colors.green}${msg}${colors.reset}`);
128
+ }
129
+
130
+ function logVerbose(msg) {
131
+ if (config.verbose && !config.quiet) {
132
+ console.log(`${colors.dim}${msg}${colors.reset}`);
133
+ }
134
+ }
135
+
136
+ // ============================================================================
137
+ // HELP
138
+ // ============================================================================
139
+ const HELP = `
140
+ ${colors.bright}svgfonts${colors.reset} - SVG Font Management Tool v${VERSION}
141
+
142
+ ${colors.cyan}USAGE:${colors.reset}
143
+ svgfonts [command] [options] <input.svg...>
144
+
145
+ ${colors.cyan}COMMANDS:${colors.reset}
146
+ embed Embed external fonts into SVG (default)
147
+ extract Extract embedded fonts to files
148
+ replace Apply font replacement map
149
+ list List fonts in SVG
150
+ interactive Interactive font management mode
151
+ template Generate replacement map template
152
+ cache Manage font cache (stats, clean)
153
+ search Search for fonts by name with similarity matching
154
+ dedupe Detect and merge duplicate @font-face rules
155
+
156
+ ${colors.cyan}GLOBAL OPTIONS:${colors.reset}
157
+ -i, --input <file> Input SVG file(s)
158
+ -o, --output <file> Output file (default: overwrite input)
159
+ -r, --recursive Process directories recursively
160
+ -q, --quiet Suppress output
161
+ -v, --verbose Verbose output
162
+ --no-backup Skip backup creation (default: create .bak)
163
+ --validate Validate SVG after each operation
164
+ --dry-run Show what would be done without changes
165
+ --no-color Disable colored output
166
+ -h, --help Show this help
167
+ --version Show version
168
+
169
+ ${colors.cyan}EMBED OPTIONS:${colors.reset}
170
+ --subset Only embed glyphs used in SVG (default)
171
+ --full Embed complete font files
172
+ --woff2 Convert fonts to WOFF2 format (~30% smaller)
173
+ --source <name> Preferred font source (google|local|fontget|fnt)
174
+ --timeout <ms> Download timeout (default: 30000)
175
+ --no-cache Disable font caching (enabled by default)
176
+ --search-alternatives If font unavailable, search for alternatives
177
+
178
+ ${colors.cyan}REPLACE OPTIONS:${colors.reset}
179
+ --map <file> Path to replacement YAML
180
+ --no-auto-download Don't download missing fonts automatically
181
+
182
+ ${colors.cyan}EXTRACT OPTIONS:${colors.reset}
183
+ --extract-dir <dir> Directory for extracted fonts (default: ./fonts/)
184
+ --restore-links Convert back to external URL references
185
+
186
+ ${colors.cyan}TEMPLATE OPTIONS:${colors.reset}
187
+ --template-output <file> Output path for template (default: stdout)
188
+
189
+ ${colors.cyan}CACHE OPTIONS:${colors.reset}
190
+ --cache-action <action> Cache action: stats, clean, list (default: stats)
191
+ --max-age <days> Max cache age in days for clean (default: 30)
192
+
193
+ ${colors.cyan}SEARCH OPTIONS:${colors.reset}
194
+ --query <name> Font name to search for (required)
195
+ --limit <n> Max results to show (default: 10)
196
+ --threshold <0-1> Minimum similarity (default: 0.3)
197
+
198
+ ${colors.cyan}EXAMPLES:${colors.reset}
199
+ svgfonts embed icon.svg # Embed fonts with subsetting
200
+ svgfonts embed --full icon.svg # Embed complete fonts
201
+ svgfonts embed --woff2 icon.svg # Embed with WOFF2 compression
202
+ svgfonts list document.svg # List all fonts in SVG
203
+ svgfonts replace --map fonts.yml *.svg # Apply replacement map
204
+ svgfonts extract -o ./fonts doc.svg # Extract embedded fonts
205
+ svgfonts interactive icon.svg # Interactive mode
206
+ svgfonts template > fonts.yml # Generate template
207
+ svgfonts cache # Show font cache stats
208
+ svgfonts cache --cache-action clean # Clean old cached fonts
209
+ svgfonts search --query "roboto" # Search for fonts by name
210
+ svgfonts dedupe icon.svg # Merge duplicate @font-face rules
211
+
212
+ ${colors.cyan}ENVIRONMENT:${colors.reset}
213
+ SVGM_REPLACEMENT_MAP Path to default replacement map YAML
214
+
215
+ ${colors.cyan}SEE ALSO:${colors.reset}
216
+ svgm (general SVG optimization)
217
+ svg-matrix (matrix operations)
218
+ `;
219
+
220
+ // ============================================================================
221
+ // ARGUMENT PARSING
222
+ // ============================================================================
223
+ function parseArgs(args) {
224
+ const result = { ...DEFAULT_CONFIG };
225
+ let i = 0;
226
+
227
+ while (i < args.length) {
228
+ const arg = args[i];
229
+
230
+ switch (arg) {
231
+ case "-h":
232
+ case "--help":
233
+ console.log(HELP);
234
+ process.exit(CONSTANTS.EXIT_SUCCESS);
235
+ break;
236
+
237
+ case "--version":
238
+ console.log(VERSION);
239
+ process.exit(CONSTANTS.EXIT_SUCCESS);
240
+ break;
241
+
242
+ case "embed":
243
+ case "extract":
244
+ case "replace":
245
+ case "list":
246
+ case "interactive":
247
+ case "template":
248
+ case "cache":
249
+ case "search":
250
+ case "dedupe":
251
+ if (!result.command) {
252
+ result.command = arg;
253
+ } else {
254
+ result.inputs.push(arg);
255
+ }
256
+ break;
257
+
258
+ case "-i":
259
+ case "--input":
260
+ if (args[i + 1]) {
261
+ result.inputs.push(args[++i]);
262
+ }
263
+ break;
264
+
265
+ case "-o":
266
+ case "--output":
267
+ result.output = args[++i];
268
+ break;
269
+
270
+ case "-r":
271
+ case "--recursive":
272
+ result.recursive = true;
273
+ break;
274
+
275
+ case "-q":
276
+ case "--quiet":
277
+ result.quiet = true;
278
+ break;
279
+
280
+ case "-v":
281
+ case "--verbose":
282
+ result.verbose = true;
283
+ break;
284
+
285
+ case "--no-backup":
286
+ result.noBackup = true;
287
+ break;
288
+
289
+ case "--validate":
290
+ result.validate = true;
291
+ break;
292
+
293
+ case "--dry-run":
294
+ result.dryRun = true;
295
+ break;
296
+
297
+ case "--no-color":
298
+ // Already handled in colors setup
299
+ break;
300
+
301
+ // Embed options
302
+ case "--subset":
303
+ result.subset = true;
304
+ result.full = false;
305
+ break;
306
+
307
+ case "--full":
308
+ result.full = true;
309
+ result.subset = false;
310
+ break;
311
+
312
+ case "--source":
313
+ result.source = args[++i];
314
+ break;
315
+
316
+ case "--timeout":
317
+ result.timeout = parseInt(args[++i], 10);
318
+ break;
319
+
320
+ // Replace options
321
+ case "--map":
322
+ result.mapFile = args[++i];
323
+ break;
324
+
325
+ case "--no-auto-download":
326
+ result.autoDownload = false;
327
+ break;
328
+
329
+ // Extract options
330
+ case "--extract-dir":
331
+ result.extractDir = args[++i];
332
+ break;
333
+
334
+ case "--restore-links":
335
+ result.restoreLinks = true;
336
+ break;
337
+
338
+ // Template options
339
+ case "--template-output":
340
+ result.templateOutput = args[++i];
341
+ break;
342
+
343
+ // Embed new options
344
+ case "--woff2":
345
+ result.woff2 = true;
346
+ break;
347
+
348
+ case "--no-cache":
349
+ result.useCache = false;
350
+ break;
351
+
352
+ case "--search-alternatives":
353
+ result.searchAlternatives = true;
354
+ break;
355
+
356
+ // Cache options
357
+ case "--cache-action":
358
+ result.cacheAction = args[++i];
359
+ break;
360
+
361
+ case "--max-age":
362
+ result.maxAge = parseInt(args[++i], 10);
363
+ break;
364
+
365
+ // Search options
366
+ case "--query":
367
+ result.query = args[++i];
368
+ break;
369
+
370
+ case "--limit":
371
+ result.limit = parseInt(args[++i], 10);
372
+ break;
373
+
374
+ case "--threshold":
375
+ result.threshold = parseFloat(args[++i]);
376
+ break;
377
+
378
+ default:
379
+ // Assume it's an input file
380
+ if (!arg.startsWith("-")) {
381
+ result.inputs.push(arg);
382
+ } else {
383
+ logError(`Unknown option: ${arg}`);
384
+ process.exit(CONSTANTS.EXIT_ERROR);
385
+ }
386
+ }
387
+ i++;
388
+ }
389
+
390
+ // Default command is 'embed' if inputs provided
391
+ if (!result.command && result.inputs.length > 0) {
392
+ result.command = "embed";
393
+ }
394
+
395
+ return result;
396
+ }
397
+
398
+ // ============================================================================
399
+ // FILE UTILITIES
400
+ // ============================================================================
401
+ function collectSvgFiles(inputs, recursive = false) {
402
+ const files = [];
403
+ const visited = new Set();
404
+
405
+ for (const input of inputs) {
406
+ const absPath = isAbsolute(input) ? input : resolve(input);
407
+
408
+ if (!existsSync(absPath)) {
409
+ logWarn(`File not found: ${input}`);
410
+ continue;
411
+ }
412
+
413
+ // Detect symlink loops
414
+ try {
415
+ const realPath = realpathSync(absPath);
416
+ if (visited.has(realPath)) continue;
417
+ visited.add(realPath);
418
+ } catch {
419
+ continue;
420
+ }
421
+
422
+ const stat = statSync(absPath);
423
+
424
+ if (stat.isFile()) {
425
+ const ext = extname(absPath).toLowerCase();
426
+ if (CONSTANTS.SVG_EXTENSIONS.includes(ext)) {
427
+ files.push(absPath);
428
+ }
429
+ } else if (stat.isDirectory() && recursive) {
430
+ const entries = readdirSync(absPath);
431
+ const subPaths = entries.map((e) => join(absPath, e));
432
+ files.push(...collectSvgFiles(subPaths, recursive));
433
+ }
434
+ }
435
+
436
+ return files;
437
+ }
438
+
439
+ // ============================================================================
440
+ // COMMAND: LIST
441
+ // ============================================================================
442
+ async function cmdList(files) {
443
+ for (const file of files) {
444
+ const content = readFileSync(file, "utf8");
445
+ const doc = parseSVG(content);
446
+
447
+ const fonts = FontManager.listFonts(doc);
448
+
449
+ log(`\n${colors.bright}${basename(file)}${colors.reset}`);
450
+ log("─".repeat(60));
451
+
452
+ if (fonts.length === 0) {
453
+ log(` ${colors.dim}No fonts found${colors.reset}`);
454
+ continue;
455
+ }
456
+
457
+ log(
458
+ ` ${colors.cyan}${"#".padEnd(4)}${"Font Family".padEnd(25)}${"Type".padEnd(12)}${"Size".padEnd(12)}Used Chars${colors.reset}`
459
+ );
460
+ log(" " + "─".repeat(56));
461
+
462
+ fonts.forEach((font, idx) => {
463
+ const num = `${idx + 1}.`.padEnd(4);
464
+ const family = font.family.slice(0, 24).padEnd(25);
465
+ const type = font.type.padEnd(12);
466
+ const size = font.size
467
+ ? `${(font.size / 1024).toFixed(1)} KB`.padEnd(12)
468
+ : font.source
469
+ ? `(${font.source.slice(0, 8)}...)`.padEnd(12)
470
+ : "".padEnd(12);
471
+ const chars = font.usedChars
472
+ ? [...font.usedChars].slice(0, 20).join("") +
473
+ (font.usedChars.size > 20 ? "..." : "")
474
+ : "";
475
+
476
+ log(` ${num}${family}${type}${size}${chars}`);
477
+ });
478
+ }
479
+ }
480
+
481
+ // ============================================================================
482
+ // COMMAND: EMBED
483
+ // ============================================================================
484
+ async function cmdEmbed(files) {
485
+ for (const file of files) {
486
+ logVerbose(`Processing: ${file}`);
487
+
488
+ const content = readFileSync(file, "utf8");
489
+
490
+ // Create backup
491
+ if (!config.noBackup && !config.dryRun) {
492
+ const backupPath = FontManager.createBackup(file, { noBackup: config.noBackup });
493
+ if (backupPath) {
494
+ logVerbose(`Backup created: ${backupPath}`);
495
+ }
496
+ }
497
+
498
+ if (config.dryRun) {
499
+ log(`[dry-run] Would embed fonts in: ${file}`);
500
+ continue;
501
+ }
502
+
503
+ const doc = parseSVG(content);
504
+
505
+ // Use svg-toolbox embedExternalDependencies
506
+ const result = await SVGToolbox.embedExternalDependencies(doc, {
507
+ embedImages: false,
508
+ embedExternalSVGs: false,
509
+ embedCSS: true,
510
+ embedFonts: true,
511
+ embedScripts: false,
512
+ embedAudio: false,
513
+ subsetFonts: config.subset && !config.full,
514
+ timeout: config.timeout,
515
+ verbose: config.verbose,
516
+ });
517
+
518
+ const output = serializeSVG(result);
519
+
520
+ // Validate if requested
521
+ if (config.validate) {
522
+ const validation = await FontManager.validateSvgAfterFontOperation(output, "embed");
523
+ if (!validation.valid) {
524
+ logError(`Validation failed for ${file}: ${validation.errors.join(", ")}`);
525
+ continue;
526
+ }
527
+ for (const warn of validation.warnings) {
528
+ logWarn(warn);
529
+ }
530
+ }
531
+
532
+ const outputPath = config.output || file;
533
+ writeFileSync(outputPath, output);
534
+ logSuccess(`Embedded fonts: ${outputPath}`);
535
+ }
536
+ }
537
+
538
+ // ============================================================================
539
+ // COMMAND: EXTRACT
540
+ // ============================================================================
541
+ async function cmdExtract(files) {
542
+ mkdirSync(config.extractDir, { recursive: true });
543
+
544
+ for (const file of files) {
545
+ logVerbose(`Processing: ${file}`);
546
+
547
+ const content = readFileSync(file, "utf8");
548
+
549
+ // Create backup
550
+ if (!config.noBackup && !config.dryRun) {
551
+ FontManager.createBackup(file, { noBackup: config.noBackup });
552
+ }
553
+
554
+ if (config.dryRun) {
555
+ log(`[dry-run] Would extract fonts from: ${file}`);
556
+ continue;
557
+ }
558
+
559
+ const doc = parseSVG(content);
560
+
561
+ // Use svg-toolbox exportEmbeddedResources
562
+ const result = await SVGToolbox.exportEmbeddedResources(doc, {
563
+ outputDir: config.extractDir,
564
+ extractFonts: true,
565
+ extractImages: false,
566
+ extractStyles: false,
567
+ restoreGoogleFonts: config.restoreLinks,
568
+ });
569
+
570
+ const output = serializeSVG(result);
571
+
572
+ // Validate if requested
573
+ if (config.validate) {
574
+ const validation = await FontManager.validateSvgAfterFontOperation(output, "extract");
575
+ if (!validation.valid) {
576
+ logError(`Validation failed for ${file}: ${validation.errors.join(", ")}`);
577
+ continue;
578
+ }
579
+ }
580
+
581
+ const outputPath = config.output || file;
582
+ writeFileSync(outputPath, output);
583
+ logSuccess(`Extracted fonts to: ${config.extractDir}`);
584
+ }
585
+ }
586
+
587
+ // ============================================================================
588
+ // COMMAND: REPLACE
589
+ // ============================================================================
590
+ async function cmdReplace(files) {
591
+ // Load replacement map
592
+ const map = FontManager.loadReplacementMap(config.mapFile);
593
+
594
+ if (!map) {
595
+ logError(
596
+ "No replacement map found. Create one with 'svgfonts template' or specify --map"
597
+ );
598
+ process.exit(CONSTANTS.EXIT_ERROR);
599
+ }
600
+
601
+ log(`Using replacement map: ${map.path}`);
602
+
603
+ const replacementCount = Object.keys(map.replacements).length;
604
+ if (replacementCount === 0) {
605
+ logWarn("Replacement map has no font mappings defined");
606
+ return;
607
+ }
608
+
609
+ log(`Found ${replacementCount} font replacement(s)`);
610
+
611
+ for (const file of files) {
612
+ logVerbose(`Processing: ${file}`);
613
+
614
+ const content = readFileSync(file, "utf8");
615
+
616
+ // Create backup
617
+ if (!config.noBackup && !config.dryRun) {
618
+ FontManager.createBackup(file, { noBackup: config.noBackup });
619
+ }
620
+
621
+ if (config.dryRun) {
622
+ log(`[dry-run] Would apply replacements to: ${file}`);
623
+ continue;
624
+ }
625
+
626
+ const doc = parseSVG(content);
627
+
628
+ // Apply replacements
629
+ const replaceResult = FontManager.applyFontReplacements(doc, map.replacements);
630
+
631
+ if (replaceResult.modified) {
632
+ const output = serializeSVG(doc);
633
+
634
+ // Validate if requested
635
+ if (config.validate) {
636
+ const validation = await FontManager.validateSvgAfterFontOperation(
637
+ output,
638
+ "replace"
639
+ );
640
+ for (const warn of validation.warnings) {
641
+ logWarn(warn);
642
+ }
643
+ }
644
+
645
+ const outputPath = config.output || file;
646
+ writeFileSync(outputPath, output);
647
+
648
+ for (const r of replaceResult.replaced) {
649
+ log(` ${r.from} -> ${r.to}`);
650
+ }
651
+ logSuccess(`Replaced fonts: ${outputPath}`);
652
+ } else {
653
+ log(` No fonts replaced in ${basename(file)}`);
654
+ }
655
+ }
656
+ }
657
+
658
+ // ============================================================================
659
+ // COMMAND: TEMPLATE
660
+ // ============================================================================
661
+ function cmdTemplate() {
662
+ const template = FontManager.generateReplacementMapTemplate();
663
+
664
+ if (config.templateOutput) {
665
+ writeFileSync(config.templateOutput, template);
666
+ logSuccess(`Template written to: ${config.templateOutput}`);
667
+ } else {
668
+ console.log(template);
669
+ }
670
+ }
671
+
672
+ // ============================================================================
673
+ // COMMAND: CACHE
674
+ // ============================================================================
675
+ function cmdCache() {
676
+ // Initialize cache if needed
677
+ FontManager.initFontCache();
678
+
679
+ switch (config.cacheAction) {
680
+ case "stats": {
681
+ const stats = FontManager.getFontCacheStats();
682
+ log(`\n${colors.bright}Font Cache Statistics${colors.reset}`);
683
+ log(`${"─".repeat(40)}`);
684
+ log(` Location: ${FontManager.FONT_CACHE_DIR}`);
685
+ log(` Total fonts: ${stats.totalFonts}`);
686
+ log(` Total size: ${formatSize(stats.totalSize)}`);
687
+ if (stats.oldestAge > 0) {
688
+ const oldestDays = Math.floor(stats.oldestAge / (24 * 60 * 60 * 1000));
689
+ const newestDays = Math.floor(stats.newestAge / (24 * 60 * 60 * 1000));
690
+ log(` Oldest entry: ${oldestDays} days ago`);
691
+ log(` Newest entry: ${newestDays} days ago`);
692
+ } else {
693
+ log(` Oldest entry: N/A`);
694
+ log(` Newest entry: N/A`);
695
+ }
696
+ break;
697
+ }
698
+
699
+ case "clean": {
700
+ const maxAgeMs = config.maxAge * 24 * 60 * 60 * 1000;
701
+ const result = FontManager.cleanupFontCache(maxAgeMs);
702
+ if (result.removed > 0) {
703
+ logSuccess(`Cleaned ${result.removed} old cached fonts (${formatSize(result.freedBytes)})`);
704
+ } else {
705
+ log("No fonts to clean (all within max age)");
706
+ }
707
+ break;
708
+ }
709
+
710
+ case "list": {
711
+ // Read the cache index directly to get list
712
+ const indexPath = join(FontManager.FONT_CACHE_DIR, FontManager.FONT_CACHE_INDEX);
713
+ log(`\n${colors.bright}Cached Fonts${colors.reset}`);
714
+ log(`${"─".repeat(60)}`);
715
+ if (existsSync(indexPath)) {
716
+ try {
717
+ const index = JSON.parse(readFileSync(indexPath, "utf8"));
718
+ const entries = Object.entries(index.fonts);
719
+ if (entries.length > 0) {
720
+ for (const [key, data] of entries) {
721
+ const age = Math.floor((Date.now() - data.timestamp) / (24 * 60 * 60 * 1000));
722
+ log(` ${key.slice(0, 30).padEnd(30)} ${formatSize(data.size || 0).padStart(10)} ${age}d ago`);
723
+ }
724
+ } else {
725
+ log(" No cached fonts");
726
+ }
727
+ } catch {
728
+ log(" No cached fonts");
729
+ }
730
+ } else {
731
+ log(" No cached fonts");
732
+ }
733
+ break;
734
+ }
735
+
736
+ default:
737
+ logError(`Unknown cache action: ${config.cacheAction}`);
738
+ process.exit(CONSTANTS.EXIT_ERROR);
739
+ }
740
+ }
741
+
742
+ // ============================================================================
743
+ // COMMAND: SEARCH
744
+ // ============================================================================
745
+ async function cmdSearch() {
746
+ if (!config.query) {
747
+ logError("Missing --query option. Usage: svgfonts search --query <font-name>");
748
+ process.exit(CONSTANTS.EXIT_ERROR);
749
+ }
750
+
751
+ log(`\n${colors.bright}Searching for fonts matching: "${config.query}"${colors.reset}`);
752
+ log(`${"─".repeat(60)}`);
753
+
754
+ const results = await FontManager.searchSimilarFonts(config.query, {
755
+ limit: config.limit,
756
+ threshold: config.threshold,
757
+ });
758
+
759
+ if (results.length === 0) {
760
+ log(`No fonts found matching "${config.query}" with similarity >= ${config.threshold}`);
761
+ return;
762
+ }
763
+
764
+ log(`\n${colors.cyan}# Similarity Font Name${" ".repeat(25)}Source${colors.reset}`);
765
+ log(`${"─".repeat(60)}`);
766
+
767
+ results.forEach((result, idx) => {
768
+ const simPercent = Math.round(result.similarity * 100);
769
+ const simBar = "█".repeat(Math.round(simPercent / 10)) + "░".repeat(10 - Math.round(simPercent / 10));
770
+ const simColor = simPercent >= 80 ? colors.green : simPercent >= 50 ? colors.yellow : colors.dim;
771
+ log(
772
+ `${(idx + 1).toString().padStart(2)}. ${simColor}${simBar}${colors.reset} ${simPercent.toString().padStart(3)}% ${result.name.padEnd(28)} ${colors.dim}${result.source}${colors.reset}`
773
+ );
774
+ });
775
+
776
+ log(`\n${colors.dim}Use 'svgfonts embed --source google <svg>' to embed a font${colors.reset}`);
777
+ }
778
+
779
+ // ============================================================================
780
+ // COMMAND: DEDUPE
781
+ // ============================================================================
782
+ async function cmdDedupe(files) {
783
+ for (const file of files) {
784
+ logVerbose(`Processing: ${file}`);
785
+
786
+ const content = readFileSync(file, "utf8");
787
+
788
+ // Create backup
789
+ if (!config.noBackup && !config.dryRun) {
790
+ const backupPath = FontManager.createBackup(file, { noBackup: config.noBackup });
791
+ if (backupPath) {
792
+ logVerbose(`Backup created: ${backupPath}`);
793
+ }
794
+ }
795
+
796
+ const doc = parseSVG(content);
797
+
798
+ // Detect duplicates first
799
+ const { duplicates, total } = FontManager.detectDuplicateFontFaces(doc);
800
+
801
+ if (duplicates.length === 0) {
802
+ log(`${basename(file)}: No duplicate @font-face rules found (${total} total)`);
803
+ continue;
804
+ }
805
+
806
+ if (config.dryRun) {
807
+ log(`[dry-run] Would merge ${duplicates.length} duplicate font group(s) in: ${file}`);
808
+ for (const dup of duplicates) {
809
+ log(` - "${dup.family}" (${dup.weight}/${dup.style}): ${dup.count} occurrences`);
810
+ }
811
+ continue;
812
+ }
813
+
814
+ // Merge duplicates
815
+ const result = FontManager.mergeDuplicateFontFaces(doc);
816
+
817
+ if (result.removed > 0) {
818
+ const output = serializeSVG(doc);
819
+
820
+ // Validate if requested
821
+ if (config.validate) {
822
+ const validation = await FontManager.validateSvgAfterFontOperation(output, "dedupe");
823
+ if (!validation.valid) {
824
+ logError(`Validation failed for ${file}: ${validation.errors.join(", ")}`);
825
+ continue;
826
+ }
827
+ }
828
+
829
+ const outputPath = config.output || file;
830
+ writeFileSync(outputPath, output);
831
+
832
+ logSuccess(`Merged ${result.removed} duplicate @font-face rule(s): ${outputPath}`);
833
+ for (const dup of duplicates) {
834
+ log(` - "${dup.family}" (${dup.weight}/${dup.style})`);
835
+ }
836
+ } else {
837
+ log(`${basename(file)}: No duplicates merged`);
838
+ }
839
+ }
840
+ }
841
+
842
+ // ============================================================================
843
+ // COMMAND: INTERACTIVE
844
+ // ============================================================================
845
+
846
+ /**
847
+ * Box-drawing characters for consistent borders
848
+ */
849
+ const BOX = {
850
+ tl: "┌",
851
+ tr: "┐",
852
+ bl: "└",
853
+ br: "┘",
854
+ h: "─",
855
+ v: "│",
856
+ ml: "├",
857
+ mr: "┤",
858
+ mt: "┬",
859
+ mb: "┴",
860
+ cross: "┼",
861
+ // Double line variants for headers
862
+ dh: "═",
863
+ dtl: "╔",
864
+ dtr: "╗",
865
+ dbl: "╚",
866
+ dbr: "╝",
867
+ dv: "║",
868
+ dml: "╠",
869
+ dmr: "╣",
870
+ };
871
+
872
+ /**
873
+ * Draw a horizontal line with box characters
874
+ */
875
+ function drawLine(width, left = BOX.ml, fill = BOX.h, right = BOX.mr) {
876
+ return left + fill.repeat(width - 2) + right;
877
+ }
878
+
879
+ /**
880
+ * Format a table row with padding
881
+ */
882
+ function tableRow(cols, widths, sep = BOX.v) {
883
+ return (
884
+ sep +
885
+ cols.map((col, i) => ` ${String(col).slice(0, widths[i] - 2).padEnd(widths[i] - 2)} `).join(sep) +
886
+ sep
887
+ );
888
+ }
889
+
890
+ /**
891
+ * Format file size for display
892
+ */
893
+ function formatSize(bytes) {
894
+ if (!bytes) return "-";
895
+ if (bytes < 1024) return `${bytes} B`;
896
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
897
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
898
+ }
899
+
900
+ /**
901
+ * Get status indicator for font type
902
+ */
903
+ function fontTypeIcon(type) {
904
+ switch (type) {
905
+ case "embedded":
906
+ return `${colors.green}●${colors.reset}`;
907
+ case "external":
908
+ return `${colors.yellow}○${colors.reset}`;
909
+ case "system":
910
+ return `${colors.cyan}◆${colors.reset}`;
911
+ default:
912
+ return `${colors.dim}?${colors.reset}`;
913
+ }
914
+ }
915
+
916
+ async function cmdInteractive(files) {
917
+ if (files.length === 0) {
918
+ logError("No input files specified for interactive mode");
919
+ process.exit(CONSTANTS.EXIT_ERROR);
920
+ }
921
+
922
+ const file = files[0]; // Process one file at a time in interactive mode
923
+ const content = readFileSync(file, "utf8");
924
+ let doc = parseSVG(content);
925
+ let modified = false;
926
+ let filterText = "";
927
+ let selectedIdx = -1;
928
+ let scrollOffset = 0; // For scrolling through long font lists
929
+ let message = ""; // Status message to display
930
+ let messageType = "info"; // "info", "success", "error", "warn"
931
+
932
+ // Page size for font list (visible rows)
933
+ const PAGE_SIZE = 10;
934
+
935
+ // Undo history (stores serialized SVG states)
936
+ const history = [];
937
+ const MAX_HISTORY = 20;
938
+
939
+ const pushHistory = () => {
940
+ const state = serializeSVG(doc);
941
+ history.push(state);
942
+ if (history.length > MAX_HISTORY) {
943
+ history.shift();
944
+ }
945
+ };
946
+
947
+ const undo = () => {
948
+ if (history.length > 0) {
949
+ const prevState = history.pop();
950
+ doc = parseSVG(prevState);
951
+ return true;
952
+ }
953
+ return false;
954
+ };
955
+
956
+ const rl = createInterface({
957
+ input: process.stdin,
958
+ output: process.stdout,
959
+ });
960
+
961
+ const question = (prompt) =>
962
+ new Promise((res) => rl.question(prompt, res));
963
+
964
+ /**
965
+ * Read single keypress (for arrow key navigation)
966
+ */
967
+ const _readKey = () =>
968
+ new Promise((res) => {
969
+ const wasRaw = process.stdin.isRaw;
970
+ if (process.stdin.setRawMode) {
971
+ process.stdin.setRawMode(true);
972
+ }
973
+ process.stdin.resume();
974
+ process.stdin.once("data", (key) => {
975
+ if (process.stdin.setRawMode) {
976
+ process.stdin.setRawMode(wasRaw);
977
+ }
978
+ res(key.toString());
979
+ });
980
+ });
981
+
982
+ // Create backup before interactive session
983
+ if (!config.noBackup) {
984
+ const backupPath = FontManager.createBackup(file, { noBackup: config.noBackup });
985
+ if (backupPath) {
986
+ message = `Backup: ${basename(backupPath)}`;
987
+ }
988
+ }
989
+
990
+ // Calculate table widths
991
+ const TABLE_WIDTH = 76;
992
+ const COL_WIDTHS = [4, 26, 12, 10, 22]; // #, Font Family, Type, Size, Used Chars
993
+
994
+ /**
995
+ * Display help screen
996
+ */
997
+ const showHelp = async () => {
998
+ console.clear();
999
+ log(`\n${colors.bright}${BOX.dtl}${BOX.dh.repeat(TABLE_WIDTH - 2)}${BOX.dtr}${colors.reset}`);
1000
+ log(`${colors.bright}${BOX.dv}${colors.reset} ${"INTERACTIVE MODE HELP".padStart(38).padEnd(TABLE_WIDTH - 4)} ${colors.bright}${BOX.dv}${colors.reset}`);
1001
+ log(`${colors.bright}${BOX.dbl}${BOX.dh.repeat(TABLE_WIDTH - 2)}${BOX.dbr}${colors.reset}`);
1002
+
1003
+ log(`\n${colors.cyan}Navigation (for long font lists):${colors.reset}`);
1004
+ log(` ${colors.bright}j${colors.reset}/${colors.bright}↓${colors.reset} Scroll down one line`);
1005
+ log(` ${colors.bright}k${colors.reset}/${colors.bright}↑${colors.reset} Scroll up one line`);
1006
+ log(` ${colors.bright}n${colors.reset} Page down (next page)`);
1007
+ log(` ${colors.bright}p${colors.reset} Page up (previous page)`);
1008
+ log(` ${colors.bright}g${colors.reset} Jump to top of list`);
1009
+ log(` ${colors.bright}G${colors.reset} Jump to bottom of list`);
1010
+
1011
+ log(`\n${colors.cyan}Main Menu Commands:${colors.reset}`);
1012
+ log(` ${colors.bright}1-99${colors.reset} Select font by number`);
1013
+ log(` ${colors.bright}a${colors.reset} Apply action to all fonts`);
1014
+ log(` ${colors.bright}e${colors.reset} Embed all external fonts (subset)`);
1015
+ log(` ${colors.bright}E${colors.reset} Embed all external fonts (full)`);
1016
+ log(` ${colors.bright}/${colors.reset} Search/filter fonts by name`);
1017
+ log(` ${colors.bright}c${colors.reset} Clear filter`);
1018
+ log(` ${colors.bright}u${colors.reset} Undo last change`);
1019
+ log(` ${colors.bright}i${colors.reset} Show SVG info summary`);
1020
+ log(` ${colors.bright}s${colors.reset} Save and exit`);
1021
+ log(` ${colors.bright}q${colors.reset} Quit without saving`);
1022
+ log(` ${colors.bright}h${colors.reset}/${colors.bright}?${colors.reset} Show this help`);
1023
+
1024
+ log(`\n${colors.cyan}Per-Font Commands:${colors.reset}`);
1025
+ log(` ${colors.bright}e${colors.reset} Embed font (subset - smaller file)`);
1026
+ log(` ${colors.bright}E${colors.reset} Embed font (full - all glyphs)`);
1027
+ log(` ${colors.bright}r${colors.reset} Replace with another font`);
1028
+ log(` ${colors.bright}x${colors.reset} Extract font to file`);
1029
+ log(` ${colors.bright}d${colors.reset} Delete font from SVG`);
1030
+ log(` ${colors.bright}v${colors.reset} Validate external URL`);
1031
+ log(` ${colors.bright}c${colors.reset} Copy font details to clipboard`);
1032
+ log(` ${colors.bright}b${colors.reset} Back to font list`);
1033
+
1034
+ log(`\n${colors.cyan}Legend:${colors.reset}`);
1035
+ log(` ${colors.green}●${colors.reset} Embedded ${colors.yellow}○${colors.reset} External ${colors.cyan}◆${colors.reset} System ${colors.dim}?${colors.reset} Unknown`);
1036
+
1037
+ log(`\n${colors.dim}Press Enter to return...${colors.reset}`);
1038
+ await question("");
1039
+ };
1040
+
1041
+ /**
1042
+ * Show SVG info summary
1043
+ */
1044
+ const showInfo = async () => {
1045
+ console.clear();
1046
+ const fonts = FontManager.listFonts(doc);
1047
+
1048
+ log(`\n${colors.bright}${BOX.dtl}${BOX.dh.repeat(TABLE_WIDTH - 2)}${BOX.dtr}${colors.reset}`);
1049
+ log(`${colors.bright}${BOX.dv}${colors.reset} ${"SVG INFORMATION".padStart(38).padEnd(TABLE_WIDTH - 4)} ${colors.bright}${BOX.dv}${colors.reset}`);
1050
+ log(`${colors.bright}${BOX.dbl}${BOX.dh.repeat(TABLE_WIDTH - 2)}${BOX.dbr}${colors.reset}`);
1051
+
1052
+ log(`\n${colors.cyan}File:${colors.reset} ${basename(file)}`);
1053
+ log(`${colors.cyan}Path:${colors.reset} ${file}`);
1054
+
1055
+ const svgContent = serializeSVG(doc);
1056
+ log(`${colors.cyan}Size:${colors.reset} ${formatSize(svgContent.length)}`);
1057
+
1058
+ log(`\n${colors.cyan}Font Summary:${colors.reset}`);
1059
+ const embedded = fonts.filter((f) => f.type === "embedded").length;
1060
+ const external = fonts.filter((f) => f.type === "external").length;
1061
+ const system = fonts.filter((f) => f.type === "system").length;
1062
+ const unknown = fonts.filter((f) => f.type === "unknown").length;
1063
+
1064
+ log(` Total fonts: ${fonts.length}`);
1065
+ log(` ${colors.green}●${colors.reset} Embedded: ${embedded}`);
1066
+ log(` ${colors.yellow}○${colors.reset} External: ${external}`);
1067
+ log(` ${colors.cyan}◆${colors.reset} System: ${system}`);
1068
+ if (unknown > 0) log(` ${colors.dim}?${colors.reset} Unknown: ${unknown}`);
1069
+
1070
+ // Calculate total embedded font size
1071
+ const totalEmbedSize = fonts
1072
+ .filter((f) => f.type === "embedded" && f.size)
1073
+ .reduce((sum, f) => sum + f.size, 0);
1074
+
1075
+ if (totalEmbedSize > 0) {
1076
+ log(`\n${colors.cyan}Embedded Font Size:${colors.reset} ${formatSize(totalEmbedSize)}`);
1077
+ }
1078
+
1079
+ // List unique characters used
1080
+ const allChars = new Set();
1081
+ fonts.forEach((f) => {
1082
+ if (f.usedChars) {
1083
+ for (const c of f.usedChars) allChars.add(c);
1084
+ }
1085
+ });
1086
+ log(`${colors.cyan}Unique Characters:${colors.reset} ${allChars.size}`);
1087
+
1088
+ log(`\n${colors.dim}Press Enter to return...${colors.reset}`);
1089
+ await question("");
1090
+ };
1091
+
1092
+ /**
1093
+ * Main interactive loop
1094
+ */
1095
+ while (true) {
1096
+ console.clear();
1097
+ const fonts = FontManager.listFonts(doc);
1098
+
1099
+ // Apply filter
1100
+ const filteredFonts = filterText
1101
+ ? fonts.filter((f) => f.family.toLowerCase().includes(filterText.toLowerCase()))
1102
+ : fonts;
1103
+
1104
+ // Header
1105
+ log(`${colors.bright}${BOX.dtl}${BOX.dh.repeat(TABLE_WIDTH - 2)}${BOX.dtr}${colors.reset}`);
1106
+ const title = ` SVGFONTS INTERACTIVE `;
1107
+ const modIndicator = modified ? `${colors.yellow}[MODIFIED]${colors.reset}` : "";
1108
+ const headerText = `${title}${modIndicator}`.padEnd(TABLE_WIDTH - 4 + (modified ? 14 : 0));
1109
+ log(`${colors.bright}${BOX.dv}${colors.reset}${headerText}${colors.bright}${BOX.dv}${colors.reset}`);
1110
+ log(`${colors.bright}${BOX.dml}${BOX.dh.repeat(TABLE_WIDTH - 2)}${BOX.dmr}${colors.reset}`);
1111
+
1112
+ // File info row
1113
+ const fileInfo = ` File: ${basename(file)} `;
1114
+ const fontCount = ` Fonts: ${filteredFonts.length}${filterText ? ` (filtered)` : ``} `;
1115
+ const undoCount = history.length > 0 ? ` Undo: ${history.length} ` : "";
1116
+ log(
1117
+ `${BOX.v}${fileInfo.padEnd(40)}${fontCount.padEnd(20)}${undoCount.padEnd(TABLE_WIDTH - 62)}${BOX.v}`
1118
+ );
1119
+ log(drawLine(TABLE_WIDTH, BOX.ml, BOX.h, BOX.mr));
1120
+
1121
+ // Filter indicator
1122
+ if (filterText) {
1123
+ log(`${BOX.v} ${colors.magenta}Filter: "${filterText}"${colors.reset}${" ".repeat(TABLE_WIDTH - filterText.length - 14)}${BOX.v}`);
1124
+ log(drawLine(TABLE_WIDTH, BOX.ml, BOX.h, BOX.mr));
1125
+ }
1126
+
1127
+ // Column headers
1128
+ log(
1129
+ tableRow(
1130
+ [
1131
+ `${colors.cyan}#${colors.reset}`,
1132
+ `${colors.cyan}Font Family${colors.reset}`,
1133
+ `${colors.cyan}Type${colors.reset}`,
1134
+ `${colors.cyan}Size${colors.reset}`,
1135
+ `${colors.cyan}Used Chars${colors.reset}`,
1136
+ ],
1137
+ COL_WIDTHS
1138
+ )
1139
+ );
1140
+ log(drawLine(TABLE_WIDTH, BOX.ml, BOX.h, BOX.mr));
1141
+
1142
+ // Font rows with pagination
1143
+ if (filteredFonts.length === 0) {
1144
+ const emptyMsg = filterText ? "No fonts match filter" : "No fonts found";
1145
+ log(`${BOX.v} ${colors.dim}${emptyMsg.padEnd(TABLE_WIDTH - 4)}${colors.reset} ${BOX.v}`);
1146
+ } else {
1147
+ // Ensure scroll offset is valid
1148
+ const maxOffset = Math.max(0, filteredFonts.length - PAGE_SIZE);
1149
+ if (scrollOffset > maxOffset) scrollOffset = maxOffset;
1150
+ if (scrollOffset < 0) scrollOffset = 0;
1151
+
1152
+ // Show visible fonts based on scroll position
1153
+ const visibleFonts = filteredFonts.slice(scrollOffset, scrollOffset + PAGE_SIZE);
1154
+ const hasMore = filteredFonts.length > PAGE_SIZE;
1155
+
1156
+ // Show scroll indicator if there are fonts above
1157
+ if (scrollOffset > 0) {
1158
+ log(`${BOX.v} ${colors.dim} ↑ ${scrollOffset} more above...${" ".repeat(TABLE_WIDTH - 24)}${colors.reset} ${BOX.v}`);
1159
+ }
1160
+
1161
+ visibleFonts.forEach((font, visibleIdx) => {
1162
+ const actualIdx = scrollOffset + visibleIdx;
1163
+ const num = `${actualIdx + 1}.`;
1164
+ const icon = fontTypeIcon(font.type);
1165
+ const family = font.family.slice(0, 22);
1166
+ const typeStr = `${icon} ${font.type}`;
1167
+ const size = formatSize(font.size);
1168
+ const chars = font.usedChars
1169
+ ? [...font.usedChars].slice(0, 18).join("") + (font.usedChars.size > 18 ? "..." : "")
1170
+ : "-";
1171
+
1172
+ // Highlight selected font
1173
+ const isSelected = actualIdx === selectedIdx;
1174
+ const highlight = isSelected ? colors.bright : "";
1175
+ const resetH = isSelected ? colors.reset : "";
1176
+ const selectMarker = isSelected ? "▶" : " ";
1177
+
1178
+ log(
1179
+ `${BOX.v}${selectMarker}${highlight}${num.padEnd(3)}${resetH} ${BOX.v} ${highlight}${family.padEnd(24)}${resetH} ${BOX.v} ${typeStr.padEnd(10 + 9)} ${BOX.v} ${size.padEnd(8)} ${BOX.v} ${chars.padEnd(20)} ${BOX.v}`
1180
+ );
1181
+ });
1182
+
1183
+ // Show scroll indicator if there are fonts below
1184
+ const remaining = filteredFonts.length - scrollOffset - PAGE_SIZE;
1185
+ if (remaining > 0) {
1186
+ log(`${BOX.v} ${colors.dim} ↓ ${remaining} more below...${" ".repeat(TABLE_WIDTH - 24)}${colors.reset} ${BOX.v}`);
1187
+ }
1188
+
1189
+ // Show pagination info if list is long
1190
+ if (hasMore) {
1191
+ const currentPage = Math.floor(scrollOffset / PAGE_SIZE) + 1;
1192
+ const totalPages = Math.ceil(filteredFonts.length / PAGE_SIZE);
1193
+ const pageInfo = `Page ${currentPage}/${totalPages}`;
1194
+ log(drawLine(TABLE_WIDTH, BOX.ml, BOX.h, BOX.mr));
1195
+ log(`${BOX.v} ${colors.dim}${pageInfo}${colors.reset}${" ".repeat(TABLE_WIDTH - pageInfo.length - 4)} ${BOX.v}`);
1196
+ }
1197
+ }
1198
+
1199
+ // Footer
1200
+ log(`${BOX.bl}${BOX.h.repeat(TABLE_WIDTH - 2)}${BOX.br}`);
1201
+
1202
+ // Status message
1203
+ if (message) {
1204
+ const msgColor =
1205
+ messageType === "success"
1206
+ ? colors.green
1207
+ : messageType === "error"
1208
+ ? colors.red
1209
+ : messageType === "warn"
1210
+ ? colors.yellow
1211
+ : colors.dim;
1212
+ log(`\n${msgColor}${message}${colors.reset}`);
1213
+ message = "";
1214
+ }
1215
+
1216
+ // Action menu - show arrow keys if list is long
1217
+ const hasMoreFonts = filteredFonts.length > PAGE_SIZE;
1218
+ if (hasMoreFonts) {
1219
+ log(`\n${colors.cyan}Navigation:${colors.reset} ${colors.dim}[↑/↓]${colors.reset} scroll ${colors.dim}[j/k]${colors.reset} up/down ${colors.dim}[PgUp/PgDn]${colors.reset} page ${colors.dim}[g/G]${colors.reset} top/bottom`);
1220
+ }
1221
+ log(`${colors.cyan}Commands:${colors.reset} ${colors.dim}[1-99]${colors.reset} select ${colors.dim}[a]${colors.reset} all ${colors.dim}[e/E]${colors.reset} embed ${colors.dim}[/]${colors.reset} filter ${colors.dim}[u]${colors.reset} undo ${colors.dim}[s]${colors.reset} save ${colors.dim}[q]${colors.reset} quit ${colors.dim}[h]${colors.reset} help`);
1222
+
1223
+ const choice = await question(`\n${colors.bright}>${colors.reset} `);
1224
+
1225
+ // Handle vim-style navigation keys
1226
+ if (choice === "j" || choice === "\x1b[B") {
1227
+ // Down arrow or j - scroll down
1228
+ scrollOffset = Math.min(scrollOffset + 1, Math.max(0, filteredFonts.length - PAGE_SIZE));
1229
+ continue;
1230
+ }
1231
+ if (choice === "k" || choice === "\x1b[A") {
1232
+ // Up arrow or k - scroll up
1233
+ scrollOffset = Math.max(0, scrollOffset - 1);
1234
+ continue;
1235
+ }
1236
+ if (choice === "g") {
1237
+ // Go to top
1238
+ scrollOffset = 0;
1239
+ message = "Jumped to top";
1240
+ messageType = "info";
1241
+ continue;
1242
+ }
1243
+ if (choice === "G") {
1244
+ // Go to bottom
1245
+ scrollOffset = Math.max(0, filteredFonts.length - PAGE_SIZE);
1246
+ message = "Jumped to bottom";
1247
+ messageType = "info";
1248
+ continue;
1249
+ }
1250
+ if (choice === "n" || choice === "\x1b[6~") {
1251
+ // Page down (n or PgDn)
1252
+ scrollOffset = Math.min(scrollOffset + PAGE_SIZE, Math.max(0, filteredFonts.length - PAGE_SIZE));
1253
+ continue;
1254
+ }
1255
+ if (choice === "p" || choice === "\x1b[5~") {
1256
+ // Page up (p or PgUp)
1257
+ scrollOffset = Math.max(0, scrollOffset - PAGE_SIZE);
1258
+ continue;
1259
+ }
1260
+
1261
+ // Handle commands
1262
+ if (choice === "q" || choice === "Q") {
1263
+ if (modified) {
1264
+ const confirm = await question(
1265
+ `${colors.yellow}Unsaved changes! Quit anyway? [y/N]${colors.reset} `
1266
+ );
1267
+ if (confirm.toLowerCase() !== "y") continue;
1268
+ }
1269
+ log("\nExiting without saving.");
1270
+ rl.close();
1271
+ return;
1272
+ }
1273
+
1274
+ if (choice === "s" || choice === "S") {
1275
+ if (modified) {
1276
+ const output = serializeSVG(doc);
1277
+ const outputPath = config.output || file;
1278
+ writeFileSync(outputPath, output);
1279
+ logSuccess(`\nSaved: ${outputPath}`);
1280
+ } else {
1281
+ log("\nNo changes to save.");
1282
+ }
1283
+ rl.close();
1284
+ return;
1285
+ }
1286
+
1287
+ if (choice === "h" || choice === "?" || choice === "H") {
1288
+ await showHelp();
1289
+ continue;
1290
+ }
1291
+
1292
+ if (choice === "i" || choice === "I") {
1293
+ await showInfo();
1294
+ continue;
1295
+ }
1296
+
1297
+ if (choice === "/") {
1298
+ const searchTerm = await question(`${colors.cyan}Filter fonts:${colors.reset} `);
1299
+ filterText = searchTerm.trim();
1300
+ message = filterText ? `Filtering by: "${filterText}"` : "Filter cleared";
1301
+ messageType = "info";
1302
+ continue;
1303
+ }
1304
+
1305
+ if (choice === "c" || choice === "C") {
1306
+ filterText = "";
1307
+ message = "Filter cleared";
1308
+ messageType = "info";
1309
+ continue;
1310
+ }
1311
+
1312
+ if (choice === "u" || choice === "U") {
1313
+ if (undo()) {
1314
+ modified = history.length > 0;
1315
+ message = "Undo successful";
1316
+ messageType = "success";
1317
+ } else {
1318
+ message = "Nothing to undo";
1319
+ messageType = "warn";
1320
+ }
1321
+ continue;
1322
+ }
1323
+
1324
+ if (choice === "e") {
1325
+ log("\nEmbedding all external fonts (subset)...");
1326
+ pushHistory();
1327
+ doc = await SVGToolbox.embedExternalDependencies(doc, {
1328
+ embedFonts: true,
1329
+ embedImages: false,
1330
+ embedCSS: true,
1331
+ subsetFonts: true,
1332
+ verbose: config.verbose,
1333
+ });
1334
+ modified = true;
1335
+ message = "All fonts embedded (subset)";
1336
+ messageType = "success";
1337
+ continue;
1338
+ }
1339
+
1340
+ if (choice === "E") {
1341
+ log("\nEmbedding all external fonts (full)...");
1342
+ pushHistory();
1343
+ doc = await SVGToolbox.embedExternalDependencies(doc, {
1344
+ embedFonts: true,
1345
+ embedImages: false,
1346
+ embedCSS: true,
1347
+ subsetFonts: false,
1348
+ verbose: config.verbose,
1349
+ });
1350
+ modified = true;
1351
+ message = "All fonts embedded (full)";
1352
+ messageType = "success";
1353
+ continue;
1354
+ }
1355
+
1356
+ if (choice === "a" || choice === "A") {
1357
+ log(`\n${colors.cyan}Apply to all fonts:${colors.reset}`);
1358
+ log(` ${colors.dim}[e]${colors.reset} Embed all (subset)`);
1359
+ log(` ${colors.dim}[E]${colors.reset} Embed all (full)`);
1360
+ log(` ${colors.dim}[r]${colors.reset} Replace all with mapping`);
1361
+ log(` ${colors.dim}[d]${colors.reset} Delete all fonts`);
1362
+ log(` ${colors.dim}[b]${colors.reset} Back`);
1363
+
1364
+ const subChoice = await question(`\n${colors.bright}>${colors.reset} `);
1365
+
1366
+ if (subChoice === "e" || subChoice === "E") {
1367
+ log("\nEmbedding all fonts...");
1368
+ pushHistory();
1369
+ doc = await SVGToolbox.embedExternalDependencies(doc, {
1370
+ embedFonts: true,
1371
+ embedImages: false,
1372
+ embedCSS: true,
1373
+ subsetFonts: subChoice === "e",
1374
+ verbose: config.verbose,
1375
+ });
1376
+ modified = true;
1377
+ message = `All fonts embedded (${subChoice === "e" ? "subset" : "full"})`;
1378
+ messageType = "success";
1379
+ } else if (subChoice === "r") {
1380
+ const mapFile = await question(`${colors.cyan}Replacement map file (or Enter for default):${colors.reset} `);
1381
+ const map = FontManager.loadReplacementMap(mapFile.trim() || null);
1382
+ if (map) {
1383
+ pushHistory();
1384
+ const result = FontManager.applyFontReplacements(doc, map.replacements);
1385
+ if (result.modified) {
1386
+ modified = true;
1387
+ message = `Replaced ${result.replaced.length} font(s)`;
1388
+ messageType = "success";
1389
+ } else {
1390
+ message = "No fonts matched replacement map";
1391
+ messageType = "warn";
1392
+ }
1393
+ } else {
1394
+ message = "No replacement map found";
1395
+ messageType = "error";
1396
+ }
1397
+ } else if (subChoice === "d") {
1398
+ const confirm = await question(
1399
+ `${colors.red}Delete ALL fonts? This cannot be undone! [y/N]${colors.reset} `
1400
+ );
1401
+ if (confirm.toLowerCase() === "y") {
1402
+ pushHistory();
1403
+ // Remove all @font-face rules and font references
1404
+ const styleEls = doc.querySelectorAll?.("style") || [];
1405
+ for (const styleEl of styleEls) {
1406
+ if (styleEl.textContent) {
1407
+ styleEl.textContent = styleEl.textContent.replace(/@font-face\s*\{[^}]*\}/gi, "");
1408
+ }
1409
+ }
1410
+ modified = true;
1411
+ message = "All fonts deleted";
1412
+ messageType = "success";
1413
+ }
1414
+ }
1415
+ continue;
1416
+ }
1417
+
1418
+ // Font selection by number
1419
+ const fontIdx = parseInt(choice, 10) - 1;
1420
+ if (fontIdx >= 0 && fontIdx < filteredFonts.length) {
1421
+ const selectedFont = filteredFonts[fontIdx];
1422
+ selectedIdx = fontIdx;
1423
+
1424
+ console.clear();
1425
+ log(`\n${colors.bright}${BOX.dtl}${BOX.dh.repeat(TABLE_WIDTH - 2)}${BOX.dtr}${colors.reset}`);
1426
+ log(`${colors.bright}${BOX.dv}${colors.reset} FONT DETAILS${" ".repeat(TABLE_WIDTH - 16)}${colors.bright}${BOX.dv}${colors.reset}`);
1427
+ log(`${colors.bright}${BOX.dbl}${BOX.dh.repeat(TABLE_WIDTH - 2)}${BOX.dbr}${colors.reset}`);
1428
+
1429
+ log(`\n${colors.cyan}Family:${colors.reset} ${selectedFont.family}`);
1430
+ log(`${colors.cyan}Type:${colors.reset} ${fontTypeIcon(selectedFont.type)} ${selectedFont.type}`);
1431
+ if (selectedFont.source) {
1432
+ const srcDisplay =
1433
+ selectedFont.source.length > 60
1434
+ ? selectedFont.source.slice(0, 57) + "..."
1435
+ : selectedFont.source;
1436
+ log(`${colors.cyan}Source:${colors.reset} ${srcDisplay}`);
1437
+ }
1438
+ if (selectedFont.size) {
1439
+ log(`${colors.cyan}Size:${colors.reset} ${formatSize(selectedFont.size)}`);
1440
+ }
1441
+ if (selectedFont.usedChars && selectedFont.usedChars.size > 0) {
1442
+ const chars = [...selectedFont.usedChars].sort().join("");
1443
+ const charsDisplay = chars.length > 60 ? chars.slice(0, 57) + "..." : chars;
1444
+ log(`${colors.cyan}Chars:${colors.reset} ${charsDisplay} (${selectedFont.usedChars.size} unique)`);
1445
+ }
1446
+
1447
+ log(`\n${colors.cyan}Actions:${colors.reset}`);
1448
+ log(` ${colors.dim}[e]${colors.reset} Embed (subset) ${colors.dim}[E]${colors.reset} Embed (full)`);
1449
+ log(` ${colors.dim}[r]${colors.reset} Replace font ${colors.dim}[x]${colors.reset} Extract to file`);
1450
+ log(` ${colors.dim}[d]${colors.reset} Delete font ${colors.dim}[v]${colors.reset} Validate URL`);
1451
+ log(` ${colors.dim}[c]${colors.reset} Copy details ${colors.dim}[b]${colors.reset} Back to list`);
1452
+
1453
+ const action = await question(`\n${colors.bright}>${colors.reset} `);
1454
+
1455
+ if (action === "e" || action === "E") {
1456
+ log(`\n${colors.dim}Embedding ${selectedFont.family}...${colors.reset}`);
1457
+ pushHistory();
1458
+ doc = await SVGToolbox.embedExternalDependencies(doc, {
1459
+ embedFonts: true,
1460
+ embedImages: false,
1461
+ embedCSS: true,
1462
+ subsetFonts: action === "e",
1463
+ verbose: config.verbose,
1464
+ });
1465
+ modified = true;
1466
+ message = `Embedded: ${selectedFont.family} (${action === "e" ? "subset" : "full"})`;
1467
+ messageType = "success";
1468
+ } else if (action === "r") {
1469
+ const newFont = await question(`${colors.cyan}Replace with:${colors.reset} `);
1470
+ if (newFont.trim()) {
1471
+ pushHistory();
1472
+ const replaceResult = FontManager.applyFontReplacements(doc, {
1473
+ [selectedFont.family]: newFont.trim(),
1474
+ });
1475
+ if (replaceResult.modified) {
1476
+ modified = true;
1477
+ message = `Replaced: ${selectedFont.family} -> ${newFont.trim()}`;
1478
+ messageType = "success";
1479
+ } else {
1480
+ message = "Font not found in document";
1481
+ messageType = "warn";
1482
+ }
1483
+ }
1484
+ } else if (action === "x") {
1485
+ if (selectedFont.type === "embedded" && selectedFont.size) {
1486
+ const extractDir = await question(
1487
+ `${colors.cyan}Extract to directory [./fonts]:${colors.reset} `
1488
+ );
1489
+ const dir = extractDir.trim() || "./fonts";
1490
+ mkdirSync(dir, { recursive: true });
1491
+
1492
+ // Extract using svg-toolbox
1493
+ await SVGToolbox.exportEmbeddedResources(doc, {
1494
+ outputDir: dir,
1495
+ extractFonts: true,
1496
+ extractImages: false,
1497
+ });
1498
+ message = `Font extracted to: ${dir}`;
1499
+ messageType = "success";
1500
+ } else {
1501
+ message = "Only embedded fonts can be extracted";
1502
+ messageType = "warn";
1503
+ }
1504
+ } else if (action === "d") {
1505
+ const confirm = await question(
1506
+ `${colors.red}Delete ${selectedFont.family}? [y/N]${colors.reset} `
1507
+ );
1508
+ if (confirm.toLowerCase() === "y") {
1509
+ pushHistory();
1510
+ // Remove @font-face for this font
1511
+ const styleEls = doc.querySelectorAll?.("style") || [];
1512
+ for (const styleEl of styleEls) {
1513
+ if (styleEl.textContent) {
1514
+ const pattern = new RegExp(
1515
+ `@font-face\\s*\\{[^}]*font-family:\\s*['"]?${selectedFont.family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]?[^}]*\\}`,
1516
+ "gi"
1517
+ );
1518
+ styleEl.textContent = styleEl.textContent.replace(pattern, "");
1519
+ }
1520
+ }
1521
+ modified = true;
1522
+ message = `Deleted: ${selectedFont.family}`;
1523
+ messageType = "success";
1524
+ }
1525
+ } else if (action === "v") {
1526
+ if (selectedFont.source && !selectedFont.source.startsWith("data:")) {
1527
+ log(`\n${colors.dim}Validating: ${selectedFont.source}${colors.reset}`);
1528
+ try {
1529
+ const response = await fetch(selectedFont.source, { method: "HEAD" });
1530
+ if (response.ok) {
1531
+ message = `Valid: ${response.status} ${response.statusText}`;
1532
+ messageType = "success";
1533
+ } else {
1534
+ message = `Invalid: HTTP ${response.status}`;
1535
+ messageType = "error";
1536
+ }
1537
+ } catch (err) {
1538
+ message = `Error: ${err.message}`;
1539
+ messageType = "error";
1540
+ }
1541
+ } else {
1542
+ message = "No external URL to validate";
1543
+ messageType = "warn";
1544
+ }
1545
+ } else if (action === "c") {
1546
+ // Copy font details to clipboard (platform-specific)
1547
+ const details = [
1548
+ `Font Family: ${selectedFont.family}`,
1549
+ `Type: ${selectedFont.type}`,
1550
+ selectedFont.source ? `Source: ${selectedFont.source}` : null,
1551
+ selectedFont.size ? `Size: ${formatSize(selectedFont.size)}` : null,
1552
+ selectedFont.usedChars
1553
+ ? `Characters: ${[...selectedFont.usedChars].join("")}`
1554
+ : null,
1555
+ ]
1556
+ .filter(Boolean)
1557
+ .join("\n");
1558
+
1559
+ try {
1560
+ // Try pbcopy (macOS), xclip (Linux), clip (Windows)
1561
+ const clipCmd =
1562
+ process.platform === "darwin"
1563
+ ? "pbcopy"
1564
+ : process.platform === "win32"
1565
+ ? "clip"
1566
+ : "xclip -selection clipboard";
1567
+ const { execSync } = await import("child_process");
1568
+ execSync(clipCmd, { input: details });
1569
+ message = "Font details copied to clipboard";
1570
+ messageType = "success";
1571
+ } catch {
1572
+ // Fallback: just show the details
1573
+ log(`\n${colors.dim}${details}${colors.reset}`);
1574
+ message = "Could not copy to clipboard (shown above)";
1575
+ messageType = "warn";
1576
+ }
1577
+ }
1578
+ // 'b' or any other key returns to list
1579
+ selectedIdx = -1;
1580
+ }
1581
+ }
1582
+ }
1583
+
1584
+ // ============================================================================
1585
+ // MAIN
1586
+ // ============================================================================
1587
+ async function main() {
1588
+ config = parseArgs(process.argv.slice(2));
1589
+
1590
+ // Show help if no command and no inputs
1591
+ if (!config.command && config.inputs.length === 0) {
1592
+ console.log(HELP);
1593
+ process.exit(CONSTANTS.EXIT_SUCCESS);
1594
+ }
1595
+
1596
+ // Handle template command (no inputs needed)
1597
+ if (config.command === "template") {
1598
+ cmdTemplate();
1599
+ process.exit(CONSTANTS.EXIT_SUCCESS);
1600
+ }
1601
+
1602
+ // Handle cache command (no inputs needed)
1603
+ if (config.command === "cache") {
1604
+ cmdCache();
1605
+ process.exit(CONSTANTS.EXIT_SUCCESS);
1606
+ }
1607
+
1608
+ // Handle search command (no inputs needed)
1609
+ if (config.command === "search") {
1610
+ await cmdSearch();
1611
+ process.exit(CONSTANTS.EXIT_SUCCESS);
1612
+ }
1613
+
1614
+ // Collect files
1615
+ const files = collectSvgFiles(config.inputs, config.recursive);
1616
+
1617
+ if (files.length === 0) {
1618
+ logError("No SVG files found");
1619
+ process.exit(CONSTANTS.EXIT_ERROR);
1620
+ }
1621
+
1622
+ log(`Found ${files.length} SVG file(s)`);
1623
+
1624
+ // Execute command
1625
+ try {
1626
+ switch (config.command) {
1627
+ case "list":
1628
+ await cmdList(files);
1629
+ break;
1630
+
1631
+ case "embed":
1632
+ await cmdEmbed(files);
1633
+ break;
1634
+
1635
+ case "extract":
1636
+ await cmdExtract(files);
1637
+ break;
1638
+
1639
+ case "replace":
1640
+ await cmdReplace(files);
1641
+ break;
1642
+
1643
+ case "interactive":
1644
+ await cmdInteractive(files);
1645
+ break;
1646
+
1647
+ case "dedupe":
1648
+ await cmdDedupe(files);
1649
+ break;
1650
+
1651
+ default:
1652
+ // Default to embed
1653
+ await cmdEmbed(files);
1654
+ }
1655
+ } catch (err) {
1656
+ logError(err.message);
1657
+ if (config.verbose) {
1658
+ console.error(err.stack);
1659
+ }
1660
+ process.exit(CONSTANTS.EXIT_ERROR);
1661
+ }
1662
+
1663
+ process.exit(CONSTANTS.EXIT_SUCCESS);
1664
+ }
1665
+
1666
+ main();