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/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
+ }