@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.
- package/bin/svgfonts.js +259 -5
- package/dist/svg-matrix.global.min.js +2 -2
- package/dist/svg-matrix.min.js +2 -2
- package/dist/svg-toolbox.global.min.js +39 -39
- package/dist/svg-toolbox.min.js +36 -36
- package/dist/svgm.min.js +26 -26
- package/dist/version.json +8 -8
- package/package.json +1 -1
- package/scripts/postinstall.js +10 -4
- package/scripts/version-sync.js +2 -2
- package/src/animation-references.js +2 -1
- package/src/flatten-pipeline.js +0 -3
- package/src/font-manager.js +935 -42
- package/src/index.js +2 -2
- package/src/matrix.js +3 -3
- package/src/mesh-gradient.js +43 -0
- package/src/pattern-resolver.js +3 -2
- package/src/svg-matrix-lib.js +4 -3
- package/src/svg-parser.js +0 -4
- package/src/svg-toolbox-lib.js +2 -2
- package/src/svg-validation-data.js +1 -1
- package/src/svgm-lib.js +2 -2
package/src/font-manager.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|
};
|