@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,1906 @@
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, statSync, unlinkSync } from "fs";
18
+ import { join, dirname, basename, extname } from "path";
19
+ import { homedir, platform } from "os";
20
+ import { 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 cache directory path
104
+ * @constant {string}
105
+ */
106
+ export const FONT_CACHE_DIR = join(homedir(), ".cache", "svgm-fonts");
107
+
108
+ /**
109
+ * Font cache index filename
110
+ * @constant {string}
111
+ */
112
+ export const FONT_CACHE_INDEX = "cache-index.json";
113
+
114
+ /**
115
+ * Maximum cache age in milliseconds (30 days)
116
+ * @constant {number}
117
+ */
118
+ export const FONT_CACHE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
119
+
120
+ // ============================================================================
121
+ // FONT CACHING SYSTEM
122
+ // ============================================================================
123
+
124
+ /**
125
+ * Initialize font cache directory and index
126
+ * @returns {{cacheDir: string, indexPath: string}}
127
+ */
128
+ export function initFontCache() {
129
+ mkdirSync(FONT_CACHE_DIR, { recursive: true });
130
+ const indexPath = join(FONT_CACHE_DIR, FONT_CACHE_INDEX);
131
+ if (!existsSync(indexPath)) {
132
+ writeFileSync(indexPath, JSON.stringify({ fonts: {}, lastCleanup: Date.now() }, null, 2));
133
+ }
134
+ return { cacheDir: FONT_CACHE_DIR, indexPath };
135
+ }
136
+
137
+ /**
138
+ * Get cached font if available and not expired
139
+ * @param {string} fontKey - Unique key for the font (URL or family+style+weight hash)
140
+ * @param {Object} [options={}] - Options
141
+ * @param {boolean} [options.subsetted=false] - Whether to look for subsetted version
142
+ * @param {string} [options.chars] - Characters for subset matching
143
+ * @returns {{found: boolean, path?: string, format?: string, age?: number}}
144
+ */
145
+ export function getCachedFont(fontKey, options = {}) {
146
+ const { subsetted = false, chars = "" } = options;
147
+ const { indexPath } = initFontCache();
148
+
149
+ try {
150
+ const index = JSON.parse(readFileSync(indexPath, "utf8"));
151
+ const cacheKey = subsetted ? `${fontKey}:subset:${hashString(chars)}` : fontKey;
152
+ const entry = index.fonts[cacheKey];
153
+
154
+ if (!entry) return { found: false };
155
+
156
+ // Check if expired
157
+ const age = Date.now() - entry.timestamp;
158
+ if (age > FONT_CACHE_MAX_AGE) {
159
+ return { found: false, expired: true };
160
+ }
161
+
162
+ // Check if file still exists
163
+ if (!existsSync(entry.path)) {
164
+ return { found: false, missing: true };
165
+ }
166
+
167
+ return {
168
+ found: true,
169
+ path: entry.path,
170
+ format: entry.format,
171
+ age,
172
+ size: entry.size,
173
+ };
174
+ } catch {
175
+ return { found: false };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Store font in cache
181
+ * @param {string} fontKey - Unique key for the font
182
+ * @param {Buffer|string} content - Font content
183
+ * @param {Object} options - Options
184
+ * @param {string} options.format - Font format (woff2, woff, ttf, otf)
185
+ * @param {string} [options.family] - Font family name
186
+ * @param {boolean} [options.subsetted=false] - Whether this is a subsetted version
187
+ * @param {string} [options.chars] - Characters included in subset
188
+ * @returns {{success: boolean, path?: string, error?: string}}
189
+ */
190
+ export function cacheFontData(fontKey, content, options) {
191
+ const { format, family = "unknown", subsetted = false, chars = "" } = options;
192
+ const { cacheDir, indexPath } = initFontCache();
193
+
194
+ try {
195
+ // Generate unique filename
196
+ const hash = hashString(fontKey + (subsetted ? chars : ""));
197
+ const ext = format.startsWith(".") ? format : `.${format}`;
198
+ const filename = `${sanitizeFilename(family)}_${hash.slice(0, 8)}${subsetted ? "_subset" : ""}${ext}`;
199
+ const fontPath = join(cacheDir, filename);
200
+
201
+ // Write font file
202
+ const buffer = typeof content === "string" ? Buffer.from(content, "base64") : content;
203
+ writeFileSync(fontPath, buffer);
204
+
205
+ // Update index
206
+ const index = JSON.parse(readFileSync(indexPath, "utf8"));
207
+ const cacheKey = subsetted ? `${fontKey}:subset:${hashString(chars)}` : fontKey;
208
+ index.fonts[cacheKey] = {
209
+ path: fontPath,
210
+ format: ext.slice(1),
211
+ family,
212
+ timestamp: Date.now(),
213
+ size: buffer.length,
214
+ subsetted,
215
+ charsHash: subsetted ? hashString(chars) : null,
216
+ };
217
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
218
+
219
+ return { success: true, path: fontPath };
220
+ } catch (err) {
221
+ return { success: false, error: err.message };
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Clean up expired cache entries
227
+ * @returns {{removed: number, freedBytes: number}}
228
+ */
229
+ export function cleanupFontCache() {
230
+ const { indexPath } = initFontCache();
231
+ let removed = 0;
232
+ let freedBytes = 0;
233
+
234
+ try {
235
+ const index = JSON.parse(readFileSync(indexPath, "utf8"));
236
+ const now = Date.now();
237
+ const keysToRemove = [];
238
+
239
+ for (const [key, entry] of Object.entries(index.fonts)) {
240
+ const age = now - entry.timestamp;
241
+ if (age > FONT_CACHE_MAX_AGE) {
242
+ keysToRemove.push(key);
243
+ if (existsSync(entry.path)) {
244
+ freedBytes += entry.size || 0;
245
+ try {
246
+ unlinkSync(entry.path);
247
+ } catch {
248
+ // File may already be deleted
249
+ }
250
+ }
251
+ removed++;
252
+ }
253
+ }
254
+
255
+ // Remove from index
256
+ for (const key of keysToRemove) {
257
+ delete index.fonts[key];
258
+ }
259
+
260
+ index.lastCleanup = now;
261
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
262
+
263
+ return { removed, freedBytes };
264
+ } catch {
265
+ return { removed: 0, freedBytes: 0 };
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Get cache statistics
271
+ * @returns {{totalFonts: number, totalSize: number, oldestAge: number, newestAge: number}}
272
+ */
273
+ export function getFontCacheStats() {
274
+ const { indexPath } = initFontCache();
275
+
276
+ try {
277
+ const index = JSON.parse(readFileSync(indexPath, "utf8"));
278
+ const fonts = Object.values(index.fonts);
279
+ const now = Date.now();
280
+
281
+ if (fonts.length === 0) {
282
+ return { totalFonts: 0, totalSize: 0, oldestAge: 0, newestAge: 0 };
283
+ }
284
+
285
+ const ages = fonts.map((f) => now - f.timestamp);
286
+ return {
287
+ totalFonts: fonts.length,
288
+ totalSize: fonts.reduce((sum, f) => sum + (f.size || 0), 0),
289
+ oldestAge: Math.max(...ages),
290
+ newestAge: Math.min(...ages),
291
+ };
292
+ } catch {
293
+ return { totalFonts: 0, totalSize: 0, oldestAge: 0, newestAge: 0 };
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Simple string hash for cache keys
299
+ * @param {string} str - String to hash
300
+ * @returns {string} Hex hash
301
+ */
302
+ function hashString(str) {
303
+ let hash = 0;
304
+ for (let i = 0; i < str.length; i++) {
305
+ const char = str.charCodeAt(i);
306
+ hash = ((hash << 5) - hash) + char;
307
+ hash = hash & hash; // Convert to 32bit integer
308
+ }
309
+ return Math.abs(hash).toString(16).padStart(8, "0");
310
+ }
311
+
312
+ /**
313
+ * Sanitize filename for cache storage
314
+ * @param {string} name - Original name
315
+ * @returns {string} Safe filename
316
+ */
317
+ function sanitizeFilename(name) {
318
+ return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32);
319
+ }
320
+
321
+ // ============================================================================
322
+ // FONT SUBSETTING (using fonttools/pyftsubset)
323
+ // ============================================================================
324
+
325
+ /**
326
+ * Check if fonttools (pyftsubset) is available
327
+ * @returns {boolean}
328
+ */
329
+ export function isFonttoolsAvailable() {
330
+ return commandExists("pyftsubset");
331
+ }
332
+
333
+ /**
334
+ * Subset a font file to include only specified characters
335
+ * Uses fonttools/pyftsubset when available
336
+ *
337
+ * @param {string} fontPath - Path to input font file (TTF/OTF/WOFF/WOFF2)
338
+ * @param {string} chars - Characters to include in subset
339
+ * @param {Object} [options={}] - Options
340
+ * @param {string} [options.outputPath] - Output path (default: auto-generated)
341
+ * @param {string} [options.outputFormat] - Output format (woff2, woff, ttf)
342
+ * @param {boolean} [options.layoutFeatures=true] - Include OpenType layout features
343
+ * @returns {Promise<{success: boolean, path?: string, size?: number, originalSize?: number, error?: string}>}
344
+ */
345
+ export async function subsetFont(fontPath, chars, options = {}) {
346
+ const { outputFormat = "woff2", layoutFeatures = true } = options;
347
+
348
+ if (!existsSync(fontPath)) {
349
+ return { success: false, error: `Font file not found: ${fontPath}` };
350
+ }
351
+
352
+ if (!isFonttoolsAvailable()) {
353
+ return { success: false, error: "fonttools not installed. Install with: pip install fonttools brotli" };
354
+ }
355
+
356
+ if (!chars || chars.length === 0) {
357
+ return { success: false, error: "No characters specified for subsetting" };
358
+ }
359
+
360
+ // Get original size
361
+ const originalSize = statSync(fontPath).size;
362
+
363
+ // Generate output path
364
+ const inputExt = extname(fontPath);
365
+ const inputBase = basename(fontPath, inputExt);
366
+ const outputExt = outputFormat.startsWith(".") ? outputFormat : `.${outputFormat}`;
367
+ const outputPath = options.outputPath || join(dirname(fontPath), `${inputBase}_subset${outputExt}`);
368
+
369
+ try {
370
+ // Build pyftsubset command
371
+ const args = [
372
+ fontPath,
373
+ `--text=${chars}`,
374
+ `--output-file=${outputPath}`,
375
+ `--flavor=${outputFormat.replace(".", "")}`,
376
+ ];
377
+
378
+ // Add layout features if requested
379
+ if (layoutFeatures) {
380
+ args.push("--layout-features=*");
381
+ }
382
+
383
+ // Add options for better compression
384
+ if (outputFormat === "woff2" || outputFormat === ".woff2") {
385
+ args.push("--with-zopfli");
386
+ }
387
+
388
+ // Execute pyftsubset
389
+ execFileSync("pyftsubset", args, {
390
+ stdio: "pipe",
391
+ timeout: 60000,
392
+ });
393
+
394
+ if (!existsSync(outputPath)) {
395
+ return { success: false, error: "Subsetting completed but output file not created" };
396
+ }
397
+
398
+ const newSize = statSync(outputPath).size;
399
+ const savings = ((originalSize - newSize) / originalSize * 100).toFixed(1);
400
+
401
+ return {
402
+ success: true,
403
+ path: outputPath,
404
+ size: newSize,
405
+ originalSize,
406
+ savings: `${savings}%`,
407
+ chars: chars.length,
408
+ };
409
+ } catch (err) {
410
+ return { success: false, error: `Subsetting failed: ${err.message}` };
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Subset font from buffer/base64 data
416
+ * Writes to temp file, subsets, and returns result
417
+ *
418
+ * @param {Buffer|string} fontData - Font data (Buffer or base64 string)
419
+ * @param {string} chars - Characters to include
420
+ * @param {Object} [options={}] - Options
421
+ * @param {string} [options.inputFormat] - Input format hint (ttf, otf, woff, woff2)
422
+ * @param {string} [options.outputFormat='woff2'] - Output format
423
+ * @returns {Promise<{success: boolean, data?: Buffer, size?: number, originalSize?: number, error?: string}>}
424
+ */
425
+ export async function subsetFontData(fontData, chars, options = {}) {
426
+ const { inputFormat = "ttf", outputFormat = "woff2" } = options;
427
+ const tmpDir = join(FONT_CACHE_DIR, "tmp");
428
+ mkdirSync(tmpDir, { recursive: true });
429
+
430
+ const tmpId = `subset_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
431
+ const inputExt = inputFormat.startsWith(".") ? inputFormat : `.${inputFormat}`;
432
+ const tmpInput = join(tmpDir, `${tmpId}_input${inputExt}`);
433
+ const outputExt = outputFormat.startsWith(".") ? outputFormat : `.${outputFormat}`;
434
+ const tmpOutput = join(tmpDir, `${tmpId}_output${outputExt}`);
435
+
436
+ try {
437
+ // Write input file
438
+ const buffer = typeof fontData === "string" ? Buffer.from(fontData, "base64") : fontData;
439
+ writeFileSync(tmpInput, buffer);
440
+
441
+ // Subset
442
+ const result = await subsetFont(tmpInput, chars, {
443
+ outputPath: tmpOutput,
444
+ outputFormat,
445
+ });
446
+
447
+ if (!result.success) {
448
+ return result;
449
+ }
450
+
451
+ // Read output
452
+ const outputData = readFileSync(tmpOutput);
453
+
454
+ // Cleanup temp files
455
+ try {
456
+ unlinkSync(tmpInput);
457
+ unlinkSync(tmpOutput);
458
+ } catch {
459
+ // Ignore cleanup errors
460
+ }
461
+
462
+ return {
463
+ success: true,
464
+ data: outputData,
465
+ size: outputData.length,
466
+ originalSize: buffer.length,
467
+ savings: result.savings,
468
+ };
469
+ } catch (err) {
470
+ // Cleanup on error
471
+ try {
472
+ if (existsSync(tmpInput)) unlinkSync(tmpInput);
473
+ if (existsSync(tmpOutput)) unlinkSync(tmpOutput);
474
+ } catch {
475
+ // Ignore cleanup errors
476
+ }
477
+ return { success: false, error: err.message };
478
+ }
479
+ }
480
+
481
+ // ============================================================================
482
+ // WOFF2 COMPRESSION
483
+ // ============================================================================
484
+
485
+ /**
486
+ * Convert font to WOFF2 format using fonttools
487
+ *
488
+ * @param {string} fontPath - Path to input font (TTF/OTF/WOFF)
489
+ * @param {Object} [options={}] - Options
490
+ * @param {string} [options.outputPath] - Output path (default: same dir with .woff2 ext)
491
+ * @returns {Promise<{success: boolean, path?: string, size?: number, originalSize?: number, error?: string}>}
492
+ */
493
+ export async function convertToWoff2(fontPath, options = {}) {
494
+ if (!existsSync(fontPath)) {
495
+ return { success: false, error: `Font file not found: ${fontPath}` };
496
+ }
497
+
498
+ if (!isFonttoolsAvailable()) {
499
+ return { success: false, error: "fonttools not installed. Install with: pip install fonttools brotli" };
500
+ }
501
+
502
+ const originalSize = statSync(fontPath).size;
503
+ const inputExt = extname(fontPath);
504
+ const inputBase = basename(fontPath, inputExt);
505
+ const outputPath = options.outputPath || join(dirname(fontPath), `${inputBase}.woff2`);
506
+
507
+ // Skip if already WOFF2
508
+ if (inputExt.toLowerCase() === ".woff2") {
509
+ copyFileSync(fontPath, outputPath);
510
+ return { success: true, path: outputPath, size: originalSize, originalSize, alreadyWoff2: true };
511
+ }
512
+
513
+ try {
514
+ // Use fonttools ttx to convert
515
+ execFileSync("fonttools", ["ttLib.woff2", "compress", fontPath, "-o", outputPath], {
516
+ stdio: "pipe",
517
+ timeout: 60000,
518
+ });
519
+
520
+ if (!existsSync(outputPath)) {
521
+ return { success: false, error: "Conversion completed but output file not created" };
522
+ }
523
+
524
+ const newSize = statSync(outputPath).size;
525
+ const savings = ((originalSize - newSize) / originalSize * 100).toFixed(1);
526
+
527
+ return {
528
+ success: true,
529
+ path: outputPath,
530
+ size: newSize,
531
+ originalSize,
532
+ savings: `${savings}%`,
533
+ };
534
+ } catch (err) {
535
+ return { success: false, error: `WOFF2 conversion failed: ${err.message}` };
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Convert font buffer to WOFF2
541
+ *
542
+ * @param {Buffer|string} fontData - Font data (Buffer or base64)
543
+ * @param {Object} [options={}] - Options
544
+ * @param {string} [options.inputFormat='ttf'] - Input format hint
545
+ * @returns {Promise<{success: boolean, data?: Buffer, size?: number, originalSize?: number, error?: string}>}
546
+ */
547
+ export async function convertDataToWoff2(fontData, options = {}) {
548
+ const { inputFormat = "ttf" } = options;
549
+ const tmpDir = join(FONT_CACHE_DIR, "tmp");
550
+ mkdirSync(tmpDir, { recursive: true });
551
+
552
+ const tmpId = `woff2_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
553
+ const inputExt = inputFormat.startsWith(".") ? inputFormat : `.${inputFormat}`;
554
+ const tmpInput = join(tmpDir, `${tmpId}_input${inputExt}`);
555
+ const tmpOutput = join(tmpDir, `${tmpId}_output.woff2`);
556
+
557
+ try {
558
+ // Write input
559
+ const buffer = typeof fontData === "string" ? Buffer.from(fontData, "base64") : fontData;
560
+ writeFileSync(tmpInput, buffer);
561
+
562
+ // Convert
563
+ const result = await convertToWoff2(tmpInput, { outputPath: tmpOutput });
564
+
565
+ if (!result.success) {
566
+ return result;
567
+ }
568
+
569
+ // Read output
570
+ const outputData = readFileSync(tmpOutput);
571
+
572
+ // Cleanup
573
+ try {
574
+ unlinkSync(tmpInput);
575
+ unlinkSync(tmpOutput);
576
+ } catch {
577
+ // Ignore
578
+ }
579
+
580
+ return {
581
+ success: true,
582
+ data: outputData,
583
+ size: outputData.length,
584
+ originalSize: buffer.length,
585
+ savings: result.savings,
586
+ };
587
+ } catch (err) {
588
+ try {
589
+ if (existsSync(tmpInput)) unlinkSync(tmpInput);
590
+ if (existsSync(tmpOutput)) unlinkSync(tmpOutput);
591
+ } catch {
592
+ // Ignore
593
+ }
594
+ return { success: false, error: err.message };
595
+ }
596
+ }
597
+
598
+ // ============================================================================
599
+ // DUPLICATE @FONT-FACE DETECTION
600
+ // ============================================================================
601
+
602
+ /**
603
+ * Detect duplicate @font-face rules in SVG document
604
+ *
605
+ * @param {Object} doc - Parsed SVG document
606
+ * @returns {{duplicates: Array<{family: string, weight: string, style: string, count: number, indices: number[]}>, total: number}}
607
+ */
608
+ export function detectDuplicateFontFaces(doc) {
609
+ const styleElements = doc.querySelectorAll?.("style") || [];
610
+ const fontFaces = [];
611
+ let index = 0;
612
+
613
+ // Extract all @font-face rules
614
+ for (const styleEl of styleElements) {
615
+ const css = styleEl.textContent || "";
616
+ const fontFaceRegex = /@font-face\s*\{([^}]*)\}/gi;
617
+ let match;
618
+
619
+ while ((match = fontFaceRegex.exec(css)) !== null) {
620
+ const block = match[1];
621
+
622
+ // Extract properties
623
+ const familyMatch = block.match(/font-family:\s*(['"]?)([^;'"]+)\1/i);
624
+ const weightMatch = block.match(/font-weight:\s*([^;]+)/i);
625
+ const styleMatch = block.match(/font-style:\s*([^;]+)/i);
626
+ const srcMatch = block.match(/src:\s*([^;]+)/i);
627
+
628
+ const family = familyMatch ? familyMatch[2].trim() : "";
629
+ const weight = weightMatch ? weightMatch[1].trim() : "400";
630
+ const style = styleMatch ? styleMatch[1].trim() : "normal";
631
+ const src = srcMatch ? srcMatch[1].trim() : "";
632
+
633
+ fontFaces.push({
634
+ family,
635
+ weight,
636
+ style,
637
+ src,
638
+ index: index++,
639
+ fullMatch: match[0],
640
+ styleElement: styleEl,
641
+ });
642
+ }
643
+ }
644
+
645
+ // Group by family+weight+style
646
+ const groups = new Map();
647
+ for (const ff of fontFaces) {
648
+ const key = `${ff.family}|${ff.weight}|${ff.style}`;
649
+ if (!groups.has(key)) {
650
+ groups.set(key, []);
651
+ }
652
+ groups.get(key).push(ff);
653
+ }
654
+
655
+ // Find duplicates
656
+ const duplicates = [];
657
+ for (const [key, entries] of groups) {
658
+ if (entries.length > 1) {
659
+ const [family, weight, style] = key.split("|");
660
+ duplicates.push({
661
+ family,
662
+ weight,
663
+ style,
664
+ count: entries.length,
665
+ indices: entries.map((e) => e.index),
666
+ entries,
667
+ });
668
+ }
669
+ }
670
+
671
+ return { duplicates, total: fontFaces.length };
672
+ }
673
+
674
+ /**
675
+ * Merge duplicate @font-face rules, keeping the first occurrence
676
+ *
677
+ * @param {Object} doc - Parsed SVG document
678
+ * @returns {{modified: boolean, removed: number, keptIndices: number[]}}
679
+ */
680
+ export function mergeDuplicateFontFaces(doc) {
681
+ const { duplicates } = detectDuplicateFontFaces(doc);
682
+
683
+ if (duplicates.length === 0) {
684
+ return { modified: false, removed: 0, keptIndices: [] };
685
+ }
686
+
687
+ let removed = 0;
688
+ const keptIndices = [];
689
+
690
+ for (const dup of duplicates) {
691
+ // Keep the first entry, remove the rest
692
+ const [keep, ...toRemove] = dup.entries;
693
+ keptIndices.push(keep.index);
694
+
695
+ for (const entry of toRemove) {
696
+ // Remove from style element
697
+ const styleEl = entry.styleElement;
698
+ if (styleEl && styleEl.textContent) {
699
+ styleEl.textContent = styleEl.textContent.replace(entry.fullMatch, "");
700
+ removed++;
701
+ }
702
+ }
703
+ }
704
+
705
+ return { modified: removed > 0, removed, keptIndices };
706
+ }
707
+
708
+ // ============================================================================
709
+ // INTELLIGENT FONT SEARCH
710
+ // ============================================================================
711
+
712
+ /**
713
+ * Calculate string similarity using Levenshtein distance
714
+ * @param {string} a - First string
715
+ * @param {string} b - Second string
716
+ * @returns {number} Similarity score 0-1 (1 = identical)
717
+ */
718
+ export function stringSimilarity(a, b) {
719
+ const aLower = a.toLowerCase().trim();
720
+ const bLower = b.toLowerCase().trim();
721
+
722
+ if (aLower === bLower) return 1;
723
+ if (aLower.length === 0 || bLower.length === 0) return 0;
724
+
725
+ // Check if one contains the other
726
+ if (aLower.includes(bLower) || bLower.includes(aLower)) {
727
+ return 0.8;
728
+ }
729
+
730
+ // Levenshtein distance
731
+ const matrix = [];
732
+ for (let i = 0; i <= bLower.length; i++) {
733
+ matrix[i] = [i];
734
+ }
735
+ for (let j = 0; j <= aLower.length; j++) {
736
+ matrix[0][j] = j;
737
+ }
738
+
739
+ for (let i = 1; i <= bLower.length; i++) {
740
+ for (let j = 1; j <= aLower.length; j++) {
741
+ if (bLower[i - 1] === aLower[j - 1]) {
742
+ matrix[i][j] = matrix[i - 1][j - 1];
743
+ } else {
744
+ matrix[i][j] = Math.min(
745
+ matrix[i - 1][j - 1] + 1,
746
+ matrix[i][j - 1] + 1,
747
+ matrix[i - 1][j] + 1
748
+ );
749
+ }
750
+ }
751
+ }
752
+
753
+ const distance = matrix[bLower.length][aLower.length];
754
+ const maxLen = Math.max(aLower.length, bLower.length);
755
+ return 1 - distance / maxLen;
756
+ }
757
+
758
+ /**
759
+ * Normalize font family name for comparison
760
+ * @param {string} name - Font family name
761
+ * @returns {string} Normalized name
762
+ */
763
+ export function normalizeFontName(name) {
764
+ return name
765
+ .toLowerCase()
766
+ .replace(/['"]/g, "")
767
+ .replace(/\s+/g, " ")
768
+ .trim()
769
+ .replace(/\s*(regular|normal|book|roman|medium|text)\s*$/i, "")
770
+ .trim();
771
+ }
772
+
773
+ /**
774
+ * List of common Google Fonts (for heuristic matching)
775
+ * @constant {string[]}
776
+ */
777
+ export const POPULAR_GOOGLE_FONTS = [
778
+ "Roboto",
779
+ "Open Sans",
780
+ "Lato",
781
+ "Montserrat",
782
+ "Oswald",
783
+ "Source Sans Pro",
784
+ "Raleway",
785
+ "PT Sans",
786
+ "Merriweather",
787
+ "Noto Sans",
788
+ "Ubuntu",
789
+ "Playfair Display",
790
+ "Nunito",
791
+ "Poppins",
792
+ "Inter",
793
+ "Fira Code",
794
+ "Fira Sans",
795
+ "Work Sans",
796
+ "Quicksand",
797
+ "Inconsolata",
798
+ "Source Code Pro",
799
+ "JetBrains Mono",
800
+ "IBM Plex Sans",
801
+ "IBM Plex Mono",
802
+ "Libre Baskerville",
803
+ "Crimson Text",
804
+ "EB Garamond",
805
+ "Spectral",
806
+ "Bitter",
807
+ "Zilla Slab",
808
+ ];
809
+
810
+ /**
811
+ * Search for similar fonts across available sources
812
+ *
813
+ * @param {string} fontFamily - Font family name to search for
814
+ * @param {Object} [options={}] - Options
815
+ * @param {boolean} [options.includeGoogle=true] - Search Google Fonts
816
+ * @param {boolean} [options.includeLocal=true] - Search local system fonts
817
+ * @param {number} [options.maxResults=10] - Maximum results to return
818
+ * @param {number} [options.minSimilarity=0.3] - Minimum similarity threshold
819
+ * @returns {Promise<Array<{name: string, source: string, similarity: number, weight?: string, path?: string}>>}
820
+ */
821
+ export async function searchSimilarFonts(fontFamily, options = {}) {
822
+ const {
823
+ includeGoogle = true,
824
+ includeLocal = true,
825
+ maxResults = 10,
826
+ minSimilarity = 0.3,
827
+ } = options;
828
+
829
+ const normalizedQuery = normalizeFontName(fontFamily);
830
+ const results = [];
831
+
832
+ // Search Google Fonts
833
+ if (includeGoogle) {
834
+ for (const googleFont of POPULAR_GOOGLE_FONTS) {
835
+ const similarity = stringSimilarity(normalizedQuery, normalizeFontName(googleFont));
836
+ if (similarity >= minSimilarity) {
837
+ results.push({
838
+ name: googleFont,
839
+ source: "google",
840
+ similarity,
841
+ url: buildGoogleFontsUrl(googleFont),
842
+ });
843
+ }
844
+ }
845
+ }
846
+
847
+ // Search local system fonts
848
+ if (includeLocal) {
849
+ const systemFonts = await listSystemFonts();
850
+ for (const font of systemFonts) {
851
+ const similarity = stringSimilarity(normalizedQuery, normalizeFontName(font.name));
852
+ if (similarity >= minSimilarity) {
853
+ results.push({
854
+ name: font.name,
855
+ source: "local",
856
+ similarity,
857
+ path: font.path,
858
+ format: font.format,
859
+ });
860
+ }
861
+ }
862
+ }
863
+
864
+ // Sort by similarity descending
865
+ results.sort((a, b) => b.similarity - a.similarity);
866
+
867
+ // Remove duplicates (same name from different sources)
868
+ const seen = new Set();
869
+ const uniqueResults = results.filter((r) => {
870
+ const key = normalizeFontName(r.name);
871
+ if (seen.has(key)) return false;
872
+ seen.add(key);
873
+ return true;
874
+ });
875
+
876
+ return uniqueResults.slice(0, maxResults);
877
+ }
878
+
879
+ /**
880
+ * Search and suggest font alternatives when original is unavailable
881
+ *
882
+ * @param {string} fontFamily - Original font family
883
+ * @param {string} [originalUrl] - Original font URL that failed
884
+ * @param {Object} [options={}] - Options
885
+ * @returns {Promise<{found: boolean, alternatives: Array, recommendation?: Object}>}
886
+ */
887
+ export async function findFontAlternatives(fontFamily, originalUrl, options = {}) {
888
+ const alternatives = await searchSimilarFonts(fontFamily, {
889
+ maxResults: 10,
890
+ minSimilarity: 0.4,
891
+ ...options,
892
+ });
893
+
894
+ if (alternatives.length === 0) {
895
+ return { found: false, alternatives: [] };
896
+ }
897
+
898
+ // Recommend the best match
899
+ const recommendation = alternatives[0];
900
+
901
+ return {
902
+ found: true,
903
+ alternatives,
904
+ recommendation,
905
+ originalUrl,
906
+ message: `Font "${fontFamily}" not found. Best match: "${recommendation.name}" (${Math.round(recommendation.similarity * 100)}% match) from ${recommendation.source}`,
907
+ };
908
+ }
909
+
910
+ /**
911
+ * Try to download a font from available sources
912
+ *
913
+ * @param {string} fontFamily - Font family name
914
+ * @param {Object} [options={}] - Options
915
+ * @param {string} [options.preferredSource] - Preferred source (google, local, fontget, fnt)
916
+ * @param {string} [options.outputDir] - Output directory for downloaded font
917
+ * @param {string[]} [options.weights=['400']] - Font weights to download
918
+ * @returns {Promise<{success: boolean, path?: string, source?: string, error?: string}>}
919
+ */
920
+ export async function downloadFont(fontFamily, options = {}) {
921
+ const {
922
+ preferredSource,
923
+ outputDir = join(FONT_CACHE_DIR, "downloads"),
924
+ weights = ["400"],
925
+ } = options;
926
+
927
+ mkdirSync(outputDir, { recursive: true });
928
+
929
+ // Try preferred source first
930
+ const sources = preferredSource
931
+ ? [preferredSource, "google", "local", "fontget", "fnt"].filter((s, i, a) => a.indexOf(s) === i)
932
+ : ["google", "local", "fontget", "fnt"];
933
+
934
+ for (const source of sources) {
935
+ try {
936
+ switch (source) {
937
+ case "local": {
938
+ const localResult = await checkLocalFont(fontFamily);
939
+ if (localResult.found) {
940
+ return { success: true, path: localResult.path, source: "local" };
941
+ }
942
+ break;
943
+ }
944
+
945
+ case "google": {
946
+ // Try Google Fonts CSS API
947
+ const url = buildGoogleFontsUrl(fontFamily, { weights });
948
+ try {
949
+ const response = await fetch(url, {
950
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; svgm/1.0)" },
951
+ });
952
+ if (response.ok) {
953
+ const css = await response.text();
954
+ // Extract WOFF2 URL from CSS
955
+ const woff2Match = css.match(/url\(([^)]+\.woff2[^)]*)\)/i);
956
+ if (woff2Match) {
957
+ const fontUrl = woff2Match[1].replace(/['"]/g, "");
958
+ const fontResponse = await fetch(fontUrl);
959
+ if (fontResponse.ok) {
960
+ const buffer = Buffer.from(await fontResponse.arrayBuffer());
961
+ const filename = `${sanitizeFilename(fontFamily)}.woff2`;
962
+ const fontPath = join(outputDir, filename);
963
+ writeFileSync(fontPath, buffer);
964
+ return { success: true, path: fontPath, source: "google" };
965
+ }
966
+ }
967
+ }
968
+ } catch {
969
+ // Try next source
970
+ }
971
+ break;
972
+ }
973
+
974
+ case "fontget": {
975
+ const result = await downloadWithFontGet(fontFamily, outputDir);
976
+ if (result.success) {
977
+ return { success: true, path: result.path, source: "fontget" };
978
+ }
979
+ break;
980
+ }
981
+
982
+ case "fnt": {
983
+ const result = await downloadWithFnt(fontFamily, outputDir);
984
+ if (result.success) {
985
+ return { success: true, path: result.path, source: "fnt" };
986
+ }
987
+ break;
988
+ }
989
+ default:
990
+ // Unknown source, skip
991
+ break;
992
+ }
993
+ } catch {
994
+ // Continue to next source
995
+ }
996
+ }
997
+
998
+ return { success: false, error: `Could not download font "${fontFamily}" from any source` };
999
+ }
1000
+
1001
+ // ============================================================================
1002
+ // FONT CHARACTER EXTRACTION
1003
+ // ============================================================================
1004
+
1005
+ /**
1006
+ * Extract all text content from SVG and map to fonts.
1007
+ * Returns a Map where keys are font family names and values are Sets of characters used.
1008
+ *
1009
+ * @param {Object} element - SVG element to scan (DOM element or JSDOM document)
1010
+ * @returns {Map<string, Set<string>>} Font to characters map
1011
+ */
1012
+ export function extractFontCharacterMap(element) {
1013
+ const fontMap = new Map();
1014
+
1015
+ const addCharsToFont = (fontFamily, text) => {
1016
+ if (!fontFamily || !text) return;
1017
+ // Normalize font family name (remove quotes, trim, take first in stack)
1018
+ const normalizedFont = fontFamily
1019
+ .replace(/['"]/g, "")
1020
+ .trim()
1021
+ .split(",")[0]
1022
+ .trim();
1023
+ if (!fontMap.has(normalizedFont)) {
1024
+ fontMap.set(normalizedFont, new Set());
1025
+ }
1026
+ const charSet = fontMap.get(normalizedFont);
1027
+ for (const char of text) {
1028
+ charSet.add(char);
1029
+ }
1030
+ };
1031
+
1032
+ const walk = (el) => {
1033
+ if (!el) return;
1034
+
1035
+ // Get font-family from style attribute
1036
+ const style = el.getAttribute?.("style") || "";
1037
+ const fontMatch = style.match(/font-family:\s*([^;]+)/i);
1038
+ const fontFromStyle = fontMatch ? fontMatch[1] : null;
1039
+
1040
+ // Get font-family from font-family attribute
1041
+ const fontFromAttr = el.getAttribute?.("font-family");
1042
+
1043
+ // Get font-family from CSS face attribute (for foreignObject content)
1044
+ const faceAttr = el.getAttribute?.("face");
1045
+
1046
+ const fontFamily = fontFromStyle || fontFromAttr || faceAttr;
1047
+
1048
+ // Get text content from this element
1049
+ // Some DOM implementations (like our svg-parser) store text directly in textContent
1050
+ // without creating actual childNode text nodes. Handle both cases.
1051
+ let directTextContent = "";
1052
+
1053
+ // First try childNodes for standard DOM implementations
1054
+ if (el.childNodes && el.childNodes.length > 0) {
1055
+ for (const node of el.childNodes) {
1056
+ if (node.nodeType === 3) {
1057
+ // TEXT_NODE
1058
+ directTextContent += node.nodeValue || "";
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ // If no text found via childNodes, but this is a text/tspan element,
1064
+ // use textContent directly (but only if it has no children to avoid double-counting)
1065
+ const isTextElement = el.tagName === "text" || el.tagName === "tspan";
1066
+ if (!directTextContent && isTextElement && el.textContent) {
1067
+ // Only use textContent if there are no child elements (which would have their own fonts)
1068
+ const hasChildElements = el.children && el.children.length > 0;
1069
+ if (!hasChildElements) {
1070
+ directTextContent = el.textContent;
1071
+ }
1072
+ }
1073
+
1074
+ if (fontFamily && directTextContent.trim()) {
1075
+ addCharsToFont(fontFamily, directTextContent);
1076
+ }
1077
+
1078
+ // Also check for text in <text> and <tspan> elements with inherited font
1079
+ if (isTextElement) {
1080
+ // Try to get inherited font from ancestors if no font on this element
1081
+ let inheritedFont = fontFamily;
1082
+ if (!inheritedFont && el.parentNode) {
1083
+ const parentStyle = el.parentNode.getAttribute?.("style") || "";
1084
+ const parentFontMatch = parentStyle.match(/font-family:\s*([^;]+)/i);
1085
+ inheritedFont = parentFontMatch
1086
+ ? parentFontMatch[1]
1087
+ : el.parentNode.getAttribute?.("font-family");
1088
+ }
1089
+ if (inheritedFont && !fontFamily && directTextContent.trim()) {
1090
+ addCharsToFont(inheritedFont, directTextContent);
1091
+ }
1092
+ }
1093
+
1094
+ // Recurse into children
1095
+ if (el.children) {
1096
+ for (const child of el.children) {
1097
+ walk(child);
1098
+ }
1099
+ }
1100
+ };
1101
+
1102
+ walk(element);
1103
+ return fontMap;
1104
+ }
1105
+
1106
+ /**
1107
+ * Convert font character map to URL-safe text parameter.
1108
+ * @param {Set<string>} charSet - Set of characters
1109
+ * @returns {string} URL-encoded unique characters
1110
+ */
1111
+ export function charsToTextParam(charSet) {
1112
+ const uniqueChars = [...charSet].sort().join("");
1113
+ return encodeURIComponent(uniqueChars);
1114
+ }
1115
+
1116
+ // ============================================================================
1117
+ // GOOGLE FONTS UTILITIES
1118
+ // ============================================================================
1119
+
1120
+ /**
1121
+ * Check if URL is a Google Fonts URL.
1122
+ * @param {string} url - URL to check
1123
+ * @returns {boolean}
1124
+ */
1125
+ export function isGoogleFontsUrl(url) {
1126
+ return (
1127
+ url &&
1128
+ (url.includes("fonts.googleapis.com") || url.includes("fonts.gstatic.com"))
1129
+ );
1130
+ }
1131
+
1132
+ /**
1133
+ * Extract font family name from Google Fonts URL.
1134
+ * @param {string} url - Google Fonts URL
1135
+ * @returns {string|null} Font family name
1136
+ */
1137
+ export function extractFontFamilyFromGoogleUrl(url) {
1138
+ try {
1139
+ const urlObj = new URL(url);
1140
+ const family = urlObj.searchParams.get("family");
1141
+ if (family) {
1142
+ // Handle "Fira+Mono" or "Fira Mono:400,700"
1143
+ return family.split(":")[0].replace(/\+/g, " ");
1144
+ }
1145
+ } catch {
1146
+ // Try regex fallback
1147
+ const match = url.match(/family=([^&:]+)/);
1148
+ if (match) {
1149
+ return match[1].replace(/\+/g, " ");
1150
+ }
1151
+ }
1152
+ return null;
1153
+ }
1154
+
1155
+ /**
1156
+ * Add text parameter to Google Fonts URL for character subsetting.
1157
+ * This dramatically reduces font file size by only including needed glyphs.
1158
+ *
1159
+ * @param {string} url - Original Google Fonts URL
1160
+ * @param {string} textParam - URL-encoded characters to include
1161
+ * @returns {string} Modified URL with text parameter
1162
+ */
1163
+ export function addTextParamToGoogleFontsUrl(url, textParam) {
1164
+ if (!textParam) return url;
1165
+
1166
+ try {
1167
+ const urlObj = new URL(url);
1168
+ urlObj.searchParams.set("text", decodeURIComponent(textParam));
1169
+ return urlObj.toString();
1170
+ } catch {
1171
+ // Fallback: append to URL string
1172
+ const separator = url.includes("?") ? "&" : "?";
1173
+ return `${url}${separator}text=${textParam}`;
1174
+ }
1175
+ }
1176
+
1177
+ /**
1178
+ * Build Google Fonts URL for a font family.
1179
+ * @param {string} fontFamily - Font family name
1180
+ * @param {Object} [options={}] - Options
1181
+ * @param {string[]} [options.weights=['400']] - Font weights to include
1182
+ * @param {string[]} [options.styles=['normal']] - Font styles
1183
+ * @param {string} [options.text] - Characters to subset
1184
+ * @param {string} [options.display='swap'] - Font-display value
1185
+ * @returns {string} Google Fonts URL
1186
+ */
1187
+ export function buildGoogleFontsUrl(fontFamily, options = {}) {
1188
+ const {
1189
+ weights = ["400"],
1190
+ styles: _styles = ["normal"],
1191
+ text,
1192
+ display = "swap",
1193
+ } = options;
1194
+
1195
+ const encodedFamily = fontFamily.replace(/ /g, "+");
1196
+ const weightStr = weights.join(",");
1197
+
1198
+ let url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@${weightStr}&display=${display}`;
1199
+
1200
+ if (text) {
1201
+ url += `&text=${encodeURIComponent(text)}`;
1202
+ }
1203
+
1204
+ return url;
1205
+ }
1206
+
1207
+ // ============================================================================
1208
+ // LOCAL FONT DETECTION
1209
+ // ============================================================================
1210
+
1211
+ /**
1212
+ * Get system font directories for current platform
1213
+ * @returns {string[]} Array of font directory paths
1214
+ */
1215
+ export function getSystemFontDirs() {
1216
+ const os = platform();
1217
+ return SYSTEM_FONT_PATHS[os] || [];
1218
+ }
1219
+
1220
+ /**
1221
+ * Recursively read directory, compatible with Node 18.0+
1222
+ * Node 18.17+ has native recursive support, older versions need manual recursion
1223
+ * @private
1224
+ * @param {string} dir - Directory to read
1225
+ * @returns {Array<{name: string, path: string, isDirectory: () => boolean}>}
1226
+ */
1227
+ function readdirRecursive(dir) {
1228
+ const results = [];
1229
+
1230
+ try {
1231
+ const entries = readdirSync(dir, { withFileTypes: true });
1232
+
1233
+ for (const entry of entries) {
1234
+ const fullPath = join(dir, entry.name);
1235
+
1236
+ if (entry.isDirectory()) {
1237
+ // Recurse into subdirectory
1238
+ results.push(...readdirRecursive(fullPath));
1239
+ } else {
1240
+ results.push({
1241
+ name: entry.name,
1242
+ path: fullPath,
1243
+ isDirectory: () => false,
1244
+ });
1245
+ }
1246
+ }
1247
+ } catch {
1248
+ // Skip inaccessible directories
1249
+ }
1250
+
1251
+ return results;
1252
+ }
1253
+
1254
+ /**
1255
+ * Check if a font is installed locally on the system.
1256
+ * @param {string} fontFamily - Font family name to check
1257
+ * @returns {Promise<{found: boolean, path?: string}>}
1258
+ */
1259
+ export async function checkLocalFont(fontFamily) {
1260
+ const fontDirs = getSystemFontDirs();
1261
+ const normalizedName = fontFamily.toLowerCase().replace(/ /g, "");
1262
+
1263
+ // Common font file naming patterns
1264
+ const patterns = [
1265
+ fontFamily.replace(/ /g, ""),
1266
+ fontFamily.replace(/ /g, "-"),
1267
+ fontFamily.replace(/ /g, "_"),
1268
+ normalizedName,
1269
+ ];
1270
+
1271
+ for (const dir of fontDirs) {
1272
+ if (!existsSync(dir)) continue;
1273
+
1274
+ try {
1275
+ const files = readdirRecursive(dir);
1276
+
1277
+ for (const file of files) {
1278
+ if (file.isDirectory()) continue;
1279
+
1280
+ const ext = extname(file.name).toLowerCase();
1281
+ if (!FONT_FORMATS[ext]) continue;
1282
+
1283
+ const baseName = basename(file.name, ext).toLowerCase();
1284
+
1285
+ for (const pattern of patterns) {
1286
+ if (baseName.includes(pattern.toLowerCase())) {
1287
+ return {
1288
+ found: true,
1289
+ path: file.path,
1290
+ };
1291
+ }
1292
+ }
1293
+ }
1294
+ } catch {
1295
+ // Skip inaccessible directories
1296
+ }
1297
+ }
1298
+
1299
+ return { found: false };
1300
+ }
1301
+
1302
+ /**
1303
+ * List all installed system fonts.
1304
+ * @returns {Promise<Array<{name: string, path: string, format: string}>>}
1305
+ */
1306
+ export async function listSystemFonts() {
1307
+ const fonts = [];
1308
+ const fontDirs = getSystemFontDirs();
1309
+
1310
+ for (const dir of fontDirs) {
1311
+ if (!existsSync(dir)) continue;
1312
+
1313
+ try {
1314
+ const files = readdirRecursive(dir);
1315
+
1316
+ for (const file of files) {
1317
+ if (file.isDirectory()) continue;
1318
+
1319
+ const ext = extname(file.name).toLowerCase();
1320
+ const format = FONT_FORMATS[ext];
1321
+ if (!format) continue;
1322
+
1323
+ const name = basename(file.name, ext);
1324
+ fonts.push({
1325
+ name,
1326
+ path: file.path,
1327
+ format: ext.slice(1),
1328
+ });
1329
+ }
1330
+ } catch {
1331
+ // Skip inaccessible directories
1332
+ }
1333
+ }
1334
+
1335
+ return fonts;
1336
+ }
1337
+
1338
+ // ============================================================================
1339
+ // EXTERNAL TOOL INTEGRATION
1340
+ // ============================================================================
1341
+
1342
+ /**
1343
+ * Check if a command exists in PATH
1344
+ * Cross-platform: uses 'where' on Windows, 'which' on Unix
1345
+ * @param {string} cmd - Command name
1346
+ * @returns {boolean}
1347
+ */
1348
+ export function commandExists(cmd) {
1349
+ try {
1350
+ // Security: use execFileSync with array args to prevent command injection
1351
+ // Windows uses 'where', Unix uses 'which'
1352
+ const checkCmd = platform() === "win32" ? "where" : "which";
1353
+ execFileSync(checkCmd, [cmd], { stdio: "ignore" });
1354
+ return true;
1355
+ } catch {
1356
+ return false;
1357
+ }
1358
+ }
1359
+
1360
+ /**
1361
+ * Download font using FontGet (npm package)
1362
+ * @param {string} fontFamily - Font family name
1363
+ * @param {string} outputDir - Output directory
1364
+ * @returns {Promise<{success: boolean, path?: string, error?: string}>}
1365
+ */
1366
+ export async function downloadWithFontGet(fontFamily, outputDir) {
1367
+ if (!commandExists("fontget")) {
1368
+ return { success: false, error: "FontGet not installed. Run: npm install -g fontget" };
1369
+ }
1370
+
1371
+ try {
1372
+ mkdirSync(outputDir, { recursive: true });
1373
+ execFileSync("fontget", [fontFamily, "-o", outputDir], {
1374
+ stdio: "pipe",
1375
+ timeout: 60000,
1376
+ });
1377
+
1378
+ // Find downloaded file
1379
+ const files = readdirSync(outputDir);
1380
+ const fontFile = files.find((f) => Object.keys(FONT_FORMATS).some((ext) => f.endsWith(ext)));
1381
+
1382
+ if (fontFile) {
1383
+ return { success: true, path: join(outputDir, fontFile) };
1384
+ }
1385
+ return { success: false, error: "No font file found after download" };
1386
+ } catch (err) {
1387
+ return { success: false, error: err.message };
1388
+ }
1389
+ }
1390
+
1391
+ /**
1392
+ * Download font using fnt (Homebrew package)
1393
+ * @param {string} fontFamily - Font family name
1394
+ * @param {string} outputDir - Output directory
1395
+ * @returns {Promise<{success: boolean, path?: string, error?: string}>}
1396
+ */
1397
+ export async function downloadWithFnt(fontFamily, outputDir) {
1398
+ if (!commandExists("fnt")) {
1399
+ return { success: false, error: "fnt not installed. Run: brew install alexmyczko/fnt/fnt" };
1400
+ }
1401
+
1402
+ try {
1403
+ mkdirSync(outputDir, { recursive: true });
1404
+ execFileSync("fnt", ["install", fontFamily], {
1405
+ stdio: "pipe",
1406
+ timeout: 60000,
1407
+ });
1408
+
1409
+ // fnt installs to ~/.fonts by default
1410
+ const fntFontsDir = join(homedir(), ".fonts");
1411
+ if (existsSync(fntFontsDir)) {
1412
+ const files = readdirSync(fntFontsDir);
1413
+ const normalizedName = fontFamily.toLowerCase().replace(/ /g, "");
1414
+ const fontFile = files.find((f) => {
1415
+ const base = basename(f, extname(f)).toLowerCase();
1416
+ return (
1417
+ base.includes(normalizedName) &&
1418
+ Object.keys(FONT_FORMATS).some((ext) => f.endsWith(ext))
1419
+ );
1420
+ });
1421
+
1422
+ if (fontFile) {
1423
+ const srcPath = join(fntFontsDir, fontFile);
1424
+ const destPath = join(outputDir, fontFile);
1425
+ copyFileSync(srcPath, destPath);
1426
+ return { success: true, path: destPath };
1427
+ }
1428
+ }
1429
+ return { success: false, error: "Font installed but file not found" };
1430
+ } catch (err) {
1431
+ return { success: false, error: err.message };
1432
+ }
1433
+ }
1434
+
1435
+ // ============================================================================
1436
+ // REPLACEMENT MAP HANDLING
1437
+ // ============================================================================
1438
+
1439
+ /**
1440
+ * Load font replacement map from YAML file.
1441
+ *
1442
+ * @param {string} [mapPath] - Path to YAML file. If not provided, checks:
1443
+ * 1. SVGM_REPLACEMENT_MAP environment variable
1444
+ * 2. ./svgm_replacement_map.yml in current directory
1445
+ * @returns {{replacements: Object, options: Object} | null} Parsed map or null if not found
1446
+ */
1447
+ export function loadReplacementMap(mapPath) {
1448
+ // Priority: explicit path > env var > default file
1449
+ const pathsToTry = [
1450
+ mapPath,
1451
+ process.env[ENV_REPLACEMENT_MAP],
1452
+ join(process.cwd(), DEFAULT_REPLACEMENT_MAP),
1453
+ join(process.cwd(), "svgm_replacement_map_default.yml"),
1454
+ ].filter(Boolean);
1455
+
1456
+ for (const p of pathsToTry) {
1457
+ if (existsSync(p)) {
1458
+ try {
1459
+ const content = readFileSync(p, "utf8");
1460
+ // Use FAILSAFE_SCHEMA for security (no function execution)
1461
+ const parsed = yaml.load(content, { schema: yaml.FAILSAFE_SCHEMA });
1462
+
1463
+ return {
1464
+ replacements: parsed.replacements || {},
1465
+ options: parsed.options || {
1466
+ default_embed: true,
1467
+ default_subset: true,
1468
+ fallback_source: "google",
1469
+ auto_download: true,
1470
+ },
1471
+ path: p,
1472
+ };
1473
+ } catch (err) {
1474
+ throw new Error(`Failed to parse replacement map ${p}: ${err.message}`);
1475
+ }
1476
+ }
1477
+ }
1478
+
1479
+ return null;
1480
+ }
1481
+
1482
+ /**
1483
+ * Apply font replacements to an SVG document.
1484
+ *
1485
+ * @param {Object} doc - Parsed SVG document
1486
+ * @param {Object} replacements - Font replacement map {original: replacement}
1487
+ * @returns {{modified: boolean, replaced: Array<{from: string, to: string}>}}
1488
+ */
1489
+ export function applyFontReplacements(doc, replacements) {
1490
+ const result = { modified: false, replaced: [] };
1491
+
1492
+ const replaceInStyle = (styleStr) => {
1493
+ let modified = styleStr;
1494
+ for (const [original, replacement] of Object.entries(replacements)) {
1495
+ const pattern = new RegExp(
1496
+ `(font-family:\\s*)(['"]?)${escapeRegex(original)}\\2`,
1497
+ "gi"
1498
+ );
1499
+ if (pattern.test(modified)) {
1500
+ const newValue =
1501
+ typeof replacement === "string" ? replacement : replacement.replacement;
1502
+ modified = modified.replace(pattern, `$1$2${newValue}$2`);
1503
+ result.replaced.push({ from: original, to: newValue });
1504
+ result.modified = true;
1505
+ }
1506
+ }
1507
+ return modified;
1508
+ };
1509
+
1510
+ const replaceInAttribute = (el, attrName) => {
1511
+ const value = el.getAttribute(attrName);
1512
+ if (!value) return;
1513
+
1514
+ for (const [original, replacement] of Object.entries(replacements)) {
1515
+ const pattern = new RegExp(`^(['"]?)${escapeRegex(original)}\\1$`, "i");
1516
+ if (pattern.test(value.trim())) {
1517
+ const newValue =
1518
+ typeof replacement === "string" ? replacement : replacement.replacement;
1519
+ el.setAttribute(attrName, newValue);
1520
+ result.replaced.push({ from: original, to: newValue });
1521
+ result.modified = true;
1522
+ }
1523
+ }
1524
+ };
1525
+
1526
+ // Walk all elements
1527
+ const walk = (el) => {
1528
+ if (!el) return;
1529
+
1530
+ // Replace in style attribute
1531
+ const style = el.getAttribute?.("style");
1532
+ if (style) {
1533
+ const newStyle = replaceInStyle(style);
1534
+ if (newStyle !== style) {
1535
+ el.setAttribute("style", newStyle);
1536
+ }
1537
+ }
1538
+
1539
+ // Replace in font-family attribute
1540
+ replaceInAttribute(el, "font-family");
1541
+
1542
+ // Replace in face attribute (for foreignObject)
1543
+ replaceInAttribute(el, "face");
1544
+
1545
+ // Recurse
1546
+ if (el.children) {
1547
+ for (const child of el.children) {
1548
+ walk(child);
1549
+ }
1550
+ }
1551
+ };
1552
+
1553
+ // Also check <style> elements
1554
+ const styleElements = doc.querySelectorAll?.("style") || [];
1555
+ for (const styleEl of styleElements) {
1556
+ if (styleEl.textContent) {
1557
+ const newContent = replaceInStyle(styleEl.textContent);
1558
+ if (newContent !== styleEl.textContent) {
1559
+ styleEl.textContent = newContent;
1560
+ }
1561
+ }
1562
+ }
1563
+
1564
+ walk(doc.documentElement || doc);
1565
+
1566
+ return result;
1567
+ }
1568
+
1569
+ /**
1570
+ * Escape string for use in regex
1571
+ * @param {string} str - String to escape
1572
+ * @returns {string}
1573
+ */
1574
+ function escapeRegex(str) {
1575
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1576
+ }
1577
+
1578
+ // ============================================================================
1579
+ // FONT LISTING AND ANALYSIS
1580
+ // ============================================================================
1581
+
1582
+ /**
1583
+ * Font information structure
1584
+ * @typedef {Object} FontInfo
1585
+ * @property {string} family - Font family name
1586
+ * @property {string} type - 'embedded' | 'external' | 'system'
1587
+ * @property {string} [source] - URL or path for external fonts
1588
+ * @property {number} [size] - Size in bytes for embedded fonts
1589
+ * @property {Set<string>} [usedChars] - Characters used in SVG
1590
+ */
1591
+
1592
+ /**
1593
+ * List all fonts used in an SVG document.
1594
+ *
1595
+ * @param {Object} doc - Parsed SVG document
1596
+ * @returns {FontInfo[]} Array of font information
1597
+ */
1598
+ export function listFonts(doc) {
1599
+ const fonts = new Map();
1600
+
1601
+ // Get character usage map
1602
+ const charMap = extractFontCharacterMap(doc.documentElement || doc);
1603
+
1604
+ // Find @font-face rules in <style> elements
1605
+ const styleElements = doc.querySelectorAll?.("style") || [];
1606
+ for (const styleEl of styleElements) {
1607
+ const css = styleEl.textContent || "";
1608
+ const fontFaceRegex = /@font-face\s*\{([^}]*)\}/gi;
1609
+ let match;
1610
+
1611
+ while ((match = fontFaceRegex.exec(css)) !== null) {
1612
+ const block = match[1];
1613
+
1614
+ // Extract font-family
1615
+ const familyMatch = block.match(/font-family:\s*(['"]?)([^;'"]+)\1/i);
1616
+ const family = familyMatch ? familyMatch[2].trim() : null;
1617
+ if (!family) continue;
1618
+
1619
+ // Extract src url
1620
+ const srcMatch = block.match(/src:\s*url\((['"]?)([^)'"]+)\1\)/i);
1621
+ const src = srcMatch ? srcMatch[2] : null;
1622
+
1623
+ // Determine type
1624
+ let type = "embedded";
1625
+ let source = null;
1626
+
1627
+ if (src) {
1628
+ if (src.startsWith("data:")) {
1629
+ type = "embedded";
1630
+ // Calculate size from base64
1631
+ const base64Match = src.match(/base64,(.+)/);
1632
+ if (base64Match) {
1633
+ const size = Math.ceil((base64Match[1].length * 3) / 4);
1634
+ if (!fonts.has(family)) {
1635
+ fonts.set(family, { family, type, size, usedChars: charMap.get(family) });
1636
+ }
1637
+ continue;
1638
+ }
1639
+ } else {
1640
+ type = "external";
1641
+ source = src;
1642
+ }
1643
+ }
1644
+
1645
+ if (!fonts.has(family)) {
1646
+ fonts.set(family, { family, type, source, usedChars: charMap.get(family) });
1647
+ }
1648
+ }
1649
+ }
1650
+
1651
+ // Add fonts from character map that aren't in @font-face (system fonts)
1652
+ for (const [family, chars] of charMap) {
1653
+ if (!fonts.has(family)) {
1654
+ fonts.set(family, {
1655
+ family,
1656
+ type: WEB_SAFE_FONTS.includes(family) ? "system" : "unknown",
1657
+ usedChars: chars,
1658
+ });
1659
+ }
1660
+ }
1661
+
1662
+ return Array.from(fonts.values());
1663
+ }
1664
+
1665
+ // ============================================================================
1666
+ // BACKUP SYSTEM
1667
+ // ============================================================================
1668
+
1669
+ /**
1670
+ * Create a backup of a file before modifying it.
1671
+ *
1672
+ * @param {string} filePath - Path to file to backup
1673
+ * @param {Object} [options={}] - Options
1674
+ * @param {boolean} [options.noBackup=false] - Skip backup creation
1675
+ * @param {number} [options.maxBackups=100] - Maximum numbered backups
1676
+ * @returns {string|null} Path to backup file, or null if skipped
1677
+ */
1678
+ export function createBackup(filePath, options = {}) {
1679
+ const { noBackup = false, maxBackups = 100 } = options;
1680
+
1681
+ if (noBackup || !existsSync(filePath)) {
1682
+ return null;
1683
+ }
1684
+
1685
+ const backupPath = `${filePath}.bak`;
1686
+
1687
+ // If backup exists, create numbered backup
1688
+ let counter = 1;
1689
+ let finalBackupPath = backupPath;
1690
+
1691
+ while (existsSync(finalBackupPath)) {
1692
+ finalBackupPath = `${filePath}.bak.${counter++}`;
1693
+ if (counter > maxBackups) {
1694
+ throw new Error(`Too many backup files (>${maxBackups}). Clean up old backups.`);
1695
+ }
1696
+ }
1697
+
1698
+ copyFileSync(filePath, finalBackupPath);
1699
+ return finalBackupPath;
1700
+ }
1701
+
1702
+ // ============================================================================
1703
+ // VALIDATION
1704
+ // ============================================================================
1705
+
1706
+ /**
1707
+ * Validate SVG after font operations.
1708
+ *
1709
+ * @param {string} svgContent - SVG content to validate
1710
+ * @param {string} [operation] - Operation that was performed
1711
+ * @returns {{valid: boolean, warnings: string[], errors: string[]}}
1712
+ */
1713
+ export async function validateSvgAfterFontOperation(svgContent, operation) {
1714
+ const result = { valid: true, warnings: [], errors: [] };
1715
+
1716
+ try {
1717
+ // Dynamic import JSDOM
1718
+ const { JSDOM } = await import("jsdom");
1719
+ const dom = new JSDOM(svgContent, { contentType: "image/svg+xml" });
1720
+ const doc = dom.window.document;
1721
+
1722
+ // Check for parser errors
1723
+ const parseError = doc.querySelector("parsererror");
1724
+ if (parseError) {
1725
+ result.valid = false;
1726
+ result.errors.push(`Invalid SVG: ${parseError.textContent}`);
1727
+ return result;
1728
+ }
1729
+
1730
+ // Verify SVG root element exists
1731
+ const svg = doc.querySelector("svg");
1732
+ if (!svg) {
1733
+ result.valid = false;
1734
+ result.errors.push("Missing <svg> root element");
1735
+ return result;
1736
+ }
1737
+
1738
+ // Operation-specific validation
1739
+ if (operation === "embed") {
1740
+ // Check for remaining external font URLs
1741
+ const styleElements = doc.querySelectorAll("style");
1742
+ for (const style of styleElements) {
1743
+ const css = style.textContent || "";
1744
+ const urlMatches = css.match(/url\((['"]?)(?!data:)([^)'"]+)\1\)/gi);
1745
+ if (urlMatches) {
1746
+ for (const match of urlMatches) {
1747
+ if (
1748
+ match.includes("fonts.googleapis.com") ||
1749
+ match.includes("fonts.gstatic.com")
1750
+ ) {
1751
+ result.warnings.push(`External font URL still present: ${match}`);
1752
+ }
1753
+ }
1754
+ }
1755
+ }
1756
+ }
1757
+
1758
+ if (operation === "replace") {
1759
+ // Verify font families are valid
1760
+ const fonts = listFonts(doc);
1761
+ for (const font of fonts) {
1762
+ if (font.type === "unknown" && font.usedChars?.size > 0) {
1763
+ result.warnings.push(
1764
+ `Font "${font.family}" is used but not defined or recognized`
1765
+ );
1766
+ }
1767
+ }
1768
+ }
1769
+
1770
+ dom.window.close();
1771
+ } catch (err) {
1772
+ result.valid = false;
1773
+ result.errors.push(`Validation failed: ${err.message}`);
1774
+ }
1775
+
1776
+ return result;
1777
+ }
1778
+
1779
+ // ============================================================================
1780
+ // TEMPLATE GENERATION
1781
+ // ============================================================================
1782
+
1783
+ /**
1784
+ * Generate YAML replacement map template
1785
+ * @returns {string} YAML template content
1786
+ */
1787
+ export function generateReplacementMapTemplate() {
1788
+ return `# SVG Font Replacement Map
1789
+ # ========================
1790
+ # This file defines font replacements for SVG processing.
1791
+ #
1792
+ # Format:
1793
+ # original_font: replacement_font
1794
+ #
1795
+ # Examples:
1796
+ # "Arial": "Inter" # Replace Arial with Inter
1797
+ # "Times New Roman": "Noto Serif"
1798
+ #
1799
+ # Font sources (in priority order):
1800
+ # 1. Local system fonts
1801
+ # 2. Google Fonts (default, free)
1802
+ # 3. FontGet (npm: fontget)
1803
+ # 4. fnt (brew: alexmyczko/fnt/fnt)
1804
+ #
1805
+ # Options per font:
1806
+ # embed: true # Embed as base64 (default: true)
1807
+ # subset: true # Only include used glyphs (default: true)
1808
+ # source: "google" # Force specific source
1809
+ # weight: "400,700" # Specific weights to include
1810
+ # style: "normal,italic" # Specific styles
1811
+ #
1812
+ # Advanced format:
1813
+ # "Arial":
1814
+ # replacement: "Inter"
1815
+ # embed: true
1816
+ # subset: true
1817
+ # source: "google"
1818
+ # weights: ["400", "500", "700"]
1819
+
1820
+ replacements:
1821
+ # Add your font mappings here
1822
+ # "Original Font": "Replacement Font"
1823
+
1824
+ options:
1825
+ default_embed: true
1826
+ default_subset: true
1827
+ fallback_source: "google"
1828
+ auto_download: true
1829
+ `;
1830
+ }
1831
+
1832
+ // ============================================================================
1833
+ // EXPORTS
1834
+ // ============================================================================
1835
+
1836
+ export default {
1837
+ // Character extraction
1838
+ extractFontCharacterMap,
1839
+ charsToTextParam,
1840
+
1841
+ // Google Fonts
1842
+ isGoogleFontsUrl,
1843
+ extractFontFamilyFromGoogleUrl,
1844
+ addTextParamToGoogleFontsUrl,
1845
+ buildGoogleFontsUrl,
1846
+ POPULAR_GOOGLE_FONTS,
1847
+
1848
+ // Local fonts
1849
+ getSystemFontDirs,
1850
+ checkLocalFont,
1851
+ listSystemFonts,
1852
+
1853
+ // External tools
1854
+ commandExists,
1855
+ downloadWithFontGet,
1856
+ downloadWithFnt,
1857
+
1858
+ // Replacement map
1859
+ loadReplacementMap,
1860
+ applyFontReplacements,
1861
+ generateReplacementMapTemplate,
1862
+
1863
+ // Font listing
1864
+ listFonts,
1865
+
1866
+ // Backup and validation
1867
+ createBackup,
1868
+ validateSvgAfterFontOperation,
1869
+
1870
+ // Font caching
1871
+ initFontCache,
1872
+ getCachedFont,
1873
+ cacheFontData,
1874
+ cleanupFontCache,
1875
+ getFontCacheStats,
1876
+
1877
+ // Font subsetting (requires fonttools/pyftsubset)
1878
+ isFonttoolsAvailable,
1879
+ subsetFont,
1880
+ subsetFontData,
1881
+
1882
+ // WOFF2 compression (requires fonttools)
1883
+ convertToWoff2,
1884
+ convertDataToWoff2,
1885
+
1886
+ // Duplicate detection
1887
+ detectDuplicateFontFaces,
1888
+ mergeDuplicateFontFaces,
1889
+
1890
+ // Intelligent font search
1891
+ stringSimilarity,
1892
+ normalizeFontName,
1893
+ searchSimilarFonts,
1894
+ findFontAlternatives,
1895
+ downloadFont,
1896
+
1897
+ // Constants
1898
+ DEFAULT_REPLACEMENT_MAP,
1899
+ ENV_REPLACEMENT_MAP,
1900
+ WEB_SAFE_FONTS,
1901
+ FONT_FORMATS,
1902
+ SYSTEM_FONT_PATHS,
1903
+ FONT_CACHE_DIR,
1904
+ FONT_CACHE_INDEX,
1905
+ FONT_CACHE_MAX_AGE,
1906
+ };