figmatk 0.3.0 → 0.3.7
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +49 -14
- package/cli.mjs +2 -0
- package/commands/render.mjs +56 -0
- package/lib/fig-deck.mjs +4 -4
- package/lib/rasterizer/deck-rasterizer.mjs +228 -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/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 +127 -0
- package/lib/rasterizer/render-report.mjs +25 -0
- package/lib/rasterizer/svg-builder.mjs +571 -0
- package/lib/rasterizer/test-render.mjs +63 -0
- package/lib/template-deck.mjs +573 -148
- package/manifest.json +21 -0
- package/mcp-server.mjs +184 -20
- package/package.json +17 -2
- package/skills/figma-slides-creator/SKILL.md +79 -172
- package/skills/figma-template-builder/SKILL.md +158 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* svg-builder.mjs — Convert a FigDeck slide 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
|
+
* TODO: Symbol instance resolution (INSTANCE → SYMBOL + apply overrides).
|
|
9
|
+
* Until that is implemented, INSTANCE nodes render as placeholders.
|
|
10
|
+
* For slides that use direct nodes (not template instances), this works fully.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { hashToHex } from '../image-helpers.mjs';
|
|
16
|
+
import { nid } from '../node-helpers.mjs';
|
|
17
|
+
|
|
18
|
+
export const SLIDE_W = 1920;
|
|
19
|
+
export const SLIDE_H = 1080;
|
|
20
|
+
|
|
21
|
+
// Per-slide ID counter — reset at the start of each slideToSvg call so IDs are unique within each SVG doc
|
|
22
|
+
let _svgIdSeq = 0;
|
|
23
|
+
|
|
24
|
+
// ── Color helpers ─────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function cssColor(color, opacity = 1) {
|
|
27
|
+
const r = Math.round((color.r ?? 0) * 255);
|
|
28
|
+
const g = Math.round((color.g ?? 0) * 255);
|
|
29
|
+
const b = Math.round((color.b ?? 0) * 255);
|
|
30
|
+
const a = ((color.a ?? 1) * opacity).toFixed(4);
|
|
31
|
+
return `rgba(${r},${g},${b},${a})`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveFill(fillPaints) {
|
|
35
|
+
if (!fillPaints?.length) return null;
|
|
36
|
+
const p = fillPaints.find(p => p.visible !== false && p.type === 'SOLID');
|
|
37
|
+
if (!p) return null;
|
|
38
|
+
return cssColor(p.color ?? {}, p.opacity ?? 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function appendDefs(defs, extra) {
|
|
42
|
+
if (!extra) return defs;
|
|
43
|
+
return defs
|
|
44
|
+
? defs.replace('</defs>', `${extra}</defs>`)
|
|
45
|
+
: `<defs>${extra}</defs>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Get effective fillPaints for any node type. */
|
|
49
|
+
function getFillPaints(node) {
|
|
50
|
+
if (node.fillPaints?.length) return node.fillPaints;
|
|
51
|
+
// SHAPE_WITH_TEXT stores fill in nodeGenerationData.overrides[0].fillPaints
|
|
52
|
+
return node.nodeGenerationData?.overrides?.[0]?.fillPaints ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function strokeSpec(node) {
|
|
56
|
+
if (!node.strokeWeight || node.strokeWeight === 0) return null;
|
|
57
|
+
const color = resolveFill(node.strokePaints) ?? 'none';
|
|
58
|
+
if (color === 'none') return null;
|
|
59
|
+
return {
|
|
60
|
+
color,
|
|
61
|
+
width: node.strokeWeight,
|
|
62
|
+
align: node.strokeAlign ?? 'CENTER',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rectStrokeSvg(x, y, w, h, rx, stroke) {
|
|
67
|
+
if (!stroke) return '';
|
|
68
|
+
let sx = x;
|
|
69
|
+
let sy = y;
|
|
70
|
+
let sw = w;
|
|
71
|
+
let sh = h;
|
|
72
|
+
let srx = Math.min(rx, w / 2, h / 2);
|
|
73
|
+
|
|
74
|
+
if (stroke.align === 'INSIDE') {
|
|
75
|
+
sx += stroke.width / 2;
|
|
76
|
+
sy += stroke.width / 2;
|
|
77
|
+
sw -= stroke.width;
|
|
78
|
+
sh -= stroke.width;
|
|
79
|
+
srx = Math.max(0, srx - stroke.width / 2);
|
|
80
|
+
} else if (stroke.align === 'OUTSIDE') {
|
|
81
|
+
sx -= stroke.width / 2;
|
|
82
|
+
sy -= stroke.width / 2;
|
|
83
|
+
sw += stroke.width;
|
|
84
|
+
sh += stroke.width;
|
|
85
|
+
srx += stroke.width / 2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (sw <= 0 || sh <= 0) return '';
|
|
89
|
+
return `<rect x="${sx}" y="${sy}" width="${sw}" height="${sh}" rx="${srx}" ry="${srx}" fill="none" stroke="${stroke.color}" stroke-width="${stroke.width}"/>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ellipseStrokeSvg(cx, cy, rx, ry, stroke) {
|
|
93
|
+
if (!stroke) return '';
|
|
94
|
+
let srx = rx;
|
|
95
|
+
let sry = ry;
|
|
96
|
+
|
|
97
|
+
if (stroke.align === 'INSIDE') {
|
|
98
|
+
srx -= stroke.width / 2;
|
|
99
|
+
sry -= stroke.width / 2;
|
|
100
|
+
} else if (stroke.align === 'OUTSIDE') {
|
|
101
|
+
srx += stroke.width / 2;
|
|
102
|
+
sry += stroke.width / 2;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (srx <= 0 || sry <= 0) return '';
|
|
106
|
+
return `<ellipse cx="${cx}" cy="${cy}" rx="${srx}" ry="${sry}" fill="none" stroke="${stroke.color}" stroke-width="${stroke.width}"/>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Transform helpers ─────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function pos(node) {
|
|
112
|
+
return { x: node.transform?.m02 ?? 0, y: node.transform?.m12 ?? 0 };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function size(node) {
|
|
116
|
+
return { w: node.size?.x ?? 0, h: node.size?.y ?? 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Node renderers ────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function renderRect(deck, node) {
|
|
122
|
+
const { x, y } = pos(node);
|
|
123
|
+
const { w, h } = size(node);
|
|
124
|
+
const rx = Math.min(node.cornerRadius ?? 0, w / 2, h / 2);
|
|
125
|
+
const stroke = strokeSpec(node);
|
|
126
|
+
const { defs, bg } = renderRoundedRectFillStack(deck, getFillPaints(node), w, h, rx);
|
|
127
|
+
const fillSvg = bg ? `<g transform="translate(${x},${y})">\n${[defs, bg].filter(Boolean).join('\n')}\n</g>` : '';
|
|
128
|
+
const strokeSvg = rectStrokeSvg(x, y, w, h, rx, stroke);
|
|
129
|
+
return [fillSvg, strokeSvg].filter(Boolean).join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderEllipse(deck, node) {
|
|
133
|
+
const { x, y } = pos(node);
|
|
134
|
+
const { w, h } = size(node);
|
|
135
|
+
const fill = resolveFill(getFillPaints(node)) ?? 'none';
|
|
136
|
+
const stroke = strokeSpec(node);
|
|
137
|
+
const cx = x + w / 2;
|
|
138
|
+
const cy = y + h / 2;
|
|
139
|
+
const rx = w / 2;
|
|
140
|
+
const ry = h / 2;
|
|
141
|
+
const fillSvg = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${fill}"/>`;
|
|
142
|
+
const strokeSvg = ellipseStrokeSvg(cx, cy, rx, ry, stroke);
|
|
143
|
+
return [fillSvg, strokeSvg].filter(Boolean).join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveLineHeight(lh, fontSize) {
|
|
147
|
+
if (!lh) return fontSize * 1.2;
|
|
148
|
+
switch (lh.units) {
|
|
149
|
+
case 'RAW': return lh.value * fontSize;
|
|
150
|
+
case 'PERCENT': return (lh.value / 100) * fontSize;
|
|
151
|
+
case 'PIXELS': return lh.value;
|
|
152
|
+
default: return fontSize * 1.2;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function esc(s) {
|
|
157
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function styleAttrsFromFontName(fontName, derivedFontWeight) {
|
|
161
|
+
const style = fontName?.style ?? 'Regular';
|
|
162
|
+
const weight = derivedFontWeight
|
|
163
|
+
? String(derivedFontWeight)
|
|
164
|
+
: /semibold/i.test(style) ? '600'
|
|
165
|
+
: /bold/i.test(style) ? '700'
|
|
166
|
+
: /medium/i.test(style) ? '500'
|
|
167
|
+
: '400';
|
|
168
|
+
const italic = /italic/i.test(style) ? 'italic' : 'normal';
|
|
169
|
+
return { weight, italic };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function glyphSlice(chars, glyphs, index) {
|
|
173
|
+
const g = glyphs[index];
|
|
174
|
+
if (g.firstCharacter == null) {
|
|
175
|
+
throw new Error('Unexpected glyph without firstCharacter');
|
|
176
|
+
}
|
|
177
|
+
let nextChar = null;
|
|
178
|
+
for (let j = index + 1; j < glyphs.length; j++) {
|
|
179
|
+
const fc = glyphs[j].firstCharacter;
|
|
180
|
+
if (fc != null && fc > g.firstCharacter) {
|
|
181
|
+
nextChar = fc;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return chars.slice(g.firstCharacter, nextChar ?? (g.firstCharacter + 1));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function renderText(deck, node) {
|
|
189
|
+
const { x, y } = pos(node);
|
|
190
|
+
const chars = node.textData?.characters ?? '';
|
|
191
|
+
if (!chars.trim()) return '';
|
|
192
|
+
const dispChars = node.textCase === 'UPPER' ? chars.toUpperCase()
|
|
193
|
+
: node.textCase === 'LOWER' ? chars.toLowerCase()
|
|
194
|
+
: chars;
|
|
195
|
+
|
|
196
|
+
const fontSize = node.fontSize ?? 24;
|
|
197
|
+
const fontFamily = node.fontName?.family ?? 'Inter';
|
|
198
|
+
const fill = resolveFill(getFillPaints(node)) ?? '#000000';
|
|
199
|
+
// Letter spacing: PERCENT is % of fontSize, PIXELS is absolute
|
|
200
|
+
const ls = node.letterSpacing;
|
|
201
|
+
const letterSpacingPx = !ls ? 0
|
|
202
|
+
: ls.units === 'PERCENT' ? (ls.value / 100) * fontSize
|
|
203
|
+
: ls.units === 'PIXELS' ? ls.value
|
|
204
|
+
: 0;
|
|
205
|
+
const baselines = node.derivedTextData?.baselines;
|
|
206
|
+
const glyphs = node.derivedTextData?.glyphs;
|
|
207
|
+
const styleIds = node.textData?.characterStyleIDs;
|
|
208
|
+
const styleTable = node.textData?.styleOverrideTable;
|
|
209
|
+
if (!glyphs?.length) {
|
|
210
|
+
throw new Error(`TEXT ${node.name ?? nid(node)} is missing derived glyph layout`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build styleID → {weight, italic, decoration, family} map
|
|
214
|
+
const styleMap = {};
|
|
215
|
+
if (styleIds && styleTable) {
|
|
216
|
+
for (const ov of styleTable) {
|
|
217
|
+
// Only override weight/italic/family if fontName is explicitly set in this style run
|
|
218
|
+
const hasFontName = ov.fontName?.family || ov.fontName?.style;
|
|
219
|
+
const { weight, italic } = hasFontName ? styleAttrsFromFontName(ov.fontName, null) : {};
|
|
220
|
+
styleMap[ov.styleID] = {
|
|
221
|
+
family: hasFontName ? (ov.fontName?.family ?? fontFamily) : null,
|
|
222
|
+
weight: hasFontName ? weight : null, // null → fall through to defWeight
|
|
223
|
+
italic: hasFontName ? italic : null, // null → fall through to defItalic
|
|
224
|
+
decoration: ov.textDecoration === 'UNDERLINE' ? 'underline' : 'none',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Node-level defaults
|
|
230
|
+
const { weight: defWeight, italic: defItalic } = styleAttrsFromFontName(
|
|
231
|
+
node.fontName, node.derivedTextData?.fontMetaData?.[0]?.fontWeight
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const useGlyphLayout = !styleIds?.length && ((baselines?.length ?? 0) > 1 || letterSpacingPx !== 0);
|
|
235
|
+
let tspans = '';
|
|
236
|
+
if (baselines?.length && glyphs?.length && styleIds?.length) {
|
|
237
|
+
// Mixed-style: group consecutive glyphs by styleID, emit per-run <tspan>
|
|
238
|
+
for (const b of baselines) {
|
|
239
|
+
const lineGlyphs = glyphs.filter(g => g.firstCharacter >= b.firstCharacter && g.firstCharacter < b.endCharacter);
|
|
240
|
+
if (!lineGlyphs.length) continue;
|
|
241
|
+
|
|
242
|
+
const runs = [];
|
|
243
|
+
let curRun = null;
|
|
244
|
+
for (const g of lineGlyphs) {
|
|
245
|
+
const sid = styleIds[g.firstCharacter] ?? 0;
|
|
246
|
+
if (!curRun || sid !== curRun.sid) {
|
|
247
|
+
curRun = { sid, glyphs: [] };
|
|
248
|
+
runs.push(curRun);
|
|
249
|
+
}
|
|
250
|
+
curRun.glyphs.push(g);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const run of runs) {
|
|
254
|
+
const st = styleMap[run.sid] ?? {};
|
|
255
|
+
const w = st.weight ?? defWeight;
|
|
256
|
+
const it = st.italic ?? defItalic;
|
|
257
|
+
const fam = st.family ?? fontFamily;
|
|
258
|
+
const first = run.glyphs[0];
|
|
259
|
+
const last = run.glyphs[run.glyphs.length - 1];
|
|
260
|
+
const endChar = last.firstCharacter + 1;
|
|
261
|
+
const slice = dispChars.slice(first.firstCharacter, Math.min(endChar, b.endCharacter)).replace(/\n$/, '');
|
|
262
|
+
const rx = x + first.position.x;
|
|
263
|
+
const ry = y + first.position.y;
|
|
264
|
+
tspans += `<tspan x="${rx}" y="${ry}" font-family="${fam}, sans-serif" font-weight="${w}" font-style="${it}">${esc(slice) || ' '}</tspan>`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else if (useGlyphLayout) {
|
|
268
|
+
for (let i = 0; i < glyphs.length; i++) {
|
|
269
|
+
const g = glyphs[i];
|
|
270
|
+
const slice = glyphSlice(dispChars, glyphs, i).replace(/\n$/, '');
|
|
271
|
+
if (!slice || /^\s+$/.test(slice)) continue;
|
|
272
|
+
tspans += `<tspan x="${x + g.position.x}" y="${y + g.position.y}">${esc(slice)}</tspan>`;
|
|
273
|
+
}
|
|
274
|
+
} else if (baselines?.length) {
|
|
275
|
+
tspans = baselines.map(b => {
|
|
276
|
+
const slice = dispChars.slice(b.firstCharacter, b.endCharacter).replace(/\n$/, '');
|
|
277
|
+
return `<tspan x="${x + b.position.x}" y="${y + b.position.y}">${esc(slice) || ' '}</tspan>`;
|
|
278
|
+
}).join('');
|
|
279
|
+
} else {
|
|
280
|
+
throw new Error(`TEXT ${node.name ?? nid(node)} is missing derived baselines`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const lsAttr = useGlyphLayout ? '' : (letterSpacingPx !== 0 ? ` letter-spacing="${letterSpacingPx.toFixed(3)}"` : '');
|
|
284
|
+
const textEl = [
|
|
285
|
+
`<text font-size="${fontSize}" font-family="${fontFamily}, sans-serif"`,
|
|
286
|
+
` font-weight="${defWeight}" font-style="${defItalic}" fill="${fill}"${lsAttr}`,
|
|
287
|
+
` text-rendering="geometricPrecision">${tspans}</text>`,
|
|
288
|
+
].join('\n');
|
|
289
|
+
|
|
290
|
+
// Use Figma's pre-computed decoration rectangles (underline/strikethrough).
|
|
291
|
+
// derivedTextData.decorations[].rects are relative to the node's top-left corner.
|
|
292
|
+
const decorations = node.derivedTextData?.decorations ?? [];
|
|
293
|
+
if (!decorations.length) return textEl;
|
|
294
|
+
const decorationRects = decorations.flatMap(d =>
|
|
295
|
+
(d.rects ?? []).map(r =>
|
|
296
|
+
`<rect x="${(x + r.x).toFixed(2)}" y="${(y + r.y).toFixed(2)}" width="${r.w.toFixed(2)}" height="${r.h.toFixed(2)}" fill="${fill}"/>`
|
|
297
|
+
)
|
|
298
|
+
);
|
|
299
|
+
return decorationRects.length ? textEl + '\n' + decorationRects.join('\n') : textEl;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resolve an IMAGE-type fillPaint to inline SVG defs + bg element.
|
|
304
|
+
* Supports FILL (cover), FIT (contain), and TILE scale modes.
|
|
305
|
+
* Throws if the image file cannot be read.
|
|
306
|
+
*/
|
|
307
|
+
function resolveImageFillSvg(deck, imgFill, w, h, rx) {
|
|
308
|
+
const hashBytes = imgFill.image?.hash;
|
|
309
|
+
const hash = hashBytes?.length ? hashToHex(hashBytes) : imgFill.image?.name;
|
|
310
|
+
if (!hash) throw new Error('Visible IMAGE fill is missing its asset hash');
|
|
311
|
+
if (!deck.imagesDir) throw new Error(`Deck is missing imagesDir for visible IMAGE fill ${hash}`);
|
|
312
|
+
let buf;
|
|
313
|
+
try {
|
|
314
|
+
buf = readFileSync(join(deck.imagesDir, hash));
|
|
315
|
+
} catch (err) {
|
|
316
|
+
throw new Error(`Missing image fill asset ${hash} in ${deck.imagesDir}: ${err.message}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const mime = (buf[0] === 0xFF && buf[1] === 0xD8) ? 'image/jpeg' : 'image/png';
|
|
320
|
+
const dataUri = `data:${mime};base64,${buf.toString('base64')}`;
|
|
321
|
+
const id = ++_svgIdSeq;
|
|
322
|
+
const clipId = `img-clip-${id}`;
|
|
323
|
+
const clipDef = `<clipPath id="${clipId}"><rect width="${w}" height="${h}" rx="${rx}" ry="${rx}"/></clipPath>`;
|
|
324
|
+
const mode = imgFill.imageScaleMode ?? 'FILL';
|
|
325
|
+
const opacityAttr = (imgFill.opacity ?? 1) !== 1 ? ` opacity="${imgFill.opacity}"` : '';
|
|
326
|
+
|
|
327
|
+
if (mode === 'TILE') {
|
|
328
|
+
const tw = (imgFill.originalImageWidth ?? 100) * (imgFill.scale ?? 1);
|
|
329
|
+
const th = (imgFill.originalImageHeight ?? 100) * (imgFill.scale ?? 1);
|
|
330
|
+
const patId = `img-pat-${id}`;
|
|
331
|
+
return {
|
|
332
|
+
defs: `${clipDef}<pattern id="${patId}" x="0" y="0" width="${tw}" height="${th}" patternUnits="userSpaceOnUse"><image href="${dataUri}" width="${tw}" height="${th}"${opacityAttr}/></pattern>`,
|
|
333
|
+
bg: `<rect x="0" y="0" width="${w}" height="${h}" rx="${rx}" ry="${rx}" fill="url(#${patId})" clip-path="url(#${clipId})"/>`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// FILL (cover) or FIT (contain)
|
|
338
|
+
const par = mode === 'FIT' ? 'xMidYMid meet' : 'xMidYMid slice';
|
|
339
|
+
return {
|
|
340
|
+
defs: clipDef,
|
|
341
|
+
bg: `<image href="${dataUri}" x="0" y="0" width="${w}" height="${h}" preserveAspectRatio="${par}" clip-path="url(#${clipId})"${opacityAttr}/>`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function renderRoundedRectFillStack(deck, fillPaints, w, h, rx) {
|
|
346
|
+
const visibleFills = fillPaints?.filter(p => p.visible !== false) ?? [];
|
|
347
|
+
let defs = '';
|
|
348
|
+
const bgParts = [];
|
|
349
|
+
|
|
350
|
+
for (const fill of visibleFills) {
|
|
351
|
+
if (fill.type === 'SOLID') {
|
|
352
|
+
bgParts.push(`<rect x="0" y="0" width="${w}" height="${h}" rx="${rx}" ry="${rx}" fill="${cssColor(fill.color ?? {}, fill.opacity ?? 1)}"/>`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (fill.type === 'IMAGE') {
|
|
356
|
+
const result = resolveImageFillSvg(deck, fill, w, h, rx);
|
|
357
|
+
defs = appendDefs(defs, result.defs);
|
|
358
|
+
bgParts.push(result.bg);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { defs, bg: bgParts.join('\n') };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function renderFrame(deck, node) {
|
|
366
|
+
const { x, y } = pos(node);
|
|
367
|
+
const { w, h } = size(node);
|
|
368
|
+
const rx = Math.min(node.cornerRadius ?? 0, w / 2, h / 2);
|
|
369
|
+
const stroke = strokeSpec(node);
|
|
370
|
+
const inner = childrenSvg(deck, node);
|
|
371
|
+
let { defs, bg } = renderRoundedRectFillStack(deck, getFillPaints(node), w, h, rx);
|
|
372
|
+
|
|
373
|
+
let clippedInner = inner;
|
|
374
|
+
if (node.frameMaskDisabled === false && inner) {
|
|
375
|
+
const clipId = `frame-clip-${++_svgIdSeq}`;
|
|
376
|
+
const clipDef = `<clipPath id="${clipId}"><rect width="${w}" height="${h}" rx="${rx}" ry="${rx}"/></clipPath>`;
|
|
377
|
+
defs = appendDefs(defs, clipDef);
|
|
378
|
+
clippedInner = `<g clip-path="url(#${clipId})">\n${inner}\n</g>`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const strokeSvg = rectStrokeSvg(0, 0, w, h, rx, stroke);
|
|
382
|
+
|
|
383
|
+
const parts = [defs, bg, clippedInner, strokeSvg].filter(Boolean).join('\n');
|
|
384
|
+
if (!parts) return '';
|
|
385
|
+
return `<g transform="translate(${x},${y})">\n${parts}\n</g>`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function renderGroup(deck, node) {
|
|
389
|
+
const { x, y } = pos(node);
|
|
390
|
+
const inner = childrenSvg(deck, node);
|
|
391
|
+
if (!inner) return '';
|
|
392
|
+
return `<g transform="translate(${x},${y})">\n${inner}\n</g>`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* SHAPE_WITH_TEXT: pill/badge nodes — styling and text stored in nodeGenerationData.overrides.
|
|
397
|
+
* overrides[0] = shape (fill, stroke, cornerRadius)
|
|
398
|
+
* overrides[1] = text (textData.characters, fontName, fontSize, textCase)
|
|
399
|
+
* Text position comes from derivedImmutableFrameData.overrides[1].
|
|
400
|
+
*/
|
|
401
|
+
function renderShapeWithText(deck, node) {
|
|
402
|
+
const { x, y } = pos(node);
|
|
403
|
+
const { w, h } = size(node);
|
|
404
|
+
const genOvs = node.nodeGenerationData?.overrides ?? [];
|
|
405
|
+
const shapeOv = genOvs[0] ?? {};
|
|
406
|
+
const textOv = genOvs[1] ?? {};
|
|
407
|
+
|
|
408
|
+
// Shape styling
|
|
409
|
+
const rawRx = shapeOv.cornerRadius ?? 0;
|
|
410
|
+
const rx = Math.min(rawRx, w / 2, h / 2); // 1000000 → pill
|
|
411
|
+
const fill = resolveFill(shapeOv.fillPaints) ?? 'none';
|
|
412
|
+
const sw = shapeOv.strokeWeight ?? 0;
|
|
413
|
+
const stroke = sw > 0 ? resolveFill(shapeOv.strokePaints) ?? 'none' : 'none';
|
|
414
|
+
const strokeAttr = sw > 0 && stroke !== 'none' ? `stroke="${stroke}" stroke-width="${sw}"` : '';
|
|
415
|
+
|
|
416
|
+
const rectSvg = `<rect x="0" y="0" width="${w}" height="${h}" rx="${rx}" ry="${rx}" fill="${fill}" ${strokeAttr}/>`;
|
|
417
|
+
|
|
418
|
+
// Text
|
|
419
|
+
const chars = textOv.textData?.characters ?? '';
|
|
420
|
+
if (!chars.trim()) return `<g transform="translate(${x},${y})">${rectSvg}</g>`;
|
|
421
|
+
|
|
422
|
+
const textCase = textOv.textCase ?? 'ORIGINAL';
|
|
423
|
+
const dispChars = textCase === 'UPPER' ? chars.toUpperCase()
|
|
424
|
+
: textCase === 'LOWER' ? chars.toLowerCase()
|
|
425
|
+
: chars;
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
// Text offset + authoritative font metrics from derivedImmutableFrameData
|
|
429
|
+
const derivedOvs = node.derivedImmutableFrameData?.overrides ?? [];
|
|
430
|
+
const textDerived = derivedOvs.find(o => o.derivedTextData) ?? {};
|
|
431
|
+
const textBoxX = textDerived.transform?.m02 ?? 0;
|
|
432
|
+
const textBoxY = textDerived.transform?.m12 ?? 0;
|
|
433
|
+
const derivedText = textDerived.derivedTextData ?? {};
|
|
434
|
+
const glyphs = derivedText.glyphs;
|
|
435
|
+
const truncationStartIndex = derivedText.truncationStartIndex >= 0
|
|
436
|
+
? derivedText.truncationStartIndex
|
|
437
|
+
: null;
|
|
438
|
+
if (!glyphs?.length) {
|
|
439
|
+
throw new Error(`SHAPE_WITH_TEXT ${node.name ?? nid(node)} is missing derived glyph layout`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// derivedTextData is authoritative — nodeGenerationData can have stale/wrong values
|
|
443
|
+
const derivedFont = textDerived.derivedTextData?.fontMetaData?.[0]?.key;
|
|
444
|
+
const fontSize = textDerived.derivedTextData?.glyphs?.[0]?.fontSize ?? textOv.fontSize ?? 24;
|
|
445
|
+
const fontFamily = derivedFont?.family ?? textOv.fontName?.family ?? 'Inter';
|
|
446
|
+
const fontStyle = derivedFont?.style ?? textOv.fontName?.style ?? 'Regular';
|
|
447
|
+
const fontWeight = /semibold|bold/i.test(fontStyle) ? 'bold'
|
|
448
|
+
: /medium/i.test(fontStyle) ? '500' : 'normal';
|
|
449
|
+
const fontItalic = /italic/i.test(fontStyle) ? 'italic' : 'normal';
|
|
450
|
+
const textFill = resolveFill(textOv.fillPaints) ?? '#000000';
|
|
451
|
+
|
|
452
|
+
let tspan;
|
|
453
|
+
function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
454
|
+
|
|
455
|
+
const spans = [];
|
|
456
|
+
for (let i = 0; i < glyphs.length; i++) {
|
|
457
|
+
const g = glyphs[i];
|
|
458
|
+
if (truncationStartIndex != null && g.firstCharacter != null && g.firstCharacter >= truncationStartIndex) continue;
|
|
459
|
+
|
|
460
|
+
let slice = '';
|
|
461
|
+
let stopAfter = false;
|
|
462
|
+
if (g.firstCharacter == null) {
|
|
463
|
+
if (truncationStartIndex == null) continue;
|
|
464
|
+
slice = '…';
|
|
465
|
+
stopAfter = true;
|
|
466
|
+
} else {
|
|
467
|
+
let nextChar = null;
|
|
468
|
+
for (let j = i + 1; j < glyphs.length; j++) {
|
|
469
|
+
const fc = glyphs[j].firstCharacter;
|
|
470
|
+
if (fc != null && fc > g.firstCharacter) {
|
|
471
|
+
nextChar = fc;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
slice = dispChars.slice(g.firstCharacter, nextChar ?? (g.firstCharacter + 1));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!slice) continue;
|
|
479
|
+
spans.push(`<tspan x="${textBoxX + g.position.x}" y="${textBoxY + g.position.y}">${esc(slice)}</tspan>`);
|
|
480
|
+
if (stopAfter) break;
|
|
481
|
+
}
|
|
482
|
+
tspan = spans.join('');
|
|
483
|
+
|
|
484
|
+
const textSvg = [
|
|
485
|
+
`<text font-size="${fontSize}" font-family="${fontFamily}, sans-serif"`,
|
|
486
|
+
` font-weight="${fontWeight}" font-style="${fontItalic}" fill="${textFill}"`,
|
|
487
|
+
` text-rendering="geometricPrecision">${tspan}</text>`,
|
|
488
|
+
].join('\n');
|
|
489
|
+
|
|
490
|
+
return `<g transform="translate(${x},${y})">\n${rectSvg}\n${textSvg}\n</g>`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function renderLine(deck, node) {
|
|
494
|
+
// LINE uses full transform matrix: direction = (m00, m10), origin = (m02, m12)
|
|
495
|
+
const x1 = node.transform?.m02 ?? 0;
|
|
496
|
+
const y1 = node.transform?.m12 ?? 0;
|
|
497
|
+
const m00 = node.transform?.m00 ?? 1;
|
|
498
|
+
const m10 = node.transform?.m10 ?? 0;
|
|
499
|
+
const len = node.size?.x ?? 0;
|
|
500
|
+
const x2 = x1 + len * m00;
|
|
501
|
+
const y2 = y1 + len * m10;
|
|
502
|
+
const stroke = resolveFill(node.strokePaints) ?? '#000000';
|
|
503
|
+
const sw = node.strokeWeight ?? 1;
|
|
504
|
+
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${sw}"/>`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function renderPlaceholder(deck, node) {
|
|
508
|
+
const { x, y } = pos(node);
|
|
509
|
+
const { w, h } = size(node);
|
|
510
|
+
const type = node.type ?? '?';
|
|
511
|
+
return `<rect x="${x}" y="${y}" width="${w || 40}" height="${h || 40}" fill="none" stroke="#ff00ff" stroke-width="2" stroke-dasharray="6" opacity="0.5"/><!-- ${type} -->`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
const RENDERERS = {
|
|
517
|
+
ROUNDED_RECTANGLE: renderRect,
|
|
518
|
+
RECTANGLE: renderRect,
|
|
519
|
+
SHAPE_WITH_TEXT: renderShapeWithText,
|
|
520
|
+
ELLIPSE: renderEllipse,
|
|
521
|
+
TEXT: renderText,
|
|
522
|
+
FRAME: renderFrame,
|
|
523
|
+
GROUP: renderGroup,
|
|
524
|
+
SECTION: renderGroup,
|
|
525
|
+
BOOLEAN_OPERATION: renderGroup,
|
|
526
|
+
// Stubs — add full implementations over time:
|
|
527
|
+
VECTOR: renderPlaceholder,
|
|
528
|
+
LINE: renderLine,
|
|
529
|
+
STAR: renderPlaceholder,
|
|
530
|
+
POLYGON: renderPlaceholder,
|
|
531
|
+
// TODO: INSTANCE → resolve symbol + apply overrides, then recurse
|
|
532
|
+
INSTANCE: renderPlaceholder,
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
function renderNode(deck, node) {
|
|
536
|
+
if (node.phase === 'REMOVED') return '';
|
|
537
|
+
const fn = RENDERERS[node.type] ?? renderPlaceholder;
|
|
538
|
+
return fn(deck, node);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function childrenSvg(deck, node) {
|
|
542
|
+
return deck.getChildren(nid(node))
|
|
543
|
+
.map(child => renderNode(deck, child))
|
|
544
|
+
.filter(Boolean)
|
|
545
|
+
.join('\n');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Convert a single slide node and its subtree to an SVG string.
|
|
552
|
+
*
|
|
553
|
+
* @param {import('../fig-deck.mjs').FigDeck} deck
|
|
554
|
+
* @param {object} slideNode - The SLIDE node object
|
|
555
|
+
* @returns {string} - Complete SVG string (1920×1080)
|
|
556
|
+
*/
|
|
557
|
+
export function slideToSvg(deck, slideNode) {
|
|
558
|
+
_svgIdSeq = 0; // reset per-slide so IDs are unique within each SVG document
|
|
559
|
+
const bg = resolveFill(getFillPaints(slideNode)) ?? 'white';
|
|
560
|
+
const body = childrenSvg(deck, slideNode);
|
|
561
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" \
|
|
562
|
+
width="${SLIDE_W}" height="${SLIDE_H}" viewBox="0 0 ${SLIDE_W} ${SLIDE_H}">
|
|
563
|
+
<defs>
|
|
564
|
+
<clipPath id="slide-clip"><rect width="${SLIDE_W}" height="${SLIDE_H}"/></clipPath>
|
|
565
|
+
</defs>
|
|
566
|
+
<rect width="${SLIDE_W}" height="${SLIDE_H}" fill="${bg}"/>
|
|
567
|
+
<g clip-path="url(#slide-clip)">
|
|
568
|
+
${body}
|
|
569
|
+
</g>
|
|
570
|
+
</svg>`;
|
|
571
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Test harness: render slide N from a .deck file to PNG.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node lib/rasterizer/test-render.mjs <file.deck> [slide-number] [--width 960] [--height 540] [--scale 0.5]
|
|
7
|
+
*
|
|
8
|
+
* slide-number is 1-based (default: 1). Size options are mutually exclusive.
|
|
9
|
+
* Output: /tmp/figmatk-test-slide-N.png
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { writeFileSync } from 'fs';
|
|
13
|
+
import { FigDeck } from '../fig-deck.mjs';
|
|
14
|
+
import { slideToSvg } from './svg-builder.mjs';
|
|
15
|
+
import { svgToPng } from './deck-rasterizer.mjs';
|
|
16
|
+
|
|
17
|
+
// Minimal arg parser
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const flags = {};
|
|
20
|
+
const positional = [];
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
if (argv[i].startsWith('--')) {
|
|
23
|
+
flags[argv[i].slice(2)] = argv[++i];
|
|
24
|
+
} else {
|
|
25
|
+
positional.push(argv[i]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const [file, slideArg] = positional;
|
|
29
|
+
if (!file) {
|
|
30
|
+
console.error('Usage: node lib/rasterizer/test-render.mjs <file.deck> [slide-number]');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const slideNum = parseInt(slideArg ?? '1', 10);
|
|
35
|
+
|
|
36
|
+
const deck = await FigDeck.fromDeckFile(file);
|
|
37
|
+
const slides = deck.getActiveSlides();
|
|
38
|
+
|
|
39
|
+
console.log(`Deck: ${file}`);
|
|
40
|
+
console.log(`Active slides: ${slides.length}`);
|
|
41
|
+
|
|
42
|
+
if (slideNum < 1 || slideNum > slides.length) {
|
|
43
|
+
console.error(`Slide ${slideNum} out of range (1–${slides.length})`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const slide = slides[slideNum - 1];
|
|
48
|
+
console.log(`Rendering slide ${slideNum}: "${slide.name ?? ''}"`);
|
|
49
|
+
|
|
50
|
+
const svg = slideToSvg(deck, slide);
|
|
51
|
+
const outSvg = `/tmp/figmatk-test-slide-${slideNum}.svg`;
|
|
52
|
+
writeFileSync(outSvg, svg);
|
|
53
|
+
console.log(` SVG → ${outSvg}`);
|
|
54
|
+
|
|
55
|
+
const renderOpts = {};
|
|
56
|
+
if (flags.width) renderOpts.width = parseInt(flags.width);
|
|
57
|
+
if (flags.height) renderOpts.height = parseInt(flags.height);
|
|
58
|
+
if (flags.scale) renderOpts.scale = parseFloat(flags.scale);
|
|
59
|
+
|
|
60
|
+
const png = await svgToPng(svg, renderOpts);
|
|
61
|
+
const outPng = `/tmp/figmatk-test-slide-${slideNum}.png`;
|
|
62
|
+
writeFileSync(outPng, png);
|
|
63
|
+
console.log(` PNG → ${outPng}`);
|