@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,1013 @@
1
+ /**
2
+ * @fileoverview Font Management Module for SVG-Matrix
3
+ *
4
+ * Core font management functionality extracted from svg-toolbox.js.
5
+ * Provides utilities for:
6
+ * - Font embedding (convert external fonts to base64 data URIs)
7
+ * - Font extraction (extract embedded fonts to files)
8
+ * - Font listing and analysis
9
+ * - Google Fonts character subsetting
10
+ * - Local system font detection
11
+ * - YAML replacement map processing
12
+ *
13
+ * @module font-manager
14
+ * @license MIT
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from "fs";
18
+ import { join, dirname, basename, extname, resolve } from "path";
19
+ import { homedir, platform } from "os";
20
+ import { execSync, execFileSync } from "child_process";
21
+ import yaml from "js-yaml";
22
+
23
+ // ============================================================================
24
+ // CONSTANTS
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Default replacement map filename
29
+ * @constant {string}
30
+ */
31
+ export const DEFAULT_REPLACEMENT_MAP = "svgm_replacement_map.yml";
32
+
33
+ /**
34
+ * Environment variable for custom replacement map path
35
+ * @constant {string}
36
+ */
37
+ export const ENV_REPLACEMENT_MAP = "SVGM_REPLACEMENT_MAP";
38
+
39
+ /**
40
+ * Common web-safe fonts that browsers have built-in
41
+ * @constant {string[]}
42
+ */
43
+ export const WEB_SAFE_FONTS = [
44
+ "Arial",
45
+ "Arial Black",
46
+ "Comic Sans MS",
47
+ "Courier New",
48
+ "Georgia",
49
+ "Impact",
50
+ "Times New Roman",
51
+ "Trebuchet MS",
52
+ "Verdana",
53
+ "Lucida Console",
54
+ "Lucida Sans Unicode",
55
+ "Palatino Linotype",
56
+ "Tahoma",
57
+ "Geneva",
58
+ "Helvetica",
59
+ "sans-serif",
60
+ "serif",
61
+ "monospace",
62
+ "cursive",
63
+ "fantasy",
64
+ "system-ui",
65
+ ];
66
+
67
+ /**
68
+ * Font formats and their MIME types
69
+ * @constant {Object}
70
+ */
71
+ export const FONT_FORMATS = {
72
+ ".woff2": "font/woff2",
73
+ ".woff": "font/woff",
74
+ ".ttf": "font/ttf",
75
+ ".otf": "font/otf",
76
+ ".eot": "application/vnd.ms-fontobject",
77
+ ".svg": "image/svg+xml",
78
+ };
79
+
80
+ /**
81
+ * System font directories by platform
82
+ * @constant {Object}
83
+ */
84
+ export const SYSTEM_FONT_PATHS = {
85
+ darwin: [
86
+ "/Library/Fonts",
87
+ "/System/Library/Fonts",
88
+ join(homedir(), "Library/Fonts"),
89
+ ],
90
+ linux: [
91
+ "/usr/share/fonts",
92
+ "/usr/local/share/fonts",
93
+ join(homedir(), ".fonts"),
94
+ join(homedir(), ".local/share/fonts"),
95
+ ],
96
+ win32: [
97
+ join(process.env.WINDIR || "C:\\Windows", "Fonts"),
98
+ join(homedir(), "AppData/Local/Microsoft/Windows/Fonts"),
99
+ ],
100
+ };
101
+
102
+ // ============================================================================
103
+ // FONT CHARACTER EXTRACTION
104
+ // ============================================================================
105
+
106
+ /**
107
+ * Extract all text content from SVG and map to fonts.
108
+ * Returns a Map where keys are font family names and values are Sets of characters used.
109
+ *
110
+ * @param {Object} element - SVG element to scan (DOM element or JSDOM document)
111
+ * @returns {Map<string, Set<string>>} Font to characters map
112
+ */
113
+ export function extractFontCharacterMap(element) {
114
+ const fontMap = new Map();
115
+
116
+ const addCharsToFont = (fontFamily, text) => {
117
+ if (!fontFamily || !text) return;
118
+ // Normalize font family name (remove quotes, trim, take first in stack)
119
+ const normalizedFont = fontFamily
120
+ .replace(/['"]/g, "")
121
+ .trim()
122
+ .split(",")[0]
123
+ .trim();
124
+ if (!fontMap.has(normalizedFont)) {
125
+ fontMap.set(normalizedFont, new Set());
126
+ }
127
+ const charSet = fontMap.get(normalizedFont);
128
+ for (const char of text) {
129
+ charSet.add(char);
130
+ }
131
+ };
132
+
133
+ const walk = (el) => {
134
+ if (!el) return;
135
+
136
+ // Get font-family from style attribute
137
+ const style = el.getAttribute?.("style") || "";
138
+ const fontMatch = style.match(/font-family:\s*([^;]+)/i);
139
+ const fontFromStyle = fontMatch ? fontMatch[1] : null;
140
+
141
+ // Get font-family from font-family attribute
142
+ const fontFromAttr = el.getAttribute?.("font-family");
143
+
144
+ // Get font-family from CSS face attribute (for foreignObject content)
145
+ const faceAttr = el.getAttribute?.("face");
146
+
147
+ const fontFamily = fontFromStyle || fontFromAttr || faceAttr;
148
+
149
+ // Get text content from this element
150
+ // Some DOM implementations (like our svg-parser) store text directly in textContent
151
+ // without creating actual childNode text nodes. Handle both cases.
152
+ let directTextContent = "";
153
+
154
+ // First try childNodes for standard DOM implementations
155
+ if (el.childNodes && el.childNodes.length > 0) {
156
+ for (const node of el.childNodes) {
157
+ if (node.nodeType === 3) {
158
+ // TEXT_NODE
159
+ directTextContent += node.nodeValue || "";
160
+ }
161
+ }
162
+ }
163
+
164
+ // If no text found via childNodes, but this is a text/tspan element,
165
+ // use textContent directly (but only if it has no children to avoid double-counting)
166
+ const isTextElement = el.tagName === "text" || el.tagName === "tspan";
167
+ if (!directTextContent && isTextElement && el.textContent) {
168
+ // Only use textContent if there are no child elements (which would have their own fonts)
169
+ const hasChildElements = el.children && el.children.length > 0;
170
+ if (!hasChildElements) {
171
+ directTextContent = el.textContent;
172
+ }
173
+ }
174
+
175
+ if (fontFamily && directTextContent.trim()) {
176
+ addCharsToFont(fontFamily, directTextContent);
177
+ }
178
+
179
+ // Also check for text in <text> and <tspan> elements with inherited font
180
+ if (isTextElement) {
181
+ // Try to get inherited font from ancestors if no font on this element
182
+ let inheritedFont = fontFamily;
183
+ if (!inheritedFont && el.parentNode) {
184
+ const parentStyle = el.parentNode.getAttribute?.("style") || "";
185
+ const parentFontMatch = parentStyle.match(/font-family:\s*([^;]+)/i);
186
+ inheritedFont = parentFontMatch
187
+ ? parentFontMatch[1]
188
+ : el.parentNode.getAttribute?.("font-family");
189
+ }
190
+ if (inheritedFont && !fontFamily && directTextContent.trim()) {
191
+ addCharsToFont(inheritedFont, directTextContent);
192
+ }
193
+ }
194
+
195
+ // Recurse into children
196
+ if (el.children) {
197
+ for (const child of el.children) {
198
+ walk(child);
199
+ }
200
+ }
201
+ };
202
+
203
+ walk(element);
204
+ return fontMap;
205
+ }
206
+
207
+ /**
208
+ * Convert font character map to URL-safe text parameter.
209
+ * @param {Set<string>} charSet - Set of characters
210
+ * @returns {string} URL-encoded unique characters
211
+ */
212
+ export function charsToTextParam(charSet) {
213
+ const uniqueChars = [...charSet].sort().join("");
214
+ return encodeURIComponent(uniqueChars);
215
+ }
216
+
217
+ // ============================================================================
218
+ // GOOGLE FONTS UTILITIES
219
+ // ============================================================================
220
+
221
+ /**
222
+ * Check if URL is a Google Fonts URL.
223
+ * @param {string} url - URL to check
224
+ * @returns {boolean}
225
+ */
226
+ export function isGoogleFontsUrl(url) {
227
+ return (
228
+ url &&
229
+ (url.includes("fonts.googleapis.com") || url.includes("fonts.gstatic.com"))
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Extract font family name from Google Fonts URL.
235
+ * @param {string} url - Google Fonts URL
236
+ * @returns {string|null} Font family name
237
+ */
238
+ export function extractFontFamilyFromGoogleUrl(url) {
239
+ try {
240
+ const urlObj = new URL(url);
241
+ const family = urlObj.searchParams.get("family");
242
+ if (family) {
243
+ // Handle "Fira+Mono" or "Fira Mono:400,700"
244
+ return family.split(":")[0].replace(/\+/g, " ");
245
+ }
246
+ } catch {
247
+ // Try regex fallback
248
+ const match = url.match(/family=([^&:]+)/);
249
+ if (match) {
250
+ return match[1].replace(/\+/g, " ");
251
+ }
252
+ }
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * Add text parameter to Google Fonts URL for character subsetting.
258
+ * This dramatically reduces font file size by only including needed glyphs.
259
+ *
260
+ * @param {string} url - Original Google Fonts URL
261
+ * @param {string} textParam - URL-encoded characters to include
262
+ * @returns {string} Modified URL with text parameter
263
+ */
264
+ export function addTextParamToGoogleFontsUrl(url, textParam) {
265
+ if (!textParam) return url;
266
+
267
+ try {
268
+ const urlObj = new URL(url);
269
+ urlObj.searchParams.set("text", decodeURIComponent(textParam));
270
+ return urlObj.toString();
271
+ } catch {
272
+ // Fallback: append to URL string
273
+ const separator = url.includes("?") ? "&" : "?";
274
+ return `${url}${separator}text=${textParam}`;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Build Google Fonts URL for a font family.
280
+ * @param {string} fontFamily - Font family name
281
+ * @param {Object} [options={}] - Options
282
+ * @param {string[]} [options.weights=['400']] - Font weights to include
283
+ * @param {string[]} [options.styles=['normal']] - Font styles
284
+ * @param {string} [options.text] - Characters to subset
285
+ * @param {string} [options.display='swap'] - Font-display value
286
+ * @returns {string} Google Fonts URL
287
+ */
288
+ export function buildGoogleFontsUrl(fontFamily, options = {}) {
289
+ const {
290
+ weights = ["400"],
291
+ styles = ["normal"],
292
+ text,
293
+ display = "swap",
294
+ } = options;
295
+
296
+ const encodedFamily = fontFamily.replace(/ /g, "+");
297
+ const weightStr = weights.join(",");
298
+
299
+ let url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@${weightStr}&display=${display}`;
300
+
301
+ if (text) {
302
+ url += `&text=${encodeURIComponent(text)}`;
303
+ }
304
+
305
+ return url;
306
+ }
307
+
308
+ /**
309
+ * List of common Google Fonts (for heuristic matching)
310
+ * @constant {string[]}
311
+ */
312
+ export const POPULAR_GOOGLE_FONTS = [
313
+ "Roboto",
314
+ "Open Sans",
315
+ "Lato",
316
+ "Montserrat",
317
+ "Oswald",
318
+ "Source Sans Pro",
319
+ "Raleway",
320
+ "PT Sans",
321
+ "Merriweather",
322
+ "Noto Sans",
323
+ "Ubuntu",
324
+ "Playfair Display",
325
+ "Nunito",
326
+ "Poppins",
327
+ "Inter",
328
+ "Fira Code",
329
+ "Fira Sans",
330
+ "Work Sans",
331
+ "Quicksand",
332
+ "Inconsolata",
333
+ "Source Code Pro",
334
+ "JetBrains Mono",
335
+ "IBM Plex Sans",
336
+ "IBM Plex Mono",
337
+ "Libre Baskerville",
338
+ "Crimson Text",
339
+ "EB Garamond",
340
+ "Spectral",
341
+ "Bitter",
342
+ "Zilla Slab",
343
+ ];
344
+
345
+ // ============================================================================
346
+ // LOCAL FONT DETECTION
347
+ // ============================================================================
348
+
349
+ /**
350
+ * Get system font directories for current platform
351
+ * @returns {string[]} Array of font directory paths
352
+ */
353
+ export function getSystemFontDirs() {
354
+ const os = platform();
355
+ return SYSTEM_FONT_PATHS[os] || [];
356
+ }
357
+
358
+ /**
359
+ * Recursively read directory, compatible with Node 18.0+
360
+ * Node 18.17+ has native recursive support, older versions need manual recursion
361
+ * @private
362
+ * @param {string} dir - Directory to read
363
+ * @returns {Array<{name: string, path: string, isDirectory: () => boolean}>}
364
+ */
365
+ function readdirRecursive(dir) {
366
+ const results = [];
367
+
368
+ try {
369
+ const entries = readdirSync(dir, { withFileTypes: true });
370
+
371
+ for (const entry of entries) {
372
+ const fullPath = join(dir, entry.name);
373
+
374
+ if (entry.isDirectory()) {
375
+ // Recurse into subdirectory
376
+ results.push(...readdirRecursive(fullPath));
377
+ } else {
378
+ results.push({
379
+ name: entry.name,
380
+ path: fullPath,
381
+ isDirectory: () => false,
382
+ });
383
+ }
384
+ }
385
+ } catch {
386
+ // Skip inaccessible directories
387
+ }
388
+
389
+ return results;
390
+ }
391
+
392
+ /**
393
+ * Check if a font is installed locally on the system.
394
+ * @param {string} fontFamily - Font family name to check
395
+ * @returns {Promise<{found: boolean, path?: string}>}
396
+ */
397
+ export async function checkLocalFont(fontFamily) {
398
+ const fontDirs = getSystemFontDirs();
399
+ const normalizedName = fontFamily.toLowerCase().replace(/ /g, "");
400
+
401
+ // Common font file naming patterns
402
+ const patterns = [
403
+ fontFamily.replace(/ /g, ""),
404
+ fontFamily.replace(/ /g, "-"),
405
+ fontFamily.replace(/ /g, "_"),
406
+ normalizedName,
407
+ ];
408
+
409
+ for (const dir of fontDirs) {
410
+ if (!existsSync(dir)) continue;
411
+
412
+ try {
413
+ const files = readdirRecursive(dir);
414
+
415
+ for (const file of files) {
416
+ if (file.isDirectory()) continue;
417
+
418
+ const ext = extname(file.name).toLowerCase();
419
+ if (!FONT_FORMATS[ext]) continue;
420
+
421
+ const baseName = basename(file.name, ext).toLowerCase();
422
+
423
+ for (const pattern of patterns) {
424
+ if (baseName.includes(pattern.toLowerCase())) {
425
+ return {
426
+ found: true,
427
+ path: file.path,
428
+ };
429
+ }
430
+ }
431
+ }
432
+ } catch {
433
+ // Skip inaccessible directories
434
+ }
435
+ }
436
+
437
+ return { found: false };
438
+ }
439
+
440
+ /**
441
+ * List all installed system fonts.
442
+ * @returns {Promise<Array<{name: string, path: string, format: string}>>}
443
+ */
444
+ export async function listSystemFonts() {
445
+ const fonts = [];
446
+ const fontDirs = getSystemFontDirs();
447
+
448
+ for (const dir of fontDirs) {
449
+ if (!existsSync(dir)) continue;
450
+
451
+ try {
452
+ const files = readdirRecursive(dir);
453
+
454
+ for (const file of files) {
455
+ if (file.isDirectory()) continue;
456
+
457
+ const ext = extname(file.name).toLowerCase();
458
+ const format = FONT_FORMATS[ext];
459
+ if (!format) continue;
460
+
461
+ const name = basename(file.name, ext);
462
+ fonts.push({
463
+ name,
464
+ path: file.path,
465
+ format: ext.slice(1),
466
+ });
467
+ }
468
+ } catch {
469
+ // Skip inaccessible directories
470
+ }
471
+ }
472
+
473
+ return fonts;
474
+ }
475
+
476
+ // ============================================================================
477
+ // EXTERNAL TOOL INTEGRATION
478
+ // ============================================================================
479
+
480
+ /**
481
+ * Check if a command exists in PATH
482
+ * Cross-platform: uses 'where' on Windows, 'which' on Unix
483
+ * @param {string} cmd - Command name
484
+ * @returns {boolean}
485
+ */
486
+ export function commandExists(cmd) {
487
+ try {
488
+ // Windows uses 'where', Unix uses 'which'
489
+ const checkCmd = platform() === "win32" ? "where" : "which";
490
+ execSync(`${checkCmd} ${cmd}`, { stdio: "ignore" });
491
+ return true;
492
+ } catch {
493
+ return false;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Download font using FontGet (npm package)
499
+ * @param {string} fontFamily - Font family name
500
+ * @param {string} outputDir - Output directory
501
+ * @returns {Promise<{success: boolean, path?: string, error?: string}>}
502
+ */
503
+ export async function downloadWithFontGet(fontFamily, outputDir) {
504
+ if (!commandExists("fontget")) {
505
+ return { success: false, error: "FontGet not installed. Run: npm install -g fontget" };
506
+ }
507
+
508
+ try {
509
+ mkdirSync(outputDir, { recursive: true });
510
+ execFileSync("fontget", [fontFamily, "-o", outputDir], {
511
+ stdio: "pipe",
512
+ timeout: 60000,
513
+ });
514
+
515
+ // Find downloaded file
516
+ const files = readdirSync(outputDir);
517
+ const fontFile = files.find((f) => Object.keys(FONT_FORMATS).some((ext) => f.endsWith(ext)));
518
+
519
+ if (fontFile) {
520
+ return { success: true, path: join(outputDir, fontFile) };
521
+ }
522
+ return { success: false, error: "No font file found after download" };
523
+ } catch (err) {
524
+ return { success: false, error: err.message };
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Download font using fnt (Homebrew package)
530
+ * @param {string} fontFamily - Font family name
531
+ * @param {string} outputDir - Output directory
532
+ * @returns {Promise<{success: boolean, path?: string, error?: string}>}
533
+ */
534
+ export async function downloadWithFnt(fontFamily, outputDir) {
535
+ if (!commandExists("fnt")) {
536
+ return { success: false, error: "fnt not installed. Run: brew install alexmyczko/fnt/fnt" };
537
+ }
538
+
539
+ try {
540
+ mkdirSync(outputDir, { recursive: true });
541
+ execFileSync("fnt", ["install", fontFamily], {
542
+ stdio: "pipe",
543
+ timeout: 60000,
544
+ });
545
+
546
+ // fnt installs to ~/.fonts by default
547
+ const fntFontsDir = join(homedir(), ".fonts");
548
+ if (existsSync(fntFontsDir)) {
549
+ const files = readdirSync(fntFontsDir);
550
+ const normalizedName = fontFamily.toLowerCase().replace(/ /g, "");
551
+ const fontFile = files.find((f) => {
552
+ const base = basename(f, extname(f)).toLowerCase();
553
+ return (
554
+ base.includes(normalizedName) &&
555
+ Object.keys(FONT_FORMATS).some((ext) => f.endsWith(ext))
556
+ );
557
+ });
558
+
559
+ if (fontFile) {
560
+ const srcPath = join(fntFontsDir, fontFile);
561
+ const destPath = join(outputDir, fontFile);
562
+ copyFileSync(srcPath, destPath);
563
+ return { success: true, path: destPath };
564
+ }
565
+ }
566
+ return { success: false, error: "Font installed but file not found" };
567
+ } catch (err) {
568
+ return { success: false, error: err.message };
569
+ }
570
+ }
571
+
572
+ // ============================================================================
573
+ // REPLACEMENT MAP HANDLING
574
+ // ============================================================================
575
+
576
+ /**
577
+ * Load font replacement map from YAML file.
578
+ *
579
+ * @param {string} [mapPath] - Path to YAML file. If not provided, checks:
580
+ * 1. SVGM_REPLACEMENT_MAP environment variable
581
+ * 2. ./svgm_replacement_map.yml in current directory
582
+ * @returns {{replacements: Object, options: Object} | null} Parsed map or null if not found
583
+ */
584
+ export function loadReplacementMap(mapPath) {
585
+ // Priority: explicit path > env var > default file
586
+ const pathsToTry = [
587
+ mapPath,
588
+ process.env[ENV_REPLACEMENT_MAP],
589
+ join(process.cwd(), DEFAULT_REPLACEMENT_MAP),
590
+ join(process.cwd(), "svgm_replacement_map_default.yml"),
591
+ ].filter(Boolean);
592
+
593
+ for (const p of pathsToTry) {
594
+ if (existsSync(p)) {
595
+ try {
596
+ const content = readFileSync(p, "utf8");
597
+ // Use FAILSAFE_SCHEMA for security (no function execution)
598
+ const parsed = yaml.load(content, { schema: yaml.FAILSAFE_SCHEMA });
599
+
600
+ return {
601
+ replacements: parsed.replacements || {},
602
+ options: parsed.options || {
603
+ default_embed: true,
604
+ default_subset: true,
605
+ fallback_source: "google",
606
+ auto_download: true,
607
+ },
608
+ path: p,
609
+ };
610
+ } catch (err) {
611
+ throw new Error(`Failed to parse replacement map ${p}: ${err.message}`);
612
+ }
613
+ }
614
+ }
615
+
616
+ return null;
617
+ }
618
+
619
+ /**
620
+ * Apply font replacements to an SVG document.
621
+ *
622
+ * @param {Object} doc - Parsed SVG document
623
+ * @param {Object} replacements - Font replacement map {original: replacement}
624
+ * @returns {{modified: boolean, replaced: Array<{from: string, to: string}>}}
625
+ */
626
+ export function applyFontReplacements(doc, replacements) {
627
+ const result = { modified: false, replaced: [] };
628
+
629
+ const replaceInStyle = (styleStr) => {
630
+ let modified = styleStr;
631
+ for (const [original, replacement] of Object.entries(replacements)) {
632
+ const pattern = new RegExp(
633
+ `(font-family:\\s*)(['"]?)${escapeRegex(original)}\\2`,
634
+ "gi"
635
+ );
636
+ if (pattern.test(modified)) {
637
+ const newValue =
638
+ typeof replacement === "string" ? replacement : replacement.replacement;
639
+ modified = modified.replace(pattern, `$1$2${newValue}$2`);
640
+ result.replaced.push({ from: original, to: newValue });
641
+ result.modified = true;
642
+ }
643
+ }
644
+ return modified;
645
+ };
646
+
647
+ const replaceInAttribute = (el, attrName) => {
648
+ const value = el.getAttribute(attrName);
649
+ if (!value) return;
650
+
651
+ for (const [original, replacement] of Object.entries(replacements)) {
652
+ const pattern = new RegExp(`^(['"]?)${escapeRegex(original)}\\1$`, "i");
653
+ if (pattern.test(value.trim())) {
654
+ const newValue =
655
+ typeof replacement === "string" ? replacement : replacement.replacement;
656
+ el.setAttribute(attrName, newValue);
657
+ result.replaced.push({ from: original, to: newValue });
658
+ result.modified = true;
659
+ }
660
+ }
661
+ };
662
+
663
+ // Walk all elements
664
+ const walk = (el) => {
665
+ if (!el) return;
666
+
667
+ // Replace in style attribute
668
+ const style = el.getAttribute?.("style");
669
+ if (style) {
670
+ const newStyle = replaceInStyle(style);
671
+ if (newStyle !== style) {
672
+ el.setAttribute("style", newStyle);
673
+ }
674
+ }
675
+
676
+ // Replace in font-family attribute
677
+ replaceInAttribute(el, "font-family");
678
+
679
+ // Replace in face attribute (for foreignObject)
680
+ replaceInAttribute(el, "face");
681
+
682
+ // Recurse
683
+ if (el.children) {
684
+ for (const child of el.children) {
685
+ walk(child);
686
+ }
687
+ }
688
+ };
689
+
690
+ // Also check <style> elements
691
+ const styleElements = doc.querySelectorAll?.("style") || [];
692
+ for (const styleEl of styleElements) {
693
+ if (styleEl.textContent) {
694
+ const newContent = replaceInStyle(styleEl.textContent);
695
+ if (newContent !== styleEl.textContent) {
696
+ styleEl.textContent = newContent;
697
+ }
698
+ }
699
+ }
700
+
701
+ walk(doc.documentElement || doc);
702
+
703
+ return result;
704
+ }
705
+
706
+ /**
707
+ * Escape string for use in regex
708
+ * @param {string} str - String to escape
709
+ * @returns {string}
710
+ */
711
+ function escapeRegex(str) {
712
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
713
+ }
714
+
715
+ // ============================================================================
716
+ // FONT LISTING AND ANALYSIS
717
+ // ============================================================================
718
+
719
+ /**
720
+ * Font information structure
721
+ * @typedef {Object} FontInfo
722
+ * @property {string} family - Font family name
723
+ * @property {string} type - 'embedded' | 'external' | 'system'
724
+ * @property {string} [source] - URL or path for external fonts
725
+ * @property {number} [size] - Size in bytes for embedded fonts
726
+ * @property {Set<string>} [usedChars] - Characters used in SVG
727
+ */
728
+
729
+ /**
730
+ * List all fonts used in an SVG document.
731
+ *
732
+ * @param {Object} doc - Parsed SVG document
733
+ * @returns {FontInfo[]} Array of font information
734
+ */
735
+ export function listFonts(doc) {
736
+ const fonts = new Map();
737
+
738
+ // Get character usage map
739
+ const charMap = extractFontCharacterMap(doc.documentElement || doc);
740
+
741
+ // Find @font-face rules in <style> elements
742
+ const styleElements = doc.querySelectorAll?.("style") || [];
743
+ for (const styleEl of styleElements) {
744
+ const css = styleEl.textContent || "";
745
+ const fontFaceRegex = /@font-face\s*\{([^}]*)\}/gi;
746
+ let match;
747
+
748
+ while ((match = fontFaceRegex.exec(css)) !== null) {
749
+ const block = match[1];
750
+
751
+ // Extract font-family
752
+ const familyMatch = block.match(/font-family:\s*(['"]?)([^;'"]+)\1/i);
753
+ const family = familyMatch ? familyMatch[2].trim() : null;
754
+ if (!family) continue;
755
+
756
+ // Extract src url
757
+ const srcMatch = block.match(/src:\s*url\((['"]?)([^)'"]+)\1\)/i);
758
+ const src = srcMatch ? srcMatch[2] : null;
759
+
760
+ // Determine type
761
+ let type = "embedded";
762
+ let source = null;
763
+
764
+ if (src) {
765
+ if (src.startsWith("data:")) {
766
+ type = "embedded";
767
+ // Calculate size from base64
768
+ const base64Match = src.match(/base64,(.+)/);
769
+ if (base64Match) {
770
+ const size = Math.ceil((base64Match[1].length * 3) / 4);
771
+ if (!fonts.has(family)) {
772
+ fonts.set(family, { family, type, size, usedChars: charMap.get(family) });
773
+ }
774
+ continue;
775
+ }
776
+ } else {
777
+ type = "external";
778
+ source = src;
779
+ }
780
+ }
781
+
782
+ if (!fonts.has(family)) {
783
+ fonts.set(family, { family, type, source, usedChars: charMap.get(family) });
784
+ }
785
+ }
786
+ }
787
+
788
+ // Add fonts from character map that aren't in @font-face (system fonts)
789
+ for (const [family, chars] of charMap) {
790
+ if (!fonts.has(family)) {
791
+ fonts.set(family, {
792
+ family,
793
+ type: WEB_SAFE_FONTS.includes(family) ? "system" : "unknown",
794
+ usedChars: chars,
795
+ });
796
+ }
797
+ }
798
+
799
+ return Array.from(fonts.values());
800
+ }
801
+
802
+ // ============================================================================
803
+ // BACKUP SYSTEM
804
+ // ============================================================================
805
+
806
+ /**
807
+ * Create a backup of a file before modifying it.
808
+ *
809
+ * @param {string} filePath - Path to file to backup
810
+ * @param {Object} [options={}] - Options
811
+ * @param {boolean} [options.noBackup=false] - Skip backup creation
812
+ * @param {number} [options.maxBackups=100] - Maximum numbered backups
813
+ * @returns {string|null} Path to backup file, or null if skipped
814
+ */
815
+ export function createBackup(filePath, options = {}) {
816
+ const { noBackup = false, maxBackups = 100 } = options;
817
+
818
+ if (noBackup || !existsSync(filePath)) {
819
+ return null;
820
+ }
821
+
822
+ const backupPath = `${filePath}.bak`;
823
+
824
+ // If backup exists, create numbered backup
825
+ let counter = 1;
826
+ let finalBackupPath = backupPath;
827
+
828
+ while (existsSync(finalBackupPath)) {
829
+ finalBackupPath = `${filePath}.bak.${counter++}`;
830
+ if (counter > maxBackups) {
831
+ throw new Error(`Too many backup files (>${maxBackups}). Clean up old backups.`);
832
+ }
833
+ }
834
+
835
+ copyFileSync(filePath, finalBackupPath);
836
+ return finalBackupPath;
837
+ }
838
+
839
+ // ============================================================================
840
+ // VALIDATION
841
+ // ============================================================================
842
+
843
+ /**
844
+ * Validate SVG after font operations.
845
+ *
846
+ * @param {string} svgContent - SVG content to validate
847
+ * @param {string} [operation] - Operation that was performed
848
+ * @returns {{valid: boolean, warnings: string[], errors: string[]}}
849
+ */
850
+ export async function validateSvgAfterFontOperation(svgContent, operation) {
851
+ const result = { valid: true, warnings: [], errors: [] };
852
+
853
+ try {
854
+ // Dynamic import JSDOM
855
+ const { JSDOM } = await import("jsdom");
856
+ const dom = new JSDOM(svgContent, { contentType: "image/svg+xml" });
857
+ const doc = dom.window.document;
858
+
859
+ // Check for parser errors
860
+ const parseError = doc.querySelector("parsererror");
861
+ if (parseError) {
862
+ result.valid = false;
863
+ result.errors.push(`Invalid SVG: ${parseError.textContent}`);
864
+ return result;
865
+ }
866
+
867
+ // Verify SVG root element exists
868
+ const svg = doc.querySelector("svg");
869
+ if (!svg) {
870
+ result.valid = false;
871
+ result.errors.push("Missing <svg> root element");
872
+ return result;
873
+ }
874
+
875
+ // Operation-specific validation
876
+ if (operation === "embed") {
877
+ // Check for remaining external font URLs
878
+ const styleElements = doc.querySelectorAll("style");
879
+ for (const style of styleElements) {
880
+ const css = style.textContent || "";
881
+ const urlMatches = css.match(/url\((['"]?)(?!data:)([^)'"]+)\1\)/gi);
882
+ if (urlMatches) {
883
+ for (const match of urlMatches) {
884
+ if (
885
+ match.includes("fonts.googleapis.com") ||
886
+ match.includes("fonts.gstatic.com")
887
+ ) {
888
+ result.warnings.push(`External font URL still present: ${match}`);
889
+ }
890
+ }
891
+ }
892
+ }
893
+ }
894
+
895
+ if (operation === "replace") {
896
+ // Verify font families are valid
897
+ const fonts = listFonts(doc);
898
+ for (const font of fonts) {
899
+ if (font.type === "unknown" && font.usedChars?.size > 0) {
900
+ result.warnings.push(
901
+ `Font "${font.family}" is used but not defined or recognized`
902
+ );
903
+ }
904
+ }
905
+ }
906
+
907
+ dom.window.close();
908
+ } catch (err) {
909
+ result.valid = false;
910
+ result.errors.push(`Validation failed: ${err.message}`);
911
+ }
912
+
913
+ return result;
914
+ }
915
+
916
+ // ============================================================================
917
+ // TEMPLATE GENERATION
918
+ // ============================================================================
919
+
920
+ /**
921
+ * Generate YAML replacement map template
922
+ * @returns {string} YAML template content
923
+ */
924
+ export function generateReplacementMapTemplate() {
925
+ return `# SVG Font Replacement Map
926
+ # ========================
927
+ # This file defines font replacements for SVG processing.
928
+ #
929
+ # Format:
930
+ # original_font: replacement_font
931
+ #
932
+ # Examples:
933
+ # "Arial": "Inter" # Replace Arial with Inter
934
+ # "Times New Roman": "Noto Serif"
935
+ #
936
+ # Font sources (in priority order):
937
+ # 1. Local system fonts
938
+ # 2. Google Fonts (default, free)
939
+ # 3. FontGet (npm: fontget)
940
+ # 4. fnt (brew: alexmyczko/fnt/fnt)
941
+ #
942
+ # Options per font:
943
+ # embed: true # Embed as base64 (default: true)
944
+ # subset: true # Only include used glyphs (default: true)
945
+ # source: "google" # Force specific source
946
+ # weight: "400,700" # Specific weights to include
947
+ # style: "normal,italic" # Specific styles
948
+ #
949
+ # Advanced format:
950
+ # "Arial":
951
+ # replacement: "Inter"
952
+ # embed: true
953
+ # subset: true
954
+ # source: "google"
955
+ # weights: ["400", "500", "700"]
956
+
957
+ replacements:
958
+ # Add your font mappings here
959
+ # "Original Font": "Replacement Font"
960
+
961
+ options:
962
+ default_embed: true
963
+ default_subset: true
964
+ fallback_source: "google"
965
+ auto_download: true
966
+ `;
967
+ }
968
+
969
+ // ============================================================================
970
+ // EXPORTS
971
+ // ============================================================================
972
+
973
+ export default {
974
+ // Character extraction
975
+ extractFontCharacterMap,
976
+ charsToTextParam,
977
+
978
+ // Google Fonts
979
+ isGoogleFontsUrl,
980
+ extractFontFamilyFromGoogleUrl,
981
+ addTextParamToGoogleFontsUrl,
982
+ buildGoogleFontsUrl,
983
+ POPULAR_GOOGLE_FONTS,
984
+
985
+ // Local fonts
986
+ getSystemFontDirs,
987
+ checkLocalFont,
988
+ listSystemFonts,
989
+
990
+ // External tools
991
+ commandExists,
992
+ downloadWithFontGet,
993
+ downloadWithFnt,
994
+
995
+ // Replacement map
996
+ loadReplacementMap,
997
+ applyFontReplacements,
998
+ generateReplacementMapTemplate,
999
+
1000
+ // Font listing
1001
+ listFonts,
1002
+
1003
+ // Backup and validation
1004
+ createBackup,
1005
+ validateSvgAfterFontOperation,
1006
+
1007
+ // Constants
1008
+ DEFAULT_REPLACEMENT_MAP,
1009
+ ENV_REPLACEMENT_MAP,
1010
+ WEB_SAFE_FONTS,
1011
+ FONT_FORMATS,
1012
+ SYSTEM_FONT_PATHS,
1013
+ };