openfig-cli 0.3.41 → 0.4.0

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.
@@ -18,6 +18,7 @@ import { imageOv, hexToHash } from '../core/image-helpers.mjs';
18
18
  import { deepClone } from '../core/deep-clone.mjs';
19
19
  import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'fs';
20
20
  import { createHash } from 'crypto';
21
+ import { tmpdir } from 'os';
21
22
  import { join, resolve, dirname } from 'path';
22
23
  import { fileURLToPath } from 'url';
23
24
 
@@ -53,12 +54,11 @@ export class Deck {
53
54
  * @returns {Promise<Deck>}
54
55
  */
55
56
  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' };
57
+ const fd = FigDeck.createEmpty({ name: opts.name ?? 'Untitled' });
59
58
  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;
59
+ // The zero-seed document ships with exactly one blank SLIDE; remember it
60
+ // so the first addBlankSlide()/addSlide() call can clear the placeholder.
61
+ deck._templateSlide = fd.getActiveSlides()[0] ?? null;
62
62
  return deck;
63
63
  }
64
64
 
@@ -122,7 +122,8 @@ export class Deck {
122
122
  addBlankSlide(opts = {}) {
123
123
  const fd = this._fd;
124
124
 
125
- // Clone structure from the first slide
125
+ // Clone structure from the first slide (before placeholder deletion —
126
+ // we still need it as the structural template).
126
127
  const templateSlide = fd.getActiveSlides()[0];
127
128
  if (!templateSlide) throw new Error('No slides to clone structure from');
128
129
 
@@ -130,6 +131,24 @@ export class Deck {
130
131
  ? `${templateSlide.parentIndex.guid.sessionID}:${templateSlide.parentIndex.guid.localID}`
131
132
  : null;
132
133
 
134
+ // Clear the zero-seed placeholder on the first real slide insertion.
135
+ // Must happen BEFORE activeCount is read, or the placeholder inflates
136
+ // the count and the first real slide ends up in slot 2.
137
+ // The placeholder is fully removed rather than flagged `phase: REMOVED`
138
+ // so the saved document contains no stale-node entries.
139
+ if (this._templateSlide) {
140
+ const toDrop = new Set();
141
+ toDrop.add(nid(this._templateSlide));
142
+ for (const child of fd.getChildren(nid(this._templateSlide))) {
143
+ toDrop.add(nid(child));
144
+ }
145
+ fd.message.nodeChanges = fd.message.nodeChanges.filter(
146
+ (n) => !toDrop.has(`${n.guid.sessionID}:${n.guid.localID}`),
147
+ );
148
+ this._templateSlide = null;
149
+ fd.rebuildMaps();
150
+ }
151
+
133
152
  const localID = fd.maxLocalID() + 1;
134
153
  const activeCount = fd.getActiveSlides().length;
135
154
 
@@ -166,18 +185,6 @@ export class Deck {
166
185
  fd.message.nodeChanges.push(newSlide);
167
186
  fd.rebuildMaps();
168
187
 
169
- // Auto-remove the original template blank slide on first addBlankSlide() call
170
- if (this._templateSlide) {
171
- this._templateSlide.phase = 'REMOVED';
172
- // Also remove children (e.g. FRAME nodes) of the template slide
173
- const templateChildren = fd.getChildren(nid(this._templateSlide));
174
- for (const child of templateChildren) {
175
- child.phase = 'REMOVED';
176
- }
177
- this._templateSlide = null;
178
- fd.rebuildMaps();
179
- }
180
-
181
188
  const slide = new Slide(fd, newSlide);
182
189
 
183
190
  if (opts.background) {
@@ -210,6 +217,21 @@ export class Deck {
210
217
 
211
218
  const templateInst = fd.getSlideInstance(nid(templateSlide));
212
219
 
220
+ // Auto-remove zero-seed placeholder on first add. Must happen before
221
+ // activeCount is read (see addBlankSlide for rationale).
222
+ if (this._templateSlide) {
223
+ const toDrop = new Set();
224
+ toDrop.add(nid(this._templateSlide));
225
+ for (const child of fd.getChildren(nid(this._templateSlide))) {
226
+ toDrop.add(nid(child));
227
+ }
228
+ fd.message.nodeChanges = fd.message.nodeChanges.filter(
229
+ (n) => !toDrop.has(`${n.guid.sessionID}:${n.guid.localID}`),
230
+ );
231
+ this._templateSlide = null;
232
+ fd.rebuildMaps();
233
+ }
234
+
213
235
  const slideRowId = templateSlide.parentIndex?.guid
214
236
  ? `${templateSlide.parentIndex.guid.sessionID}:${templateSlide.parentIndex.guid.localID}`
215
237
  : null;
@@ -246,8 +268,8 @@ export class Deck {
246
268
  newSlide.transform.m02 = activeCount * 2160;
247
269
  }
248
270
 
249
- // Build the INSTANCE node — clone from existing INSTANCE or construct from
250
- // scratch when the template SLIDE has a FRAME child instead (e.g. blank-template.deck).
271
+ // Build the INSTANCE node — clone from an existing INSTANCE if the deck
272
+ // already has one, otherwise construct a bare INSTANCE from scratch.
251
273
  let newInst;
252
274
  if (templateInst) {
253
275
  newInst = deepClone(templateInst);
@@ -342,6 +364,30 @@ export class Slide {
342
364
  get guid() { return nid(this._node); }
343
365
  get index() { return this._fd.getActiveSlides().indexOf(this._node); }
344
366
 
367
+ // --- Speaker notes --------------------------------------------------------
368
+
369
+ /**
370
+ * Set the speaker notes for this slide.
371
+ *
372
+ * Figma stores speaker notes as a Lexical editor state JSON string on the
373
+ * SLIDE node's `slideSpeakerNotes` field, NOT plain text. Writing a raw
374
+ * plain-text string here produces a .deck file that Figma refuses to import
375
+ * ("Something went wrong..."), because the importer unconditionally parses
376
+ * the field as Lexical JSON. We wrap the caller's plain text into a minimal
377
+ * Lexical tree (one paragraph per `\n`-delimited line).
378
+ */
379
+ setSpeakerNotes(text) {
380
+ this._node.slideSpeakerNotes = _plainToLexical(text ?? '');
381
+ return this;
382
+ }
383
+
384
+ /**
385
+ * Get the speaker notes for this slide as plain text (Lexical → plain).
386
+ */
387
+ getSpeakerNotes() {
388
+ return _lexicalToPlain(this._node.slideSpeakerNotes ?? '');
389
+ }
390
+
345
391
  // --- Shape access ----------------------------------------------------------
346
392
 
347
393
  /**
@@ -502,12 +548,12 @@ export class Slide {
502
548
 
503
549
  const { width, height } = await getImageDimensions(imgBuf);
504
550
 
505
- const tmpThumb = `/tmp/openfig_thumb_${Date.now()}.png`;
551
+ const tmpThumb = join(tmpdir(), `openfig_thumb_${Date.now()}.png`);
506
552
  await generateThumbnail(imgBuf, tmpThumb);
507
553
  const thumbHash = sha1Hex(readFileSync(tmpThumb));
508
554
 
509
555
  copyToImagesDir(this._fd, imgHash, imgPath ?? (() => {
510
- const tmp = `/tmp/openfig_img_${Date.now()}`;
556
+ const tmp = join(tmpdir(), `openfig_img_${Date.now()}`);
511
557
  writeFileSync(tmp, imgBuf);
512
558
  return tmp;
513
559
  })());
@@ -597,6 +643,12 @@ export class Slide {
597
643
  const fd = this._fd;
598
644
  const localID = fd.maxLocalID() + 1;
599
645
  const fill = parseColor(fd, opts.fill ?? 'White');
646
+ const strokeColor = opts.stroke ? parseColor(fd, opts.stroke) : null;
647
+ const strokePaints = strokeColor ? [{
648
+ type: 'SOLID',
649
+ color: { r: strokeColor.r, g: strokeColor.g, b: strokeColor.b, a: strokeColor.a ?? 1 },
650
+ opacity: 1, visible: true, blendMode: 'NORMAL',
651
+ }] : [];
600
652
 
601
653
  const node = {
602
654
  guid: { sessionID: 1, localID },
@@ -611,9 +663,11 @@ export class Slide {
611
663
  opacity: opts.opacity ?? 1,
612
664
  size: { x: width, y: height },
613
665
  transform: { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y },
614
- strokeWeight: 1,
615
- strokeAlign: 'INSIDE',
666
+ strokeWeight: opts.strokeWeight ?? (strokeColor ? 1 : 0),
667
+ strokeAlign: opts.strokeAlign ?? 'INSIDE',
616
668
  strokeJoin: 'MITER',
669
+ strokePaints,
670
+ ...(opts.dashPattern ? { dashPattern: opts.dashPattern } : {}),
617
671
  ...(opts.cornerRadius ? {
618
672
  cornerRadius: opts.cornerRadius,
619
673
  rectangleTopLeftCornerRadius: opts.cornerRadius,
@@ -670,7 +724,7 @@ export class Slide {
670
724
  const { width: origW, height: origH } = await getImageDimensions(imgBuf);
671
725
 
672
726
  // Generate thumbnail
673
- const tmpThumb = `/tmp/openfig_thumb_${Date.now()}.png`;
727
+ const tmpThumb = join(tmpdir(), `openfig_thumb_${Date.now()}.png`);
674
728
  await generateThumbnail(imgBuf, tmpThumb);
675
729
  const thumbHash = sha1Hex(readFileSync(tmpThumb));
676
730
 
@@ -678,7 +732,7 @@ export class Slide {
678
732
  if (imgPath) {
679
733
  copyToImagesDir(fd, imgHash, imgPath);
680
734
  } else {
681
- const tmpImg = `/tmp/openfig_img_${Date.now()}`;
735
+ const tmpImg = join(tmpdir(), `openfig_img_${Date.now()}`);
682
736
  writeFileSync(tmpImg, imgBuf);
683
737
  copyToImagesDir(fd, imgHash, tmpImg);
684
738
  }
@@ -810,11 +864,18 @@ export class Slide {
810
864
  styleIdForText = { guid: deepClone(styleDef.guid) };
811
865
  } else {
812
866
  // Detached or no style — explicit fields
813
- fontName = {
814
- family: opts.font ?? 'Inter',
815
- style: opts.fontStyle ?? 'Regular',
816
- postscript: '',
817
- };
867
+ const family = opts.font ?? 'Inter';
868
+ const style = opts.fontStyle ?? 'Regular';
869
+ // Figma's font resolver appears to match on (family, postscript) together.
870
+ // Empty postscript triggers substitution to a fallback even when the
871
+ // requested family is available (e.g. via Figma's Google Fonts
872
+ // integration). Synthesize a conventional PostScript name — this matches
873
+ // what Figma-authored files emit for standard Google Fonts (Inter-Bold,
874
+ // EBGaramond-Regular, etc.). Callers can pass `opts.postscript`
875
+ // explicitly to override.
876
+ const psName = opts.postscript
877
+ ?? family.replace(/\s+/g, '') + '-' + style.replace(/\s+/g, '');
878
+ fontName = { family, style, postscript: psName };
818
879
  fontSize = opts.fontSize ?? 36;
819
880
  lineHeight = { value: 1.4, units: 'RAW' };
820
881
  letterSpacing = { value: 0, units: 'PERCENT' };
@@ -827,12 +888,34 @@ export class Slide {
827
888
  fontSize = opts.fontSize;
828
889
  }
829
890
 
891
+ // Optional letterSpacing override: number (pixels) or raw { value, units }
892
+ if (opts.letterSpacing !== undefined) {
893
+ if (typeof opts.letterSpacing === 'number') {
894
+ letterSpacing = { value: opts.letterSpacing, units: 'PIXELS' };
895
+ } else {
896
+ letterSpacing = opts.letterSpacing;
897
+ }
898
+ }
899
+
900
+ // Optional lineHeight override: number → MULTIPLE (if <10) or PIXELS; or raw { value, units }
901
+ if (opts.lineHeight !== undefined) {
902
+ if (typeof opts.lineHeight === 'number') {
903
+ if (opts.lineHeight < 10) {
904
+ lineHeight = { value: opts.lineHeight, units: 'RAW' };
905
+ } else {
906
+ lineHeight = { value: opts.lineHeight, units: 'PIXELS' };
907
+ }
908
+ } else {
909
+ lineHeight = opts.lineHeight;
910
+ }
911
+ }
912
+
830
913
  const chars = (fullText === '' || fullText == null) ? ' ' : fullText;
831
914
  const textData = { characters: chars };
832
915
 
833
916
  // Build per-run formatting overrides
834
- if (isRuns && textOrRuns.some(r => r.bold || r.italic || r.underline || r.strikethrough || r.hyperlink)) {
835
- const overrides = buildRunOverrides(textOrRuns, fontName, styleIdForText);
917
+ if (isRuns && textOrRuns.some(r => r.bold || r.italic || r.underline || r.strikethrough || r.hyperlink || r.color)) {
918
+ const overrides = buildRunOverrides(textOrRuns, fontName, styleIdForText, fd);
836
919
  textData.styleOverrideTable = overrides.styleOverrideTable;
837
920
  textData.characterStyleIDs = overrides.characterStyleIDs;
838
921
  }
@@ -855,9 +938,8 @@ export class Slide {
855
938
  type: 'TEXT',
856
939
  name: opts.name ?? 'Text',
857
940
  visible: true,
858
- opacity: 1,
941
+ opacity: opts.opacity ?? 1,
859
942
  size: { x: opts.width ?? 1200, y: opts.height ?? 50 },
860
- textAutoResize: opts.height ? 'NONE' : 'HEIGHT',
861
943
  transform: {
862
944
  m00: 1, m01: 0, m02: opts.x ?? 128,
863
945
  m10: 0, m11: 1, m12: opts.y ?? 128,
@@ -868,9 +950,21 @@ export class Slide {
868
950
  lineHeight,
869
951
  letterSpacing,
870
952
  textTracking,
871
- textAutoResize: 'HEIGHT',
953
+ textAutoResize:
954
+ opts.autoresize === 'WIDTH_AND_HEIGHT'
955
+ ? 'WIDTH_AND_HEIGHT'
956
+ : opts.autoresize === 'HEIGHT'
957
+ ? 'HEIGHT'
958
+ : opts.autoresize === 'NONE'
959
+ ? 'NONE'
960
+ : opts.height
961
+ ? 'NONE'
962
+ : 'HEIGHT',
872
963
  textAlignHorizontal: opts.align ?? 'LEFT',
873
- textAlignVertical: 'TOP',
964
+ textAlignVertical:
965
+ opts.verticalAlign === 'CENTER' || opts.verticalAlign === 'BOTTOM'
966
+ ? opts.verticalAlign
967
+ : 'TOP',
874
968
  styleIdForText,
875
969
  fillPaints: [{
876
970
  type: 'SOLID',
@@ -951,6 +1045,12 @@ export class Slide {
951
1045
  color: { r: 0, g: 0, b: 0, a: 1 },
952
1046
  opacity: 1, visible: true, blendMode: 'NORMAL',
953
1047
  };
1048
+ const strokeColor = opts.stroke ? parseColor(fd, opts.stroke) : null;
1049
+ const strokePaint = strokeColor ? {
1050
+ type: 'SOLID',
1051
+ color: { r: strokeColor.r, g: strokeColor.g, b: strokeColor.b, a: strokeColor.a ?? 1 },
1052
+ opacity: 1, visible: true, blendMode: 'NORMAL',
1053
+ } : null;
954
1054
 
955
1055
  const node = {
956
1056
  guid: { sessionID: 1, localID },
@@ -970,7 +1070,7 @@ export class Slide {
970
1070
  shapeTruncates: false,
971
1071
  autoRename: true,
972
1072
  frameMaskDisabled: true,
973
- nodeGenerationData: buildShapeNodeGenData(fillPaint, textFill),
1073
+ nodeGenerationData: buildShapeNodeGenData(fillPaint, textFill, strokePaint, opts.strokeWeight ?? 1),
974
1074
  };
975
1075
 
976
1076
  fd.message.nodeChanges.push(node);
@@ -994,7 +1094,7 @@ export class Slide {
994
1094
  addLine(x1, y1, x2, y2, opts = {}) {
995
1095
  const fd = this._fd;
996
1096
  const localID = fd.maxLocalID() + 1;
997
- const color = parseColor(fd, opts.color ?? 'black');
1097
+ const color = parseColor(fd, opts.stroke ?? opts.color ?? 'black');
998
1098
 
999
1099
  const dx = x2 - x1;
1000
1100
  const dy = y2 - y1;
@@ -1012,12 +1112,14 @@ export class Slide {
1012
1112
  type: 'LINE',
1013
1113
  name: opts.name ?? 'Line',
1014
1114
  visible: true,
1015
- opacity: 1,
1115
+ opacity: opts.opacity ?? 1,
1016
1116
  size: { x: length, y: 0 },
1017
1117
  transform: { m00: cos, m01: -sin, m02: x1, m10: sin, m11: cos, m12: y1 },
1018
- strokeWeight: opts.weight ?? 2,
1118
+ strokeWeight: opts.strokeWeight ?? opts.weight ?? 2,
1019
1119
  strokeAlign: 'CENTER',
1020
1120
  strokeJoin: 'MITER',
1121
+ strokeCap: opts.strokeCap ?? 'NONE',
1122
+ ...(opts.dashPattern ? { dashPattern: opts.dashPattern } : {}),
1021
1123
  strokePaints: [{
1022
1124
  type: 'SOLID',
1023
1125
  color: { r: color.r, g: color.g, b: color.b, a: color.a ?? 1 },
@@ -1275,6 +1377,128 @@ export class Slide {
1275
1377
  return frameNode;
1276
1378
  }
1277
1379
 
1380
+ /**
1381
+ * Add a stroked SVG path (supports M/L/C/Q/Z open paths).
1382
+ * Unlike addSVG (closed-region fills), addPath draws a stroked line/curve.
1383
+ *
1384
+ * @param {string} d - SVG path data (e.g. 'M 100 100 Q 200 50 300 100')
1385
+ * @param {object} [opts]
1386
+ * @param {number} [opts.x=0] - X offset on slide
1387
+ * @param {number} [opts.y=0] - Y offset on slide
1388
+ * @param {object|string} [opts.stroke='black'] - Stroke color
1389
+ * @param {number} [opts.strokeWeight=2]
1390
+ * @param {string} [opts.strokeCap='NONE'] - 'NONE' | 'ROUND' | 'SQUARE' | 'LINE_ARROW' | 'TRIANGLE_ARROW'
1391
+ * @param {string} [opts.strokeJoin='MITER']
1392
+ * @param {number[]} [opts.dashPattern]
1393
+ * @param {object|string} [opts.fill] - Optional fill color for closed regions
1394
+ * @param {number} [opts.opacity=1]
1395
+ * @param {string} [opts.name='Path']
1396
+ * @returns {object} the raw VECTOR node
1397
+ */
1398
+ addPath(d, opts = {}) {
1399
+ const fd = this._fd;
1400
+ const localID = fd.maxLocalID() + 1;
1401
+
1402
+ const cmds = _parseSVGPath(d);
1403
+ if (!cmds.length) throw new Error('addPath: path data produced no commands');
1404
+
1405
+ // Compute bounding box from vertices
1406
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1407
+ for (const c of cmds) {
1408
+ const pts = [];
1409
+ if (c.type === 'M' || c.type === 'L') pts.push([c.x, c.y]);
1410
+ else if (c.type === 'C') pts.push([c.c1x, c.c1y], [c.c2x, c.c2y], [c.x, c.y]);
1411
+ for (const [px, py] of pts) {
1412
+ if (px < minX) minX = px; if (py < minY) minY = py;
1413
+ if (px > maxX) maxX = px; if (py > maxY) maxY = py;
1414
+ }
1415
+ }
1416
+ if (!isFinite(minX)) { minX = 0; minY = 0; maxX = 0; maxY = 0; }
1417
+ const width = Math.max(1, maxX - minX);
1418
+ const height = Math.max(1, maxY - minY);
1419
+
1420
+ // Shift all commands to origin (0,0)
1421
+ const shifted = cmds.map(c => {
1422
+ if (c.type === 'M' || c.type === 'L') return { ...c, x: c.x - minX, y: c.y - minY };
1423
+ if (c.type === 'C') return {
1424
+ ...c,
1425
+ c1x: c.c1x - minX, c1y: c.c1y - minY,
1426
+ c2x: c.c2x - minX, c2y: c.c2y - minY,
1427
+ x: c.x - minX, y: c.y - minY,
1428
+ };
1429
+ return c;
1430
+ });
1431
+
1432
+ // commandsBlob (scaled 1:1) and vectorNetworkBlob
1433
+ fd.message.blobs.push({ bytes: _encodeCommandsBlob(shifted, 1, 1) });
1434
+ const cmdsBlobIdx = fd.message.blobs.length - 1;
1435
+ fd.message.blobs.push({ bytes: _buildVectorNetworkBlob([shifted], { emitRegions: !!opts.fill }) });
1436
+ const vnbIdx = fd.message.blobs.length - 1;
1437
+
1438
+ const strokeColor = parseColor(fd, opts.stroke ?? 'black');
1439
+ const strokePaints = [{
1440
+ type: 'SOLID',
1441
+ color: { r: strokeColor.r, g: strokeColor.g, b: strokeColor.b, a: strokeColor.a ?? 1 },
1442
+ opacity: 1,
1443
+ visible: true,
1444
+ blendMode: 'NORMAL',
1445
+ }];
1446
+
1447
+ const fillPaints = [];
1448
+ if (opts.fill) {
1449
+ const fc = parseColor(fd, opts.fill);
1450
+ fillPaints.push({
1451
+ type: 'SOLID',
1452
+ color: { r: fc.r, g: fc.g, b: fc.b, a: fc.a ?? 1 },
1453
+ opacity: 1,
1454
+ visible: true,
1455
+ blendMode: 'NORMAL',
1456
+ });
1457
+ }
1458
+
1459
+ const node = {
1460
+ guid: { sessionID: 1, localID },
1461
+ phase: 'CREATED',
1462
+ parentIndex: {
1463
+ guid: this._node.guid,
1464
+ position: positionChar(fd.getChildren(nid(this._node)).length),
1465
+ },
1466
+ type: 'VECTOR',
1467
+ name: opts.name ?? 'Path',
1468
+ visible: true,
1469
+ opacity: opts.opacity ?? 1,
1470
+ size: { x: width, y: height },
1471
+ transform: {
1472
+ m00: 1, m01: 0, m02: (opts.x ?? 0) + minX,
1473
+ m10: 0, m11: 1, m12: (opts.y ?? 0) + minY,
1474
+ },
1475
+ strokeWeight: opts.strokeWeight ?? 2,
1476
+ strokeAlign: opts.strokeAlign ?? 'CENTER',
1477
+ strokeJoin: opts.strokeJoin ?? 'MITER',
1478
+ strokeCap: opts.strokeCap ?? 'NONE',
1479
+ strokePaints,
1480
+ fillPaints,
1481
+ fillGeometry: opts.fill ? [{ windingRule: 'NONZERO', commandsBlob: cmdsBlobIdx, styleID: 0 }] : [],
1482
+ // strokeGeometry must be a *pre-expanded outline ribbon* (Figma fills
1483
+ // it with the stroke color — it's not rendered as an SVG stroke). We
1484
+ // only have the centerline here, so leave it empty and let Figma
1485
+ // derive the stroke from vectorNetworkBlob + strokePaints + strokeWeight.
1486
+ // Supplying the centerline as strokeGeometry makes Figma's server
1487
+ // rasterizer fill the open curve, producing a "lens" shape in previews.
1488
+ strokeGeometry: [],
1489
+ ...(opts.dashPattern ? { dashPattern: opts.dashPattern } : {}),
1490
+ vectorData: {
1491
+ vectorNetworkBlob: vnbIdx,
1492
+ normalizedSize: { x: width, y: height },
1493
+ styleOverrideTable: [],
1494
+ },
1495
+ };
1496
+
1497
+ fd.message.nodeChanges.push(node);
1498
+ fd.rebuildMaps();
1499
+ return node;
1500
+ }
1501
+
1278
1502
  /**
1279
1503
  * Add a FRAME (auto-layout container) to this slide.
1280
1504
  * Useful for grouping text nodes with automatic spacing.
@@ -1567,14 +1791,14 @@ export class Shape {
1567
1791
  const imgHash = sha1Hex(imgBuf);
1568
1792
  const { width: origW, height: origH } = await getImageDimensions(imgBuf);
1569
1793
 
1570
- const tmpThumb = `/tmp/openfig_thumb_${Date.now()}.png`;
1794
+ const tmpThumb = join(tmpdir(), `openfig_thumb_${Date.now()}.png`);
1571
1795
  await generateThumbnail(imgBuf, tmpThumb);
1572
1796
  const thumbHash = sha1Hex(readFileSync(tmpThumb));
1573
1797
 
1574
1798
  if (imgPath) {
1575
1799
  copyToImagesDir(this._fd, imgHash, imgPath);
1576
1800
  } else {
1577
- const tmpImg = `/tmp/openfig_img_${Date.now()}`;
1801
+ const tmpImg = join(tmpdir(), `openfig_img_${Date.now()}`);
1578
1802
  writeFileSync(tmpImg, imgBuf);
1579
1803
  copyToImagesDir(this._fd, imgHash, tmpImg);
1580
1804
  }
@@ -1623,6 +1847,10 @@ class FrameProxy {
1623
1847
  return Slide.prototype.addText.call(this, text, { ...opts, x: 0, y: 0 });
1624
1848
  }
1625
1849
 
1850
+ addFrame(x, y, width, height, opts = {}) {
1851
+ return Slide.prototype.addFrame.call(this, x, y, width, height, opts);
1852
+ }
1853
+
1626
1854
  addRectangle(x, y, width, height, opts = {}) {
1627
1855
  return Slide.prototype.addRectangle.call(this, x, y, width, height, opts);
1628
1856
  }
@@ -1630,6 +1858,30 @@ class FrameProxy {
1630
1858
  async addImage(pathOrBuf, opts = {}) {
1631
1859
  return Slide.prototype.addImage.call(this, pathOrBuf, opts);
1632
1860
  }
1861
+
1862
+ addEllipse(x, y, width, height, opts = {}) {
1863
+ return Slide.prototype.addEllipse.call(this, x, y, width, height, opts);
1864
+ }
1865
+
1866
+ addPath(d, opts = {}) {
1867
+ return Slide.prototype.addPath.call(this, d, opts);
1868
+ }
1869
+
1870
+ addLine(x1, y1, x2, y2, opts = {}) {
1871
+ return Slide.prototype.addLine.call(this, x1, y1, x2, y2, opts);
1872
+ }
1873
+
1874
+ addSVG(svg, opts = {}) {
1875
+ return Slide.prototype.addSVG.call(this, svg, opts);
1876
+ }
1877
+
1878
+ // addEllipse / addDiamond / addTriangle / addStar all route through
1879
+ // Slide.prototype._addShapeWithText, which expects `this._addShapeWithText`
1880
+ // to resolve on the receiver. Expose it here so FrameProxy behaves like a
1881
+ // Slide target for those shape handlers.
1882
+ _addShapeWithText(shapeType, x, y, width, height, opts = {}) {
1883
+ return Slide.prototype._addShapeWithText.call(this, shapeType, x, y, width, height, opts);
1884
+ }
1633
1885
  }
1634
1886
 
1635
1887
  // ---------------------------------------------------------------------------
@@ -1665,7 +1917,12 @@ function resolveTextStyle(fd, styleName) {
1665
1917
  );
1666
1918
  if (preview) return preview;
1667
1919
 
1668
- throw new Error(`Unknown text style: "${styleName}". Available: Title, Header 1, Header 2, Header 3, Body 1, Body 2, Body 3, Note`);
1920
+ const available = nodes
1921
+ .filter(n => n.type === 'TEXT' && (n.isPublishable === true || (n.locked === true && n.visible === false)))
1922
+ .map(n => n.name)
1923
+ .filter(Boolean);
1924
+ const avail = available.length ? available.join(', ') : '(no named styles in deck — pass explicit font/fontSize instead)';
1925
+ throw new Error(`Unknown text style: "${styleName}". Available: ${avail}`);
1669
1926
  }
1670
1927
 
1671
1928
  // Designer-friendly color aliases → hex
@@ -1694,6 +1951,58 @@ function _hexToRgb(hex) {
1694
1951
  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 };
1695
1952
  }
1696
1953
 
1954
+ // Speaker-notes Lexical <-> plain-text helpers. See setSpeakerNotes for the
1955
+ // Figma-import failure mode this works around.
1956
+ function _plainToLexical(plain) {
1957
+ // Match Figma's native speaker-notes format byte-for-byte: paragraphs with
1958
+ // text content get direction "ltr" and textFormat 0; truly empty paragraphs
1959
+ // (blank-line separators, or a fully-empty notes document) get direction null
1960
+ // and textFormat null to match the blank-deck template.
1961
+ const lines = String(plain).split('\n');
1962
+ const children = lines.map(line => {
1963
+ const hasText = line !== '';
1964
+ return {
1965
+ children: hasText ? [{
1966
+ detail: 0, format: 0, mode: 'normal', style: '',
1967
+ text: line, type: 'text', version: 1,
1968
+ }] : [],
1969
+ direction: hasText ? 'ltr' : null,
1970
+ format: '',
1971
+ indent: 0,
1972
+ type: 'paragraph',
1973
+ version: 1,
1974
+ textFormat: hasText ? 0 : null,
1975
+ textStyle: '',
1976
+ };
1977
+ });
1978
+ const hasAnyText = lines.some(l => l !== '');
1979
+ return JSON.stringify({
1980
+ root: {
1981
+ children: children.length ? children : [{
1982
+ children: [], direction: null, format: '', textFormat: null,
1983
+ indent: 0, type: 'paragraph', version: 1, textStyle: '',
1984
+ }],
1985
+ direction: hasAnyText ? 'ltr' : null,
1986
+ format: '',
1987
+ indent: 0,
1988
+ type: 'root',
1989
+ version: 1,
1990
+ },
1991
+ });
1992
+ }
1993
+
1994
+ function _lexicalToPlain(stored) {
1995
+ if (!stored) return '';
1996
+ // Tolerate plain-text round-trips from before the Lexical wrapping existed.
1997
+ if (typeof stored !== 'string' || !stored.startsWith('{')) return String(stored);
1998
+ let doc;
1999
+ try { doc = JSON.parse(stored); } catch { return stored; }
2000
+ const paragraphs = doc?.root?.children ?? [];
2001
+ return paragraphs.map(p =>
2002
+ (p?.children ?? []).map(c => c?.text ?? '').join('')
2003
+ ).join('\n');
2004
+ }
2005
+
1697
2006
  /**
1698
2007
  * Resolve any color value to { r, g, b } (0-1) plus optional colorVar for Figma variables.
1699
2008
  * Accepts:
@@ -1706,6 +2015,15 @@ function parseColor(fd, color) {
1706
2015
  if (!color && color !== 0) return { r: 0, g: 0, b: 0 };
1707
2016
  if (typeof color === 'object') return { r: color.r, g: color.g, b: color.b };
1708
2017
  if (typeof color === 'string') {
2018
+ // CSS rgb()/rgba() — common in SVG `fill` attributes.
2019
+ const rgbMatch = color.match(/^rgba?\(\s*([0-9.]+)[,\s]+([0-9.]+)[,\s]+([0-9.]+)(?:[,\s/]+[0-9.]+%?)?\s*\)$/i);
2020
+ if (rgbMatch) {
2021
+ return {
2022
+ r: Math.max(0, Math.min(1, parseFloat(rgbMatch[1]) / 255)),
2023
+ g: Math.max(0, Math.min(1, parseFloat(rgbMatch[2]) / 255)),
2024
+ b: Math.max(0, Math.min(1, parseFloat(rgbMatch[3]) / 255)),
2025
+ };
2026
+ }
1709
2027
  // Hex string
1710
2028
  if (/^#?[0-9a-fA-F]{6}$/.test(color)) return _hexToRgb(color);
1711
2029
  // Figma theme variable first (exact match, preserves colorVar binding for slides)
@@ -1749,14 +2067,14 @@ function resolveColorVariable(fd, colorName) {
1749
2067
  * Build styleOverrideTable + characterStyleIDs from an array of text runs.
1750
2068
  * Each unique formatting combination gets its own styleID.
1751
2069
  */
1752
- function buildRunOverrides(runs, baseFontName, styleIdForText) {
2070
+ function buildRunOverrides(runs, baseFontName, styleIdForText, fd) {
1753
2071
  const styleOverrideTable = [];
1754
2072
  const characterStyleIDs = [];
1755
2073
  const styleMap = new Map(); // formatKey → styleID
1756
2074
  let nextStyleID = 1;
1757
2075
 
1758
2076
  for (const run of runs) {
1759
- const hasFormat = run.bold || run.italic || run.underline || run.strikethrough || run.hyperlink;
2077
+ const hasFormat = run.bold || run.italic || run.underline || run.strikethrough || run.hyperlink || run.color;
1760
2078
 
1761
2079
  if (!hasFormat) {
1762
2080
  // Plain text — styleID 0 (base style)
@@ -1765,7 +2083,8 @@ function buildRunOverrides(runs, baseFontName, styleIdForText) {
1765
2083
  }
1766
2084
 
1767
2085
  // Build a key for this unique formatting combination
1768
- const key = `${run.bold ? 'b' : ''}${run.italic ? 'i' : ''}${run.underline ? 'u' : ''}${run.strikethrough ? 's' : ''}${run.hyperlink ? 'h:' + run.hyperlink : ''}`;
2086
+ const colorKey = run.color ? 'c:' + (typeof run.color === 'string' ? run.color : JSON.stringify(run.color)) : '';
2087
+ const key = `${run.bold ? 'b' : ''}${run.italic ? 'i' : ''}${run.underline ? 'u' : ''}${run.strikethrough ? 's' : ''}${run.hyperlink ? 'h:' + run.hyperlink : ''}${colorKey}`;
1769
2088
 
1770
2089
  if (!styleMap.has(key)) {
1771
2090
  const styleID = nextStyleID++;
@@ -1809,6 +2128,18 @@ function buildRunOverrides(runs, baseFontName, styleIdForText) {
1809
2128
  entry.textDecoration = 'STRIKETHROUGH';
1810
2129
  }
1811
2130
 
2131
+ // Per-run color
2132
+ if (run.color && fd) {
2133
+ const c = parseColor(fd, run.color);
2134
+ entry.fillPaints = [{
2135
+ type: 'SOLID',
2136
+ color: { r: c.r, g: c.g, b: c.b, a: c.a ?? 1 },
2137
+ opacity: 1,
2138
+ visible: true,
2139
+ blendMode: 'NORMAL',
2140
+ }];
2141
+ }
2142
+
1812
2143
  styleOverrideTable.push(entry);
1813
2144
  }
1814
2145
 
@@ -1918,7 +2249,7 @@ function buildLines(characters, runs, listType) {
1918
2249
  * Build the nodeGenerationData required for SHAPE_WITH_TEXT nodes.
1919
2250
  * Two override entries: [0] = shape background, [1] = inner text.
1920
2251
  */
1921
- function buildShapeNodeGenData(shapeFillPaint, textFillPaint) {
2252
+ function buildShapeNodeGenData(shapeFillPaint, textFillPaint, shapeStrokePaint = null, shapeStrokeWeight = 1) {
1922
2253
  const DETACHED = { guid: { sessionID: 4294967295, localID: 4294967295 } };
1923
2254
  const baseOverride = {
1924
2255
  styleIdForFill: DETACHED,
@@ -1962,6 +2293,10 @@ function buildShapeNodeGenData(shapeFillPaint, textFillPaint) {
1962
2293
  ...baseOverride,
1963
2294
  guidPath: { guids: [{ sessionID: 40000000, localID: 0 }] },
1964
2295
  fillPaints: [shapeFillPaint],
2296
+ ...(shapeStrokePaint ? {
2297
+ strokePaints: [shapeStrokePaint],
2298
+ strokeWeight: shapeStrokeWeight,
2299
+ } : {}),
1965
2300
  },
1966
2301
  {
1967
2302
  ...baseOverride,
@@ -1979,15 +2314,24 @@ function buildShapeNodeGenData(shapeFillPaint, textFillPaint) {
1979
2314
 
1980
2315
  function _parseSVGPath(d) {
1981
2316
  const tokens = [];
1982
- const re = /([MmLlCcSsHhVvZz])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/g;
2317
+ const re = /([MmLlCcSsHhVvQqTtZz])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/g;
1983
2318
  let m;
1984
2319
  while ((m = re.exec(d)) !== null) {
1985
2320
  if (m[1]) tokens.push(m[1]);
1986
2321
  else tokens.push(parseFloat(m[2]));
1987
2322
  }
1988
2323
  const cmds = [];
1989
- let i = 0, cx = 0, cy = 0, startX = 0, startY = 0, prevC2x = 0, prevC2y = 0, cmd = '';
2324
+ let i = 0, cx = 0, cy = 0, startX = 0, startY = 0, prevC2x = 0, prevC2y = 0, prevQx = 0, prevQy = 0, cmd = '';
1990
2325
  const num = () => tokens[i++];
2326
+ // Q(c, end) → C using: c1 = start + 2/3(c - start), c2 = end + 2/3(c - end)
2327
+ const qToC = (sx, sy, qx, qy, ex, ey) => ({
2328
+ type: 'C',
2329
+ c1x: sx + (2 / 3) * (qx - sx),
2330
+ c1y: sy + (2 / 3) * (qy - sy),
2331
+ c2x: ex + (2 / 3) * (qx - ex),
2332
+ c2y: ey + (2 / 3) * (qy - ey),
2333
+ x: ex, y: ey,
2334
+ });
1991
2335
  while (i < tokens.length) {
1992
2336
  if (typeof tokens[i] === 'string') cmd = tokens[i++];
1993
2337
  switch (cmd) {
@@ -2003,6 +2347,10 @@ function _parseSVGPath(d) {
2003
2347
  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; }
2004
2348
  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; }
2005
2349
  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; }
2350
+ case 'Q': { const sx=cx,sy=cy,qx=num(),qy=num(); cx=num(); cy=num(); prevQx=qx; prevQy=qy; cmds.push(qToC(sx,sy,qx,qy,cx,cy)); break; }
2351
+ case 'q': { const sx=cx,sy=cy,qx=cx+num(),qy=cy+num(); cx+=num(); cy+=num(); prevQx=qx; prevQy=qy; cmds.push(qToC(sx,sy,qx,qy,cx,cy)); break; }
2352
+ case 'T': { const sx=cx,sy=cy,qx=2*cx-prevQx,qy=2*cy-prevQy; cx=num(); cy=num(); prevQx=qx; prevQy=qy; cmds.push(qToC(sx,sy,qx,qy,cx,cy)); break; }
2353
+ case 't': { const sx=cx,sy=cy,qx=2*cx-prevQx,qy=2*cy-prevQy; cx+=num(); cy+=num(); prevQx=qx; prevQy=qy; cmds.push(qToC(sx,sy,qx,qy,cx,cy)); break; }
2006
2354
  case 'Z': case 'z': cmds.push({ type: 'Z' }); cx = startX; cy = startY; break;
2007
2355
  default: i++; break;
2008
2356
  }
@@ -2029,17 +2377,33 @@ function _encodeCommandsBlob(cmds, sx, sy) {
2029
2377
  return new Uint8Array(buf.buffer, 0, off);
2030
2378
  }
2031
2379
 
2032
- function _buildVectorNetworkBlob(allPathCmds) {
2380
+ function _buildVectorNetworkBlob(allPathCmds, { emitRegions = true } = {}) {
2381
+ // Regions in a vector network define closed fillable loops. addSVG needs
2382
+ // them (filled glyphs/shapes). addPath emits open stroked curves — if we
2383
+ // emit a region for those, Figma renders a filled area between endpoints
2384
+ // even when fillPaints is empty, producing "lens" shapes instead of strokes.
2033
2385
  const vertices = [];
2034
2386
  const segments = [];
2035
2387
  const regions = [];
2036
2388
 
2037
2389
  for (const pathCmds of allPathCmds) {
2038
- const regionSegs = [];
2390
+ // One region can contain multiple independent loops (SVG `M ... Z M ... Z`
2391
+ // draws e.g. the two O-shapes in a pair of letters). Track loops as
2392
+ // separate segment-index arrays so the region header can emit the correct
2393
+ // numLoops and each loop's segment list. Writing a single flat list with
2394
+ // numLoops=1 makes Figma connect the end of one subpath to the start of
2395
+ // the next as if they were a single chain — visible as a diagonal bar
2396
+ // running through multi-letter wordmark paths.
2397
+ const loops = [];
2398
+ let currentLoop = [];
2039
2399
  let firstVtx = -1, prevVtx = -1, prevX = 0, prevY = 0;
2040
2400
 
2041
2401
  for (const c of pathCmds) {
2042
2402
  if (c.type === 'M') {
2403
+ if (currentLoop.length > 0) {
2404
+ loops.push(currentLoop);
2405
+ currentLoop = [];
2406
+ }
2043
2407
  const vi = vertices.length;
2044
2408
  vertices.push({ x: c.x, y: c.y });
2045
2409
  firstVtx = vi; prevVtx = vi; prevX = c.x; prevY = c.y;
@@ -2047,7 +2411,7 @@ function _buildVectorNetworkBlob(allPathCmds) {
2047
2411
  const vi = vertices.length;
2048
2412
  vertices.push({ x: c.x, y: c.y });
2049
2413
  if (prevVtx >= 0) {
2050
- regionSegs.push(segments.length);
2414
+ currentLoop.push(segments.length);
2051
2415
  segments.push({ s: prevVtx, tsx: 0, tsy: 0, e: vi, tex: 0, tey: 0, t: 0 });
2052
2416
  }
2053
2417
  prevVtx = vi; prevX = c.x; prevY = c.y;
@@ -2055,24 +2419,30 @@ function _buildVectorNetworkBlob(allPathCmds) {
2055
2419
  const vi = vertices.length;
2056
2420
  vertices.push({ x: c.x, y: c.y });
2057
2421
  if (prevVtx >= 0) {
2058
- regionSegs.push(segments.length);
2422
+ currentLoop.push(segments.length);
2059
2423
  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 });
2060
2424
  }
2061
2425
  prevVtx = vi; prevX = c.x; prevY = c.y;
2062
2426
  } else if (c.type === 'Z') {
2063
2427
  if (prevVtx >= 0 && prevVtx !== firstVtx) {
2064
- regionSegs.push(segments.length);
2428
+ currentLoop.push(segments.length);
2065
2429
  segments.push({ s: prevVtx, tsx: 0, tsy: 0, e: firstVtx, tex: 0, tey: 0, t: 0 });
2066
2430
  }
2067
2431
  prevVtx = firstVtx; prevX = vertices[firstVtx].x; prevY = vertices[firstVtx].y;
2068
2432
  }
2069
2433
  }
2070
- regions.push(regionSegs);
2434
+ if (currentLoop.length > 0) loops.push(currentLoop);
2435
+ if (emitRegions) regions.push(loops);
2071
2436
  }
2072
2437
 
2073
2438
  // Calculate size: header(16) + vertices(12 each) + segments(28 each) + regions(variable)
2439
+ // Region layout: numLoops(u32) + [segCount(u32) + segIndices(u32*n)]*numLoops + windingRule(u32)
2074
2440
  let regSize = 0;
2075
- for (const r of regions) regSize += 4 + 4 + r.length * 4 + 4; // numLoops + segCount + indices + windingRule
2441
+ for (const loops of regions) {
2442
+ regSize += 4; // numLoops
2443
+ for (const loop of loops) regSize += 4 + loop.length * 4; // segCount + indices
2444
+ regSize += 4; // windingRule
2445
+ }
2076
2446
  const totalSize = 16 + vertices.length * 12 + segments.length * 28 + regSize;
2077
2447
  const buf = Buffer.alloc(totalSize);
2078
2448
  let off = 0;
@@ -2101,11 +2471,13 @@ function _buildVectorNetworkBlob(allPathCmds) {
2101
2471
  buf.writeUInt32LE(s.t, off); off += 4;
2102
2472
  }
2103
2473
 
2104
- // Regions: numLoops(u32) segCount(u32) segIndices(u32*n) windingRule(u32)
2105
- for (const r of regions) {
2106
- buf.writeUInt32LE(1, off); off += 4; // numLoops = 1
2107
- buf.writeUInt32LE(r.length, off); off += 4;
2108
- for (const si of r) { buf.writeUInt32LE(si, off); off += 4; }
2474
+ // Regions: numLoops(u32) + [segCount(u32) + segIndices(u32*n)]*numLoops + windingRule(u32)
2475
+ for (const loops of regions) {
2476
+ buf.writeUInt32LE(loops.length, off); off += 4;
2477
+ for (const loop of loops) {
2478
+ buf.writeUInt32LE(loop.length, off); off += 4;
2479
+ for (const si of loop) { buf.writeUInt32LE(si, off); off += 4; }
2480
+ }
2109
2481
  buf.writeUInt32LE(1, off); off += 4; // windingRule = NONZERO
2110
2482
  }
2111
2483
 
@@ -2118,7 +2490,7 @@ function sha1Hex(buf) {
2118
2490
 
2119
2491
  function copyToImagesDir(fd, hash, srcPath) {
2120
2492
  if (!fd.imagesDir) {
2121
- fd.imagesDir = `/tmp/openfig_images_${Date.now()}`;
2493
+ fd.imagesDir = join(tmpdir(), `openfig_images_${Date.now()}`);
2122
2494
  mkdirSync(fd.imagesDir, { recursive: true });
2123
2495
  }
2124
2496
  const dest = join(fd.imagesDir, hash);