openfig-cli 0.3.11

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