figmatk 0.3.1 → 0.3.7

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 (34) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +39 -21
  4. package/cli.mjs +2 -0
  5. package/commands/render.mjs +56 -0
  6. package/lib/rasterizer/deck-rasterizer.mjs +228 -0
  7. package/lib/rasterizer/download-font.mjs +57 -0
  8. package/lib/rasterizer/font-resolver.mjs +602 -0
  9. package/lib/rasterizer/fonts/DarkerGrotesque-400.ttf +0 -0
  10. package/lib/rasterizer/fonts/DarkerGrotesque-500.ttf +0 -0
  11. package/lib/rasterizer/fonts/DarkerGrotesque-600.ttf +0 -0
  12. package/lib/rasterizer/fonts/Inter-Regular.ttf +0 -0
  13. package/lib/rasterizer/fonts/Inter-Variable.ttf +0 -0
  14. package/lib/rasterizer/fonts/Inter.var.woff2 +0 -0
  15. package/lib/rasterizer/fonts/darker-grotesque-patched-400-normal.woff2 +0 -0
  16. package/lib/rasterizer/fonts/darker-grotesque-patched-500-normal.woff2 +0 -0
  17. package/lib/rasterizer/fonts/darker-grotesque-patched-600-normal.woff2 +0 -0
  18. package/lib/rasterizer/fonts/darker-grotesque-patched-700-normal.woff2 +0 -0
  19. package/lib/rasterizer/fonts/inter-v3-400-italic.woff2 +0 -0
  20. package/lib/rasterizer/fonts/inter-v3-400-normal.woff2 +0 -0
  21. package/lib/rasterizer/fonts/inter-v3-500-normal.woff2 +0 -0
  22. package/lib/rasterizer/fonts/inter-v3-600-normal.woff2 +0 -0
  23. package/lib/rasterizer/fonts/inter-v3-700-italic.woff2 +0 -0
  24. package/lib/rasterizer/fonts/inter-v3-700-normal.woff2 +0 -0
  25. package/lib/rasterizer/fonts/inter-v3-meta.json +6 -0
  26. package/lib/rasterizer/render-report-lib.mjs +127 -0
  27. package/lib/rasterizer/render-report.mjs +25 -0
  28. package/lib/rasterizer/svg-builder.mjs +571 -0
  29. package/lib/rasterizer/test-render.mjs +63 -0
  30. package/manifest.json +21 -0
  31. package/mcp-server.mjs +65 -4
  32. package/package.json +17 -2
  33. package/skills/figma-slides-creator/SKILL.md +82 -209
  34. package/skills/figma-template-builder/SKILL.md +11 -1
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "name": "figmatk",
15
15
  "description": "Swiss Army Knife for Figma Files (.deck)",
16
- "version": "0.3.1",
16
+ "version": "0.3.3",
17
17
  "author": {
18
18
  "name": "FigmaTK Contributors"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figmatk",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Create and edit Figma Slides .deck files programmatically — no Figma API required",
5
5
  "author": {
6
6
  "name": "FigmaTK Contributors"
package/README.md CHANGED
@@ -21,7 +21,7 @@ Figma Slides lets you download presentations as `.deck` files and re-upload them
21
21
 
22
22
  FigmaTK makes this round-trip programmable. Download a `.deck`, modify it, re-upload. Everything stays native.
23
23
 
24
- Plug in [Claude Code](https://claude.ai/code) or any coding agent and you have an AI that can read and edit Figma presentations end-to-end — without ever opening the Figma UI.
24
+ Plug in Claude Cowork or any coding agent and you have an AI that can read and edit Figma presentations end-to-end — without ever opening the Figma UI.
25
25
 
26
26
  ## Use Cases
27
27
 
@@ -48,26 +48,48 @@ figmatk list-overrides my-presentation.deck # editable fields per symbol
48
48
 
49
49
  → Full CLI reference: [docs/cli.md](docs/cli.md)
50
50
 
51
- ## Claude Code / MCP Integration
51
+ ## Claude Cowork / MCP Integration
52
52
 
53
- FigmaTK ships as a **Cowork plugin** with an MCP server — Claude can manipulate `.deck` files directly as tool calls.
53
+ FigmaTK supports two Claude Cowork install paths:
54
+
55
+ - GitHub-backed personal plugin install, which preserves Claude Cowork's repo update-check behavior
56
+ - Local `.mcpb` bundle install, which matches Anthropic's current desktop-extension packaging model
57
+
58
+ ### Option 1 — Install from GitHub in Claude Cowork
59
+
60
+ If you want Claude Cowork to keep checking the repo for updates, install `figmatk` from GitHub/personal plugins inside Claude Cowork.
61
+
62
+ That path uses the checked-in [plugin.json](/Users/rob/Dev/figmatk/.claude-plugin/plugin.json) and [marketplace.json](/Users/rob/Dev/figmatk/.claude-plugin/marketplace.json) metadata.
63
+
64
+ ### Option 2 — Install the local MCPB bundle
65
+
66
+ Build the extension bundle:
54
67
 
55
68
  ```bash
56
- claude plugin marketplace add rcoenen/figmatk
57
- claude plugin install figmatk
69
+ npm install
70
+ npm run pack
58
71
  ```
59
72
 
60
- Or add manually in Claude Desktop Settings Developer → Edit Config:
73
+ This creates `dist/figmatk.mcpb`. Install that bundle from Claude Desktop/Cowork's Extensions UI.
61
74
 
62
- ```json
63
- {
64
- "mcpServers": {
65
- "figmatk": { "command": "figmatk-mcp" }
66
- }
67
- }
68
- ```
75
+ Install in Claude Cowork:
76
+
77
+ 1. Open Claude Cowork or Claude Desktop.
78
+ 2. Go to `Settings`.
79
+ 3. Open `Extensions`.
80
+ 4. Choose the local install/add option.
81
+ 5. Select `dist/figmatk.mcpb`.
82
+
83
+ Use this path when you want a local extension artifact. Unlike the GitHub-backed personal plugin path, local `.mcpb` installs do not poll the repo for updates automatically.
69
84
 
70
- Available MCP tools: `figmatk_create_deck`, `figmatk_create_template_draft`, `figmatk_annotate_template_layout`, `figmatk_publish_template_draft`, `figmatk_list_template_layouts`, `figmatk_create_from_template`, `figmatk_inspect`, `figmatk_list_text`, `figmatk_list_overrides`, `figmatk_update_text`, `figmatk_insert_image`, `figmatk_clone_slide`, `figmatk_remove_slide`, `figmatk_roundtrip`.
85
+ The MCP server covers four high-level workflows:
86
+
87
+ - create a new deck from scratch
88
+ - author a reusable Slides template
89
+ - instantiate a new deck from a template
90
+ - inspect or edit an existing deck
91
+
92
+ → MCP tool reference: [docs/mcp.md](docs/mcp.md)
71
93
 
72
94
  ## Template Workflows
73
95
 
@@ -76,14 +98,9 @@ FigmaTK supports two related template states:
76
98
  - Draft templates: `SLIDE_ROW -> SLIDE -> ...`
77
99
  - Published templates: `SLIDE_ROW -> MODULE -> SLIDE -> ...`
78
100
 
79
- Use explicit naming conventions when authoring reusable templates:
80
-
81
- - Layouts: `layout:<name>`
82
- - Text slots: `slot:text:<name>`
83
- - Image slots: `slot:image:<name>`
84
- - Decorative fixed imagery: `fixed:image:<name>`
101
+ Reusable template authoring is built around explicit layout and slot naming, then a publish-like wrapping step before later instantiation.
85
102
 
86
- `figmatk_list_template_layouts` understands those conventions and only falls back to heuristic image placeholders when a layout has not been explicitly annotated yet.
103
+ Template workflow guide: [docs/template-workflows.md](docs/template-workflows.md)
87
104
 
88
105
  ## Programmatic API
89
106
 
@@ -98,6 +115,7 @@ await deck.save('output.deck');
98
115
 
99
116
  | Docs | |
100
117
  |------|---|
118
+ | MCP / Claude workflows | [docs/mcp.md](docs/mcp.md) |
101
119
  | High-level API | [docs/figmatk-api-spec.md](docs/figmatk-api-spec.md) |
102
120
  | Low-level FigDeck API | [docs/library.md](docs/library.md) |
103
121
  | Template workflows | [docs/template-workflows.md](docs/template-workflows.md) |
package/cli.mjs CHANGED
@@ -24,6 +24,7 @@ const COMMANDS = {
24
24
  'clone-slide': './commands/clone-slide.mjs',
25
25
  'remove-slide': './commands/remove-slide.mjs',
26
26
  'roundtrip': './commands/roundtrip.mjs',
27
+ 'render': './commands/render.mjs',
27
28
  };
28
29
 
29
30
  const command = process.argv[2];
@@ -39,6 +40,7 @@ if (!command || command === '--help' || command === '-h') {
39
40
  console.log(' clone-slide Duplicate a template slide with new content');
40
41
  console.log(' remove-slide Mark slides as REMOVED');
41
42
  console.log(' roundtrip Decode and re-encode (pipeline validation)');
43
+ console.log(' render Rasterize slides to PNG');
42
44
  console.log('\nUsage: figmatk <command> [args...]');
43
45
  process.exit(0);
44
46
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * render — Rasterize slides in a .deck file to PNG.
3
+ *
4
+ * Usage:
5
+ * figmatk render <file.deck> -o <output-dir> [options]
6
+ *
7
+ * Options:
8
+ * -o <dir> Output directory (default: ./render-out)
9
+ * --slide <n> Render only slide N (1-based). Omit to render all.
10
+ * --scale <n> Zoom factor: 1 = 1920×1080, 0.5 = 960×540 (default: 1)
11
+ * --width <px> Output width in pixels (height scales proportionally)
12
+ * --fonts <dir> Extra font directory to load (can repeat)
13
+ */
14
+
15
+ import { mkdirSync, writeFileSync } from 'fs';
16
+ import { join, resolve } from 'path';
17
+ import { FigDeck } from '../lib/fig-deck.mjs';
18
+ import { renderDeck, registerFontDir } from '../lib/rasterizer/deck-rasterizer.mjs';
19
+ import { resolveFonts } from '../lib/rasterizer/font-resolver.mjs';
20
+
21
+ export async function run(args, flags) {
22
+ const file = args[0];
23
+ if (!file) {
24
+ console.error('Usage: render <file.deck> -o <output-dir> [--slide N] [--scale 0.5] [--width 400] [--fonts <dir>]');
25
+ process.exit(1);
26
+ }
27
+
28
+ const outDir = resolve(flags.o ?? flags.output ?? './render-out');
29
+
30
+ // Build render options
31
+ const renderOpts = {};
32
+ if (flags.width) renderOpts.width = parseInt(flags.width);
33
+ else if (flags.scale) renderOpts.scale = parseFloat(flags.scale);
34
+
35
+ // Load extra font directories
36
+ const fontDirs = [].concat(flags.fonts ?? []);
37
+ for (const d of fontDirs) registerFontDir(resolve(d));
38
+
39
+ const deck = await FigDeck.fromDeckFile(file);
40
+ await resolveFonts(deck, { quiet: false });
41
+ mkdirSync(outDir, { recursive: true });
42
+
43
+ // Filter to single slide if requested
44
+ const slideFilter = flags.slide ? parseInt(flags.slide) : null;
45
+ const slides = await renderDeck(deck, renderOpts);
46
+
47
+ for (const { index, slideId, png } of slides) {
48
+ if (slideFilter && index + 1 !== slideFilter) continue;
49
+ const outFile = join(outDir, `slide-${String(index + 1).padStart(3, '0')}.png`);
50
+ writeFileSync(outFile, png);
51
+ console.log(` slide ${index + 1} → ${outFile}`);
52
+ }
53
+
54
+ const count = slideFilter ? 1 : slides.length;
55
+ console.log(`\nRendered ${count} slide(s) to ${outDir}`);
56
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * deck-rasterizer.mjs — Render FigDeck slides to PNG via WASM (resvg).
3
+ *
4
+ * WASM is initialized once per process. Fonts are loaded at init time and
5
+ * can be hot-plugged via registerFont() before or after initialization.
6
+ *
7
+ * Usage:
8
+ * import { renderDeck, registerFont } from './deck-rasterizer.mjs';
9
+ * await registerFont('/path/to/CustomFont.ttf');
10
+ * const pngs = await renderDeck(deck); // Map<slideIndex, Uint8Array>
11
+ */
12
+
13
+ import { readFileSync, readdirSync, statSync } from 'fs';
14
+ import { join, dirname } from 'path';
15
+ import { fileURLToPath } from 'url';
16
+ import { initWasm, Resvg } from '@resvg/resvg-wasm';
17
+ import { slideToSvg } from './svg-builder.mjs';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const WASM_PATH = join(__dirname, '../../node_modules/@resvg/resvg-wasm/index_bg.wasm');
21
+
22
+ // ── Font registry ─────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Try to load a font file from a path — returns Buffer or null (never throws).
26
+ * Tries the given path variants in order, returning the first that exists.
27
+ */
28
+ function tryFont(...paths) {
29
+ for (const p of paths) {
30
+ try { return readFileSync(p); } catch { /* not found */ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Load all available weights of a font from @fontsource, trying WOFF2 then TTF.
37
+ * Silently skips missing files/packages.
38
+ * @param {string} pkg e.g. '@fontsource/darker-grotesque'
39
+ * @param {string} slug e.g. 'darker-grotesque'
40
+ * @param {number[]} weights e.g. [400, 500, 600, 700]
41
+ */
42
+ function tryFontsourceFamily(pkg, slug, weights = [400, 500, 600, 700]) {
43
+ const base = join(__dirname, `../../node_modules/${pkg}/files`);
44
+ const bufs = [];
45
+ for (const w of weights) {
46
+ const buf = tryFont(
47
+ `${base}/${slug}-latin-${w}-normal.woff2`,
48
+ `${base}/${slug}-latin-${w}-normal.woff`,
49
+ `${base}/${slug}-latin-${w}-normal.ttf`,
50
+ `${base}/${slug}-all-${w}-normal.woff2`,
51
+ );
52
+ if (buf) bufs.push(buf);
53
+ }
54
+ return bufs;
55
+ }
56
+
57
+ // ── Inter font loading with version warning ────────────────────────────────────
58
+ //
59
+ // Figma bundles Inter v3.015 (variable font, wght+slnt axes) inside its app.
60
+ // @fontsource/inter ships v4.x — italic glyph shapes (e.g. "a") differ visibly.
61
+ //
62
+ // Run node lib/rasterizer/extract-figma-fonts.mjs once to generate the v3
63
+ // static WOFF2 files in lib/rasterizer/fonts/inter-v3-*.woff2.
64
+ //
65
+ // KNOWN_FIGMA_INTER_VERSION is the nameID 5 string from Figma's bundled font.
66
+ const KNOWN_FIGMA_INTER_VERSION = 'Version 3.015;git-7f5c04026';
67
+
68
+ function loadInterFonts() {
69
+ const v3Meta = join(__dirname, 'fonts/inter-v3-meta.json');
70
+ const v3Instances = ['400-normal', '500-normal', '600-normal', '700-normal', '400-italic', '700-italic'];
71
+ const bufs = v3Instances.map(s => tryFont(join(__dirname, `fonts/inter-v3-${s}.woff2`))).filter(Boolean);
72
+
73
+ if (bufs.length === v3Instances.length) {
74
+ // All v3 static fonts present — check version matches expectation
75
+ try {
76
+ const meta = JSON.parse(readFileSync(v3Meta, 'utf8'));
77
+ if (meta.version !== KNOWN_FIGMA_INTER_VERSION) {
78
+ process.stderr.write(
79
+ `[figmatk] Inter version warning: fonts/inter-v3-*.woff2 reports "${meta.version}", ` +
80
+ `expected "${KNOWN_FIGMA_INTER_VERSION}". Renders may differ.\n`
81
+ );
82
+ }
83
+ } catch { /* meta missing — skip version check */ }
84
+ return bufs;
85
+ }
86
+
87
+ // Fallback: @fontsource/inter (v4.x)
88
+ process.stderr.write(
89
+ `[figmatk] Inter version warning: Figma uses Inter "${KNOWN_FIGMA_INTER_VERSION}" but ` +
90
+ `local v3 fonts not found. Falling back to @fontsource/inter (v4.x) — ` +
91
+ `italic glyphs (e.g. "a") may differ. ` +
92
+ `Run: node lib/rasterizer/extract-figma-fonts.mjs\n`
93
+ );
94
+ return [
95
+ ...tryFontsourceFamily('@fontsource/inter', 'inter', [400, 500, 600, 700]),
96
+ ...['400', '700'].flatMap(w => {
97
+ const buf = tryFont(join(__dirname, `../../node_modules/@fontsource/inter/files/inter-latin-${w}-italic.woff2`));
98
+ return buf ? [buf] : [];
99
+ }),
100
+ ];
101
+ }
102
+
103
+ const fontBuffers = [
104
+ ...loadInterFonts(),
105
+ // Darker Grotesque — patched WOFF2 with family name "Darker Grotesque" so resvg can match it
106
+ ...['400', '500', '600', '700'].flatMap(w => {
107
+ const buf = tryFont(join(__dirname, `fonts/darker-grotesque-patched-${w}-normal.woff2`));
108
+ return buf ? [buf] : [];
109
+ }),
110
+ // Irish Grover — internal name already matches, load directly from @fontsource
111
+ ...tryFontsourceFamily('@fontsource/irish-grover', 'irish-grover', [400]),
112
+ ];
113
+
114
+ /**
115
+ * Register an additional font for rendering.
116
+ * Can be called at any time — takes effect on the next render call.
117
+ * @param {string|Buffer|Uint8Array} source File path or raw buffer.
118
+ */
119
+ export function registerFont(source) {
120
+ const buf = typeof source === 'string'
121
+ ? readFileSync(source)
122
+ : Buffer.isBuffer(source) ? source : Buffer.from(source);
123
+ fontBuffers.push(buf);
124
+ }
125
+
126
+ /**
127
+ * Register all fonts in a directory (recursively scans .ttf/.otf/.woff/.woff2).
128
+ * Call this before rendering if slides use custom fonts.
129
+ * @param {string} dir Directory path to scan.
130
+ */
131
+ export function registerFontDir(dir) {
132
+ const scan = (d) => {
133
+ for (const entry of readdirSync(d)) {
134
+ const full = join(d, entry);
135
+ if (statSync(full).isDirectory()) { scan(full); continue; }
136
+ if (/\.(ttf|otf|woff2?)$/i.test(entry)) registerFont(full);
137
+ }
138
+ };
139
+ scan(dir);
140
+ }
141
+
142
+ // ── WASM init (lazy, once) ────────────────────────────────────────────────────
143
+
144
+ let wasmReady = false;
145
+ let wasmInitPromise = null;
146
+
147
+ async function ensureWasm() {
148
+ if (wasmReady) return;
149
+ if (!wasmInitPromise) {
150
+ wasmInitPromise = initWasm(readFileSync(WASM_PATH)).then(() => {
151
+ wasmReady = true;
152
+ });
153
+ }
154
+ await wasmInitPromise;
155
+ }
156
+
157
+ // ── Core render ───────────────────────────────────────────────────────────────
158
+
159
+ const SLIDE_W = 1920;
160
+ const SLIDE_H = 1080;
161
+
162
+ const DEFAULT_OPTS = {
163
+ scale: 1, // 1 = 1920×1080, 0.5 = 960×540 — capped at 1
164
+ background: '#ffffff',
165
+ };
166
+
167
+ /**
168
+ * Resolve scale from opts. Accepts:
169
+ * scale (float) — direct multiplier, capped at 1
170
+ * width (px) — fit to width, preserving aspect ratio
171
+ * height (px) — fit to height, preserving aspect ratio
172
+ * Never upscales beyond native 1920×1080.
173
+ */
174
+ function resolveScale(opts) {
175
+ if (opts.width) return opts.width / SLIDE_W;
176
+ if (opts.height) return opts.height / SLIDE_H;
177
+ return opts.scale ?? 1;
178
+ }
179
+
180
+ /**
181
+ * Render a single SVG string to PNG.
182
+ * @param {string} svg
183
+ * @param {object} opts
184
+ * @returns {Promise<Uint8Array>} PNG bytes
185
+ */
186
+ export async function svgToPng(svg, opts = {}) {
187
+ await ensureWasm();
188
+ const { background } = { ...DEFAULT_OPTS, ...opts };
189
+ const scale = resolveScale(opts);
190
+
191
+ const resvg = new Resvg(svg, {
192
+ background,
193
+ fitTo: scale !== 1 ? { mode: 'zoom', value: scale } : { mode: 'original' },
194
+ font: {
195
+ fontBuffers: fontBuffers.map(b => new Uint8Array(b)),
196
+ loadSystemFonts: false,
197
+ sansSerifFamily: 'Inter',
198
+ defaultFontFamily: 'Inter',
199
+ },
200
+ });
201
+
202
+ const rendered = resvg.render();
203
+ const png = rendered.asPng();
204
+ rendered.free();
205
+ resvg.free();
206
+ return png;
207
+ }
208
+
209
+ /**
210
+ * Render all active slides in a deck to PNG.
211
+ * @param {import('../fig-deck.mjs').FigDeck} deck
212
+ * @param {object} opts
213
+ * @param {number} [opts.scale=1] Zoom factor (e.g. 0.5 for thumbnails)
214
+ * @returns {Promise<Array<{index: number, slideId: string, png: Uint8Array}>>}
215
+ */
216
+ export async function renderDeck(deck, opts = {}) {
217
+ const slides = deck.getActiveSlides();
218
+ const results = [];
219
+ for (let i = 0; i < slides.length; i++) {
220
+ const slide = slides[i];
221
+ const svg = slideToSvg(deck, slide);
222
+ const png = await svgToPng(svg, opts);
223
+ results.push({ index: i, slideId: slide.guid
224
+ ? `${slide.guid.sessionID}:${slide.guid.localID}`
225
+ : String(i), png });
226
+ }
227
+ return results;
228
+ }
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * font-helper — Install fonts from @fontsource and register with the rasterizer.
4
+ *
5
+ * resvg-wasm accepts WOFF2 directly — no conversion needed.
6
+ *
7
+ * Usage:
8
+ * node lib/rasterizer/download-font.mjs "Darker Grotesque" 500 600
9
+ * node lib/rasterizer/download-font.mjs "Inter" 400 700
10
+ *
11
+ * What it does:
12
+ * 1. npm install @fontsource/<family> (if not already installed)
13
+ * 2. Prints the registerFont() calls to add to deck-rasterizer.mjs
14
+ */
15
+
16
+ import { existsSync } from 'fs';
17
+ import { join, dirname } from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import { execSync } from 'child_process';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const ROOT = join(__dirname, '../..');
23
+
24
+ const [,, familyArg, ...weightArgs] = process.argv;
25
+ if (!familyArg) {
26
+ console.error('Usage: node download-font.mjs "Family Name" [weight...]\n');
27
+ console.error(' node download-font.mjs "Darker Grotesque" 500 600');
28
+ process.exit(1);
29
+ }
30
+
31
+ const weights = weightArgs.length ? weightArgs.map(Number) : [400];
32
+ const family = familyArg.trim();
33
+ const pkgSlug = family.toLowerCase().replace(/\s+/g, '-');
34
+ const pkgName = `@fontsource/${pkgSlug}`;
35
+ const pkgDir = join(ROOT, 'node_modules', pkgName, 'files');
36
+
37
+ if (!existsSync(pkgDir)) {
38
+ console.log(`Installing ${pkgName}…`);
39
+ execSync(`npm install ${pkgName} --save-dev`, { cwd: ROOT, stdio: 'inherit' });
40
+ }
41
+
42
+ if (!existsSync(pkgDir)) {
43
+ console.error(`${pkgName} not found after install.`);
44
+ process.exit(1);
45
+ }
46
+
47
+ console.log(`\n✓ ${pkgName} ready. Add to deck-rasterizer.mjs fontBuffers:\n`);
48
+ for (const w of weights) {
49
+ const file = `${pkgSlug}-latin-${w}-normal.woff2`;
50
+ const full = join(pkgDir, file);
51
+ if (existsSync(full)) {
52
+ const rel = full.replace(ROOT + '/', '');
53
+ console.log(` readFileSync(join(ROOT, '${rel}')), // ${family} ${w}`);
54
+ } else {
55
+ console.warn(` ⚠ not found: ${file}`);
56
+ }
57
+ }