figmatk 0.3.0 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +49 -14
- package/cli.mjs +2 -0
- package/commands/render.mjs +56 -0
- package/lib/fig-deck.mjs +4 -4
- package/lib/rasterizer/deck-rasterizer.mjs +228 -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/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 +127 -0
- package/lib/rasterizer/render-report.mjs +25 -0
- package/lib/rasterizer/svg-builder.mjs +571 -0
- package/lib/rasterizer/test-render.mjs +63 -0
- package/lib/template-deck.mjs +573 -148
- package/manifest.json +21 -0
- package/mcp-server.mjs +184 -20
- package/package.json +17 -2
- package/skills/figma-slides-creator/SKILL.md +79 -172
- package/skills/figma-template-builder/SKILL.md +158 -0
package/lib/template-deck.mjs
CHANGED
|
@@ -1,129 +1,179 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* template-deck —
|
|
2
|
+
* template-deck — Inspect, author, wrap, and instantiate Figma Slides templates.
|
|
3
3
|
*
|
|
4
|
-
* Template
|
|
5
|
-
*
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
|
|
32
|
+
return describeTemplateLayouts(deck, opts);
|
|
33
|
+
}
|
|
18
34
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
64
|
-
* @param {string} outputPath
|
|
65
|
-
* @param {Array
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
135
|
+
const sessionId = 200;
|
|
81
136
|
|
|
82
137
|
for (let defIdx = 0; defIdx < slideDefs.length; defIdx++) {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
96
|
-
const rootId = sourceModule ? nid(sourceModule) : slideId;
|
|
142
|
+
const rootId = sourceLayout.moduleId ?? sourceLayout.slideId;
|
|
97
143
|
const subtreeNodes = [];
|
|
98
|
-
deck.walkTree(rootId,
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
}
|