figmatk 0.3.0 → 0.3.1

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.
@@ -1,129 +1,179 @@
1
1
  /**
2
- * template-deck — Clone slides from a Figma template and populate with content.
2
+ * template-deck — Inspect, author, wrap, and instantiate Figma Slides templates.
3
3
  *
4
- * Template decks use MODULE > SLIDE > (TEXT, FRAME, ...) structure.
5
- * Unlike generated decks, text is set directly on child nodes — not via symbolOverrides.
4
+ * Template workflows now cover two structural states:
5
+ * - Draft templates: SLIDE_ROW -> SLIDE -> ...
6
+ * - Published templates: SLIDE_ROW -> MODULE -> SLIDE -> ...
6
7
  */
7
- import { FigDeck } from './fig-deck.mjs';
8
- import { nid, removeNode } from './node-helpers.mjs';
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';
9
12
  import { deepClone } from './deep-clone.mjs';
13
+ import { FigDeck } from './fig-deck.mjs';
14
+ import { hexToHash } from './image-helpers.mjs';
15
+ import { getImageDimensions, generateThumbnail } from './image-utils.mjs';
16
+ import { nid, positionChar } from './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;
10
26
 
11
27
  /**
12
- * Inspect a template deck and return available layout slots.
13
- * Returns an array of { slideId, name, textFields: [{ nodeId, name }] }
28
+ * Inspect a template deck and return available layouts plus explicit slot metadata.
14
29
  */
15
- export async function listTemplateLayouts(templatePath) {
30
+ export async function listTemplateLayouts(templatePath, opts = {}) {
16
31
  const deck = await FigDeck.fromDeckFile(templatePath);
17
- const layouts = [];
32
+ return describeTemplateLayouts(deck, opts);
33
+ }
18
34
 
19
- for (const slide of deck.getActiveSlides()) {
20
- const id = nid(slide);
21
- const textFields = [];
22
- const imagePlaceholders = [];
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'];
23
43
 
24
- deck.walkTree(id, (node) => {
25
- if (node.type === 'TEXT' && node.name && node.textData?.characters) {
26
- textFields.push({ nodeId: nid(node), name: node.name, preview: node.textData.characters.slice(0, 80) });
27
- }
28
- // SHAPE_WITH_TEXT: text lives in nodeGenerationData.overrides[n].textData
29
- if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
30
- for (const ov of node.nodeGenerationData.overrides) {
31
- if (ov.textData?.characters) {
32
- const preview = ov.textData.characters.trim();
33
- // Use #nodeId as key so createFromTemplate can target this specific field
34
- textFields.push({ nodeId: nid(node), name: `#${nid(node)}`, preview });
35
- }
36
- }
37
- }
38
- // Detect image placeholder areas: frames/shapes with IMAGE fill, or large empty frames
39
- const hasImageFill = node.fillPaints?.some(f => f.type === 'IMAGE');
40
- const isLargeFrame = (node.type === 'FRAME' || node.type === 'ROUNDED_RECTANGLE')
41
- && node.size?.x > 100 && node.size?.y > 100
42
- && deck.getChildren(nid(node)).filter(c => c.type !== 'FRAME').length === 0;
43
- if (hasImageFill || isLargeFrame) {
44
- imagePlaceholders.push({
45
- nodeId: nid(node),
46
- type: node.type,
47
- width: Math.round(node.size?.x ?? 0),
48
- height: Math.round(node.size?.y ?? 0),
49
- hasCurrentImage: hasImageFill,
50
- });
51
- }
52
- });
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
+ }
53
52
 
54
- layouts.push({ slideId: id, name: slide.name, textFields, imagePlaceholders });
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}`);
55
61
  }
56
62
 
57
- return layouts;
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);
58
75
  }
59
76
 
60
77
  /**
61
- * Create a new deck from a template by cherry-picking and populating slides.
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.
62
118
  *
63
- * @param {string} templatePath - Path to source .deck file
64
- * @param {string} outputPath - Path to write output .deck file
65
- * @param {Array} slideDefs - [{ slideId: '1:74', text: { 'Title': 'Hello', 'Body 1': '...' } }]
66
- * @returns {number} bytes written
119
+ * @param {string} templatePath
120
+ * @param {string} outputPath
121
+ * @param {Array<{slideId: string, text?: Record<string, string>, images?: Record<string, string>}>} slideDefs
67
122
  */
68
123
  export async function createFromTemplate(templatePath, outputPath, slideDefs) {
69
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];
70
129
 
71
- // Record original MODULE/SLIDE IDs to remove after cloning
72
- const originalSlideIds = deck.getActiveSlides().map(s => nid(s));
73
-
74
- // Find SLIDE_ROW id (parent of MODULEs)
75
- const slideRowNode = deck.message.nodeChanges.find(n => n.type === 'SLIDE_ROW');
76
- if (!slideRowNode) throw new Error('No SLIDE_ROW found in template');
77
- const slideRowId = nid(slideRowNode);
130
+ if (!targetRow) {
131
+ throw new Error('No main-canvas SLIDE_ROW found in template');
132
+ }
78
133
 
79
134
  let nextId = deck.maxLocalID() + 1;
80
- const SESSION = 200; // fresh session ID for cloned nodes
135
+ const sessionId = 200;
81
136
 
82
137
  for (let defIdx = 0; defIdx < slideDefs.length; defIdx++) {
83
- const { slideId, text = {} } = slideDefs[defIdx];
84
-
85
- // Find the source SLIDE node
86
- const sourceSlide = deck.getNode(slideId);
87
- if (!sourceSlide) throw new Error(`Slide not found: ${slideId}`);
88
-
89
- // Find the parent MODULE (if present)
90
- const parentModuleId = sourceSlide.parentIndex?.guid
91
- ? `${sourceSlide.parentIndex.guid.sessionID}:${sourceSlide.parentIndex.guid.localID}`
92
- : null;
93
- const sourceModule = parentModuleId ? deck.getNode(parentModuleId) : null;
138
+ const def = slideDefs[defIdx];
139
+ const sourceLayout = layoutBySlideId.get(def.slideId);
140
+ if (!sourceLayout) throw new Error(`Layout not found: ${def.slideId}`);
94
141
 
95
- // Collect all nodes in the subtree to clone (MODULE + SLIDE + all descendants)
96
- const rootId = sourceModule ? nid(sourceModule) : slideId;
142
+ const rootId = sourceLayout.moduleId ?? sourceLayout.slideId;
97
143
  const subtreeNodes = [];
98
- deck.walkTree(rootId, (node) => subtreeNodes.push(node));
144
+ deck.walkTree(rootId, node => {
145
+ if (node.phase !== 'REMOVED') subtreeNodes.push(node);
146
+ });
99
147
 
100
- // Build ID remap table: old "s:l" → new { sessionID, localID }
101
148
  const idMap = new Map();
102
149
  for (const node of subtreeNodes) {
103
- const oldId = nid(node);
104
- if (oldId) idMap.set(oldId, { sessionID: SESSION, localID: nextId++ });
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);
105
156
  }
106
157
 
107
- // Deep-clone each node with remapped IDs
108
158
  const clonedNodes = subtreeNodes.map(node => {
109
159
  const clone = deepClone(node);
110
-
111
- // Remap own guid
112
- const newGuid = idMap.get(nid(node));
160
+ const oldId = nid(node);
161
+ const newGuid = idMap.get(oldId);
113
162
  if (newGuid) clone.guid = newGuid;
114
163
 
115
- // Remap parentIndex.guid if it's within the subtree
116
- if (clone.parentIndex?.guid) {
117
- const pid = `${clone.parentIndex.guid.sessionID}:${clone.parentIndex.guid.localID}`;
118
- const remapped = idMap.get(pid);
119
- if (remapped) {
120
- clone.parentIndex = { ...clone.parentIndex, guid: remapped };
121
- } else if (pid === slideRowId || pid === parentModuleId) {
122
- // Root node — attach to SLIDE_ROW
123
- clone.parentIndex = {
124
- guid: { sessionID: slideRowNode.guid.sessionID, localID: slideRowNode.guid.localID },
125
- position: String.fromCharCode(0x21 + (originalSlideIds.length + defIdx)),
126
- };
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 };
127
177
  }
128
178
  }
129
179
 
@@ -135,84 +185,459 @@ export async function createFromTemplate(templatePath, outputPath, slideDefs) {
135
185
  return clone;
136
186
  });
137
187
 
138
- // Apply text overrides: find TEXT nodes by name, set characters
139
- const clonedSlideGuid = idMap.get(slideId);
140
- const clonedSlideId = clonedSlideGuid
141
- ? `${clonedSlideGuid.sessionID}:${clonedSlideGuid.localID}`
142
- : null;
143
-
144
188
  for (const clone of clonedNodes) {
145
- // TEXT nodes — matched by node name
146
- if (clone.type === 'TEXT' && clone.name && text[clone.name] !== undefined) {
147
- const chars = text[clone.name] || ' ';
148
- if (!clone.textData) clone.textData = {};
149
- clone.textData.characters = chars;
150
- clone.textData.lines = chars.split('\n').map(() => ({
151
- lineType: 'PLAIN', styleId: 0, indentationLevel: 0,
152
- sourceDirectionality: 'AUTO', listStartOffset: 0, isFirstLineOfList: false,
153
- }));
154
- delete clone.derivedTextData;
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);
155
193
  }
156
194
 
157
- // SHAPE_WITH_TEXT nodes matched by #nodeId key (original ID before remapping)
158
- if (clone.type === 'SHAPE_WITH_TEXT' && clone.nodeGenerationData?.overrides) {
159
- // Find original node ID (before remapping) by reverse-looking up from idMap
160
- const origId = [...idMap.entries()].find(
161
- ([, v]) => v.sessionID === clone.guid.sessionID && v.localID === clone.guid.localID
162
- )?.[0];
163
- const key = origId ? `#${origId}` : null;
164
- if (key && text[key] !== undefined) {
165
- const chars = text[key] || ' ';
166
- for (const ov of clone.nodeGenerationData.overrides) {
167
- if (ov.textData?.characters !== undefined) {
168
- ov.textData.characters = chars;
169
- ov.textData.lines = chars.split('\n').map(() => ({
170
- lineType: 'PLAIN', styleId: 0, indentationLevel: 0,
171
- sourceDirectionality: 'AUTO', listStartOffset: 0, isFirstLineOfList: false,
172
- }));
173
- }
174
- }
175
- delete clone.derivedImmutableFrameData;
176
- }
195
+ const imagePath = pickMappedValue(def.images, candidateImageKeys(sourceLayout, clone, originalId));
196
+ if (imagePath !== undefined) {
197
+ await applyImageValue(deck, clone, imagePath);
177
198
  }
178
199
  }
179
200
 
180
201
  deck.message.nodeChanges.push(...clonedNodes);
181
202
  }
182
203
 
183
- // Rebuild maps so we can walk the original slide subtrees
184
204
  deck.rebuildMaps();
185
205
 
186
- // Collect all node IDs in original slide subtrees to physically remove from nodeChanges.
187
- // phase=REMOVED is NOT used here — Figma ignores it for TEXT/FRAME nodes.
188
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);
189
231
 
190
- function collectSubtree(id) {
191
- if (pruneIds.has(id)) return;
192
- pruneIds.add(id);
193
- for (const child of deck.getChildren(id)) {
194
- collectSubtree(nid(child));
232
+ const layouts = [];
233
+ for (const row of rows) {
234
+ for (const layout of getRowLayouts(deck, row)) {
235
+ layouts.push(describeLayout(deck, layout, row));
195
236
  }
196
237
  }
238
+ return layouts;
239
+ }
197
240
 
198
- for (const id of originalSlideIds) {
199
- const slide = deck.getNode(id);
200
- if (!slide) continue;
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
+ }
201
248
 
202
- if (slide.parentIndex?.guid) {
203
- const modId = `${slide.parentIndex.guid.sessionID}:${slide.parentIndex.guid.localID}`;
204
- const mod = deck.getNode(modId);
205
- if (mod?.type === 'MODULE') { collectSubtree(modId); continue; }
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 });
206
262
  }
207
- collectSubtree(id);
208
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
+ function discoverSlots(deck, rootId) {
302
+ const explicitTextSlots = [];
303
+ const explicitImageSlots = [];
304
+ const fallbackTextSlots = [];
305
+ const fallbackImageSlots = [];
306
+
307
+ deck.walkTree(rootId, node => {
308
+ const textSlotName = parsePrefixedName(node.name, TEXT_SLOT_PREFIX);
309
+ if (textSlotName) {
310
+ const slot = describeTextSlot(node, textSlotName, 'explicit');
311
+ if (slot) explicitTextSlots.push(slot);
312
+ return;
313
+ }
314
+
315
+ const imageSlotName = parsePrefixedName(node.name, IMAGE_SLOT_PREFIX);
316
+ if (imageSlotName) {
317
+ const slot = describeImageSlot(node, imageSlotName, 'explicit');
318
+ if (slot) explicitImageSlots.push(slot);
319
+ return;
320
+ }
321
+
322
+ if (parsePrefixedName(node.name, FIXED_IMAGE_PREFIX)) {
323
+ return;
324
+ }
209
325
 
210
- // Filter them out entirely — absent nodes render as non-existent in Figma
211
- deck.message.nodeChanges = deck.message.nodeChanges.filter(n => {
212
- const id = nid(n);
213
- return !id || !pruneIds.has(id);
326
+ const fallbackText = describeFallbackTextSlot(node);
327
+ if (fallbackText) fallbackTextSlots.push(fallbackText);
328
+
329
+ const fallbackImage = describeFallbackImageSlot(deck, node);
330
+ if (fallbackImage) fallbackImageSlots.push(fallbackImage);
214
331
  });
215
332
 
216
- deck.rebuildMaps();
217
- return deck.saveDeck(outputPath);
333
+ const hasExplicitSlotMetadata = explicitTextSlots.length > 0 || explicitImageSlots.length > 0;
334
+
335
+ return {
336
+ hasExplicitSlotMetadata,
337
+ textSlots: explicitTextSlots.length ? explicitTextSlots : fallbackTextSlots,
338
+ imageSlots: hasExplicitSlotMetadata ? explicitImageSlots : fallbackImageSlots,
339
+ };
340
+ }
341
+
342
+ function describeFallbackTextSlot(node) {
343
+ if (node.type === 'TEXT' && node.name) {
344
+ return describeTextSlot(node, node.name, 'heuristic');
345
+ }
346
+
347
+ if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides?.length) {
348
+ return {
349
+ type: 'text',
350
+ nodeId: nid(node),
351
+ name: `#${nid(node)}`,
352
+ preview: firstShapeText(node),
353
+ source: 'heuristic',
354
+ nodeType: node.type,
355
+ };
356
+ }
357
+
358
+ return null;
359
+ }
360
+
361
+ function describeTextSlot(node, name, source) {
362
+ if (node.type === 'TEXT') {
363
+ return {
364
+ type: 'text',
365
+ nodeId: nid(node),
366
+ name,
367
+ preview: (node.textData?.characters ?? '').slice(0, 80),
368
+ source,
369
+ nodeType: node.type,
370
+ };
371
+ }
372
+
373
+ if (node.type === 'SHAPE_WITH_TEXT') {
374
+ return {
375
+ type: 'text',
376
+ nodeId: nid(node),
377
+ name,
378
+ preview: firstShapeText(node),
379
+ source,
380
+ nodeType: node.type,
381
+ };
382
+ }
383
+
384
+ return null;
385
+ }
386
+
387
+ function describeFallbackImageSlot(deck, node) {
388
+ if (node.name && parsePrefixedName(node.name, FIXED_IMAGE_PREFIX)) return null;
389
+ const hasImageFill = node.fillPaints?.some(fill => fill.type === 'IMAGE');
390
+ const isLargeEmptyFrame = (node.type === 'FRAME' || node.type === 'ROUNDED_RECTANGLE')
391
+ && (node.size?.x ?? 0) > 100
392
+ && (node.size?.y ?? 0) > 100
393
+ && deck.getChildren(nid(node)).filter(child => child.phase !== 'REMOVED').length === 0;
394
+
395
+ if (!hasImageFill && !isLargeEmptyFrame) return null;
396
+
397
+ return describeImageSlot(node, `#${nid(node)}`, 'heuristic');
398
+ }
399
+
400
+ function describeImageSlot(node, name, source) {
401
+ const hasImageFill = node.fillPaints?.some(fill => fill.type === 'IMAGE') ?? false;
402
+ return {
403
+ type: 'image',
404
+ nodeId: nid(node),
405
+ name,
406
+ source,
407
+ nodeType: node.type,
408
+ width: Math.round(node.size?.x ?? 0),
409
+ height: Math.round(node.size?.y ?? 0),
410
+ hasCurrentImage: hasImageFill,
411
+ };
412
+ }
413
+
414
+ function candidateTextKeys(layout, node, originalId) {
415
+ const keys = [];
416
+ const field = layout.textFields.find(entry => entry.nodeId === originalId);
417
+ if (field?.name) keys.push(field.name);
418
+ if (originalId) {
419
+ keys.push(originalId);
420
+ keys.push(`#${originalId}`);
421
+ }
422
+ if (node.name) keys.push(node.name);
423
+ return dedupe(keys);
424
+ }
425
+
426
+ function candidateImageKeys(layout, node, originalId) {
427
+ const keys = [];
428
+ const field = layout.imagePlaceholders.find(entry => entry.nodeId === originalId);
429
+ if (field?.name) keys.push(field.name);
430
+ if (originalId) {
431
+ keys.push(originalId);
432
+ keys.push(`#${originalId}`);
433
+ }
434
+ if (node.name) keys.push(node.name);
435
+ return dedupe(keys);
436
+ }
437
+
438
+ function pickMappedValue(map, keys) {
439
+ if (!map) return undefined;
440
+ for (const key of keys) {
441
+ if (key in map) return map[key];
442
+ }
443
+ return undefined;
444
+ }
445
+
446
+ function applyTextValue(node, text) {
447
+ const chars = text === '' || text == null ? ' ' : text;
448
+
449
+ if (node.type === 'TEXT') {
450
+ if (!node.textData) node.textData = {};
451
+ node.textData.characters = chars;
452
+ node.textData.lines = chars.split('\n').map(() => ({
453
+ lineType: 'PLAIN',
454
+ styleId: 0,
455
+ indentationLevel: 0,
456
+ sourceDirectionality: 'AUTO',
457
+ listStartOffset: 0,
458
+ isFirstLineOfList: false,
459
+ }));
460
+ delete node.derivedTextData;
461
+ return;
462
+ }
463
+
464
+ if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
465
+ for (const override of node.nodeGenerationData.overrides) {
466
+ if (!override.textData) continue;
467
+ override.textData.characters = chars;
468
+ override.textData.lines = chars.split('\n').map(() => ({
469
+ lineType: 'PLAIN',
470
+ styleId: 0,
471
+ indentationLevel: 0,
472
+ sourceDirectionality: 'AUTO',
473
+ listStartOffset: 0,
474
+ isFirstLineOfList: false,
475
+ }));
476
+ }
477
+ delete node.derivedImmutableFrameData;
478
+ }
479
+ }
480
+
481
+ async function applyImageValue(deck, node, imagePath) {
482
+ const absPath = resolve(imagePath);
483
+ const imgBuf = readFileSync(absPath);
484
+ const imgHash = sha1Hex(imgBuf);
485
+ const { width, height } = await getImageDimensions(imgBuf);
486
+
487
+ const tmpThumb = `/tmp/figmatk_thumb_${Date.now()}_${Math.random().toString(36).slice(2)}.png`;
488
+ await generateThumbnail(imgBuf, tmpThumb);
489
+ const thumbHash = sha1Hex(readFileSync(tmpThumb));
490
+
491
+ copyToImagesDir(deck, imgHash, absPath);
492
+ copyToImagesDir(deck, thumbHash, tmpThumb);
493
+
494
+ const fill = {
495
+ type: 'IMAGE',
496
+ opacity: 1,
497
+ visible: true,
498
+ blendMode: 'NORMAL',
499
+ transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
500
+ image: { hash: hexToHash(imgHash), name: imgHash },
501
+ imageThumbnail: { hash: hexToHash(thumbHash), name: thumbHash },
502
+ animationFrame: 0,
503
+ imageScaleMode: existingImageScaleMode(node) ?? 'FILL',
504
+ imageShouldColorManage: false,
505
+ rotation: 0,
506
+ scale: 0.5,
507
+ originalImageWidth: width,
508
+ originalImageHeight: height,
509
+ thumbHash: new Uint8Array(0),
510
+ altText: '',
511
+ };
512
+
513
+ if (node.fillPaints?.length) {
514
+ const idx = node.fillPaints.findIndex(paint => paint.type === 'IMAGE');
515
+ if (idx >= 0) {
516
+ node.fillPaints.splice(idx, 1, fill);
517
+ } else {
518
+ node.fillPaints = [fill];
519
+ }
520
+ } else {
521
+ node.fillPaints = [fill];
522
+ }
523
+
524
+ delete node.derivedImmutableFrameData;
525
+ }
526
+
527
+ function collectSubtree(deck, rootId, seen) {
528
+ if (!rootId || seen.has(rootId)) return;
529
+ seen.add(rootId);
530
+ for (const child of deck.getChildren(rootId)) {
531
+ collectSubtree(deck, nid(child), seen);
532
+ }
533
+ }
534
+
535
+ function renameNodes(deck, map, prefix) {
536
+ if (!map) return;
537
+ for (const [nodeId, name] of Object.entries(map)) {
538
+ const node = deck.getNode(nodeId);
539
+ if (!node) throw new Error(`Node not found: ${nodeId}`);
540
+ node.name = `${prefix}${stripPrefix(name)}`;
541
+ }
542
+ }
543
+
544
+ function createModuleWrapper(slide, moduleGuid) {
545
+ return {
546
+ guid: deepClone(moduleGuid),
547
+ phase: 'CREATED',
548
+ type: 'MODULE',
549
+ name: slide.name ?? 'layout',
550
+ isPublishable: true,
551
+ version: MODULE_VERSION,
552
+ userFacingVersion: MODULE_VERSION,
553
+ visible: true,
554
+ opacity: 1,
555
+ size: deepClone(slide.size ?? { x: 1920, y: 1080 }),
556
+ transform: { m00: 1, m01: 0, m02: slide.transform?.m02 ?? 0, m10: 0, m11: 1, m12: slide.transform?.m12 ?? 0 },
557
+ strokeWeight: 1,
558
+ strokeAlign: 'INSIDE',
559
+ strokeJoin: 'MITER',
560
+ fillPaints: deepClone(slide.fillPaints ?? [{
561
+ type: 'SOLID',
562
+ color: { r: 1, g: 1, b: 1, a: 1 },
563
+ opacity: 1,
564
+ visible: true,
565
+ blendMode: 'NORMAL',
566
+ }]),
567
+ fillGeometry: [{
568
+ windingRule: 'NONZERO',
569
+ commandsBlob: 13,
570
+ styleID: 0,
571
+ }],
572
+ frameMaskDisabled: false,
573
+ };
574
+ }
575
+
576
+ function getParentModule(deck, slide) {
577
+ if (!slide?.parentIndex?.guid) return null;
578
+ const parent = deck.getNode(guidId(slide.parentIndex.guid));
579
+ return parent?.type === 'MODULE' ? parent : null;
580
+ }
581
+
582
+ function getAncestorCanvas(deck, node) {
583
+ let current = node;
584
+ while (current?.parentIndex?.guid) {
585
+ const parent = deck.getNode(guidId(current.parentIndex.guid));
586
+ if (!parent) return null;
587
+ if (parent.type === 'CANVAS') return parent;
588
+ current = parent;
589
+ }
590
+ return null;
591
+ }
592
+
593
+ function isInternalCanvas(canvas) {
594
+ return canvas?.name === INTERNAL_CANVAS_NAME;
595
+ }
596
+
597
+ function parsePrefixedName(value, prefix) {
598
+ if (!value || !value.startsWith(prefix)) return null;
599
+ return stripPrefix(value.slice(prefix.length));
600
+ }
601
+
602
+ function normalizeLayoutName(value) {
603
+ const stripped = stripLayoutPrefix(value || 'layout');
604
+ return `${LAYOUT_PREFIX}${stripped}`;
605
+ }
606
+
607
+ function stripLayoutPrefix(value) {
608
+ return parsePrefixedName(value, LAYOUT_PREFIX) ?? stripPrefix(value);
609
+ }
610
+
611
+ function stripPrefix(value) {
612
+ return String(value ?? '').trim().replace(/^(layout:|slot:text:|slot:image:|fixed:image:)/, '');
613
+ }
614
+
615
+ function firstShapeText(node) {
616
+ const text = node.nodeGenerationData?.overrides?.find(override => override.textData?.characters)?.textData?.characters ?? '';
617
+ return text.trim().slice(0, 80);
618
+ }
619
+
620
+ function existingImageScaleMode(node) {
621
+ return node.fillPaints?.find(fill => fill.type === 'IMAGE')?.imageScaleMode;
622
+ }
623
+
624
+ function dedupe(values) {
625
+ return [...new Set(values.filter(Boolean))];
626
+ }
627
+
628
+ function guidId(guid) {
629
+ return guid ? `${guid.sessionID}:${guid.localID}` : null;
630
+ }
631
+
632
+ function sha1Hex(buf) {
633
+ return createHash('sha1').update(buf).digest('hex');
634
+ }
635
+
636
+ function copyToImagesDir(deck, hash, srcPath) {
637
+ if (!deck.imagesDir) {
638
+ deck.imagesDir = `/tmp/figmatk_images_${Date.now()}`;
639
+ mkdirSync(deck.imagesDir, { recursive: true });
640
+ }
641
+ const dest = join(deck.imagesDir, hash);
642
+ if (!existsSync(dest)) copyFileSync(srcPath, dest);
218
643
  }