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
Binary file
@@ -0,0 +1,671 @@
1
+ /**
2
+ * template-deck — Inspect, author, wrap, and instantiate Figma Slides templates.
3
+ *
4
+ * Template workflows now cover two structural states:
5
+ * - Draft templates: SLIDE_ROW -> SLIDE -> ...
6
+ * - Published templates: SLIDE_ROW -> MODULE -> SLIDE -> ...
7
+ */
8
+ import { createHash } from 'crypto';
9
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'fs';
10
+ import { join, resolve } from 'path';
11
+ import { Deck } from './api.mjs';
12
+ import { deepClone } from '../core/deep-clone.mjs';
13
+ import { FigDeck } from '../core/fig-deck.mjs';
14
+ import { hexToHash } from '../core/image-helpers.mjs';
15
+ import { getImageDimensions, generateThumbnail } from '../core/image-utils.mjs';
16
+ import { nid, positionChar } from '../core/node-helpers.mjs';
17
+
18
+ export const LAYOUT_PREFIX = 'layout:';
19
+ export const TEXT_SLOT_PREFIX = 'slot:text:';
20
+ export const IMAGE_SLOT_PREFIX = 'slot:image:';
21
+ export const FIXED_IMAGE_PREFIX = 'fixed:image:';
22
+
23
+ const INTERNAL_CANVAS_NAME = 'Internal Only Canvas';
24
+ const MODULE_VERSION = '1:37';
25
+ const DEFAULT_ROW_GAP = 2160;
26
+
27
+ /**
28
+ * Inspect a template deck and return available layouts plus explicit slot metadata.
29
+ */
30
+ export async function listTemplateLayouts(templatePath, opts = {}) {
31
+ const deck = await FigDeck.fromDeckFile(templatePath);
32
+ return describeTemplateLayouts(deck, opts);
33
+ }
34
+
35
+ /**
36
+ * Create a new draft template deck from scratch.
37
+ */
38
+ export async function createDraftTemplate(outputPath, opts = {}) {
39
+ const title = opts.title ?? 'Untitled';
40
+ const layoutNames = Array.isArray(opts.layouts) && opts.layouts.length
41
+ ? opts.layouts
42
+ : ['cover'];
43
+
44
+ const deck = await Deck.create({ name: title });
45
+ for (const name of layoutNames) {
46
+ deck.addBlankSlide({ name: normalizeLayoutName(name) });
47
+ }
48
+
49
+ await deck.save(outputPath);
50
+ return statSync(resolve(outputPath)).size;
51
+ }
52
+
53
+ /**
54
+ * Add or update explicit layout/slot metadata on an existing draft or published template.
55
+ */
56
+ export async function annotateTemplateLayout(path, outputPath, opts = {}) {
57
+ const deck = await FigDeck.fromDeckFile(path);
58
+ const slide = deck.getNode(opts.slideId);
59
+ if (!slide || slide.type !== 'SLIDE') {
60
+ throw new Error(`Slide not found: ${opts.slideId}`);
61
+ }
62
+
63
+ const module = getParentModule(deck, slide);
64
+ if (opts.layoutName) {
65
+ const layoutName = normalizeLayoutName(opts.layoutName);
66
+ slide.name = layoutName;
67
+ if (module) module.name = layoutName;
68
+ }
69
+
70
+ renameNodes(deck, opts.textSlots, TEXT_SLOT_PREFIX);
71
+ renameNodes(deck, opts.imageSlots, IMAGE_SLOT_PREFIX);
72
+ renameNodes(deck, opts.fixedImages, FIXED_IMAGE_PREFIX);
73
+
74
+ return deck.saveDeck(outputPath);
75
+ }
76
+
77
+ /**
78
+ * Convert draft slides into publish-like module-backed layouts.
79
+ */
80
+ export async function publishTemplateDraft(path, outputPath, opts = {}) {
81
+ const deck = await FigDeck.fromDeckFile(path);
82
+ const targetIds = new Set(opts.slideIds ?? []);
83
+ const layouts = describeTemplateLayouts(deck);
84
+ const draftLayouts = layouts.filter(layout => layout.state === 'draft');
85
+ const targets = targetIds.size
86
+ ? draftLayouts.filter(layout => targetIds.has(layout.slideId))
87
+ : draftLayouts;
88
+
89
+ if (!targets.length) {
90
+ throw new Error('No draft template slides found to wrap');
91
+ }
92
+
93
+ let nextId = deck.maxLocalID() + 1;
94
+ for (const layout of targets) {
95
+ const slide = deck.getNode(layout.slideId);
96
+ if (!slide) continue;
97
+
98
+ const row = deck.getNode(layout.rowId);
99
+ if (!row) {
100
+ throw new Error(`Slide row not found for ${layout.slideId}`);
101
+ }
102
+
103
+ const moduleGuid = { sessionID: slide.guid.sessionID, localID: nextId++ };
104
+ const module = createModuleWrapper(slide, moduleGuid);
105
+ module.parentIndex = deepClone(slide.parentIndex);
106
+
107
+ slide.parentIndex = { guid: deepClone(moduleGuid), position: '!' };
108
+
109
+ deck.message.nodeChanges.push(module);
110
+ }
111
+
112
+ deck.rebuildMaps();
113
+ return deck.saveDeck(outputPath);
114
+ }
115
+
116
+ /**
117
+ * Create a new deck from a template by cherry-picking and populating layouts.
118
+ *
119
+ * @param {string} templatePath
120
+ * @param {string} outputPath
121
+ * @param {Array<{slideId: string, text?: Record<string, string>, images?: Record<string, string>}>} slideDefs
122
+ */
123
+ export async function createFromTemplate(templatePath, outputPath, slideDefs) {
124
+ const deck = await FigDeck.fromDeckFile(templatePath);
125
+ const layouts = describeTemplateLayouts(deck);
126
+ const layoutBySlideId = new Map(layouts.map(layout => [layout.slideId, layout]));
127
+ const mainRows = getMainSlideRows(deck);
128
+ const targetRow = mainRows[0];
129
+
130
+ if (!targetRow) {
131
+ throw new Error('No main-canvas SLIDE_ROW found in template');
132
+ }
133
+
134
+ let nextId = deck.maxLocalID() + 1;
135
+ const sessionId = 200;
136
+
137
+ for (let defIdx = 0; defIdx < slideDefs.length; defIdx++) {
138
+ const def = slideDefs[defIdx];
139
+ const sourceLayout = layoutBySlideId.get(def.slideId);
140
+ if (!sourceLayout) throw new Error(`Layout not found: ${def.slideId}`);
141
+
142
+ const rootId = sourceLayout.moduleId ?? sourceLayout.slideId;
143
+ const subtreeNodes = [];
144
+ deck.walkTree(rootId, node => {
145
+ if (node.phase !== 'REMOVED') subtreeNodes.push(node);
146
+ });
147
+
148
+ const idMap = new Map();
149
+ for (const node of subtreeNodes) {
150
+ idMap.set(nid(node), { sessionID: sessionId, localID: nextId++ });
151
+ }
152
+
153
+ const reverseIdMap = new Map();
154
+ for (const [oldId, guid] of idMap.entries()) {
155
+ reverseIdMap.set(`${guid.sessionID}:${guid.localID}`, oldId);
156
+ }
157
+
158
+ const clonedNodes = subtreeNodes.map(node => {
159
+ const clone = deepClone(node);
160
+ const oldId = nid(node);
161
+ const newGuid = idMap.get(oldId);
162
+ if (newGuid) clone.guid = newGuid;
163
+
164
+ if (oldId === rootId) {
165
+ clone.parentIndex = {
166
+ guid: deepClone(targetRow.guid),
167
+ position: positionChar(defIdx),
168
+ };
169
+ if (clone.transform) {
170
+ clone.transform.m02 = defIdx * DEFAULT_ROW_GAP;
171
+ }
172
+ } else if (clone.parentIndex?.guid) {
173
+ const parentId = `${clone.parentIndex.guid.sessionID}:${clone.parentIndex.guid.localID}`;
174
+ const remappedParent = idMap.get(parentId);
175
+ if (remappedParent) {
176
+ clone.parentIndex = { ...clone.parentIndex, guid: remappedParent };
177
+ }
178
+ }
179
+
180
+ clone.phase = 'CREATED';
181
+ delete clone.slideThumbnailHash;
182
+ delete clone.editInfo;
183
+ delete clone.prototypeInteractions;
184
+
185
+ return clone;
186
+ });
187
+
188
+ for (const clone of clonedNodes) {
189
+ const originalId = reverseIdMap.get(nid(clone));
190
+ const textValue = pickMappedValue(def.text, candidateTextKeys(sourceLayout, clone, originalId));
191
+ if (textValue !== undefined) {
192
+ applyTextValue(clone, textValue);
193
+ }
194
+
195
+ const imagePath = pickMappedValue(def.images, candidateImageKeys(sourceLayout, clone, originalId));
196
+ if (imagePath !== undefined) {
197
+ await applyImageValue(deck, clone, imagePath);
198
+ }
199
+ }
200
+
201
+ deck.message.nodeChanges.push(...clonedNodes);
202
+ }
203
+
204
+ deck.rebuildMaps();
205
+
206
+ const pruneIds = new Set();
207
+ for (const layout of layouts) {
208
+ collectSubtree(deck, layout.moduleId ?? layout.slideId, pruneIds);
209
+ }
210
+
211
+ const targetRowId = nid(targetRow);
212
+ const extraRowIds = new Set(mainRows.slice(1).map(row => nid(row)));
213
+
214
+ deck.message.nodeChanges = deck.message.nodeChanges.filter(node => {
215
+ const id = nid(node);
216
+ if (!id) return true;
217
+ if (pruneIds.has(id)) return false;
218
+ if (id !== targetRowId && extraRowIds.has(id)) return false;
219
+ return true;
220
+ });
221
+
222
+ deck.rebuildMaps();
223
+ return deck.saveDeck(outputPath);
224
+ }
225
+
226
+ function describeTemplateLayouts(deck, opts = {}) {
227
+ const includeInternal = Boolean(opts.includeInternal);
228
+ const rows = includeInternal
229
+ ? deck.message.nodeChanges.filter(node => node.type === 'SLIDE_ROW' && node.phase !== 'REMOVED')
230
+ : getMainSlideRows(deck);
231
+
232
+ const layouts = [];
233
+ for (const row of rows) {
234
+ for (const layout of getRowLayouts(deck, row)) {
235
+ layouts.push(describeLayout(deck, layout, row));
236
+ }
237
+ }
238
+ return layouts;
239
+ }
240
+
241
+ function getMainSlideRows(deck) {
242
+ return deck.message.nodeChanges.filter(node => {
243
+ if (node.type !== 'SLIDE_ROW' || node.phase === 'REMOVED') return false;
244
+ const canvas = getAncestorCanvas(deck, node);
245
+ return !isInternalCanvas(canvas);
246
+ });
247
+ }
248
+
249
+ function getRowLayouts(deck, row) {
250
+ const layouts = [];
251
+ for (const child of deck.getChildren(nid(row))) {
252
+ if (child.phase === 'REMOVED') continue;
253
+ if (child.type === 'MODULE') {
254
+ for (const maybeSlide of deck.getChildren(nid(child))) {
255
+ if (maybeSlide.phase === 'REMOVED' || maybeSlide.type !== 'SLIDE') continue;
256
+ layouts.push({ slide: maybeSlide, module: child });
257
+ }
258
+ continue;
259
+ }
260
+ if (child.type === 'SLIDE') {
261
+ layouts.push({ slide: child, module: null });
262
+ }
263
+ }
264
+ return layouts;
265
+ }
266
+
267
+ function describeLayout(deck, layout, row) {
268
+ const rootId = layout.module ? nid(layout.module) : nid(layout.slide);
269
+ const slotDiscovery = discoverSlots(deck, rootId);
270
+ const nameSource = layout.module?.name || layout.slide.name || layout.slide.name || 'Untitled';
271
+ const canonicalName = stripLayoutPrefix(nameSource);
272
+
273
+ return {
274
+ slideId: nid(layout.slide),
275
+ moduleId: layout.module ? nid(layout.module) : null,
276
+ rowId: nid(row),
277
+ rowName: row.name || 'Slide row',
278
+ name: canonicalName,
279
+ rawName: nameSource,
280
+ state: layout.module ? 'published' : 'draft',
281
+ hasExplicitSlotMetadata: slotDiscovery.hasExplicitSlotMetadata,
282
+ slots: [...slotDiscovery.textSlots, ...slotDiscovery.imageSlots],
283
+ textFields: slotDiscovery.textSlots.map(slot => ({
284
+ nodeId: slot.nodeId,
285
+ name: slot.name,
286
+ preview: slot.preview,
287
+ source: slot.source,
288
+ })),
289
+ imagePlaceholders: slotDiscovery.imageSlots.map(slot => ({
290
+ nodeId: slot.nodeId,
291
+ name: slot.name,
292
+ type: slot.nodeType,
293
+ width: slot.width,
294
+ height: slot.height,
295
+ hasCurrentImage: slot.hasCurrentImage,
296
+ source: slot.source,
297
+ })),
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Walk the node tree like deck.walkTree, but follow INSTANCE → SYMBOL links.
303
+ * When an INSTANCE node is encountered, its referenced SYMBOL's children are
304
+ * also walked so that slots inside published template components are discovered.
305
+ */
306
+ function walkTreeThroughInstances(deck, rootId, visitor, depth = 0, visited = new Set()) {
307
+ if (!rootId || visited.has(rootId)) return;
308
+ visited.add(rootId);
309
+
310
+ const node = deck.getNode(rootId);
311
+ if (!node || node.phase === 'REMOVED') return;
312
+ visitor(node, depth);
313
+
314
+ // Follow INSTANCE → SYMBOL: walk the SYMBOL's children
315
+ if (node.type === 'INSTANCE' && node.symbolData?.symbolID) {
316
+ const sid = node.symbolData.symbolID;
317
+ const symNid = `${sid.sessionID}:${sid.localID}`;
318
+ for (const child of deck.getChildren(symNid)) {
319
+ walkTreeThroughInstances(deck, nid(child), visitor, depth + 1, visited);
320
+ }
321
+ }
322
+
323
+ // Walk direct children
324
+ for (const child of deck.getChildren(rootId)) {
325
+ walkTreeThroughInstances(deck, nid(child), visitor, depth + 1, visited);
326
+ }
327
+ }
328
+
329
+ function discoverSlots(deck, rootId) {
330
+ const explicitTextSlots = [];
331
+ const explicitImageSlots = [];
332
+ const fallbackTextSlots = [];
333
+ const fallbackImageSlots = [];
334
+
335
+ walkTreeThroughInstances(deck, rootId, node => {
336
+ const textSlotName = parsePrefixedName(node.name, TEXT_SLOT_PREFIX);
337
+ if (textSlotName) {
338
+ const slot = describeTextSlot(node, textSlotName, 'explicit');
339
+ if (slot) explicitTextSlots.push(slot);
340
+ return;
341
+ }
342
+
343
+ const imageSlotName = parsePrefixedName(node.name, IMAGE_SLOT_PREFIX);
344
+ if (imageSlotName) {
345
+ const slot = describeImageSlot(node, imageSlotName, 'explicit');
346
+ if (slot) explicitImageSlots.push(slot);
347
+ return;
348
+ }
349
+
350
+ if (parsePrefixedName(node.name, FIXED_IMAGE_PREFIX)) {
351
+ return;
352
+ }
353
+
354
+ const fallbackText = describeFallbackTextSlot(node);
355
+ if (fallbackText) fallbackTextSlots.push(fallbackText);
356
+
357
+ const fallbackImage = describeFallbackImageSlot(deck, node);
358
+ if (fallbackImage) fallbackImageSlots.push(fallbackImage);
359
+ });
360
+
361
+ const hasExplicitSlotMetadata = explicitTextSlots.length > 0 || explicitImageSlots.length > 0;
362
+
363
+ return {
364
+ hasExplicitSlotMetadata,
365
+ textSlots: explicitTextSlots.length ? explicitTextSlots : fallbackTextSlots,
366
+ imageSlots: hasExplicitSlotMetadata ? explicitImageSlots : fallbackImageSlots,
367
+ };
368
+ }
369
+
370
+ function describeFallbackTextSlot(node) {
371
+ if (node.type === 'TEXT' && node.name) {
372
+ return describeTextSlot(node, node.name, 'heuristic');
373
+ }
374
+
375
+ if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides?.length) {
376
+ return {
377
+ type: 'text',
378
+ nodeId: nid(node),
379
+ name: `#${nid(node)}`,
380
+ preview: firstShapeText(node),
381
+ source: 'heuristic',
382
+ nodeType: node.type,
383
+ };
384
+ }
385
+
386
+ return null;
387
+ }
388
+
389
+ function describeTextSlot(node, name, source) {
390
+ if (node.type === 'TEXT') {
391
+ return {
392
+ type: 'text',
393
+ nodeId: nid(node),
394
+ name,
395
+ preview: (node.textData?.characters ?? '').slice(0, 80),
396
+ source,
397
+ nodeType: node.type,
398
+ };
399
+ }
400
+
401
+ if (node.type === 'SHAPE_WITH_TEXT') {
402
+ return {
403
+ type: 'text',
404
+ nodeId: nid(node),
405
+ name,
406
+ preview: firstShapeText(node),
407
+ source,
408
+ nodeType: node.type,
409
+ };
410
+ }
411
+
412
+ return null;
413
+ }
414
+
415
+ function describeFallbackImageSlot(deck, node) {
416
+ if (node.name && parsePrefixedName(node.name, FIXED_IMAGE_PREFIX)) return null;
417
+ const hasImageFill = node.fillPaints?.some(fill => fill.type === 'IMAGE');
418
+ const isLargeEmptyFrame = (node.type === 'FRAME' || node.type === 'ROUNDED_RECTANGLE')
419
+ && (node.size?.x ?? 0) > 100
420
+ && (node.size?.y ?? 0) > 100
421
+ && deck.getChildren(nid(node)).filter(child => child.phase !== 'REMOVED').length === 0;
422
+
423
+ if (!hasImageFill && !isLargeEmptyFrame) return null;
424
+
425
+ return describeImageSlot(node, `#${nid(node)}`, 'heuristic');
426
+ }
427
+
428
+ function describeImageSlot(node, name, source) {
429
+ const hasImageFill = node.fillPaints?.some(fill => fill.type === 'IMAGE') ?? false;
430
+ return {
431
+ type: 'image',
432
+ nodeId: nid(node),
433
+ name,
434
+ source,
435
+ nodeType: node.type,
436
+ width: Math.round(node.size?.x ?? 0),
437
+ height: Math.round(node.size?.y ?? 0),
438
+ hasCurrentImage: hasImageFill,
439
+ };
440
+ }
441
+
442
+ function candidateTextKeys(layout, node, originalId) {
443
+ const keys = [];
444
+ const field = layout.textFields.find(entry => entry.nodeId === originalId);
445
+ if (field?.name) keys.push(field.name);
446
+ if (originalId) {
447
+ keys.push(originalId);
448
+ keys.push(`#${originalId}`);
449
+ }
450
+ if (node.name) keys.push(node.name);
451
+ return dedupe(keys);
452
+ }
453
+
454
+ function candidateImageKeys(layout, node, originalId) {
455
+ const keys = [];
456
+ const field = layout.imagePlaceholders.find(entry => entry.nodeId === originalId);
457
+ if (field?.name) keys.push(field.name);
458
+ if (originalId) {
459
+ keys.push(originalId);
460
+ keys.push(`#${originalId}`);
461
+ }
462
+ if (node.name) keys.push(node.name);
463
+ return dedupe(keys);
464
+ }
465
+
466
+ function pickMappedValue(map, keys) {
467
+ if (!map) return undefined;
468
+ for (const key of keys) {
469
+ if (key in map) return map[key];
470
+ }
471
+ return undefined;
472
+ }
473
+
474
+ function applyTextValue(node, text) {
475
+ const chars = text === '' || text == null ? ' ' : text;
476
+
477
+ if (node.type === 'TEXT') {
478
+ if (!node.textData) node.textData = {};
479
+ node.textData.characters = chars;
480
+ node.textData.lines = chars.split('\n').map(() => ({
481
+ lineType: 'PLAIN',
482
+ styleId: 0,
483
+ indentationLevel: 0,
484
+ sourceDirectionality: 'AUTO',
485
+ listStartOffset: 0,
486
+ isFirstLineOfList: false,
487
+ }));
488
+ delete node.derivedTextData;
489
+ return;
490
+ }
491
+
492
+ if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
493
+ for (const override of node.nodeGenerationData.overrides) {
494
+ if (!override.textData) continue;
495
+ override.textData.characters = chars;
496
+ override.textData.lines = chars.split('\n').map(() => ({
497
+ lineType: 'PLAIN',
498
+ styleId: 0,
499
+ indentationLevel: 0,
500
+ sourceDirectionality: 'AUTO',
501
+ listStartOffset: 0,
502
+ isFirstLineOfList: false,
503
+ }));
504
+ }
505
+ delete node.derivedImmutableFrameData;
506
+ }
507
+ }
508
+
509
+ async function applyImageValue(deck, node, imagePath) {
510
+ const absPath = resolve(imagePath);
511
+ const imgBuf = readFileSync(absPath);
512
+ const imgHash = sha1Hex(imgBuf);
513
+ const { width, height } = await getImageDimensions(imgBuf);
514
+
515
+ const tmpThumb = `/tmp/openfig_thumb_${Date.now()}_${Math.random().toString(36).slice(2)}.png`;
516
+ await generateThumbnail(imgBuf, tmpThumb);
517
+ const thumbHash = sha1Hex(readFileSync(tmpThumb));
518
+
519
+ copyToImagesDir(deck, imgHash, absPath);
520
+ copyToImagesDir(deck, thumbHash, tmpThumb);
521
+
522
+ const fill = {
523
+ type: 'IMAGE',
524
+ opacity: 1,
525
+ visible: true,
526
+ blendMode: 'NORMAL',
527
+ transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
528
+ image: { hash: hexToHash(imgHash), name: imgHash },
529
+ imageThumbnail: { hash: hexToHash(thumbHash), name: thumbHash },
530
+ animationFrame: 0,
531
+ imageScaleMode: existingImageScaleMode(node) ?? 'FILL',
532
+ imageShouldColorManage: false,
533
+ rotation: 0,
534
+ scale: 0.5,
535
+ originalImageWidth: width,
536
+ originalImageHeight: height,
537
+ thumbHash: new Uint8Array(0),
538
+ altText: '',
539
+ };
540
+
541
+ if (node.fillPaints?.length) {
542
+ const idx = node.fillPaints.findIndex(paint => paint.type === 'IMAGE');
543
+ if (idx >= 0) {
544
+ node.fillPaints.splice(idx, 1, fill);
545
+ } else {
546
+ node.fillPaints = [fill];
547
+ }
548
+ } else {
549
+ node.fillPaints = [fill];
550
+ }
551
+
552
+ delete node.derivedImmutableFrameData;
553
+ }
554
+
555
+ function collectSubtree(deck, rootId, seen) {
556
+ if (!rootId || seen.has(rootId)) return;
557
+ seen.add(rootId);
558
+ for (const child of deck.getChildren(rootId)) {
559
+ collectSubtree(deck, nid(child), seen);
560
+ }
561
+ }
562
+
563
+ function renameNodes(deck, map, prefix) {
564
+ if (!map) return;
565
+ for (const [nodeId, name] of Object.entries(map)) {
566
+ const node = deck.getNode(nodeId);
567
+ if (!node) throw new Error(`Node not found: ${nodeId}`);
568
+ node.name = `${prefix}${stripPrefix(name)}`;
569
+ }
570
+ }
571
+
572
+ function createModuleWrapper(slide, moduleGuid) {
573
+ return {
574
+ guid: deepClone(moduleGuid),
575
+ phase: 'CREATED',
576
+ type: 'MODULE',
577
+ name: slide.name ?? 'layout',
578
+ isPublishable: true,
579
+ version: MODULE_VERSION,
580
+ userFacingVersion: MODULE_VERSION,
581
+ visible: true,
582
+ opacity: 1,
583
+ size: deepClone(slide.size ?? { x: 1920, y: 1080 }),
584
+ transform: { m00: 1, m01: 0, m02: slide.transform?.m02 ?? 0, m10: 0, m11: 1, m12: slide.transform?.m12 ?? 0 },
585
+ strokeWeight: 1,
586
+ strokeAlign: 'INSIDE',
587
+ strokeJoin: 'MITER',
588
+ fillPaints: deepClone(slide.fillPaints ?? [{
589
+ type: 'SOLID',
590
+ color: { r: 1, g: 1, b: 1, a: 1 },
591
+ opacity: 1,
592
+ visible: true,
593
+ blendMode: 'NORMAL',
594
+ }]),
595
+ fillGeometry: [{
596
+ windingRule: 'NONZERO',
597
+ commandsBlob: 13,
598
+ styleID: 0,
599
+ }],
600
+ frameMaskDisabled: false,
601
+ };
602
+ }
603
+
604
+ function getParentModule(deck, slide) {
605
+ if (!slide?.parentIndex?.guid) return null;
606
+ const parent = deck.getNode(guidId(slide.parentIndex.guid));
607
+ return parent?.type === 'MODULE' ? parent : null;
608
+ }
609
+
610
+ function getAncestorCanvas(deck, node) {
611
+ let current = node;
612
+ while (current?.parentIndex?.guid) {
613
+ const parent = deck.getNode(guidId(current.parentIndex.guid));
614
+ if (!parent) return null;
615
+ if (parent.type === 'CANVAS') return parent;
616
+ current = parent;
617
+ }
618
+ return null;
619
+ }
620
+
621
+ function isInternalCanvas(canvas) {
622
+ return canvas?.name === INTERNAL_CANVAS_NAME;
623
+ }
624
+
625
+ function parsePrefixedName(value, prefix) {
626
+ if (!value || !value.startsWith(prefix)) return null;
627
+ return stripPrefix(value.slice(prefix.length));
628
+ }
629
+
630
+ function normalizeLayoutName(value) {
631
+ const stripped = stripLayoutPrefix(value || 'layout');
632
+ return `${LAYOUT_PREFIX}${stripped}`;
633
+ }
634
+
635
+ function stripLayoutPrefix(value) {
636
+ return parsePrefixedName(value, LAYOUT_PREFIX) ?? stripPrefix(value);
637
+ }
638
+
639
+ function stripPrefix(value) {
640
+ return String(value ?? '').trim().replace(/^(layout:|slot:text:|slot:image:|fixed:image:)/, '');
641
+ }
642
+
643
+ function firstShapeText(node) {
644
+ const text = node.nodeGenerationData?.overrides?.find(override => override.textData?.characters)?.textData?.characters ?? '';
645
+ return text.trim().slice(0, 80);
646
+ }
647
+
648
+ function existingImageScaleMode(node) {
649
+ return node.fillPaints?.find(fill => fill.type === 'IMAGE')?.imageScaleMode;
650
+ }
651
+
652
+ function dedupe(values) {
653
+ return [...new Set(values.filter(Boolean))];
654
+ }
655
+
656
+ function guidId(guid) {
657
+ return guid ? `${guid.sessionID}:${guid.localID}` : null;
658
+ }
659
+
660
+ function sha1Hex(buf) {
661
+ return createHash('sha1').update(buf).digest('hex');
662
+ }
663
+
664
+ function copyToImagesDir(deck, hash, srcPath) {
665
+ if (!deck.imagesDir) {
666
+ deck.imagesDir = `/tmp/openfig_images_${Date.now()}`;
667
+ mkdirSync(deck.imagesDir, { recursive: true });
668
+ }
669
+ const dest = join(deck.imagesDir, hash);
670
+ if (!existsSync(dest)) copyFileSync(srcPath, dest);
671
+ }
package/manifest.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "manifest_version": "0.2",
3
+ "name": "openfig",
4
+ "version": "0.3.11",
5
+ "description": "Open-source tools for parsing and rendering Figma design files",
6
+ "author": {
7
+ "name": "OpenFig Contributors"
8
+ },
9
+ "server": {
10
+ "type": "node",
11
+ "entry_point": "mcp-server.mjs",
12
+ "mcp_config": {
13
+ "command": "node",
14
+ "args": [
15
+ "${__dirname}/mcp-server.mjs"
16
+ ],
17
+ "env": {}
18
+ }
19
+ },
20
+ "license": "MIT"
21
+ }