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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/cli.mjs +111 -0
- package/bin/commands/clone-slide.mjs +153 -0
- package/bin/commands/export.mjs +83 -0
- package/bin/commands/insert-image.mjs +90 -0
- package/bin/commands/inspect.mjs +91 -0
- package/bin/commands/list-overrides.mjs +66 -0
- package/bin/commands/list-text.mjs +60 -0
- package/bin/commands/remove-slide.mjs +47 -0
- package/bin/commands/roundtrip.mjs +37 -0
- package/bin/commands/update-text.mjs +79 -0
- package/lib/core/deep-clone.mjs +16 -0
- package/lib/core/fig-deck.mjs +332 -0
- package/lib/core/image-helpers.mjs +56 -0
- package/lib/core/image-utils.mjs +29 -0
- package/lib/core/node-helpers.mjs +49 -0
- package/lib/rasterizer/deck-rasterizer.mjs +233 -0
- package/lib/rasterizer/download-font.mjs +57 -0
- package/lib/rasterizer/font-resolver.mjs +602 -0
- package/lib/rasterizer/fonts/DarkerGrotesque-400.ttf +0 -0
- package/lib/rasterizer/fonts/DarkerGrotesque-500.ttf +0 -0
- package/lib/rasterizer/fonts/DarkerGrotesque-600.ttf +0 -0
- package/lib/rasterizer/fonts/Inter-Regular.ttf +0 -0
- package/lib/rasterizer/fonts/Inter-Variable.ttf +0 -0
- package/lib/rasterizer/fonts/Inter.var.woff2 +0 -0
- package/lib/rasterizer/fonts/avenir-next-bold-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-bold.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-demibold-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-demibold.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-medium-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-medium.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-regular.ttf +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-400-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-500-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-600-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-700-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-400-italic.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-400-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-500-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-600-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-700-italic.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-700-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-meta.json +6 -0
- package/lib/rasterizer/render-report-lib.mjs +239 -0
- package/lib/rasterizer/render-report.mjs +25 -0
- package/lib/rasterizer/svg-builder.mjs +1328 -0
- package/lib/rasterizer/test-render.mjs +57 -0
- package/lib/slides/api.mjs +2100 -0
- package/lib/slides/blank-template.deck +0 -0
- package/lib/slides/template-deck.mjs +671 -0
- package/manifest.json +21 -0
- package/mcp-server.mjs +541 -0
- 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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|