figmatk 0.0.6
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 +24 -0
- package/.claude-plugin/plugin.json +11 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/cli.mjs +101 -0
- package/commands/clone-slide.mjs +153 -0
- package/commands/insert-image.mjs +90 -0
- package/commands/inspect.mjs +91 -0
- package/commands/list-overrides.mjs +66 -0
- package/commands/list-text.mjs +60 -0
- package/commands/remove-slide.mjs +47 -0
- package/commands/roundtrip.mjs +37 -0
- package/commands/update-text.mjs +79 -0
- package/lib/api.mjs +2030 -0
- package/lib/blank-template.deck +0 -0
- package/lib/deep-clone.mjs +16 -0
- package/lib/fig-deck.mjs +307 -0
- package/lib/image-helpers.mjs +56 -0
- package/lib/image-utils.mjs +29 -0
- package/lib/node-helpers.mjs +49 -0
- package/mcp-server.mjs +251 -0
- package/package.json +63 -0
- package/skills/figmatk/SKILL.md +39 -0
package/lib/api.mjs
ADDED
|
@@ -0,0 +1,2030 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* figmatk programmatic API
|
|
3
|
+
*
|
|
4
|
+
* High-level Deck / Slide / Symbol classes wrapping FigDeck.
|
|
5
|
+
* Analogous to python-pptx's Presentation / Slide model.
|
|
6
|
+
*
|
|
7
|
+
* Phases implemented:
|
|
8
|
+
* Phase 1 — Read API (Deck.open, deck.slides, slide.textNodes, slide.imageNodes)
|
|
9
|
+
* Phase 2 — Text write (slide.setText, slide.setTexts)
|
|
10
|
+
* Phase 3 — Image write (slide.setImage)
|
|
11
|
+
* Phase 4 — Slide mgmt (deck.addSlide, deck.removeSlide, deck.moveSlide)
|
|
12
|
+
* Phase 5+ — Shape props (not yet implemented)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { FigDeck } from './fig-deck.mjs';
|
|
16
|
+
import { nid, parseId, positionChar } from './node-helpers.mjs';
|
|
17
|
+
import { imageOv, hexToHash } from './image-helpers.mjs';
|
|
18
|
+
import { deepClone } from './deep-clone.mjs';
|
|
19
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'fs';
|
|
20
|
+
import { createHash } from 'crypto';
|
|
21
|
+
import { join, resolve, dirname } from 'path';
|
|
22
|
+
import { fileURLToPath } from 'url';
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
import { getImageDimensions, generateThumbnail } from './image-utils.mjs';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Deck
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export class Deck {
|
|
32
|
+
constructor(figDeck, sourcePath = null) {
|
|
33
|
+
this._fd = figDeck;
|
|
34
|
+
this._sourcePath = sourcePath;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Open a .deck file.
|
|
39
|
+
* @param {string} path
|
|
40
|
+
* @returns {Promise<Deck>}
|
|
41
|
+
*/
|
|
42
|
+
static async open(path) {
|
|
43
|
+
const fd = await FigDeck.fromDeckFile(resolve(path));
|
|
44
|
+
return new Deck(fd, resolve(path));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a new blank deck from scratch.
|
|
49
|
+
* Includes the Light Slides theme with all 8 text styles and 23 colors.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} [opts]
|
|
52
|
+
* @param {string} [opts.name] - Presentation name (default: 'Untitled')
|
|
53
|
+
* @returns {Promise<Deck>}
|
|
54
|
+
*/
|
|
55
|
+
static async create(opts = {}) {
|
|
56
|
+
const templatePath = join(__dirname, 'blank-template.deck');
|
|
57
|
+
const fd = await FigDeck.fromDeckFile(templatePath);
|
|
58
|
+
fd.deckMeta = { file_name: opts.name ?? 'Untitled', version: '1' };
|
|
59
|
+
return new Deck(fd, null);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Presentation metadata from meta.json */
|
|
63
|
+
get meta() {
|
|
64
|
+
return this._fd.deckMeta ?? {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Ordered list of active (non-REMOVED) Slide objects */
|
|
68
|
+
get slides() {
|
|
69
|
+
return this._fd.getActiveSlides().map(n => new Slide(this._fd, n));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Slide width in px (read from first active SLIDE node). */
|
|
73
|
+
get slideWidth() {
|
|
74
|
+
const slide = this._fd.getActiveSlides()[0];
|
|
75
|
+
return slide?.size?.x ?? 1920;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Slide height in px (read from first active SLIDE node). */
|
|
79
|
+
get slideHeight() {
|
|
80
|
+
const slide = this._fd.getActiveSlides()[0];
|
|
81
|
+
return slide?.size?.y ?? 1080;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** All SYMBOL nodes available as templates */
|
|
85
|
+
get symbols() {
|
|
86
|
+
return this._fd.getSymbols().map(n => new Symbol(this._fd, n));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Save to a file. Defaults to overwriting the source path.
|
|
91
|
+
* @param {string} [outPath]
|
|
92
|
+
*/
|
|
93
|
+
async save(outPath) {
|
|
94
|
+
const target = outPath ? resolve(outPath) : this._sourcePath;
|
|
95
|
+
if (!target) throw new Error('No output path specified and no source path known');
|
|
96
|
+
await this._fd.saveDeck(target);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Phase 4: Slide management -------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Add a new blank slide (no template/symbol).
|
|
103
|
+
* Use this for building slides from scratch with addText(), addImage(), etc.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} [opts]
|
|
106
|
+
* @param {string} [opts.name] - Slide name (default: auto-numbered)
|
|
107
|
+
* @param {string|object} [opts.background] - Named color or { r, g, b }
|
|
108
|
+
* @returns {Slide}
|
|
109
|
+
*/
|
|
110
|
+
addBlankSlide(opts = {}) {
|
|
111
|
+
const fd = this._fd;
|
|
112
|
+
|
|
113
|
+
// Clone structure from the first slide
|
|
114
|
+
const templateSlide = fd.getActiveSlides()[0];
|
|
115
|
+
if (!templateSlide) throw new Error('No slides to clone structure from');
|
|
116
|
+
|
|
117
|
+
const slideRowId = templateSlide.parentIndex?.guid
|
|
118
|
+
? `${templateSlide.parentIndex.guid.sessionID}:${templateSlide.parentIndex.guid.localID}`
|
|
119
|
+
: null;
|
|
120
|
+
|
|
121
|
+
const localID = fd.maxLocalID() + 1;
|
|
122
|
+
const activeCount = fd.getActiveSlides().length;
|
|
123
|
+
|
|
124
|
+
const newSlide = deepClone(templateSlide);
|
|
125
|
+
newSlide.guid = { sessionID: 1, localID };
|
|
126
|
+
newSlide.name = opts.name ?? `${activeCount + 1}`;
|
|
127
|
+
newSlide.phase = 'CREATED';
|
|
128
|
+
delete newSlide.prototypeInteractions;
|
|
129
|
+
delete newSlide.slideThumbnailHash;
|
|
130
|
+
delete newSlide.editInfo;
|
|
131
|
+
|
|
132
|
+
// Position in SLIDE_ROW
|
|
133
|
+
if (slideRowId) {
|
|
134
|
+
newSlide.parentIndex = {
|
|
135
|
+
guid: parseId(slideRowId),
|
|
136
|
+
position: positionChar(activeCount),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// X transform position
|
|
141
|
+
if (newSlide.transform) {
|
|
142
|
+
newSlide.transform.m02 = activeCount * 2160;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// White background by default
|
|
146
|
+
newSlide.fillPaints = [{
|
|
147
|
+
type: 'SOLID',
|
|
148
|
+
color: { r: 1, g: 1, b: 1, a: 1 },
|
|
149
|
+
opacity: 1,
|
|
150
|
+
visible: true,
|
|
151
|
+
blendMode: 'NORMAL',
|
|
152
|
+
}];
|
|
153
|
+
|
|
154
|
+
fd.message.nodeChanges.push(newSlide);
|
|
155
|
+
fd.rebuildMaps();
|
|
156
|
+
|
|
157
|
+
const slide = new Slide(fd, newSlide);
|
|
158
|
+
|
|
159
|
+
// Apply background if specified
|
|
160
|
+
if (opts.background) {
|
|
161
|
+
slide.setBackground(opts.background);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return slide;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Add a new slide by cloning a Symbol (template).
|
|
169
|
+
* @param {Symbol} symbol - Template to clone from
|
|
170
|
+
* @param {object} [opts]
|
|
171
|
+
* @param {Slide} [opts.after] - Insert after this slide (default: end)
|
|
172
|
+
* @param {string} [opts.name] - Slide name (default: symbol name)
|
|
173
|
+
* @returns {Slide}
|
|
174
|
+
*/
|
|
175
|
+
addSlide(symbol, opts = {}) {
|
|
176
|
+
const fd = this._fd;
|
|
177
|
+
|
|
178
|
+
// Find a representative slide that uses this symbol as a template source
|
|
179
|
+
// to clone its SLIDE node structure (transform, size, etc.)
|
|
180
|
+
const templateSlide = fd.getActiveSlides().find(s => {
|
|
181
|
+
const inst = fd.getSlideInstance(nid(s));
|
|
182
|
+
return inst?.symbolData?.symbolID &&
|
|
183
|
+
nid({ guid: inst.symbolData.symbolID }) === nid(symbol._node);
|
|
184
|
+
}) ?? fd.getActiveSlides()[0];
|
|
185
|
+
|
|
186
|
+
if (!templateSlide) throw new Error('No slides to clone structure from');
|
|
187
|
+
|
|
188
|
+
const templateInst = fd.getSlideInstance(nid(templateSlide));
|
|
189
|
+
if (!templateInst) throw new Error('Template slide has no instance');
|
|
190
|
+
|
|
191
|
+
const slideRowId = templateSlide.parentIndex?.guid
|
|
192
|
+
? `${templateSlide.parentIndex.guid.sessionID}:${templateSlide.parentIndex.guid.localID}`
|
|
193
|
+
: null;
|
|
194
|
+
|
|
195
|
+
// Assign new GUIDs
|
|
196
|
+
let nextId = fd.maxLocalID() + 1;
|
|
197
|
+
const slideLocalId = nextId++;
|
|
198
|
+
const instLocalId = nextId++;
|
|
199
|
+
|
|
200
|
+
// Clone SLIDE node
|
|
201
|
+
const newSlide = deepClone(templateSlide);
|
|
202
|
+
newSlide.guid = { sessionID: 1, localID: slideLocalId };
|
|
203
|
+
newSlide.name = opts.name ?? symbol._node.name ?? 'New Slide';
|
|
204
|
+
newSlide.phase = 'CREATED';
|
|
205
|
+
delete newSlide.prototypeInteractions;
|
|
206
|
+
delete newSlide.slideThumbnailHash;
|
|
207
|
+
delete newSlide.editInfo;
|
|
208
|
+
|
|
209
|
+
// Position in SLIDE_ROW
|
|
210
|
+
if (slideRowId) {
|
|
211
|
+
const activeCount = fd.getActiveSlides().length;
|
|
212
|
+
const insertAt = opts.after
|
|
213
|
+
? fd.getActiveSlides().indexOf(opts.after._node) + 1
|
|
214
|
+
: activeCount;
|
|
215
|
+
newSlide.parentIndex = {
|
|
216
|
+
guid: parseId(slideRowId),
|
|
217
|
+
position: positionChar(insertAt),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// X transform position
|
|
222
|
+
if (newSlide.transform) {
|
|
223
|
+
const activeCount = fd.getActiveSlides().length;
|
|
224
|
+
newSlide.transform.m02 = activeCount * 2160;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Clone INSTANCE node, pointing at the given symbol
|
|
228
|
+
const newInst = deepClone(templateInst);
|
|
229
|
+
newInst.guid = { sessionID: 1, localID: instLocalId };
|
|
230
|
+
newInst.name = newSlide.name;
|
|
231
|
+
newInst.phase = 'CREATED';
|
|
232
|
+
newInst.parentIndex = { guid: { sessionID: 1, localID: slideLocalId }, position: '!' };
|
|
233
|
+
newInst.symbolData = {
|
|
234
|
+
symbolID: deepClone(symbol._node.guid),
|
|
235
|
+
symbolOverrides: [],
|
|
236
|
+
uniformScaleFactor: 1,
|
|
237
|
+
};
|
|
238
|
+
delete newInst.derivedSymbolData;
|
|
239
|
+
delete newInst.derivedSymbolDataLayoutVersion;
|
|
240
|
+
delete newInst.editInfo;
|
|
241
|
+
|
|
242
|
+
fd.message.nodeChanges.push(newSlide);
|
|
243
|
+
fd.message.nodeChanges.push(newInst);
|
|
244
|
+
fd.rebuildMaps();
|
|
245
|
+
|
|
246
|
+
return new Slide(fd, newSlide);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Remove a slide (marks as REMOVED — never deletes from nodeChanges).
|
|
251
|
+
* @param {Slide} slide
|
|
252
|
+
*/
|
|
253
|
+
removeSlide(slide) {
|
|
254
|
+
const node = slide._node;
|
|
255
|
+
node.phase = 'REMOVED';
|
|
256
|
+
delete node.prototypeInteractions;
|
|
257
|
+
|
|
258
|
+
const inst = this._fd.getSlideInstance(nid(node));
|
|
259
|
+
if (inst) {
|
|
260
|
+
inst.phase = 'REMOVED';
|
|
261
|
+
delete inst.prototypeInteractions;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Move a slide to a given index (0-based) in the active slide list.
|
|
267
|
+
* Adjusts parentIndex.position characters to reorder.
|
|
268
|
+
* @param {Slide} slide
|
|
269
|
+
* @param {number} toIndex
|
|
270
|
+
*/
|
|
271
|
+
moveSlide(slide, toIndex) {
|
|
272
|
+
const active = this._fd.getActiveSlides();
|
|
273
|
+
const fromIndex = active.indexOf(slide._node);
|
|
274
|
+
if (fromIndex === -1) throw new Error('Slide not found in active slides');
|
|
275
|
+
|
|
276
|
+
// Reorder array
|
|
277
|
+
active.splice(fromIndex, 1);
|
|
278
|
+
active.splice(toIndex, 0, slide._node);
|
|
279
|
+
|
|
280
|
+
// Reassign position characters
|
|
281
|
+
active.forEach((s, i) => {
|
|
282
|
+
s.parentIndex.position = positionChar(i);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Slide
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
export class Slide {
|
|
292
|
+
constructor(figDeck, slideNode) {
|
|
293
|
+
this._fd = figDeck;
|
|
294
|
+
this._node = slideNode;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
get name() { return this._node.name; }
|
|
298
|
+
get guid() { return nid(this._node); }
|
|
299
|
+
get index() { return this._fd.getActiveSlides().indexOf(this._node); }
|
|
300
|
+
|
|
301
|
+
// --- Shape access ----------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* All direct child nodes on this slide as Shape objects.
|
|
305
|
+
* Use for reading/writing geometry, visibility, opacity on any node.
|
|
306
|
+
* @returns {Shape[]}
|
|
307
|
+
*/
|
|
308
|
+
get shapes() {
|
|
309
|
+
return this._fd.getChildren(nid(this._node))
|
|
310
|
+
.filter(n => n.phase !== 'REMOVED')
|
|
311
|
+
.map(n => new Shape(n, this._fd));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- Slide background -----------------------------------------------------
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get or set the slide background color.
|
|
318
|
+
*
|
|
319
|
+
* Named color: slide.setBackground('Blue')
|
|
320
|
+
* Raw RGB: slide.setBackground({ r: 1, g: 0, b: 0 })
|
|
321
|
+
* Named + opacity: slide.setBackground('Red', { opacity: 0.5 })
|
|
322
|
+
*
|
|
323
|
+
* @param {string|object} color - Named color string or { r, g, b } (0-1)
|
|
324
|
+
* @param {object} [opts]
|
|
325
|
+
* @param {number} [opts.opacity] - Fill opacity 0-1 (default: 1)
|
|
326
|
+
*/
|
|
327
|
+
setBackground(color, opts = {}) {
|
|
328
|
+
const opacity = opts.opacity ?? 1;
|
|
329
|
+
let rgb, colorVar;
|
|
330
|
+
|
|
331
|
+
if (typeof color === 'string') {
|
|
332
|
+
const variable = resolveColorVariable(this._fd, color);
|
|
333
|
+
rgb = { r: variable.r, g: variable.g, b: variable.b, a: 1 };
|
|
334
|
+
colorVar = {
|
|
335
|
+
value: { alias: { guid: deepClone(variable.guid) } },
|
|
336
|
+
dataType: 'ALIAS',
|
|
337
|
+
resolvedDataType: 'COLOR',
|
|
338
|
+
};
|
|
339
|
+
} else {
|
|
340
|
+
rgb = { r: color.r, g: color.g, b: color.b, a: color.a ?? 1 };
|
|
341
|
+
colorVar = undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const fill = {
|
|
345
|
+
type: 'SOLID',
|
|
346
|
+
color: rgb,
|
|
347
|
+
opacity,
|
|
348
|
+
visible: true,
|
|
349
|
+
blendMode: 'NORMAL',
|
|
350
|
+
};
|
|
351
|
+
if (colorVar) fill.colorVar = colorVar;
|
|
352
|
+
|
|
353
|
+
this._node.fillPaints = [fill];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Get the current background color as { r, g, b, a }. */
|
|
357
|
+
get background() {
|
|
358
|
+
const fill = this._node.fillPaints?.[0];
|
|
359
|
+
if (!fill?.color) return null;
|
|
360
|
+
return { ...fill.color };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The INSTANCE child of this slide */
|
|
364
|
+
get _instance() {
|
|
365
|
+
return this._fd.getSlideInstance(nid(this._node));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The SYMBOL this slide's instance references */
|
|
369
|
+
get _symbol() {
|
|
370
|
+
const inst = this._instance;
|
|
371
|
+
if (!inst?.symbolData?.symbolID) return null;
|
|
372
|
+
const { sessionID, localID } = inst.symbolData.symbolID;
|
|
373
|
+
return this._fd.getNode(`${sessionID}:${localID}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* All TEXT nodes in the symbol that have an overrideKey,
|
|
378
|
+
* merged with any active text overrides on this instance.
|
|
379
|
+
* @returns {TextNode[]}
|
|
380
|
+
*/
|
|
381
|
+
get textNodes() {
|
|
382
|
+
const sym = this._symbol;
|
|
383
|
+
if (!sym) return [];
|
|
384
|
+
const nodes = [];
|
|
385
|
+
this._fd.walkTree(nid(sym), (node) => {
|
|
386
|
+
if (node.type === 'TEXT' && node.overrideKey) {
|
|
387
|
+
const ov = this._findTextOverride(node.overrideKey);
|
|
388
|
+
nodes.push(new TextNode(node, ov));
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
return nodes;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Image placeholder nodes in the symbol (ROUNDED_RECTANGLE with overrideKey).
|
|
396
|
+
* @returns {ImageNode[]}
|
|
397
|
+
*/
|
|
398
|
+
get imageNodes() {
|
|
399
|
+
const sym = this._symbol;
|
|
400
|
+
if (!sym) return [];
|
|
401
|
+
const nodes = [];
|
|
402
|
+
this._fd.walkTree(nid(sym), (node) => {
|
|
403
|
+
if (node.overrideKey &&
|
|
404
|
+
(node.type === 'ROUNDED_RECTANGLE' || node.type === 'RECTANGLE') &&
|
|
405
|
+
node.fillPaints?.some(p => p.type === 'IMAGE')) {
|
|
406
|
+
const ov = this._findImageOverride(node.overrideKey);
|
|
407
|
+
nodes.push(new ImageNode(node, ov));
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
return nodes;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- Phase 2: Text write --------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Set text on a placeholder by name or override key string ("s:l").
|
|
417
|
+
* @param {string} nameOrKey - Node name (e.g. "Title") or key "57:48"
|
|
418
|
+
* @param {string} value
|
|
419
|
+
*/
|
|
420
|
+
setText(nameOrKey, value) {
|
|
421
|
+
const key = this._resolveTextKey(nameOrKey);
|
|
422
|
+
if (!key) throw new Error(`Text node not found: ${nameOrKey}`);
|
|
423
|
+
|
|
424
|
+
const chars = (value === '' || value == null) ? ' ' : value;
|
|
425
|
+
const overrides = this._ensureOverrides();
|
|
426
|
+
const existing = this._findTextOverride(key);
|
|
427
|
+
|
|
428
|
+
if (existing) {
|
|
429
|
+
existing.textData.characters = chars;
|
|
430
|
+
} else {
|
|
431
|
+
overrides.push({ guidPath: { guids: [key] }, textData: { characters: chars } });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Set multiple text values at once.
|
|
437
|
+
* @param {Record<string, string>} map - { nameOrKey: value }
|
|
438
|
+
*/
|
|
439
|
+
setTexts(map) {
|
|
440
|
+
for (const [k, v] of Object.entries(map)) {
|
|
441
|
+
this.setText(k, v);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// --- Phase 3: Image write -------------------------------------------------
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Set an image on a placeholder by name or override key string ("s:l").
|
|
449
|
+
* Handles SHA-1 hashing, thumbnail generation, images/ dir management.
|
|
450
|
+
* @param {string} nameOrKey - Node name or key "57:48"
|
|
451
|
+
* @param {string|Buffer} pathOrBuf
|
|
452
|
+
*/
|
|
453
|
+
async setImage(nameOrKey, pathOrBuf) {
|
|
454
|
+
const key = this._resolveImageKey(nameOrKey);
|
|
455
|
+
if (!key) throw new Error(`Image node not found: ${nameOrKey}`);
|
|
456
|
+
|
|
457
|
+
const imgBuf = typeof pathOrBuf === 'string'
|
|
458
|
+
? readFileSync(resolve(pathOrBuf))
|
|
459
|
+
: pathOrBuf;
|
|
460
|
+
const imgPath = typeof pathOrBuf === 'string' ? resolve(pathOrBuf) : null;
|
|
461
|
+
|
|
462
|
+
const imgHash = sha1Hex(imgBuf);
|
|
463
|
+
|
|
464
|
+
const { width, height } = await getImageDimensions(imgBuf);
|
|
465
|
+
|
|
466
|
+
const tmpThumb = `/tmp/figmatk_thumb_${Date.now()}.png`;
|
|
467
|
+
await generateThumbnail(imgBuf, tmpThumb);
|
|
468
|
+
const thumbHash = sha1Hex(readFileSync(tmpThumb));
|
|
469
|
+
|
|
470
|
+
copyToImagesDir(this._fd, imgHash, imgPath ?? (() => {
|
|
471
|
+
const tmp = `/tmp/figmatk_img_${Date.now()}`;
|
|
472
|
+
writeFileSync(tmp, imgBuf);
|
|
473
|
+
return tmp;
|
|
474
|
+
})());
|
|
475
|
+
copyToImagesDir(this._fd, thumbHash, tmpThumb);
|
|
476
|
+
|
|
477
|
+
const override = imageOv(key, imgHash, thumbHash, width, height);
|
|
478
|
+
const overrides = this._ensureOverrides();
|
|
479
|
+
|
|
480
|
+
// Replace existing image override for this key if present
|
|
481
|
+
const existingIdx = overrides.findIndex(o =>
|
|
482
|
+
o.fillPaints &&
|
|
483
|
+
o.guidPath?.guids?.length >= 1 &&
|
|
484
|
+
o.guidPath.guids[0].sessionID === key.sessionID &&
|
|
485
|
+
o.guidPath.guids[0].localID === key.localID
|
|
486
|
+
);
|
|
487
|
+
if (existingIdx >= 0) {
|
|
488
|
+
overrides.splice(existingIdx, 1, override);
|
|
489
|
+
} else {
|
|
490
|
+
overrides.push(override);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// --- Internals ------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
_ensureOverrides() {
|
|
497
|
+
const inst = this._instance;
|
|
498
|
+
if (!inst) throw new Error(`Slide ${this.guid} has no instance`);
|
|
499
|
+
if (!inst.symbolData) inst.symbolData = {};
|
|
500
|
+
if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
|
|
501
|
+
return inst.symbolData.symbolOverrides;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
_findTextOverride(key) {
|
|
505
|
+
const overrides = this._instance?.symbolData?.symbolOverrides ?? [];
|
|
506
|
+
return overrides.find(o =>
|
|
507
|
+
o.textData &&
|
|
508
|
+
o.guidPath?.guids?.length === 1 &&
|
|
509
|
+
o.guidPath.guids[0].sessionID === key.sessionID &&
|
|
510
|
+
o.guidPath.guids[0].localID === key.localID
|
|
511
|
+
) ?? null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
_findImageOverride(key) {
|
|
515
|
+
const overrides = this._instance?.symbolData?.symbolOverrides ?? [];
|
|
516
|
+
return overrides.find(o =>
|
|
517
|
+
o.fillPaints &&
|
|
518
|
+
o.guidPath?.guids?.length >= 1 &&
|
|
519
|
+
o.guidPath.guids[0].sessionID === key.sessionID &&
|
|
520
|
+
o.guidPath.guids[0].localID === key.localID
|
|
521
|
+
) ?? null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Resolve a name or "s:l" string to an overrideKey from the symbol tree. */
|
|
525
|
+
_resolveTextKey(nameOrKey) {
|
|
526
|
+
// Try as "s:l" string first
|
|
527
|
+
if (/^\d+:\d+$/.test(nameOrKey)) return parseId(nameOrKey);
|
|
528
|
+
|
|
529
|
+
const sym = this._symbol;
|
|
530
|
+
if (!sym) return null;
|
|
531
|
+
let found = null;
|
|
532
|
+
this._fd.walkTree(nid(sym), (node) => {
|
|
533
|
+
if (!found && node.type === 'TEXT' && node.name === nameOrKey && node.overrideKey) {
|
|
534
|
+
found = node.overrideKey;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
return found;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// --- Phase 2.8: Shape creation (validated) --------------------------------
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Add a rectangle (ROUNDED_RECTANGLE) directly to this slide.
|
|
544
|
+
* Validated: fillGeometry not required, Figma recomputes it.
|
|
545
|
+
*
|
|
546
|
+
* @param {number} x
|
|
547
|
+
* @param {number} y
|
|
548
|
+
* @param {number} width
|
|
549
|
+
* @param {number} height
|
|
550
|
+
* @param {object} [opts]
|
|
551
|
+
* @param {object} [opts.fill] - { r, g, b, a } normalized 0-1 (default white)
|
|
552
|
+
* @param {string} [opts.name] - Node name
|
|
553
|
+
* @param {number} [opts.opacity] - 0-1 (default 1)
|
|
554
|
+
* @param {number} [opts.cornerRadius]- per-corner radius (default 0)
|
|
555
|
+
* @returns {object} the raw node (for further manipulation)
|
|
556
|
+
*/
|
|
557
|
+
addRectangle(x, y, width, height, opts = {}) {
|
|
558
|
+
const fd = this._fd;
|
|
559
|
+
const localID = fd.maxLocalID() + 1;
|
|
560
|
+
const fill = opts.fill ?? { r: 1, g: 1, b: 1, a: 1 };
|
|
561
|
+
|
|
562
|
+
const node = {
|
|
563
|
+
guid: { sessionID: 1, localID },
|
|
564
|
+
phase: 'CREATED',
|
|
565
|
+
parentIndex: {
|
|
566
|
+
guid: this._node.guid,
|
|
567
|
+
position: positionChar(fd.getChildren(nid(this._node)).length),
|
|
568
|
+
},
|
|
569
|
+
type: 'ROUNDED_RECTANGLE',
|
|
570
|
+
name: opts.name ?? 'Rectangle',
|
|
571
|
+
visible: true,
|
|
572
|
+
opacity: opts.opacity ?? 1,
|
|
573
|
+
size: { x: width, y: height },
|
|
574
|
+
transform: { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y },
|
|
575
|
+
strokeWeight: 1,
|
|
576
|
+
strokeAlign: 'INSIDE',
|
|
577
|
+
strokeJoin: 'MITER',
|
|
578
|
+
...(opts.cornerRadius ? {
|
|
579
|
+
cornerRadius: opts.cornerRadius,
|
|
580
|
+
rectangleTopLeftCornerRadius: opts.cornerRadius,
|
|
581
|
+
rectangleTopRightCornerRadius: opts.cornerRadius,
|
|
582
|
+
rectangleBottomLeftCornerRadius: opts.cornerRadius,
|
|
583
|
+
rectangleBottomRightCornerRadius: opts.cornerRadius,
|
|
584
|
+
} : {}),
|
|
585
|
+
fillPaints: [{
|
|
586
|
+
type: 'SOLID',
|
|
587
|
+
color: { r: fill.r, g: fill.g, b: fill.b, a: fill.a ?? 1 },
|
|
588
|
+
opacity: 1,
|
|
589
|
+
visible: true,
|
|
590
|
+
blendMode: 'NORMAL',
|
|
591
|
+
}],
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
fd.message.nodeChanges.push(node);
|
|
595
|
+
fd.rebuildMaps();
|
|
596
|
+
return node;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// --- Phase 2.8: Image placement --------------------------------------------
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Add a freestanding image directly on this slide.
|
|
603
|
+
* The image is placed as a ROUNDED_RECTANGLE with an IMAGE fill.
|
|
604
|
+
*
|
|
605
|
+
* @param {number} x
|
|
606
|
+
* @param {number} y
|
|
607
|
+
* @param {number} width
|
|
608
|
+
* @param {number} height
|
|
609
|
+
* @param {string|Buffer} pathOrBuf - Image file path or Buffer
|
|
610
|
+
* @param {object} [opts]
|
|
611
|
+
* @param {string} [opts.name] - Node name
|
|
612
|
+
* @param {string} [opts.scaleMode] - 'FILL' | 'FIT' | 'CROP' | 'TILE' (default: 'FILL')
|
|
613
|
+
* @param {number} [opts.cornerRadius]- Corner radius (default: 0)
|
|
614
|
+
* @returns {Promise<object>} the raw node
|
|
615
|
+
*/
|
|
616
|
+
async addImage(x, y, width, height, pathOrBuf, opts = {}) {
|
|
617
|
+
const fd = this._fd;
|
|
618
|
+
const localID = fd.maxLocalID() + 1;
|
|
619
|
+
|
|
620
|
+
const imgBuf = typeof pathOrBuf === 'string'
|
|
621
|
+
? readFileSync(resolve(pathOrBuf))
|
|
622
|
+
: pathOrBuf;
|
|
623
|
+
const imgPath = typeof pathOrBuf === 'string' ? resolve(pathOrBuf) : null;
|
|
624
|
+
|
|
625
|
+
const imgHash = sha1Hex(imgBuf);
|
|
626
|
+
const { width: origW, height: origH } = await getImageDimensions(imgBuf);
|
|
627
|
+
|
|
628
|
+
// Generate thumbnail
|
|
629
|
+
const tmpThumb = `/tmp/figmatk_thumb_${Date.now()}.png`;
|
|
630
|
+
await generateThumbnail(imgBuf, tmpThumb);
|
|
631
|
+
const thumbHash = sha1Hex(readFileSync(tmpThumb));
|
|
632
|
+
|
|
633
|
+
// Copy both to images dir
|
|
634
|
+
if (imgPath) {
|
|
635
|
+
copyToImagesDir(fd, imgHash, imgPath);
|
|
636
|
+
} else {
|
|
637
|
+
const tmpImg = `/tmp/figmatk_img_${Date.now()}`;
|
|
638
|
+
writeFileSync(tmpImg, imgBuf);
|
|
639
|
+
copyToImagesDir(fd, imgHash, tmpImg);
|
|
640
|
+
}
|
|
641
|
+
copyToImagesDir(fd, thumbHash, tmpThumb);
|
|
642
|
+
|
|
643
|
+
const node = {
|
|
644
|
+
guid: { sessionID: 1, localID },
|
|
645
|
+
phase: 'CREATED',
|
|
646
|
+
parentIndex: {
|
|
647
|
+
guid: this._node.guid,
|
|
648
|
+
position: positionChar(fd.getChildren(nid(this._node)).length),
|
|
649
|
+
},
|
|
650
|
+
type: 'ROUNDED_RECTANGLE',
|
|
651
|
+
name: opts.name ?? 'Image',
|
|
652
|
+
visible: true,
|
|
653
|
+
opacity: 1,
|
|
654
|
+
proportionsConstrained: true,
|
|
655
|
+
size: { x: width, y: height },
|
|
656
|
+
transform: { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y },
|
|
657
|
+
strokeWeight: 1,
|
|
658
|
+
strokeAlign: 'INSIDE',
|
|
659
|
+
strokeJoin: 'MITER',
|
|
660
|
+
...(opts.cornerRadius ? {
|
|
661
|
+
cornerRadius: opts.cornerRadius,
|
|
662
|
+
rectangleTopLeftCornerRadius: opts.cornerRadius,
|
|
663
|
+
rectangleTopRightCornerRadius: opts.cornerRadius,
|
|
664
|
+
rectangleBottomLeftCornerRadius: opts.cornerRadius,
|
|
665
|
+
rectangleBottomRightCornerRadius: opts.cornerRadius,
|
|
666
|
+
} : {}),
|
|
667
|
+
fillPaints: [{
|
|
668
|
+
type: 'IMAGE',
|
|
669
|
+
opacity: 1,
|
|
670
|
+
visible: true,
|
|
671
|
+
blendMode: 'NORMAL',
|
|
672
|
+
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
673
|
+
image: { hash: hexToHash(imgHash), name: imgHash },
|
|
674
|
+
imageThumbnail: { hash: hexToHash(thumbHash), name: thumbHash },
|
|
675
|
+
animationFrame: 0,
|
|
676
|
+
imageScaleMode: opts.scaleMode ?? 'FILL',
|
|
677
|
+
imageShouldColorManage: false,
|
|
678
|
+
rotation: 0,
|
|
679
|
+
scale: 0.5,
|
|
680
|
+
originalImageWidth: origW,
|
|
681
|
+
originalImageHeight: origH,
|
|
682
|
+
thumbHash: new Uint8Array(0),
|
|
683
|
+
altText: '',
|
|
684
|
+
}],
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
fd.message.nodeChanges.push(node);
|
|
688
|
+
fd.rebuildMaps();
|
|
689
|
+
return node;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// --- Phase 2.8: Text creation (validated) ----------------------------------
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Add a freestanding text node directly to this slide.
|
|
696
|
+
*
|
|
697
|
+
* Simple text:
|
|
698
|
+
* slide.addText('Hello World', { style: 'Title' })
|
|
699
|
+
* slide.addText('Body copy', { style: 'Body 1', color: { r: 1, g: 1, b: 1 } })
|
|
700
|
+
*
|
|
701
|
+
* Per-run formatting (bold, italic, underline, strikethrough, hyperlinks):
|
|
702
|
+
* slide.addText([
|
|
703
|
+
* { text: 'Normal ' },
|
|
704
|
+
* { text: 'bold', bold: true },
|
|
705
|
+
* { text: ' and ' },
|
|
706
|
+
* { text: 'italic', italic: true },
|
|
707
|
+
* { text: ' with a ' },
|
|
708
|
+
* { text: 'link', hyperlink: 'https://example.com' },
|
|
709
|
+
* ], { style: 'Body 1' })
|
|
710
|
+
*
|
|
711
|
+
* Lists — simple (all lines same type):
|
|
712
|
+
* slide.addText('One\nTwo\nThree', { style: 'Body 1', list: 'bullet' })
|
|
713
|
+
* slide.addText('One\nTwo\nThree', { style: 'Body 1', list: 'number' })
|
|
714
|
+
*
|
|
715
|
+
* Lists — per-run control (mixed types, nesting):
|
|
716
|
+
* slide.addText([
|
|
717
|
+
* { text: 'Heading\n' },
|
|
718
|
+
* { text: 'Bullet\n', bullet: true },
|
|
719
|
+
* { text: 'Nested\n', bullet: true, indent: 2 },
|
|
720
|
+
* { text: 'Numbered\n', number: true },
|
|
721
|
+
* ], { style: 'Body 1' })
|
|
722
|
+
*
|
|
723
|
+
* Custom font (detaches from named style):
|
|
724
|
+
* slide.addText('Custom', { font: 'Georgia', fontSize: 36 })
|
|
725
|
+
*
|
|
726
|
+
* @param {string|Array<{text:string, bold?:boolean, italic?:boolean,
|
|
727
|
+
* underline?:boolean, strikethrough?:boolean, hyperlink?:string}>} textOrRuns
|
|
728
|
+
* @param {object} [opts]
|
|
729
|
+
* @param {string} [opts.style] - Named text style: 'Title', 'Header 1'-'Header 3',
|
|
730
|
+
* 'Body 1'-'Body 3', 'Note'
|
|
731
|
+
* @param {number} [opts.x] - X position on slide (default: 128)
|
|
732
|
+
* @param {number} [opts.y] - Y position on slide (default: 128)
|
|
733
|
+
* @param {number} [opts.width] - Text box width (default: 1200)
|
|
734
|
+
* @param {string} [opts.font] - Font family (e.g. 'Georgia') — detaches style
|
|
735
|
+
* @param {string} [opts.fontStyle] - Font style/weight (e.g. 'Bold', 'Italic') — detaches style
|
|
736
|
+
* @param {number} [opts.fontSize] - Font size — detaches style if no named style
|
|
737
|
+
* @param {object} [opts.color] - Fill color { r, g, b } normalized 0-1
|
|
738
|
+
* @param {string} [opts.align] - Horizontal alignment: 'LEFT' | 'CENTER' | 'RIGHT'
|
|
739
|
+
* @param {string} [opts.list] - List type for all lines: 'bullet' | 'number'
|
|
740
|
+
* @param {string} [opts.name] - Node name
|
|
741
|
+
* @returns {object} the raw TEXT node
|
|
742
|
+
*/
|
|
743
|
+
addText(textOrRuns, opts = {}) {
|
|
744
|
+
const fd = this._fd;
|
|
745
|
+
const localID = fd.maxLocalID() + 1;
|
|
746
|
+
|
|
747
|
+
// Normalize input — string or array of runs
|
|
748
|
+
const isRuns = Array.isArray(textOrRuns);
|
|
749
|
+
const fullText = isRuns
|
|
750
|
+
? textOrRuns.map(r => r.text).join('')
|
|
751
|
+
: textOrRuns;
|
|
752
|
+
|
|
753
|
+
const styleDef = opts.style ? resolveTextStyle(fd, opts.style) : null;
|
|
754
|
+
const isDetached = !!(opts.font || (!opts.style && opts.fontSize));
|
|
755
|
+
|
|
756
|
+
// Resolve typography — from named style, or explicit, or defaults
|
|
757
|
+
let fontName, fontSize, lineHeight, letterSpacing, textTracking, styleIdForText;
|
|
758
|
+
|
|
759
|
+
if (styleDef && !isDetached) {
|
|
760
|
+
// Use named style — inherit typography from the style definition node
|
|
761
|
+
fontName = deepClone(styleDef.fontName);
|
|
762
|
+
fontSize = styleDef.fontSize;
|
|
763
|
+
lineHeight = deepClone(styleDef.lineHeight);
|
|
764
|
+
letterSpacing = deepClone(styleDef.letterSpacing);
|
|
765
|
+
textTracking = styleDef.textTracking ?? 0;
|
|
766
|
+
styleIdForText = { guid: deepClone(styleDef.guid) };
|
|
767
|
+
} else {
|
|
768
|
+
// Detached or no style — explicit fields
|
|
769
|
+
fontName = {
|
|
770
|
+
family: opts.font ?? 'Inter',
|
|
771
|
+
style: opts.fontStyle ?? 'Regular',
|
|
772
|
+
postscript: '',
|
|
773
|
+
};
|
|
774
|
+
fontSize = opts.fontSize ?? 36;
|
|
775
|
+
lineHeight = { value: 1.4, units: 'RAW' };
|
|
776
|
+
letterSpacing = { value: 0, units: 'PERCENT' };
|
|
777
|
+
textTracking = 0;
|
|
778
|
+
styleIdForText = DETACHED_STYLE_ID;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Allow overriding individual properties even with a named style
|
|
782
|
+
if (opts.fontSize && styleDef && !isDetached) {
|
|
783
|
+
fontSize = opts.fontSize;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const chars = (fullText === '' || fullText == null) ? ' ' : fullText;
|
|
787
|
+
const textData = { characters: chars };
|
|
788
|
+
|
|
789
|
+
// Build per-run formatting overrides
|
|
790
|
+
if (isRuns && textOrRuns.some(r => r.bold || r.italic || r.underline || r.strikethrough || r.hyperlink)) {
|
|
791
|
+
const overrides = buildRunOverrides(textOrRuns, fontName, styleIdForText);
|
|
792
|
+
textData.styleOverrideTable = overrides.styleOverrideTable;
|
|
793
|
+
textData.characterStyleIDs = overrides.characterStyleIDs;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Build lines array for list/paragraph formatting
|
|
797
|
+
const hasListRuns = isRuns && textOrRuns.some(r => r.bullet || r.number);
|
|
798
|
+
if (opts.list || hasListRuns) {
|
|
799
|
+
textData.lines = buildLines(chars, isRuns ? textOrRuns : null, opts.list);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const fillColor = opts.color ?? { r: 0, g: 0, b: 0 };
|
|
803
|
+
|
|
804
|
+
const node = {
|
|
805
|
+
guid: { sessionID: 1, localID },
|
|
806
|
+
phase: 'CREATED',
|
|
807
|
+
parentIndex: {
|
|
808
|
+
guid: this._node.guid,
|
|
809
|
+
position: positionChar(fd.getChildren(nid(this._node)).length),
|
|
810
|
+
},
|
|
811
|
+
type: 'TEXT',
|
|
812
|
+
name: opts.name ?? 'Text',
|
|
813
|
+
visible: true,
|
|
814
|
+
opacity: 1,
|
|
815
|
+
size: { x: opts.width ?? 1200, y: 50 }, // height will be recomputed by Figma
|
|
816
|
+
transform: {
|
|
817
|
+
m00: 1, m01: 0, m02: opts.x ?? 128,
|
|
818
|
+
m10: 0, m11: 1, m12: opts.y ?? 128,
|
|
819
|
+
},
|
|
820
|
+
textData,
|
|
821
|
+
fontName,
|
|
822
|
+
fontSize,
|
|
823
|
+
lineHeight,
|
|
824
|
+
letterSpacing,
|
|
825
|
+
textTracking,
|
|
826
|
+
textAutoResize: 'HEIGHT',
|
|
827
|
+
textAlignHorizontal: opts.align ?? 'LEFT',
|
|
828
|
+
textAlignVertical: 'TOP',
|
|
829
|
+
styleIdForText,
|
|
830
|
+
fillPaints: [{
|
|
831
|
+
type: 'SOLID',
|
|
832
|
+
color: { r: fillColor.r, g: fillColor.g, b: fillColor.b, a: fillColor.a ?? 1 },
|
|
833
|
+
opacity: 1,
|
|
834
|
+
visible: true,
|
|
835
|
+
blendMode: 'NORMAL',
|
|
836
|
+
}],
|
|
837
|
+
strokeWeight: 0,
|
|
838
|
+
strokeAlign: 'OUTSIDE',
|
|
839
|
+
strokeJoin: 'MITER',
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
fd.message.nodeChanges.push(node);
|
|
843
|
+
fd.rebuildMaps();
|
|
844
|
+
return node;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Add an ellipse (SHAPE_WITH_TEXT with ELLIPSE type) to this slide.
|
|
849
|
+
*
|
|
850
|
+
* @param {number} x
|
|
851
|
+
* @param {number} y
|
|
852
|
+
* @param {number} width
|
|
853
|
+
* @param {number} height
|
|
854
|
+
* @param {object} [opts]
|
|
855
|
+
* @param {object} [opts.fill] - { r, g, b } normalized 0-1 (default: white)
|
|
856
|
+
* @param {string} [opts.name] - Node name
|
|
857
|
+
* @param {number} [opts.opacity] - 0-1 (default: 1)
|
|
858
|
+
* @returns {object} the raw node
|
|
859
|
+
*/
|
|
860
|
+
addEllipse(x, y, width, height, opts = {}) {
|
|
861
|
+
return this._addShapeWithText('ELLIPSE', x, y, width, height, opts);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Add a diamond shape to this slide.
|
|
866
|
+
* @param {number} x @param {number} y @param {number} width @param {number} height
|
|
867
|
+
* @param {object} [opts] - Same as addEllipse
|
|
868
|
+
* @returns {object} the raw node
|
|
869
|
+
*/
|
|
870
|
+
addDiamond(x, y, width, height, opts = {}) {
|
|
871
|
+
return this._addShapeWithText('DIAMOND', x, y, width, height, opts);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Add a triangle shape to this slide.
|
|
876
|
+
* @param {number} x @param {number} y @param {number} width @param {number} height
|
|
877
|
+
* @param {object} [opts] - Same as addEllipse
|
|
878
|
+
* @returns {object} the raw node
|
|
879
|
+
*/
|
|
880
|
+
addTriangle(x, y, width, height, opts = {}) {
|
|
881
|
+
return this._addShapeWithText('TRIANGLE_UP', x, y, width, height, opts);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Add a star shape to this slide.
|
|
886
|
+
* @param {number} x @param {number} y @param {number} width @param {number} height
|
|
887
|
+
* @param {object} [opts] - Same as addEllipse
|
|
888
|
+
* @returns {object} the raw node
|
|
889
|
+
*/
|
|
890
|
+
addStar(x, y, width, height, opts = {}) {
|
|
891
|
+
return this._addShapeWithText('STAR', x, y, width, height, opts);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Internal: create a SHAPE_WITH_TEXT node with the given sub-type. */
|
|
895
|
+
_addShapeWithText(shapeType, x, y, width, height, opts = {}) {
|
|
896
|
+
const fd = this._fd;
|
|
897
|
+
const localID = fd.maxLocalID() + 1;
|
|
898
|
+
const fill = opts.fill ?? { r: 1, g: 1, b: 1, a: 1 };
|
|
899
|
+
const fillPaint = {
|
|
900
|
+
type: 'SOLID',
|
|
901
|
+
color: { r: fill.r, g: fill.g, b: fill.b, a: fill.a ?? 1 },
|
|
902
|
+
opacity: 1, visible: true, blendMode: 'NORMAL',
|
|
903
|
+
};
|
|
904
|
+
const textFill = {
|
|
905
|
+
type: 'SOLID',
|
|
906
|
+
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
907
|
+
opacity: 1, visible: true, blendMode: 'NORMAL',
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const node = {
|
|
911
|
+
guid: { sessionID: 1, localID },
|
|
912
|
+
phase: 'CREATED',
|
|
913
|
+
parentIndex: {
|
|
914
|
+
guid: this._node.guid,
|
|
915
|
+
position: positionChar(fd.getChildren(nid(this._node)).length),
|
|
916
|
+
},
|
|
917
|
+
type: 'SHAPE_WITH_TEXT',
|
|
918
|
+
name: opts.name ?? 'Shape',
|
|
919
|
+
visible: true,
|
|
920
|
+
opacity: opts.opacity ?? 1,
|
|
921
|
+
size: { x: width, y: height },
|
|
922
|
+
transform: { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y },
|
|
923
|
+
shapeWithTextType: shapeType,
|
|
924
|
+
shapeUserHeight: height,
|
|
925
|
+
shapeTruncates: false,
|
|
926
|
+
autoRename: true,
|
|
927
|
+
frameMaskDisabled: true,
|
|
928
|
+
nodeGenerationData: buildShapeNodeGenData(fillPaint, textFill),
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
fd.message.nodeChanges.push(node);
|
|
932
|
+
fd.rebuildMaps();
|
|
933
|
+
return node;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Add a line to this slide.
|
|
938
|
+
*
|
|
939
|
+
* @param {number} x1 - Start X
|
|
940
|
+
* @param {number} y1 - Start Y
|
|
941
|
+
* @param {number} x2 - End X
|
|
942
|
+
* @param {number} y2 - End Y
|
|
943
|
+
* @param {object} [opts]
|
|
944
|
+
* @param {object} [opts.color] - { r, g, b } normalized 0-1 (default: black)
|
|
945
|
+
* @param {number} [opts.weight] - Stroke weight (default: 2)
|
|
946
|
+
* @param {string} [opts.name] - Node name
|
|
947
|
+
* @returns {object} the raw node
|
|
948
|
+
*/
|
|
949
|
+
addLine(x1, y1, x2, y2, opts = {}) {
|
|
950
|
+
const fd = this._fd;
|
|
951
|
+
const localID = fd.maxLocalID() + 1;
|
|
952
|
+
const color = opts.color ?? { r: 0, g: 0, b: 0 };
|
|
953
|
+
|
|
954
|
+
const dx = x2 - x1;
|
|
955
|
+
const dy = y2 - y1;
|
|
956
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
957
|
+
const cos = length ? dx / length : 1;
|
|
958
|
+
const sin = length ? dy / length : 0;
|
|
959
|
+
|
|
960
|
+
const node = {
|
|
961
|
+
guid: { sessionID: 1, localID },
|
|
962
|
+
phase: 'CREATED',
|
|
963
|
+
parentIndex: {
|
|
964
|
+
guid: this._node.guid,
|
|
965
|
+
position: positionChar(fd.getChildren(nid(this._node)).length),
|
|
966
|
+
},
|
|
967
|
+
type: 'LINE',
|
|
968
|
+
name: opts.name ?? 'Line',
|
|
969
|
+
visible: true,
|
|
970
|
+
opacity: 1,
|
|
971
|
+
size: { x: length, y: 0 },
|
|
972
|
+
transform: { m00: cos, m01: -sin, m02: x1, m10: sin, m11: cos, m12: y1 },
|
|
973
|
+
strokeWeight: opts.weight ?? 2,
|
|
974
|
+
strokeAlign: 'CENTER',
|
|
975
|
+
strokeJoin: 'MITER',
|
|
976
|
+
strokePaints: [{
|
|
977
|
+
type: 'SOLID',
|
|
978
|
+
color: { r: color.r, g: color.g, b: color.b, a: color.a ?? 1 },
|
|
979
|
+
opacity: 1,
|
|
980
|
+
visible: true,
|
|
981
|
+
blendMode: 'NORMAL',
|
|
982
|
+
}],
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
fd.message.nodeChanges.push(node);
|
|
986
|
+
fd.rebuildMaps();
|
|
987
|
+
return node;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Add a table to this slide.
|
|
992
|
+
*
|
|
993
|
+
* @param {number} x
|
|
994
|
+
* @param {number} y
|
|
995
|
+
* @param {string[][]} data - 2D array of cell strings, e.g. [['A','B'],['C','D']]
|
|
996
|
+
* @param {object} [opts]
|
|
997
|
+
* @param {number} [opts.colWidth] - Width per column (default: 192)
|
|
998
|
+
* @param {number} [opts.rowHeight] - Height per row (default: auto)
|
|
999
|
+
* @param {number} [opts.cornerRadius]- Table corner radius (default: 12)
|
|
1000
|
+
* @param {string} [opts.name] - Node name
|
|
1001
|
+
* @returns {object} the raw TABLE node
|
|
1002
|
+
*/
|
|
1003
|
+
addTable(x, y, data, opts = {}) {
|
|
1004
|
+
const fd = this._fd;
|
|
1005
|
+
let nextId = fd.maxLocalID() + 1;
|
|
1006
|
+
const tableLocalId = nextId++;
|
|
1007
|
+
|
|
1008
|
+
const numRows = data.length;
|
|
1009
|
+
const numCols = data[0]?.length ?? 0;
|
|
1010
|
+
if (numRows === 0 || numCols === 0) throw new Error('Table data must have at least 1 row and 1 column');
|
|
1011
|
+
|
|
1012
|
+
const colWidth = opts.colWidth ?? 192;
|
|
1013
|
+
const totalWidth = colWidth * numCols;
|
|
1014
|
+
const rowHeight = opts.rowHeight;
|
|
1015
|
+
|
|
1016
|
+
// Assign IDs for rows and columns
|
|
1017
|
+
const rowIds = [];
|
|
1018
|
+
for (let r = 0; r < numRows; r++) rowIds.push({ sessionID: 1, localID: nextId++ });
|
|
1019
|
+
const colIds = [];
|
|
1020
|
+
for (let c = 0; c < numCols; c++) colIds.push({ sessionID: 1, localID: nextId++ });
|
|
1021
|
+
|
|
1022
|
+
// Build cell text overrides
|
|
1023
|
+
const cellOverrides = [];
|
|
1024
|
+
for (let r = 0; r < numRows; r++) {
|
|
1025
|
+
for (let c = 0; c < numCols; c++) {
|
|
1026
|
+
const text = data[r][c] ?? ' ';
|
|
1027
|
+
cellOverrides.push({
|
|
1028
|
+
guidPath: { guids: [{ sessionID: 40000000, localID: 1 }, rowIds[r], colIds[c]] },
|
|
1029
|
+
textData: {
|
|
1030
|
+
characters: text === '' ? ' ' : text,
|
|
1031
|
+
lines: [{ lineType: 'PLAIN', styleId: 0, indentationLevel: 0, sourceDirectionality: 'AUTO', listStartOffset: 0, isFirstLineOfList: false }],
|
|
1032
|
+
},
|
|
1033
|
+
textUserLayoutVersion: 5,
|
|
1034
|
+
textBidiVersion: 1,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Cell text styling override
|
|
1040
|
+
const DETACHED = { guid: { sessionID: 4294967295, localID: 4294967295 } };
|
|
1041
|
+
const cellStyleBase = {
|
|
1042
|
+
styleIdForFill: DETACHED,
|
|
1043
|
+
styleIdForStrokeFill: DETACHED,
|
|
1044
|
+
styleIdForText: DETACHED,
|
|
1045
|
+
fontSize: 12,
|
|
1046
|
+
paragraphIndent: 0,
|
|
1047
|
+
paragraphSpacing: 0,
|
|
1048
|
+
textAlignHorizontal: 'LEFT',
|
|
1049
|
+
textAlignVertical: 'TOP',
|
|
1050
|
+
textCase: 'ORIGINAL',
|
|
1051
|
+
textDecoration: 'NONE',
|
|
1052
|
+
lineHeight: { value: 100, units: 'PERCENT' },
|
|
1053
|
+
fontName: { family: 'Inter', style: 'Regular', postscript: '' },
|
|
1054
|
+
letterSpacing: { value: 0, units: 'PERCENT' },
|
|
1055
|
+
fontVersion: '',
|
|
1056
|
+
leadingTrim: 'NONE',
|
|
1057
|
+
fontVariations: [],
|
|
1058
|
+
opacity: 1,
|
|
1059
|
+
dashPattern: [],
|
|
1060
|
+
cornerRadius: 0,
|
|
1061
|
+
strokeWeight: 1,
|
|
1062
|
+
strokeAlign: 'INSIDE',
|
|
1063
|
+
strokeCap: 'NONE',
|
|
1064
|
+
strokeJoin: 'MITER',
|
|
1065
|
+
effects: [],
|
|
1066
|
+
textDecorationSkipInk: true,
|
|
1067
|
+
textTracking: 0,
|
|
1068
|
+
listSpacing: 0,
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
const styleOverrides = [
|
|
1072
|
+
{
|
|
1073
|
+
...cellStyleBase,
|
|
1074
|
+
guidPath: { guids: [{ sessionID: 40000000, localID: 2 }] },
|
|
1075
|
+
fillPaints: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
|
|
1076
|
+
strokePaints: [{ type: 'SOLID', color: { r: 0.85, g: 0.85, b: 0.85, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
|
|
1077
|
+
},
|
|
1078
|
+
{
|
|
1079
|
+
...cellStyleBase,
|
|
1080
|
+
guidPath: { guids: [{ sessionID: 40000000, localID: 3 }] },
|
|
1081
|
+
fillPaints: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
|
|
1082
|
+
strokePaints: [],
|
|
1083
|
+
},
|
|
1084
|
+
];
|
|
1085
|
+
|
|
1086
|
+
// Default row height: estimate based on font size + padding
|
|
1087
|
+
const defaultRowHeight = rowHeight ?? 44;
|
|
1088
|
+
const totalHeight = defaultRowHeight * numRows;
|
|
1089
|
+
|
|
1090
|
+
const node = {
|
|
1091
|
+
guid: { sessionID: 1, localID: tableLocalId },
|
|
1092
|
+
phase: 'CREATED',
|
|
1093
|
+
parentIndex: {
|
|
1094
|
+
guid: this._node.guid,
|
|
1095
|
+
position: positionChar(fd.getChildren(nid(this._node)).length),
|
|
1096
|
+
},
|
|
1097
|
+
type: 'TABLE',
|
|
1098
|
+
name: opts.name ?? 'Table',
|
|
1099
|
+
visible: true,
|
|
1100
|
+
opacity: 1,
|
|
1101
|
+
size: { x: totalWidth, y: totalHeight },
|
|
1102
|
+
transform: { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y },
|
|
1103
|
+
cornerRadius: opts.cornerRadius ?? 12,
|
|
1104
|
+
strokeWeight: 1,
|
|
1105
|
+
strokeAlign: 'INSIDE',
|
|
1106
|
+
strokeJoin: 'MITER',
|
|
1107
|
+
fillPaints: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
|
|
1108
|
+
frameMaskDisabled: true,
|
|
1109
|
+
nodeGenerationData: {
|
|
1110
|
+
overrides: [...cellOverrides, ...styleOverrides],
|
|
1111
|
+
useFineGrainedSyncing: false,
|
|
1112
|
+
diffOnlyRemovals: [],
|
|
1113
|
+
},
|
|
1114
|
+
tableRowPositions: { entries: rowIds.map((id, i) => ({ id, position: positionChar(i) })) },
|
|
1115
|
+
tableColumnPositions: { entries: colIds.map((id, i) => ({ id, position: positionChar(i) })) },
|
|
1116
|
+
tableRowHeights: { entries: rowHeight ? rowIds.map(id => ({ id, size: rowHeight })) : [] },
|
|
1117
|
+
tableColumnWidths: { entries: colIds.map(id => ({ id, size: colWidth })) },
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
fd.message.nodeChanges.push(node);
|
|
1121
|
+
fd.rebuildMaps();
|
|
1122
|
+
return node;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Add an SVG vector graphic to this slide.
|
|
1127
|
+
*
|
|
1128
|
+
* @param {number} x
|
|
1129
|
+
* @param {number} y
|
|
1130
|
+
* @param {number} width - Display width on slide
|
|
1131
|
+
* @param {string} svgPathOrBuf - File path to .svg or SVG string
|
|
1132
|
+
* @param {object} [opts]
|
|
1133
|
+
* @param {object} [opts.fill] - { r, g, b } fill color (default: black)
|
|
1134
|
+
* @param {number} [opts.opacity] - Fill opacity (default: 1)
|
|
1135
|
+
* @param {string} [opts.name] - Node name
|
|
1136
|
+
* @returns {object} the raw FRAME node wrapping the VECTOR
|
|
1137
|
+
*/
|
|
1138
|
+
addSVG(x, y, width, svgPathOrBuf, opts = {}) {
|
|
1139
|
+
const fd = this._fd;
|
|
1140
|
+
let nextId = fd.maxLocalID() + 1;
|
|
1141
|
+
|
|
1142
|
+
// Read SVG
|
|
1143
|
+
let svgStr;
|
|
1144
|
+
if (svgPathOrBuf.includes('<svg')) {
|
|
1145
|
+
svgStr = svgPathOrBuf;
|
|
1146
|
+
} else {
|
|
1147
|
+
svgStr = readFileSync(svgPathOrBuf, 'utf8');
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Parse viewBox
|
|
1151
|
+
const vbMatch = svgStr.match(/viewBox="([^"]+)"/);
|
|
1152
|
+
if (!vbMatch) throw new Error('SVG must have a viewBox attribute');
|
|
1153
|
+
const vbParts = vbMatch[1].split(/\s+/).map(Number);
|
|
1154
|
+
const vbW = vbParts[2], vbH = vbParts[3];
|
|
1155
|
+
|
|
1156
|
+
// Parse all <path d="..."> elements
|
|
1157
|
+
const pathDatas = [...svgStr.matchAll(/<path\b[^>]*\bd="([^"]+)"/g)].map(m => m[1]);
|
|
1158
|
+
if (pathDatas.length === 0) throw new Error('SVG contains no <path> elements');
|
|
1159
|
+
|
|
1160
|
+
// Calculate proportional height
|
|
1161
|
+
const height = width * (vbH / vbW);
|
|
1162
|
+
const sx = width / vbW;
|
|
1163
|
+
const sy = height / vbH;
|
|
1164
|
+
|
|
1165
|
+
// Parse SVG paths
|
|
1166
|
+
const allCmds = pathDatas.map(d => _parseSVGPath(d));
|
|
1167
|
+
|
|
1168
|
+
// Build fillGeometry blobs (scaled to node size)
|
|
1169
|
+
const fillGeometry = [];
|
|
1170
|
+
for (const cmds of allCmds) {
|
|
1171
|
+
fd.message.blobs.push({ bytes: _encodeCommandsBlob(cmds, sx, sy) });
|
|
1172
|
+
fillGeometry.push({ windingRule: 'NONZERO', commandsBlob: fd.message.blobs.length - 1, styleID: 0 });
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Build vectorNetworkBlob (in SVG coordinate space)
|
|
1176
|
+
fd.message.blobs.push({ bytes: _buildVectorNetworkBlob(allCmds) });
|
|
1177
|
+
const vnbIdx = fd.message.blobs.length - 1;
|
|
1178
|
+
|
|
1179
|
+
const fill = opts.fill ?? { r: 0, g: 0, b: 0 };
|
|
1180
|
+
const opacity = opts.opacity ?? 1;
|
|
1181
|
+
|
|
1182
|
+
const frameId = nextId++;
|
|
1183
|
+
const vectorId = nextId++;
|
|
1184
|
+
|
|
1185
|
+
const frameNode = {
|
|
1186
|
+
guid: { sessionID: 1, localID: frameId },
|
|
1187
|
+
phase: 'CREATED',
|
|
1188
|
+
parentIndex: {
|
|
1189
|
+
guid: this._node.guid,
|
|
1190
|
+
position: positionChar(fd.getChildren(nid(this._node)).length),
|
|
1191
|
+
},
|
|
1192
|
+
type: 'FRAME',
|
|
1193
|
+
name: opts.name ?? 'SVG',
|
|
1194
|
+
visible: true,
|
|
1195
|
+
opacity: 1,
|
|
1196
|
+
size: { x: width, y: height },
|
|
1197
|
+
transform: { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y },
|
|
1198
|
+
strokeWeight: 1,
|
|
1199
|
+
strokeAlign: 'INSIDE',
|
|
1200
|
+
strokeJoin: 'MITER',
|
|
1201
|
+
frameMaskDisabled: true,
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
const vectorNode = {
|
|
1205
|
+
guid: { sessionID: 1, localID: vectorId },
|
|
1206
|
+
phase: 'CREATED',
|
|
1207
|
+
parentIndex: { guid: { sessionID: 1, localID: frameId }, position: '!' },
|
|
1208
|
+
type: 'VECTOR',
|
|
1209
|
+
name: (opts.name ?? 'SVG') + '_vector',
|
|
1210
|
+
visible: true,
|
|
1211
|
+
opacity: 1,
|
|
1212
|
+
size: { x: width, y: height },
|
|
1213
|
+
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
1214
|
+
strokeWeight: 0,
|
|
1215
|
+
strokeAlign: 'INSIDE',
|
|
1216
|
+
strokeJoin: 'MITER',
|
|
1217
|
+
fillPaints: [{ type: 'SOLID', color: { r: fill.r, g: fill.g, b: fill.b, a: 1 }, opacity, visible: true, blendMode: 'NORMAL' }],
|
|
1218
|
+
fillGeometry,
|
|
1219
|
+
horizontalConstraint: 'SCALE',
|
|
1220
|
+
verticalConstraint: 'SCALE',
|
|
1221
|
+
vectorData: {
|
|
1222
|
+
vectorNetworkBlob: vnbIdx,
|
|
1223
|
+
normalizedSize: { x: vbW, y: vbH },
|
|
1224
|
+
styleOverrideTable: [],
|
|
1225
|
+
},
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
fd.message.nodeChanges.push(frameNode, vectorNode);
|
|
1229
|
+
fd.rebuildMaps();
|
|
1230
|
+
return frameNode;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Add a FRAME (auto-layout container) to this slide.
|
|
1235
|
+
* Useful for grouping text nodes with automatic spacing.
|
|
1236
|
+
*
|
|
1237
|
+
* @param {number} x
|
|
1238
|
+
* @param {number} y
|
|
1239
|
+
* @param {number} width
|
|
1240
|
+
* @param {number} height
|
|
1241
|
+
* @param {object} [opts]
|
|
1242
|
+
* @param {string} [opts.direction] - 'VERTICAL' | 'HORIZONTAL' (default: 'VERTICAL')
|
|
1243
|
+
* @param {number} [opts.spacing] - Gap between children in px (default: 24)
|
|
1244
|
+
* @param {string} [opts.name] - Node name
|
|
1245
|
+
* @returns {Slide} a Slide-like wrapper so you can call addText() on the frame
|
|
1246
|
+
*/
|
|
1247
|
+
addFrame(x, y, width, height, opts = {}) {
|
|
1248
|
+
const fd = this._fd;
|
|
1249
|
+
const localID = fd.maxLocalID() + 1;
|
|
1250
|
+
|
|
1251
|
+
const node = {
|
|
1252
|
+
guid: { sessionID: 1, localID },
|
|
1253
|
+
phase: 'CREATED',
|
|
1254
|
+
parentIndex: {
|
|
1255
|
+
guid: this._node.guid,
|
|
1256
|
+
position: positionChar(fd.getChildren(nid(this._node)).length),
|
|
1257
|
+
},
|
|
1258
|
+
type: 'FRAME',
|
|
1259
|
+
name: opts.name ?? 'Frame',
|
|
1260
|
+
visible: true,
|
|
1261
|
+
opacity: 1,
|
|
1262
|
+
size: { x: width, y: height },
|
|
1263
|
+
transform: {
|
|
1264
|
+
m00: 1, m01: 0, m02: x,
|
|
1265
|
+
m10: 0, m11: 1, m12: y,
|
|
1266
|
+
},
|
|
1267
|
+
stackMode: opts.direction ?? 'VERTICAL',
|
|
1268
|
+
stackSpacing: opts.spacing ?? 24,
|
|
1269
|
+
frameMaskDisabled: true,
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
fd.message.nodeChanges.push(node);
|
|
1273
|
+
fd.rebuildMaps();
|
|
1274
|
+
|
|
1275
|
+
// Return a Slide-like object so addText/addRectangle can be called on the frame
|
|
1276
|
+
return new FrameProxy(fd, node, this._node);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
_resolveImageKey(nameOrKey) {
|
|
1280
|
+
if (/^\d+:\d+$/.test(nameOrKey)) return parseId(nameOrKey);
|
|
1281
|
+
|
|
1282
|
+
const sym = this._symbol;
|
|
1283
|
+
if (!sym) return null;
|
|
1284
|
+
let found = null;
|
|
1285
|
+
this._fd.walkTree(nid(sym), (node) => {
|
|
1286
|
+
if (!found && node.overrideKey && node.name === nameOrKey &&
|
|
1287
|
+
(node.type === 'ROUNDED_RECTANGLE' || node.type === 'RECTANGLE')) {
|
|
1288
|
+
found = node.overrideKey;
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
return found;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ---------------------------------------------------------------------------
|
|
1296
|
+
// Symbol
|
|
1297
|
+
// ---------------------------------------------------------------------------
|
|
1298
|
+
|
|
1299
|
+
export class Symbol {
|
|
1300
|
+
constructor(figDeck, symbolNode) {
|
|
1301
|
+
this._fd = figDeck;
|
|
1302
|
+
this._node = symbolNode;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
get name() { return this._node.name; }
|
|
1306
|
+
get guid() { return nid(this._node); }
|
|
1307
|
+
|
|
1308
|
+
/** Text slots defined in this symbol (nodes with overrideKey). */
|
|
1309
|
+
get textSlots() {
|
|
1310
|
+
const slots = [];
|
|
1311
|
+
this._fd.walkTree(nid(this._node), (node) => {
|
|
1312
|
+
if (node.type === 'TEXT' && node.overrideKey) {
|
|
1313
|
+
slots.push({ name: node.name, key: `${node.overrideKey.sessionID}:${node.overrideKey.localID}` });
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
return slots;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Image slots defined in this symbol. */
|
|
1320
|
+
get imageSlots() {
|
|
1321
|
+
const slots = [];
|
|
1322
|
+
this._fd.walkTree(nid(this._node), (node) => {
|
|
1323
|
+
if (node.overrideKey &&
|
|
1324
|
+
(node.type === 'ROUNDED_RECTANGLE' || node.type === 'RECTANGLE') &&
|
|
1325
|
+
node.fillPaints?.some(p => p.type === 'IMAGE')) {
|
|
1326
|
+
slots.push({ name: node.name, key: `${node.overrideKey.sessionID}:${node.overrideKey.localID}` });
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
return slots;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// ---------------------------------------------------------------------------
|
|
1334
|
+
// TextNode
|
|
1335
|
+
// ---------------------------------------------------------------------------
|
|
1336
|
+
|
|
1337
|
+
export class TextNode {
|
|
1338
|
+
constructor(symbolNode, override = null) {
|
|
1339
|
+
this._node = symbolNode;
|
|
1340
|
+
this._override = override;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
get name() { return this._node.name; }
|
|
1344
|
+
get key() { return `${this._node.overrideKey.sessionID}:${this._node.overrideKey.localID}`; }
|
|
1345
|
+
|
|
1346
|
+
/** Current characters — override value if set, else default from symbol. */
|
|
1347
|
+
get characters() {
|
|
1348
|
+
if (this._override) return this._override.textData.characters;
|
|
1349
|
+
return this._node.textData?.characters ?? '';
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// ---------------------------------------------------------------------------
|
|
1354
|
+
// ImageNode
|
|
1355
|
+
// ---------------------------------------------------------------------------
|
|
1356
|
+
|
|
1357
|
+
export class ImageNode {
|
|
1358
|
+
constructor(symbolNode, override = null) {
|
|
1359
|
+
this._node = symbolNode;
|
|
1360
|
+
this._override = override;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
get name() { return this._node.name; }
|
|
1364
|
+
get key() { return `${this._node.overrideKey.sessionID}:${this._node.overrideKey.localID}`; }
|
|
1365
|
+
|
|
1366
|
+
/** SHA-1 hex of the current image, or null if not overridden. */
|
|
1367
|
+
get hashHex() {
|
|
1368
|
+
const fill = this._override?.fillPaints?.[0] ?? this._node.fillPaints?.[0];
|
|
1369
|
+
if (!fill?.image?.name) return null;
|
|
1370
|
+
return fill.image.name;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// ---------------------------------------------------------------------------
|
|
1375
|
+
// Shape — geometry wrapper for any node on a slide
|
|
1376
|
+
// ---------------------------------------------------------------------------
|
|
1377
|
+
|
|
1378
|
+
export class Shape {
|
|
1379
|
+
constructor(node, figDeck = null) {
|
|
1380
|
+
this._node = node;
|
|
1381
|
+
this._fd = figDeck;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
get name() { return this._node.name; }
|
|
1385
|
+
set name(v) { this._node.name = v; }
|
|
1386
|
+
get type() { return this._node.type; }
|
|
1387
|
+
get guid() { return nid(this._node); }
|
|
1388
|
+
|
|
1389
|
+
get x() { return this._node.transform?.m02 ?? 0; }
|
|
1390
|
+
set x(v) { if (this._node.transform) this._node.transform.m02 = v; }
|
|
1391
|
+
|
|
1392
|
+
get y() { return this._node.transform?.m12 ?? 0; }
|
|
1393
|
+
set y(v) { if (this._node.transform) this._node.transform.m12 = v; }
|
|
1394
|
+
|
|
1395
|
+
get width() { return this._node.size?.x ?? 0; }
|
|
1396
|
+
set width(v) { if (this._node.size) this._node.size.x = v; }
|
|
1397
|
+
|
|
1398
|
+
get height() { return this._node.size?.y ?? 0; }
|
|
1399
|
+
set height(v) { if (this._node.size) this._node.size.y = v; }
|
|
1400
|
+
|
|
1401
|
+
/** Rotation in degrees (clockwise). */
|
|
1402
|
+
get rotation() {
|
|
1403
|
+
const t = this._node.transform;
|
|
1404
|
+
if (!t) return 0;
|
|
1405
|
+
return Math.round(Math.atan2(t.m10, t.m00) * 180 / Math.PI * 1000) / 1000;
|
|
1406
|
+
}
|
|
1407
|
+
set rotation(degrees) {
|
|
1408
|
+
const t = this._node.transform;
|
|
1409
|
+
if (!t) return;
|
|
1410
|
+
const rad = degrees * Math.PI / 180;
|
|
1411
|
+
const cos = Math.cos(rad);
|
|
1412
|
+
const sin = Math.sin(rad);
|
|
1413
|
+
t.m00 = cos; t.m01 = -sin;
|
|
1414
|
+
t.m10 = sin; t.m11 = cos;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
get visible() { return this._node.visible ?? true; }
|
|
1418
|
+
set visible(v) { this._node.visible = v; }
|
|
1419
|
+
|
|
1420
|
+
get opacity() { return this._node.opacity ?? 1; }
|
|
1421
|
+
set opacity(v) { this._node.opacity = v; }
|
|
1422
|
+
|
|
1423
|
+
// --- Fill ----------------------------------------------------------------
|
|
1424
|
+
|
|
1425
|
+
/** Get the current fill color as { r, g, b, a } or null. */
|
|
1426
|
+
get fill() {
|
|
1427
|
+
let f;
|
|
1428
|
+
if (this._node.type === 'SHAPE_WITH_TEXT' && this._node.nodeGenerationData?.overrides?.length) {
|
|
1429
|
+
f = this._node.nodeGenerationData.overrides[0].fillPaints?.[0];
|
|
1430
|
+
} else {
|
|
1431
|
+
f = this._node.fillPaints?.[0];
|
|
1432
|
+
}
|
|
1433
|
+
if (!f?.color) return null;
|
|
1434
|
+
return { ...f.color };
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Set a solid fill color.
|
|
1439
|
+
* @param {object} color - { r, g, b } normalized 0-1
|
|
1440
|
+
* @param {object} [opts]
|
|
1441
|
+
* @param {number} [opts.opacity] - Fill opacity 0-1 (default: 1)
|
|
1442
|
+
*/
|
|
1443
|
+
setFill(color, opts = {}) {
|
|
1444
|
+
const paint = [{
|
|
1445
|
+
type: 'SOLID',
|
|
1446
|
+
color: { r: color.r, g: color.g, b: color.b, a: color.a ?? 1 },
|
|
1447
|
+
opacity: opts.opacity ?? 1,
|
|
1448
|
+
visible: true,
|
|
1449
|
+
blendMode: 'NORMAL',
|
|
1450
|
+
}];
|
|
1451
|
+
this._setShapeFill(paint);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** Remove all fills. */
|
|
1455
|
+
removeFill() {
|
|
1456
|
+
this._setShapeFill([]);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** Internal: set fillPaints on the correct target (nodeGenerationData for SHAPE_WITH_TEXT). */
|
|
1460
|
+
_setShapeFill(paints) {
|
|
1461
|
+
if (this._node.type === 'SHAPE_WITH_TEXT' && this._node.nodeGenerationData?.overrides?.length) {
|
|
1462
|
+
this._node.nodeGenerationData.overrides[0].fillPaints = paints;
|
|
1463
|
+
} else {
|
|
1464
|
+
this._node.fillPaints = paints;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// --- Stroke --------------------------------------------------------------
|
|
1469
|
+
|
|
1470
|
+
/** Get the current stroke as { r, g, b, a, weight } or null. */
|
|
1471
|
+
get stroke() {
|
|
1472
|
+
const s = this._node.strokePaints?.[0];
|
|
1473
|
+
if (!s?.color) return null;
|
|
1474
|
+
return { ...s.color, weight: this._node.strokeWeight ?? 0 };
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Set a solid stroke.
|
|
1479
|
+
* @param {object} color - { r, g, b } normalized 0-1
|
|
1480
|
+
* @param {object} [opts]
|
|
1481
|
+
* @param {number} [opts.weight] - Stroke weight in px (default: 2)
|
|
1482
|
+
* @param {string} [opts.align] - 'INSIDE' | 'OUTSIDE' | 'CENTER' (default: 'INSIDE')
|
|
1483
|
+
*/
|
|
1484
|
+
setStroke(color, opts = {}) {
|
|
1485
|
+
this._node.strokePaints = [{
|
|
1486
|
+
type: 'SOLID',
|
|
1487
|
+
color: { r: color.r, g: color.g, b: color.b, a: color.a ?? 1 },
|
|
1488
|
+
opacity: 1,
|
|
1489
|
+
visible: true,
|
|
1490
|
+
blendMode: 'NORMAL',
|
|
1491
|
+
}];
|
|
1492
|
+
this._node.strokeWeight = opts.weight ?? 2;
|
|
1493
|
+
this._node.strokeAlign = opts.align ?? 'INSIDE';
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/** Remove stroke. */
|
|
1497
|
+
removeStroke() {
|
|
1498
|
+
this._node.strokePaints = [];
|
|
1499
|
+
this._node.strokeWeight = 0;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// --- Image fill -----------------------------------------------------------
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Set an image as the fill of this shape.
|
|
1506
|
+
*
|
|
1507
|
+
* @param {string|Buffer} pathOrBuf - Image file path or Buffer
|
|
1508
|
+
* @param {object} [opts]
|
|
1509
|
+
* @param {string} [opts.scaleMode] - 'FILL' | 'FIT' | 'CROP' | 'TILE' (default: 'FILL')
|
|
1510
|
+
* @returns {Promise<void>}
|
|
1511
|
+
*/
|
|
1512
|
+
async setImageFill(pathOrBuf, opts = {}) {
|
|
1513
|
+
if (!this._fd) throw new Error('Shape requires FigDeck reference for image operations');
|
|
1514
|
+
|
|
1515
|
+
const imgBuf = typeof pathOrBuf === 'string'
|
|
1516
|
+
? readFileSync(resolve(pathOrBuf))
|
|
1517
|
+
: pathOrBuf;
|
|
1518
|
+
const imgPath = typeof pathOrBuf === 'string' ? resolve(pathOrBuf) : null;
|
|
1519
|
+
|
|
1520
|
+
const imgHash = sha1Hex(imgBuf);
|
|
1521
|
+
const { width: origW, height: origH } = await getImageDimensions(imgBuf);
|
|
1522
|
+
|
|
1523
|
+
const tmpThumb = `/tmp/figmatk_thumb_${Date.now()}.png`;
|
|
1524
|
+
await generateThumbnail(imgBuf, tmpThumb);
|
|
1525
|
+
const thumbHash = sha1Hex(readFileSync(tmpThumb));
|
|
1526
|
+
|
|
1527
|
+
if (imgPath) {
|
|
1528
|
+
copyToImagesDir(this._fd, imgHash, imgPath);
|
|
1529
|
+
} else {
|
|
1530
|
+
const tmpImg = `/tmp/figmatk_img_${Date.now()}`;
|
|
1531
|
+
writeFileSync(tmpImg, imgBuf);
|
|
1532
|
+
copyToImagesDir(this._fd, imgHash, tmpImg);
|
|
1533
|
+
}
|
|
1534
|
+
copyToImagesDir(this._fd, thumbHash, tmpThumb);
|
|
1535
|
+
|
|
1536
|
+
this._setShapeFill([{
|
|
1537
|
+
type: 'IMAGE',
|
|
1538
|
+
opacity: 1,
|
|
1539
|
+
visible: true,
|
|
1540
|
+
blendMode: 'NORMAL',
|
|
1541
|
+
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
1542
|
+
image: { hash: hexToHash(imgHash), name: imgHash },
|
|
1543
|
+
imageThumbnail: { hash: hexToHash(thumbHash), name: thumbHash },
|
|
1544
|
+
animationFrame: 0,
|
|
1545
|
+
imageScaleMode: opts.scaleMode ?? 'FILL',
|
|
1546
|
+
imageShouldColorManage: false,
|
|
1547
|
+
rotation: 0,
|
|
1548
|
+
scale: 0.5,
|
|
1549
|
+
originalImageWidth: origW,
|
|
1550
|
+
originalImageHeight: origH,
|
|
1551
|
+
thumbHash: new Uint8Array(0),
|
|
1552
|
+
altText: '',
|
|
1553
|
+
}]);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// ---------------------------------------------------------------------------
|
|
1558
|
+
// FrameProxy — lets you call addText/addRectangle on a FRAME node
|
|
1559
|
+
// ---------------------------------------------------------------------------
|
|
1560
|
+
|
|
1561
|
+
class FrameProxy {
|
|
1562
|
+
constructor(figDeck, frameNode, slideNode) {
|
|
1563
|
+
this._fd = figDeck;
|
|
1564
|
+
this._node = frameNode;
|
|
1565
|
+
this._slideNode = slideNode;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
get guid() { return nid(this._node); }
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Add a text node inside this frame.
|
|
1572
|
+
* Position is relative to the frame (auto-layout handles placement).
|
|
1573
|
+
*/
|
|
1574
|
+
addText(text, opts = {}) {
|
|
1575
|
+
// Override position to 0,0 — frame auto-layout handles positioning
|
|
1576
|
+
return Slide.prototype.addText.call(this, text, { ...opts, x: 0, y: 0 });
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
addRectangle(x, y, width, height, opts = {}) {
|
|
1580
|
+
return Slide.prototype.addRectangle.call(this, x, y, width, height, opts);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
async addImage(x, y, width, height, pathOrBuf, opts = {}) {
|
|
1584
|
+
return Slide.prototype.addImage.call(this, x, y, width, height, pathOrBuf, opts);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// ---------------------------------------------------------------------------
|
|
1589
|
+
// Helpers
|
|
1590
|
+
// ---------------------------------------------------------------------------
|
|
1591
|
+
|
|
1592
|
+
/** Sentinel GUID for detached styles (0xFFFFFFFF:0xFFFFFFFF) */
|
|
1593
|
+
const DETACHED_STYLE_ID = {
|
|
1594
|
+
guid: { sessionID: 4294967295, localID: 4294967295 }
|
|
1595
|
+
};
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Find a named text style definition node in the deck.
|
|
1599
|
+
* Looks for publishable TEXT nodes with matching name (e.g. 'Title', 'Body 1').
|
|
1600
|
+
* Returns the node so we can read its typography fields and guid.
|
|
1601
|
+
*/
|
|
1602
|
+
function resolveTextStyle(fd, styleName) {
|
|
1603
|
+
const nodes = fd.message.nodeChanges;
|
|
1604
|
+
// Find the publishable style token (characters "Ag", isPublishable true)
|
|
1605
|
+
const match = nodes.find(n =>
|
|
1606
|
+
n.type === 'TEXT' &&
|
|
1607
|
+
n.isPublishable === true &&
|
|
1608
|
+
n.name === styleName
|
|
1609
|
+
);
|
|
1610
|
+
if (match) return match;
|
|
1611
|
+
|
|
1612
|
+
// Fallback: try the preview nodes (characters "Rag 123")
|
|
1613
|
+
const preview = nodes.find(n =>
|
|
1614
|
+
n.type === 'TEXT' &&
|
|
1615
|
+
n.name === styleName &&
|
|
1616
|
+
n.locked === true &&
|
|
1617
|
+
n.visible === false
|
|
1618
|
+
);
|
|
1619
|
+
if (preview) return preview;
|
|
1620
|
+
|
|
1621
|
+
throw new Error(`Unknown text style: "${styleName}". Available: Title, Header 1, Header 2, Header 3, Body 1, Body 2, Body 3, Note`);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* Resolve a named color (e.g. 'Blue', 'Red') to its VARIABLE node.
|
|
1626
|
+
* Returns { guid, r, g, b } from the Light Slides color variable set.
|
|
1627
|
+
*/
|
|
1628
|
+
/**
|
|
1629
|
+
* Resolve a named color (e.g. 'Blue', 'Red') to its VARIABLE node.
|
|
1630
|
+
* Returns { guid, r, g, b } from the Light Slides color variable set.
|
|
1631
|
+
*/
|
|
1632
|
+
function resolveColorVariable(fd, colorName) {
|
|
1633
|
+
const nodes = fd.message.nodeChanges;
|
|
1634
|
+
const variable = nodes.find(n =>
|
|
1635
|
+
n.type === 'VARIABLE' &&
|
|
1636
|
+
n.name === colorName &&
|
|
1637
|
+
n.variableResolvedType === 'COLOR'
|
|
1638
|
+
);
|
|
1639
|
+
if (!variable) {
|
|
1640
|
+
const available = nodes
|
|
1641
|
+
.filter(n => n.type === 'VARIABLE' && n.variableResolvedType === 'COLOR')
|
|
1642
|
+
.map(n => n.name);
|
|
1643
|
+
throw new Error(`Unknown color: "${colorName}". Available: ${available.join(', ')}`);
|
|
1644
|
+
}
|
|
1645
|
+
const val = variable.variableDataValues?.entries?.[0]?.variableData?.value?.colorValue;
|
|
1646
|
+
if (!val) {
|
|
1647
|
+
throw new Error(`Color variable "${colorName}" has no color value`);
|
|
1648
|
+
}
|
|
1649
|
+
return { guid: variable.guid, r: val.r, g: val.g, b: val.b };
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Build styleOverrideTable + characterStyleIDs from an array of text runs.
|
|
1654
|
+
* Each unique formatting combination gets its own styleID.
|
|
1655
|
+
*/
|
|
1656
|
+
function buildRunOverrides(runs, baseFontName, styleIdForText) {
|
|
1657
|
+
const styleOverrideTable = [];
|
|
1658
|
+
const characterStyleIDs = [];
|
|
1659
|
+
const styleMap = new Map(); // formatKey → styleID
|
|
1660
|
+
let nextStyleID = 1;
|
|
1661
|
+
|
|
1662
|
+
for (const run of runs) {
|
|
1663
|
+
const hasFormat = run.bold || run.italic || run.underline || run.strikethrough || run.hyperlink;
|
|
1664
|
+
|
|
1665
|
+
if (!hasFormat) {
|
|
1666
|
+
// Plain text — styleID 0 (base style)
|
|
1667
|
+
for (let i = 0; i < run.text.length; i++) characterStyleIDs.push(0);
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// Build a key for this unique formatting combination
|
|
1672
|
+
const key = `${run.bold ? 'b' : ''}${run.italic ? 'i' : ''}${run.underline ? 'u' : ''}${run.strikethrough ? 's' : ''}${run.hyperlink ? 'h:' + run.hyperlink : ''}`;
|
|
1673
|
+
|
|
1674
|
+
if (!styleMap.has(key)) {
|
|
1675
|
+
const styleID = nextStyleID++;
|
|
1676
|
+
styleMap.set(key, styleID);
|
|
1677
|
+
|
|
1678
|
+
const entry = {
|
|
1679
|
+
styleID,
|
|
1680
|
+
isOverrideOverTextStyle: true,
|
|
1681
|
+
styleIdForText: deepClone(styleIdForText),
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
// Derive font style from base + bold/italic flags
|
|
1685
|
+
if (run.bold || run.italic) {
|
|
1686
|
+
const baseLower = baseFontName.style.toLowerCase();
|
|
1687
|
+
const baseBold = baseLower.includes('bold');
|
|
1688
|
+
const baseItalic = baseLower.includes('italic');
|
|
1689
|
+
const wantBold = run.bold || baseBold;
|
|
1690
|
+
const wantItalic = run.italic || baseItalic;
|
|
1691
|
+
|
|
1692
|
+
let style;
|
|
1693
|
+
if (wantBold && wantItalic) style = 'Bold Italic';
|
|
1694
|
+
else if (wantBold) style = 'Bold';
|
|
1695
|
+
else if (wantItalic) style = 'Italic';
|
|
1696
|
+
else style = 'Regular';
|
|
1697
|
+
|
|
1698
|
+
entry.fontName = { family: baseFontName.family, style, postscript: '' };
|
|
1699
|
+
entry.fontVersion = '1';
|
|
1700
|
+
if (wantBold) entry.semanticWeight = 'BOLD';
|
|
1701
|
+
if (wantItalic) entry.semanticItalic = 'ITALIC';
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Text decoration
|
|
1705
|
+
if (run.hyperlink) {
|
|
1706
|
+
entry.textDecoration = 'UNDERLINE';
|
|
1707
|
+
entry.hyperlink = { url: run.hyperlink };
|
|
1708
|
+
entry.textDecorationSkipInk = true;
|
|
1709
|
+
} else if (run.underline) {
|
|
1710
|
+
entry.textDecoration = 'UNDERLINE';
|
|
1711
|
+
entry.textDecorationSkipInk = true;
|
|
1712
|
+
} else if (run.strikethrough) {
|
|
1713
|
+
entry.textDecoration = 'STRIKETHROUGH';
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
styleOverrideTable.push(entry);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const styleID = styleMap.get(key);
|
|
1720
|
+
for (let i = 0; i < run.text.length; i++) characterStyleIDs.push(styleID);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
return { styleOverrideTable, characterStyleIDs };
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Build the `lines` array for list/paragraph formatting.
|
|
1728
|
+
* One entry per \n-delimited line in `characters`.
|
|
1729
|
+
*
|
|
1730
|
+
* @param {string} characters - The full text (with \n line breaks)
|
|
1731
|
+
* @param {Array|null} runs - Run objects (with bullet/number/indent), or null for simple mode
|
|
1732
|
+
* @param {string|null} listType - 'bullet' or 'number' for simple all-lines mode
|
|
1733
|
+
*/
|
|
1734
|
+
function buildLines(characters, runs, listType) {
|
|
1735
|
+
const textLines = characters.split('\n');
|
|
1736
|
+
const lines = [];
|
|
1737
|
+
|
|
1738
|
+
if (!runs) {
|
|
1739
|
+
// Simple mode: opts.list applies to all lines
|
|
1740
|
+
const lt = listType === 'number' ? 'ORDERED_LIST' : 'UNORDERED_LIST';
|
|
1741
|
+
for (let i = 0; i < textLines.length; i++) {
|
|
1742
|
+
const isTrailingEmpty = i === textLines.length - 1 && textLines[i] === '';
|
|
1743
|
+
lines.push({
|
|
1744
|
+
lineType: isTrailingEmpty ? 'PLAIN' : lt,
|
|
1745
|
+
styleId: 0,
|
|
1746
|
+
indentationLevel: isTrailingEmpty ? 0 : 1,
|
|
1747
|
+
sourceDirectionality: 'AUTO',
|
|
1748
|
+
listStartOffset: 0,
|
|
1749
|
+
isFirstLineOfList: !isTrailingEmpty && (i === 0 || lines[i - 1].lineType === 'PLAIN'),
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
return lines;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Per-run mode: map each line to the run that contains its first character
|
|
1756
|
+
const lineStartPositions = [];
|
|
1757
|
+
let pos = 0;
|
|
1758
|
+
for (const line of textLines) {
|
|
1759
|
+
lineStartPositions.push(pos);
|
|
1760
|
+
pos += line.length + 1; // +1 for \n
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// Build a map: character position → run index
|
|
1764
|
+
const runStarts = [];
|
|
1765
|
+
let rPos = 0;
|
|
1766
|
+
for (let i = 0; i < runs.length; i++) {
|
|
1767
|
+
runStarts.push(rPos);
|
|
1768
|
+
rPos += runs[i].text.length;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
for (let i = 0; i < textLines.length; i++) {
|
|
1772
|
+
const lineStart = lineStartPositions[i];
|
|
1773
|
+
|
|
1774
|
+
// Find the run that contains this line's first character
|
|
1775
|
+
let runIdx = 0;
|
|
1776
|
+
for (let r = runs.length - 1; r >= 0; r--) {
|
|
1777
|
+
if (runStarts[r] <= lineStart) { runIdx = r; break; }
|
|
1778
|
+
}
|
|
1779
|
+
const run = runs[runIdx];
|
|
1780
|
+
|
|
1781
|
+
let lineType = 'PLAIN';
|
|
1782
|
+
let indentationLevel = 0;
|
|
1783
|
+
|
|
1784
|
+
if (run.bullet) {
|
|
1785
|
+
lineType = 'UNORDERED_LIST';
|
|
1786
|
+
indentationLevel = run.indent ?? 1;
|
|
1787
|
+
} else if (run.number) {
|
|
1788
|
+
lineType = 'ORDERED_LIST';
|
|
1789
|
+
indentationLevel = run.indent ?? 1;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// Trailing empty line after last \n → PLAIN
|
|
1793
|
+
if (i === textLines.length - 1 && textLines[i] === '' && lineType !== 'PLAIN') {
|
|
1794
|
+
lineType = 'PLAIN';
|
|
1795
|
+
indentationLevel = 0;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// isFirstLineOfList: true when list type or indent changes
|
|
1799
|
+
let isFirstLineOfList = false;
|
|
1800
|
+
if (lineType !== 'PLAIN') {
|
|
1801
|
+
if (i === 0 ||
|
|
1802
|
+
lines[i - 1].lineType !== lineType ||
|
|
1803
|
+
lines[i - 1].indentationLevel !== indentationLevel) {
|
|
1804
|
+
isFirstLineOfList = true;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
lines.push({
|
|
1809
|
+
lineType,
|
|
1810
|
+
styleId: 0,
|
|
1811
|
+
indentationLevel,
|
|
1812
|
+
sourceDirectionality: 'AUTO',
|
|
1813
|
+
listStartOffset: 0,
|
|
1814
|
+
isFirstLineOfList,
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
return lines;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* Build the nodeGenerationData required for SHAPE_WITH_TEXT nodes.
|
|
1823
|
+
* Two override entries: [0] = shape background, [1] = inner text.
|
|
1824
|
+
*/
|
|
1825
|
+
function buildShapeNodeGenData(shapeFillPaint, textFillPaint) {
|
|
1826
|
+
const DETACHED = { guid: { sessionID: 4294967295, localID: 4294967295 } };
|
|
1827
|
+
const baseOverride = {
|
|
1828
|
+
styleIdForFill: DETACHED,
|
|
1829
|
+
styleIdForStrokeFill: DETACHED,
|
|
1830
|
+
styleIdForText: DETACHED,
|
|
1831
|
+
fontSize: 12,
|
|
1832
|
+
paragraphIndent: 0,
|
|
1833
|
+
paragraphSpacing: 0,
|
|
1834
|
+
textAlignHorizontal: 'CENTER',
|
|
1835
|
+
textAlignVertical: 'TOP',
|
|
1836
|
+
textCase: 'ORIGINAL',
|
|
1837
|
+
textDecoration: 'NONE',
|
|
1838
|
+
lineHeight: { value: 100, units: 'PERCENT' },
|
|
1839
|
+
fontName: { family: 'Inter', style: 'Regular', postscript: '' },
|
|
1840
|
+
letterSpacing: { value: 0, units: 'PERCENT' },
|
|
1841
|
+
fontVersion: '',
|
|
1842
|
+
leadingTrim: 'NONE',
|
|
1843
|
+
fontVariations: [],
|
|
1844
|
+
opacity: 1,
|
|
1845
|
+
dashPattern: [],
|
|
1846
|
+
cornerRadius: 0,
|
|
1847
|
+
strokeWeight: 1,
|
|
1848
|
+
strokeAlign: 'INSIDE',
|
|
1849
|
+
strokeCap: 'NONE',
|
|
1850
|
+
strokeJoin: 'MITER',
|
|
1851
|
+
strokePaints: [],
|
|
1852
|
+
effects: [],
|
|
1853
|
+
textDecorationSkipInk: true,
|
|
1854
|
+
rectangleTopLeftCornerRadius: 0,
|
|
1855
|
+
rectangleTopRightCornerRadius: 0,
|
|
1856
|
+
rectangleBottomLeftCornerRadius: 0,
|
|
1857
|
+
rectangleBottomRightCornerRadius: 0,
|
|
1858
|
+
rectangleCornerRadiiIndependent: false,
|
|
1859
|
+
textTracking: 0,
|
|
1860
|
+
listSpacing: 0,
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
return {
|
|
1864
|
+
overrides: [
|
|
1865
|
+
{
|
|
1866
|
+
...baseOverride,
|
|
1867
|
+
guidPath: { guids: [{ sessionID: 40000000, localID: 0 }] },
|
|
1868
|
+
fillPaints: [shapeFillPaint],
|
|
1869
|
+
},
|
|
1870
|
+
{
|
|
1871
|
+
...baseOverride,
|
|
1872
|
+
guidPath: { guids: [{ sessionID: 40000000, localID: 1 }] },
|
|
1873
|
+
strokeAlign: 'OUTSIDE',
|
|
1874
|
+
fillPaints: [textFillPaint],
|
|
1875
|
+
},
|
|
1876
|
+
],
|
|
1877
|
+
useFineGrainedSyncing: false,
|
|
1878
|
+
diffOnlyRemovals: [],
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// --- SVG import helpers ---
|
|
1883
|
+
|
|
1884
|
+
function _parseSVGPath(d) {
|
|
1885
|
+
const tokens = [];
|
|
1886
|
+
const re = /([MmLlCcSsHhVvZz])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/g;
|
|
1887
|
+
let m;
|
|
1888
|
+
while ((m = re.exec(d)) !== null) {
|
|
1889
|
+
if (m[1]) tokens.push(m[1]);
|
|
1890
|
+
else tokens.push(parseFloat(m[2]));
|
|
1891
|
+
}
|
|
1892
|
+
const cmds = [];
|
|
1893
|
+
let i = 0, cx = 0, cy = 0, startX = 0, startY = 0, prevC2x = 0, prevC2y = 0, cmd = '';
|
|
1894
|
+
const num = () => tokens[i++];
|
|
1895
|
+
while (i < tokens.length) {
|
|
1896
|
+
if (typeof tokens[i] === 'string') cmd = tokens[i++];
|
|
1897
|
+
switch (cmd) {
|
|
1898
|
+
case 'M': cx = num(); cy = num(); startX = cx; startY = cy; cmds.push({ type: 'M', x: cx, y: cy }); cmd = 'L'; break;
|
|
1899
|
+
case 'm': cx += num(); cy += num(); startX = cx; startY = cy; cmds.push({ type: 'M', x: cx, y: cy }); cmd = 'l'; break;
|
|
1900
|
+
case 'L': cx = num(); cy = num(); cmds.push({ type: 'L', x: cx, y: cy }); break;
|
|
1901
|
+
case 'l': cx += num(); cy += num(); cmds.push({ type: 'L', x: cx, y: cy }); break;
|
|
1902
|
+
case 'H': cx = num(); cmds.push({ type: 'L', x: cx, y: cy }); break;
|
|
1903
|
+
case 'h': cx += num(); cmds.push({ type: 'L', x: cx, y: cy }); break;
|
|
1904
|
+
case 'V': cy = num(); cmds.push({ type: 'L', x: cx, y: cy }); break;
|
|
1905
|
+
case 'v': cy += num(); cmds.push({ type: 'L', x: cx, y: cy }); break;
|
|
1906
|
+
case 'C': { const c1x=num(),c1y=num(),c2x=num(),c2y=num(); cx=num(); cy=num(); prevC2x=c2x; prevC2y=c2y; cmds.push({type:'C',c1x,c1y,c2x,c2y,x:cx,y:cy}); break; }
|
|
1907
|
+
case 'c': { const c1x=cx+num(),c1y=cy+num(),c2x=cx+num(),c2y=cy+num(); cx+=num(); cy+=num(); prevC2x=c2x; prevC2y=c2y; cmds.push({type:'C',c1x,c1y,c2x,c2y,x:cx,y:cy}); break; }
|
|
1908
|
+
case 'S': { const c1x=2*cx-prevC2x,c1y=2*cy-prevC2y,c2x=num(),c2y=num(); cx=num(); cy=num(); prevC2x=c2x; prevC2y=c2y; cmds.push({type:'C',c1x,c1y,c2x,c2y,x:cx,y:cy}); break; }
|
|
1909
|
+
case 's': { const c1x=2*cx-prevC2x,c1y=2*cy-prevC2y,c2x=cx+num(),c2y=cy+num(); cx+=num(); cy+=num(); prevC2x=c2x; prevC2y=c2y; cmds.push({type:'C',c1x,c1y,c2x,c2y,x:cx,y:cy}); break; }
|
|
1910
|
+
case 'Z': case 'z': cmds.push({ type: 'Z' }); cx = startX; cy = startY; break;
|
|
1911
|
+
default: i++; break;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return cmds;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function _encodeCommandsBlob(cmds, sx, sy) {
|
|
1918
|
+
let size = 0;
|
|
1919
|
+
for (const c of cmds) { size += 1; if (c.type === 'M' || c.type === 'L') size += 8; else if (c.type === 'C') size += 24; }
|
|
1920
|
+
const buf = Buffer.alloc(size);
|
|
1921
|
+
let off = 0;
|
|
1922
|
+
for (const c of cmds) {
|
|
1923
|
+
switch (c.type) {
|
|
1924
|
+
case 'M': buf[off++] = 1; buf.writeFloatLE(c.x * sx, off); off += 4; buf.writeFloatLE(c.y * sy, off); off += 4; break;
|
|
1925
|
+
case 'L': buf[off++] = 2; buf.writeFloatLE(c.x * sx, off); off += 4; buf.writeFloatLE(c.y * sy, off); off += 4; break;
|
|
1926
|
+
case 'C': buf[off++] = 4;
|
|
1927
|
+
buf.writeFloatLE(c.c1x * sx, off); off += 4; buf.writeFloatLE(c.c1y * sy, off); off += 4;
|
|
1928
|
+
buf.writeFloatLE(c.c2x * sx, off); off += 4; buf.writeFloatLE(c.c2y * sy, off); off += 4;
|
|
1929
|
+
buf.writeFloatLE(c.x * sx, off); off += 4; buf.writeFloatLE(c.y * sy, off); off += 4; break;
|
|
1930
|
+
case 'Z': buf[off++] = 0; break;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
return new Uint8Array(buf.buffer, 0, off);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function _buildVectorNetworkBlob(allPathCmds) {
|
|
1937
|
+
const vertices = [];
|
|
1938
|
+
const segments = [];
|
|
1939
|
+
const regions = [];
|
|
1940
|
+
|
|
1941
|
+
for (const pathCmds of allPathCmds) {
|
|
1942
|
+
const regionSegs = [];
|
|
1943
|
+
let firstVtx = -1, prevVtx = -1, prevX = 0, prevY = 0;
|
|
1944
|
+
|
|
1945
|
+
for (const c of pathCmds) {
|
|
1946
|
+
if (c.type === 'M') {
|
|
1947
|
+
const vi = vertices.length;
|
|
1948
|
+
vertices.push({ x: c.x, y: c.y });
|
|
1949
|
+
firstVtx = vi; prevVtx = vi; prevX = c.x; prevY = c.y;
|
|
1950
|
+
} else if (c.type === 'L') {
|
|
1951
|
+
const vi = vertices.length;
|
|
1952
|
+
vertices.push({ x: c.x, y: c.y });
|
|
1953
|
+
if (prevVtx >= 0) {
|
|
1954
|
+
regionSegs.push(segments.length);
|
|
1955
|
+
segments.push({ s: prevVtx, tsx: 0, tsy: 0, e: vi, tex: 0, tey: 0, t: 0 });
|
|
1956
|
+
}
|
|
1957
|
+
prevVtx = vi; prevX = c.x; prevY = c.y;
|
|
1958
|
+
} else if (c.type === 'C') {
|
|
1959
|
+
const vi = vertices.length;
|
|
1960
|
+
vertices.push({ x: c.x, y: c.y });
|
|
1961
|
+
if (prevVtx >= 0) {
|
|
1962
|
+
regionSegs.push(segments.length);
|
|
1963
|
+
segments.push({ s: prevVtx, tsx: c.c1x - prevX, tsy: c.c1y - prevY, e: vi, tex: c.c2x - c.x, tey: c.c2y - c.y, t: 4 });
|
|
1964
|
+
}
|
|
1965
|
+
prevVtx = vi; prevX = c.x; prevY = c.y;
|
|
1966
|
+
} else if (c.type === 'Z') {
|
|
1967
|
+
if (prevVtx >= 0 && prevVtx !== firstVtx) {
|
|
1968
|
+
regionSegs.push(segments.length);
|
|
1969
|
+
segments.push({ s: prevVtx, tsx: 0, tsy: 0, e: firstVtx, tex: 0, tey: 0, t: 0 });
|
|
1970
|
+
}
|
|
1971
|
+
prevVtx = firstVtx; prevX = vertices[firstVtx].x; prevY = vertices[firstVtx].y;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
regions.push(regionSegs);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Calculate size: header(16) + vertices(12 each) + segments(28 each) + regions(variable)
|
|
1978
|
+
let regSize = 0;
|
|
1979
|
+
for (const r of regions) regSize += 4 + 4 + r.length * 4 + 4; // numLoops + segCount + indices + windingRule
|
|
1980
|
+
const totalSize = 16 + vertices.length * 12 + segments.length * 28 + regSize;
|
|
1981
|
+
const buf = Buffer.alloc(totalSize);
|
|
1982
|
+
let off = 0;
|
|
1983
|
+
|
|
1984
|
+
// Header
|
|
1985
|
+
buf.writeUInt32LE(vertices.length, off); off += 4;
|
|
1986
|
+
buf.writeUInt32LE(segments.length, off); off += 4;
|
|
1987
|
+
buf.writeUInt32LE(regions.length, off); off += 4;
|
|
1988
|
+
buf.writeUInt32LE(1, off); off += 4;
|
|
1989
|
+
|
|
1990
|
+
// Vertices: x(f32) y(f32) handleMirroring(u32)
|
|
1991
|
+
for (const v of vertices) {
|
|
1992
|
+
buf.writeFloatLE(v.x, off); off += 4;
|
|
1993
|
+
buf.writeFloatLE(v.y, off); off += 4;
|
|
1994
|
+
buf.writeUInt32LE(4, off); off += 4;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// Segments: start(u32) tsx(f32) tsy(f32) end(u32) tex(f32) tey(f32) type(u32)
|
|
1998
|
+
for (const s of segments) {
|
|
1999
|
+
buf.writeUInt32LE(s.s, off); off += 4;
|
|
2000
|
+
buf.writeFloatLE(s.tsx, off); off += 4;
|
|
2001
|
+
buf.writeFloatLE(s.tsy, off); off += 4;
|
|
2002
|
+
buf.writeUInt32LE(s.e, off); off += 4;
|
|
2003
|
+
buf.writeFloatLE(s.tex, off); off += 4;
|
|
2004
|
+
buf.writeFloatLE(s.tey, off); off += 4;
|
|
2005
|
+
buf.writeUInt32LE(s.t, off); off += 4;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// Regions: numLoops(u32) segCount(u32) segIndices(u32*n) windingRule(u32)
|
|
2009
|
+
for (const r of regions) {
|
|
2010
|
+
buf.writeUInt32LE(1, off); off += 4; // numLoops = 1
|
|
2011
|
+
buf.writeUInt32LE(r.length, off); off += 4;
|
|
2012
|
+
for (const si of r) { buf.writeUInt32LE(si, off); off += 4; }
|
|
2013
|
+
buf.writeUInt32LE(1, off); off += 4; // windingRule = NONZERO
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
return new Uint8Array(buf.buffer, 0, off);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function sha1Hex(buf) {
|
|
2020
|
+
return createHash('sha1').update(buf).digest('hex');
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function copyToImagesDir(fd, hash, srcPath) {
|
|
2024
|
+
if (!fd.imagesDir) {
|
|
2025
|
+
fd.imagesDir = `/tmp/figmatk_images_${Date.now()}`;
|
|
2026
|
+
mkdirSync(fd.imagesDir, { recursive: true });
|
|
2027
|
+
}
|
|
2028
|
+
const dest = join(fd.imagesDir, hash);
|
|
2029
|
+
if (!existsSync(dest)) copyFileSync(srcPath, dest);
|
|
2030
|
+
}
|