openfig-cli 0.3.11

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/bin/cli.mjs +111 -0
  4. package/bin/commands/clone-slide.mjs +153 -0
  5. package/bin/commands/export.mjs +83 -0
  6. package/bin/commands/insert-image.mjs +90 -0
  7. package/bin/commands/inspect.mjs +91 -0
  8. package/bin/commands/list-overrides.mjs +66 -0
  9. package/bin/commands/list-text.mjs +60 -0
  10. package/bin/commands/remove-slide.mjs +47 -0
  11. package/bin/commands/roundtrip.mjs +37 -0
  12. package/bin/commands/update-text.mjs +79 -0
  13. package/lib/core/deep-clone.mjs +16 -0
  14. package/lib/core/fig-deck.mjs +332 -0
  15. package/lib/core/image-helpers.mjs +56 -0
  16. package/lib/core/image-utils.mjs +29 -0
  17. package/lib/core/node-helpers.mjs +49 -0
  18. package/lib/rasterizer/deck-rasterizer.mjs +233 -0
  19. package/lib/rasterizer/download-font.mjs +57 -0
  20. package/lib/rasterizer/font-resolver.mjs +602 -0
  21. package/lib/rasterizer/fonts/DarkerGrotesque-400.ttf +0 -0
  22. package/lib/rasterizer/fonts/DarkerGrotesque-500.ttf +0 -0
  23. package/lib/rasterizer/fonts/DarkerGrotesque-600.ttf +0 -0
  24. package/lib/rasterizer/fonts/Inter-Regular.ttf +0 -0
  25. package/lib/rasterizer/fonts/Inter-Variable.ttf +0 -0
  26. package/lib/rasterizer/fonts/Inter.var.woff2 +0 -0
  27. package/lib/rasterizer/fonts/avenir-next-bold-italic.ttf +0 -0
  28. package/lib/rasterizer/fonts/avenir-next-bold.ttf +0 -0
  29. package/lib/rasterizer/fonts/avenir-next-demibold-italic.ttf +0 -0
  30. package/lib/rasterizer/fonts/avenir-next-demibold.ttf +0 -0
  31. package/lib/rasterizer/fonts/avenir-next-italic.ttf +0 -0
  32. package/lib/rasterizer/fonts/avenir-next-medium-italic.ttf +0 -0
  33. package/lib/rasterizer/fonts/avenir-next-medium.ttf +0 -0
  34. package/lib/rasterizer/fonts/avenir-next-regular.ttf +0 -0
  35. package/lib/rasterizer/fonts/darker-grotesque-patched-400-normal.woff2 +0 -0
  36. package/lib/rasterizer/fonts/darker-grotesque-patched-500-normal.woff2 +0 -0
  37. package/lib/rasterizer/fonts/darker-grotesque-patched-600-normal.woff2 +0 -0
  38. package/lib/rasterizer/fonts/darker-grotesque-patched-700-normal.woff2 +0 -0
  39. package/lib/rasterizer/fonts/inter-v3-400-italic.woff2 +0 -0
  40. package/lib/rasterizer/fonts/inter-v3-400-normal.woff2 +0 -0
  41. package/lib/rasterizer/fonts/inter-v3-500-normal.woff2 +0 -0
  42. package/lib/rasterizer/fonts/inter-v3-600-normal.woff2 +0 -0
  43. package/lib/rasterizer/fonts/inter-v3-700-italic.woff2 +0 -0
  44. package/lib/rasterizer/fonts/inter-v3-700-normal.woff2 +0 -0
  45. package/lib/rasterizer/fonts/inter-v3-meta.json +6 -0
  46. package/lib/rasterizer/render-report-lib.mjs +239 -0
  47. package/lib/rasterizer/render-report.mjs +25 -0
  48. package/lib/rasterizer/svg-builder.mjs +1328 -0
  49. package/lib/rasterizer/test-render.mjs +57 -0
  50. package/lib/slides/api.mjs +2100 -0
  51. package/lib/slides/blank-template.deck +0 -0
  52. package/lib/slides/template-deck.mjs +671 -0
  53. package/manifest.json +21 -0
  54. package/mcp-server.mjs +541 -0
  55. package/package.json +74 -0
@@ -0,0 +1,602 @@
1
+ /**
2
+ * font-resolver.mjs — Auto-download Google Fonts for deck rendering.
3
+ *
4
+ * Scans a FigDeck for font families used, downloads missing ones from
5
+ * Google Fonts, patches nameID 1 for resvg matching, and caches locally.
6
+ *
7
+ * Usage:
8
+ * import { resolveFonts } from './font-resolver.mjs';
9
+ * await resolveFonts(deck); // downloads + registers missing fonts
10
+ * const pngs = await renderDeck(deck);
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
14
+ import { join, dirname } from 'path';
15
+ import { fileURLToPath } from 'url';
16
+ import { homedir } from 'os';
17
+ import { nid } from '../core/node-helpers.mjs';
18
+ import { registerFont } from './deck-rasterizer.mjs';
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+
22
+ // ── Built-in fonts (already loaded by deck-rasterizer.mjs) ──────────────────
23
+
24
+ const BUILTIN_FAMILIES = new Set([
25
+ 'inter',
26
+ 'darker grotesque',
27
+ 'irish grover',
28
+ ]);
29
+
30
+ // ── Cache directory ──────────────────────────────────────────────────────────
31
+
32
+ const CACHE_DIR = join(homedir(), '.openfig', 'fonts');
33
+
34
+ function ensureCacheDir() {
35
+ if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
36
+ }
37
+
38
+ // ── Scan deck for font families + weights ────────────────────────────────────
39
+
40
+ /**
41
+ * Walk all nodes in a deck and collect {family → Set<weight>} for fonts used.
42
+ * @param {import('../fig-deck.mjs').FigDeck} deck
43
+ * @returns {Map<string, Set<number>>} family name → set of numeric weights
44
+ */
45
+ export function scanDeckFonts(deck) {
46
+ const fonts = new Map(); // family → Set<weight>
47
+
48
+ function addFont(family, weight) {
49
+ if (!family) return;
50
+ const key = family.trim();
51
+ if (!key) return;
52
+ if (!fonts.has(key)) fonts.set(key, new Set());
53
+ fonts.get(key).add(weight);
54
+ }
55
+
56
+ function weightFromStyle(style) {
57
+ if (!style) return 400;
58
+ if (/black|heavy/i.test(style)) return 900;
59
+ if (/extrabold|ultra\s*bold/i.test(style)) return 800;
60
+ if (/bold/i.test(style)) return 700;
61
+ if (/semibold|demi\s*bold/i.test(style)) return 600;
62
+ if (/medium/i.test(style)) return 500;
63
+ if (/light/i.test(style)) return 300;
64
+ if (/thin|hairline/i.test(style)) return 100;
65
+ return 400;
66
+ }
67
+
68
+ function walkNode(node) {
69
+ if (node.phase === 'REMOVED') return;
70
+
71
+ // TEXT nodes
72
+ if (node.fontName?.family) {
73
+ addFont(node.fontName.family, weightFromStyle(node.fontName.style));
74
+ }
75
+
76
+ // Per-run style overrides
77
+ if (node.textData?.styleOverrideTable) {
78
+ for (const ov of node.textData.styleOverrideTable) {
79
+ if (ov.fontName?.family) {
80
+ addFont(ov.fontName.family, weightFromStyle(ov.fontName.style));
81
+ }
82
+ }
83
+ }
84
+
85
+ // SHAPE_WITH_TEXT text overrides
86
+ const genOvs = node.nodeGenerationData?.overrides;
87
+ if (genOvs?.[1]?.fontName?.family) {
88
+ addFont(genOvs[1].fontName.family, weightFromStyle(genOvs[1].fontName.style));
89
+ }
90
+
91
+ // Recurse children
92
+ for (const child of deck.getChildren(nid(node))) {
93
+ walkNode(child);
94
+ }
95
+ }
96
+
97
+ for (const slide of deck.getActiveSlides()) {
98
+ walkNode(slide);
99
+ }
100
+
101
+ return fonts;
102
+ }
103
+
104
+ // ── Google Fonts download ────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Fetch Google Fonts CSS and parse TTF URLs per weight.
108
+ * @param {string} family e.g. "Darker Grotesque"
109
+ * @param {number[]} weights e.g. [400, 500, 600, 700]
110
+ * @returns {Promise<Map<number, string>>} weight → TTF URL
111
+ */
112
+ async function fetchGoogleFontUrls(family, weights) {
113
+ const slug = family.replace(/\s+/g, '+');
114
+ const wStr = weights.sort((a, b) => a - b).join(';');
115
+ const url = `https://fonts.googleapis.com/css2?family=${slug}:wght@${wStr}`;
116
+
117
+ const resp = await fetch(url, {
118
+ headers: { 'User-Agent': 'Mozilla/5.0' }, // gets TTF format
119
+ });
120
+ if (!resp.ok) return new Map();
121
+
122
+ const css = await resp.text();
123
+ const urls = new Map();
124
+
125
+ // Parse @font-face blocks: font-weight: N ... src: url(...) format('truetype')
126
+ const blocks = css.split('@font-face');
127
+ for (const block of blocks) {
128
+ const wMatch = block.match(/font-weight:\s*(\d+)/);
129
+ const uMatch = block.match(/src:\s*url\(([^)]+\.ttf)\)/);
130
+ if (wMatch && uMatch) {
131
+ urls.set(parseInt(wMatch[1]), uMatch[1]);
132
+ }
133
+ }
134
+ return urls;
135
+ }
136
+
137
+ /**
138
+ * Download a TTF from a URL.
139
+ * @param {string} url
140
+ * @returns {Promise<Buffer>}
141
+ */
142
+ async function downloadFont(url) {
143
+ const resp = await fetch(url);
144
+ if (!resp.ok) throw new Error(`Failed to download font: ${resp.status} ${url}`);
145
+ return Buffer.from(await resp.arrayBuffer());
146
+ }
147
+
148
+ // ── System font lookup ───────────────────────────────────────────────────────
149
+
150
+ /**
151
+ * Platform-specific system font directories.
152
+ * Figma supports local fonts installed via the OS font manager (TTF/OTF only).
153
+ */
154
+ function getSystemFontDirs() {
155
+ const home = homedir();
156
+ switch (process.platform) {
157
+ case 'darwin':
158
+ return [
159
+ '/System/Library/Fonts',
160
+ '/Library/Fonts',
161
+ join(home, 'Library/Fonts'),
162
+ ];
163
+ case 'win32':
164
+ return [
165
+ join(process.env.WINDIR || 'C:\\Windows', 'Fonts'),
166
+ join(home, 'AppData/Local/Microsoft/Windows/Fonts'),
167
+ ];
168
+ default: // linux
169
+ return [
170
+ '/usr/share/fonts',
171
+ '/usr/local/share/fonts',
172
+ join(home, '.local/share/fonts'),
173
+ ];
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Search system font directories for a font family.
179
+ * Matches filenames heuristically: "DarkerGrotesque-Medium.ttf" for family
180
+ * "Darker Grotesque" weight 500. Returns found file paths.
181
+ *
182
+ * @param {string} family e.g. "Darker Grotesque"
183
+ * @param {number[]} weights e.g. [400, 500, 600, 700]
184
+ * @returns {Map<number, string>} weight → file path (only found weights)
185
+ */
186
+ function findSystemFonts(family, weights) {
187
+ const results = new Map();
188
+ // Build filename patterns: "darkergrotesque", "darker-grotesque", "darker_grotesque"
189
+ const slug = family.toLowerCase().replace(/\s+/g, '');
190
+ const slugDash = family.toLowerCase().replace(/\s+/g, '-');
191
+ const slugUnderscore = family.toLowerCase().replace(/\s+/g, '_');
192
+ const patterns = [slug, slugDash, slugUnderscore];
193
+
194
+ const weightNames = {
195
+ 100: ['thin', 'hairline'],
196
+ 200: ['extralight', 'ultralight'],
197
+ 300: ['light'],
198
+ 400: ['regular', 'normal', ''],
199
+ 500: ['medium'],
200
+ 600: ['semibold', 'demibold'],
201
+ 700: ['bold'],
202
+ 800: ['extrabold', 'ultrabold'],
203
+ 900: ['black', 'heavy'],
204
+ };
205
+
206
+ function scanDir(dir) {
207
+ try {
208
+ const entries = readdirSync(dir, { withFileTypes: true });
209
+ for (const entry of entries) {
210
+ const full = join(dir, entry.name);
211
+ if (entry.isDirectory()) { scanDir(full); continue; }
212
+ if (!/\.(ttf|otf|ttc)$/i.test(entry.name)) continue;
213
+ const lower = entry.name.toLowerCase();
214
+ // Check if filename contains the family slug
215
+ if (!patterns.some(p => lower.includes(p))) continue;
216
+
217
+ // TTC files bundle all weights — map to every requested weight
218
+ if (/\.ttc$/i.test(entry.name)) {
219
+ for (const w of weights) {
220
+ if (!results.has(w)) results.set(w, full);
221
+ }
222
+ continue;
223
+ }
224
+
225
+ // TTF/OTF: try to match weight from filename
226
+ for (const w of weights) {
227
+ if (results.has(w)) continue;
228
+ const names = weightNames[w] || [];
229
+ for (const wn of names) {
230
+ if (wn === '' && (lower.includes('-regular') || lower.includes('regular') || !/-\w+\./.test(lower))) {
231
+ results.set(w, full);
232
+ } else if (wn && lower.includes(wn)) {
233
+ results.set(w, full);
234
+ }
235
+ }
236
+ }
237
+ }
238
+ } catch { /* dir doesn't exist or permission denied */ }
239
+ }
240
+
241
+ for (const dir of getSystemFontDirs()) {
242
+ scanDir(dir);
243
+ if (results.size === weights.length) break; // found all weights
244
+ }
245
+ return results;
246
+ }
247
+
248
+ // ── TTF name table patcher (zero dependencies) ──────────────────────────────
249
+ //
250
+ // Patches nameID 1 (font family) and nameID 16 (preferred family) in a TTF
251
+ // file so resvg can match font-family="X" in SVG to the font binary.
252
+ //
253
+ // Approach: rebuild the name table with new strings, then reconstruct the
254
+ // entire TTF file with updated table directory and checksums.
255
+
256
+ function u16(buf, off) { return buf.readUInt16BE(off); }
257
+ function u32(buf, off) { return buf.readUInt32BE(off); }
258
+
259
+ function calcChecksum(buf) {
260
+ // OpenType table checksum: sum of uint32 values (pad to 4 bytes)
261
+ const padded = Buffer.alloc(Math.ceil(buf.length / 4) * 4);
262
+ buf.copy(padded);
263
+ let sum = 0;
264
+ for (let i = 0; i < padded.length; i += 4) {
265
+ sum = (sum + padded.readUInt32BE(i)) >>> 0;
266
+ }
267
+ return sum;
268
+ }
269
+
270
+ function pad4(n) { return (n + 3) & ~3; }
271
+
272
+ /**
273
+ * Patch a TTF buffer's nameID 1 and 16 (font family) to a target name.
274
+ * Returns a new Buffer with the patched font.
275
+ * @param {Buffer} ttf
276
+ * @param {string} targetFamily e.g. "Darker Grotesque"
277
+ * @returns {Buffer}
278
+ */
279
+ function patchFontFamily(ttf, targetFamily) {
280
+ // Guard: only handle plain TTF files (sfVersion 0x00010000 or 'OTTO' for CFF)
281
+ const sfVersion = u32(ttf, 0);
282
+ if (sfVersion !== 0x00010000 && sfVersion !== 0x4F54544F) {
283
+ const tag = ttf.toString('ascii', 0, 4);
284
+ process.stderr.write(
285
+ `[openfig] Cannot patch font nameID: unsupported format "${tag}" ` +
286
+ `(expected TTF or OTF). Font will be registered unpatched — ` +
287
+ `resvg may not match it by family name.\n`
288
+ );
289
+ return ttf;
290
+ }
291
+
292
+ const numTables = u16(ttf, 4);
293
+
294
+ // Read table directory
295
+ const tables = [];
296
+ for (let i = 0; i < numTables; i++) {
297
+ const off = 12 + i * 16;
298
+ tables.push({
299
+ tag: ttf.toString('ascii', off, off + 4),
300
+ checksum: u32(ttf, off + 4),
301
+ offset: u32(ttf, off + 8),
302
+ length: u32(ttf, off + 12),
303
+ });
304
+ }
305
+
306
+ // Extract table data, replacing 'name' table
307
+ const tableBuffers = new Map();
308
+ for (const t of tables) {
309
+ if (t.tag === 'name') {
310
+ tableBuffers.set(t.tag, buildPatchedNameTable(ttf, t.offset, t.length, targetFamily));
311
+ } else {
312
+ tableBuffers.set(t.tag, ttf.subarray(t.offset, t.offset + t.length));
313
+ }
314
+ }
315
+
316
+ // Rebuild TTF: offset table + table directory + table data
317
+ const headerSize = 12 + numTables * 16;
318
+ let dataOffset = pad4(headerSize);
319
+
320
+ // Calculate offsets for each table
321
+ const offsets = new Map();
322
+ for (const t of tables) {
323
+ offsets.set(t.tag, dataOffset);
324
+ dataOffset += pad4(tableBuffers.get(t.tag).length);
325
+ }
326
+
327
+ const out = Buffer.alloc(dataOffset);
328
+
329
+ // Write offset table (copy first 12 bytes: sfVersion, numTables, searchRange, etc.)
330
+ ttf.copy(out, 0, 0, 12);
331
+
332
+ // Write table directory
333
+ for (let i = 0; i < tables.length; i++) {
334
+ const off = 12 + i * 16;
335
+ const t = tables[i];
336
+ const data = tableBuffers.get(t.tag);
337
+ const cs = calcChecksum(data);
338
+ out.write(t.tag, off, 4, 'ascii');
339
+ out.writeUInt32BE(cs, off + 4);
340
+ out.writeUInt32BE(offsets.get(t.tag), off + 8);
341
+ out.writeUInt32BE(data.length, off + 12);
342
+ }
343
+
344
+ // Write table data
345
+ for (const t of tables) {
346
+ tableBuffers.get(t.tag).copy(out, offsets.get(t.tag));
347
+ }
348
+
349
+ // Fix head.checksumAdjustment (offset 8 within 'head' table)
350
+ const headEntry = tables.find(t => t.tag === 'head');
351
+ if (headEntry) {
352
+ const headOff = offsets.get('head');
353
+ // Zero out checksumAdjustment, compute whole-file checksum, then set adjustment
354
+ out.writeUInt32BE(0, headOff + 8);
355
+ const fileChecksum = calcChecksum(out);
356
+ out.writeUInt32BE((0xB1B0AFBA - fileChecksum) >>> 0, headOff + 8);
357
+ }
358
+
359
+ return out;
360
+ }
361
+
362
+ /**
363
+ * Build a new 'name' table buffer with nameID 1 and 16 replaced.
364
+ */
365
+ function buildPatchedNameTable(ttf, tableOff, tableLen, targetFamily) {
366
+ const format = u16(ttf, tableOff);
367
+ if (format !== 0) {
368
+ // Format 1 has extra language tag records — we don't handle that.
369
+ // Return the original table unmodified with a warning.
370
+ process.stderr.write(
371
+ `[openfig] Name table format ${format} not supported for patching ` +
372
+ `(expected format 0). Font will keep its original nameID.\n`
373
+ );
374
+ return Buffer.from(ttf.subarray(tableOff, tableOff + tableLen));
375
+ }
376
+
377
+ const count = u16(ttf, tableOff + 2);
378
+ const stringOff = u16(ttf, tableOff + 4);
379
+ const stringBase = tableOff + stringOff;
380
+
381
+ // Read all name records
382
+ const records = [];
383
+ for (let i = 0; i < count; i++) {
384
+ const r = tableOff + 6 + i * 12;
385
+ records.push({
386
+ platformID: u16(ttf, r),
387
+ encodingID: u16(ttf, r + 2),
388
+ languageID: u16(ttf, r + 4),
389
+ nameID: u16(ttf, r + 6),
390
+ length: u16(ttf, r + 8),
391
+ offset: u16(ttf, r + 10),
392
+ });
393
+ }
394
+
395
+ // Build new string data
396
+ const strings = [];
397
+ let totalLen = 0;
398
+ for (const rec of records) {
399
+ let strBuf;
400
+ if (rec.nameID === 1 || rec.nameID === 16) {
401
+ if (rec.platformID === 3 || rec.platformID === 0) {
402
+ // Windows/Unicode: UTF-16BE
403
+ strBuf = Buffer.alloc(targetFamily.length * 2);
404
+ for (let j = 0; j < targetFamily.length; j++) {
405
+ strBuf.writeUInt16BE(targetFamily.charCodeAt(j), j * 2);
406
+ }
407
+ } else {
408
+ // Mac: ASCII/Latin-1
409
+ strBuf = Buffer.from(targetFamily, 'latin1');
410
+ }
411
+ } else {
412
+ strBuf = Buffer.from(ttf.subarray(stringBase + rec.offset, stringBase + rec.offset + rec.length));
413
+ }
414
+ rec._newLen = strBuf.length;
415
+ rec._newOff = totalLen;
416
+ strings.push(strBuf);
417
+ totalLen += strBuf.length;
418
+ }
419
+
420
+ // Assemble new name table
421
+ const headerLen = 6 + records.length * 12;
422
+ const newTable = Buffer.alloc(headerLen + totalLen);
423
+ newTable.writeUInt16BE(0, 0); // format
424
+ newTable.writeUInt16BE(count, 2); // count
425
+ newTable.writeUInt16BE(headerLen, 4); // stringOffset
426
+
427
+ for (let i = 0; i < records.length; i++) {
428
+ const off = 6 + i * 12;
429
+ const rec = records[i];
430
+ newTable.writeUInt16BE(rec.platformID, off);
431
+ newTable.writeUInt16BE(rec.encodingID, off + 2);
432
+ newTable.writeUInt16BE(rec.languageID, off + 4);
433
+ newTable.writeUInt16BE(rec.nameID, off + 6);
434
+ newTable.writeUInt16BE(rec._newLen, off + 8);
435
+ newTable.writeUInt16BE(rec._newOff, off + 10);
436
+ }
437
+
438
+ for (let i = 0; i < strings.length; i++) {
439
+ strings[i].copy(newTable, headerLen + records[i]._newOff);
440
+ }
441
+
442
+ return newTable;
443
+ }
444
+
445
+ // ── Cache helpers ────────────────────────────────────────────────────────────
446
+
447
+ /**
448
+ * Cache key for a font: family-weight-normal.ttf
449
+ */
450
+ function cacheKey(family, weight) {
451
+ const slug = family.toLowerCase().replace(/\s+/g, '-');
452
+ return `${slug}-${weight}-normal.ttf`;
453
+ }
454
+
455
+ /**
456
+ * Check if a font weight is already cached.
457
+ */
458
+ function isCached(family, weight) {
459
+ return existsSync(join(CACHE_DIR, cacheKey(family, weight)));
460
+ }
461
+
462
+ /**
463
+ * Read a cached font.
464
+ */
465
+ function readCached(family, weight) {
466
+ return readFileSync(join(CACHE_DIR, cacheKey(family, weight)));
467
+ }
468
+
469
+ /**
470
+ * Write a font to cache.
471
+ */
472
+ function writeCache(family, weight, buffer) {
473
+ ensureCacheDir();
474
+ writeFileSync(join(CACHE_DIR, cacheKey(family, weight)), Buffer.from(buffer));
475
+ }
476
+
477
+ // ── Public API ───────────────────────────────────────────────────────────────
478
+
479
+ /**
480
+ * Scan a deck for font families, download missing ones from Google Fonts,
481
+ * patch nameID 1 for resvg matching, cache locally, and register.
482
+ *
483
+ * @param {import('../fig-deck.mjs').FigDeck} deck
484
+ * @param {object} [opts]
485
+ * @param {boolean} [opts.quiet=false] Suppress warnings
486
+ * @returns {Promise<{resolved: string[], failed: string[]}>}
487
+ */
488
+ export async function resolveFonts(deck, opts = {}) {
489
+ const deckFonts = scanDeckFonts(deck);
490
+ const resolved = [];
491
+ const failed = [];
492
+
493
+ for (const [family, weights] of deckFonts) {
494
+ // Skip built-in fonts
495
+ if (BUILTIN_FAMILIES.has(family.toLowerCase())) continue;
496
+
497
+ const weightsArr = [...weights].sort((a, b) => a - b);
498
+ const missing = weightsArr.filter(w => !isCached(family, w));
499
+
500
+ // If all weights are cached, just register them
501
+ if (missing.length === 0) {
502
+ for (const w of weightsArr) {
503
+ registerFont(readCached(family, w));
504
+ }
505
+ resolved.push(family);
506
+ continue;
507
+ }
508
+
509
+ // Try Google Fonts first, then system fonts, then give up
510
+ let googleOk = false;
511
+ try {
512
+ const urls = await fetchGoogleFontUrls(family, missing);
513
+ if (urls.size > 0) {
514
+ for (const w of missing) {
515
+ const url = urls.get(w);
516
+ if (!url) continue;
517
+ const ttfBuf = await downloadFont(url);
518
+ const patched = patchFontFamily(ttfBuf, family);
519
+ writeCache(family, w, patched);
520
+ }
521
+ googleOk = true;
522
+ }
523
+ } catch (err) {
524
+ if (!opts.quiet) {
525
+ process.stderr.write(
526
+ `[openfig] Google Fonts download failed for "${family}": ${err.message}\n`
527
+ );
528
+ }
529
+ }
530
+
531
+ // Check what's still missing after Google Fonts
532
+ const stillMissing = weightsArr.filter(w => !isCached(family, w));
533
+
534
+ // Fallback: search system font directories for remaining weights
535
+ if (stillMissing.length > 0) {
536
+ const systemFonts = findSystemFonts(family, stillMissing);
537
+ const registeredPaths = new Set(); // avoid registering same TTC twice
538
+ for (const [w, path] of systemFonts) {
539
+ if (/\.ttc$/i.test(path)) {
540
+ // TTC (TrueType Collection): register raw file — resvg parses all
541
+ // fonts inside and matches by internal nameID. No patching needed
542
+ // since system fonts already have correct family names.
543
+ if (!registeredPaths.has(path)) {
544
+ registerFont(readFileSync(path));
545
+ registeredPaths.add(path);
546
+ }
547
+ // Mark as resolved in cache via empty sentinel
548
+ writeCache(family, w, Buffer.from('TTC:' + path));
549
+ } else {
550
+ const ttfBuf = readFileSync(path);
551
+ const patched = patchFontFamily(ttfBuf, family);
552
+ writeCache(family, w, patched);
553
+ }
554
+ if (!opts.quiet) {
555
+ process.stderr.write(`[openfig] Loaded "${family}" weight ${w} from system: ${path}\n`);
556
+ }
557
+ }
558
+ }
559
+
560
+ // Register all cached weights
561
+ const finalMissing = [];
562
+ const registeredTtcPaths = new Set();
563
+ for (const w of weightsArr) {
564
+ if (isCached(family, w)) {
565
+ const cached = readCached(family, w);
566
+ // TTC sentinel: "TTC:/path/to/Font.ttc" — re-read and register system file
567
+ if (cached.length < 512 && cached.toString().startsWith('TTC:')) {
568
+ const ttcPath = cached.toString().slice(4);
569
+ if (!registeredTtcPaths.has(ttcPath) && existsSync(ttcPath)) {
570
+ registerFont(readFileSync(ttcPath));
571
+ registeredTtcPaths.add(ttcPath);
572
+ }
573
+ } else {
574
+ registerFont(cached);
575
+ }
576
+ } else {
577
+ finalMissing.push(w);
578
+ }
579
+ }
580
+
581
+ if (finalMissing.length === 0) {
582
+ resolved.push(family);
583
+ } else {
584
+ if (!opts.quiet) {
585
+ process.stderr.write(
586
+ `[openfig] Font "${family}" missing weights [${finalMissing.join(', ')}] — ` +
587
+ `not on Google Fonts, not found in system fonts. ` +
588
+ `Text will render in Inter as fallback. ` +
589
+ `Use registerFont() to supply this font manually.\n`
590
+ );
591
+ }
592
+ // Partial success: some weights resolved
593
+ if (finalMissing.length < weightsArr.length) {
594
+ resolved.push(family);
595
+ } else {
596
+ failed.push(family);
597
+ }
598
+ }
599
+ }
600
+
601
+ return { resolved, failed };
602
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "source": "Figma.app/Contents/Resources/app.asar /assets/Inter.var.woff2",
3
+ "version": "Version 3.015;git-7f5c04026",
4
+ "axes": { "wght": [100, 900], "slnt": [-10, 0] },
5
+ "instances": ["400-normal", "500-normal", "600-normal", "700-normal", "400-italic", "700-italic"]
6
+ }