figma.showUI(__html__, { width: 320, height: 180 }); const NODE_LIMIT = 500; async function runExport() { const selection = figma.currentPage.selection; if (selection.length === 0) { figma.ui.postMessage({ type: 'error', message: 'Please select a frame or node.' }); return; } let nodeCount = 0; function countNodes(node: SceneNode) { nodeCount++; if ("children" in node) { for (const child of node.children) countNodes(child); } } for (const node of selection) countNodes(node); if (nodeCount > NODE_LIMIT) { figma.ui.postMessage({ type: 'error', message: `Selection exceeds limit. Selected: ${nodeCount}. Limit: ${NODE_LIMIT}.` }); return; } let markdownOutput = "# AI DATA EXTRACTION GUIDE\n\n"; markdownOutput += "This archive contains structural design data and visual assets extracted from Figma specifically for machine learning interpretation.\n\n"; markdownOutput += "## Structural Architecture\n"; markdownOutput += "- data.md: This document contains the hierarchical tree of design nodes, text strings, and layout properties.\n"; markdownOutput += "- Folders: Named after the top-level selected nodes, containing the associated PNG and SVG image files.\n\n"; markdownOutput += "## Interpretation Rules for AI\n"; markdownOutput += "1. Tree Hierarchy: Indentation levels indicate parent-child node relationships.\n"; markdownOutput += "2. Asset Mapping: Visual exports (PNG and SVG formats) are located inside the corresponding top-level folder. Nodes are exported visually if they contain photographic fills or child text layers requiring structural context.\n"; markdownOutput += "3. Text Handling: Independent text nodes skip visual image export because their complete textual data is captured inline within this document.\n"; markdownOutput += "4. Property Metadata: The Raw Properties object provides precise dimensions, positions, fills, and styling definitions parsed directly from the Figma canvas API.\n\n"; markdownOutput += "--- \n\n"; markdownOutput += "## Hierarchical Tree Data\n\n"; const exportedImages: Array<{ folder: string; name: string; data: Uint8Array }> = []; const allFolders: string[] = []; function getSafeName(node: SceneNode): string { let cleanName = node.name.replace(/[^a-z0-9]/gi, '_'); if (cleanName.length > 30) { cleanName = cleanName.substring(0, 30); } const cleanId = node.id.replace(/[^a-z0-9]/gi, '_'); return `${cleanName}_${cleanId}`; } function hasText(node: SceneNode): boolean { if (node.type === "TEXT") return true; if ("children" in node) { for (const child of node.children) { if (hasText(child)) return true; } } return false; } function cleanMixed(val: any): any { if (val === figma.mixed) return "MIXED"; return val; } function cleanRawProps(props: any): any { const cleaned: any = {}; for (const key of Object.keys(props)) { const val = props[key]; if (val === null || val === undefined) { continue; } if (Array.isArray(val) && val.length === 0) { continue; } if (key === "opacity" && val === 1) continue; if (key === "rotation" && val === 0) continue; if (key === "cornerRadius" && val === 0) continue; if (key === "topLeftRadius" && val === 0) continue; if (key === "topRightRadius" && val === 0) continue; if (key === "bottomLeftRadius" && val === 0) continue; if (key === "bottomRightRadius" && val === 0) continue; if (key === "paddingLeft" && val === 0) continue; if (key === "paddingRight" && val === 0) continue; if (key === "paddingTop" && val === 0) continue; if (key === "paddingBottom" && val === 0) continue; if (key === "itemSpacing" && val === 0) continue; if (key === "textCase" && val === "ORIGINAL") continue; if (key === "textDecoration" && val === "NONE") continue; if (key === "lineHeight" && typeof val === "object" && val.unit === "AUTO") { continue; } if (key === "letterSpacing" && typeof val === "object" && val.value === 0) { continue; } cleaned[key] = val; } return cleaned; } async function traverseNode(node: SceneNode, depth: number, currentFolderPath: string, rootNode: SceneNode) { allFolders.push(currentFolderPath); const indent = " ".repeat(depth); markdownOutput += `${indent}- Name: ${node.name} (Type: ${node.type}, Path: ${currentFolderPath})\n`; if (node.type === "TEXT") { markdownOutput += `${indent} - Text Content: ${node.characters}\n`; } let localX = node.x; let localY = node.y; if (node.id === rootNode.id) { localX = 0; localY = 0; } else if (node.absoluteBoundingBox && rootNode.absoluteBoundingBox) { localX = node.absoluteBoundingBox.x - rootNode.absoluteBoundingBox.x; localY = node.absoluteBoundingBox.y - rootNode.absoluteBoundingBox.y; } const rawProps: any = { id: node.id, width: node.width, height: node.height, x: localX, y: localY, fills: "fills" in node ? node.fills : null, strokes: "strokes" in node ? node.strokes : null, effects: "effects" in node ? node.effects : null, opacity: "opacity" in node ? node.opacity : null, rotation: "rotation" in node ? node.rotation : null }; if (node.type === "TEXT") { rawProps.fontSize = cleanMixed(node.fontSize); rawProps.fontName = cleanMixed(node.fontName); rawProps.lineHeight = cleanMixed(node.lineHeight); rawProps.letterSpacing = cleanMixed(node.letterSpacing); rawProps.textAlignHorizontal = cleanMixed(node.textAlignHorizontal); rawProps.textAlignVertical = cleanMixed(node.textAlignVertical); rawProps.textCase = cleanMixed(node.textCase); rawProps.textDecoration = cleanMixed(node.textDecoration); } if ("layoutMode" in node) { rawProps.layoutMode = node.layoutMode; rawProps.primaryAxisAlignItems = node.primaryAxisAlignItems; rawProps.counterAxisAlignItems = node.counterAxisAlignItems; rawProps.paddingLeft = node.paddingLeft; rawProps.paddingRight = node.paddingRight; rawProps.paddingTop = node.paddingTop; rawProps.paddingBottom = node.paddingBottom; rawProps.itemSpacing = node.itemSpacing; } if ("cornerRadius" in node) { rawProps.cornerRadius = cleanMixed(node.cornerRadius); } if ("topLeftRadius" in node) { rawProps.topLeftRadius = cleanMixed(node.topLeftRadius); rawProps.topRightRadius = cleanMixed(node.topRightRadius); rawProps.bottomLeftRadius = cleanMixed(node.bottomLeftRadius); rawProps.bottomRightRadius = cleanMixed(node.bottomRightRadius); } markdownOutput += `${indent} - Raw Properties: ${JSON.stringify(cleanRawProps(rawProps))}\n`; const isTextNode = node.type === "TEXT"; if (!isTextNode) { try { const pngBuffer = await node.exportAsync({ format: 'PNG' }); const safeNodeName = getSafeName(node); exportedImages.push({ folder: currentFolderPath, name: `${safeNodeName}.png`, data: pngBuffer }); const nodeHasText = hasText(node); if (!nodeHasText) { const svgBuffer = await node.exportAsync({ format: 'SVG' }); exportedImages.push({ folder: currentFolderPath, name: `${safeNodeName}.svg`, data: svgBuffer }); } } catch (e) { console.error(`Failed to export ${node.name}`, e); } } if ("children" in node) { for (const child of node.children) { const childSafeName = getSafeName(child); const childFolderPath = `${currentFolderPath}/children/${childSafeName}`; await traverseNode(child, depth + 1, childFolderPath, rootNode); } } } for (const node of selection) { const safeFolderName = getSafeName(node); await traverseNode(node, 0, safeFolderName, node); } figma.ui.postMessage({ type: 'export', markdown: markdownOutput, images: exportedImages, folders: allFolders }); } figma.ui.onmessage = (msg) => { if (msg.type === 'run-extraction') { runExport(); } };