@emasoft/svg-matrix 1.2.1 → 1.3.0

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