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,29 @@
1
+ /**
2
+ * Cross-platform image utilities using sharp.
3
+ * Replaces macOS-only sips calls.
4
+ */
5
+ import sharp from 'sharp';
6
+ import { writeFileSync } from 'fs';
7
+
8
+ /**
9
+ * Get pixel dimensions of an image.
10
+ * @param {string|Buffer} input - file path or buffer
11
+ * @returns {Promise<{width: number, height: number}>}
12
+ */
13
+ export async function getImageDimensions(input) {
14
+ const meta = await sharp(input).metadata();
15
+ return { width: meta.width ?? 0, height: meta.height ?? 0 };
16
+ }
17
+
18
+ /**
19
+ * Generate a thumbnail (~320px wide) and write to a temp file.
20
+ * @param {string|Buffer} input - file path or buffer
21
+ * @param {string} outPath - destination file path
22
+ * @returns {Promise<void>}
23
+ */
24
+ export async function generateThumbnail(input, outPath) {
25
+ await sharp(input)
26
+ .resize(320, null, { withoutEnlargement: true })
27
+ .png()
28
+ .toFile(outPath);
29
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Node ID formatting, tree walking, override builders.
3
+ */
4
+
5
+ /** Format a node's guid as "sessionID:localID" */
6
+ export function nid(node) {
7
+ if (!node?.guid) return null;
8
+ return `${node.guid.sessionID}:${node.guid.localID}`;
9
+ }
10
+
11
+ /** Parse "57:48" → { sessionID: 57, localID: 48 } */
12
+ export function parseId(str) {
13
+ const [s, l] = str.split(':').map(Number);
14
+ return { sessionID: s, localID: l };
15
+ }
16
+
17
+ /** Shorthand for { sessionID, localID } */
18
+ export function makeGuid(sessionID, localID) {
19
+ return { sessionID, localID };
20
+ }
21
+
22
+ /**
23
+ * Build a text override for symbolOverrides.
24
+ * Empty string is replaced with ' ' (space) — empty crashes Figma.
25
+ */
26
+ export function ov(key, text) {
27
+ const chars = (text === '' || text == null) ? ' ' : text;
28
+ return { guidPath: { guids: [key] }, textData: { characters: chars } };
29
+ }
30
+
31
+ /**
32
+ * Build a nested text override (e.g., quote inside paraGrid).
33
+ * guidPath has 2 guids: [instanceKey, textKey].
34
+ */
35
+ export function nestedOv(instKey, textKey, text) {
36
+ const chars = (text === '' || text == null) ? ' ' : text;
37
+ return { guidPath: { guids: [instKey, textKey] }, textData: { characters: chars } };
38
+ }
39
+
40
+ /** Mark a node as REMOVED (never delete from nodeChanges array). */
41
+ export function removeNode(node) {
42
+ node.phase = 'REMOVED';
43
+ delete node.prototypeInteractions;
44
+ }
45
+
46
+ /** Position character for sibling ordering in parentIndex. */
47
+ export function positionChar(index) {
48
+ return String.fromCharCode(0x21 + index); // '!' = 0, '"' = 1, etc.
49
+ }
@@ -0,0 +1,233 @@
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
+ `[openfig] 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
+ `[openfig] 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
+ // Avenir Next — extracted from macOS TTC, family names patched to "Avenir Next"
113
+ ...['regular', 'medium', 'demibold', 'bold', 'italic', 'medium-italic', 'demibold-italic', 'bold-italic'].flatMap(s => {
114
+ const buf = tryFont(join(__dirname, `fonts/avenir-next-${s}.ttf`));
115
+ return buf ? [buf] : [];
116
+ }),
117
+ ];
118
+
119
+ /**
120
+ * Register an additional font for rendering.
121
+ * Can be called at any time — takes effect on the next render call.
122
+ * @param {string|Buffer|Uint8Array} source File path or raw buffer.
123
+ */
124
+ export function registerFont(source) {
125
+ const buf = typeof source === 'string'
126
+ ? readFileSync(source)
127
+ : Buffer.isBuffer(source) ? source : Buffer.from(source);
128
+ fontBuffers.push(buf);
129
+ }
130
+
131
+ /**
132
+ * Register all fonts in a directory (recursively scans .ttf/.otf/.woff/.woff2).
133
+ * Call this before rendering if slides use custom fonts.
134
+ * @param {string} dir Directory path to scan.
135
+ */
136
+ export function registerFontDir(dir) {
137
+ const scan = (d) => {
138
+ for (const entry of readdirSync(d)) {
139
+ const full = join(d, entry);
140
+ if (statSync(full).isDirectory()) { scan(full); continue; }
141
+ if (/\.(ttf|otf|woff2?)$/i.test(entry)) registerFont(full);
142
+ }
143
+ };
144
+ scan(dir);
145
+ }
146
+
147
+ // ── WASM init (lazy, once) ────────────────────────────────────────────────────
148
+
149
+ let wasmReady = false;
150
+ let wasmInitPromise = null;
151
+
152
+ async function ensureWasm() {
153
+ if (wasmReady) return;
154
+ if (!wasmInitPromise) {
155
+ wasmInitPromise = initWasm(readFileSync(WASM_PATH)).then(() => {
156
+ wasmReady = true;
157
+ });
158
+ }
159
+ await wasmInitPromise;
160
+ }
161
+
162
+ // ── Core render ───────────────────────────────────────────────────────────────
163
+
164
+ const SLIDE_W = 1920;
165
+ const SLIDE_H = 1080;
166
+
167
+ const DEFAULT_OPTS = {
168
+ scale: 1, // 1 = 1920×1080, 0.5 = 960×540 — capped at 1
169
+ background: '#ffffff',
170
+ };
171
+
172
+ /**
173
+ * Resolve scale from opts. Accepts:
174
+ * scale (float) — direct multiplier, capped at 1
175
+ * width (px) — fit to width, preserving aspect ratio
176
+ * height (px) — fit to height, preserving aspect ratio
177
+ * Never upscales beyond native 1920×1080.
178
+ */
179
+ function resolveScale(opts) {
180
+ if (opts.width) return opts.width / SLIDE_W;
181
+ if (opts.height) return opts.height / SLIDE_H;
182
+ return opts.scale ?? 1;
183
+ }
184
+
185
+ /**
186
+ * Render a single SVG string to PNG.
187
+ * @param {string} svg
188
+ * @param {object} opts
189
+ * @returns {Promise<Uint8Array>} PNG bytes
190
+ */
191
+ export async function svgToPng(svg, opts = {}) {
192
+ await ensureWasm();
193
+ const { background } = { ...DEFAULT_OPTS, ...opts };
194
+ const scale = resolveScale(opts);
195
+
196
+ const resvg = new Resvg(svg, {
197
+ background,
198
+ fitTo: scale !== 1 ? { mode: 'zoom', value: scale } : { mode: 'original' },
199
+ font: {
200
+ fontBuffers: fontBuffers.map(b => new Uint8Array(b)),
201
+ loadSystemFonts: false,
202
+ sansSerifFamily: 'Inter',
203
+ defaultFontFamily: 'Inter',
204
+ },
205
+ });
206
+
207
+ const rendered = resvg.render();
208
+ const png = rendered.asPng();
209
+ rendered.free();
210
+ resvg.free();
211
+ return png;
212
+ }
213
+
214
+ /**
215
+ * Render all active slides in a deck to PNG.
216
+ * @param {import('../fig-deck.mjs').FigDeck} deck
217
+ * @param {object} opts
218
+ * @param {number} [opts.scale=1] Zoom factor (e.g. 0.5 for thumbnails)
219
+ * @returns {Promise<Array<{index: number, slideId: string, png: Uint8Array}>>}
220
+ */
221
+ export async function renderDeck(deck, opts = {}) {
222
+ const slides = deck.getActiveSlides();
223
+ const results = [];
224
+ for (let i = 0; i < slides.length; i++) {
225
+ const slide = deck.getSlide(i + 1);
226
+ const svg = slideToSvg(deck, slide);
227
+ const png = await svgToPng(svg, opts);
228
+ results.push({ index: i, slideId: slide.guid
229
+ ? `${slide.guid.sessionID}:${slide.guid.localID}`
230
+ : String(i), png });
231
+ }
232
+ return results;
233
+ }
@@ -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
+ }