figmatk 0.3.7 → 0.3.9
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/lib/rasterizer/svg-builder.mjs +157 -8
- package/lib/template-deck.mjs +29 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/skills/figma-slides-creator/SKILL.md +1 -1
- package/skills/figma-template-builder/SKILL.md +1 -1
|
@@ -4,10 +4,6 @@
|
|
|
4
4
|
* Architecture: dispatcher pattern — each Figma node type maps to a render
|
|
5
5
|
* function. Unknown types emit a magenta placeholder rect so renders never
|
|
6
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
7
|
*/
|
|
12
8
|
|
|
13
9
|
import { readFileSync } from 'fs';
|
|
@@ -504,6 +500,100 @@ function renderLine(deck, node) {
|
|
|
504
500
|
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${sw}"/>`;
|
|
505
501
|
}
|
|
506
502
|
|
|
503
|
+
/**
|
|
504
|
+
* VECTOR — decode fillGeometry/strokeGeometry commandsBlob binary to SVG paths.
|
|
505
|
+
*
|
|
506
|
+
* Blob format: [cmdByte][float32LE params...]
|
|
507
|
+
* 0x01 = moveTo (x, y)
|
|
508
|
+
* 0x02 = lineTo (x, y)
|
|
509
|
+
* 0x04 = cubicTo (c1x, c1y, c2x, c2y, x, y)
|
|
510
|
+
* 0x00 = close
|
|
511
|
+
*
|
|
512
|
+
* Coordinates are in node-size space. The full affine transform matrix is used
|
|
513
|
+
* to position, scale, and rotate the vector in the slide.
|
|
514
|
+
*/
|
|
515
|
+
function renderVector(deck, node) {
|
|
516
|
+
const t = node.transform ?? {};
|
|
517
|
+
const m00 = t.m00 ?? 1, m01 = t.m01 ?? 0, m02 = t.m02 ?? 0;
|
|
518
|
+
const m10 = t.m10 ?? 0, m11 = t.m11 ?? 1, m12 = t.m12 ?? 0;
|
|
519
|
+
const blobs = deck.message?.blobs;
|
|
520
|
+
const parts = [];
|
|
521
|
+
|
|
522
|
+
// Fill paths
|
|
523
|
+
const fillColor = resolveFill(getFillPaints(node));
|
|
524
|
+
if (fillColor && node.fillGeometry?.length && blobs) {
|
|
525
|
+
for (const geo of node.fillGeometry) {
|
|
526
|
+
const d = decodeCmdBlob(blobs, geo.commandsBlob);
|
|
527
|
+
if (!d) continue;
|
|
528
|
+
const rule = geo.windingRule === 'EVENODD' ? ' fill-rule="evenodd"' : '';
|
|
529
|
+
parts.push(`<path d="${d}" fill="${fillColor}"${rule}/>`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Stroke paths
|
|
534
|
+
const strokeColor = resolveFill(node.strokePaints);
|
|
535
|
+
const sw = node.strokeWeight ?? 0;
|
|
536
|
+
if (strokeColor && sw > 0 && node.strokeGeometry?.length && blobs) {
|
|
537
|
+
for (const geo of node.strokeGeometry) {
|
|
538
|
+
const d = decodeCmdBlob(blobs, geo.commandsBlob);
|
|
539
|
+
if (!d) continue;
|
|
540
|
+
// strokeGeometry encodes the stroke outline as a fill shape
|
|
541
|
+
const rule = geo.windingRule === 'EVENODD' ? ' fill-rule="evenodd"' : '';
|
|
542
|
+
parts.push(`<path d="${d}" fill="${strokeColor}"${rule}/>`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!parts.length) return renderPlaceholder(deck, node);
|
|
547
|
+
return `<g transform="matrix(${m00},${m10},${m01},${m11},${m02},${m12})">\n${parts.join('\n')}\n</g>`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Decode a commandsBlob index into an SVG path d-string. */
|
|
551
|
+
function decodeCmdBlob(blobs, blobIdx) {
|
|
552
|
+
if (blobIdx == null || !blobs?.[blobIdx]) return null;
|
|
553
|
+
const raw = blobs[blobIdx].bytes ?? blobs[blobIdx];
|
|
554
|
+
if (!raw) return null;
|
|
555
|
+
|
|
556
|
+
// Convert indexed object to Buffer if needed
|
|
557
|
+
let buf;
|
|
558
|
+
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
|
|
559
|
+
buf = Buffer.from(raw);
|
|
560
|
+
} else {
|
|
561
|
+
const len = Object.keys(raw).length;
|
|
562
|
+
buf = Buffer.alloc(len);
|
|
563
|
+
for (let i = 0; i < len; i++) buf[i] = raw[i];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const cmds = [];
|
|
567
|
+
let off = 0;
|
|
568
|
+
while (off < buf.length) {
|
|
569
|
+
const cmd = buf[off++];
|
|
570
|
+
if (cmd === 0x01) { // moveTo
|
|
571
|
+
const x = buf.readFloatLE(off); off += 4;
|
|
572
|
+
const y = buf.readFloatLE(off); off += 4;
|
|
573
|
+
cmds.push(`M${f(x)},${f(y)}`);
|
|
574
|
+
} else if (cmd === 0x02) { // lineTo
|
|
575
|
+
const x = buf.readFloatLE(off); off += 4;
|
|
576
|
+
const y = buf.readFloatLE(off); off += 4;
|
|
577
|
+
cmds.push(`L${f(x)},${f(y)}`);
|
|
578
|
+
} else if (cmd === 0x04) { // cubicTo
|
|
579
|
+
const c1x = buf.readFloatLE(off); off += 4;
|
|
580
|
+
const c1y = buf.readFloatLE(off); off += 4;
|
|
581
|
+
const c2x = buf.readFloatLE(off); off += 4;
|
|
582
|
+
const c2y = buf.readFloatLE(off); off += 4;
|
|
583
|
+
const x = buf.readFloatLE(off); off += 4;
|
|
584
|
+
const y = buf.readFloatLE(off); off += 4;
|
|
585
|
+
cmds.push(`C${f(c1x)},${f(c1y)} ${f(c2x)},${f(c2y)} ${f(x)},${f(y)}`);
|
|
586
|
+
} else if (cmd === 0x00) { // close
|
|
587
|
+
cmds.push('Z');
|
|
588
|
+
} else {
|
|
589
|
+
break; // unknown command — stop
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return cmds.length ? cmds.join('') : null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function f(v) { return +v.toFixed(2); }
|
|
596
|
+
|
|
507
597
|
function renderPlaceholder(deck, node) {
|
|
508
598
|
const { x, y } = pos(node);
|
|
509
599
|
const { w, h } = size(node);
|
|
@@ -511,6 +601,67 @@ function renderPlaceholder(deck, node) {
|
|
|
511
601
|
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
602
|
}
|
|
513
603
|
|
|
604
|
+
/**
|
|
605
|
+
* INSTANCE → SYMBOL resolution.
|
|
606
|
+
*
|
|
607
|
+
* Figma templates use INSTANCE nodes that reference a SYMBOL definition.
|
|
608
|
+
* The SYMBOL's children (TEXT, shapes, frames, etc.) define the visual content.
|
|
609
|
+
* The INSTANCE may carry symbolOverrides that modify specific child properties
|
|
610
|
+
* (text content, fills, etc.).
|
|
611
|
+
*
|
|
612
|
+
* Strategy:
|
|
613
|
+
* - Resolve the SYMBOL via symbolData.symbolID
|
|
614
|
+
* - Render the SYMBOL's children tree (they live in the normal node hierarchy)
|
|
615
|
+
* - Apply symbolOverrides: text and fill overrides are temporarily applied
|
|
616
|
+
* to the target nodes, rendered, then restored.
|
|
617
|
+
*/
|
|
618
|
+
function renderInstance(deck, node) {
|
|
619
|
+
const { x, y } = pos(node);
|
|
620
|
+
const symbolId = node.symbolData?.symbolID;
|
|
621
|
+
if (!symbolId) return renderPlaceholder(deck, node);
|
|
622
|
+
|
|
623
|
+
const symNid = `${symbolId.sessionID}:${symbolId.localID}`;
|
|
624
|
+
const symbol = deck.getNode(symNid);
|
|
625
|
+
if (!symbol) return renderPlaceholder(deck, node);
|
|
626
|
+
|
|
627
|
+
// Temporarily apply symbolOverrides so rendered content reflects overrides.
|
|
628
|
+
// Only single-level guidPath overrides are handled (covers the common case).
|
|
629
|
+
const overrides = node.symbolData?.symbolOverrides ?? [];
|
|
630
|
+
const restores = [];
|
|
631
|
+
|
|
632
|
+
for (const ov of overrides) {
|
|
633
|
+
const guids = ov.guidPath?.guids;
|
|
634
|
+
if (!guids?.length || guids.length !== 1) continue;
|
|
635
|
+
const targetId = `${guids[0].sessionID}:${guids[0].localID}`;
|
|
636
|
+
const target = deck.getNode(targetId);
|
|
637
|
+
if (!target) continue;
|
|
638
|
+
|
|
639
|
+
// Text override — replace characters but keep derived glyph layout.
|
|
640
|
+
// The glyph positions come from the original text, so this is approximate
|
|
641
|
+
// when character count differs, but visually far better than a placeholder.
|
|
642
|
+
if (ov.textData?.characters != null && target.textData) {
|
|
643
|
+
const origChars = target.textData.characters;
|
|
644
|
+
restores.push(() => { target.textData.characters = origChars; });
|
|
645
|
+
target.textData.characters = ov.textData.characters;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Fill override (image swaps, color changes)
|
|
649
|
+
if (ov.fillPaints) {
|
|
650
|
+
const origFill = target.fillPaints;
|
|
651
|
+
restores.push(() => { target.fillPaints = origFill; });
|
|
652
|
+
target.fillPaints = ov.fillPaints;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const inner = childrenSvg(deck, symbol);
|
|
657
|
+
|
|
658
|
+
// Restore mutations
|
|
659
|
+
for (const fn of restores) fn();
|
|
660
|
+
|
|
661
|
+
if (!inner) return '';
|
|
662
|
+
return `<g transform="translate(${x},${y})">\n${inner}\n</g>`;
|
|
663
|
+
}
|
|
664
|
+
|
|
514
665
|
// ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
515
666
|
|
|
516
667
|
const RENDERERS = {
|
|
@@ -523,13 +674,11 @@ const RENDERERS = {
|
|
|
523
674
|
GROUP: renderGroup,
|
|
524
675
|
SECTION: renderGroup,
|
|
525
676
|
BOOLEAN_OPERATION: renderGroup,
|
|
526
|
-
|
|
527
|
-
VECTOR: renderPlaceholder,
|
|
677
|
+
VECTOR: renderVector,
|
|
528
678
|
LINE: renderLine,
|
|
529
679
|
STAR: renderPlaceholder,
|
|
530
680
|
POLYGON: renderPlaceholder,
|
|
531
|
-
|
|
532
|
-
INSTANCE: renderPlaceholder,
|
|
681
|
+
INSTANCE: renderInstance,
|
|
533
682
|
};
|
|
534
683
|
|
|
535
684
|
function renderNode(deck, node) {
|
package/lib/template-deck.mjs
CHANGED
|
@@ -298,13 +298,41 @@ function describeLayout(deck, layout, row) {
|
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Walk the node tree like deck.walkTree, but follow INSTANCE → SYMBOL links.
|
|
303
|
+
* When an INSTANCE node is encountered, its referenced SYMBOL's children are
|
|
304
|
+
* also walked so that slots inside published template components are discovered.
|
|
305
|
+
*/
|
|
306
|
+
function walkTreeThroughInstances(deck, rootId, visitor, depth = 0, visited = new Set()) {
|
|
307
|
+
if (!rootId || visited.has(rootId)) return;
|
|
308
|
+
visited.add(rootId);
|
|
309
|
+
|
|
310
|
+
const node = deck.getNode(rootId);
|
|
311
|
+
if (!node || node.phase === 'REMOVED') return;
|
|
312
|
+
visitor(node, depth);
|
|
313
|
+
|
|
314
|
+
// Follow INSTANCE → SYMBOL: walk the SYMBOL's children
|
|
315
|
+
if (node.type === 'INSTANCE' && node.symbolData?.symbolID) {
|
|
316
|
+
const sid = node.symbolData.symbolID;
|
|
317
|
+
const symNid = `${sid.sessionID}:${sid.localID}`;
|
|
318
|
+
for (const child of deck.getChildren(symNid)) {
|
|
319
|
+
walkTreeThroughInstances(deck, nid(child), visitor, depth + 1, visited);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Walk direct children
|
|
324
|
+
for (const child of deck.getChildren(rootId)) {
|
|
325
|
+
walkTreeThroughInstances(deck, nid(child), visitor, depth + 1, visited);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
301
329
|
function discoverSlots(deck, rootId) {
|
|
302
330
|
const explicitTextSlots = [];
|
|
303
331
|
const explicitImageSlots = [];
|
|
304
332
|
const fallbackTextSlots = [];
|
|
305
333
|
const fallbackImageSlots = [];
|
|
306
334
|
|
|
307
|
-
deck
|
|
335
|
+
walkTreeThroughInstances(deck, rootId, node => {
|
|
308
336
|
const textSlotName = parsePrefixedName(node.name, TEXT_SLOT_PREFIX);
|
|
309
337
|
if (textSlotName) {
|
|
310
338
|
const slot = describeTextSlot(node, textSlotName, 'explicit');
|
package/manifest.json
CHANGED
package/package.json
CHANGED