From 60bd07466f9bcd75de533f64b7da13fcf57426b9 Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Sat, 11 Apr 2026 14:06:43 +0700
Subject: [PATCH 1/5] feat: render library component instances from Figma
clipboard
Library components (e.g. iOS status bars from Apple's UI Kit) were
invisible after pasting from Figma because their derivedSymbolData
contains rendering hints (fill geometry paths, glyph outlines, color
overrides) instead of full node trees.
- Add binary vector command blob decoder (MoveTo, LineTo, CubicBezier)
- Detect rendering-hint format derivedSymbolData (guidPath vs guid)
- Build approximate SVG from decoded geometry as generic fallback
- Add clean HTML/CSS renderer for iOS status bars with system icons
- Extract container background fills from unused symbolOverrides
- Embed derived content in figmaToHtml.js for INSTANCE nodes
---
src/utils/figmaToHtml.js | 7 +
src/utils/parseFigmaClipboard.js | 500 ++++++++++++++++++++++++++++++-
2 files changed, 506 insertions(+), 1 deletion(-)
diff --git a/src/utils/figmaToHtml.js b/src/utils/figmaToHtml.js
index bb6bccd..a4d92f4 100644
--- a/src/utils/figmaToHtml.js
+++ b/src/utils/figmaToHtml.js
@@ -548,6 +548,13 @@ function convertFrameNode(node, isRoot) {
styles.flexGrow = node.stackChildPrimaryGrow;
}
+ // Library component instances with no resolved children may carry a
+ // pre-built SVG from derivedSymbolData rendering hints.
+ if (node._derivedSvg && (!node.children || node.children.length === 0)) {
+ const inlineStyle = stylesToString(styles);
+ return `
\n${node._derivedSvg}\n
`;
+ }
+
// Render children
const childrenHtml = (node.children || [])
.map((child, i) => {
diff --git a/src/utils/parseFigmaClipboard.js b/src/utils/parseFigmaClipboard.js
index 5d3c0c3..cb28d28 100644
--- a/src/utils/parseFigmaClipboard.js
+++ b/src/utils/parseFigmaClipboard.js
@@ -203,6 +203,479 @@ function resolveNestedInstances(nodes, guidToKiwi, message, depth = 0) {
}
}
+// ---------------------------------------------------------------------------
+// Derived rendering: decode fill/stroke geometry blobs and glyph outlines
+// from library component instances whose derivedSymbolData contains rendering
+// hints (guidPath + fillGeometry/derivedTextData) instead of full nodeChanges.
+// ---------------------------------------------------------------------------
+
+/**
+ * Decode a Figma vector commands blob into an SVG path `d` string.
+ *
+ * Binary format: sequence of (command_byte, float32-LE params).
+ * 1 = MoveTo (x, y) — 2 floats
+ * 2 = LineTo (x, y) — 2 floats
+ * 4 = CubicBezier (c1x, c1y, c2x, c2y, ex, ey) — 6 floats
+ * Close is encoded as MoveTo with a sentinel x (|x| > 1e15).
+ *
+ * Glyph outline blobs start with a 0x00 header byte before the first command.
+ * Fill/stroke geometry blobs start directly with a command byte.
+ */
+function decodeFigmaCommandsBlob(blobBytes) {
+ if (!blobBytes?.length) return "";
+ const view = new DataView(blobBytes.buffer, blobBytes.byteOffset, blobBytes.byteLength);
+ let offset = 0;
+ let pathD = "";
+
+ // Glyph blobs start with 0x00 header; skip it
+ if (blobBytes[0] === 0x00 && blobBytes.length > 1 && [1, 2, 4].includes(blobBytes[1])) {
+ offset = 1;
+ }
+
+ while (offset < blobBytes.length) {
+ const cmd = blobBytes[offset];
+ offset++;
+
+ if (cmd === 1) {
+ if (offset + 8 > blobBytes.length) break;
+ const x = view.getFloat32(offset, true); offset += 4;
+ const y = view.getFloat32(offset, true); offset += 4;
+ if (Math.abs(x) > 1e15) { pathD += "Z "; continue; }
+ pathD += `M${fmt(x)} ${fmt(y)} `;
+ } else if (cmd === 2) {
+ if (offset + 8 > blobBytes.length) break;
+ const x = view.getFloat32(offset, true); offset += 4;
+ const y = view.getFloat32(offset, true); offset += 4;
+ pathD += `L${fmt(x)} ${fmt(y)} `;
+ } else if (cmd === 4) {
+ if (offset + 24 > blobBytes.length) break;
+ const c = [];
+ for (let i = 0; i < 6; i++) { c.push(view.getFloat32(offset, true)); offset += 4; }
+ pathD += `C${c.map(fmt).join(" ")} `;
+ } else {
+ break;
+ }
+ }
+ return pathD.trim();
+}
+
+/** Round to 2 decimal places for compact SVG output. */
+function fmt(n) {
+ return Math.round(n * 100) / 100;
+}
+
+/**
+ * Compute the bounding box of an SVG path `d` string by extracting all
+ * numeric coordinate pairs.
+ */
+function pathBoundingBox(pathD) {
+ const nums = pathD.match(/[-+]?\d*\.?\d+/g)?.map(Number) || [];
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let i = 0; i < nums.length - 1; i += 2) {
+ const x = nums[i], y = nums[i + 1];
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ if (!isFinite(minX)) return { x: 0, y: 0, w: 0, h: 0 };
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
+}
+
+/**
+ * Check if derivedSymbolData entries are rendering hints (guidPath format)
+ * rather than full nodeChange objects (guid + type format).
+ */
+function isDerivedRenderingHints(derivedNCs) {
+ if (!derivedNCs?.length) return false;
+ // Rendering hints have guidPath and either fillGeometry or derivedTextData.
+ // Full nodeChanges have guid and type.
+ return derivedNCs[0].guidPath && !derivedNCs[0].guid;
+}
+
+// ---------------------------------------------------------------------------
+// iOS Status Bar: detect and render a clean HTML status bar for known system
+// components. Produces much higher fidelity than the generic SVG path fallback.
+// ---------------------------------------------------------------------------
+
+/** Standard iOS signal bars icon (4 bars ascending). */
+const IOS_SIGNAL_SVG = ``;
+
+/** Standard iOS WiFi icon. */
+const IOS_WIFI_SVG = ``;
+
+/** Standard iOS battery icon. */
+const IOS_BATTERY_SVG = ``;
+
+/**
+ * Detect whether a library component INSTANCE is an iOS status bar.
+ * Checks component name, instance dimensions, and text content.
+ */
+function isIOSStatusBar(kiwiInstance, derivedNCs) {
+ const name = (kiwiInstance.name || "").toLowerCase();
+ const w = kiwiInstance.size?.x ?? 0;
+ const h = kiwiInstance.size?.y ?? 0;
+
+ // Height check: iOS status bars are 44-54px tall
+ if (h < 40 || h > 60) return false;
+
+ // Width check: at least 300px (iPhone width range)
+ if (w < 300) return false;
+
+ // Name heuristic: common iOS status bar component names
+ const nameHints = ["status", "top bar", "navigator", "statusbar", "status bar"];
+ const nameMatch = nameHints.some((hint) => name.includes(hint));
+
+ // Text content: look for a time pattern (H:MM or HH:MM)
+ let hasTimeText = false;
+ for (const entry of derivedNCs) {
+ if (entry.derivedTextData?.glyphs?.length >= 3 && entry.derivedTextData.glyphs.length <= 5) {
+ // 3-5 glyphs matches patterns like "9:41" or "12:00"
+ hasTimeText = true;
+ }
+ }
+
+ // Need at least name match OR time text, plus fill geometry for icons
+ const hasFillGeometry = derivedNCs.some((e) => e.fillGeometry?.length > 0);
+ return (nameMatch || hasTimeText) && hasFillGeometry;
+}
+
+/**
+ * Render a clean HTML iOS status bar using the text and colors extracted
+ * from derivedSymbolData.
+ */
+function buildIOSStatusBarHtml(kiwiInstance, derivedNCs, _message) {
+ const instW = kiwiInstance.size?.x ?? 393;
+ const instH = kiwiInstance.size?.y ?? 44;
+
+ // Extract time text from derivedTextData glyphs
+ let timeText = "9:41";
+ for (const entry of derivedNCs) {
+ if (!entry.derivedTextData?.glyphs?.length) continue;
+ // Glyphs have firstCharacter index — reconstruct text
+ // The characters aren't stored directly; we infer from glyph count and layout.
+ // Common times: "9:41" (4 glyphs), "12:00" (5 glyphs)
+ const glyphCount = entry.derivedTextData.glyphs.length;
+ if (glyphCount >= 3 && glyphCount <= 5) {
+ // Use glyph count to guess format; the actual characters come from
+ // the component's text content which we can't read. Default to "9:41".
+ timeText = glyphCount === 5 ? "12:00" : "9:41";
+ break;
+ }
+ }
+
+ // Extract foreground color from symbolOverrides
+ let fgColor = "#000";
+ let bgColor = "#fff";
+ const derivedGpKeys = new Set();
+ for (const entry of derivedNCs) {
+ if (entry.guidPath?.guids) {
+ derivedGpKeys.add(entry.guidPath.guids.map((g) => `${g.sessionID}:${g.localID}`).join("/"));
+ }
+ }
+
+ for (const ov of kiwiInstance.symbolData?.symbolOverrides || []) {
+ if (!ov.guidPath?.guids?.length) continue;
+ const key = ov.guidPath.guids.map((g) => `${g.sessionID}:${g.localID}`).join("/");
+ if (ov.fillPaints?.[0]?.color) {
+ const c = ov.fillPaints[0].color;
+ const rgb = figmaColorToRgb(c);
+ if (derivedGpKeys.has(key)) {
+ fgColor = rgb; // leaf element color → foreground
+ } else {
+ bgColor = rgb; // container color → background
+ }
+ }
+ }
+
+ return `` +
+ `
${timeText}
` +
+ `
` +
+ IOS_SIGNAL_SVG + IOS_WIFI_SVG + IOS_BATTERY_SVG +
+ `
`;
+}
+
+/**
+ * Build an SVG string for a library component instance using its
+ * derivedSymbolData rendering hints (fillGeometry, strokeGeometry,
+ * derivedTextData) and symbolOverride colors.
+ *
+ * The instance's internal layout (child positions) lives in the component
+ * master on Figma's servers and is not in the clipboard. We reconstruct
+ * approximate positions by grouping elements and distributing them across
+ * the instance width.
+ *
+ * @param {object} kiwiInstance - raw kiwi nodeChange for the INSTANCE
+ * @param {object} message - kiwi message object (has .blobs)
+ * @returns {string|null} SVG or HTML markup, or null if nothing to render
+ */
+function buildDerivedInstanceSvg(kiwiInstance, message) {
+ const derivedNCs = kiwiInstance.derivedSymbolData;
+ const instW = kiwiInstance.size?.x ?? 0;
+ const instH = kiwiInstance.size?.y ?? 0;
+ if (instW < 1 || instH < 1) return null;
+
+ // Collect guidPath keys from derivedSymbolData entries (leaf elements).
+ const derivedGpKeys = new Set();
+ for (const entry of derivedNCs) {
+ if (entry.guidPath?.guids) {
+ derivedGpKeys.add(entry.guidPath.guids.map((g) => `${g.sessionID}:${g.localID}`).join("/"));
+ }
+ }
+
+ // Build color override map: guidPath key → { fill, stroke }
+ // Also detect container background fills: overrides whose guidPath doesn't
+ // match any derivedSymbolData entry belong to intermediate container frames
+ // (e.g. the white background behind a dark-themed status bar).
+ const colorMap = new Map();
+ let containerBg = null;
+ for (const ov of kiwiInstance.symbolData?.symbolOverrides || []) {
+ if (!ov.guidPath?.guids?.length) continue;
+ const key = ov.guidPath.guids.map((g) => `${g.sessionID}:${g.localID}`).join("/");
+ const entry = colorMap.get(key) || {};
+ if (ov.fillPaints?.[0]?.color) entry.fill = figmaColorToRgb(ov.fillPaints[0].color);
+ if (ov.strokePaints?.[0]?.color) entry.stroke = figmaColorToRgb(ov.strokePaints[0].color);
+ colorMap.set(key, entry);
+
+ // Container background: fill override for a non-leaf node
+ if (!derivedGpKeys.has(key) && entry.fill) {
+ containerBg = entry.fill;
+ }
+ }
+
+ // Collect all visual elements
+ const elements = []; // { type: "shape"|"glyph", svgPath, bbox, gpKey, strokePath? }
+
+ for (const entry of derivedNCs) {
+ const gpKey = entry.guidPath?.guids?.map((g) => `${g.sessionID}:${g.localID}`).join("/") || "";
+
+ if (entry.derivedTextData) {
+ // Text glyphs: each glyph is an SVG path in em-relative coordinates,
+ // positioned by glyph.position and scaled by glyph.fontSize.
+ const td = entry.derivedTextData;
+ for (const glyph of td.glyphs || []) {
+ const blob = message.blobs?.[glyph.commandsBlob]?.bytes;
+ if (!blob) continue;
+ const rawPath = decodeFigmaCommandsBlob(blob);
+ if (!rawPath) continue;
+ const fontSize = glyph.fontSize || 16;
+ elements.push({
+ type: "glyph",
+ svgPath: rawPath,
+ fontSize,
+ glyphX: glyph.position?.x ?? 0,
+ glyphY: glyph.position?.y ?? 0,
+ bbox: pathBoundingBox(rawPath),
+ gpKey,
+ });
+ }
+ }
+
+ if (entry.fillGeometry) {
+ for (const fg of entry.fillGeometry) {
+ const blob = message.blobs?.[fg.commandsBlob]?.bytes;
+ if (!blob) continue;
+ const svgPath = decodeFigmaCommandsBlob(blob);
+ if (!svgPath) continue;
+ const bbox = pathBoundingBox(svgPath);
+ if (bbox.w < 0.5 && bbox.h < 0.5) continue;
+ // Check for matching stroke
+ let strokePath = null;
+ if (entry.strokeGeometry) {
+ for (const sg of entry.strokeGeometry) {
+ const sBlob = message.blobs?.[sg.commandsBlob]?.bytes;
+ if (sBlob) strokePath = decodeFigmaCommandsBlob(sBlob);
+ }
+ }
+ elements.push({ type: "shape", svgPath, bbox, gpKey, strokePath });
+ }
+ }
+ }
+
+ if (elements.length === 0) return null;
+
+ // Group consecutive elements by guidPath prefix similarity and bbox size.
+ // Elements from the same visual group (e.g. signal bars) tend to be
+ // consecutive in derivedSymbolData and have similar bounding box dimensions.
+ const groups = [];
+ let currentGroup = [];
+ let lastBboxArea = -1;
+
+ for (const el of elements) {
+ const area = el.bbox.w * el.bbox.h;
+ const isGlyph = el.type === "glyph";
+ const prevIsGlyph = currentGroup.length > 0 && currentGroup[0].type === "glyph";
+
+ // Start a new group when element type changes, or shape sizes diverge significantly
+ if (currentGroup.length > 0) {
+ const sameType = isGlyph === prevIsGlyph;
+ const sizeRatio = lastBboxArea > 0 ? area / lastBboxArea : 1;
+ const similarSize = sizeRatio > 0.2 && sizeRatio < 5;
+
+ if (!sameType || (!isGlyph && !similarSize)) {
+ groups.push(currentGroup);
+ currentGroup = [];
+ }
+ }
+ currentGroup.push(el);
+ lastBboxArea = area;
+ }
+ if (currentGroup.length > 0) groups.push(currentGroup);
+
+ // Compute each group's composite bounding box and arrange within the instance.
+ // Heuristic: distribute groups evenly across the instance width, centered vertically.
+ const groupMetas = groups.map((group) => {
+ let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
+ let totalW = 0;
+
+ if (group[0].type === "glyph") {
+ // Glyphs are positioned relative to the text node's origin
+ for (const el of group) {
+ const scaledW = el.bbox.w * el.fontSize;
+ const scaledH = el.bbox.h * el.fontSize;
+ const x = el.glyphX;
+ const y = el.glyphY - scaledH;
+ gMinX = Math.min(gMinX, x);
+ gMinY = Math.min(gMinY, y);
+ gMaxX = Math.max(gMaxX, x + scaledW);
+ gMaxY = Math.max(gMaxY, y + scaledH);
+ }
+ } else {
+ // Shapes: stack side by side with a small gap
+ const GAP = 2;
+ let cx = 0;
+ for (const el of group) {
+ gMinY = Math.min(gMinY, el.bbox.y);
+ gMaxY = Math.max(gMaxY, el.bbox.y + el.bbox.h);
+ cx += el.bbox.w + GAP;
+ }
+ totalW = Math.max(0, cx - GAP);
+ gMinX = 0;
+ gMaxX = totalW;
+ if (!isFinite(gMinY)) { gMinY = 0; gMaxY = 0; }
+ }
+
+ return {
+ group,
+ w: gMaxX - gMinX,
+ h: gMaxY - gMinY,
+ isText: group[0].type === "glyph",
+ };
+ });
+
+ // Position groups: text groups get centered, shape groups distributed left/right.
+ const textGroups = groupMetas.filter((g) => g.isText);
+ const shapeGroups = groupMetas.filter((g) => !g.isText);
+
+ // Split shape groups into left and right by cumulative width.
+ // Walk from the start, accumulating width. Once we exceed half the
+ // available space (instance width minus text and padding), switch to right.
+ const totalShapeW = shapeGroups.reduce((s, g) => s + g.w, 0);
+ const halfTarget = totalShapeW / 2;
+
+ const leftShapes = [];
+ const rightShapes = [];
+ let accW = 0;
+ for (const g of shapeGroups) {
+ if (accW + g.w / 2 < halfTarget) {
+ leftShapes.push(g);
+ } else {
+ rightShapes.push(g);
+ }
+ accW += g.w;
+ }
+
+ // Build SVG content
+ let svgContent = "";
+
+ // Container background from symbolOverrides (intermediate frame fills)
+ if (containerBg) {
+ svgContent += ``;
+ }
+
+ const PADDING = 8;
+ const vCenter = instH / 2;
+
+ // Render left-side shape groups
+ let xCursor = PADDING;
+ for (const meta of leftShapes) {
+ svgContent += renderShapeGroup(meta, xCursor, vCenter, colorMap);
+ xCursor += meta.w + PADDING;
+ }
+
+ // Render centered text groups
+ for (const meta of textGroups) {
+ const tx = (instW - meta.w) / 2;
+ svgContent += renderGlyphGroup(meta, tx, vCenter, colorMap);
+ }
+
+ // Render right-side shape groups
+ xCursor = instW - PADDING;
+ for (let i = rightShapes.length - 1; i >= 0; i--) {
+ const meta = rightShapes[i];
+ xCursor -= meta.w;
+ svgContent += renderShapeGroup(meta, xCursor, vCenter, colorMap);
+ xCursor -= PADDING;
+ }
+
+ return ``;
+}
+
+function renderGlyphGroup(meta, groupX, vCenter, colorMap) {
+ const { group, h } = meta;
+ let svg = "";
+ const yOffset = vCenter - h / 2;
+ const gpKey = group[0]?.gpKey || "";
+ const fill = colorMap.get(gpKey)?.fill || "#333";
+
+ for (const el of group) {
+ const scale = el.fontSize;
+ // Glyph Y in Figma is the baseline position. In SVG, we flip Y since
+ // glyph paths use Y-up coordinates (0 at baseline, positive up).
+ const tx = groupX + el.glyphX;
+ const ty = yOffset + el.glyphY;
+ svg += ``;
+ svg += ``;
+ svg += "";
+ }
+ return svg;
+}
+
+function renderShapeGroup(meta, groupX, vCenter, colorMap) {
+ const { group, h } = meta;
+ let svg = "";
+ const yOffset = vCenter - h / 2;
+ const GAP = 2;
+ let cx = groupX;
+
+ for (const el of group) {
+ const gpKey = el.gpKey || "";
+ const fill = colorMap.get(gpKey)?.fill || "#333";
+ const strokeColor = colorMap.get(gpKey)?.stroke;
+ const tx = cx - el.bbox.x;
+ const ty = yOffset + (h - el.bbox.h) - el.bbox.y;
+
+ svg += ``;
+ svg += ``;
+ if (el.strokePath && strokeColor) {
+ svg += ``;
+ }
+ svg += "";
+ cx += el.bbox.w + GAP;
+ }
+ return svg;
+}
+
+function figmaColorToRgb(color) {
+ if (!color) return null;
+ const r = Math.round((color.r ?? 0) * 255);
+ const g = Math.round((color.g ?? 0) * 255);
+ const b = Math.round((color.b ?? 0) * 255);
+ const a = color.a ?? 1;
+ if (a >= 1) return `rgb(${r},${g},${b})`;
+ return `rgba(${r},${g},${b},${a.toFixed(3)})`;
+}
+
function resolveSharedComponents(doc, captured) {
if (!captured?.derived?.size) return 0;
@@ -214,7 +687,32 @@ function resolveSharedComponents(doc, captured) {
const { rootChildren, guidToKiwi } =
buildDerivedTree(derivedNCs, message, instanceGuid);
- if (rootChildren.length === 0) continue;
+
+ if (rootChildren.length === 0) {
+ // Full node tree not available — this is a library component whose
+ // derivedSymbolData contains rendering hints (guidPath + fillGeometry /
+ // derivedTextData) instead of complete nodeChange objects.
+ // Build an approximate SVG rendering from the available geometry.
+ if (isDerivedRenderingHints(derivedNCs)) {
+ const instanceNode = findNodeInFigFile(doc._figFile, instanceGuid);
+ if (instanceNode) {
+ // Try clean HTML rendering for known system components first,
+ // fall back to generic SVG path reconstruction.
+ let content = null;
+ if (isIOSStatusBar(kiwiInstance, derivedNCs)) {
+ content = buildIOSStatusBarHtml(kiwiInstance, derivedNCs, message);
+ }
+ if (!content) {
+ content = buildDerivedInstanceSvg(kiwiInstance, message);
+ }
+ if (content) {
+ instanceNode._derivedSvg = content;
+ patchCount++;
+ }
+ }
+ }
+ continue;
+ }
// Apply text / visibility overrides from the instance
applySymbolOverrides(rootChildren, kiwiInstance);
From 6a049df9e7dfb8e1ea53c5cd98f3f2dd784f95d9 Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Sat, 11 Apr 2026 15:13:39 +0700
Subject: [PATCH 2/5] fix: render vector icons from Figma paste with proper
strokes and fills
The kiwi parser produces vectorNetwork data in a different format than
the REST API: vertices as {x, y} objects and segments as {start, end}
with nested vertex/tangent properties. The vectorNetworkToSvgPath
converter now detects and handles both formats, fixing icons that
previously rendered as blank colored squares.
Also preserves stroke properties (strokePaints, strokeWeight, strokeCap,
strokeJoin, fillPaints) from raw kiwi nodeChanges that the @grida/refig
factory drops, and renders proper SVG stroke attributes including
linecap, linejoin, fill-rule from winding rules, and overflow:visible
to prevent stroke clipping at viewBox edges.
---
src/utils/figmaToHtml.js | 55 ++++++++++++++++++++++++--------
src/utils/parseFigmaClipboard.js | 17 ++++++++++
2 files changed, 59 insertions(+), 13 deletions(-)
diff --git a/src/utils/figmaToHtml.js b/src/utils/figmaToHtml.js
index a4d92f4..d540a68 100644
--- a/src/utils/figmaToHtml.js
+++ b/src/utils/figmaToHtml.js
@@ -311,33 +311,46 @@ function vectorNetworkToSvgPath(network) {
const { vertices, segments } = network;
if (!segments?.length || !vertices?.length) return "";
+ // Detect format: kiwi uses {x, y} objects; REST API uses [x, y] arrays.
+ const isKiwi = typeof vertices[0]?.x === "number";
+
+ const vx = (v) => (isKiwi ? v.x : v[0]);
+ const vy = (v) => (isKiwi ? v.y : v[1]);
+
const parts = [];
let currentStart = null;
let previousEnd = null;
for (const seg of segments) {
- const { a, b, ta, tb } = seg;
+ // Kiwi: { start: { vertex, dx, dy }, end: { vertex, dx, dy } }
+ // REST: { a, b, ta: [dx, dy], tb: [dx, dy] }
+ const a = isKiwi ? seg.start.vertex : seg.a;
+ const b = isKiwi ? seg.end.vertex : seg.b;
+ const ta0 = isKiwi ? (seg.start.dx ?? 0) : (seg.ta?.[0] ?? 0);
+ const ta1 = isKiwi ? (seg.start.dy ?? 0) : (seg.ta?.[1] ?? 0);
+ const tb0 = isKiwi ? (seg.end.dx ?? 0) : (seg.tb?.[0] ?? 0);
+ const tb1 = isKiwi ? (seg.end.dy ?? 0) : (seg.tb?.[1] ?? 0);
+
const start = vertices[a];
const end = vertices[b];
if (!start || !end) continue;
if (previousEnd !== a) {
- parts.push(`M${fmt(start[0])} ${fmt(start[1])}`);
+ parts.push(`M${fmt(vx(start))} ${fmt(vy(start))}`);
currentStart = a;
}
- const noTangents =
- (ta[0] === 0 && ta[1] === 0 && tb[0] === 0 && tb[1] === 0);
+ const noTangents = (ta0 === 0 && ta1 === 0 && tb0 === 0 && tb1 === 0);
if (noTangents) {
- parts.push(`L${fmt(end[0])} ${fmt(end[1])}`);
+ parts.push(`L${fmt(vx(end))} ${fmt(vy(end))}`);
} else {
- const c1x = start[0] + ta[0];
- const c1y = start[1] + ta[1];
- const c2x = end[0] + tb[0];
- const c2y = end[1] + tb[1];
+ const c1x = vx(start) + ta0;
+ const c1y = vy(start) + ta1;
+ const c2x = vx(end) + tb0;
+ const c2y = vy(end) + tb1;
parts.push(
- `C${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end[0])} ${fmt(end[1])}`
+ `C${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(vx(end))} ${fmt(vy(end))}`
);
}
@@ -598,16 +611,21 @@ function convertShapeNode(node) {
}
// Fill color
+ // Figma explicitly sets fills=[] when a node has no fill (stroke-only icons).
+ // Use "none" for empty arrays; only fall back to "currentColor" when fills
+ // is undefined (possible inherited fill from boolean operation parent).
const fills = node.fillPaints ?? node.fills;
- let fillColor = "currentColor";
+ let fillColor = "none";
if (fills?.length) {
const solidFill = fills.find((f) => f.type === "SOLID" && f.visible !== false);
if (solidFill) {
fillColor = figmaColorToCss(solidFill.color, solidFill.opacity);
}
+ } else if (fills == null) {
+ fillColor = "currentColor";
}
- // Stroke color
+ // Stroke color + linecap/linejoin
const strokes = node.strokePaints ?? node.strokes;
const strokeWeight = node.strokeWeight ?? 0;
let strokeAttr = "";
@@ -616,11 +634,22 @@ function convertShapeNode(node) {
if (solidStroke) {
const strokeColor = figmaColorToCss(solidStroke.color, solidStroke.opacity);
strokeAttr = ` stroke="${strokeColor}" stroke-width="${strokeWeight}"`;
+ // Stroke line cap and join
+ const cap = node.strokeCap;
+ if (cap === "ROUND") strokeAttr += ` stroke-linecap="round"`;
+ else if (cap === "SQUARE") strokeAttr += ` stroke-linecap="square"`;
+ const join = node.strokeJoin;
+ if (join === "ROUND") strokeAttr += ` stroke-linejoin="round"`;
+ else if (join === "BEVEL") strokeAttr += ` stroke-linejoin="bevel"`;
}
}
+ // Use fill-rule from vectorNetwork regions if available
+ const windingRule = node.vectorNetwork?.regions?.[0]?.windingRule;
+ const fillRuleAttr = windingRule === "EVENODD" ? ` fill-rule="evenodd"` : "";
+
const wrapStyle = stylesToString(styles);
- return ``;
+ return ``;
}
// Fallback: render as a colored div (no vector path data available)
diff --git a/src/utils/parseFigmaClipboard.js b/src/utils/parseFigmaClipboard.js
index cb28d28..77cb73e 100644
--- a/src/utils/parseFigmaClipboard.js
+++ b/src/utils/parseFigmaClipboard.js
@@ -32,6 +32,17 @@ const KIWI_LAYOUT_PROPS = [
"stackPrimarySizing",
];
+// Stroke & vector properties from kiwi that the factory drops.
+// Needed for proper stroke-based icon rendering in figmaToHtml.js.
+const KIWI_STROKE_PROPS = [
+ "strokePaints",
+ "strokeWeight",
+ "strokeCap",
+ "strokeJoin",
+ "strokeAlign",
+ "fillPaints",
+];
+
iofigma.kiwi.factory.node = function (nc, message) {
if (captureState) {
captureState.message = message;
@@ -49,6 +60,12 @@ iofigma.kiwi.factory.node = function (nc, message) {
node[prop] = nc[prop];
}
}
+ // Preserve stroke properties for vector/icon rendering
+ for (const prop of KIWI_STROKE_PROPS) {
+ if (nc[prop] != null && node[prop] == null) {
+ node[prop] = nc[prop];
+ }
+ }
}
return node;
From 6fe68ab5240faf976abbdde3eff52a52f1ff85b9 Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Sat, 11 Apr 2026 15:23:44 +0700
Subject: [PATCH 3/5] fix: prevent text from wrapping unexpectedly in Figma
paste
Preserve textAutoResize from kiwi nodeChanges (dropped by @grida/refig
factory) and use it in figmaToHtml to control text sizing:
- WIDTH_AND_HEIGHT (auto-width): white-space nowrap, no fixed width
- HEIGHT (fixed-width): add 1px buffer for CSS font metric differences
---
src/utils/figmaToHtml.js | 15 ++++++++++++++-
src/utils/parseFigmaClipboard.js | 21 +++++++++++++++++++++
2 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/src/utils/figmaToHtml.js b/src/utils/figmaToHtml.js
index d540a68..ca730df 100644
--- a/src/utils/figmaToHtml.js
+++ b/src/utils/figmaToHtml.js
@@ -394,8 +394,21 @@ function convertTextNode(node, isRoot) {
const { width, height } = getNodeSize(node);
const styles = {};
+ // textAutoResize controls Figma's text sizing behaviour:
+ // WIDTH_AND_HEIGHT — auto-size both axes (single-line, never wraps)
+ // HEIGHT — fixed width, auto-height (wraps at width)
+ // NONE / TRUNCATE — fully fixed size
+ const autoResize = node.textAutoResize;
+
if (!isRoot) {
- styles.width = `${Math.ceil(width)}px`;
+ if (autoResize === "WIDTH_AND_HEIGHT") {
+ // Auto-width text: don't constrain width, prevent wrapping
+ styles.whiteSpace = "nowrap";
+ styles.flexShrink = "0";
+ } else {
+ // Fixed-width text: add 1px buffer for font metric differences
+ styles.width = `${Math.ceil(width) + 1}px`;
+ }
styles.minHeight = `${Math.ceil(height)}px`;
}
diff --git a/src/utils/parseFigmaClipboard.js b/src/utils/parseFigmaClipboard.js
index 77cb73e..8105eef 100644
--- a/src/utils/parseFigmaClipboard.js
+++ b/src/utils/parseFigmaClipboard.js
@@ -43,6 +43,12 @@ const KIWI_STROKE_PROPS = [
"fillPaints",
];
+// Text properties from kiwi that the factory drops.
+// Needed for correct text sizing/wrapping in figmaToHtml.js.
+const KIWI_TEXT_PROPS = [
+ "textAutoResize",
+];
+
iofigma.kiwi.factory.node = function (nc, message) {
if (captureState) {
captureState.message = message;
@@ -66,6 +72,12 @@ iofigma.kiwi.factory.node = function (nc, message) {
node[prop] = nc[prop];
}
}
+ // Preserve text properties for correct sizing/wrapping
+ for (const prop of KIWI_TEXT_PROPS) {
+ if (nc[prop] != null && node[prop] == null) {
+ node[prop] = nc[prop];
+ }
+ }
}
return node;
@@ -157,6 +169,15 @@ function buildDerivedTree(derivedNCs, message, parentGuid) {
return iofigma.kiwi.guid(kiwi.parentIndex.guid) === parentGuid;
});
+ // Sort root children by fractional position index (same as intermediate children above).
+ // Without this, root-level children of shared library instances retain arbitrary
+ // kiwi binary order, causing incorrect z-index assignment in figmaToHtml.
+ rootChildren.sort((a, b) => {
+ const aPos = guidToKiwi.get(a.id)?.parentIndex?.position ?? "";
+ const bPos = guidToKiwi.get(b.id)?.parentIndex?.position ?? "";
+ return aPos.localeCompare(bPos);
+ });
+
return { rootChildren, guidToNode, guidToKiwi };
}
From 0bea743e0d0e615c303bcd83ecafb5044aab8c5f Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Sat, 11 Apr 2026 15:44:22 +0700
Subject: [PATCH 4/5] fix: correct z-ordering of elements pasted from Figma
The @grida/refig library sorts Figma node children using
String.localeCompare(), but Figma's fractional index strings
(parentIndex.position) use mixed-case ASCII characters designed for
byte-level comparison. localeCompare is case-insensitive and
alphabetical, producing wrong order (e.g. "i" < "P" alphabetically,
but "P" < "i" in ASCII byte order). This scrambles both z-index for
absolutely-positioned children and visual flow for auto-layout children.
Fix by:
- Preserving kiwi parentIndex.position on each factory node
- Re-sorting the entire _figFile tree after parsing with byte-level
comparison (< / >) instead of localeCompare
- Using the same corrected comparator in buildDerivedTree for shared
library component children
---
src/utils/parseFigmaClipboard.js | 60 ++++++++++++++++++++++++++++----
1 file changed, 54 insertions(+), 6 deletions(-)
diff --git a/src/utils/parseFigmaClipboard.js b/src/utils/parseFigmaClipboard.js
index 8105eef..9523b2f 100644
--- a/src/utils/parseFigmaClipboard.js
+++ b/src/utils/parseFigmaClipboard.js
@@ -78,6 +78,13 @@ iofigma.kiwi.factory.node = function (nc, message) {
node[prop] = nc[prop];
}
}
+ // Preserve kiwi parentIndex.position for correct child re-sorting.
+ // The refig library sorts with localeCompare which produces wrong order
+ // for Figma's mixed-case ASCII fractional indices; resortFigFileChildren
+ // uses this value to re-sort with byte-level comparison.
+ if (nc.parentIndex?.position != null) {
+ node._kiwiParentPosition = nc.parentIndex.position;
+ }
}
return node;
@@ -93,6 +100,43 @@ function endCapture() {
return result;
}
+// Figma's fractional indices (parentIndex.position) use mixed-case ASCII
+// characters designed for byte-level comparison. The refig library sorts
+// children with localeCompare, which is locale-aware and case-insensitive,
+// producing wrong order (e.g. "i" < "P" alphabetically, but "P" < "i" in
+// ASCII). This comparator uses plain string comparison to match Figma's
+// intended ordering.
+function comparePosition(a, b) {
+ if (a < b) return -1;
+ if (a > b) return 1;
+ return 0;
+}
+
+// Re-sort all children in the _figFile tree using byte-level comparison
+// to fix the ordering produced by refig's localeCompare-based sort.
+function resortFigFileChildren(figFile) {
+ if (!figFile?.pages) return;
+ for (const page of figFile.pages) {
+ if (page.rootNodes) {
+ for (const node of page.rootNodes) {
+ resortNodeChildren(node);
+ }
+ }
+ }
+}
+
+function resortNodeChildren(node) {
+ if (!node?.children?.length) return;
+ node.children.sort((a, b) => {
+ const aPos = a._kiwiParentPosition ?? "";
+ const bPos = b._kiwiParentPosition ?? "";
+ return comparePosition(aPos, bPos);
+ });
+ for (const child of node.children) {
+ resortNodeChildren(child);
+ }
+}
+
// ---------------------------------------------------------------------------
// Shared-component resolution: process captured derivedSymbolData, build
// parent-child trees from the derived NodeChanges, and patch INSTANCE nodes
@@ -152,13 +196,14 @@ function buildDerivedTree(derivedNCs, message, parentGuid) {
}
});
- // Sort children by fractional position index
+ // Sort children by fractional position index (byte-level comparison, not
+ // localeCompare — Figma's fractional indices use mixed-case ASCII).
guidToNode.forEach((parent) => {
if (!parent.children?.length) return;
parent.children.sort((a, b) => {
const aPos = guidToKiwi.get(a.id)?.parentIndex?.position ?? "";
const bPos = guidToKiwi.get(b.id)?.parentIndex?.position ?? "";
- return aPos.localeCompare(bPos);
+ return comparePosition(aPos, bPos);
});
});
@@ -169,13 +214,11 @@ function buildDerivedTree(derivedNCs, message, parentGuid) {
return iofigma.kiwi.guid(kiwi.parentIndex.guid) === parentGuid;
});
- // Sort root children by fractional position index (same as intermediate children above).
- // Without this, root-level children of shared library instances retain arbitrary
- // kiwi binary order, causing incorrect z-index assignment in figmaToHtml.
+ // Sort root children by fractional position index.
rootChildren.sort((a, b) => {
const aPos = guidToKiwi.get(a.id)?.parentIndex?.position ?? "";
const bPos = guidToKiwi.get(b.id)?.parentIndex?.position ?? "";
- return aPos.localeCompare(bPos);
+ return comparePosition(aPos, bPos);
});
return { rootChildren, guidToNode, guidToKiwi };
@@ -868,6 +911,11 @@ export function parseFigmaFrames(buffer) {
}
}
+ // Re-sort all children using byte-level comparison to fix refig's
+ // localeCompare-based sort which produces wrong order for Figma's
+ // mixed-case ASCII fractional indices.
+ resortFigFileChildren(doc._figFile);
+
const figFile = doc._figFile;
const frames = [];
From bc10cbae4cd8ed2fcc98c74238625ac514da8d81 Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Sat, 11 Apr 2026 15:45:49 +0700
Subject: [PATCH 5/5] chore: bump mcp-server version to 1.1.1
---
mcp-server/package-lock.json | 4 ++--
mcp-server/package.json | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json
index 21bb8ec..3e1dee4 100644
--- a/mcp-server/package-lock.json
+++ b/mcp-server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "drawd-mcp-server",
- "version": "1.1.0",
+ "version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "drawd-mcp-server",
- "version": "1.1.0",
+ "version": "1.1.1",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
diff --git a/mcp-server/package.json b/mcp-server/package.json
index ae9dcdc..ab86a9a 100644
--- a/mcp-server/package.json
+++ b/mcp-server/package.json
@@ -1,6 +1,6 @@
{
"name": "drawd-mcp-server",
- "version": "1.1.0",
+ "version": "1.1.1",
"description": "MCP server for Drawd — AI agent flow builder. Create app flow designs programmatically with AI agents.",
"type": "module",
"bin": {