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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/cli.mjs +111 -0
- package/bin/commands/clone-slide.mjs +153 -0
- package/bin/commands/export.mjs +83 -0
- package/bin/commands/insert-image.mjs +90 -0
- package/bin/commands/inspect.mjs +91 -0
- package/bin/commands/list-overrides.mjs +66 -0
- package/bin/commands/list-text.mjs +60 -0
- package/bin/commands/remove-slide.mjs +47 -0
- package/bin/commands/roundtrip.mjs +37 -0
- package/bin/commands/update-text.mjs +79 -0
- package/lib/core/deep-clone.mjs +16 -0
- package/lib/core/fig-deck.mjs +332 -0
- package/lib/core/image-helpers.mjs +56 -0
- package/lib/core/image-utils.mjs +29 -0
- package/lib/core/node-helpers.mjs +49 -0
- package/lib/rasterizer/deck-rasterizer.mjs +233 -0
- package/lib/rasterizer/download-font.mjs +57 -0
- package/lib/rasterizer/font-resolver.mjs +602 -0
- package/lib/rasterizer/fonts/DarkerGrotesque-400.ttf +0 -0
- package/lib/rasterizer/fonts/DarkerGrotesque-500.ttf +0 -0
- package/lib/rasterizer/fonts/DarkerGrotesque-600.ttf +0 -0
- package/lib/rasterizer/fonts/Inter-Regular.ttf +0 -0
- package/lib/rasterizer/fonts/Inter-Variable.ttf +0 -0
- package/lib/rasterizer/fonts/Inter.var.woff2 +0 -0
- package/lib/rasterizer/fonts/avenir-next-bold-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-bold.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-demibold-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-demibold.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-medium-italic.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-medium.ttf +0 -0
- package/lib/rasterizer/fonts/avenir-next-regular.ttf +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-400-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-500-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-600-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/darker-grotesque-patched-700-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-400-italic.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-400-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-500-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-600-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-700-italic.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-700-normal.woff2 +0 -0
- package/lib/rasterizer/fonts/inter-v3-meta.json +6 -0
- package/lib/rasterizer/render-report-lib.mjs +239 -0
- package/lib/rasterizer/render-report.mjs +25 -0
- package/lib/rasterizer/svg-builder.mjs +1328 -0
- package/lib/rasterizer/test-render.mjs +57 -0
- package/lib/slides/api.mjs +2100 -0
- package/lib/slides/blank-template.deck +0 -0
- package/lib/slides/template-deck.mjs +671 -0
- package/manifest.json +21 -0
- package/mcp-server.mjs +541 -0
- package/package.json +74 -0
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* svg-builder.mjs — Convert a Figma node tree to an SVG string.
|
|
3
|
+
*
|
|
4
|
+
* Architecture: dispatcher pattern — each Figma node type maps to a render
|
|
5
|
+
* function. Unknown types emit a magenta placeholder rect so renders never
|
|
6
|
+
* crash. Add handlers incrementally as coverage grows.
|
|
7
|
+
*
|
|
8
|
+
* Parameter naming — two entry points, one shared engine:
|
|
9
|
+
*
|
|
10
|
+
* slideToSvg(deck, slideNode) — Slides entry point (.deck files)
|
|
11
|
+
* "deck" = a parsed .deck file. Expects SLIDE nodes, 1920×1080 viewport.
|
|
12
|
+
*
|
|
13
|
+
* frameToSvg(fig, node) — Design entry point (.fig files)
|
|
14
|
+
* "fig" = a parsed .fig file. Uses the node's own size as viewport.
|
|
15
|
+
*
|
|
16
|
+
* Internal render functions all accept "deck" for historical reasons,
|
|
17
|
+
* but they are format-agnostic — they work on any Figma node tree
|
|
18
|
+
* regardless of whether it came from a .deck or .fig file.
|
|
19
|
+
* Both formats share the same binary codec (canvas.fig inside a ZIP).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
import { hashToHex } from '../core/image-helpers.mjs';
|
|
25
|
+
import { nid } from '../core/node-helpers.mjs';
|
|
26
|
+
|
|
27
|
+
export const SLIDE_W = 1920;
|
|
28
|
+
export const SLIDE_H = 1080;
|
|
29
|
+
|
|
30
|
+
// Per-slide ID counter — reset at the start of each slideToSvg call so IDs are unique within each SVG doc
|
|
31
|
+
let _svgIdSeq = 0;
|
|
32
|
+
|
|
33
|
+
// Depth counter for SYMBOL rendering context. When > 0 we're inside a component
|
|
34
|
+
// tree and frame clipping is suppressed — Figma doesn't enforce child frame clips
|
|
35
|
+
// inside components whose root allows overflow (frameMaskDisabled=true).
|
|
36
|
+
let _symbolDepth = 0;
|
|
37
|
+
|
|
38
|
+
// ── Color helpers ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function cssColor(color, opacity = 1) {
|
|
41
|
+
const r = Math.round((color.r ?? 0) * 255);
|
|
42
|
+
const g = Math.round((color.g ?? 0) * 255);
|
|
43
|
+
const b = Math.round((color.b ?? 0) * 255);
|
|
44
|
+
const a = ((color.a ?? 1) * opacity).toFixed(4);
|
|
45
|
+
return `rgba(${r},${g},${b},${a})`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveFill(fillPaints) {
|
|
49
|
+
if (!fillPaints?.length) return null;
|
|
50
|
+
const p = fillPaints.find(p => p.visible !== false && p.type === 'SOLID');
|
|
51
|
+
if (!p) return null;
|
|
52
|
+
return cssColor(p.color ?? {}, p.opacity ?? 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Generate an SVG gradient definition for a GRADIENT_LINEAR or GRADIENT_RADIAL paint.
|
|
56
|
+
* w, h = element dimensions in pixels (needed for userSpaceOnUse coordinates).
|
|
57
|
+
* Returns { defs: string, fill: string } where fill is 'url(#grad-N)'. */
|
|
58
|
+
function resolveGradientSvg(paint, w, h) {
|
|
59
|
+
const id = `grad-${++_svgIdSeq}`;
|
|
60
|
+
const stops = (paint.stops ?? []).map(s => {
|
|
61
|
+
const color = cssColor(s.color ?? {});
|
|
62
|
+
return `<stop offset="${s.position}" stop-color="${color}"/>`;
|
|
63
|
+
}).join('');
|
|
64
|
+
const opacityAttr = (paint.opacity ?? 1) !== 1 ? ` opacity="${paint.opacity}"` : '';
|
|
65
|
+
|
|
66
|
+
// Figma's paint.transform maps from NODE space to GRADIENT space.
|
|
67
|
+
// We need the inverse: gradient space → node normalized space → pixels.
|
|
68
|
+
const t = paint.transform ?? {};
|
|
69
|
+
const ga = t.m00 ?? 1, gc = t.m01 ?? 0, ge = t.m02 ?? 0;
|
|
70
|
+
const gb = t.m10 ?? 0, gd = t.m11 ?? 1, gf = t.m12 ?? 0;
|
|
71
|
+
const det = ga * gd - gb * gc;
|
|
72
|
+
// Inverse affine: paint.transform maps node→gradient; we need gradient→node
|
|
73
|
+
const ia = gd / det, ic = -gc / det, ie = (gc * gf - gd * ge) / det;
|
|
74
|
+
const ib = -gb / det, iid = ga / det, iif = (gb * ge - ga * gf) / det;
|
|
75
|
+
const tx = (gx, gy) => (ia * gx + ic * gy + ie) * w;
|
|
76
|
+
const ty = (gx, gy) => (ib * gx + iid * gy + iif) * h;
|
|
77
|
+
const f = v => +v.toFixed(2);
|
|
78
|
+
|
|
79
|
+
if (paint.type === 'GRADIENT_LINEAR') {
|
|
80
|
+
// Linear gradient line: (0, 0.5) → (1, 0.5) in gradient space
|
|
81
|
+
return {
|
|
82
|
+
defs: `<linearGradient id="${id}" x1="${f(tx(0,0.5))}" y1="${f(ty(0,0.5))}" x2="${f(tx(1,0.5))}" y2="${f(ty(1,0.5))}" gradientUnits="userSpaceOnUse">${stops}</linearGradient>`,
|
|
83
|
+
fill: `url(#${id})`,
|
|
84
|
+
opacityAttr,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (paint.type === 'GRADIENT_RADIAL') {
|
|
88
|
+
// Radial gradient: center (0.5, 0.5), radius mapped through transform
|
|
89
|
+
const cx = f(tx(0.5, 0.5)), cy = f(ty(0.5, 0.5));
|
|
90
|
+
// Radius along the gradient's x-axis: distance from center to (1, 0.5)
|
|
91
|
+
const rx = f(Math.hypot(tx(1, 0.5) - tx(0.5, 0.5), ty(1, 0.5) - ty(0.5, 0.5)));
|
|
92
|
+
const ry = f(Math.hypot(tx(0.5, 1) - tx(0.5, 0.5), ty(0.5, 1) - ty(0.5, 0.5)));
|
|
93
|
+
// Rotation angle from the transform
|
|
94
|
+
const angle = f(Math.atan2(ty(1, 0.5) - ty(0.5, 0.5), tx(1, 0.5) - tx(0.5, 0.5)) * 180 / Math.PI);
|
|
95
|
+
return {
|
|
96
|
+
defs: `<radialGradient id="${id}" cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" gradientUnits="userSpaceOnUse" gradientTransform="rotate(${angle},${cx},${cy})">${stops}</radialGradient>`,
|
|
97
|
+
fill: `url(#${id})`,
|
|
98
|
+
opacityAttr,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function appendDefs(defs, extra) {
|
|
105
|
+
if (!extra) return defs;
|
|
106
|
+
return defs
|
|
107
|
+
? defs.replace('</defs>', `${extra}</defs>`)
|
|
108
|
+
: `<defs>${extra}</defs>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Get effective fillPaints for any node type. */
|
|
112
|
+
function getFillPaints(node) {
|
|
113
|
+
if (node.fillPaints?.length) return node.fillPaints;
|
|
114
|
+
// SHAPE_WITH_TEXT stores fill in nodeGenerationData.overrides[0].fillPaints
|
|
115
|
+
return node.nodeGenerationData?.overrides?.[0]?.fillPaints ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function strokeSpec(node) {
|
|
119
|
+
if (!node.strokeWeight || node.strokeWeight === 0) return null;
|
|
120
|
+
const color = resolveFill(node.strokePaints) ?? 'none';
|
|
121
|
+
if (color === 'none') return null;
|
|
122
|
+
return {
|
|
123
|
+
color,
|
|
124
|
+
width: node.strokeWeight,
|
|
125
|
+
align: node.strokeAlign ?? 'CENTER',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Render strokeGeometry blobs as filled <path> elements (Figma's pre-expanded stroke outlines).
|
|
130
|
+
* Returns SVG string or '' if no strokeGeometry is available. */
|
|
131
|
+
function strokeGeometrySvg(deck, node) {
|
|
132
|
+
const strokeColor = resolveFill(node.strokePaints);
|
|
133
|
+
const sw = node.strokeWeight ?? 0;
|
|
134
|
+
const blobs = deck.message?.blobs;
|
|
135
|
+
if (!strokeColor || sw <= 0 || !node.strokeGeometry?.length || !blobs) return '';
|
|
136
|
+
const segments = [];
|
|
137
|
+
let hasEvenOdd = false;
|
|
138
|
+
for (const geo of node.strokeGeometry) {
|
|
139
|
+
const d = decodeCmdBlob(blobs, geo.commandsBlob);
|
|
140
|
+
if (d) segments.push(d);
|
|
141
|
+
if (geo.windingRule === 'EVENODD') hasEvenOdd = true;
|
|
142
|
+
}
|
|
143
|
+
if (!segments.length) return '';
|
|
144
|
+
const rule = (segments.length > 1 || hasEvenOdd) ? ' fill-rule="evenodd"' : '';
|
|
145
|
+
return `<path d="${segments.join('')}" fill="${strokeColor}"${rule}/>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function rectStrokeSvg(x, y, w, h, rx, stroke) {
|
|
149
|
+
if (!stroke) return '';
|
|
150
|
+
let sx = x;
|
|
151
|
+
let sy = y;
|
|
152
|
+
let sw = w;
|
|
153
|
+
let sh = h;
|
|
154
|
+
let srx = Math.min(rx, w / 2, h / 2);
|
|
155
|
+
|
|
156
|
+
if (stroke.align === 'INSIDE') {
|
|
157
|
+
sx += stroke.width / 2;
|
|
158
|
+
sy += stroke.width / 2;
|
|
159
|
+
sw -= stroke.width;
|
|
160
|
+
sh -= stroke.width;
|
|
161
|
+
srx = Math.max(0, srx - stroke.width / 2);
|
|
162
|
+
} else if (stroke.align === 'OUTSIDE') {
|
|
163
|
+
sx -= stroke.width / 2;
|
|
164
|
+
sy -= stroke.width / 2;
|
|
165
|
+
sw += stroke.width;
|
|
166
|
+
sh += stroke.width;
|
|
167
|
+
srx += stroke.width / 2;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (sw <= 0 || sh <= 0) return '';
|
|
171
|
+
return `<rect x="${sx}" y="${sy}" width="${sw}" height="${sh}" rx="${srx}" ry="${srx}" fill="none" stroke="${stroke.color}" stroke-width="${stroke.width}"/>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ellipseStrokeSvg(cx, cy, rx, ry, stroke) {
|
|
175
|
+
if (!stroke) return '';
|
|
176
|
+
let srx = rx;
|
|
177
|
+
let sry = ry;
|
|
178
|
+
|
|
179
|
+
if (stroke.align === 'INSIDE') {
|
|
180
|
+
srx -= stroke.width / 2;
|
|
181
|
+
sry -= stroke.width / 2;
|
|
182
|
+
} else if (stroke.align === 'OUTSIDE') {
|
|
183
|
+
srx += stroke.width / 2;
|
|
184
|
+
sry += stroke.width / 2;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (srx <= 0 || sry <= 0) return '';
|
|
188
|
+
return `<ellipse cx="${cx}" cy="${cy}" rx="${srx}" ry="${sry}" fill="none" stroke="${stroke.color}" stroke-width="${stroke.width}"/>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Transform helpers ─────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/** Return the full SVG transform attribute value for a node.
|
|
194
|
+
* Uses `translate(x,y)` for pure translations, `matrix(a,b,c,d,e,f)` when
|
|
195
|
+
* rotation or scale is present. */
|
|
196
|
+
function svgTransform(node) {
|
|
197
|
+
const t = node.transform;
|
|
198
|
+
if (!t) return 'translate(0,0)';
|
|
199
|
+
const m00 = t.m00 ?? 1, m01 = t.m01 ?? 0, m02 = t.m02 ?? 0;
|
|
200
|
+
const m10 = t.m10 ?? 0, m11 = t.m11 ?? 1, m12 = t.m12 ?? 0;
|
|
201
|
+
// Pure translation — no rotation or scale
|
|
202
|
+
if (Math.abs(m00 - 1) < 1e-6 && Math.abs(m01) < 1e-6 &&
|
|
203
|
+
Math.abs(m10) < 1e-6 && Math.abs(m11 - 1) < 1e-6) {
|
|
204
|
+
return `translate(${m02},${m12})`;
|
|
205
|
+
}
|
|
206
|
+
// Use high precision for rotation/scale — 2dp on a 2000px element = ~8px error
|
|
207
|
+
const h = v => +v.toFixed(6);
|
|
208
|
+
return `matrix(${h(m00)},${h(m10)},${h(m01)},${h(m11)},${f(m02)},${f(m12)})`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function size(node) {
|
|
212
|
+
return { w: node.size?.x ?? 0, h: node.size?.y ?? 0 };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Node renderers ────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
function renderRect(deck, node) {
|
|
218
|
+
const { w, h } = size(node);
|
|
219
|
+
const rx = Math.min(node.cornerRadius ?? 0, w / 2, h / 2);
|
|
220
|
+
const { defs, bg } = renderRoundedRectFillStack(deck, getFillPaints(node), w, h, rx);
|
|
221
|
+
const fillSvg = bg ? `${[defs, bg].filter(Boolean).join('\n')}` : '';
|
|
222
|
+
// Prefer strokeGeometry (Figma's pre-computed outline) for matching anti-aliasing.
|
|
223
|
+
// strokeGeometry is CENTER-aligned — render underneath fill so fill covers inner half.
|
|
224
|
+
const geoStroke = strokeGeometrySvg(deck, node);
|
|
225
|
+
const strokeSvg = geoStroke || rectStrokeSvg(0, 0, w, h, rx, strokeSpec(node));
|
|
226
|
+
// Stroke underneath, fill on top (hides inner half for OUTSIDE strokes)
|
|
227
|
+
const inner = geoStroke
|
|
228
|
+
? [strokeSvg, fillSvg].filter(Boolean).join('\n')
|
|
229
|
+
: [fillSvg, strokeSvg].filter(Boolean).join('\n');
|
|
230
|
+
if (!inner) return '';
|
|
231
|
+
return `<g transform="${svgTransform(node)}">\n${inner}\n</g>`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderEllipse(deck, node) {
|
|
235
|
+
const { w, h } = size(node);
|
|
236
|
+
const fill = resolveFill(getFillPaints(node)) ?? 'none';
|
|
237
|
+
// Local coordinates — svgTransform handles position + rotation
|
|
238
|
+
const cx = w / 2;
|
|
239
|
+
const cy = h / 2;
|
|
240
|
+
const rx = w / 2;
|
|
241
|
+
const ry = h / 2;
|
|
242
|
+
const fillSvg = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${fill}"/>`;
|
|
243
|
+
// Prefer strokeGeometry (Figma's pre-computed outline) for matching anti-aliasing.
|
|
244
|
+
// strokeGeometry is CENTER-aligned — render underneath fill so fill covers inner half.
|
|
245
|
+
const geoStroke = strokeGeometrySvg(deck, node);
|
|
246
|
+
const strokeSvg = geoStroke || ellipseStrokeSvg(cx, cy, rx, ry, strokeSpec(node));
|
|
247
|
+
// Stroke underneath, fill on top (hides inner half for OUTSIDE strokes)
|
|
248
|
+
const inner = geoStroke
|
|
249
|
+
? [strokeSvg, fillSvg].filter(Boolean).join('\n')
|
|
250
|
+
: [fillSvg, strokeSvg].filter(Boolean).join('\n');
|
|
251
|
+
return `<g transform="${svgTransform(node)}">\n${inner}\n</g>`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveLineHeight(lh, fontSize) {
|
|
255
|
+
if (!lh) return fontSize * 1.2;
|
|
256
|
+
switch (lh.units) {
|
|
257
|
+
case 'RAW': return lh.value * fontSize;
|
|
258
|
+
case 'PERCENT': return (lh.value / 100) * fontSize;
|
|
259
|
+
case 'PIXELS': return lh.value;
|
|
260
|
+
default: return fontSize * 1.2;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function esc(s) {
|
|
265
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function styleAttrsFromFontName(fontName, derivedFontWeight) {
|
|
269
|
+
const style = fontName?.style ?? 'Regular';
|
|
270
|
+
const weight = derivedFontWeight
|
|
271
|
+
? String(derivedFontWeight)
|
|
272
|
+
: /semibold/i.test(style) ? '600'
|
|
273
|
+
: /bold/i.test(style) ? '700'
|
|
274
|
+
: /medium/i.test(style) ? '500'
|
|
275
|
+
: '400';
|
|
276
|
+
const italic = /italic/i.test(style) ? 'italic' : 'normal';
|
|
277
|
+
return { weight, italic };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function glyphSlice(chars, glyphs, index) {
|
|
281
|
+
const g = glyphs[index];
|
|
282
|
+
if (g.firstCharacter == null) {
|
|
283
|
+
throw new Error('Unexpected glyph without firstCharacter');
|
|
284
|
+
}
|
|
285
|
+
let nextChar = null;
|
|
286
|
+
for (let j = index + 1; j < glyphs.length; j++) {
|
|
287
|
+
const fc = glyphs[j].firstCharacter;
|
|
288
|
+
if (fc != null && fc > g.firstCharacter) {
|
|
289
|
+
nextChar = fc;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return chars.slice(g.firstCharacter, nextChar ?? (g.firstCharacter + 1));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function renderText(deck, node) {
|
|
297
|
+
const chars = node.textData?.characters ?? '';
|
|
298
|
+
if (!chars.trim()) return '';
|
|
299
|
+
const dispChars = node.textCase === 'UPPER' ? chars.toUpperCase()
|
|
300
|
+
: node.textCase === 'LOWER' ? chars.toLowerCase()
|
|
301
|
+
: chars;
|
|
302
|
+
|
|
303
|
+
const fontSize = node.derivedTextData?.glyphs?.[0]?.fontSize ?? node.fontSize ?? 24;
|
|
304
|
+
const fontFamily = node.fontName?.family ?? 'Inter';
|
|
305
|
+
const fill = resolveFill(getFillPaints(node)) ?? '#000000';
|
|
306
|
+
// Letter spacing: PERCENT is % of fontSize, PIXELS is absolute
|
|
307
|
+
const ls = node.letterSpacing;
|
|
308
|
+
const letterSpacingPx = !ls ? 0
|
|
309
|
+
: ls.units === 'PERCENT' ? (ls.value / 100) * fontSize
|
|
310
|
+
: ls.units === 'PIXELS' ? ls.value
|
|
311
|
+
: 0;
|
|
312
|
+
const baselines = node.derivedTextData?.baselines;
|
|
313
|
+
const glyphs = node.derivedTextData?.glyphs;
|
|
314
|
+
const styleIds = node.textData?.characterStyleIDs;
|
|
315
|
+
const styleTable = node.textData?.styleOverrideTable;
|
|
316
|
+
if (!glyphs?.length) {
|
|
317
|
+
throw new Error(`TEXT ${node.name ?? nid(node)} is missing derived glyph layout`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Build styleID → {weight, italic, decoration, family} map
|
|
321
|
+
const styleMap = {};
|
|
322
|
+
if (styleIds && styleTable) {
|
|
323
|
+
for (const ov of styleTable) {
|
|
324
|
+
// Only override weight/italic/family if fontName is explicitly set in this style run
|
|
325
|
+
const hasFontName = ov.fontName?.family || ov.fontName?.style;
|
|
326
|
+
const { weight, italic } = hasFontName ? styleAttrsFromFontName(ov.fontName, null) : {};
|
|
327
|
+
styleMap[ov.styleID] = {
|
|
328
|
+
family: hasFontName ? (ov.fontName?.family ?? fontFamily) : null,
|
|
329
|
+
weight: hasFontName ? weight : null, // null → fall through to defWeight
|
|
330
|
+
italic: hasFontName ? italic : null, // null → fall through to defItalic
|
|
331
|
+
decoration: ov.textDecoration === 'UNDERLINE' ? 'underline' : 'none',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Node-level defaults
|
|
337
|
+
const { weight: defWeight, italic: defItalic } = styleAttrsFromFontName(
|
|
338
|
+
node.fontName, node.derivedTextData?.fontMetaData?.[0]?.fontWeight
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Per-glyph positioning with ligature merging: each character gets its own
|
|
342
|
+
// <tspan> with Figma's x,y, EXCEPT known ligature sequences (fi, fl, ff,
|
|
343
|
+
// ffi, ffl) which are merged into a single <tspan> positioned at the first
|
|
344
|
+
// glyph so the font engine can substitute the ligature glyph.
|
|
345
|
+
const LIGATURES = ['ffl', 'ffi', 'ff', 'fi', 'fl'];
|
|
346
|
+
|
|
347
|
+
/** Check if a ligature starts at position i in the display string.
|
|
348
|
+
* Returns the ligature length or 0. */
|
|
349
|
+
function ligatureAt(i) {
|
|
350
|
+
for (const lig of LIGATURES) {
|
|
351
|
+
if (dispChars.slice(i, i + lig.length) === lig) return lig.length;
|
|
352
|
+
}
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let tspans = '';
|
|
357
|
+
if (baselines?.length && glyphs?.length && styleIds?.length) {
|
|
358
|
+
// Mixed-style: group consecutive glyphs by styleID, emit per-glyph with ligature merge
|
|
359
|
+
for (const b of baselines) {
|
|
360
|
+
const lineGlyphs = glyphs.filter(g => g.firstCharacter >= b.firstCharacter && g.firstCharacter < b.endCharacter);
|
|
361
|
+
if (!lineGlyphs.length) continue;
|
|
362
|
+
|
|
363
|
+
const runs = [];
|
|
364
|
+
let curRun = null;
|
|
365
|
+
for (const g of lineGlyphs) {
|
|
366
|
+
const sid = styleIds[g.firstCharacter] ?? 0;
|
|
367
|
+
if (!curRun || sid !== curRun.sid) {
|
|
368
|
+
curRun = { sid, glyphs: [] };
|
|
369
|
+
runs.push(curRun);
|
|
370
|
+
}
|
|
371
|
+
curRun.glyphs.push(g);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const run of runs) {
|
|
375
|
+
const st = styleMap[run.sid] ?? {};
|
|
376
|
+
const w = st.weight ?? defWeight;
|
|
377
|
+
const it = st.italic ?? defItalic;
|
|
378
|
+
const fam = st.family ?? fontFamily;
|
|
379
|
+
for (let gi = 0; gi < run.glyphs.length; gi++) {
|
|
380
|
+
const g = run.glyphs[gi];
|
|
381
|
+
const ligLen = ligatureAt(g.firstCharacter);
|
|
382
|
+
const span = ligLen > 1 ? ligLen : 1;
|
|
383
|
+
const endIdx = gi + span < run.glyphs.length
|
|
384
|
+
? run.glyphs[gi + span].firstCharacter
|
|
385
|
+
: Math.min((run.glyphs[run.glyphs.length - 1].firstCharacter + 1), b.endCharacter);
|
|
386
|
+
const slice = dispChars.slice(g.firstCharacter, endIdx).replace(/\n$/, '');
|
|
387
|
+
if (!slice || /^\s+$/.test(slice)) continue;
|
|
388
|
+
tspans += `<tspan x="${g.position.x}" y="${g.position.y}" font-family="${fam}, sans-serif" font-weight="${w}" font-style="${it}">${esc(slice)}</tspan>`;
|
|
389
|
+
if (span > 1) gi += span - 1; // skip merged glyphs
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} else if (glyphs?.length) {
|
|
394
|
+
// Per-glyph positioning with ligature merging
|
|
395
|
+
for (let i = 0; i < glyphs.length; i++) {
|
|
396
|
+
const g = glyphs[i];
|
|
397
|
+
const ligLen = ligatureAt(g.firstCharacter);
|
|
398
|
+
const span = ligLen > 1 ? ligLen : 1;
|
|
399
|
+
const endIdx = i + span < glyphs.length
|
|
400
|
+
? glyphs[i + span].firstCharacter
|
|
401
|
+
: chars.length;
|
|
402
|
+
const slice = dispChars.slice(g.firstCharacter, endIdx).replace(/\n$/, '');
|
|
403
|
+
if (!slice || /^\s+$/.test(slice)) { continue; }
|
|
404
|
+
tspans += `<tspan x="${g.position.x}" y="${g.position.y}">${esc(slice)}</tspan>`;
|
|
405
|
+
if (span > 1) i += span - 1; // skip merged glyphs
|
|
406
|
+
}
|
|
407
|
+
} else if (baselines?.length) {
|
|
408
|
+
tspans = baselines.map(b => {
|
|
409
|
+
const slice = dispChars.slice(b.firstCharacter, b.endCharacter).replace(/\n$/, '');
|
|
410
|
+
return `<tspan x="${b.position.x}" y="${b.position.y}">${esc(slice) || ' '}</tspan>`;
|
|
411
|
+
}).join('');
|
|
412
|
+
} else {
|
|
413
|
+
throw new Error(`TEXT ${node.name ?? nid(node)} is missing derived baselines`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const lsAttr = letterSpacingPx === 0 ? '' : ` letter-spacing="${letterSpacingPx.toFixed(3)}"`;
|
|
417
|
+
|
|
418
|
+
const textEl = [
|
|
419
|
+
`<text font-size="${fontSize}" font-family="${fontFamily}, sans-serif"`,
|
|
420
|
+
` font-weight="${defWeight}" font-style="${defItalic}" fill="${fill}"${lsAttr}`,
|
|
421
|
+
` text-rendering="geometricPrecision">${tspans}</text>`,
|
|
422
|
+
].join('\n');
|
|
423
|
+
|
|
424
|
+
// Use Figma's pre-computed decoration rectangles (underline/strikethrough).
|
|
425
|
+
// derivedTextData.decorations[].rects are relative to the node's local origin.
|
|
426
|
+
const decorations = node.derivedTextData?.decorations ?? [];
|
|
427
|
+
if (!decorations.length) return `<g transform="${svgTransform(node)}">\n${textEl}\n</g>`;
|
|
428
|
+
const decorationRects = decorations.flatMap(d =>
|
|
429
|
+
(d.rects ?? []).map(r =>
|
|
430
|
+
`<rect x="${r.x.toFixed(2)}" y="${r.y.toFixed(2)}" width="${r.w.toFixed(2)}" height="${r.h.toFixed(2)}" fill="${fill}"/>`
|
|
431
|
+
)
|
|
432
|
+
);
|
|
433
|
+
const inner = decorationRects.length ? textEl + '\n' + decorationRects.join('\n') : textEl;
|
|
434
|
+
return `<g transform="${svgTransform(node)}">\n${inner}\n</g>`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Resolve an IMAGE-type fillPaint to inline SVG defs + bg element.
|
|
439
|
+
* Supports FILL (cover), FIT (contain), and TILE scale modes.
|
|
440
|
+
* Throws if the image file cannot be read.
|
|
441
|
+
*/
|
|
442
|
+
function resolveImageFillSvg(deck, imgFill, w, h, rx) {
|
|
443
|
+
const hashBytes = imgFill.image?.hash;
|
|
444
|
+
const hash = hashBytes?.length ? hashToHex(hashBytes) : imgFill.image?.name;
|
|
445
|
+
if (!hash) throw new Error('Visible IMAGE fill is missing its asset hash');
|
|
446
|
+
if (!deck.imagesDir) throw new Error(`Deck is missing imagesDir for visible IMAGE fill ${hash}`);
|
|
447
|
+
let buf;
|
|
448
|
+
try {
|
|
449
|
+
buf = readFileSync(join(deck.imagesDir, hash));
|
|
450
|
+
} catch (err) {
|
|
451
|
+
throw new Error(`Missing image fill asset ${hash} in ${deck.imagesDir}: ${err.message}`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const mime = (buf[0] === 0xFF && buf[1] === 0xD8) ? 'image/jpeg' : 'image/png';
|
|
455
|
+
const dataUri = `data:${mime};base64,${buf.toString('base64')}`;
|
|
456
|
+
const id = ++_svgIdSeq;
|
|
457
|
+
const clipId = `img-clip-${id}`;
|
|
458
|
+
const clipDef = `<clipPath id="${clipId}"><rect width="${w}" height="${h}" rx="${rx}" ry="${rx}"/></clipPath>`;
|
|
459
|
+
const mode = imgFill.imageScaleMode ?? 'FILL';
|
|
460
|
+
const opacityAttr = (imgFill.opacity ?? 1) !== 1 ? ` opacity="${imgFill.opacity}"` : '';
|
|
461
|
+
|
|
462
|
+
if (mode === 'TILE') {
|
|
463
|
+
const tw = (imgFill.originalImageWidth ?? 100) * (imgFill.scale ?? 1);
|
|
464
|
+
const th = (imgFill.originalImageHeight ?? 100) * (imgFill.scale ?? 1);
|
|
465
|
+
const patId = `img-pat-${id}`;
|
|
466
|
+
return {
|
|
467
|
+
defs: `${clipDef}<pattern id="${patId}" x="0" y="0" width="${tw}" height="${th}" patternUnits="userSpaceOnUse"><image href="${dataUri}" width="${tw}" height="${th}"${opacityAttr}/></pattern>`,
|
|
468
|
+
bg: `<rect x="0" y="0" width="${w}" height="${h}" rx="${rx}" ry="${rx}" fill="url(#${patId})" clip-path="url(#${clipId})"/>`,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// FILL (cover) or FIT (contain)
|
|
473
|
+
const par = mode === 'FIT' ? 'xMidYMid meet' : 'xMidYMid slice';
|
|
474
|
+
return {
|
|
475
|
+
defs: clipDef,
|
|
476
|
+
bg: `<image href="${dataUri}" x="0" y="0" width="${w}" height="${h}" preserveAspectRatio="${par}" clip-path="url(#${clipId})"${opacityAttr}/>`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function renderRoundedRectFillStack(deck, fillPaints, w, h, rx) {
|
|
481
|
+
const visibleFills = fillPaints?.filter(p => p.visible !== false) ?? [];
|
|
482
|
+
let defs = '';
|
|
483
|
+
const bgParts = [];
|
|
484
|
+
|
|
485
|
+
for (const fill of visibleFills) {
|
|
486
|
+
if (fill.type === 'SOLID') {
|
|
487
|
+
bgParts.push(`<rect x="0" y="0" width="${w}" height="${h}" rx="${rx}" ry="${rx}" fill="${cssColor(fill.color ?? {}, fill.opacity ?? 1)}"/>`);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (fill.type === 'IMAGE') {
|
|
491
|
+
const result = resolveImageFillSvg(deck, fill, w, h, rx);
|
|
492
|
+
defs = appendDefs(defs, result.defs);
|
|
493
|
+
bgParts.push(result.bg);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (fill.type === 'GRADIENT_LINEAR' || fill.type === 'GRADIENT_RADIAL') {
|
|
497
|
+
const grad = resolveGradientSvg(fill, w, h);
|
|
498
|
+
if (grad) {
|
|
499
|
+
defs = appendDefs(defs, grad.defs);
|
|
500
|
+
bgParts.push(`<rect x="0" y="0" width="${w}" height="${h}" rx="${rx}" ry="${rx}" fill="${grad.fill}"${grad.opacityAttr}/>`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return { defs, bg: bgParts.join('\n') };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function renderFrame(deck, node) {
|
|
509
|
+
const { w, h } = size(node);
|
|
510
|
+
const rx = Math.min(node.cornerRadius ?? 0, w / 2, h / 2);
|
|
511
|
+
const stroke = strokeSpec(node);
|
|
512
|
+
const inner = childrenSvg(deck, node);
|
|
513
|
+
let { defs, bg } = renderRoundedRectFillStack(deck, getFillPaints(node), w, h, rx);
|
|
514
|
+
|
|
515
|
+
// Frame clipping: Figma frames clip children when "Clip content" is ON,
|
|
516
|
+
// stored as frameMaskDisabled === false (explicit). Clipping is suppressed:
|
|
517
|
+
// - inside SYMBOL trees (Figma doesn't enforce child frame clips in components)
|
|
518
|
+
// - on frames with no visible fill (grouping containers, not visual viewports)
|
|
519
|
+
const hasFill = getFillPaints(node)?.some(p => p.visible !== false);
|
|
520
|
+
let clippedInner = inner;
|
|
521
|
+
if (inner && node.frameMaskDisabled === false && _symbolDepth === 0 && hasFill) {
|
|
522
|
+
const clipId = `frame-clip-${++_svgIdSeq}`;
|
|
523
|
+
defs += `<clipPath id="${clipId}"><rect width="${w}" height="${h}" rx="${rx}" ry="${rx}"/></clipPath>`;
|
|
524
|
+
clippedInner = `<g clip-path="url(#${clipId})">${inner}</g>`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const strokeSvg = rectStrokeSvg(0, 0, w, h, rx, stroke);
|
|
528
|
+
|
|
529
|
+
const parts = [defs, bg, clippedInner, strokeSvg].filter(Boolean).join('\n');
|
|
530
|
+
if (!parts) return '';
|
|
531
|
+
return `<g transform="${svgTransform(node)}">\n${parts}\n</g>`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function renderGroup(deck, node) {
|
|
535
|
+
const inner = childrenSvg(deck, node);
|
|
536
|
+
if (!inner) return '';
|
|
537
|
+
return `<g transform="${svgTransform(node)}">\n${inner}\n</g>`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* SHAPE_WITH_TEXT: pill/badge nodes — styling and text stored in nodeGenerationData.overrides.
|
|
542
|
+
* overrides[0] = shape (fill, stroke, cornerRadius)
|
|
543
|
+
* overrides[1] = text (textData.characters, fontName, fontSize, textCase)
|
|
544
|
+
* Text position comes from derivedImmutableFrameData.overrides[1].
|
|
545
|
+
*/
|
|
546
|
+
function renderShapeWithText(deck, node) {
|
|
547
|
+
const { w, h } = size(node);
|
|
548
|
+
const genOvs = node.nodeGenerationData?.overrides ?? [];
|
|
549
|
+
const shapeOv = genOvs[0] ?? {};
|
|
550
|
+
const textOv = genOvs[1] ?? {};
|
|
551
|
+
|
|
552
|
+
// Shape styling
|
|
553
|
+
const rawRx = shapeOv.cornerRadius ?? 0;
|
|
554
|
+
const rx = Math.min(rawRx, w / 2, h / 2); // 1000000 → pill
|
|
555
|
+
const fill = resolveFill(shapeOv.fillPaints) ?? 'none';
|
|
556
|
+
|
|
557
|
+
// Prefer strokeGeometry (pre-computed filled outlines) over manual SVG strokes
|
|
558
|
+
const geoStroke = strokeGeometrySvg(deck, node);
|
|
559
|
+
let strokeAttr = '';
|
|
560
|
+
if (!geoStroke) {
|
|
561
|
+
const sw = shapeOv.strokeWeight ?? 0;
|
|
562
|
+
const stroke = sw > 0 ? resolveFill(shapeOv.strokePaints) ?? 'none' : 'none';
|
|
563
|
+
strokeAttr = sw > 0 && stroke !== 'none' ? `stroke="${stroke}" stroke-width="${sw}"` : '';
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const fillSvg = `<rect x="0" y="0" width="${w}" height="${h}" rx="${rx}" ry="${rx}" fill="${fill}" ${strokeAttr}/>`;
|
|
567
|
+
// Stroke-under-fill compositing: strokeGeometry underneath, fill on top
|
|
568
|
+
const rectSvg = geoStroke ? `${geoStroke}\n${fillSvg}` : fillSvg;
|
|
569
|
+
|
|
570
|
+
// Text
|
|
571
|
+
const chars = textOv.textData?.characters ?? '';
|
|
572
|
+
if (!chars.trim()) return `<g transform="${svgTransform(node)}">${rectSvg}</g>`;
|
|
573
|
+
|
|
574
|
+
const textCase = textOv.textCase ?? 'ORIGINAL';
|
|
575
|
+
const dispChars = textCase === 'UPPER' ? chars.toUpperCase()
|
|
576
|
+
: textCase === 'LOWER' ? chars.toLowerCase()
|
|
577
|
+
: chars;
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
// Text offset + authoritative font metrics from derivedImmutableFrameData
|
|
581
|
+
const derivedOvs = node.derivedImmutableFrameData?.overrides ?? [];
|
|
582
|
+
const textDerived = derivedOvs.find(o => o.derivedTextData) ?? {};
|
|
583
|
+
const textBoxX = textDerived.transform?.m02 ?? 0;
|
|
584
|
+
const textBoxY = textDerived.transform?.m12 ?? 0;
|
|
585
|
+
const derivedText = textDerived.derivedTextData ?? {};
|
|
586
|
+
const glyphs = derivedText.glyphs;
|
|
587
|
+
const truncationStartIndex = derivedText.truncationStartIndex >= 0
|
|
588
|
+
? derivedText.truncationStartIndex
|
|
589
|
+
: null;
|
|
590
|
+
if (!glyphs?.length) {
|
|
591
|
+
throw new Error(`SHAPE_WITH_TEXT ${node.name ?? nid(node)} is missing derived glyph layout`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// derivedTextData is authoritative — nodeGenerationData can have stale/wrong values
|
|
595
|
+
const derivedFont = textDerived.derivedTextData?.fontMetaData?.[0]?.key;
|
|
596
|
+
const fontSize = textDerived.derivedTextData?.glyphs?.[0]?.fontSize ?? textOv.fontSize ?? 24;
|
|
597
|
+
const fontFamily = derivedFont?.family ?? textOv.fontName?.family ?? 'Inter';
|
|
598
|
+
const fontStyle = derivedFont?.style ?? textOv.fontName?.style ?? 'Regular';
|
|
599
|
+
const fontWeight = /semibold|bold/i.test(fontStyle) ? 'bold'
|
|
600
|
+
: /medium/i.test(fontStyle) ? '500' : 'normal';
|
|
601
|
+
const fontItalic = /italic/i.test(fontStyle) ? 'italic' : 'normal';
|
|
602
|
+
const textFill = resolveFill(textOv.fillPaints) ?? '#000000';
|
|
603
|
+
|
|
604
|
+
let tspan;
|
|
605
|
+
function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
606
|
+
|
|
607
|
+
const spans = [];
|
|
608
|
+
for (let i = 0; i < glyphs.length; i++) {
|
|
609
|
+
const g = glyphs[i];
|
|
610
|
+
if (truncationStartIndex != null && g.firstCharacter != null && g.firstCharacter >= truncationStartIndex) continue;
|
|
611
|
+
|
|
612
|
+
let slice = '';
|
|
613
|
+
let stopAfter = false;
|
|
614
|
+
if (g.firstCharacter == null) {
|
|
615
|
+
if (truncationStartIndex == null) continue;
|
|
616
|
+
slice = '…';
|
|
617
|
+
stopAfter = true;
|
|
618
|
+
} else {
|
|
619
|
+
let nextChar = null;
|
|
620
|
+
for (let j = i + 1; j < glyphs.length; j++) {
|
|
621
|
+
const fc = glyphs[j].firstCharacter;
|
|
622
|
+
if (fc != null && fc > g.firstCharacter) {
|
|
623
|
+
nextChar = fc;
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
slice = dispChars.slice(g.firstCharacter, nextChar ?? (g.firstCharacter + 1));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!slice) continue;
|
|
631
|
+
spans.push(`<tspan x="${textBoxX + g.position.x}" y="${textBoxY + g.position.y}">${esc(slice)}</tspan>`);
|
|
632
|
+
if (stopAfter) break;
|
|
633
|
+
}
|
|
634
|
+
tspan = spans.join('');
|
|
635
|
+
|
|
636
|
+
const textSvg = [
|
|
637
|
+
`<text font-size="${fontSize}" font-family="${fontFamily}, sans-serif"`,
|
|
638
|
+
` font-weight="${fontWeight}" font-style="${fontItalic}" fill="${textFill}"`,
|
|
639
|
+
` text-rendering="geometricPrecision">${tspan}</text>`,
|
|
640
|
+
].join('\n');
|
|
641
|
+
|
|
642
|
+
return `<g transform="${svgTransform(node)}">\n${rectSvg}\n${textSvg}\n</g>`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function renderLine(deck, node) {
|
|
646
|
+
// Prefer strokeGeometry (filled path outline) — gives better anti-aliasing
|
|
647
|
+
// than SVG <line> for near-horizontal/vertical angled lines.
|
|
648
|
+
const geoSvg = strokeGeometrySvg(deck, node);
|
|
649
|
+
if (geoSvg) {
|
|
650
|
+
return `<g transform="${svgTransform(node)}">\n${geoSvg}\n</g>`;
|
|
651
|
+
}
|
|
652
|
+
// Fallback: LINE uses full transform matrix: direction = (m00, m10), origin = (m02, m12)
|
|
653
|
+
const x1 = node.transform?.m02 ?? 0;
|
|
654
|
+
const y1 = node.transform?.m12 ?? 0;
|
|
655
|
+
const m00 = node.transform?.m00 ?? 1;
|
|
656
|
+
const m10 = node.transform?.m10 ?? 0;
|
|
657
|
+
const len = node.size?.x ?? 0;
|
|
658
|
+
const x2 = x1 + len * m00;
|
|
659
|
+
const y2 = y1 + len * m10;
|
|
660
|
+
const stroke = resolveFill(node.strokePaints) ?? '#000000';
|
|
661
|
+
const sw = node.strokeWeight ?? 1;
|
|
662
|
+
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${sw}"/>`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* VECTOR — decode fillGeometry/strokeGeometry commandsBlob binary to SVG paths.
|
|
667
|
+
*
|
|
668
|
+
* Blob format: [cmdByte][float32LE params...]
|
|
669
|
+
* 0x01 = moveTo (x, y)
|
|
670
|
+
* 0x02 = lineTo (x, y)
|
|
671
|
+
* 0x04 = cubicTo (c1x, c1y, c2x, c2y, x, y)
|
|
672
|
+
* 0x00 = close
|
|
673
|
+
*
|
|
674
|
+
* Coordinates are in node-size space. The full affine transform matrix is used
|
|
675
|
+
* to position, scale, and rotate the vector in the slide.
|
|
676
|
+
*/
|
|
677
|
+
function renderVector(deck, node) {
|
|
678
|
+
const t = node.transform ?? {};
|
|
679
|
+
const m00 = t.m00 ?? 1, m01 = t.m01 ?? 0, m02 = t.m02 ?? 0;
|
|
680
|
+
const m10 = t.m10 ?? 0, m11 = t.m11 ?? 1, m12 = t.m12 ?? 0;
|
|
681
|
+
const blobs = deck.message?.blobs;
|
|
682
|
+
const parts = [];
|
|
683
|
+
|
|
684
|
+
// Collect fill paths — each fillGeometry entry is an independent fill region.
|
|
685
|
+
// Sub-paths *within* one entry interact via fill-rule (e.g. QR codes, letter cutouts),
|
|
686
|
+
// but entries are never combined — otherwise nesting causes evenodd to cut holes.
|
|
687
|
+
const fillColor = resolveFill(getFillPaints(node));
|
|
688
|
+
const fillEntries = []; // { d, color, rule }
|
|
689
|
+
if (node.fillGeometry?.length && blobs) {
|
|
690
|
+
// Build styleID → fill color map from per-path overrides
|
|
691
|
+
const styleMap = new Map();
|
|
692
|
+
if (node.vectorData?.styleOverrideTable?.length) {
|
|
693
|
+
for (const s of node.vectorData.styleOverrideTable) {
|
|
694
|
+
if (s.styleID != null && s.fillPaints) {
|
|
695
|
+
styleMap.set(s.styleID, resolveFill(s.fillPaints));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
for (const geo of node.fillGeometry) {
|
|
701
|
+
const color = (geo.styleID && styleMap.has(geo.styleID))
|
|
702
|
+
? styleMap.get(geo.styleID)
|
|
703
|
+
: fillColor;
|
|
704
|
+
if (!color) continue;
|
|
705
|
+
const d = decodeCmdBlob(blobs, geo.commandsBlob);
|
|
706
|
+
if (!d) continue;
|
|
707
|
+
const rule = geo.windingRule === 'EVENODD' ? ' fill-rule="evenodd"' : '';
|
|
708
|
+
fillEntries.push({ d, color, rule });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Stroke handling — depends on strokeAlign:
|
|
713
|
+
// OUTSIDE: strokeGeometry is always CENTER-aligned in the blob data, regardless of
|
|
714
|
+
// the node's strokeAlign setting. For OUTSIDE, we use SVG native strokes at
|
|
715
|
+
// 2× width underneath the fill — the fill covers the inner half, leaving
|
|
716
|
+
// only the outer portion visible.
|
|
717
|
+
// CENTER: strokeGeometry blobs are pre-expanded filled outlines (correct as-is).
|
|
718
|
+
// INSIDE: strokeGeometry + clip (TODO — falls back to CENTER for now).
|
|
719
|
+
const strokeColor = resolveFill(node.strokePaints);
|
|
720
|
+
const sw = node.strokeWeight ?? 0;
|
|
721
|
+
const strokeAlign = node.strokeAlign ?? 'CENTER';
|
|
722
|
+
|
|
723
|
+
if (strokeAlign === 'OUTSIDE' && strokeColor && sw > 0 && fillEntries.length) {
|
|
724
|
+
// Stroke layer underneath (2× width, centered = sw outside + sw inside)
|
|
725
|
+
for (const { d, rule } of fillEntries) {
|
|
726
|
+
parts.push(`<path d="${d}" fill="none" stroke="${strokeColor}" stroke-width="${sw * 2}" stroke-linejoin="miter"${rule}/>`);
|
|
727
|
+
}
|
|
728
|
+
// Fill layer on top — covers inner half of stroke
|
|
729
|
+
for (const { d, color, rule } of fillEntries) {
|
|
730
|
+
parts.push(`<path d="${d}" fill="${color}"${rule}/>`);
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
// Emit fill paths
|
|
734
|
+
for (const { d, color, rule } of fillEntries) {
|
|
735
|
+
parts.push(`<path d="${d}" fill="${color}"${rule}/>`);
|
|
736
|
+
}
|
|
737
|
+
// CENTER/INSIDE: use pre-computed strokeGeometry outlines as filled paths
|
|
738
|
+
if (strokeColor && sw > 0 && node.strokeGeometry?.length && blobs) {
|
|
739
|
+
const segments = [];
|
|
740
|
+
let hasEvenOdd = false;
|
|
741
|
+
for (const geo of node.strokeGeometry) {
|
|
742
|
+
const d = decodeCmdBlob(blobs, geo.commandsBlob);
|
|
743
|
+
if (d) segments.push(d);
|
|
744
|
+
if (geo.windingRule === 'EVENODD') hasEvenOdd = true;
|
|
745
|
+
}
|
|
746
|
+
if (segments.length) {
|
|
747
|
+
const rule = (segments.length > 1 || hasEvenOdd) ? ' fill-rule="evenodd"' : '';
|
|
748
|
+
parts.push(`<path d="${segments.join('')}" fill="${strokeColor}"${rule}/>`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Fallback: decode vectorNetworkBlob when no pre-computed fill/strokeGeometry
|
|
754
|
+
if (!parts.length && node.vectorData?.vectorNetworkBlob != null && blobs) {
|
|
755
|
+
const vnbD = decodeVnb(blobs, node.vectorData.vectorNetworkBlob, node.vectorData.normalizedSize, node.size);
|
|
756
|
+
if (vnbD) {
|
|
757
|
+
const color = fillColor ?? resolveFill(node.strokePaints) ?? '#000000';
|
|
758
|
+
parts.push(`<path d="${vnbD}" fill="${color}" fill-rule="evenodd"/>`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (!parts.length) return renderPlaceholder(deck, node);
|
|
763
|
+
return `<g transform="matrix(${m00},${m10},${m01},${m11},${m02},${m12})">\n${parts.join('\n')}\n</g>`;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** BOOLEAN_OPERATION — render the pre-computed boolean result shape.
|
|
767
|
+
* fillGeometry contains the merged boolean result; children are shape operands
|
|
768
|
+
* baked into fillGeometry. However, the compound path may have winding-direction
|
|
769
|
+
* holes that need filling. Children are re-rendered with the PARENT's fill color
|
|
770
|
+
* (not their own) to fill these gaps — Figma applies the boolean node's fill
|
|
771
|
+
* to all content uniformly. */
|
|
772
|
+
function renderBooleanOp(deck, node) {
|
|
773
|
+
const ownSvg = renderVector(deck, node);
|
|
774
|
+
|
|
775
|
+
// For UNION booleans: re-render children using the parent's fill color to
|
|
776
|
+
// fill winding-direction holes. XOR/SUBTRACT/INTERSECT fillGeometry is
|
|
777
|
+
// self-contained — children are pure shape operands.
|
|
778
|
+
let inner = '';
|
|
779
|
+
if (node.booleanOperation === 'UNION') {
|
|
780
|
+
const parentFill = resolveFill(getFillPaints(node));
|
|
781
|
+
if (parentFill) {
|
|
782
|
+
const children = deck.getChildren(nid(node));
|
|
783
|
+
const childParts = [];
|
|
784
|
+
for (const child of children) {
|
|
785
|
+
if (child.phase === 'REMOVED') continue;
|
|
786
|
+
const origFills = child.fillPaints;
|
|
787
|
+
child.fillPaints = getFillPaints(node);
|
|
788
|
+
childParts.push(renderNode(deck, child));
|
|
789
|
+
child.fillPaints = origFills;
|
|
790
|
+
}
|
|
791
|
+
inner = childParts.filter(Boolean).join('\n');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (!ownSvg && !inner) return '';
|
|
796
|
+
if (!inner) return ownSvg;
|
|
797
|
+
if (!ownSvg) {
|
|
798
|
+
const t = node.transform ?? {};
|
|
799
|
+
const m00 = t.m00 ?? 1, m01 = t.m01 ?? 0, m02 = t.m02 ?? 0;
|
|
800
|
+
const m10 = t.m10 ?? 0, m11 = t.m11 ?? 1, m12 = t.m12 ?? 0;
|
|
801
|
+
return `<g transform="matrix(${m00},${m10},${m01},${m11},${m02},${m12})">\n${inner}\n</g>`;
|
|
802
|
+
}
|
|
803
|
+
const closeIdx = ownSvg.lastIndexOf('</g>');
|
|
804
|
+
return ownSvg.slice(0, closeIdx) + inner + '\n</g>';
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Decode a commandsBlob index into an SVG path d-string. */
|
|
808
|
+
function decodeCmdBlob(blobs, blobIdx) {
|
|
809
|
+
if (blobIdx == null || !blobs?.[blobIdx]) return null;
|
|
810
|
+
const raw = blobs[blobIdx].bytes ?? blobs[blobIdx];
|
|
811
|
+
if (!raw) return null;
|
|
812
|
+
|
|
813
|
+
// Convert indexed object to Buffer if needed
|
|
814
|
+
let buf;
|
|
815
|
+
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
|
|
816
|
+
buf = Buffer.from(raw);
|
|
817
|
+
} else {
|
|
818
|
+
const len = Object.keys(raw).length;
|
|
819
|
+
buf = Buffer.alloc(len);
|
|
820
|
+
for (let i = 0; i < len; i++) buf[i] = raw[i];
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const cmds = [];
|
|
824
|
+
let off = 0;
|
|
825
|
+
while (off < buf.length) {
|
|
826
|
+
const cmd = buf[off++];
|
|
827
|
+
if (cmd === 0x01) { // moveTo
|
|
828
|
+
const x = buf.readFloatLE(off); off += 4;
|
|
829
|
+
const y = buf.readFloatLE(off); off += 4;
|
|
830
|
+
cmds.push(`M${f(x)},${f(y)}`);
|
|
831
|
+
} else if (cmd === 0x02) { // lineTo
|
|
832
|
+
const x = buf.readFloatLE(off); off += 4;
|
|
833
|
+
const y = buf.readFloatLE(off); off += 4;
|
|
834
|
+
cmds.push(`L${f(x)},${f(y)}`);
|
|
835
|
+
} else if (cmd === 0x04) { // cubicTo
|
|
836
|
+
const c1x = buf.readFloatLE(off); off += 4;
|
|
837
|
+
const c1y = buf.readFloatLE(off); off += 4;
|
|
838
|
+
const c2x = buf.readFloatLE(off); off += 4;
|
|
839
|
+
const c2y = buf.readFloatLE(off); off += 4;
|
|
840
|
+
const x = buf.readFloatLE(off); off += 4;
|
|
841
|
+
const y = buf.readFloatLE(off); off += 4;
|
|
842
|
+
cmds.push(`C${f(c1x)},${f(c1y)} ${f(c2x)},${f(c2y)} ${f(x)},${f(y)}`);
|
|
843
|
+
} else if (cmd === 0x00) { // close
|
|
844
|
+
cmds.push('Z');
|
|
845
|
+
} else {
|
|
846
|
+
break; // unknown command — stop
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return cmds.length ? cmds.join('') : null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function f(v) { return +v.toFixed(2); }
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Decode vectorNetworkBlob into an SVG path d-string.
|
|
856
|
+
* VNB stores vertices, segments (lines/cubics), and regions (loops of segment indices).
|
|
857
|
+
* Coordinates are in normalizedSize space and must be scaled to nodeSize.
|
|
858
|
+
*
|
|
859
|
+
* Binary layout (all little-endian):
|
|
860
|
+
* Header: numVertices(u32), numSegments(u32), numRegions(u32), numStyles(u32)
|
|
861
|
+
* Vertices: x(f32), y(f32), handleMirroring(u32) — 12 bytes each
|
|
862
|
+
* Segments: startVertex(u32), tangentStartX(f32), tangentStartY(f32),
|
|
863
|
+
* endVertex(u32), tangentEndX(f32), tangentEndY(f32), segType(u32) — 28 bytes each
|
|
864
|
+
* Regions: numLoops(u32), per loop: segCount(u32) + segIndices(u32[segCount]), windingRule(u32)
|
|
865
|
+
*/
|
|
866
|
+
function decodeVnb(blobs, blobIdx, normalizedSize, nodeSize) {
|
|
867
|
+
const buf = blobToBuffer(blobs, blobIdx);
|
|
868
|
+
if (!buf || buf.length < 16) return null;
|
|
869
|
+
|
|
870
|
+
const scaleX = (nodeSize?.x ?? 1) / (normalizedSize?.x ?? 1);
|
|
871
|
+
const scaleY = (nodeSize?.y ?? 1) / (normalizedSize?.y ?? 1);
|
|
872
|
+
|
|
873
|
+
let off = 0;
|
|
874
|
+
const numVerts = buf.readUInt32LE(off); off += 4;
|
|
875
|
+
const numSegs = buf.readUInt32LE(off); off += 4;
|
|
876
|
+
const numRegions = buf.readUInt32LE(off); off += 4;
|
|
877
|
+
off += 4; // numStyles
|
|
878
|
+
|
|
879
|
+
// Parse vertices
|
|
880
|
+
const verts = [];
|
|
881
|
+
for (let i = 0; i < numVerts; i++) {
|
|
882
|
+
const x = buf.readFloatLE(off) * scaleX; off += 4;
|
|
883
|
+
const y = buf.readFloatLE(off) * scaleY; off += 4;
|
|
884
|
+
off += 4; // handleMirroring
|
|
885
|
+
verts.push({ x, y });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Parse segments
|
|
889
|
+
const segs = [];
|
|
890
|
+
for (let i = 0; i < numSegs; i++) {
|
|
891
|
+
const sv = buf.readUInt32LE(off); off += 4;
|
|
892
|
+
const tsx = buf.readFloatLE(off) * scaleX; off += 4;
|
|
893
|
+
const tsy = buf.readFloatLE(off) * scaleY; off += 4;
|
|
894
|
+
const ev = buf.readUInt32LE(off); off += 4;
|
|
895
|
+
const tex = buf.readFloatLE(off) * scaleX; off += 4;
|
|
896
|
+
const tey = buf.readFloatLE(off) * scaleY; off += 4;
|
|
897
|
+
const type = buf.readUInt32LE(off); off += 4;
|
|
898
|
+
segs.push({ sv, tsx, tsy, ev, tex, tey, type });
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Parse regions → build SVG paths
|
|
902
|
+
const cmds = [];
|
|
903
|
+
for (let r = 0; r < numRegions; r++) {
|
|
904
|
+
if (off + 4 > buf.length) break;
|
|
905
|
+
const numLoops = buf.readUInt32LE(off); off += 4;
|
|
906
|
+
for (let loop = 0; loop < numLoops; loop++) {
|
|
907
|
+
if (off + 4 > buf.length) break;
|
|
908
|
+
const segCount = buf.readUInt32LE(off); off += 4;
|
|
909
|
+
for (let s = 0; s < segCount; s++) {
|
|
910
|
+
if (off + 4 > buf.length) break;
|
|
911
|
+
const segIdx = buf.readUInt32LE(off); off += 4;
|
|
912
|
+
if (segIdx >= segs.length) continue;
|
|
913
|
+
const seg = segs[segIdx];
|
|
914
|
+
const start = verts[seg.sv];
|
|
915
|
+
const end = verts[seg.ev];
|
|
916
|
+
if (!start || !end) continue;
|
|
917
|
+
|
|
918
|
+
if (s === 0) cmds.push(`M${f(start.x)},${f(start.y)}`);
|
|
919
|
+
|
|
920
|
+
if (seg.type === 0) {
|
|
921
|
+
// Line
|
|
922
|
+
cmds.push(`L${f(end.x)},${f(end.y)}`);
|
|
923
|
+
} else {
|
|
924
|
+
// Cubic bezier — tangents are relative to their vertex
|
|
925
|
+
const c1x = start.x + seg.tsx;
|
|
926
|
+
const c1y = start.y + seg.tsy;
|
|
927
|
+
const c2x = end.x + seg.tex;
|
|
928
|
+
const c2y = end.y + seg.tey;
|
|
929
|
+
cmds.push(`C${f(c1x)},${f(c1y)} ${f(c2x)},${f(c2y)} ${f(end.x)},${f(end.y)}`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
cmds.push('Z');
|
|
933
|
+
}
|
|
934
|
+
off += 4; // windingRule
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return cmds.length ? cmds.join('') : null;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function blobToBuffer(blobs, blobIdx) {
|
|
941
|
+
if (blobIdx == null || !blobs?.[blobIdx]) return null;
|
|
942
|
+
const raw = blobs[blobIdx].bytes ?? blobs[blobIdx];
|
|
943
|
+
if (!raw) return null;
|
|
944
|
+
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) return Buffer.from(raw);
|
|
945
|
+
const len = Object.keys(raw).length;
|
|
946
|
+
const buf = Buffer.alloc(len);
|
|
947
|
+
for (let i = 0; i < len; i++) buf[i] = raw[i];
|
|
948
|
+
return buf;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function renderPlaceholder(deck, node) {
|
|
952
|
+
const { w, h } = size(node);
|
|
953
|
+
const type = node.type ?? '?';
|
|
954
|
+
return `<g transform="${svgTransform(node)}"><rect x="0" y="0" width="${w || 40}" height="${h || 40}" fill="none" stroke="#ff00ff" stroke-width="2" stroke-dasharray="6" opacity="0.5"/><!-- ${type} --></g>`;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* INSTANCE → SYMBOL resolution.
|
|
959
|
+
*
|
|
960
|
+
* Figma templates use INSTANCE nodes that reference a SYMBOL definition.
|
|
961
|
+
* The SYMBOL's children (TEXT, shapes, frames, etc.) define the visual content.
|
|
962
|
+
* The INSTANCE may carry symbolOverrides that modify specific child properties
|
|
963
|
+
* (text content, fills, etc.).
|
|
964
|
+
*
|
|
965
|
+
* Strategy:
|
|
966
|
+
* - Resolve the SYMBOL via symbolData.symbolID
|
|
967
|
+
* - Render the SYMBOL's children tree (they live in the normal node hierarchy)
|
|
968
|
+
* - Apply symbolOverrides: text and fill overrides are temporarily applied
|
|
969
|
+
* to the target nodes, rendered, then restored.
|
|
970
|
+
*/
|
|
971
|
+
function renderInstance(deck, node) {
|
|
972
|
+
const symbolId = node.symbolData?.symbolID;
|
|
973
|
+
if (!symbolId) return renderPlaceholder(deck, node);
|
|
974
|
+
|
|
975
|
+
const symNid = `${symbolId.sessionID}:${symbolId.localID}`;
|
|
976
|
+
const symbol = deck.getNode(symNid);
|
|
977
|
+
if (!symbol) return renderPlaceholder(deck, node);
|
|
978
|
+
|
|
979
|
+
// Temporarily apply symbolOverrides so rendered content reflects overrides.
|
|
980
|
+
// Override guidPaths may reference library-original IDs (e.g. 100:656) rather
|
|
981
|
+
// than local node IDs (e.g. 1:1131). Nodes expose their library ID via the
|
|
982
|
+
// `overrideKey` property, so we build a lookup from overrideKey → local node.
|
|
983
|
+
const overrides = node.symbolData?.symbolOverrides ?? [];
|
|
984
|
+
const restores = [];
|
|
985
|
+
|
|
986
|
+
// Build overrideKey → node map for all SYMBOL descendants
|
|
987
|
+
const okMap = new Map();
|
|
988
|
+
function buildOkMap(nid) {
|
|
989
|
+
for (const child of deck.getChildren(nid)) {
|
|
990
|
+
const ok = child.overrideKey;
|
|
991
|
+
if (ok) okMap.set(`${ok.sessionID}:${ok.localID}`, child);
|
|
992
|
+
buildOkMap(`${child.guid.sessionID}:${child.guid.localID}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
buildOkMap(symNid);
|
|
996
|
+
|
|
997
|
+
// Build derivedSymbolData lookup: guidPath ID → entry.
|
|
998
|
+
// Contains Figma-computed layout (size, transform, derivedTextData) for child
|
|
999
|
+
// nodes as they appear in this INSTANCE, accounting for auto-layout resizing.
|
|
1000
|
+
const dsdMap = new Map();
|
|
1001
|
+
for (const entry of node.derivedSymbolData ?? []) {
|
|
1002
|
+
const guids = entry.guidPath?.guids;
|
|
1003
|
+
if (!guids?.length) continue;
|
|
1004
|
+
// Use the last guid in the path for single-level lookups
|
|
1005
|
+
const g = guids[guids.length - 1];
|
|
1006
|
+
dsdMap.set(`${g.sessionID}:${g.localID}`, entry);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Apply symbolOverrides (symbol swaps, text characters, fill paints).
|
|
1010
|
+
// overriddenSymbolID entries swap which SYMBOL a nested INSTANCE renders —
|
|
1011
|
+
// e.g. swapping a body pose or head style in a character component.
|
|
1012
|
+
// These must be processed first so okMap gets extended with the new symbol's
|
|
1013
|
+
// descendants before text/fill overrides are applied.
|
|
1014
|
+
for (const ov of overrides) {
|
|
1015
|
+
const guids = ov.guidPath?.guids;
|
|
1016
|
+
if (!guids?.length || guids.length !== 1) continue;
|
|
1017
|
+
const targetId = `${guids[0].sessionID}:${guids[0].localID}`;
|
|
1018
|
+
const target = deck.getNode(targetId) ?? okMap.get(targetId);
|
|
1019
|
+
if (!target) continue;
|
|
1020
|
+
|
|
1021
|
+
if (ov.overriddenSymbolID && target.symbolData) {
|
|
1022
|
+
const origSymbolID = target.symbolData.symbolID;
|
|
1023
|
+
restores.push(() => { target.symbolData.symbolID = origSymbolID; });
|
|
1024
|
+
target.symbolData.symbolID = ov.overriddenSymbolID;
|
|
1025
|
+
// Extend okMap with the new symbol's descendants so downstream
|
|
1026
|
+
// overrides and derivedSymbolData can find them by overrideKey.
|
|
1027
|
+
const newSymNid = `${ov.overriddenSymbolID.sessionID}:${ov.overriddenSymbolID.localID}`;
|
|
1028
|
+
buildOkMap(newSymNid);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (ov.textData?.characters != null && target.textData) {
|
|
1032
|
+
const origChars = target.textData.characters;
|
|
1033
|
+
restores.push(() => { target.textData.characters = origChars; });
|
|
1034
|
+
target.textData.characters = ov.textData.characters;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (ov.fillPaints) {
|
|
1038
|
+
const origFill = target.fillPaints;
|
|
1039
|
+
restores.push(() => { target.fillPaints = origFill; });
|
|
1040
|
+
target.fillPaints = ov.fillPaints;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Apply derivedSymbolData to matching nodes.
|
|
1045
|
+
// Auto-layout symbols (stackMode set): Figma re-positions/resizes children,
|
|
1046
|
+
// so apply size + transform + derivedTextData and skip global scale.
|
|
1047
|
+
// Non-auto-layout symbols: children scale proportionally, so only apply
|
|
1048
|
+
// derivedTextData (glyph re-layout) and use global scale for positioning.
|
|
1049
|
+
const isAutoLayout = !!symbol.stackMode;
|
|
1050
|
+
for (const [dsdId, dsd] of dsdMap) {
|
|
1051
|
+
const target = deck.getNode(dsdId) ?? okMap.get(dsdId);
|
|
1052
|
+
if (!target) continue;
|
|
1053
|
+
|
|
1054
|
+
if (dsd.derivedTextData) {
|
|
1055
|
+
const orig = target.derivedTextData;
|
|
1056
|
+
restores.push(() => { target.derivedTextData = orig; });
|
|
1057
|
+
target.derivedTextData = dsd.derivedTextData;
|
|
1058
|
+
}
|
|
1059
|
+
if (isAutoLayout && dsd.size) {
|
|
1060
|
+
const orig = target.size;
|
|
1061
|
+
restores.push(() => { target.size = orig; });
|
|
1062
|
+
target.size = dsd.size;
|
|
1063
|
+
}
|
|
1064
|
+
if (isAutoLayout && dsd.transform) {
|
|
1065
|
+
const orig = target.transform;
|
|
1066
|
+
restores.push(() => { target.transform = orig; });
|
|
1067
|
+
target.transform = dsd.transform;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Scale when INSTANCE size differs from SYMBOL size.
|
|
1072
|
+
// Auto-layout symbols have per-node layout from derivedSymbolData, so skip scale.
|
|
1073
|
+
const instSize = size(node);
|
|
1074
|
+
const symSize = size(symbol.size ? symbol : node);
|
|
1075
|
+
const sx = symSize.w ? instSize.w / symSize.w : 1;
|
|
1076
|
+
const sy = symSize.h ? instSize.h / symSize.h : 1;
|
|
1077
|
+
const needsScale = !isAutoLayout && (Math.abs(sx - 1) > 0.001 || Math.abs(sy - 1) > 0.001);
|
|
1078
|
+
|
|
1079
|
+
// Render SYMBOL's own fill as background (e.g. dark-blue cover slide)
|
|
1080
|
+
// Use INSTANCE size when auto-layout provides per-node layout
|
|
1081
|
+
const bgW = isAutoLayout ? instSize.w : symSize.w;
|
|
1082
|
+
const bgH = isAutoLayout ? instSize.h : symSize.h;
|
|
1083
|
+
const rx = Math.min(symbol.cornerRadius ?? 0, bgW / 2, bgH / 2);
|
|
1084
|
+
let { defs, bg } = renderRoundedRectFillStack(deck, getFillPaints(symbol), bgW, bgH, rx);
|
|
1085
|
+
|
|
1086
|
+
// Render stroke. Use the INSTANCE's strokeGeometry (already computed for
|
|
1087
|
+
// INSTANCE dimensions) when borders are independent; fall back to SYMBOL rect.
|
|
1088
|
+
let strokeSvg = '';
|
|
1089
|
+
const strokeSrc = node.strokePaints ? node : symbol;
|
|
1090
|
+
const strokeColor = resolveFill(strokeSrc.strokePaints);
|
|
1091
|
+
if (strokeColor && (strokeSrc.strokeWeight ?? 0) > 0) {
|
|
1092
|
+
if (strokeSrc.borderStrokeWeightsIndependent && strokeSrc.strokeGeometry?.length) {
|
|
1093
|
+
const blobs = deck.message?.blobs;
|
|
1094
|
+
if (blobs) {
|
|
1095
|
+
const segs = [];
|
|
1096
|
+
for (const geo of strokeSrc.strokeGeometry) {
|
|
1097
|
+
const d = decodeCmdBlob(blobs, geo.commandsBlob);
|
|
1098
|
+
if (d) segs.push(d);
|
|
1099
|
+
}
|
|
1100
|
+
if (segs.length) {
|
|
1101
|
+
// Clip stroke to frame bounds — Figma's INSIDE-aligned stroke geometry
|
|
1102
|
+
// extends ±strokeWeight outside the frame edge (symmetric expansion).
|
|
1103
|
+
// A clipPath matching the frame bounds shows only the inside portion.
|
|
1104
|
+
const scId = `stroke-clip-${++_svgIdSeq}`;
|
|
1105
|
+
strokeSvg = `<clipPath id="${scId}"><rect width="${bgW}" height="${bgH}"/></clipPath>`
|
|
1106
|
+
+ `<path d="${segs.join('')}" fill="${strokeColor}" clip-path="url(#${scId})"/>`;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
} else {
|
|
1110
|
+
const stroke = strokeSpec(strokeSrc);
|
|
1111
|
+
strokeSvg = rectStrokeSvg(0, 0, bgW, bgH, rx, stroke);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
_symbolDepth++;
|
|
1116
|
+
const inner = childrenSvg(deck, symbol);
|
|
1117
|
+
_symbolDepth--;
|
|
1118
|
+
|
|
1119
|
+
// Restore mutations
|
|
1120
|
+
for (const fn of restores) fn();
|
|
1121
|
+
|
|
1122
|
+
let content = [defs, bg, inner, strokeSvg].filter(Boolean).join('\n');
|
|
1123
|
+
if (!content) return '';
|
|
1124
|
+
if (needsScale) {
|
|
1125
|
+
content = `<g transform="scale(${sx},${sy})">\n${content}\n</g>`;
|
|
1126
|
+
}
|
|
1127
|
+
return `<g transform="${svgTransform(node)}">\n${content}\n</g>`;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
1131
|
+
|
|
1132
|
+
const RENDERERS = {
|
|
1133
|
+
ROUNDED_RECTANGLE: renderRect,
|
|
1134
|
+
RECTANGLE: renderRect,
|
|
1135
|
+
SHAPE_WITH_TEXT: renderShapeWithText,
|
|
1136
|
+
ELLIPSE: renderEllipse,
|
|
1137
|
+
TEXT: renderText,
|
|
1138
|
+
FRAME: renderFrame,
|
|
1139
|
+
GROUP: renderGroup,
|
|
1140
|
+
SECTION: renderGroup,
|
|
1141
|
+
BOOLEAN_OPERATION: renderBooleanOp,
|
|
1142
|
+
VECTOR: renderVector,
|
|
1143
|
+
LINE: renderLine,
|
|
1144
|
+
STAR: renderVector,
|
|
1145
|
+
REGULAR_POLYGON: renderVector,
|
|
1146
|
+
POLYGON: renderVector,
|
|
1147
|
+
INSTANCE: renderInstance,
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
/** Build an SVG 1.1 <filter> for DROP_SHADOW effects.
|
|
1151
|
+
* Uses feGaussianBlur + feOffset + feFlood + feComposite + feMerge (SVG 1.1)
|
|
1152
|
+
* instead of feDropShadow (SVG 2) for broad renderer compatibility. */
|
|
1153
|
+
function buildEffectFilter(node) {
|
|
1154
|
+
const effects = node.effects?.filter(e => e.visible !== false);
|
|
1155
|
+
if (!effects?.length) return null;
|
|
1156
|
+
|
|
1157
|
+
const shadows = effects.filter(e => e.type === 'DROP_SHADOW');
|
|
1158
|
+
if (!shadows.length) return null;
|
|
1159
|
+
|
|
1160
|
+
const id = `fx-${++_svgIdSeq}`;
|
|
1161
|
+
const parts = [];
|
|
1162
|
+
const mergeNodes = [];
|
|
1163
|
+
|
|
1164
|
+
for (let i = 0; i < shadows.length; i++) {
|
|
1165
|
+
const s = shadows[i];
|
|
1166
|
+
const c = s.color ?? {};
|
|
1167
|
+
const r = Math.round((c.r ?? 0) * 255);
|
|
1168
|
+
const g = Math.round((c.g ?? 0) * 255);
|
|
1169
|
+
const b = Math.round((c.b ?? 0) * 255);
|
|
1170
|
+
const a = (c.a ?? 1).toFixed(4);
|
|
1171
|
+
const dx = s.offset?.x ?? 0;
|
|
1172
|
+
const dy = s.offset?.y ?? 0;
|
|
1173
|
+
const stdDev = (s.radius ?? 0) / 2;
|
|
1174
|
+
const sid = `s${i}`;
|
|
1175
|
+
parts.push(
|
|
1176
|
+
`<feGaussianBlur in="SourceAlpha" stdDeviation="${stdDev}" result="${sid}b"/>`,
|
|
1177
|
+
`<feOffset in="${sid}b" dx="${dx}" dy="${dy}" result="${sid}o"/>`,
|
|
1178
|
+
`<feFlood flood-color="rgb(${r},${g},${b})" flood-opacity="${a}" result="${sid}c"/>`,
|
|
1179
|
+
`<feComposite in="${sid}c" in2="${sid}o" operator="in" result="${sid}"/>`,
|
|
1180
|
+
);
|
|
1181
|
+
mergeNodes.push(`<feMergeNode in="${sid}"/>`);
|
|
1182
|
+
}
|
|
1183
|
+
mergeNodes.push(`<feMergeNode in="SourceGraphic"/>`);
|
|
1184
|
+
parts.push(`<feMerge>${mergeNodes.join('')}</feMerge>`);
|
|
1185
|
+
|
|
1186
|
+
// SVG default filter region: 10% padding. Sufficient for typical drop shadows
|
|
1187
|
+
// (radius ≤ 20px, offset ≤ 20px on elements > 100px).
|
|
1188
|
+
const defs = `<filter id="${id}" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB">${parts.join('')}</filter>`;
|
|
1189
|
+
return { defs, attr: `filter="url(#${id})"` };
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function renderNode(deck, node) {
|
|
1193
|
+
if (node.phase === 'REMOVED') return '';
|
|
1194
|
+
const fn = RENDERERS[node.type] ?? renderPlaceholder;
|
|
1195
|
+
let svg = fn(deck, node);
|
|
1196
|
+
if (!svg) return '';
|
|
1197
|
+
|
|
1198
|
+
// Apply effects (drop shadows)
|
|
1199
|
+
const fx = buildEffectFilter(node);
|
|
1200
|
+
if (fx) {
|
|
1201
|
+
svg = `<defs>${fx.defs}</defs>\n<g ${fx.attr}>${svg}</g>`;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const op = node.opacity;
|
|
1205
|
+
if (op != null && op < 1) {
|
|
1206
|
+
svg = `<g opacity="${op}">${svg}</g>`;
|
|
1207
|
+
}
|
|
1208
|
+
return svg;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function childrenSvg(deck, node) {
|
|
1212
|
+
return deck.getChildren(nid(node))
|
|
1213
|
+
.map(child => renderNode(deck, child))
|
|
1214
|
+
.filter(Boolean)
|
|
1215
|
+
.join('\n');
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Convert a single slide node and its subtree to an SVG string.
|
|
1222
|
+
*
|
|
1223
|
+
* @param {import('../fig-deck.mjs').FigDeck} deck
|
|
1224
|
+
* @param {object} slideNode - The SLIDE node object
|
|
1225
|
+
* @returns {string} - Complete SVG string (1920×1080)
|
|
1226
|
+
*/
|
|
1227
|
+
export function slideToSvg(deck, slideNode) {
|
|
1228
|
+
_svgIdSeq = 0;
|
|
1229
|
+
_symbolDepth = 0;
|
|
1230
|
+
const bg = resolveFill(getFillPaints(slideNode)) ?? 'white';
|
|
1231
|
+
|
|
1232
|
+
const body = childrenSvg(deck, slideNode);
|
|
1233
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" \
|
|
1234
|
+
width="${SLIDE_W}" height="${SLIDE_H}" viewBox="0 0 ${SLIDE_W} ${SLIDE_H}">
|
|
1235
|
+
<defs>
|
|
1236
|
+
<clipPath id="slide-clip"><rect width="${SLIDE_W}" height="${SLIDE_H}"/></clipPath>
|
|
1237
|
+
</defs>
|
|
1238
|
+
<rect width="${SLIDE_W}" height="${SLIDE_H}" fill="${bg}"/>
|
|
1239
|
+
<g clip-path="url(#slide-clip)">
|
|
1240
|
+
${body}
|
|
1241
|
+
</g>
|
|
1242
|
+
</svg>`;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Render any node (FRAME, GROUP, etc.) to SVG using its own size as the viewport.
|
|
1247
|
+
* Works for Design file frames, standalone components, or any node with a size field.
|
|
1248
|
+
*
|
|
1249
|
+
* Note: Figma's export expands bounds to include overflow content when a frame has
|
|
1250
|
+
* When frameMaskDisabled=true (clip content OFF), Figma's export expands the
|
|
1251
|
+
* bounds to include overflowing children. We replicate this by computing the
|
|
1252
|
+
* content bounding box and expanding the SVG viewport accordingly.
|
|
1253
|
+
*
|
|
1254
|
+
* @param {FigDeck} fig - Parsed Figma file (works with both .deck and .fig)
|
|
1255
|
+
* @param {object} node - The node to render (typically a FRAME)
|
|
1256
|
+
*/
|
|
1257
|
+
export function frameToSvg(fig, node) {
|
|
1258
|
+
_svgIdSeq = 0;
|
|
1259
|
+
_symbolDepth = 0;
|
|
1260
|
+
const fw = Math.round(node.size?.x ?? 100);
|
|
1261
|
+
const fh = Math.round(node.size?.y ?? 100);
|
|
1262
|
+
const body = childrenSvg(fig, node);
|
|
1263
|
+
|
|
1264
|
+
// Build background fill (supports solid, gradient, and image fills)
|
|
1265
|
+
const { defs: bgDefs, bg: bgSvg } = renderRoundedRectFillStack(fig, getFillPaints(node), fw, fh, 0);
|
|
1266
|
+
// Fallback to white rect if no visible fills
|
|
1267
|
+
const bgContent = bgSvg || `<rect x="0" y="0" width="${fw}" height="${fh}" fill="white"/>`;
|
|
1268
|
+
const defsBlock = bgDefs ? `${bgDefs}\n` : '';
|
|
1269
|
+
|
|
1270
|
+
// When clip content is OFF, expand viewport to include overflow.
|
|
1271
|
+
// Use fractional viewBox origin for exact positioning; ceil the total
|
|
1272
|
+
// coordinate range for pixel dimensions (matches Figma's export sizing).
|
|
1273
|
+
let vx = 0, vy = 0, w = fw, h = fh;
|
|
1274
|
+
if (node.frameMaskDisabled === true) {
|
|
1275
|
+
const bounds = _contentBounds(fig, node);
|
|
1276
|
+
vx = bounds.minX;
|
|
1277
|
+
vy = bounds.minY;
|
|
1278
|
+
w = Math.ceil(bounds.maxX - bounds.minX);
|
|
1279
|
+
h = Math.ceil(bounds.maxY - bounds.minY);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" \
|
|
1283
|
+
width="${w}" height="${h}" viewBox="${vx} ${vy} ${w} ${h}">
|
|
1284
|
+
${defsBlock}${bgContent}
|
|
1285
|
+
${body}
|
|
1286
|
+
</svg>`;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** Compute content bounding box of a node's children in the node's local space.
|
|
1290
|
+
* Recurses into INSTANCE→SYMBOL to catch overflow from scaled symbol children. */
|
|
1291
|
+
function _contentBounds(deck, node, depth = 2) {
|
|
1292
|
+
const fw = node.size?.x ?? 0;
|
|
1293
|
+
const fh = node.size?.y ?? 0;
|
|
1294
|
+
let minX = 0, minY = 0, maxX = fw, maxY = fh;
|
|
1295
|
+
|
|
1296
|
+
for (const child of deck.getChildren(nid(node))) {
|
|
1297
|
+
if (child.phase === 'REMOVED' || child.visible === false) continue;
|
|
1298
|
+
|
|
1299
|
+
const cx = child.transform?.m02 ?? 0;
|
|
1300
|
+
const cy = child.transform?.m12 ?? 0;
|
|
1301
|
+
const cw = child.size?.x ?? 0;
|
|
1302
|
+
const ch = child.size?.y ?? 0;
|
|
1303
|
+
|
|
1304
|
+
minX = Math.min(minX, cx);
|
|
1305
|
+
minY = Math.min(minY, cy);
|
|
1306
|
+
maxX = Math.max(maxX, cx + cw);
|
|
1307
|
+
maxY = Math.max(maxY, cy + ch);
|
|
1308
|
+
|
|
1309
|
+
// For INSTANCE nodes, check if symbol children overflow (scaled)
|
|
1310
|
+
if (depth > 0 && child.type === 'INSTANCE' && child.symbolData?.symbolID) {
|
|
1311
|
+
const symId = child.symbolData.symbolID;
|
|
1312
|
+
const sym = deck.getNode(`${symId.sessionID}:${symId.localID}`);
|
|
1313
|
+
if (sym) {
|
|
1314
|
+
const sw = sym.size?.x || cw;
|
|
1315
|
+
const sh = sym.size?.y || ch;
|
|
1316
|
+
const sx = cw / sw;
|
|
1317
|
+
const sy = ch / sh;
|
|
1318
|
+
const symBounds = _contentBounds(deck, sym, depth - 1);
|
|
1319
|
+
minX = Math.min(minX, cx + symBounds.minX * sx);
|
|
1320
|
+
minY = Math.min(minY, cy + symBounds.minY * sy);
|
|
1321
|
+
maxX = Math.max(maxX, cx + symBounds.maxX * sx);
|
|
1322
|
+
maxY = Math.max(maxY, cy + symBounds.maxY * sy);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
return { minX, minY, maxX, maxY };
|
|
1328
|
+
}
|