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": { diff --git a/src/utils/figmaToHtml.js b/src/utils/figmaToHtml.js index bb6bccd..ca730df 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))}` ); } @@ -381,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`; } @@ -548,6 +574,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) => { @@ -591,16 +624,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 = ""; @@ -609,11 +647,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 5d3c0c3..9523b2f 100644 --- a/src/utils/parseFigmaClipboard.js +++ b/src/utils/parseFigmaClipboard.js @@ -32,6 +32,23 @@ 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", +]; + +// 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; @@ -49,6 +66,25 @@ 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]; + } + } + // 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]; + } + } + // 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; @@ -64,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 @@ -123,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); }); }); @@ -140,6 +214,13 @@ function buildDerivedTree(derivedNCs, message, parentGuid) { return iofigma.kiwi.guid(kiwi.parentIndex.guid) === parentGuid; }); + // 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 comparePosition(aPos, bPos); + }); + return { rootChildren, guidToNode, guidToKiwi }; } @@ -203,6 +284,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 `${svgContent}`; +} + +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 +768,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); @@ -332,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 = [];