openfig-cli 0.3.42 → 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.
- package/README.md +4 -1
- package/bin/cli.mjs +5 -0
- package/bin/commands/convert-html.mjs +44 -0
- package/bin/commands/create-deck.mjs +34 -0
- package/lib/core/fig-deck.mjs +39 -0
- package/lib/rasterizer/svg-builder.mjs +181 -41
- package/lib/slides/api.mjs +435 -63
- package/lib/slides/browser-extract.mjs +1280 -0
- package/lib/slides/empty-deck.mjs +354 -0
- package/lib/slides/handoff/bundle-loader.mjs +93 -0
- package/lib/slides/handoff/element-dispatch.mjs +1685 -0
- package/lib/slides/handoff-converter.mjs +321 -0
- package/lib/slides/html-converter.mjs +395 -0
- package/lib/slides/playwright-layout.mjs +169 -0
- package/mcp-server.mjs +36 -0
- package/package.json +4 -1
- package/lib/slides/blank-template.deck +0 -0
package/lib/slides/api.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
61
|
-
|
|
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
|
|
250
|
-
//
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 = /([
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2106
|
-
buf.writeUInt32LE(
|
|
2107
|
-
|
|
2108
|
-
|
|
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 =
|
|
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);
|