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
package/mcp-server.mjs
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* OpenFig MCP Server — exposes deck manipulation as tools for Claude Cowork / Claude Code.
|
|
4
|
+
*/
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import packageJson from './package.json' with { type: 'json' };
|
|
9
|
+
import { FigDeck } from './lib/core/fig-deck.mjs';
|
|
10
|
+
import { Deck } from './lib/slides/api.mjs';
|
|
11
|
+
import { slideToSvg } from './lib/rasterizer/svg-builder.mjs';
|
|
12
|
+
import { svgToPng } from './lib/rasterizer/deck-rasterizer.mjs';
|
|
13
|
+
import { resolveFonts } from './lib/rasterizer/font-resolver.mjs';
|
|
14
|
+
import {
|
|
15
|
+
annotateTemplateLayout,
|
|
16
|
+
createDraftTemplate,
|
|
17
|
+
createFromTemplate,
|
|
18
|
+
listTemplateLayouts,
|
|
19
|
+
publishTemplateDraft,
|
|
20
|
+
} from './lib/slides/template-deck.mjs';
|
|
21
|
+
import { nid, ov, removeNode } from './lib/core/node-helpers.mjs';
|
|
22
|
+
import { imageOv, hashToHex } from './lib/core/image-helpers.mjs';
|
|
23
|
+
import { deepClone } from './lib/core/deep-clone.mjs';
|
|
24
|
+
|
|
25
|
+
const server = new McpServer({
|
|
26
|
+
name: 'openfig',
|
|
27
|
+
version: packageJson.version,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── inspect ─────────────────────────────────────────────────────────────
|
|
31
|
+
server.tool(
|
|
32
|
+
'openfig_inspect',
|
|
33
|
+
'Show the node hierarchy tree of a Figma .deck or .fig file',
|
|
34
|
+
{ path: z.string().describe('Path to .deck or .fig file') },
|
|
35
|
+
async ({ path }) => {
|
|
36
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
37
|
+
const lines = [];
|
|
38
|
+
const doc = deck.message.nodeChanges.find(n => n.type === 'DOCUMENT');
|
|
39
|
+
if (!doc) return { content: [{ type: 'text', text: 'No DOCUMENT node found' }] };
|
|
40
|
+
|
|
41
|
+
function walk(nodeId, indent) {
|
|
42
|
+
const node = deck.getNode(nodeId);
|
|
43
|
+
if (!node || node.phase === 'REMOVED') return;
|
|
44
|
+
const id = nid(node);
|
|
45
|
+
const name = node.name || '';
|
|
46
|
+
const type = node.type || '?';
|
|
47
|
+
lines.push(`${' '.repeat(indent)}${type} ${id} "${name}"`);
|
|
48
|
+
const children = deck.childrenMap.get(nodeId) || [];
|
|
49
|
+
for (const child of children) walk(nid(child), indent + 2);
|
|
50
|
+
}
|
|
51
|
+
walk(nid(doc), 0);
|
|
52
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// ── list-text ───────────────────────────────────────────────────────────
|
|
57
|
+
server.tool(
|
|
58
|
+
'openfig_list_text',
|
|
59
|
+
'List visible text and image content per slide in a .deck file, including direct slide nodes and instance overrides.',
|
|
60
|
+
{ path: z.string().describe('Path to .deck or .fig file') },
|
|
61
|
+
async ({ path }) => {
|
|
62
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
63
|
+
const lines = [];
|
|
64
|
+
const slides = deck.getSlides();
|
|
65
|
+
for (const slide of slides) {
|
|
66
|
+
if (slide.phase === 'REMOVED') continue;
|
|
67
|
+
const id = nid(slide);
|
|
68
|
+
lines.push(`\n── Slide ${id} "${slide.name || ''}" ──`);
|
|
69
|
+
|
|
70
|
+
const directLines = [];
|
|
71
|
+
deck.walkTree(id, (node, depth) => {
|
|
72
|
+
if (depth === 0 || node.phase === 'REMOVED') return;
|
|
73
|
+
if (node.type === 'TEXT' && node.textData?.characters) {
|
|
74
|
+
directLines.push(` [text-node] ${nid(node)} "${node.name || ''}": ${node.textData.characters.substring(0, 120)}`);
|
|
75
|
+
}
|
|
76
|
+
if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
|
|
77
|
+
for (const override of node.nodeGenerationData.overrides) {
|
|
78
|
+
if (override.textData?.characters) {
|
|
79
|
+
directLines.push(` [shape-text] ${nid(node)} "${node.name || ''}": ${override.textData.characters.substring(0, 120)}`);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const imageFill = node.fillPaints?.find(p => p.type === 'IMAGE' && p.image?.hash);
|
|
85
|
+
if (imageFill) {
|
|
86
|
+
directLines.push(` [image-node] ${nid(node)} "${node.name || ''}": ${hashToHex(imageFill.image.hash)}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
lines.push(...directLines);
|
|
91
|
+
|
|
92
|
+
const inst = deck.getSlideInstance(id);
|
|
93
|
+
if (!inst?.symbolData?.symbolOverrides) continue;
|
|
94
|
+
for (const ov of inst.symbolData.symbolOverrides) {
|
|
95
|
+
const key = ov.guidPath?.guids?.[0];
|
|
96
|
+
const keyStr = key ? `${key.sessionID}:${key.localID}` : '?';
|
|
97
|
+
if (ov.textData?.characters) {
|
|
98
|
+
lines.push(` [text-override] ${keyStr}: ${ov.textData.characters.substring(0, 120)}`);
|
|
99
|
+
}
|
|
100
|
+
if (ov.fillPaints?.length) {
|
|
101
|
+
for (const p of ov.fillPaints) {
|
|
102
|
+
if (p.image?.hash) {
|
|
103
|
+
lines.push(` [image-override] ${keyStr}: ${hashToHex(p.image.hash)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { content: [{ type: 'text', text: lines.join('\n') || 'No slides found' }] };
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// ── list-overrides ──────────────────────────────────────────────────────
|
|
114
|
+
server.tool(
|
|
115
|
+
'openfig_list_overrides',
|
|
116
|
+
'List editable override keys for each symbol in the deck',
|
|
117
|
+
{ path: z.string().describe('Path to .deck or .fig file') },
|
|
118
|
+
async ({ path }) => {
|
|
119
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
120
|
+
const lines = [];
|
|
121
|
+
const symbols = deck.getSymbols();
|
|
122
|
+
for (const sym of symbols) {
|
|
123
|
+
const id = nid(sym);
|
|
124
|
+
lines.push(`\nSymbol ${id} "${sym.name || ''}"`);
|
|
125
|
+
const children = deck.childrenMap.get(id) || [];
|
|
126
|
+
function walkChildren(nodeId, depth) {
|
|
127
|
+
const node = deck.getNode(nodeId);
|
|
128
|
+
if (!node || node.phase === 'REMOVED') return;
|
|
129
|
+
const key = node.overrideKey ? `${node.overrideKey.sessionID}:${node.overrideKey.localID}` : null;
|
|
130
|
+
const type = node.type || '?';
|
|
131
|
+
const name = node.name || '';
|
|
132
|
+
if (key && (type === 'TEXT' || node.fillPaints?.some(p => p.type === 'IMAGE'))) {
|
|
133
|
+
lines.push(` ${' '.repeat(depth)}${type} ${key} "${name}"`);
|
|
134
|
+
}
|
|
135
|
+
const kids = deck.childrenMap.get(nid(node)) || [];
|
|
136
|
+
for (const kid of kids) walkChildren(nid(kid), depth + 1);
|
|
137
|
+
}
|
|
138
|
+
for (const child of children) walkChildren(nid(child), 0);
|
|
139
|
+
}
|
|
140
|
+
return { content: [{ type: 'text', text: lines.join('\n') || 'No symbols found' }] };
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// ── update-text ─────────────────────────────────────────────────────────
|
|
145
|
+
server.tool(
|
|
146
|
+
'openfig_update_text',
|
|
147
|
+
'Apply text overrides to a slide instance. Pass key=value pairs.',
|
|
148
|
+
{
|
|
149
|
+
path: z.string().describe('Path to .deck file'),
|
|
150
|
+
output: z.string().describe('Output .deck path'),
|
|
151
|
+
instanceId: z.string().describe('Instance node ID (e.g. "1:1631")'),
|
|
152
|
+
overrides: z.record(z.string()).describe('Object of overrideKey: text pairs, e.g. {"75:127": "Hello"}'),
|
|
153
|
+
},
|
|
154
|
+
async ({ path, output, instanceId, overrides }) => {
|
|
155
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
156
|
+
const inst = deck.getNode(instanceId);
|
|
157
|
+
if (!inst) return { content: [{ type: 'text', text: `Instance ${instanceId} not found` }] };
|
|
158
|
+
if (!inst.symbolData) inst.symbolData = { symbolOverrides: [] };
|
|
159
|
+
if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
|
|
160
|
+
|
|
161
|
+
for (const [key, text] of Object.entries(overrides)) {
|
|
162
|
+
const [s, l] = key.split(':').map(Number);
|
|
163
|
+
const nextOverride = ov({ sessionID: s, localID: l }, text);
|
|
164
|
+
const existingIdx = inst.symbolData.symbolOverrides.findIndex(entry =>
|
|
165
|
+
entry.guidPath?.guids?.length >= 1 &&
|
|
166
|
+
entry.guidPath.guids[0].sessionID === s &&
|
|
167
|
+
entry.guidPath.guids[0].localID === l &&
|
|
168
|
+
entry.textData
|
|
169
|
+
);
|
|
170
|
+
if (existingIdx >= 0) {
|
|
171
|
+
inst.symbolData.symbolOverrides.splice(existingIdx, 1, nextOverride);
|
|
172
|
+
} else {
|
|
173
|
+
inst.symbolData.symbolOverrides.push(nextOverride);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const bytes = await deck.saveDeck(output);
|
|
178
|
+
return { content: [{ type: 'text', text: `Saved ${output} (${bytes} bytes), ${Object.keys(overrides).length} text overrides applied` }] };
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// ── insert-image ────────────────────────────────────────────────────────
|
|
183
|
+
server.tool(
|
|
184
|
+
'openfig_insert_image',
|
|
185
|
+
'Apply an image fill override to a slide instance',
|
|
186
|
+
{
|
|
187
|
+
path: z.string().describe('Path to .deck file'),
|
|
188
|
+
output: z.string().describe('Output .deck path'),
|
|
189
|
+
instanceId: z.string().describe('Instance node ID'),
|
|
190
|
+
targetKey: z.string().describe('Override key for the image rectangle (e.g. "75:126")'),
|
|
191
|
+
imageHash: z.string().describe('40-char hex SHA-1 hash of the full image'),
|
|
192
|
+
thumbHash: z.string().describe('40-char hex SHA-1 hash of the thumbnail'),
|
|
193
|
+
width: z.number().describe('Image width in pixels'),
|
|
194
|
+
height: z.number().describe('Image height in pixels'),
|
|
195
|
+
imagesDir: z.string().optional().describe('Path to images directory to include in deck'),
|
|
196
|
+
},
|
|
197
|
+
async ({ path, output, instanceId, targetKey, imageHash, thumbHash, width, height, imagesDir }) => {
|
|
198
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
199
|
+
const inst = deck.getNode(instanceId);
|
|
200
|
+
if (!inst) return { content: [{ type: 'text', text: `Instance ${instanceId} not found` }] };
|
|
201
|
+
if (!inst.symbolData) inst.symbolData = { symbolOverrides: [] };
|
|
202
|
+
if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
|
|
203
|
+
|
|
204
|
+
const [s, l] = targetKey.split(':').map(Number);
|
|
205
|
+
const nextOverride = imageOv({ sessionID: s, localID: l }, imageHash, thumbHash, width, height);
|
|
206
|
+
const existingIdx = inst.symbolData.symbolOverrides.findIndex(entry =>
|
|
207
|
+
entry.guidPath?.guids?.length >= 1 &&
|
|
208
|
+
entry.guidPath.guids[0].sessionID === s &&
|
|
209
|
+
entry.guidPath.guids[0].localID === l &&
|
|
210
|
+
entry.fillPaints
|
|
211
|
+
);
|
|
212
|
+
if (existingIdx >= 0) {
|
|
213
|
+
inst.symbolData.symbolOverrides.splice(existingIdx, 1, nextOverride);
|
|
214
|
+
} else {
|
|
215
|
+
inst.symbolData.symbolOverrides.push(nextOverride);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const opts = imagesDir ? { imagesDir } : {};
|
|
219
|
+
const bytes = await deck.saveDeck(output, opts);
|
|
220
|
+
return { content: [{ type: 'text', text: `Saved ${output} (${bytes} bytes), image override applied` }] };
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// ── clone-slide ─────────────────────────────────────────────────────────
|
|
225
|
+
server.tool(
|
|
226
|
+
'openfig_clone_slide',
|
|
227
|
+
'Duplicate a slide from the deck',
|
|
228
|
+
{
|
|
229
|
+
path: z.string().describe('Path to .deck file'),
|
|
230
|
+
output: z.string().describe('Output .deck path'),
|
|
231
|
+
slideId: z.string().describe('Source slide node ID to clone'),
|
|
232
|
+
},
|
|
233
|
+
async ({ path, output, slideId }) => {
|
|
234
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
235
|
+
const slide = deck.getNode(slideId);
|
|
236
|
+
if (!slide) return { content: [{ type: 'text', text: `Slide ${slideId} not found` }] };
|
|
237
|
+
|
|
238
|
+
let nextId = deck.maxLocalID() + 1;
|
|
239
|
+
const newSlide = deepClone(slide);
|
|
240
|
+
const newSlideId = nextId++;
|
|
241
|
+
newSlide.guid = { sessionID: 1, localID: newSlideId };
|
|
242
|
+
newSlide.phase = 'CREATED';
|
|
243
|
+
delete newSlide.prototypeInteractions;
|
|
244
|
+
delete newSlide.slideThumbnailHash;
|
|
245
|
+
delete newSlide.editInfo;
|
|
246
|
+
|
|
247
|
+
const inst = deck.getSlideInstance(slideId);
|
|
248
|
+
if (inst) {
|
|
249
|
+
const newInst = deepClone(inst);
|
|
250
|
+
newInst.guid = { sessionID: 1, localID: nextId++ };
|
|
251
|
+
newInst.phase = 'CREATED';
|
|
252
|
+
newInst.parentIndex = { guid: { sessionID: 1, localID: newSlideId }, position: '!' };
|
|
253
|
+
delete newInst.derivedSymbolData;
|
|
254
|
+
delete newInst.derivedSymbolDataLayoutVersion;
|
|
255
|
+
delete newInst.editInfo;
|
|
256
|
+
deck.message.nodeChanges.push(newInst);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
deck.message.nodeChanges.push(newSlide);
|
|
260
|
+
deck.rebuildMaps();
|
|
261
|
+
|
|
262
|
+
const bytes = await deck.saveDeck(output);
|
|
263
|
+
return { content: [{ type: 'text', text: `Cloned slide ${slideId} → 1:${newSlideId}. Saved ${output} (${bytes} bytes)` }] };
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// ── remove-slide ────────────────────────────────────────────────────────
|
|
268
|
+
server.tool(
|
|
269
|
+
'openfig_remove_slide',
|
|
270
|
+
'Mark a slide as REMOVED',
|
|
271
|
+
{
|
|
272
|
+
path: z.string().describe('Path to .deck file'),
|
|
273
|
+
output: z.string().describe('Output .deck path'),
|
|
274
|
+
slideId: z.string().describe('Slide node ID to remove'),
|
|
275
|
+
},
|
|
276
|
+
async ({ path, output, slideId }) => {
|
|
277
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
278
|
+
const slide = deck.getNode(slideId);
|
|
279
|
+
if (!slide) return { content: [{ type: 'text', text: `Slide ${slideId} not found` }] };
|
|
280
|
+
removeNode(slide);
|
|
281
|
+
const inst = deck.getSlideInstance(slideId);
|
|
282
|
+
if (inst) removeNode(inst);
|
|
283
|
+
|
|
284
|
+
const bytes = await deck.saveDeck(output);
|
|
285
|
+
return { content: [{ type: 'text', text: `Removed slide ${slideId}. Saved ${output} (${bytes} bytes)` }] };
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// ── roundtrip ───────────────────────────────────────────────────────────
|
|
290
|
+
server.tool(
|
|
291
|
+
'openfig_roundtrip',
|
|
292
|
+
'Decode and re-encode a .deck file to validate the pipeline',
|
|
293
|
+
{
|
|
294
|
+
path: z.string().describe('Path to input .deck file'),
|
|
295
|
+
output: z.string().describe('Path to output .deck file'),
|
|
296
|
+
},
|
|
297
|
+
async ({ path, output }) => {
|
|
298
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
299
|
+
const bytes = await deck.saveDeck(output);
|
|
300
|
+
return { content: [{ type: 'text', text: `Roundtrip complete: ${output} (${bytes} bytes)` }] };
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// ── create-deck ─────────────────────────────────────────────────────────
|
|
305
|
+
const THEMES = {
|
|
306
|
+
midnight: { dark: 'Black', light: 'White', accent: { r: 0.792, g: 0.863, b: 0.988 }, textDark: 'White', textLight: 'Black' },
|
|
307
|
+
ocean: { dark: 'Blue', light: 'Pale Blue', accent: { r: 0.129, g: 0.161, b: 0.361 }, textDark: 'White', textLight: 'Black' },
|
|
308
|
+
forest: { dark: 'Green', light: 'Pale Green', accent: { r: 0.592, g: 0.737, b: 0.384 }, textDark: 'White', textLight: 'Black' },
|
|
309
|
+
coral: { dark: 'Persimmon', light: 'Pale Persimmon', accent: { r: 0.184, g: 0.235, b: 0.494 }, textDark: 'White', textLight: 'Black' },
|
|
310
|
+
terracotta: { dark: 'Persimmon', light: 'Pale Persimmon', accent: { r: 0.906, g: 0.910, b: 0.820 }, textDark: 'White', textLight: 'Black' },
|
|
311
|
+
minimal: { dark: 'Black', light: 'White', accent: { r: 0.212, g: 0.271, b: 0.310 }, textDark: 'White', textLight: 'Black' },
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const SlideSchema = z.object({
|
|
315
|
+
type: z.enum(['title', 'bullets', 'two-column', 'stat', 'image-full', 'closing']),
|
|
316
|
+
title: z.string().optional(),
|
|
317
|
+
subtitle: z.string().optional(),
|
|
318
|
+
body: z.string().optional(),
|
|
319
|
+
bullets: z.array(z.string()).optional(),
|
|
320
|
+
stat: z.string().optional(),
|
|
321
|
+
caption: z.string().optional(),
|
|
322
|
+
image: z.string().optional().describe('Absolute path to image file'),
|
|
323
|
+
leftText: z.string().optional(),
|
|
324
|
+
rightText: z.string().optional(),
|
|
325
|
+
background: z.string().optional().describe('Override background (named color)'),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
server.tool(
|
|
329
|
+
'openfig_create_deck',
|
|
330
|
+
'Create a new Figma Slides .deck file from a structured description. No npm install needed — runs directly in the MCP server.',
|
|
331
|
+
{
|
|
332
|
+
output: z.string().describe('Output path for the .deck file, e.g. /tmp/my-deck.deck'),
|
|
333
|
+
title: z.string().describe('Deck title'),
|
|
334
|
+
theme: z.string().optional().describe('Theme: midnight | ocean | forest | coral | terracotta | minimal (default: midnight)'),
|
|
335
|
+
slides: z.array(SlideSchema).describe('Slides to create'),
|
|
336
|
+
},
|
|
337
|
+
async ({ output, title, theme, slides }) => {
|
|
338
|
+
const t = THEMES[theme ?? 'midnight'] ?? THEMES.midnight;
|
|
339
|
+
const deck = await Deck.create(title);
|
|
340
|
+
|
|
341
|
+
for (const s of slides) {
|
|
342
|
+
const slide = deck.addBlankSlide();
|
|
343
|
+
const isDark = ['title', 'closing', 'stat', 'image-full'].includes(s.type);
|
|
344
|
+
const bg = s.background ?? (isDark ? t.dark : t.light);
|
|
345
|
+
const fg = isDark ? t.textDark : t.textLight;
|
|
346
|
+
slide.setBackground(bg);
|
|
347
|
+
|
|
348
|
+
if (s.type === 'title' || s.type === 'closing') {
|
|
349
|
+
slide.addRectangle(0, 0, 1920, 8, { fill: t.accent });
|
|
350
|
+
if (s.title) slide.addText(s.title, { style: 'Title', color: fg, x: 80, y: 360, width: 1760, align: 'CENTER' });
|
|
351
|
+
if (s.subtitle) slide.addText(s.subtitle, { style: 'Body 1', color: fg, x: 80, y: 540, width: 1760, align: 'CENTER' });
|
|
352
|
+
|
|
353
|
+
} else if (s.type === 'bullets') {
|
|
354
|
+
slide.addRectangle(0, 0, 1920, 8, { fill: t.accent });
|
|
355
|
+
if (s.title) slide.addText(s.title, { style: 'Header 2', color: fg, x: 80, y: 80, width: 1760, align: 'LEFT' });
|
|
356
|
+
const items = s.bullets ?? (s.body ? s.body.split('\n') : []);
|
|
357
|
+
let y = 240;
|
|
358
|
+
for (const item of items) {
|
|
359
|
+
slide.addRectangle(80, y + 10, 12, 12, { fill: t.accent });
|
|
360
|
+
slide.addText(item, { style: 'Body 1', color: fg, x: 116, y, width: 1724, align: 'LEFT' });
|
|
361
|
+
y += 80;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
} else if (s.type === 'two-column') {
|
|
365
|
+
slide.addRectangle(0, 0, 1920, 8, { fill: t.accent });
|
|
366
|
+
if (s.title) slide.addText(s.title, { style: 'Header 2', color: fg, x: 80, y: 80, width: 1760, align: 'LEFT' });
|
|
367
|
+
slide.addRectangle(960, 200, 4, 800, { fill: t.accent });
|
|
368
|
+
if (s.leftText) slide.addText(s.leftText, { style: 'Body 1', color: fg, x: 80, y: 240, width: 840, align: 'LEFT' });
|
|
369
|
+
if (s.rightText) slide.addText(s.rightText, { style: 'Body 1', color: fg, x: 1004, y: 240, width: 836, align: 'LEFT' });
|
|
370
|
+
if (s.image) await slide.addImage(s.image, { x: 1004, y: 200, width: 836, height: 800 });
|
|
371
|
+
|
|
372
|
+
} else if (s.type === 'stat') {
|
|
373
|
+
slide.addRectangle(0, 0, 1920, 8, { fill: t.accent });
|
|
374
|
+
if (s.title) slide.addText(s.title, { style: 'Header 2', color: fg, x: 80, y: 80, width: 1760, align: 'LEFT' });
|
|
375
|
+
if (s.stat) slide.addText(s.stat, { style: 'Title', color: fg, x: 80, y: 300, width: 1760, align: 'CENTER' });
|
|
376
|
+
if (s.caption) slide.addText(s.caption, { style: 'Body 1', color: fg, x: 80, y: 720, width: 1760, align: 'CENTER' });
|
|
377
|
+
|
|
378
|
+
} else if (s.type === 'image-full') {
|
|
379
|
+
if (s.image) await slide.addImage(s.image, { x: 0, y: 0, width: 1920, height: 1080 });
|
|
380
|
+
if (s.title || s.body) {
|
|
381
|
+
slide.addRectangle(0, 680, 1920, 400, { fill: { r: 0, g: 0, b: 0 }, opacity: 0.7 });
|
|
382
|
+
if (s.title) slide.addText(s.title, { style: 'Header 1', color: 'White', x: 80, y: 720, width: 1760, align: 'LEFT' });
|
|
383
|
+
if (s.body) slide.addText(s.body, { style: 'Body 1', color: 'White', x: 80, y: 880, width: 1760, align: 'LEFT' });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await deck.save(output);
|
|
389
|
+
return { content: [{ type: 'text', text: `Created ${output} — ${slides.length} slides. Open in Figma Desktop.` }] };
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// ── openfig_list_template_layouts ────────────────────────────────────────
|
|
394
|
+
server.tool(
|
|
395
|
+
'openfig_create_template_draft',
|
|
396
|
+
'Create a new draft template deck. Draft templates are normal slide decks; later annotate slots and publish-wrap them into module-backed layouts.',
|
|
397
|
+
{
|
|
398
|
+
output: z.string().describe('Output path for the draft template .deck file'),
|
|
399
|
+
title: z.string().describe('Template deck title'),
|
|
400
|
+
layouts: z.array(z.string()).optional().describe('Optional ordered list of layout names to create, e.g. ["cover", "agenda", "section"]'),
|
|
401
|
+
},
|
|
402
|
+
async ({ output, title, layouts }) => {
|
|
403
|
+
const bytes = await createDraftTemplate(output, { title, layouts });
|
|
404
|
+
return { content: [{ type: 'text', text: `Created draft template ${output} (${bytes} bytes). Use openfig_annotate_template_layout to mark layout and slot names.` }] };
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
server.tool(
|
|
409
|
+
'openfig_annotate_template_layout',
|
|
410
|
+
'Add explicit layout and slot metadata to a draft or published template. Use openfig_inspect or openfig_list_template_layouts first to get slide and node IDs.',
|
|
411
|
+
{
|
|
412
|
+
path: z.string().describe('Path to the source .deck file'),
|
|
413
|
+
output: z.string().describe('Output path for the updated .deck file'),
|
|
414
|
+
slideId: z.string().describe('Slide node ID to annotate'),
|
|
415
|
+
layoutName: z.string().optional().describe('Logical layout name without the layout: prefix, e.g. "cover"'),
|
|
416
|
+
textSlots: z.record(z.string()).optional().describe('Map of nodeId -> text slot name, e.g. {"1:120": "title"}'),
|
|
417
|
+
imageSlots: z.record(z.string()).optional().describe('Map of nodeId -> image slot name, e.g. {"1:144": "hero_image"}'),
|
|
418
|
+
fixedImages: z.record(z.string()).optional().describe('Map of nodeId -> fixed image label for decorative/sample content'),
|
|
419
|
+
},
|
|
420
|
+
async ({ path, output, slideId, layoutName, textSlots, imageSlots, fixedImages }) => {
|
|
421
|
+
const bytes = await annotateTemplateLayout(path, output, { slideId, layoutName, textSlots, imageSlots, fixedImages });
|
|
422
|
+
return { content: [{ type: 'text', text: `Annotated slide ${slideId}. Saved ${output} (${bytes} bytes).` }] };
|
|
423
|
+
}
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
server.tool(
|
|
427
|
+
'openfig_publish_template_draft',
|
|
428
|
+
'Wrap draft template slides in publish-like MODULE nodes while preserving the slide subtree and internal canvas assets.',
|
|
429
|
+
{
|
|
430
|
+
path: z.string().describe('Path to the draft template .deck file'),
|
|
431
|
+
output: z.string().describe('Output path for the wrapped .deck file'),
|
|
432
|
+
slideIds: z.array(z.string()).optional().describe('Optional list of draft slide IDs to wrap. Defaults to every draft layout on the main canvas.'),
|
|
433
|
+
},
|
|
434
|
+
async ({ path, output, slideIds }) => {
|
|
435
|
+
const bytes = await publishTemplateDraft(path, output, { slideIds });
|
|
436
|
+
return { content: [{ type: 'text', text: `Publish-wrapped draft template to ${output} (${bytes} bytes).` }] };
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
server.tool(
|
|
441
|
+
'openfig_list_template_layouts',
|
|
442
|
+
'Inspect a template or draft template .deck file and return a layout library catalog with explicit text/image slot metadata. Use this to inventory candidate layouts, classify what each layout is for, and choose a subset before calling openfig_create_from_template. Do not edit, remove, or reorder the source template as a way to build the output deck.',
|
|
443
|
+
{
|
|
444
|
+
template: z.string().describe('Path to the .deck template file'),
|
|
445
|
+
},
|
|
446
|
+
async ({ template }) => {
|
|
447
|
+
const layouts = await listTemplateLayouts(template);
|
|
448
|
+
const lines = layouts.map(l => {
|
|
449
|
+
const textSlots = l.textFields.map(f => ` - ${f.name} (${f.nodeId}, ${f.source}): "${f.preview}"`).join('\n');
|
|
450
|
+
const imageSlots = l.imagePlaceholders.map(f => ` - ${f.name} (${f.nodeId}, ${f.source}, ${f.width}x${f.height})${f.hasCurrentImage ? ' [image]' : ''}`).join('\n');
|
|
451
|
+
return [
|
|
452
|
+
`Layout "${l.name}" [${l.slideId}]`,
|
|
453
|
+
` state: ${l.state}${l.moduleId ? `, module ${l.moduleId}` : ''}, row ${l.rowId}`,
|
|
454
|
+
` explicit slots: ${l.hasExplicitSlotMetadata ? 'yes' : 'no'}`,
|
|
455
|
+
textSlots ? ` text slots:\n${textSlots}` : ' text slots: (none)',
|
|
456
|
+
imageSlots ? ` image slots:\n${imageSlots}` : ' image slots: (none)',
|
|
457
|
+
].join('\n');
|
|
458
|
+
});
|
|
459
|
+
return { content: [{ type: 'text', text: lines.join('\n\n') }] };
|
|
460
|
+
}
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// ── openfig_create_from_template ─────────────────────────────────────────
|
|
464
|
+
server.tool(
|
|
465
|
+
'openfig_create_from_template',
|
|
466
|
+
'Create a new Figma Slides deck by selecting any ordered subset of layouts from a draft, published, or publish-like template .deck file and populating explicit text/image slots. The slides array defines the output order. This clones the chosen layouts into a new deck; do not remove, reorder, or mutate the source template to build the result. Works with flat-frame template slides as well as module-backed layouts, and preserves colors, fonts, internal assets, and special nodes.',
|
|
467
|
+
{
|
|
468
|
+
template: z.string().describe('Path to the source .deck template file'),
|
|
469
|
+
output: z.string().describe('Output path for the new .deck file (use /tmp/)'),
|
|
470
|
+
slides: z.array(z.object({
|
|
471
|
+
slideId: z.string().describe('Slide ID from openfig_list_template_layouts (e.g. "1:74")'),
|
|
472
|
+
text: z.record(z.string()).optional().describe('Map of text slot/name/nodeId -> value (e.g. { "title": "My Company" })'),
|
|
473
|
+
images: z.record(z.string()).optional().describe('Map of image slot/name/nodeId -> absolute image path (e.g. { "hero_image": "/tmp/photo.jpg" })'),
|
|
474
|
+
})).describe('Ordered subset of template layouts to include in the output deck. The array order becomes the output deck order, regardless of the template\'s original order.'),
|
|
475
|
+
},
|
|
476
|
+
async ({ template, output, slides }) => {
|
|
477
|
+
const bytes = await createFromTemplate(template, output, slides);
|
|
478
|
+
return { content: [{ type: 'text', text: `Created ${output} — ${slides.length} slides (${bytes} bytes). Open in Figma Desktop.` }] };
|
|
479
|
+
}
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
// ── render-slide ───────────────────────────────────────────────────────
|
|
483
|
+
server.tool(
|
|
484
|
+
'openfig_render_slide',
|
|
485
|
+
'Render a slide from a .deck file to an image. Without output path, returns inline WebP for visual QA. With output path, saves full PNG. Default is 1920×1080; use width (pixels) or scale (e.g. "50%") to resize.',
|
|
486
|
+
{
|
|
487
|
+
path: z.string().describe('Path to .deck file'),
|
|
488
|
+
slide: z.number().int().min(1).describe('Slide number (1-based)'),
|
|
489
|
+
output: z.string().optional().describe('Optional output path to save the PNG file (full resolution)'),
|
|
490
|
+
width: z.number().int().optional().describe('Output width in pixels (height scales proportionally)'),
|
|
491
|
+
scale: z.string().optional().describe('Scale as percentage, e.g. "50%" or "25%"'),
|
|
492
|
+
},
|
|
493
|
+
async ({ path, slide, output, width, scale }) => {
|
|
494
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
495
|
+
await resolveFonts(deck, { quiet: true });
|
|
496
|
+
const slides = deck.getActiveSlides();
|
|
497
|
+
|
|
498
|
+
if (slide > slides.length) {
|
|
499
|
+
return { content: [{ type: 'text', text: `Slide ${slide} does not exist — deck has ${slides.length} slides` }] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const svg = slideToSvg(deck, deck.getSlide(slide));
|
|
503
|
+
|
|
504
|
+
// Build render options
|
|
505
|
+
const renderOpts = {};
|
|
506
|
+
if (width) {
|
|
507
|
+
renderOpts.width = width;
|
|
508
|
+
} else if (scale) {
|
|
509
|
+
const pct = parseFloat(scale.replace('%', ''));
|
|
510
|
+
if (!isNaN(pct) && pct > 0) renderOpts.scale = pct / 100;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const png = await svgToPng(svg, renderOpts);
|
|
514
|
+
const buf = Buffer.from(png);
|
|
515
|
+
|
|
516
|
+
if (output) {
|
|
517
|
+
const { writeFileSync } = await import('fs');
|
|
518
|
+
writeFileSync(output, buf);
|
|
519
|
+
return { content: [{ type: 'text', text: `Rendered slide ${slide} → ${output} (${buf.length} bytes)` }] };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// For inline display: convert to WebP (much smaller) via sharp
|
|
523
|
+
// Default to width=800 if no size specified to stay under MCP 1MB limit
|
|
524
|
+
const sharp = (await import('sharp')).default;
|
|
525
|
+
let img = sharp(buf);
|
|
526
|
+
if (!width && !scale) img = img.resize(800);
|
|
527
|
+
const webp = await img.webp({ quality: 80 }).toBuffer();
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
content: [{
|
|
531
|
+
type: 'image',
|
|
532
|
+
data: webp.toString('base64'),
|
|
533
|
+
mimeType: 'image/webp',
|
|
534
|
+
}],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// ── Start server ────────────────────────────────────────────────────────
|
|
540
|
+
const transport = new StdioServerTransport();
|
|
541
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openfig-cli",
|
|
3
|
+
"version": "0.3.11",
|
|
4
|
+
"description": "OpenFig — Open-source tools for parsing and rendering Figma design files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"openfig": "bin/cli.mjs",
|
|
8
|
+
"openfig-mcp": "mcp-server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./lib/slides/api.mjs",
|
|
12
|
+
"./deck": "./lib/core/fig-deck.mjs",
|
|
13
|
+
"./node-helpers": "./lib/core/node-helpers.mjs",
|
|
14
|
+
"./image-helpers": "./lib/core/image-helpers.mjs",
|
|
15
|
+
"./deep-clone": "./lib/core/deep-clone.mjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/",
|
|
19
|
+
"mcp-server.mjs",
|
|
20
|
+
"manifest.json",
|
|
21
|
+
"lib/",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"start": "node bin/cli.mjs",
|
|
27
|
+
"pack": "node scripts/pack.mjs",
|
|
28
|
+
"release": "node scripts/release.mjs",
|
|
29
|
+
"validate:extension": "mcpb validate manifest.json",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/rcoenen/OpenFig.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/rcoenen/OpenFig",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/rcoenen/OpenFig/issues"
|
|
43
|
+
},
|
|
44
|
+
"author": "rcoenen",
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
47
|
+
"@resvg/resvg-wasm": "^2.6.2",
|
|
48
|
+
"archiver": "^7.0.1",
|
|
49
|
+
"fzstd": "^0.1.1",
|
|
50
|
+
"kiwi-schema": "^0.5.0",
|
|
51
|
+
"pako": "^2.1.0",
|
|
52
|
+
"sharp": "^0.34.5",
|
|
53
|
+
"ssim.js": "^3.5.0",
|
|
54
|
+
"zstd-codec": "^0.1.5"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@anthropic-ai/mcpb": "^2.1.2",
|
|
58
|
+
"@fontsource/darker-grotesque": "^5.2.8",
|
|
59
|
+
"@fontsource/inter": "^5.2.8",
|
|
60
|
+
"@fontsource/irish-grover": "^5.2.7",
|
|
61
|
+
"vitest": "^4.1.0"
|
|
62
|
+
},
|
|
63
|
+
"keywords": [
|
|
64
|
+
"figma",
|
|
65
|
+
"deck",
|
|
66
|
+
"fig",
|
|
67
|
+
"slides",
|
|
68
|
+
"presentation",
|
|
69
|
+
"kiwi",
|
|
70
|
+
"binary",
|
|
71
|
+
"cli"
|
|
72
|
+
],
|
|
73
|
+
"license": "MIT"
|
|
74
|
+
}
|