figmatk 0.3.9 → 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.
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "name": "figmatk",
15
15
  "description": "Swiss Army Knife for Figma Files (.deck)",
16
- "version": "0.3.9",
16
+ "version": "0.3.11",
17
17
  "author": {
18
18
  "name": "FigmaTK Contributors"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figmatk",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Create and edit Figma Slides .deck files programmatically — no Figma API required",
5
5
  "author": {
6
6
  "name": "FigmaTK Contributors"
@@ -519,27 +519,47 @@ function renderVector(deck, node) {
519
519
  const blobs = deck.message?.blobs;
520
520
  const parts = [];
521
521
 
522
- // Fill paths
522
+ // Fill paths — combine all fillGeometry entries into one compound path so
523
+ // overlapping sub-paths interact via fill-rule (e.g. QR codes, letter cutouts).
523
524
  const fillColor = resolveFill(getFillPaints(node));
524
525
  if (fillColor && node.fillGeometry?.length && blobs) {
526
+ const segments = [];
527
+ let hasEvenOdd = false;
525
528
  for (const geo of node.fillGeometry) {
526
529
  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
+ if (d) segments.push(d);
531
+ if (geo.windingRule === 'EVENODD') hasEvenOdd = true;
532
+ }
533
+ if (segments.length) {
534
+ // Multiple sub-paths: use evenodd so overlapping regions create holes
535
+ const rule = (segments.length > 1 || hasEvenOdd) ? ' fill-rule="evenodd"' : '';
536
+ parts.push(`<path d="${segments.join('')}" fill="${fillColor}"${rule}/>`);
530
537
  }
531
538
  }
532
539
 
533
- // Stroke paths
540
+ // Stroke paths — also compound (stroke outlines are pre-expanded fill shapes)
534
541
  const strokeColor = resolveFill(node.strokePaints);
535
542
  const sw = node.strokeWeight ?? 0;
536
543
  if (strokeColor && sw > 0 && node.strokeGeometry?.length && blobs) {
544
+ const segments = [];
545
+ let hasEvenOdd = false;
537
546
  for (const geo of node.strokeGeometry) {
538
547
  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}/>`);
548
+ if (d) segments.push(d);
549
+ if (geo.windingRule === 'EVENODD') hasEvenOdd = true;
550
+ }
551
+ if (segments.length) {
552
+ const rule = (segments.length > 1 || hasEvenOdd) ? ' fill-rule="evenodd"' : '';
553
+ parts.push(`<path d="${segments.join('')}" fill="${strokeColor}"${rule}/>`);
554
+ }
555
+ }
556
+
557
+ // Fallback: decode vectorNetworkBlob when no pre-computed fill/strokeGeometry
558
+ if (!parts.length && node.vectorData?.vectorNetworkBlob != null && blobs) {
559
+ const vnbD = decodeVnb(blobs, node.vectorData.vectorNetworkBlob, node.vectorData.normalizedSize, node.size);
560
+ if (vnbD) {
561
+ const color = fillColor ?? resolveFill(node.strokePaints) ?? '#000000';
562
+ parts.push(`<path d="${vnbD}" fill="${color}" fill-rule="evenodd"/>`);
543
563
  }
544
564
  }
545
565
 
@@ -594,6 +614,103 @@ function decodeCmdBlob(blobs, blobIdx) {
594
614
 
595
615
  function f(v) { return +v.toFixed(2); }
596
616
 
617
+ /**
618
+ * Decode vectorNetworkBlob into an SVG path d-string.
619
+ * VNB stores vertices, segments (lines/cubics), and regions (loops of segment indices).
620
+ * Coordinates are in normalizedSize space and must be scaled to nodeSize.
621
+ *
622
+ * Binary layout (all little-endian):
623
+ * Header: numVertices(u32), numSegments(u32), numRegions(u32), numStyles(u32)
624
+ * Vertices: x(f32), y(f32), handleMirroring(u32) — 12 bytes each
625
+ * Segments: startVertex(u32), tangentStartX(f32), tangentStartY(f32),
626
+ * endVertex(u32), tangentEndX(f32), tangentEndY(f32), segType(u32) — 28 bytes each
627
+ * Regions: numLoops(u32), per loop: segCount(u32) + segIndices(u32[segCount]), windingRule(u32)
628
+ */
629
+ function decodeVnb(blobs, blobIdx, normalizedSize, nodeSize) {
630
+ const buf = blobToBuffer(blobs, blobIdx);
631
+ if (!buf || buf.length < 16) return null;
632
+
633
+ const scaleX = (nodeSize?.x ?? 1) / (normalizedSize?.x ?? 1);
634
+ const scaleY = (nodeSize?.y ?? 1) / (normalizedSize?.y ?? 1);
635
+
636
+ let off = 0;
637
+ const numVerts = buf.readUInt32LE(off); off += 4;
638
+ const numSegs = buf.readUInt32LE(off); off += 4;
639
+ const numRegions = buf.readUInt32LE(off); off += 4;
640
+ off += 4; // numStyles
641
+
642
+ // Parse vertices
643
+ const verts = [];
644
+ for (let i = 0; i < numVerts; i++) {
645
+ const x = buf.readFloatLE(off) * scaleX; off += 4;
646
+ const y = buf.readFloatLE(off) * scaleY; off += 4;
647
+ off += 4; // handleMirroring
648
+ verts.push({ x, y });
649
+ }
650
+
651
+ // Parse segments
652
+ const segs = [];
653
+ for (let i = 0; i < numSegs; i++) {
654
+ const sv = buf.readUInt32LE(off); off += 4;
655
+ const tsx = buf.readFloatLE(off) * scaleX; off += 4;
656
+ const tsy = buf.readFloatLE(off) * scaleY; off += 4;
657
+ const ev = buf.readUInt32LE(off); off += 4;
658
+ const tex = buf.readFloatLE(off) * scaleX; off += 4;
659
+ const tey = buf.readFloatLE(off) * scaleY; off += 4;
660
+ const type = buf.readUInt32LE(off); off += 4;
661
+ segs.push({ sv, tsx, tsy, ev, tex, tey, type });
662
+ }
663
+
664
+ // Parse regions → build SVG paths
665
+ const cmds = [];
666
+ for (let r = 0; r < numRegions; r++) {
667
+ if (off + 4 > buf.length) break;
668
+ const numLoops = buf.readUInt32LE(off); off += 4;
669
+ for (let loop = 0; loop < numLoops; loop++) {
670
+ if (off + 4 > buf.length) break;
671
+ const segCount = buf.readUInt32LE(off); off += 4;
672
+ for (let s = 0; s < segCount; s++) {
673
+ if (off + 4 > buf.length) break;
674
+ const segIdx = buf.readUInt32LE(off); off += 4;
675
+ if (segIdx >= segs.length) continue;
676
+ const seg = segs[segIdx];
677
+ const start = verts[seg.sv];
678
+ const end = verts[seg.ev];
679
+ if (!start || !end) continue;
680
+
681
+ if (s === 0) cmds.push(`M${f(start.x)},${f(start.y)}`);
682
+
683
+ if (seg.type === 0) {
684
+ // Line
685
+ cmds.push(`L${f(end.x)},${f(end.y)}`);
686
+ } else {
687
+ // Cubic bezier — tangents are relative to their vertex
688
+ const c1x = start.x + seg.tsx;
689
+ const c1y = start.y + seg.tsy;
690
+ const c2x = end.x + seg.tex;
691
+ const c2y = end.y + seg.tey;
692
+ cmds.push(`C${f(c1x)},${f(c1y)} ${f(c2x)},${f(c2y)} ${f(end.x)},${f(end.y)}`);
693
+ }
694
+ }
695
+ cmds.push('Z');
696
+ }
697
+ off += 4; // windingRule
698
+ }
699
+
700
+ return cmds.length ? cmds.join('') : null;
701
+ }
702
+
703
+ function blobToBuffer(blobs, blobIdx) {
704
+ if (blobIdx == null || !blobs?.[blobIdx]) return null;
705
+ const raw = blobs[blobIdx].bytes ?? blobs[blobIdx];
706
+ if (!raw) return null;
707
+ if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) return Buffer.from(raw);
708
+ const len = Object.keys(raw).length;
709
+ const buf = Buffer.alloc(len);
710
+ for (let i = 0; i < len; i++) buf[i] = raw[i];
711
+ return buf;
712
+ }
713
+
597
714
  function renderPlaceholder(deck, node) {
598
715
  const { x, y } = pos(node);
599
716
  const { w, h } = size(node);
@@ -653,13 +770,19 @@ function renderInstance(deck, node) {
653
770
  }
654
771
  }
655
772
 
773
+ // Render SYMBOL's own fill as background (e.g. dark-blue cover slide)
774
+ const { w, h } = size(symbol.size ? symbol : node);
775
+ const rx = Math.min(symbol.cornerRadius ?? 0, w / 2, h / 2);
776
+ let { defs, bg } = renderRoundedRectFillStack(deck, getFillPaints(symbol), w, h, rx);
777
+
656
778
  const inner = childrenSvg(deck, symbol);
657
779
 
658
780
  // Restore mutations
659
781
  for (const fn of restores) fn();
660
782
 
661
- if (!inner) return '';
662
- return `<g transform="translate(${x},${y})">\n${inner}\n</g>`;
783
+ const parts = [defs, bg, inner].filter(Boolean).join('\n');
784
+ if (!parts) return '';
785
+ return `<g transform="translate(${x},${y})">\n${parts}\n</g>`;
663
786
  }
664
787
 
665
788
  // ── Dispatcher ────────────────────────────────────────────────────────────────
@@ -684,7 +807,12 @@ const RENDERERS = {
684
807
  function renderNode(deck, node) {
685
808
  if (node.phase === 'REMOVED') return '';
686
809
  const fn = RENDERERS[node.type] ?? renderPlaceholder;
687
- return fn(deck, node);
810
+ let svg = fn(deck, node);
811
+ const op = node.opacity;
812
+ if (svg && op != null && op < 1) {
813
+ svg = `<g opacity="${op}">${svg}</g>`;
814
+ }
815
+ return svg;
688
816
  }
689
817
 
690
818
  function childrenSvg(deck, node) {
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.2",
3
3
  "name": "figmatk",
4
- "version": "0.3.9",
4
+ "version": "0.3.11",
5
5
  "description": "Create and edit Figma Slides .deck files programmatically - no Figma API required",
6
6
  "author": {
7
7
  "name": "FigmaTK Contributors"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figmatk",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Figma Toolkit — Swiss-army knife CLI for Figma .deck and .fig files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@ description: >
6
6
  clone or remove slides, or produce a .deck file for Figma Slides.
7
7
  Powered by FigmaTK under the hood.
8
8
  metadata:
9
- version: "0.3.9"
9
+ version: "0.3.11"
10
10
  ---
11
11
 
12
12
  # FigmaTK Skill
@@ -6,7 +6,7 @@ description: >
6
6
  template from an existing deck, define reusable layouts, mark editable
7
7
  text/image slots, or prepare a draft template for later instantiation.
8
8
  metadata:
9
- version: "0.3.9"
9
+ version: "0.3.11"
10
10
  ---
11
11
 
12
12
  # Figma Template Builder