@emasoft/svg-matrix 1.3.0 → 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.
@@ -14,10 +14,10 @@
14
14
  * @license MIT
15
15
  */
16
16
 
17
- import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from "fs";
18
- import { join, dirname, basename, extname, resolve } from "path";
17
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
18
+ import { join, dirname, basename, extname } from "path";
19
19
  import { homedir, platform } from "os";
20
- import { execSync, execFileSync } from "child_process";
20
+ import { execFileSync } from "child_process";
21
21
  import yaml from "js-yaml";
22
22
 
23
23
  // ============================================================================
@@ -99,6 +99,905 @@ export const SYSTEM_FONT_PATHS = {
99
99
  ],
100
100
  };
101
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
+
102
1001
  // ============================================================================
103
1002
  // FONT CHARACTER EXTRACTION
104
1003
  // ============================================================================
@@ -288,7 +1187,7 @@ export function addTextParamToGoogleFontsUrl(url, textParam) {
288
1187
  export function buildGoogleFontsUrl(fontFamily, options = {}) {
289
1188
  const {
290
1189
  weights = ["400"],
291
- styles = ["normal"],
1190
+ styles: _styles = ["normal"],
292
1191
  text,
293
1192
  display = "swap",
294
1193
  } = options;
@@ -305,43 +1204,6 @@ export function buildGoogleFontsUrl(fontFamily, options = {}) {
305
1204
  return url;
306
1205
  }
307
1206
 
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
1207
  // ============================================================================
346
1208
  // LOCAL FONT DETECTION
347
1209
  // ============================================================================
@@ -485,9 +1347,10 @@ export async function listSystemFonts() {
485
1347
  */
486
1348
  export function commandExists(cmd) {
487
1349
  try {
1350
+ // Security: use execFileSync with array args to prevent command injection
488
1351
  // Windows uses 'where', Unix uses 'which'
489
1352
  const checkCmd = platform() === "win32" ? "where" : "which";
490
- execSync(`${checkCmd} ${cmd}`, { stdio: "ignore" });
1353
+ execFileSync(checkCmd, [cmd], { stdio: "ignore" });
491
1354
  return true;
492
1355
  } catch {
493
1356
  return false;
@@ -1004,10 +1867,40 @@ export default {
1004
1867
  createBackup,
1005
1868
  validateSvgAfterFontOperation,
1006
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
+
1007
1897
  // Constants
1008
1898
  DEFAULT_REPLACEMENT_MAP,
1009
1899
  ENV_REPLACEMENT_MAP,
1010
1900
  WEB_SAFE_FONTS,
1011
1901
  FONT_FORMATS,
1012
1902
  SYSTEM_FONT_PATHS,
1903
+ FONT_CACHE_DIR,
1904
+ FONT_CACHE_INDEX,
1905
+ FONT_CACHE_MAX_AGE,
1013
1906
  };