@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.
- package/README.md +75 -0
- package/bin/svgfonts.js +1666 -0
- package/bin/svgm.js +1 -1
- package/dist/svg-matrix.global.min.js +8 -0
- package/dist/svg-matrix.min.js +2 -2
- package/dist/svg-toolbox.global.min.js +493 -0
- package/dist/svg-toolbox.min.js +36 -36
- package/dist/svgm.min.js +64 -64
- package/dist/version.json +44 -16
- package/package.json +11 -3
- package/scripts/postinstall.js +10 -4
- package/scripts/version-sync.js +2 -2
- package/src/animation-references.js +2 -1
- package/src/bezier-intersections.js +1 -1
- package/src/browser-verify.js +0 -1
- package/src/clip-path-resolver.js +3 -1
- package/src/flatten-pipeline.js +0 -3
- package/src/font-manager.js +1906 -0
- package/src/index.js +2 -2
- package/src/inkscape-support.js +2 -2
- package/src/mask-resolver.js +14 -6
- package/src/matrix.js +3 -3
- package/src/mesh-gradient.js +43 -2
- package/src/off-canvas-detection.js +14 -22
- package/src/pattern-resolver.js +3 -2
- package/src/svg-boolean-ops.js +0 -5
- package/src/svg-collections.js +11 -0
- package/src/svg-matrix-lib.js +4 -3
- package/src/svg-parser.js +0 -28
- package/src/svg-rendering-context.js +2 -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/transform-optimization.js +93 -142
- package/src/verification.js +0 -2
- package/templates/svgm_replacement_map.yml +53 -0
|
@@ -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
|
+
};
|