figmatk 0.3.1 → 0.3.8

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