This is page 2 of 6. Use http://codebase.md/1yhy/figma-context-mcp?page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .env.example
├── .github
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── workflows
│ └── ci.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .lintstagedrc.json
├── .nvmrc
├── .prettierrc
├── CHANGELOG.md
├── commitlint.config.js
├── CONTRIBUTING.md
├── Dockerfile
├── docs
│ ├── en
│ │ ├── absolute-to-relative-research.md
│ │ ├── architecture.md
│ │ ├── cache-architecture.md
│ │ ├── grid-layout-research.md
│ │ ├── icon-detection.md
│ │ ├── layout-detection-research.md
│ │ └── layout-detection.md
│ └── zh-CN
│ ├── absolute-to-relative-research.md
│ ├── architecture.md
│ ├── cache-architecture.md
│ ├── grid-layout-research.md
│ ├── icon-detection.md
│ ├── layout-detection-research.md
│ ├── layout-detection.md
│ └── TODO-feature-enhancements.md
├── eslint.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── README.zh-CN.md
├── scripts
│ ├── fetch-test-data.ts
│ └── optimize-figma-json.ts
├── smithery.yaml
├── src
│ ├── algorithms
│ │ ├── icon
│ │ │ ├── detector.ts
│ │ │ └── index.ts
│ │ └── layout
│ │ ├── detector.ts
│ │ ├── index.ts
│ │ ├── optimizer.ts
│ │ └── spatial.ts
│ ├── config.ts
│ ├── core
│ │ ├── effects.ts
│ │ ├── layout.ts
│ │ ├── parser.ts
│ │ └── style.ts
│ ├── index.ts
│ ├── prompts
│ │ ├── design-to-code.ts
│ │ └── index.ts
│ ├── resources
│ │ ├── figma-resources.ts
│ │ └── index.ts
│ ├── server.ts
│ ├── services
│ │ ├── cache
│ │ │ ├── cache-manager.ts
│ │ │ ├── disk-cache.ts
│ │ │ ├── index.ts
│ │ │ ├── lru-cache.ts
│ │ │ └── types.ts
│ │ ├── cache.ts
│ │ ├── figma.ts
│ │ └── simplify-node-response.ts
│ ├── types
│ │ ├── figma.ts
│ │ ├── index.ts
│ │ └── simplified.ts
│ └── utils
│ ├── color.ts
│ ├── css.ts
│ ├── file.ts
│ └── validation.ts
├── tests
│ ├── fixtures
│ │ ├── expected
│ │ │ ├── node-240-32163-optimized.json
│ │ │ ├── node-402-34955-optimized.json
│ │ │ └── real-node-data-optimized.json
│ │ └── figma-data
│ │ ├── node-240-32163.json
│ │ ├── node-402-34955.json
│ │ └── real-node-data.json
│ ├── integration
│ │ ├── __snapshots__
│ │ │ ├── layout-optimization.test.ts.snap
│ │ │ └── output-quality.test.ts.snap
│ │ ├── layout-optimization.test.ts
│ │ ├── output-quality.test.ts
│ │ └── parser.test.ts
│ ├── unit
│ │ ├── algorithms
│ │ │ ├── icon-optimization.test.ts
│ │ │ ├── icon.test.ts
│ │ │ └── layout.test.ts
│ │ ├── resources
│ │ │ └── figma-resources.test.ts
│ │ └── services
│ │ └── cache.test.ts
│ └── utils
│ ├── preview-generator.ts
│ ├── preview.ts
│ ├── run-simplification.ts
│ └── viewer.html
├── tsconfig.json
├── tsup.config.ts
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/src/algorithms/layout/spatial.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Spatial Projection Analyzer
*
* Analyzes spatial relationships between nodes using 2D projection techniques.
* Used to detect layout patterns, grouping, and containment relationships.
*
* @module algorithms/layout/spatial
*/
import type { SimplifiedNode } from "~/types/index.js";
// ==================== Type Definitions ====================
/**
* Rectangle representing a node's bounding box
*/
export interface Rect {
left: number;
top: number;
width: number;
height: number;
}
/**
* Spatial relationship between two nodes
*/
export enum NodeRelationship {
/** One node completely contains another */
CONTAINS = "contains",
/** Two nodes partially overlap */
INTERSECTS = "intersects",
/** Two nodes do not overlap */
SEPARATE = "separate",
}
/**
* Projection line for spatial division
*/
export interface ProjectionLine {
/** Position of the line */
position: number;
/** Direction: 'horizontal' or 'vertical' */
direction: "horizontal" | "vertical";
/** Indices of nodes intersecting this line */
nodeIndices: number[];
}
// ==================== Helper Functions ====================
/**
* Safely parse CSS numeric value
* @param value - CSS value string
* @param defaultValue - Default value if parsing fails
*/
function safeParseFloat(value: string | undefined | null, defaultValue: number = 0): number {
if (!value) return defaultValue;
const parsed = parseFloat(value);
return isNaN(parsed) ? defaultValue : parsed;
}
/**
* Get node position from absolute coordinates or CSS styles
*/
function getNodePosition(node: SimplifiedNode): { left: number; top: number } {
let left = 0;
let top = 0;
// Prefer absolute coordinates
if (typeof node._absoluteX === "number") {
left = node._absoluteX;
} else if (node.cssStyles?.left) {
left = safeParseFloat(node.cssStyles.left as string);
}
if (typeof node._absoluteY === "number") {
top = node._absoluteY;
} else if (node.cssStyles?.top) {
top = safeParseFloat(node.cssStyles.top as string);
}
return { left, top };
}
// ==================== Rectangle Utilities ====================
/**
* Utility class for rectangle operations
*/
export class RectUtils {
/**
* Create Rect from SimplifiedNode
*/
static fromNode(node: SimplifiedNode): Rect | null {
if (!node.cssStyles || !node.cssStyles.width || !node.cssStyles.height) {
return null;
}
const width = safeParseFloat(node.cssStyles.width as string, 0);
const height = safeParseFloat(node.cssStyles.height as string, 0);
// Invalid rectangle if dimensions are zero
if (width <= 0 || height <= 0) {
return null;
}
const { left, top } = getNodePosition(node);
return { left, top, width, height };
}
/**
* Check if rectangle A contains rectangle B
*/
static contains(a: Rect, b: Rect): boolean {
return (
a.left <= b.left &&
a.top <= b.top &&
a.left + a.width >= b.left + b.width &&
a.top + a.height >= b.top + b.height
);
}
/**
* Check if two rectangles intersect
*/
static intersects(a: Rect, b: Rect): boolean {
return !(
a.left + a.width <= b.left ||
b.left + b.width <= a.left ||
a.top + a.height <= b.top ||
b.top + b.height <= a.top
);
}
/**
* Calculate intersection of two rectangles
*/
static intersection(a: Rect, b: Rect): Rect | null {
if (!RectUtils.intersects(a, b)) {
return null;
}
const left = Math.max(a.left, b.left);
const top = Math.max(a.top, b.top);
const right = Math.min(a.left + a.width, b.left + b.width);
const bottom = Math.min(a.top + a.height, b.top + b.height);
return {
left,
top,
width: right - left,
height: bottom - top,
};
}
/**
* Analyze spatial relationship between two rectangles
*/
static analyzeRelationship(a: Rect, b: Rect): NodeRelationship {
if (RectUtils.contains(a, b)) {
return NodeRelationship.CONTAINS;
} else if (RectUtils.contains(b, a)) {
return NodeRelationship.CONTAINS;
} else if (RectUtils.intersects(a, b)) {
return NodeRelationship.INTERSECTS;
} else {
return NodeRelationship.SEPARATE;
}
}
}
// ==================== Spatial Projection Analyzer ====================
/**
* 2D Spatial Projection Analyzer
*
* Uses projection lines to analyze spatial relationships
* and group nodes by rows and columns.
*/
export class SpatialProjectionAnalyzer {
/**
* Convert SimplifiedNode array to Rect array
*/
static nodesToRects(nodes: SimplifiedNode[]): Rect[] {
return nodes
.map((node) => RectUtils.fromNode(node))
.filter((rect): rect is Rect => rect !== null);
}
/**
* Generate horizontal projection lines (for row detection)
* @param rects - Rectangle array
* @param tolerance - Coordinate tolerance in pixels
*/
static generateHorizontalProjectionLines(rects: Rect[], tolerance: number = 1): ProjectionLine[] {
if (rects.length === 0) return [];
// Collect all top and bottom Y coordinates
const yCoordinates: number[] = [];
rects.forEach((rect) => {
yCoordinates.push(rect.top);
yCoordinates.push(rect.top + rect.height);
});
// Sort and filter nearby coordinates
yCoordinates.sort((a, b) => a - b);
const uniqueYCoordinates: number[] = [];
for (let i = 0; i < yCoordinates.length; i++) {
if (i === 0 || Math.abs(yCoordinates[i] - yCoordinates[i - 1]) > tolerance) {
uniqueYCoordinates.push(yCoordinates[i]);
}
}
// Create projection line for each Y coordinate
return uniqueYCoordinates.map((y) => {
const line: ProjectionLine = {
position: y,
direction: "horizontal",
nodeIndices: [],
};
// Find all nodes intersecting this line
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
if (y >= rect.top && y <= rect.top + rect.height) {
line.nodeIndices.push(i);
}
}
return line;
});
}
/**
* Generate vertical projection lines (for column detection)
* @param rects - Rectangle array
* @param tolerance - Coordinate tolerance in pixels
*/
static generateVerticalProjectionLines(rects: Rect[], tolerance: number = 1): ProjectionLine[] {
if (rects.length === 0) return [];
// Collect all left and right X coordinates
const xCoordinates: number[] = [];
rects.forEach((rect) => {
xCoordinates.push(rect.left);
xCoordinates.push(rect.left + rect.width);
});
// Sort and filter nearby coordinates
xCoordinates.sort((a, b) => a - b);
const uniqueXCoordinates: number[] = [];
for (let i = 0; i < xCoordinates.length; i++) {
if (i === 0 || Math.abs(xCoordinates[i] - xCoordinates[i - 1]) > tolerance) {
uniqueXCoordinates.push(xCoordinates[i]);
}
}
// Create projection line for each X coordinate
return uniqueXCoordinates.map((x) => {
const line: ProjectionLine = {
position: x,
direction: "vertical",
nodeIndices: [],
};
// Find all nodes intersecting this line
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
if (x >= rect.left && x <= rect.left + rect.width) {
line.nodeIndices.push(i);
}
}
return line;
});
}
/**
* Group nodes by rows
* @param nodes - Node array
* @param tolerance - Tolerance in pixels
*/
static groupNodesByRows(nodes: SimplifiedNode[], tolerance: number = 1): SimplifiedNode[][] {
const rects = this.nodesToRects(nodes);
if (rects.length === 0) return [nodes];
const projectionLines = this.generateHorizontalProjectionLines(rects, tolerance);
const rows: SimplifiedNode[][] = [];
for (let i = 0; i < projectionLines.length - 1; i++) {
const currentLine = projectionLines[i];
const nextLine = projectionLines[i + 1];
const nodesBetweenLines = new Set<number>();
// Find nodes completely between these two lines
for (let j = 0; j < rects.length; j++) {
const rect = rects[j];
if (rect.top >= currentLine.position && rect.top + rect.height <= nextLine.position) {
nodesBetweenLines.add(j);
}
}
if (nodesBetweenLines.size > 0) {
// Sort nodes left to right
const rowNodes = Array.from(nodesBetweenLines)
.map((index) => nodes[index])
.sort((a, b) => {
const { left: aLeft } = getNodePosition(a);
const { left: bLeft } = getNodePosition(b);
return aLeft - bLeft;
});
rows.push(rowNodes);
}
}
// If no rows found, treat all nodes as one row
if (rows.length === 0) {
rows.push([...nodes]);
}
return rows;
}
/**
* Group row nodes by columns
* @param rowNodes - Nodes in a row
* @param tolerance - Tolerance in pixels
*/
static groupRowNodesByColumns(
rowNodes: SimplifiedNode[],
tolerance: number = 1,
): SimplifiedNode[][] {
const rects = this.nodesToRects(rowNodes);
if (rects.length === 0) return [rowNodes];
const projectionLines = this.generateVerticalProjectionLines(rects, tolerance);
const columns: SimplifiedNode[][] = [];
for (let i = 0; i < projectionLines.length - 1; i++) {
const currentLine = projectionLines[i];
const nextLine = projectionLines[i + 1];
const nodesBetweenLines = new Set<number>();
// Find nodes completely between these two lines
for (let j = 0; j < rects.length; j++) {
const rect = rects[j];
if (rect.left >= currentLine.position && rect.left + rect.width <= nextLine.position) {
nodesBetweenLines.add(j);
}
}
if (nodesBetweenLines.size > 0) {
const colNodes = Array.from(nodesBetweenLines).map((index) => rowNodes[index]);
columns.push(colNodes);
}
}
// If no columns found, treat all nodes as one column
if (columns.length === 0) {
columns.push([...rowNodes]);
}
return columns;
}
/**
* Process node spatial relationships and build containment hierarchy
* @param nodes - Node array
*/
static processNodeRelationships(nodes: SimplifiedNode[]): SimplifiedNode[] {
if (nodes.length <= 1) return [...nodes];
const rects = this.nodesToRects(nodes);
if (rects.length !== nodes.length) {
return nodes; // Cannot process all nodes, return as-is
}
// Find all containment relationships
const containsRelations: [number, number][] = [];
for (let i = 0; i < rects.length; i++) {
for (let j = 0; j < rects.length; j++) {
if (i !== j && RectUtils.contains(rects[i], rects[j])) {
containsRelations.push([i, j]); // Node i contains node j
}
}
}
// Build containment graph
const childrenMap = new Map<number, Set<number>>();
const parentMap = new Map<number, number | null>();
// Initialize all nodes without parents
for (let i = 0; i < nodes.length; i++) {
parentMap.set(i, null);
childrenMap.set(i, new Set<number>());
}
// Process containment relationships
for (const [parent, child] of containsRelations) {
childrenMap.get(parent)?.add(child);
parentMap.set(child, parent);
}
// Fix multi-level containment - ensure each node has only its direct parent
for (const [child, parent] of parentMap.entries()) {
if (parent === null) continue;
let currentParent = parent;
let grandParent = parentMap.get(currentParent);
while (grandParent !== null && grandParent !== undefined) {
// If grandparent also directly contains child, remove parent-child direct relation
if (childrenMap.get(grandParent)?.has(child)) {
childrenMap.get(currentParent)?.delete(child);
}
currentParent = grandParent;
grandParent = parentMap.get(currentParent);
}
}
// Build new node tree structure
const rootIndices = Array.from(parentMap.entries())
.filter(([_, parent]) => parent === null)
.map(([index]) => index);
const result: SimplifiedNode[] = [];
// Recursively build node tree
const buildNodeTree = (nodeIndex: number): SimplifiedNode => {
const node = { ...nodes[nodeIndex] };
const childIndices = Array.from(childrenMap.get(nodeIndex) || []);
if (childIndices.length > 0) {
node.children = childIndices.map(buildNodeTree);
}
return node;
};
// Build from all root nodes
for (const rootIndex of rootIndices) {
result.push(buildNodeTree(rootIndex));
}
return result;
}
}
```
--------------------------------------------------------------------------------
/src/services/figma.ts:
--------------------------------------------------------------------------------
```typescript
import fs from "fs";
import path from "path";
import { parseFigmaResponse } from "~/core/parser.js";
import type { SimplifiedDesign } from "~/types/index.js";
import { cacheManager } from "./cache.js";
import type {
GetImagesResponse,
GetFileResponse,
GetFileNodesResponse,
GetImageFillsResponse,
} from "@figma/rest-api-spec";
import { Logger } from "~/server.js";
import type {
FigmaError,
RateLimitInfo,
FetchImageParams,
FetchImageFillParams,
} from "~/types/index.js";
// Re-export types for backward compatibility
export type { FigmaError, RateLimitInfo, FetchImageParams, FetchImageFillParams };
// ==================== Internal Types ====================
/**
* API Response Result (internal use only)
*/
interface ApiResponse<T> {
data: T;
rateLimitInfo: RateLimitInfo;
}
// ==================== Utility Functions ====================
/**
* Validate fileKey format
*/
function validateFileKey(fileKey: string): void {
if (!fileKey || typeof fileKey !== "string") {
throw createFigmaError(400, "fileKey is required");
}
// Figma fileKey is typically alphanumeric
if (!/^[a-zA-Z0-9_-]+$/.test(fileKey)) {
throw createFigmaError(400, `Invalid fileKey format: ${fileKey}`);
}
}
/**
* Validate nodeId format
*/
function validateNodeId(nodeId: string): void {
if (!nodeId || typeof nodeId !== "string") {
throw createFigmaError(400, "nodeId is required");
}
// Figma nodeId format is typically number:number or number-number
if (!/^[\d:_-]+$/.test(nodeId)) {
throw createFigmaError(400, `Invalid nodeId format: ${nodeId}`);
}
}
/**
* Validate depth parameter
*/
function validateDepth(depth?: number): void {
if (depth !== undefined) {
if (typeof depth !== "number" || depth < 1 || depth > 100) {
throw createFigmaError(400, "depth must be a number between 1 and 100");
}
}
}
/**
* Validate local path security
*/
function validateLocalPath(localPath: string, fileName: string): string {
const normalizedPath = path.resolve(localPath, fileName);
const resolvedLocalPath = path.resolve(localPath);
if (!normalizedPath.startsWith(resolvedLocalPath)) {
throw createFigmaError(400, "Invalid file path: path traversal detected");
}
return normalizedPath;
}
/**
* Create Figma error
*/
function createFigmaError(
status: number,
message: string,
rateLimitInfo?: RateLimitInfo,
): FigmaError {
return {
status,
err: message,
rateLimitInfo,
};
}
/**
* Extract Rate Limit information from response headers
*/
function extractRateLimitInfo(headers: Headers): RateLimitInfo {
return {
remaining: headers.has("x-rate-limit-remaining")
? parseInt(headers.get("x-rate-limit-remaining")!, 10)
: null,
resetAfter: headers.has("x-rate-limit-reset")
? parseInt(headers.get("x-rate-limit-reset")!, 10)
: null,
retryAfter: headers.has("retry-after") ? parseInt(headers.get("retry-after")!, 10) : null,
};
}
/**
* Format Rate Limit error message
*/
function formatRateLimitError(rateLimitInfo: RateLimitInfo): string {
const parts: string[] = ["Figma API rate limit exceeded (429 Too Many Requests)."];
if (rateLimitInfo.retryAfter !== null) {
const minutes = Math.ceil(rateLimitInfo.retryAfter / 60);
const hours = Math.ceil(rateLimitInfo.retryAfter / 3600);
const days = Math.ceil(rateLimitInfo.retryAfter / 86400);
if (days > 1) {
parts.push(`Please retry after ${days} days.`);
} else if (hours > 1) {
parts.push(`Please retry after ${hours} hours.`);
} else {
parts.push(`Please retry after ${minutes} minutes.`);
}
}
parts.push(
"\nThis is likely due to Figma's November 2025 rate limit update.",
"Starter plan: 6 requests/month. Professional plan: 10 requests/minute.",
"\nSuggestions:",
"1. Check if the design file belongs to a Starter plan workspace",
"2. Duplicate the file to your own Professional workspace",
"3. Wait for the rate limit to reset",
);
return parts.join(" ");
}
/**
* Download image to local filesystem
*/
async function downloadImage(
url: string,
localPath: string,
fileName: string,
fileKey: string,
nodeId: string,
format: string,
): Promise<string> {
// Validate path security
const fullPath = validateLocalPath(localPath, fileName);
// Check image cache
const cachedPath = await cacheManager.hasImage(fileKey, nodeId, format);
if (cachedPath) {
// Copy from cache to target path
const copied = await cacheManager.copyImageFromCache(fileKey, nodeId, format, fullPath);
if (copied) {
Logger.log(`Image loaded from cache: ${fileName}`);
return fullPath;
}
}
// Ensure directory exists
const dir = path.dirname(fullPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Download image
const response = await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(30000), // 30 second timeout
});
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
// Use arrayBuffer instead of streaming for better reliability
const buffer = await response.arrayBuffer();
await fs.promises.writeFile(fullPath, Buffer.from(buffer));
// Cache image
await cacheManager.cacheImage(fullPath, fileKey, nodeId, format);
return fullPath;
}
// ==================== Logging Utilities ====================
/**
* Write development logs
*/
function writeLogs(name: string, value: unknown): void {
try {
if (process.env.NODE_ENV !== "development") return;
const logsDir = "logs";
try {
fs.accessSync(process.cwd(), fs.constants.W_OK);
} catch {
return;
}
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir);
}
fs.writeFileSync(`${logsDir}/${name}`, JSON.stringify(value, null, 2));
} catch {
// Ignore log write errors
}
}
// ==================== Figma Service Class ====================
/**
* Figma API Service
*/
export class FigmaService {
private readonly apiKey: string;
private readonly baseUrl = "https://api.figma.com/v1";
/** Most recent Rate Limit information */
private lastRateLimitInfo: RateLimitInfo | null = null;
constructor(apiKey: string) {
if (!apiKey || typeof apiKey !== "string") {
throw new Error("Figma API key is required");
}
this.apiKey = apiKey;
}
/**
* Get most recent Rate Limit information
*/
getRateLimitInfo(): RateLimitInfo | null {
return this.lastRateLimitInfo;
}
/**
* Make API request
*/
private async request<T>(endpoint: string): Promise<ApiResponse<T>> {
if (typeof fetch !== "function") {
throw new Error(
"The MCP server requires Node.js 18+ with fetch support.\n" +
"Please upgrade your Node.js version to continue.",
);
}
Logger.log(`Calling ${this.baseUrl}${endpoint}`);
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: {
"X-Figma-Token": this.apiKey,
},
});
// Extract Rate Limit information
const rateLimitInfo = extractRateLimitInfo(response.headers);
this.lastRateLimitInfo = rateLimitInfo;
// Handle error responses
if (!response.ok) {
const status = response.status;
let errorMessage = response.statusText || "Unknown error";
// Special handling for 429 errors
if (status === 429) {
errorMessage = formatRateLimitError(rateLimitInfo);
} else if (status === 403) {
errorMessage = "Access denied. Please check your Figma API key and file permissions.";
} else if (status === 404) {
errorMessage = "File or node not found. Please verify the fileKey and nodeId are correct.";
}
throw createFigmaError(status, errorMessage, rateLimitInfo);
}
const data = (await response.json()) as T;
return { data, rateLimitInfo };
}
/**
* Get image fill URLs and download
*/
async getImageFills(
fileKey: string,
nodes: FetchImageFillParams[],
localPath: string,
): Promise<string[]> {
if (nodes.length === 0) return [];
// Validate parameters
validateFileKey(fileKey);
nodes.forEach((node) => {
validateNodeId(node.nodeId);
});
const endpoint = `/files/${fileKey}/images`;
const { data } = await this.request<GetImageFillsResponse>(endpoint);
const { images = {} } = data.meta;
const downloads = nodes.map(async ({ imageRef, fileName, nodeId }) => {
const imageUrl = images[imageRef];
if (!imageUrl) {
Logger.log(`Image not found for ref: ${imageRef}`);
return "";
}
try {
const format = fileName.toLowerCase().endsWith(".svg") ? "svg" : "png";
return await downloadImage(imageUrl, localPath, fileName, fileKey, nodeId, format);
} catch (error) {
Logger.error(`Failed to download image ${fileName}:`, error);
return "";
}
});
return Promise.all(downloads);
}
/**
* Render nodes as images and download
*/
async getImages(
fileKey: string,
nodes: FetchImageParams[],
localPath: string,
): Promise<string[]> {
if (nodes.length === 0) return [];
// Validate parameters
validateFileKey(fileKey);
nodes.forEach((node) => validateNodeId(node.nodeId));
// Categorize PNG and SVG nodes
const pngNodes = nodes.filter(({ fileType }) => fileType === "png");
const svgNodes = nodes.filter(({ fileType }) => fileType === "svg");
// Get image URLs (sequential execution to reduce Rate Limit risk)
const imageUrls: Record<string, string> = {};
if (pngNodes.length > 0) {
const pngIds = pngNodes.map(({ nodeId }) => nodeId).join(",");
const { data } = await this.request<GetImagesResponse>(
`/images/${fileKey}?ids=${pngIds}&scale=2&format=png`,
);
Object.assign(imageUrls, data.images || {});
}
if (svgNodes.length > 0) {
const svgIds = svgNodes.map(({ nodeId }) => nodeId).join(",");
const { data } = await this.request<GetImagesResponse>(
`/images/${fileKey}?ids=${svgIds}&scale=2&format=svg`,
);
Object.assign(imageUrls, data.images || {});
}
// Download images
const downloads = nodes.map(async ({ nodeId, fileName, fileType }) => {
const imageUrl = imageUrls[nodeId];
if (!imageUrl) {
Logger.log(`Image URL not found for node: ${nodeId}`);
return "";
}
try {
return await downloadImage(imageUrl, localPath, fileName, fileKey, nodeId, fileType);
} catch (error) {
Logger.error(`Failed to download image ${fileName}:`, error);
return "";
}
});
return Promise.all(downloads);
}
/**
* Get entire Figma file
*/
async getFile(fileKey: string, depth?: number): Promise<SimplifiedDesign> {
// Validate parameters
validateFileKey(fileKey);
validateDepth(depth);
// Try to get from cache
const cached = await cacheManager.getNodeData<SimplifiedDesign>(fileKey, undefined, depth);
if (cached) {
Logger.log(`File loaded from cache: ${fileKey}`);
return cached;
}
try {
const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`;
Logger.log(`Retrieving Figma file: ${fileKey} (depth: ${depth ?? "default"})`);
const { data: response } = await this.request<GetFileResponse>(endpoint);
Logger.log("Got response");
const simplifiedResponse = parseFigmaResponse(response);
// Write development logs
writeLogs("figma-raw.json", response);
writeLogs("figma-simplified.json", simplifiedResponse);
// Write to cache
await cacheManager.setNodeData(simplifiedResponse, fileKey, undefined, depth);
return simplifiedResponse;
} catch (error) {
// Re-throw Figma errors to preserve details
if ((error as FigmaError).status) {
throw error;
}
Logger.error("Failed to get file:", error);
throw error;
}
}
/**
* Get specific node
*/
async getNode(fileKey: string, nodeId: string, depth?: number): Promise<SimplifiedDesign> {
// Validate parameters
validateFileKey(fileKey);
validateNodeId(nodeId);
validateDepth(depth);
// Try to get from cache
const cached = await cacheManager.getNodeData<SimplifiedDesign>(fileKey, nodeId, depth);
if (cached) {
Logger.log(`Node loaded from cache: ${fileKey}/${nodeId}`);
return cached;
}
const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`;
const { data: response } = await this.request<GetFileNodesResponse>(endpoint);
Logger.log("Got response from getNode, now parsing.");
writeLogs("figma-raw.json", response);
const simplifiedResponse = parseFigmaResponse(response);
writeLogs("figma-simplified.json", simplifiedResponse);
// Write to cache
await cacheManager.setNodeData(simplifiedResponse, fileKey, nodeId, depth);
return simplifiedResponse;
}
}
```
--------------------------------------------------------------------------------
/src/services/cache/disk-cache.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Disk Cache
*
* Persistent file-based cache for Figma data and images.
* Acts as L2 cache layer after in-memory LRU cache.
*
* @module services/cache/disk-cache
*/
import fs from "fs";
import path from "path";
import crypto from "crypto";
import os from "os";
import type { DiskCacheConfig, CacheEntryMeta, DiskCacheStats } from "./types.js";
/**
* Default disk cache configuration
*/
const DEFAULT_CONFIG: DiskCacheConfig = {
cacheDir: path.join(os.homedir(), ".figma-mcp-cache"),
maxSize: 500 * 1024 * 1024, // 500MB
ttl: 24 * 60 * 60 * 1000, // 24 hours
};
/**
* Disk-based persistent cache
*/
export class DiskCache {
private config: DiskCacheConfig;
private dataDir: string;
private imageDir: string;
private metadataDir: string;
private stats: { hits: number; misses: number };
constructor(config: Partial<DiskCacheConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.dataDir = path.join(this.config.cacheDir, "data");
this.imageDir = path.join(this.config.cacheDir, "images");
this.metadataDir = path.join(this.config.cacheDir, "metadata");
this.stats = { hits: 0, misses: 0 };
this.ensureCacheDirectories();
}
/**
* Ensure cache directories exist
*/
private ensureCacheDirectories(): void {
try {
[this.config.cacheDir, this.dataDir, this.imageDir, this.metadataDir].forEach((dir) => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
} catch (error) {
console.warn("Failed to create cache directories:", error);
}
}
/**
* Generate cache key from components
*/
static generateKey(fileKey: string, nodeId?: string, depth?: number): string {
const keyParts = [fileKey];
if (nodeId) keyParts.push(`node-${nodeId}`);
if (depth !== undefined) keyParts.push(`depth-${depth}`);
const keyString = keyParts.join("_");
return crypto.createHash("md5").update(keyString).digest("hex");
}
/**
* Generate image cache key
*/
static generateImageKey(fileKey: string, nodeId: string, format: string): string {
const keyString = `${fileKey}_${nodeId}_${format}`;
return crypto.createHash("md5").update(keyString).digest("hex");
}
// ==================== Node Data Operations ====================
/**
* Get cached node data
*/
async get<T>(
fileKey: string,
nodeId?: string,
depth?: number,
version?: string,
): Promise<T | null> {
try {
const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
// Check if files exist
if (!fs.existsSync(dataPath) || !fs.existsSync(metadataPath)) {
this.stats.misses++;
return null;
}
// Read metadata and check expiration
const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
// Check TTL expiration
if (Date.now() > metadata.expiresAt) {
this.deleteByKey(cacheKey);
this.stats.misses++;
return null;
}
// Check version mismatch
if (version && metadata.version && metadata.version !== version) {
this.deleteByKey(cacheKey);
this.stats.misses++;
return null;
}
// Read and return cached data
const data = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
this.stats.hits++;
return data as T;
} catch (error) {
console.warn("Failed to read disk cache:", error);
this.stats.misses++;
return null;
}
}
/**
* Set node data cache
*/
async set<T>(
data: T,
fileKey: string,
nodeId?: string,
depth?: number,
version?: string,
): Promise<void> {
try {
const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
const dataString = JSON.stringify(data, null, 2);
// Create metadata
const metadata: CacheEntryMeta = {
key: cacheKey,
createdAt: Date.now(),
expiresAt: Date.now() + this.config.ttl,
fileKey,
nodeId,
depth,
version,
size: Buffer.byteLength(dataString, "utf-8"),
};
// Write data and metadata
fs.writeFileSync(dataPath, dataString);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
// Enforce size limit asynchronously
this.enforceSizeLimit().catch(() => {});
} catch (error) {
console.warn("Failed to write disk cache:", error);
}
}
/**
* Check if cache entry exists
*/
async has(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> {
const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
if (!fs.existsSync(dataPath) || !fs.existsSync(metadataPath)) {
return false;
}
try {
const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
return Date.now() <= metadata.expiresAt;
} catch {
return false;
}
}
/**
* Delete cache entry by key
*/
private deleteByKey(cacheKey: string): void {
try {
const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath);
if (fs.existsSync(metadataPath)) fs.unlinkSync(metadataPath);
} catch {
// Ignore deletion errors
}
}
/**
* Delete cache for a file key
*/
async delete(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> {
const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
this.deleteByKey(cacheKey);
return true;
}
/**
* Invalidate all cache entries for a file
*/
async invalidateFile(fileKey: string): Promise<number> {
let invalidated = 0;
try {
const metadataFiles = fs.readdirSync(this.metadataDir);
for (const file of metadataFiles) {
if (!file.endsWith(".meta.json") || file.startsWith("img_")) continue;
const metadataPath = path.join(this.metadataDir, file);
try {
const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
if (metadata.fileKey === fileKey) {
const cacheKey = file.replace(".meta.json", "");
this.deleteByKey(cacheKey);
invalidated++;
}
} catch {
// Skip individual file errors
}
}
} catch (error) {
console.warn("Failed to invalidate file cache:", error);
}
return invalidated;
}
// ==================== Image Operations ====================
/**
* Check if image is cached
*/
async hasImage(fileKey: string, nodeId: string, format: string): Promise<string | null> {
try {
const cacheKey = DiskCache.generateImageKey(fileKey, nodeId, format);
const imagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`);
const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`);
if (!fs.existsSync(imagePath) || !fs.existsSync(metadataPath)) {
return null;
}
// Check expiration
const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
if (Date.now() > metadata.expiresAt) {
this.deleteImageByKey(cacheKey, format);
return null;
}
return imagePath;
} catch {
return null;
}
}
/**
* Cache image file
*/
async cacheImage(
sourcePath: string,
fileKey: string,
nodeId: string,
format: string,
): Promise<string> {
try {
const cacheKey = DiskCache.generateImageKey(fileKey, nodeId, format);
const cachedImagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`);
const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`);
// Copy image to cache
fs.copyFileSync(sourcePath, cachedImagePath);
// Get file size
const stats = fs.statSync(cachedImagePath);
// Create metadata
const metadata: CacheEntryMeta = {
key: cacheKey,
createdAt: Date.now(),
expiresAt: Date.now() + this.config.ttl,
fileKey,
nodeId,
size: stats.size,
};
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
return cachedImagePath;
} catch (error) {
console.warn("Failed to cache image:", error);
return sourcePath;
}
}
/**
* Copy image from cache to target path
*/
async copyImageFromCache(
fileKey: string,
nodeId: string,
format: string,
targetPath: string,
): Promise<boolean> {
const cachedPath = await this.hasImage(fileKey, nodeId, format);
if (!cachedPath) return false;
try {
const targetDir = path.dirname(targetPath);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
fs.copyFileSync(cachedPath, targetPath);
return true;
} catch {
return false;
}
}
/**
* Delete image cache by key
*/
private deleteImageByKey(cacheKey: string, format: string): void {
try {
const imagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`);
const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`);
if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath);
if (fs.existsSync(metadataPath)) fs.unlinkSync(metadataPath);
} catch {
// Ignore errors
}
}
// ==================== Maintenance Operations ====================
/**
* Clean all expired cache entries
*/
async cleanExpired(): Promise<number> {
let deletedCount = 0;
const now = Date.now();
try {
const metadataFiles = fs.readdirSync(this.metadataDir);
for (const file of metadataFiles) {
if (!file.endsWith(".meta.json")) continue;
const metadataPath = path.join(this.metadataDir, file);
try {
const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
if (now > metadata.expiresAt) {
const cacheKey = file.replace(".meta.json", "");
if (file.startsWith("img_")) {
// Image cache
const imgCacheKey = cacheKey.replace("img_", "");
["png", "jpg", "svg", "pdf"].forEach((format) => {
const imagePath = path.join(this.imageDir, `${imgCacheKey}.${format}`);
if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath);
});
} else {
// Data cache
const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath);
}
fs.unlinkSync(metadataPath);
deletedCount++;
}
} catch {
// Skip individual errors
}
}
} catch (error) {
console.warn("Failed to clean expired cache:", error);
}
return deletedCount;
}
/**
* Enforce size limit by removing oldest entries
*/
async enforceSizeLimit(): Promise<number> {
let removedCount = 0;
try {
const stats = await this.getStats();
if (stats.totalSize <= this.config.maxSize) {
return 0;
}
// Get all metadata entries sorted by creation time
const entries: Array<{ path: string; meta: CacheEntryMeta }> = [];
const metadataFiles = fs.readdirSync(this.metadataDir);
for (const file of metadataFiles) {
if (!file.endsWith(".meta.json")) continue;
const metadataPath = path.join(this.metadataDir, file);
try {
const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
entries.push({ path: metadataPath, meta: metadata });
} catch {
// Skip invalid entries
}
}
// Sort by creation time (oldest first)
entries.sort((a, b) => a.meta.createdAt - b.meta.createdAt);
// Remove oldest entries until under limit
let currentSize = stats.totalSize;
for (const entry of entries) {
if (currentSize <= this.config.maxSize) break;
const cacheKey = path.basename(entry.path).replace(".meta.json", "");
if (cacheKey.startsWith("img_")) {
const imgKey = cacheKey.replace("img_", "");
["png", "jpg", "svg", "pdf"].forEach((format) => {
const imagePath = path.join(this.imageDir, `${imgKey}.${format}`);
if (fs.existsSync(imagePath)) {
currentSize -= fs.statSync(imagePath).size;
fs.unlinkSync(imagePath);
}
});
} else {
const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
if (fs.existsSync(dataPath)) {
currentSize -= fs.statSync(dataPath).size;
fs.unlinkSync(dataPath);
}
}
fs.unlinkSync(entry.path);
currentSize -= entry.meta.size || 0;
removedCount++;
}
} catch (error) {
console.warn("Failed to enforce size limit:", error);
}
return removedCount;
}
/**
* Clear all cache
*/
async clearAll(): Promise<void> {
try {
[this.dataDir, this.imageDir, this.metadataDir].forEach((dir) => {
if (fs.existsSync(dir)) {
const files = fs.readdirSync(dir);
files.forEach((file) => {
fs.unlinkSync(path.join(dir, file));
});
}
});
this.stats = { hits: 0, misses: 0 };
} catch (error) {
console.warn("Failed to clear cache:", error);
}
}
/**
* Get cache statistics
*/
async getStats(): Promise<DiskCacheStats> {
let totalSize = 0;
let nodeFileCount = 0;
let imageFileCount = 0;
try {
if (fs.existsSync(this.dataDir)) {
const dataFiles = fs.readdirSync(this.dataDir);
nodeFileCount = dataFiles.filter((f) => f.endsWith(".json")).length;
dataFiles.forEach((file) => {
const stat = fs.statSync(path.join(this.dataDir, file));
totalSize += stat.size;
});
}
if (fs.existsSync(this.imageDir)) {
const imageFiles = fs.readdirSync(this.imageDir);
imageFileCount = imageFiles.length;
imageFiles.forEach((file) => {
const stat = fs.statSync(path.join(this.imageDir, file));
totalSize += stat.size;
});
}
} catch {
// Ignore errors
}
return {
hits: this.stats.hits,
misses: this.stats.misses,
totalSize,
maxSize: this.config.maxSize,
nodeFileCount,
imageFileCount,
};
}
/**
* Get cache directory path
*/
getCacheDir(): string {
return this.config.cacheDir;
}
}
```
--------------------------------------------------------------------------------
/src/algorithms/icon/detector.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Icon Detection Algorithm
*
* Industry-based algorithm for detecting icons and mergeable layer groups.
*
* Core strategies:
* 1. Prioritize Figma exportSettings (designer-marked exports)
* 2. Smart detection: based on size, type ratio, structure depth
* 3. Bottom-up merging: child icon groups merge first, then parent nodes
*
* @module algorithms/icon/detector
*/
import type { IconDetectionResult, IconDetectionConfig } from "~/types/index.js";
// Re-export types for module consumers
export type { IconDetectionResult };
// Use IconDetectionConfig from types, alias as DetectionConfig for internal use
export type DetectionConfig = IconDetectionConfig;
// ==================== Module-Specific Types ====================
/**
* Figma node structure for icon detection (minimal interface)
*/
export interface FigmaNode {
id: string;
name: string;
type: string;
children?: FigmaNode[];
absoluteBoundingBox?: {
x: number;
y: number;
width: number;
height: number;
};
exportSettings?: Array<{
format: string;
suffix?: string;
constraint?: {
type: string;
value: number;
};
}>;
fills?: Array<{
type: string;
visible?: boolean;
imageRef?: string;
blendMode?: string;
}>;
effects?: Array<{
type: string;
visible?: boolean;
}>;
strokes?: Array<unknown>;
}
// ==================== Constants ====================
/** Default detection configuration */
export const DEFAULT_CONFIG: DetectionConfig = {
maxIconSize: 300,
minIconSize: 8,
mergeableRatio: 0.6,
maxDepth: 5,
maxChildren: 100,
respectExportSettingsMaxSize: 400,
};
/** Container node types */
const CONTAINER_TYPES = ["GROUP", "FRAME", "COMPONENT", "INSTANCE"] as const;
/** Mergeable graphics types (can be represented as SVG) */
const MERGEABLE_TYPES = [
"VECTOR",
"RECTANGLE",
"ELLIPSE",
"LINE",
"POLYGON",
"STAR",
"BOOLEAN_OPERATION",
"REGULAR_POLYGON",
] as const;
/** Single element types that should not be auto-exported (typically backgrounds) */
const SINGLE_ELEMENT_EXCLUDE_TYPES = ["RECTANGLE"] as const;
/** Types that exclude a group from being merged as icon */
const EXCLUDE_TYPES = ["TEXT", "COMPONENT", "INSTANCE"] as const;
/** Effects that require PNG export */
const PNG_REQUIRED_EFFECTS = [
"DROP_SHADOW",
"INNER_SHADOW",
"LAYER_BLUR",
"BACKGROUND_BLUR",
] as const;
// ==================== Helper Functions ====================
/**
* Check if type is a container type
*/
function isContainerType(type: string): boolean {
return CONTAINER_TYPES.includes(type as (typeof CONTAINER_TYPES)[number]);
}
/**
* Check if type is mergeable (can be part of an icon)
*/
function isMergeableType(type: string): boolean {
return MERGEABLE_TYPES.includes(type as (typeof MERGEABLE_TYPES)[number]);
}
/**
* Check if type should be excluded from icon merging
*/
function isExcludeType(type: string): boolean {
return EXCLUDE_TYPES.includes(type as (typeof EXCLUDE_TYPES)[number]);
}
/**
* Get node dimensions
*/
function getNodeSize(node: FigmaNode): { width: number; height: number } | null {
if (!node.absoluteBoundingBox) return null;
return {
width: node.absoluteBoundingBox.width,
height: node.absoluteBoundingBox.height,
};
}
/**
* Check if node has image fill
*/
function hasImageFill(node: FigmaNode): boolean {
if (!node.fills) return false;
return node.fills.some(
(fill) => fill.type === "IMAGE" && fill.visible !== false && fill.imageRef,
);
}
/**
* Check if node has complex effects (requires PNG)
*/
function hasComplexEffects(node: FigmaNode): boolean {
if (!node.effects) return false;
return node.effects.some(
(effect) =>
effect.visible !== false &&
PNG_REQUIRED_EFFECTS.includes(effect.type as (typeof PNG_REQUIRED_EFFECTS)[number]),
);
}
// ==================== Optimized Single-Pass Stats Collection ====================
/**
* Statistics collected from a node tree in a single traversal
* This replaces multiple recursive functions with one unified pass
*/
interface NodeTreeStats {
/** Maximum depth of the tree */
depth: number;
/** Total number of descendants (not including root) */
totalChildren: number;
/** Whether tree contains excluded types (TEXT, COMPONENT, INSTANCE) */
hasExcludeType: boolean;
/** Whether tree contains image fills */
hasImageFill: boolean;
/** Whether tree contains complex effects requiring PNG */
hasComplexEffects: boolean;
/** Whether all leaf nodes are mergeable types */
allLeavesMergeable: boolean;
/** Ratio of mergeable types in direct children */
mergeableRatio: number;
}
/**
* Collect all tree statistics in a single traversal
*
* OPTIMIZATION: This replaces 6 separate recursive functions:
* - calculateDepth()
* - countTotalChildren()
* - hasExcludeTypeInTree()
* - hasImageFillInTree()
* - hasComplexEffectsInTree()
* - areAllLeavesMergeable()
*
* Before: O(6n) - each function traverses entire tree
* After: O(n) - single traversal collects all data
*
* @param node - Node to analyze
* @returns Collected statistics
*/
function collectNodeStats(node: FigmaNode): NodeTreeStats {
// Base case: leaf node (no children)
if (!node.children || node.children.length === 0) {
const isMergeable = isMergeableType(node.type);
return {
depth: 0,
totalChildren: 0,
hasExcludeType: isExcludeType(node.type),
hasImageFill: hasImageFill(node),
hasComplexEffects: hasComplexEffects(node),
allLeavesMergeable: isMergeable,
mergeableRatio: isMergeable ? 1 : 0,
};
}
// Recursive case: collect stats from all children
const childStats = node.children.map(collectNodeStats);
// Aggregate child statistics
const maxChildDepth = Math.max(...childStats.map((s) => s.depth));
const totalDescendants = childStats.reduce((sum, s) => sum + 1 + s.totalChildren, 0);
const hasExcludeInChildren = childStats.some((s) => s.hasExcludeType);
const hasImageInChildren = childStats.some((s) => s.hasImageFill);
const hasEffectsInChildren = childStats.some((s) => s.hasComplexEffects);
const allChildrenMergeable = childStats.every((s) => s.allLeavesMergeable);
// Calculate mergeable ratio for direct children
const mergeableCount = node.children.filter(
(child) => isMergeableType(child.type) || isContainerType(child.type),
).length;
const mergeableRatio = mergeableCount / node.children.length;
// Determine if all leaves are mergeable
// For containers: all children must have all leaves mergeable
// For other types: check if this type itself is mergeable
const allLeavesMergeable = isContainerType(node.type)
? allChildrenMergeable
: isMergeableType(node.type);
return {
depth: maxChildDepth + 1,
totalChildren: totalDescendants,
hasExcludeType: isExcludeType(node.type) || hasExcludeInChildren,
hasImageFill: hasImageFill(node) || hasImageInChildren,
hasComplexEffects: hasComplexEffects(node) || hasEffectsInChildren,
allLeavesMergeable,
mergeableRatio,
};
}
// ==================== Main Detection Functions ====================
/**
* Detect if a single node should be exported as an icon
*
* OPTIMIZED: Uses single-pass collectNodeStats() instead of multiple recursive functions
*
* @param node - Figma node to analyze
* @param config - Detection configuration
* @returns Detection result with export recommendation
*/
export function detectIcon(
node: FigmaNode,
config: DetectionConfig = DEFAULT_CONFIG,
): IconDetectionResult {
const result: IconDetectionResult = {
nodeId: node.id,
nodeName: node.name,
shouldMerge: false,
exportFormat: "SVG",
reason: "",
};
// Get node size once
const size = getNodeSize(node);
if (size) {
result.size = size;
}
// 1. Check Figma exportSettings (with size restrictions)
if (node.exportSettings && node.exportSettings.length > 0) {
const isSmallEnough =
!size ||
(size.width <= config.respectExportSettingsMaxSize &&
size.height <= config.respectExportSettingsMaxSize);
// For exportSettings, we need to check for excluded types
// Use optimized single-pass collection
const stats = collectNodeStats(node);
const containsText = stats.hasExcludeType;
if (isSmallEnough && !containsText) {
const exportSetting = node.exportSettings[0];
result.shouldMerge = true;
result.exportFormat = exportSetting.format === "SVG" ? "SVG" : "PNG";
result.reason = `Designer marked export as ${exportSetting.format}`;
return result;
}
// Large nodes or nodes with TEXT: ignore exportSettings, continue detection
}
// 2. Must be container type or mergeable single element
if (!isContainerType(node.type)) {
// Single mergeable type node
if (isMergeableType(node.type)) {
// Single RECTANGLE is typically a background, not exported
if (
SINGLE_ELEMENT_EXCLUDE_TYPES.includes(
node.type as (typeof SINGLE_ELEMENT_EXCLUDE_TYPES)[number],
)
) {
result.reason = `Single ${node.type} is typically a background, not exported`;
return result;
}
// Check size for single elements
if (size) {
if (size.width > config.maxIconSize || size.height > config.maxIconSize) {
result.reason = `Single element too large (${Math.round(size.width)}x${Math.round(size.height)} > ${config.maxIconSize})`;
return result;
}
}
result.shouldMerge = true;
result.exportFormat = hasComplexEffects(node) ? "PNG" : "SVG";
result.reason = "Single vector/shape element";
return result;
}
result.reason = "Not a container or mergeable type";
return result;
}
// 3. Check size
if (size) {
// Too large: likely a layout container
if (size.width > config.maxIconSize || size.height > config.maxIconSize) {
result.reason = `Size too large (${size.width}x${size.height} > ${config.maxIconSize})`;
return result;
}
// Too small
if (size.width < config.minIconSize && size.height < config.minIconSize) {
result.reason = `Size too small (${size.width}x${size.height} < ${config.minIconSize})`;
return result;
}
}
// OPTIMIZATION: Collect all tree statistics in a single pass
// This replaces 6 separate recursive traversals with 1
const stats = collectNodeStats(node);
// 4. Check for excluded types (TEXT, etc.)
if (stats.hasExcludeType) {
result.reason = "Contains TEXT or other exclude types";
return result;
}
// 5. Check structure depth
if (stats.depth > config.maxDepth) {
result.reason = `Depth too deep (${stats.depth} > ${config.maxDepth})`;
return result;
}
// 6. Check child count
result.childCount = stats.totalChildren;
if (stats.totalChildren > config.maxChildren) {
result.reason = `Too many children (${stats.totalChildren} > ${config.maxChildren})`;
return result;
}
// 7. Check mergeable type ratio
if (stats.mergeableRatio < config.mergeableRatio) {
result.reason = `Mergeable ratio too low (${(stats.mergeableRatio * 100).toFixed(1)}% < ${config.mergeableRatio * 100}%)`;
return result;
}
// 8. Check if all leaf nodes are mergeable
if (!stats.allLeavesMergeable) {
result.reason = "Not all leaf nodes are mergeable types";
return result;
}
// 9. Determine export format (using stats collected in single pass)
if (stats.hasImageFill) {
result.exportFormat = "PNG";
result.reason = "Contains image fills, export as PNG";
} else if (stats.hasComplexEffects) {
result.exportFormat = "PNG";
result.reason = "Contains complex effects, export as PNG";
} else {
result.exportFormat = "SVG";
result.reason = "All vector elements, export as SVG";
}
result.shouldMerge = true;
return result;
}
/**
* Process node tree bottom-up, detecting and marking icons
*
* @param node - Root node
* @param config - Detection configuration
* @returns Processed node with _iconDetection markers
*/
export function processNodeTree(
node: FigmaNode,
config: DetectionConfig = DEFAULT_CONFIG,
): FigmaNode & { _iconDetection?: IconDetectionResult } {
const processedNode = { ...node } as FigmaNode & { _iconDetection?: IconDetectionResult };
// Process children first (bottom-up)
if (node.children && node.children.length > 0) {
processedNode.children = node.children.map((child) => processNodeTree(child, config));
// Check if all children are marked as icons (can be merged to parent)
const allChildrenAreIcons = processedNode.children.every((child) => {
const childWithDetection = child as FigmaNode & { _iconDetection?: IconDetectionResult };
return childWithDetection._iconDetection?.shouldMerge;
});
// If all children are icons, try to merge to current node
if (allChildrenAreIcons) {
const detection = detectIcon(processedNode, config);
if (detection.shouldMerge) {
processedNode._iconDetection = detection;
// Clear child markers since they will be merged
processedNode.children.forEach((child) => {
delete (child as FigmaNode & { _iconDetection?: IconDetectionResult })._iconDetection;
});
return processedNode;
}
}
}
// Detect current node
const detection = detectIcon(processedNode, config);
if (detection.shouldMerge) {
processedNode._iconDetection = detection;
}
return processedNode;
}
/**
* Collect all exportable icons from processed node tree
*
* @param node - Processed node with _iconDetection markers
* @returns Array of icon detection results
*/
export function collectExportableIcons(
node: FigmaNode & { _iconDetection?: IconDetectionResult },
): IconDetectionResult[] {
const results: IconDetectionResult[] = [];
// If current node is an icon, add to results
if (node._iconDetection?.shouldMerge) {
results.push(node._iconDetection);
// Don't recurse into children (they will be merged)
return results;
}
// Recurse into children
if (node.children) {
for (const child of node.children) {
results.push(
...collectExportableIcons(child as FigmaNode & { _iconDetection?: IconDetectionResult }),
);
}
}
return results;
}
/**
* Analyze node tree and return icon detection report
*
* @param node - Root Figma node
* @param config - Detection configuration
* @returns Analysis result with processed tree, exportable icons, and summary
*/
export function analyzeNodeTree(
node: FigmaNode,
config: DetectionConfig = DEFAULT_CONFIG,
): {
processedTree: FigmaNode & { _iconDetection?: IconDetectionResult };
exportableIcons: IconDetectionResult[];
summary: {
totalIcons: number;
svgCount: number;
pngCount: number;
};
} {
const processedTree = processNodeTree(node, config);
const exportableIcons = collectExportableIcons(processedTree);
const summary = {
totalIcons: exportableIcons.length,
svgCount: exportableIcons.filter((i) => i.exportFormat === "SVG").length,
pngCount: exportableIcons.filter((i) => i.exportFormat === "PNG").length,
};
return { processedTree, exportableIcons, summary };
}
```
--------------------------------------------------------------------------------
/tests/integration/layout-optimization.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Layout Optimization Integration Tests
*
* Tests the complete layout optimization pipeline using real Figma data.
* Converted from scripts/test-layout-optimization.ts
*/
import { describe, it, expect, beforeAll } from "vitest";
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import { parseFigmaResponse } from "~/core/parser.js";
import { LayoutOptimizer } from "~/algorithms/layout/optimizer.js";
import type { SimplifiedNode, SimplifiedDesign } from "~/types/index.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesDir = path.join(__dirname, "../fixtures/figma-data");
const expectedDir = path.join(__dirname, "../fixtures/expected");
// Test file configurations
const TEST_FILES = [
{ name: "node-402-34955", desc: "Group 1410104853 (1580x895)" },
{ name: "node-240-32163", desc: "Call Logs 有数据 (375x827)" },
];
// Layout statistics interface
interface LayoutStats {
total: number;
flex: number;
grid: number;
flexRow: number;
flexColumn: number;
absolute: number;
}
// Helper: Count layouts recursively
function countLayouts(node: SimplifiedNode, stats: LayoutStats): void {
stats.total++;
const display = node.cssStyles?.display;
if (display === "flex") {
stats.flex++;
if (node.cssStyles?.flexDirection === "column") {
stats.flexColumn++;
} else {
stats.flexRow++;
}
} else if (display === "grid") {
stats.grid++;
} else if (node.cssStyles?.position === "absolute") {
stats.absolute++;
}
if (node.children) {
for (const child of node.children) {
countLayouts(child, stats);
}
}
}
// Helper: Find nodes by layout type
function findLayoutNodes(
node: SimplifiedNode,
layoutType: "flex" | "grid",
results: SimplifiedNode[] = [],
): SimplifiedNode[] {
if (node.cssStyles?.display === layoutType) {
results.push(node);
}
if (node.children) {
for (const child of node.children) {
findLayoutNodes(child, layoutType, results);
}
}
return results;
}
// Helper: Load raw fixture data
function loadFixture(name: string): unknown {
const filePath = path.join(fixturesDir, `${name}.json`);
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
// Helper: Load expected output if exists
function loadExpectedOutput(name: string): unknown | null {
const filePath = path.join(expectedDir, `${name}-optimized.json`);
if (fs.existsSync(filePath)) {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
return null;
}
describe("Layout Optimization Integration", () => {
TEST_FILES.forEach(({ name, desc }) => {
describe(`${name} (${desc})`, () => {
let rawData: unknown;
let result: ReturnType<typeof parseFigmaResponse>;
let originalSize: number;
let optimizedSize: number;
let stats: LayoutStats;
beforeAll(() => {
const filePath = path.join(fixturesDir, `${name}.json`);
if (!fs.existsSync(filePath)) {
throw new Error(`Test fixture not found: ${name}.json`);
}
rawData = loadFixture(name);
originalSize = fs.statSync(filePath).size;
result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
optimizedSize = Buffer.byteLength(JSON.stringify(result));
// Calculate layout stats
stats = {
total: 0,
flex: 0,
grid: 0,
flexRow: 0,
flexColumn: 0,
absolute: 0,
};
for (const node of result.nodes) {
countLayouts(node, stats);
}
});
describe("Data Compression", () => {
it("should achieve significant data compression", () => {
const compressionRate = (1 - optimizedSize / originalSize) * 100;
// Should achieve at least 50% compression
expect(compressionRate).toBeGreaterThan(50);
});
it("should reduce file size to under 100KB for typical nodes", () => {
// Optimized output should be reasonable size
expect(optimizedSize).toBeLessThan(100 * 1024);
});
});
describe("Layout Detection", () => {
it("should detect layout types (not all absolute)", () => {
// Should have some flex or grid layouts
expect(stats.flex + stats.grid).toBeGreaterThan(0);
});
it("should not have excessive absolute positioning", () => {
// Absolute positioning should not dominate in most cases
// Some fixtures may have higher absolute ratios due to their design
const absoluteRatio = stats.absolute / stats.total;
// Allow up to 90% for complex layouts, but ensure some semantic layouts exist
expect(absoluteRatio).toBeLessThan(0.9);
});
it("should have balanced flex row/column distribution", () => {
// At least some flex should be detected
expect(stats.flex).toBeGreaterThan(0);
});
});
describe("Flex Layout Properties", () => {
it("should have valid flex properties when flex is detected", () => {
const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
flexNodes.forEach((node) => {
expect(node.cssStyles?.display).toBe("flex");
// Direction should be defined
expect(["row", "column", undefined]).toContain(node.cssStyles?.flexDirection);
});
});
it("should have gap property for flex containers with spacing", () => {
const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
// At least some flex containers should have gap
const flexWithGap = flexNodes.filter((n) => n.cssStyles?.gap);
if (flexNodes.length > 2) {
expect(flexWithGap.length).toBeGreaterThan(0);
}
});
});
describe("Grid Layout Properties", () => {
it("should have valid grid properties when grid is detected", () => {
const gridNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "grid"));
gridNodes.forEach((node) => {
expect(node.cssStyles?.display).toBe("grid");
// Grid must have gridTemplateColumns
expect(node.cssStyles?.gridTemplateColumns).toBeDefined();
});
});
it("should have at least 4 children for grid containers", () => {
const gridNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "grid"));
gridNodes.forEach((node) => {
expect(node.children?.length).toBeGreaterThanOrEqual(4);
});
});
it("should have valid gridTemplateColumns format", () => {
const gridNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "grid"));
gridNodes.forEach((node) => {
const columns = node.cssStyles?.gridTemplateColumns;
if (columns) {
// Should be space-separated pixel values or repeat()
expect(columns).toMatch(/^(\d+px\s*)+$|^repeat\(/);
}
});
});
});
describe("Child Style Cleanup", () => {
it("should clean position:absolute from flex children", () => {
const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
flexNodes.forEach((parent) => {
parent.children?.forEach((child) => {
// Non-overlapping children should not have position:absolute
if (!hasOverlapWithSiblings(child, parent.children || [])) {
expect(child.cssStyles?.position).not.toBe("absolute");
}
});
});
});
it("should clean left/top from flex children", () => {
const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
flexNodes.forEach((parent) => {
parent.children?.forEach((child) => {
if (!hasOverlapWithSiblings(child, parent.children || [])) {
expect(child.cssStyles?.left).toBeUndefined();
expect(child.cssStyles?.top).toBeUndefined();
}
});
});
});
});
describe("Output Structure", () => {
it("should have correct response structure", () => {
expect(result).toHaveProperty("name");
expect(result).toHaveProperty("nodes");
expect(Array.isArray(result.nodes)).toBe(true);
});
it("should preserve node hierarchy", () => {
expect(result.nodes.length).toBeGreaterThan(0);
const rootNode = result.nodes[0];
expect(rootNode).toHaveProperty("id");
expect(rootNode).toHaveProperty("name");
expect(rootNode).toHaveProperty("type");
});
});
describe("Snapshot Comparison", () => {
it("should match expected output structure", () => {
const expected = loadExpectedOutput(name);
if (expected) {
// Compare node count
expect(result.nodes.length).toBe((expected as { nodes: unknown[] }).nodes.length);
}
});
it("should produce consistent layout stats", () => {
// Use inline snapshot for layout stats
expect({
total: stats.total,
flexRatio: Math.round((stats.flex / stats.total) * 100),
gridRatio: Math.round((stats.grid / stats.total) * 100),
absoluteRatio: Math.round((stats.absolute / stats.total) * 100),
}).toMatchSnapshot();
});
});
});
});
});
// Helper: Check if a node overlaps with its siblings
function hasOverlapWithSiblings(node: SimplifiedNode, siblings: SimplifiedNode[]): boolean {
const nodeRect = extractRect(node);
if (!nodeRect) return false;
return siblings.some((sibling) => {
if (sibling.id === node.id) return false;
const siblingRect = extractRect(sibling);
if (!siblingRect) return false;
return calculateIoU(nodeRect, siblingRect) > 0.1;
});
}
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
function extractRect(node: SimplifiedNode): Rect | null {
const styles = node.cssStyles;
if (!styles?.width || !styles?.height) return null;
return {
x: parseFloat(styles.left || "0"),
y: parseFloat(styles.top || "0"),
width: parseFloat(styles.width),
height: parseFloat(styles.height),
};
}
function calculateIoU(a: Rect, b: Rect): number {
const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
const intersection = xOverlap * yOverlap;
const areaA = a.width * a.height;
const areaB = b.width * b.height;
const union = areaA + areaB - intersection;
return union > 0 ? intersection / union : 0;
}
// ==================== Optimization Idempotency Tests ====================
describe("Layout Optimization Idempotency", () => {
// Helper: Count occurrences of a key-value pair in object tree
function countOccurrences(obj: unknown, key: string, value: string): number {
let count = 0;
function traverse(o: unknown): void {
if (o && typeof o === "object") {
if (Array.isArray(o)) {
o.forEach(traverse);
} else {
const record = o as Record<string, unknown>;
if (record[key] === value) count++;
Object.values(record).forEach(traverse);
}
}
}
traverse(obj);
return count;
}
// Helper: Count nodes with a specific property
function countProperty(obj: unknown, prop: string): number {
let count = 0;
function traverse(o: unknown): void {
if (o && typeof o === "object") {
if (Array.isArray(o)) {
o.forEach(traverse);
} else {
const record = o as Record<string, unknown>;
if (prop in record) count++;
Object.values(record).forEach(traverse);
}
}
}
traverse(obj);
return count;
}
TEST_FILES.forEach(({ name, desc }) => {
describe(`${name} (${desc})`, () => {
let optimizedData: SimplifiedDesign;
beforeAll(() => {
const filePath = path.join(expectedDir, `${name}-optimized.json`);
optimizedData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
});
it("should be idempotent (re-optimizing produces same result)", () => {
// Optimize again
const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
// Compare key metrics
const originalAbsolute = countOccurrences(optimizedData, "position", "absolute");
const reOptimizedAbsolute = countOccurrences(reOptimized, "position", "absolute");
const originalFlex = countOccurrences(optimizedData, "display", "flex");
const reOptimizedFlex = countOccurrences(reOptimized, "display", "flex");
const originalGrid = countOccurrences(optimizedData, "display", "grid");
const reOptimizedGrid = countOccurrences(reOptimized, "display", "grid");
// Re-optimization should not change layout counts significantly
// (small differences possible due to background merging on first pass)
expect(reOptimizedAbsolute).toBeLessThanOrEqual(originalAbsolute);
expect(reOptimizedFlex).toBeGreaterThanOrEqual(originalFlex);
expect(reOptimizedGrid).toBeGreaterThanOrEqual(originalGrid);
});
it("should maintain or reduce absolute positioning count", () => {
const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
const originalAbsolute = countOccurrences(optimizedData, "position", "absolute");
const reOptimizedAbsolute = countOccurrences(reOptimized, "position", "absolute");
// Absolute count should not increase
expect(reOptimizedAbsolute).toBeLessThanOrEqual(originalAbsolute);
});
it("should maintain or reduce left/top property count", () => {
const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
const originalLeft = countProperty(optimizedData, "left");
const reOptimizedLeft = countProperty(reOptimized, "left");
const originalTop = countProperty(optimizedData, "top");
const reOptimizedTop = countProperty(reOptimized, "top");
expect(reOptimizedLeft).toBeLessThanOrEqual(originalLeft);
expect(reOptimizedTop).toBeLessThanOrEqual(originalTop);
});
it("should produce stable output on second re-optimization", () => {
// First re-optimization
const firstPass = LayoutOptimizer.optimizeDesign(optimizedData);
// Second re-optimization
const secondPass = LayoutOptimizer.optimizeDesign(firstPass);
// After two passes, results should be identical (stable)
const firstPassJson = JSON.stringify(firstPass);
const secondPassJson = JSON.stringify(secondPass);
expect(secondPassJson).toBe(firstPassJson);
});
it("should have expected optimization metrics", () => {
const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
expect({
absoluteCount: countOccurrences(reOptimized, "position", "absolute"),
flexCount: countOccurrences(reOptimized, "display", "flex"),
gridCount: countOccurrences(reOptimized, "display", "grid"),
paddingCount: countProperty(reOptimized, "padding"),
}).toMatchSnapshot();
});
});
});
});
```
--------------------------------------------------------------------------------
/tests/unit/resources/figma-resources.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Figma Resources Unit Tests
*
* Tests the resource handlers that extract lightweight data from Figma files.
*/
import { describe, it, expect, vi } from "vitest";
import {
getFileMetadata,
getStyleTokens,
getComponentList,
getAssetList,
createFileMetadataTemplate,
createStylesTemplate,
createComponentsTemplate,
createAssetsTemplate,
FIGMA_MCP_HELP,
} from "~/resources/figma-resources.js";
import type { SimplifiedDesign, SimplifiedNode } from "~/types/index.js";
import type { FigmaService } from "~/services/figma.js";
// ==================== Mock Types ====================
type MockFigmaService = Pick<
FigmaService,
"getFile" | "getNode" | "getImages" | "getImageFills" | "getRateLimitInfo"
>;
// ==================== Mock Data ====================
const createMockNode = (overrides: Partial<SimplifiedNode> = {}): SimplifiedNode => ({
id: "node-1",
name: "Test Node",
type: "FRAME",
...overrides,
});
const createMockDesign = (overrides: Partial<SimplifiedDesign> = {}): SimplifiedDesign => ({
name: "Test Design",
lastModified: "2024-01-15T10:30:00Z",
thumbnailUrl: "https://figma.com/thumbnail.png",
nodes: [],
...overrides,
});
// Mock FigmaService
const createMockFigmaService = (design: SimplifiedDesign): MockFigmaService => ({
getFile: vi.fn().mockResolvedValue(design),
getNode: vi.fn().mockResolvedValue(design),
getImages: vi.fn().mockResolvedValue([]),
getImageFills: vi.fn().mockResolvedValue([]),
getRateLimitInfo: vi.fn().mockReturnValue(null),
});
// ==================== Tests ====================
describe("Figma Resources", () => {
describe("getFileMetadata", () => {
it("should extract basic file metadata", async () => {
const mockDesign = createMockDesign({
name: "My Design File",
lastModified: "2024-03-20T15:00:00Z",
nodes: [
createMockNode({
id: "page-1",
name: "Page 1",
type: "CANVAS",
children: [createMockNode(), createMockNode()],
}),
createMockNode({
id: "page-2",
name: "Page 2",
type: "CANVAS",
children: [createMockNode()],
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const metadata = await getFileMetadata(mockService as FigmaService, "test-file-key");
expect(metadata.name).toBe("My Design File");
expect(metadata.lastModified).toBe("2024-03-20T15:00:00Z");
expect(metadata.pages).toHaveLength(2);
expect(metadata.pages[0]).toEqual({
id: "page-1",
name: "Page 1",
childCount: 2,
});
expect(metadata.pages[1]).toEqual({
id: "page-2",
name: "Page 2",
childCount: 1,
});
});
it("should call getFile with depth 1", async () => {
const mockDesign = createMockDesign();
const mockService = createMockFigmaService(mockDesign);
await getFileMetadata(mockService as FigmaService, "test-key");
expect(mockService.getFile).toHaveBeenCalledWith("test-key", 1);
});
it("should handle files with no pages", async () => {
const mockDesign = createMockDesign({ nodes: [] });
const mockService = createMockFigmaService(mockDesign);
const metadata = await getFileMetadata(mockService as FigmaService, "test-key");
expect(metadata.pages).toHaveLength(0);
});
it("should filter out non-CANVAS nodes from pages", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({ id: "page-1", name: "Page", type: "CANVAS" }),
createMockNode({ id: "frame-1", name: "Frame", type: "FRAME" }),
createMockNode({ id: "page-2", name: "Page 2", type: "CANVAS" }),
],
});
const mockService = createMockFigmaService(mockDesign);
const metadata = await getFileMetadata(mockService as FigmaService, "test-key");
expect(metadata.pages).toHaveLength(2);
expect(metadata.pages.every((p) => p.name.startsWith("Page"))).toBe(true);
});
});
describe("getStyleTokens", () => {
it("should extract colors from node CSS", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
name: "Primary Button",
cssStyles: {
backgroundColor: "#24C790",
color: "#FFFFFF",
},
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const styles = await getStyleTokens(mockService as FigmaService, "test-key");
expect(styles.colors.length).toBeGreaterThan(0);
expect(styles.colors.some((c) => c.hex === "#24C790")).toBe(true);
});
it("should extract typography from node CSS", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
name: "Heading",
cssStyles: {
fontFamily: "Inter",
fontSize: "24px",
fontWeight: "700",
lineHeight: "32px",
},
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const styles = await getStyleTokens(mockService as FigmaService, "test-key");
expect(styles.typography.length).toBeGreaterThan(0);
expect(styles.typography[0]).toMatchObject({
fontFamily: "Inter",
fontSize: 24,
fontWeight: 700,
});
});
it("should extract shadow effects", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
name: "Card",
cssStyles: {
boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.1)",
},
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const styles = await getStyleTokens(mockService as FigmaService, "test-key");
expect(styles.effects.length).toBeGreaterThan(0);
expect(styles.effects[0].type).toBe("shadow");
});
it("should deduplicate colors", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({ cssStyles: { backgroundColor: "#FF0000" } }),
createMockNode({ cssStyles: { backgroundColor: "#FF0000" } }),
createMockNode({ cssStyles: { backgroundColor: "#00FF00" } }),
],
});
const mockService = createMockFigmaService(mockDesign);
const styles = await getStyleTokens(mockService as FigmaService, "test-key");
const redColors = styles.colors.filter((c) => c.hex === "#FF0000");
expect(redColors.length).toBe(1);
});
it("should limit results to avoid token bloat", async () => {
// Create many nodes with unique colors
const nodes = Array.from({ length: 50 }, (_, i) =>
createMockNode({
name: `Node ${i}`,
cssStyles: { backgroundColor: `#${i.toString(16).padStart(6, "0")}` },
}),
);
const mockDesign = createMockDesign({ nodes });
const mockService = createMockFigmaService(mockDesign);
const styles = await getStyleTokens(mockService as FigmaService, "test-key");
expect(styles.colors.length).toBeLessThanOrEqual(20);
});
it("should recursively extract from children", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
name: "Parent",
children: [
createMockNode({
name: "Child",
cssStyles: { backgroundColor: "#AABBCC" },
}),
],
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const styles = await getStyleTokens(mockService as FigmaService, "test-key");
expect(styles.colors.some((c) => c.hex === "#AABBCC")).toBe(true);
});
});
describe("getComponentList", () => {
it("should find COMPONENT nodes", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
id: "comp-1",
name: "Button",
type: "COMPONENT",
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const components = await getComponentList(mockService as FigmaService, "test-key");
expect(components).toHaveLength(1);
expect(components[0]).toMatchObject({
id: "comp-1",
name: "Button",
type: "COMPONENT",
});
});
it("should find COMPONENT_SET nodes with variants", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
id: "set-1",
name: "Button",
type: "COMPONENT_SET",
children: [
createMockNode({ name: "Primary" }),
createMockNode({ name: "Secondary" }),
createMockNode({ name: "Outline" }),
],
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const components = await getComponentList(mockService as FigmaService, "test-key");
expect(components).toHaveLength(1);
expect(components[0].type).toBe("COMPONENT_SET");
expect(components[0].variants).toEqual(["Primary", "Secondary", "Outline"]);
});
it("should limit variants to 5", async () => {
const variants = Array.from({ length: 10 }, (_, i) =>
createMockNode({ name: `Variant ${i}` }),
);
const mockDesign = createMockDesign({
nodes: [
createMockNode({
id: "set-1",
name: "Button",
type: "COMPONENT_SET",
children: variants,
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const components = await getComponentList(mockService as FigmaService, "test-key");
expect(components[0].variants).toHaveLength(5);
});
it("should find nested components", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
name: "Page",
type: "CANVAS",
children: [
createMockNode({
name: "Components",
type: "FRAME",
children: [
createMockNode({ id: "c1", name: "Button", type: "COMPONENT" }),
createMockNode({ id: "c2", name: "Input", type: "COMPONENT" }),
],
}),
],
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const components = await getComponentList(mockService as FigmaService, "test-key");
expect(components).toHaveLength(2);
});
it("should limit to 50 components", async () => {
const nodes = Array.from({ length: 60 }, (_, i) =>
createMockNode({
id: `comp-${i}`,
name: `Component ${i}`,
type: "COMPONENT",
}),
);
const mockDesign = createMockDesign({ nodes });
const mockService = createMockFigmaService(mockDesign);
const components = await getComponentList(mockService as FigmaService, "test-key");
expect(components.length).toBeLessThanOrEqual(50);
});
});
describe("getAssetList", () => {
it("should find nodes with exportInfo", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
id: "icon-1",
name: "arrow-right",
type: "VECTOR",
exportInfo: { type: "IMAGE", format: "SVG" },
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const assets = await getAssetList(mockService as FigmaService, "test-key");
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({
nodeId: "icon-1",
name: "arrow-right",
type: "icon",
exportFormats: ["SVG"],
});
});
it("should identify icons by type VECTOR", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
id: "icon-1",
name: "small-icon",
type: "VECTOR",
exportInfo: { type: "IMAGE", format: "SVG" },
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const assets = await getAssetList(mockService as FigmaService, "test-key");
expect(assets[0].type).toBe("icon");
});
it("should identify large exports as vector type", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
id: "illustration-1",
name: "hero-image",
type: "FRAME",
exportInfo: { type: "IMAGE_GROUP", format: "SVG" },
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const assets = await getAssetList(mockService as FigmaService, "test-key");
expect(assets[0].type).toBe("vector");
});
it("should find nodes with image fills", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
id: "img-1",
name: "photo",
type: "RECTANGLE",
fills: [{ type: "IMAGE", imageRef: "img:abc123" }],
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const assets = await getAssetList(mockService as FigmaService, "test-key");
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({
nodeId: "img-1",
name: "photo",
type: "image",
imageRef: "img:abc123",
});
});
it("should find nested assets", async () => {
const mockDesign = createMockDesign({
nodes: [
createMockNode({
name: "Card",
children: [
createMockNode({
id: "icon",
name: "icon",
type: "VECTOR",
exportInfo: { type: "IMAGE", format: "SVG" },
}),
createMockNode({
id: "image",
name: "thumbnail",
fills: [{ type: "IMAGE", imageRef: "img:xyz" }],
}),
],
}),
],
});
const mockService = createMockFigmaService(mockDesign);
const assets = await getAssetList(mockService as FigmaService, "test-key");
expect(assets).toHaveLength(2);
});
it("should limit to 100 assets", async () => {
const nodes = Array.from({ length: 150 }, (_, i) =>
createMockNode({
id: `asset-${i}`,
name: `Asset ${i}`,
type: "VECTOR",
exportInfo: { type: "IMAGE", format: "SVG" },
}),
);
const mockDesign = createMockDesign({ nodes });
const mockService = createMockFigmaService(mockDesign);
const assets = await getAssetList(mockService as FigmaService, "test-key");
expect(assets.length).toBeLessThanOrEqual(100);
});
});
describe("Resource Templates", () => {
it("should create file metadata template with correct URI pattern", () => {
const template = createFileMetadataTemplate();
expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}");
});
it("should create styles template with correct URI pattern", () => {
const template = createStylesTemplate();
expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}/styles");
});
it("should create components template with correct URI pattern", () => {
const template = createComponentsTemplate();
expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}/components");
});
it("should create assets template with correct URI pattern", () => {
const template = createAssetsTemplate();
expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}/assets");
});
});
describe("Help Content", () => {
it("should contain resource documentation", () => {
expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}");
expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}/styles");
expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}/components");
expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}/assets");
});
it("should explain token costs", () => {
expect(FIGMA_MCP_HELP).toContain("Token cost");
});
it("should explain how to get fileKey", () => {
expect(FIGMA_MCP_HELP).toContain("fileKey");
expect(FIGMA_MCP_HELP).toContain("figma.com");
});
});
});
```
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { FigmaService, type FigmaError } from "./services/figma.js";
import express, { type Request, type Response } from "express";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { type IncomingMessage, type ServerResponse } from "http";
import { type Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { SimplifiedDesign } from "./types/index.js";
import {
DESIGN_TO_CODE_PROMPT,
COMPONENT_ANALYSIS_PROMPT,
STYLE_EXTRACTION_PROMPT,
} from "./prompts/index.js";
import {
getFileMetadata,
getStyleTokens,
getComponentList,
getAssetList,
createFileMetadataTemplate,
createStylesTemplate,
createComponentsTemplate,
createAssetsTemplate,
FIGMA_MCP_HELP,
} from "./resources/index.js";
// ==================== Logging Utilities ====================
export const Logger = {
log: (..._args: unknown[]) => {},
error: (..._args: unknown[]) => {},
};
// ==================== Error Formatting ====================
/**
* Check if error is a Figma API error
*/
function isFigmaError(error: unknown): error is FigmaError {
return (
typeof error === "object" &&
error !== null &&
"status" in error &&
typeof (error as FigmaError).status === "number"
);
}
/**
* Format error information for AI understanding
*/
function formatErrorForAI(error: unknown, context: string): string {
if (isFigmaError(error)) {
const parts: string[] = [`[Figma API Error] ${context}`];
parts.push(`Status: ${error.status}`);
parts.push(`Message: ${error.err}`);
if (error.rateLimitInfo) {
const { remaining, resetAfter, retryAfter } = error.rateLimitInfo;
if (remaining !== null) parts.push(`Rate Limit Remaining: ${remaining}`);
if (retryAfter !== null) parts.push(`Retry After: ${retryAfter} seconds`);
if (resetAfter !== null) parts.push(`Reset After: ${resetAfter} seconds`);
}
return parts.join("\n");
}
if (error instanceof Error) {
return `[Error] ${context}: ${error.message}`;
}
return `[Error] ${context}: ${String(error)}`;
}
// ==================== MCP Server ====================
export class FigmaMcpServer {
private readonly server: McpServer;
private readonly figmaService: FigmaService;
private sseTransport: SSEServerTransport | null = null;
constructor(figmaApiKey: string) {
this.figmaService = new FigmaService(figmaApiKey);
this.server = new McpServer(
{
name: "Figma MCP Server",
version: "1.0.2",
},
{
capabilities: {
logging: {},
tools: {},
prompts: {},
resources: {},
},
},
);
this.registerTools();
this.registerPrompts();
this.registerResources();
}
private registerTools(): void {
// Tool: Get Figma data
this.server.tool(
"get_figma_data",
"Get layout and style information from a Figma file or specific node. " +
"Returns simplified design data including CSS styles, text content, and export info. " +
"Results are cached for 24 hours to reduce API calls.",
{
fileKey: z
.string()
.describe(
"The key of the Figma file to fetch, found in URL like figma.com/(file|design)/<fileKey>/...",
),
nodeId: z
.string()
.optional()
.describe(
"The ID of a specific node to fetch (e.g., '1234:5678'), found as URL parameter node-id=<nodeId>. Use this for better performance with large files.",
),
depth: z
.number()
.optional()
.describe(
"How many levels deep to traverse the node tree (1-100). Only use if explicitly needed.",
),
},
async ({ fileKey, nodeId, depth }) => {
try {
Logger.log(
`Fetching ${depth ? `${depth} layers deep` : "all layers"} of ${
nodeId ? `node ${nodeId} from file` : `full file`
} ${fileKey}`,
);
let file: SimplifiedDesign;
if (nodeId) {
file = await this.figmaService.getNode(fileKey, nodeId, depth);
} else {
file = await this.figmaService.getFile(fileKey, depth);
}
Logger.log(`Successfully fetched file: ${file.name}`);
const { nodes, ...metadata } = file;
// Serialize in segments to handle large files
const nodesJson = `[${nodes.map((node) => JSON.stringify(node, null, 2)).join(",")}]`;
const metadataJson = JSON.stringify(metadata, null, 2);
const resultJson = `{ "metadata": ${metadataJson}, "nodes": ${nodesJson} }`;
// Add cache status information
const rateLimitInfo = this.figmaService.getRateLimitInfo();
let statusNote = "";
if (rateLimitInfo && rateLimitInfo.remaining !== null) {
statusNote = `\n\n[API Status] Rate limit remaining: ${rateLimitInfo.remaining}`;
}
return {
content: [{ type: "text", text: resultJson + statusNote }],
};
} catch (error) {
Logger.error(`Error fetching file ${fileKey}:`, error);
const errorMessage = formatErrorForAI(
error,
`Failed to fetch Figma data for file ${fileKey}`,
);
return {
isError: true,
content: [{ type: "text", text: errorMessage }],
};
}
},
);
// Tool: Download images
this.server.tool(
"download_figma_images",
"Download SVG and PNG images from a Figma file. " +
"Supports both rendered node images and image fills. " +
"Images are cached locally to avoid repeated downloads.",
{
fileKey: z.string().describe("The key of the Figma file containing the images"),
nodes: z
.object({
nodeId: z
.string()
.describe("The ID of the Figma image node to fetch (e.g., '1234:5678')"),
imageRef: z
.string()
.optional()
.describe(
"Required for image fills (background images). Leave blank for vector/icon SVGs.",
),
fileName: z
.string()
.describe("The local filename to save as (e.g., 'icon.svg', 'photo.png')"),
})
.array()
.describe("Array of image nodes to download"),
localPath: z
.string()
.describe(
"Absolute path to the directory where images should be saved. Directories will be created if needed.",
),
},
async ({ fileKey, nodes, localPath }) => {
try {
// Classify processing: image fills vs rendered nodes
const imageFills = nodes.filter(({ imageRef }) => !!imageRef) as {
nodeId: string;
imageRef: string;
fileName: string;
}[];
const renderRequests = nodes
.filter(({ imageRef }) => !imageRef)
.map(({ nodeId, fileName }) => ({
nodeId,
fileName,
fileType: fileName.toLowerCase().endsWith(".svg")
? ("svg" as const)
: ("png" as const),
}));
// Execute sequentially to reduce rate limit risk
const fillResults = await this.figmaService.getImageFills(fileKey, imageFills, localPath);
const renderResults = await this.figmaService.getImages(
fileKey,
renderRequests,
localPath,
);
const allDownloads = [...fillResults, ...renderResults];
const successfulDownloads = allDownloads.filter((path) => path && path.length > 0);
const failedCount = allDownloads.length - successfulDownloads.length;
let resultMessage: string;
if (successfulDownloads.length === allDownloads.length) {
resultMessage = `Successfully downloaded ${successfulDownloads.length} images:\n${successfulDownloads.join("\n")}`;
} else if (successfulDownloads.length > 0) {
resultMessage = `Downloaded ${successfulDownloads.length}/${allDownloads.length} images (${failedCount} failed):\n${successfulDownloads.join("\n")}`;
} else {
resultMessage = `Failed to download any images. Please check the node IDs and try again.`;
}
return {
content: [{ type: "text", text: resultMessage }],
};
} catch (error) {
Logger.error(`Error downloading images from file ${fileKey}:`, error);
const errorMessage = formatErrorForAI(
error,
`Failed to download images from file ${fileKey}`,
);
return {
isError: true,
content: [{ type: "text", text: errorMessage }],
};
}
},
);
}
private registerPrompts(): void {
// Prompt: Design to Code - Full workflow
this.server.prompt(
"design_to_code",
"Complete workflow for converting Figma designs to production-ready code with project analysis",
{
framework: z
.enum(["react", "vue", "html", "auto"])
.optional()
.describe("Target framework for code generation (default: auto-detect from project)"),
includeResponsive: z
.boolean()
.optional()
.describe("Include responsive/mobile adaptation guidelines (default: true)"),
},
async ({ framework, includeResponsive }) => {
let prompt = DESIGN_TO_CODE_PROMPT;
// Add framework-specific context
if (framework && framework !== "auto") {
prompt += `\n\n## Framework Context\nTarget framework: **${framework.toUpperCase()}**\n`;
if (framework === "vue") {
prompt += `- USE Vue 3 Composition API with <script setup>
- USE defineProps/defineEmits for component interface
- PREFER template syntax over JSX`;
} else if (framework === "react") {
prompt += `- USE functional components with hooks
- USE TypeScript for props interface
- PREFER named exports for components`;
}
}
// Add responsive guidelines toggle
if (includeResponsive === false) {
prompt += `\n\n## Note\nSkip Phase 6 (Responsive Adaptation) - desktop only implementation required.`;
}
return {
messages: [
{
role: "user",
content: {
type: "text",
text: prompt,
},
},
],
};
},
);
// Prompt: Component Analysis
this.server.prompt(
"analyze_components",
"Analyze Figma design to identify optimal component structure and reusability",
{},
async () => {
return {
messages: [
{
role: "user",
content: {
type: "text",
text: COMPONENT_ANALYSIS_PROMPT,
},
},
],
};
},
);
// Prompt: Style Extraction
this.server.prompt(
"extract_styles",
"Extract design tokens (colors, typography, spacing) from Figma design data",
{},
async () => {
return {
messages: [
{
role: "user",
content: {
type: "text",
text: STYLE_EXTRACTION_PROMPT,
},
},
],
};
},
);
}
private registerResources(): void {
// Static Resource: Help guide
this.server.resource(
"figma_help",
"figma://help",
{
description: "Figma MCP Server usage guide and resource documentation",
mimeType: "text/markdown",
},
async () => {
return {
contents: [
{
uri: "figma://help",
mimeType: "text/markdown",
text: FIGMA_MCP_HELP,
},
],
};
},
);
// Template Resource: File metadata
this.server.resource(
"figma_file",
createFileMetadataTemplate(),
{
description: "Get Figma file metadata (name, pages, last modified). Low token cost (~200).",
mimeType: "application/json",
},
async (uri, variables) => {
const fileKey = variables.fileKey as string;
if (!fileKey) {
throw new Error("fileKey is required");
}
try {
const metadata = await getFileMetadata(this.figmaService, fileKey);
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(metadata, null, 2),
},
],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to fetch file metadata: ${message}`);
}
},
);
// Template Resource: Style tokens
this.server.resource(
"figma_styles",
createStylesTemplate(),
{
description:
"Extract design tokens (colors, typography, effects) from Figma file. Token cost ~500.",
mimeType: "application/json",
},
async (uri, variables) => {
const fileKey = variables.fileKey as string;
if (!fileKey) {
throw new Error("fileKey is required");
}
try {
const styles = await getStyleTokens(this.figmaService, fileKey);
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(styles, null, 2),
},
],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to fetch styles: ${message}`);
}
},
);
// Template Resource: Component list
this.server.resource(
"figma_components",
createComponentsTemplate(),
{
description: "List all components and component sets in Figma file. Token cost ~300.",
mimeType: "application/json",
},
async (uri, variables) => {
const fileKey = variables.fileKey as string;
if (!fileKey) {
throw new Error("fileKey is required");
}
try {
const components = await getComponentList(this.figmaService, fileKey);
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(components, null, 2),
},
],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to fetch components: ${message}`);
}
},
);
// Template Resource: Asset list
this.server.resource(
"figma_assets",
createAssetsTemplate(),
{
description:
"List exportable assets (icons, images, vectors) with node IDs for download. Token cost ~400.",
mimeType: "application/json",
},
async (uri, variables) => {
const fileKey = variables.fileKey as string;
if (!fileKey) {
throw new Error("fileKey is required");
}
try {
const assets = await getAssetList(this.figmaService, fileKey);
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(assets, null, 2),
},
],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to fetch assets: ${message}`);
}
},
);
}
async connect(transport: Transport): Promise<void> {
await this.server.connect(transport);
Logger.log = (...args: unknown[]) => {
this.server.server.sendLoggingMessage({
level: "info",
data: args,
});
};
Logger.error = (...args: unknown[]) => {
this.server.server.sendLoggingMessage({
level: "error",
data: args,
});
};
Logger.log("Server connected and ready to process requests");
}
async startHttpServer(port: number): Promise<void> {
const app = express();
app.get("/sse", async (req: Request, res: Response) => {
console.log("New SSE connection established");
this.sseTransport = new SSEServerTransport(
"/messages",
res as unknown as ServerResponse<IncomingMessage>,
);
await this.server.connect(this.sseTransport);
});
app.post("/messages", async (req: Request, res: Response) => {
if (!this.sseTransport) {
res.sendStatus(400);
return;
}
await this.sseTransport.handlePostMessage(
req as unknown as IncomingMessage,
res as unknown as ServerResponse<IncomingMessage>,
);
});
Logger.log = console.log;
Logger.error = console.error;
app.listen(port, () => {
Logger.log(`HTTP server listening on port ${port}`);
Logger.log(`SSE endpoint available at http://localhost:${port}/sse`);
Logger.log(`Message endpoint available at http://localhost:${port}/messages`);
});
}
}
```
--------------------------------------------------------------------------------
/tests/utils/viewer.html:
--------------------------------------------------------------------------------
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Figma 节点 CSS 样式查看器</title>
<style>
:root {
--primary-color: #1E88E5;
--secondary-color: #757575;
--background-color: #FAFAFA;
--card-background: #FFFFFF;
--border-color: #E0E0E0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: var(--background-color);
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
background-color: var(--card-background);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
}
h1 {
color: var(--primary-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
margin-top: 0;
}
.info-box {
background-color: #E3F2FD;
border-left: 4px solid var(--primary-color);
padding: 10px 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.file-input-container {
display: flex;
margin-bottom: 20px;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
input[type="file"] {
flex: 1;
min-width: 300px;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
}
button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #1565C0;
}
.tabs {
display: flex;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
.tab {
padding: 10px 20px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab.active {
color: var(--primary-color);
border-bottom: 2px solid var(--primary-color);
font-weight: 500;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.nodes {
font-family: monospace;
white-space: pre-wrap;
padding: 15px;
background-color: #F5F5F5;
border-radius: 4px;
overflow: auto;
max-height: 600px;
}
.css-styles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.style-card {
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
.style-preview {
height: 120px;
display: flex;
justify-content: center;
align-items: center;
}
.style-info {
padding: 15px;
border-top: 1px solid var(--border-color);
background-color: #F5F5F5;
}
.style-name {
font-weight: 500;
margin-bottom: 5px;
}
.style-properties {
font-family: monospace;
font-size: 13px;
}
.property {
margin: 3px 0;
}
.color-box {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 3px;
margin-right: 6px;
vertical-align: middle;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.search-container {
margin-bottom: 15px;
}
#nodeSearch {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
margin-bottom: 10px;
}
.tree-view {
font-family: monospace;
line-height: 1.5;
}
.tree-item {
margin: 2px 0;
cursor: pointer;
}
.tree-toggle {
display: inline-block;
width: 16px;
text-align: center;
user-select: none;
}
.tree-content {
padding-left: 20px;
display: none;
}
.tree-content.expanded {
display: block;
}
.selected {
background-color: #E3F2FD;
border-radius: 3px;
}
#nodeDetails {
margin-top: 20px;
padding: 15px;
background-color: #F5F5F5;
border-radius: 4px;
display: none;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
.detail-section {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px;
background-color: white;
}
.detail-title {
font-weight: 500;
margin-bottom: 10px;
color: var(--primary-color);
}
.detail-content {
max-height: 300px;
overflow: auto;
}
.css-preview {
border: 1px solid #ddd;
padding: 15px;
margin-top: 10px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>Figma 节点 CSS 样式查看器</h1>
<div class="info-box">
此工具用于查看Figma节点数据及其CSS样式转换结果。您可以上传JSON文件或加载示例数据。
</div>
<div class="file-input-container">
<input type="file" id="fileInput" accept=".json">
<button id="loadFile">加载文件</button>
<button id="loadSample">加载示例数据</button>
</div>
<div class="tabs">
<div class="tab active" data-tab="nodeTree">节点树</div>
<div class="tab" data-tab="cssStyles">CSS 样式</div>
</div>
<div id="nodeTree" class="tab-content active">
<div class="search-container">
<input type="text" id="nodeSearch" placeholder="搜索节点名称...">
</div>
<div class="tree-view" id="nodeTreeView"></div>
<div id="nodeDetails">
<h3>节点详情</h3>
<div class="detail-grid">
<div class="detail-section">
<div class="detail-title">基本信息</div>
<div class="detail-content" id="nodeBasicInfo"></div>
</div>
<div class="detail-section">
<div class="detail-title">CSS 样式</div>
<div class="detail-content" id="nodeCssStyles"></div>
<div class="css-preview" id="cssPreview"></div>
</div>
</div>
</div>
</div>
<div id="cssStyles" class="tab-content">
<div id="cssStylesContent" class="css-styles"></div>
</div>
</div>
<script>
let figmaData = null
// DOM元素
const fileInput = document.getElementById('fileInput')
const loadFileBtn = document.getElementById('loadFile')
const loadSampleBtn = document.getElementById('loadSample')
const tabs = document.querySelectorAll('.tab')
const tabContents = document.querySelectorAll('.tab-content')
const nodeTreeView = document.getElementById('nodeTreeView')
const nodeSearch = document.getElementById('nodeSearch')
const nodeDetails = document.getElementById('nodeDetails')
const nodeBasicInfo = document.getElementById('nodeBasicInfo')
const nodeCssStyles = document.getElementById('nodeCssStyles')
const cssPreview = document.getElementById('cssPreview')
const cssStylesContent = document.getElementById('cssStylesContent')
// 初始化
document.addEventListener('DOMContentLoaded', () => {
// 加载示例数据(如果在同目录下存在)
try {
fetch('./simplified-with-css.json')
.then(response => {
if (!response.ok) throw new Error('示例数据未找到')
return response.json()
})
.then(data => {
figmaData = data
renderData()
})
.catch(err => console.log('未找到示例数据,请上传文件'))
} catch (e) {
console.log('未找到示例数据,请上传文件')
}
})
// 事件监听器
loadFileBtn.addEventListener('click', () => {
if (fileInput.files.length > 0) {
const file = fileInput.files[0]
const reader = new FileReader()
reader.onload = (e) => {
try {
figmaData = JSON.parse(e.target.result)
renderData()
} catch (err) {
alert('JSON解析错误: ' + err.message)
}
}
reader.readAsText(file)
} else {
alert('请选择一个JSON文件')
}
})
loadSampleBtn.addEventListener('click', async () => {
try {
const response = await fetch('./simplified-with-css.json')
if (!response.ok) throw new Error('示例数据未找到')
figmaData = await response.json()
renderData()
} catch (err) {
alert('加载示例数据失败: ' + err.message)
}
})
// 标签切换
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const tabId = tab.getAttribute('data-tab')
tabs.forEach(t => t.classList.remove('active'))
tabContents.forEach(tc => tc.classList.remove('active'))
tab.classList.add('active')
document.getElementById(tabId).classList.add('active')
})
})
// 搜索功能
nodeSearch.addEventListener('input', () => {
const searchTerm = nodeSearch.value.toLowerCase()
const treeItems = document.querySelectorAll('.tree-item')
treeItems.forEach(item => {
const text = item.textContent.toLowerCase()
if (text.includes(searchTerm)) {
item.style.display = 'block'
// 展开父级
let parent = item.parentElement
while (parent && parent.classList.contains('tree-content')) {
parent.classList.add('expanded')
parent = parent.parentElement.parentElement
}
} else {
item.style.display = 'none'
}
})
})
// 渲染数据
function renderData() {
if (!figmaData) return
// 渲染节点树
renderNodeTree()
// 渲染CSS样式
renderCssStyles()
}
// 渲染节点树
function renderNodeTree() {
nodeTreeView.innerHTML = ''
if (figmaData.nodes && Array.isArray(figmaData.nodes)) {
figmaData.nodes.forEach(node => {
nodeTreeView.appendChild(createTreeItem(node))
})
}
}
// 创建树项
function createTreeItem(node, level = 0) {
const item = document.createElement('div')
item.className = 'tree-item'
item.dataset.nodeId = node.id || ''
const hasChildren = node.children && node.children.length > 0
const toggle = document.createElement('span')
toggle.className = 'tree-toggle'
toggle.textContent = hasChildren ? '▶' : ' '
const label = document.createElement('span')
label.className = 'tree-label'
label.textContent = `${node.name || 'Unnamed'} (${node.type || 'Unknown'})`
item.appendChild(toggle)
item.appendChild(label)
if (hasChildren) {
const content = document.createElement('div')
content.className = 'tree-content'
node.children.forEach(child => {
content.appendChild(createTreeItem(child, level + 1))
})
toggle.addEventListener('click', () => {
toggle.textContent = content.classList.toggle('expanded') ? '▼' : '▶'
})
item.appendChild(content)
}
// 点击查看节点详情
item.addEventListener('click', (e) => {
if (e.target !== toggle) {
document.querySelectorAll('.tree-item').forEach(i => i.classList.remove('selected'))
item.classList.add('selected')
showNodeDetails(node)
}
e.stopPropagation()
})
return item
}
// 显示节点详情
function showNodeDetails(node) {
nodeDetails.style.display = 'block'
// 基本信息
nodeBasicInfo.innerHTML = `
<div><strong>ID:</strong> ${node.id || 'N/A'}</div>
<div><strong>名称:</strong> ${node.name || 'Unnamed'}</div>
<div><strong>类型:</strong> ${node.type || 'Unknown'}</div>
${node.boundingBox ? `
<div><strong>位置:</strong> X: ${node.boundingBox.x.toFixed(2)}, Y: ${node.boundingBox.y.toFixed(2)}</div>
<div><strong>尺寸:</strong> W: ${node.boundingBox.width.toFixed(2)}, H: ${node.boundingBox.height.toFixed(2)}</div>
` : ''}
`
// CSS样式
if (node.cssStyles && Object.keys(node.cssStyles).length > 0) {
let cssStylesHtml = '<div class="properties">'
for (const [property, value] of Object.entries(node.cssStyles)) {
cssStylesHtml += `
<div class="property">
${property.includes('color') || property.includes('background') ?
`<span class="color-box" style="background-color: ${value}"></span>` : ''}
<strong>${property}:</strong> ${value}
</div>
`
}
cssStylesHtml += '</div>'
nodeCssStyles.innerHTML = cssStylesHtml
// CSS预览
let styles = ''
for (const [property, value] of Object.entries(node.cssStyles)) {
styles += `${property}: ${value};\n`
}
cssPreview.innerHTML = `
<div class="detail-title">预览</div>
<div style="${styles} border: 1px dashed #ccc; min-height: 50px; display: flex; align-items: center; justify-content: center;">
${node.type === 'TEXT' && node.characters ? node.characters : 'CSS样式预览'}
</div>
<pre style="margin-top: 10px;">${styles}</pre>
`
cssPreview.style.display = 'block'
} else {
nodeCssStyles.innerHTML = '<div>该节点没有CSS样式</div>'
cssPreview.style.display = 'none'
}
}
// 渲染CSS样式
function renderCssStyles() {
cssStylesContent.innerHTML = ''
if (!figmaData.nodes) return
// 收集所有样式
const stylesMap = new Map()
function collectStyles(nodes) {
if (!Array.isArray(nodes)) return
nodes.forEach(node => {
if (node.cssStyles && Object.keys(node.cssStyles).length > 0) {
const styleKey = JSON.stringify(node.cssStyles)
if (!stylesMap.has(styleKey)) {
stylesMap.set(styleKey, {
styles: node.cssStyles,
count: 1,
nodeName: node.name,
nodeType: node.type
})
} else {
const info = stylesMap.get(styleKey)
info.count++
}
}
if (node.children) {
collectStyles(node.children)
}
})
}
collectStyles(figmaData.nodes)
// 按使用频率排序并仅显示前50个样式
const sortedStyles = Array.from(stylesMap.entries())
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 50)
// 创建样式卡片
sortedStyles.forEach(([styleKey, info]) => {
const { styles, count, nodeName, nodeType } = info
const card = document.createElement('div')
card.className = 'style-card'
let preview = ''
if (styles.backgroundColor) {
preview = `background-color: ${styles.backgroundColor};`
} else if (styles.color) {
preview = `color: ${styles.color}; background-color: #f0f0f0;`
}
let stylesStr = ''
for (const [property, value] of Object.entries(styles)) {
stylesStr += `${property}: ${value};\n`
}
card.innerHTML = `
<div class="style-preview" style="${preview}">
<div style="${Object.entries(styles).map(([p, v]) => `${p}: ${v}`).join('; ')}">
${nodeType === 'TEXT' ? '文本样式示例' : '样式预览'}
</div>
</div>
<div class="style-info">
<div class="style-name">${nodeName || 'Unnamed'} (${nodeType || 'Unknown'}) - 使用 ${count} 次</div>
<div class="style-properties">
${Object.entries(styles).map(([property, value]) => `
<div class="property">
${property.includes('color') || property.includes('background') ?
`<span class="color-box" style="background-color: ${value}"></span>` : ''}
<strong>${property}:</strong> ${value}
</div>
`).join('')}
</div>
</div>
`
cssStylesContent.appendChild(card)
})
}
</script>
</body>
</html>
```
--------------------------------------------------------------------------------
/src/core/parser.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Figma Response Parser
*
* Core parsing logic for converting Figma API responses to simplified
* node structures. Handles node extraction, style processing, and
* image resource detection.
*
* @module core/parser
*/
import type {
GetFileNodesResponse,
Node as FigmaDocumentNode,
GetFileResponse,
} from "@figma/rest-api-spec";
import { generateCSSShorthand } from "~/utils/css.js";
import { isVisible, isVisibleInParent } from "~/utils/validation.js";
import { convertColor, formatRGBAColor } from "~/utils/color.js";
import { isRectangleCornerRadii, hasValue } from "~/utils/validation.js";
import { buildSimplifiedEffects } from "~/core/effects.js";
import { buildSimplifiedStrokes } from "~/core/style.js";
import { generateFileName } from "~/utils/file.js";
import { LayoutOptimizer } from "~/algorithms/layout/optimizer.js";
import { formatPxValue } from "~/utils/css.js";
import { analyzeNodeTree, type FigmaNode } from "~/algorithms/icon/index.js";
import type {
CSSStyle,
TextStyle,
SimplifiedDesign,
SimplifiedNode,
ExportInfo,
ImageResource,
IconDetectionResult,
} from "~/types/index.js";
// ==================== Node Utilities ====================
/** Node with fill properties */
interface NodeWithFills {
fills?: Array<{ type: string; imageRef?: string }>;
}
/** Node with styles and children */
interface NodeWithChildren {
id: string;
name: string;
type: string;
cssStyles?: { backgroundImage?: string; top?: string; left?: string };
children?: NodeWithChildren[];
exportInfo?: ExportInfo;
}
/**
* Check whether the node has an image fill
*/
export function hasImageFill(node: NodeWithFills): boolean {
return node.fills?.some((fill) => fill.type === "IMAGE" && fill.imageRef) || false;
}
/**
* Detect and mark image groups
*/
export function detectAndMarkImageGroup(
node: NodeWithChildren,
suggestExportFormat: (node: NodeWithChildren) => string,
generateFileNameFn: (name: string, format: string) => string,
): void {
// Only handle groups and frames
if (node.type !== "GROUP" && node.type !== "FRAME") return;
// Without children it cannot be an image group
if (!node.children || node.children.length === 0) return;
// Check whether all children are image types
const allChildrenAreImages = node.children.every(
(child) =>
child.type === "IMAGE" ||
(child.type === "RECTANGLE" && hasImageFill(child as NodeWithFills)) ||
(child.type === "ELLIPSE" && hasImageFill(child as NodeWithFills)) ||
(child.type === "VECTOR" && hasImageFill(child as NodeWithFills)) ||
(child.type === "FRAME" && child.cssStyles?.backgroundImage),
);
// Mark the node as an image group
if (allChildrenAreImages) {
const format = suggestExportFormat(node);
node.exportInfo = {
type: "IMAGE_GROUP",
format: format as "PNG" | "JPG" | "SVG",
nodeId: node.id,
fileName: generateFileNameFn(node.name, format),
};
// Remove child information and export as a whole
delete node.children;
}
}
/**
* Sort nodes by position (top to bottom, left to right)
*/
export function sortNodesByPosition<T extends { cssStyles?: { top?: string; left?: string } }>(
nodes: T[],
): T[] {
return [...nodes].sort((a, b) => {
// Sort by the top value (top to bottom)
const aTop = a.cssStyles?.top ? parseFloat(a.cssStyles.top) : 0;
const bTop = b.cssStyles?.top ? parseFloat(b.cssStyles.top) : 0;
if (aTop !== bTop) {
return aTop - bTop;
}
// When top values are equal, sort by left (left to right)
const aLeft = a.cssStyles?.left ? parseFloat(a.cssStyles.left) : 0;
const bLeft = b.cssStyles?.left ? parseFloat(b.cssStyles.left) : 0;
return aLeft - bLeft;
});
}
/**
* Clean up temporary computed properties
*/
export function cleanupTemporaryProperties(node: SimplifiedNode): void {
// Remove absolute coordinates
delete node._absoluteX;
delete node._absoluteY;
// Recursively clean child nodes
if (node.children && node.children.length > 0) {
node.children.forEach(cleanupTemporaryProperties);
}
}
// ==================== Main Parser ====================
/**
* Parse Figma API response to simplified design structure
*/
export function parseFigmaResponse(data: GetFileResponse | GetFileNodesResponse): SimplifiedDesign {
// Extract basic information
const { name, lastModified, thumbnailUrl } = data;
// Process nodes
let nodes: FigmaDocumentNode[] = [];
if ("document" in data) {
// If it's a response for the entire file
nodes = data.document.children;
} else if ("nodes" in data) {
// If it's a response for specific nodes
const nodeData = Object.values(data.nodes).filter(
(node): node is NonNullable<typeof node> =>
node !== null && typeof node === "object" && "document" in node,
);
nodes = nodeData.map((n) => (n as { document: FigmaDocumentNode }).document);
}
// Use the new icon detection algorithm to analyze the node tree
// Build icon ID map for fast lookup
const iconMap = new Map<string, IconDetectionResult>();
for (const node of nodes) {
const { exportableIcons } = analyzeNodeTree(node as unknown as FigmaNode);
for (const icon of exportableIcons) {
iconMap.set(icon.nodeId, icon);
}
}
// Extract nodes and generate simplified data, passing in the icon map
const simplifiedNodes = extractNodes(nodes, undefined, iconMap);
// Clean up temporary properties
simplifiedNodes.forEach(cleanupTemporaryProperties);
// Apply layout optimization
const optimizedDesign = LayoutOptimizer.optimizeDesign({
name,
lastModified,
thumbnailUrl: thumbnailUrl || "",
nodes: simplifiedNodes,
});
return optimizedDesign;
}
// ==================== Node Extraction ====================
/**
* Extract multiple nodes from Figma response
*/
function extractNodes(
children: FigmaDocumentNode[],
parentNode?: SimplifiedNode,
iconMap?: Map<string, IconDetectionResult>,
): SimplifiedNode[] {
if (!Array.isArray(children)) return [];
// Create a corresponding original parent node object for visibility judgment
const parentForVisibility = parentNode
? {
clipsContent: (parentNode as any).clipsContent,
absoluteBoundingBox:
parentNode._absoluteX !== undefined && parentNode._absoluteY !== undefined
? {
x: parentNode._absoluteX,
y: parentNode._absoluteY,
width: parseFloat(parentNode.cssStyles?.width || "0"),
height: parseFloat(parentNode.cssStyles?.height || "0"),
}
: undefined,
}
: undefined;
const visibilityFilter = (node: FigmaDocumentNode) => {
// Use type guard to ensure only checking nodes with necessary properties
const nodeForVisibility = {
visible: (node as any).visible,
opacity: (node as any).opacity,
absoluteBoundingBox: (node as any).absoluteBoundingBox,
absoluteRenderBounds: (node as any).absoluteRenderBounds,
};
// If there's no parent node information, only check the node's own visibility
if (!parentForVisibility) {
return isVisible(nodeForVisibility);
}
// If there's a parent node, also consider the parent's clipping effect
return isVisibleInParent(nodeForVisibility, parentForVisibility);
};
const nodes = children
.filter(visibilityFilter)
.map((node) => extractNode(node, parentNode, iconMap))
.filter((node): node is SimplifiedNode => node !== null);
// Sort sibling elements by top value (from top to bottom)
return sortNodesByPosition(nodes);
}
/**
* Extract single node information
* Use the new icon detection algorithm to handle icon merging
*/
function extractNode(
node: FigmaDocumentNode,
parentNode?: SimplifiedNode,
iconMap?: Map<string, IconDetectionResult>,
): SimplifiedNode | null {
if (!node) return null;
const { id, name, type } = node;
// Check if this is an icon node that needs to be exported
const iconInfo = iconMap?.get(id);
if (iconInfo && iconInfo.shouldMerge) {
// This is an icon node, export as a whole, don't process child nodes
const result: SimplifiedNode = {
id,
name,
type,
};
result.cssStyles = {};
// Add size information
if (hasValue("absoluteBoundingBox", node) && node.absoluteBoundingBox) {
result.cssStyles.width = formatPxValue(node.absoluteBoundingBox.width);
result.cssStyles.height = formatPxValue(node.absoluteBoundingBox.height);
if ((node.type as string) !== "DOCUMENT" && (node.type as string) !== "CANVAS") {
result.cssStyles.position = "absolute";
result._absoluteX = node.absoluteBoundingBox.x;
result._absoluteY = node.absoluteBoundingBox.y;
if (
parentNode &&
parentNode._absoluteX !== undefined &&
parentNode._absoluteY !== undefined
) {
result.cssStyles.left = formatPxValue(node.absoluteBoundingBox.x - parentNode._absoluteX);
result.cssStyles.top = formatPxValue(node.absoluteBoundingBox.y - parentNode._absoluteY);
} else {
result.cssStyles.left = formatPxValue(node.absoluteBoundingBox.x);
result.cssStyles.top = formatPxValue(node.absoluteBoundingBox.y);
}
}
}
// Set export information
result.exportInfo = {
type: "IMAGE",
format: iconInfo.exportFormat,
fileName: generateFileName(name, iconInfo.exportFormat),
};
// Don't process child nodes, export as a whole image
return result;
}
// Create basic node object
const result: SimplifiedNode = {
id,
name,
type,
};
// Set CSS styles
result.cssStyles = {};
// Add CSS conversion logic for size and position
if (hasValue("absoluteBoundingBox", node) && node.absoluteBoundingBox) {
// Add to CSS styles (using optimized precision)
result.cssStyles.width = formatPxValue(node.absoluteBoundingBox.width);
result.cssStyles.height = formatPxValue(node.absoluteBoundingBox.height);
// Add positioning information for non-root nodes
if ((node.type as string) !== "DOCUMENT" && (node.type as string) !== "CANVAS") {
result.cssStyles.position = "absolute";
// Store original coordinates for child nodes to calculate relative positions
result._absoluteX = node.absoluteBoundingBox.x;
result._absoluteY = node.absoluteBoundingBox.y;
// If there's a parent node, calculate relative position
if (
parentNode &&
parentNode._absoluteX !== undefined &&
parentNode._absoluteY !== undefined
) {
result.cssStyles.left = formatPxValue(node.absoluteBoundingBox.x - parentNode._absoluteX);
result.cssStyles.top = formatPxValue(node.absoluteBoundingBox.y - parentNode._absoluteY);
} else {
// Otherwise use absolute position (top-level elements)
result.cssStyles.left = formatPxValue(node.absoluteBoundingBox.x);
result.cssStyles.top = formatPxValue(node.absoluteBoundingBox.y);
}
}
}
// Process text - preserve original text content
if (hasValue("characters", node) && typeof node.characters === "string") {
result.text = node.characters;
// For text nodes, add text color style
if (hasValue("fills", node) && Array.isArray(node.fills) && node.fills.length > 0) {
const fill = node.fills[0];
if (fill.type === "SOLID" && fill.color) {
// Use convertColor to get hex format color
const { hex, opacity } = convertColor(fill.color, fill.opacity ?? 1);
// If opacity is 1, use hex format, otherwise use rgba format
result.cssStyles.color = opacity === 1 ? hex : formatRGBAColor(fill.color, opacity);
}
}
}
// Extract image information
processImageResources(node, result, iconMap);
// Extract common property processing logic
processNodeStyle(node, result);
processFills(node, result);
processStrokes(node, result);
processEffects(node, result);
processCornerRadius(node, result);
// Recursively process child nodes
if (hasValue("children", node) && Array.isArray(node.children) && node.children.length) {
result.children = extractNodes(node.children, result, iconMap);
// Process image groups (keep original logic for handling image fill cases)
markImageGroup(result);
}
return result;
}
/**
* Wrapper for detectAndMarkImageGroup with default format suggestion
*/
function markImageGroup(node: SimplifiedNode): void {
detectAndMarkImageGroup(node, () => "PNG", generateFileName);
}
// ==================== Style Processing ====================
/**
* Extract image resources from the node
* Icon export is already handled by iconMap, only process image fills here
*/
function processImageResources(
node: FigmaDocumentNode,
result: SimplifiedNode,
iconMap?: Map<string, IconDetectionResult>,
): void {
// If already marked as icon export, skip
if (iconMap?.has(result.id)) {
return;
}
// Check image resources in fills and background
const imageResources: ImageResource[] = [];
// Extract image resources from fills
if (hasValue("fills", node) && Array.isArray(node.fills)) {
const fillImages = node.fills
.filter((fill) => fill.type === "IMAGE" && (fill as { imageRef?: string }).imageRef)
.map((fill) => ({
imageRef: (fill as { imageRef: string }).imageRef,
}));
imageResources.push(...fillImages);
}
// Extract image resources from background
if (hasValue("background", node) && Array.isArray(node.background)) {
const bgImages = node.background
.filter((bg) => bg.type === "IMAGE" && (bg as { imageRef?: string }).imageRef)
.map((bg) => ({
imageRef: (bg as { imageRef: string }).imageRef,
}));
imageResources.push(...bgImages);
}
// If image resources are found, save and add export information
if (imageResources.length > 0) {
// Set CSS background image property - use the first image
if (!result.cssStyles) {
result.cssStyles = {};
}
const primaryImage = imageResources[0];
result.cssStyles.backgroundImage = `url({{FIGMA_IMAGE:${primaryImage.imageRef}}})`;
// Add export information (omit nodeId as it's the same as node id)
result.exportInfo = {
type: "IMAGE",
format: "PNG",
// nodeId omitted because it's the same as node id, can be obtained from node id when downloading
fileName: generateFileName(result.name, "PNG"),
};
}
}
/**
* Process node's style properties
*/
function processNodeStyle(node: FigmaDocumentNode, result: SimplifiedNode): void {
if (!hasValue("style", node)) return;
const style = node.style as any;
// Convert text style
const textStyle: TextStyle = {
fontFamily: style?.fontFamily,
fontSize: style?.fontSize,
fontWeight: style?.fontWeight,
textAlignHorizontal: style?.textAlignHorizontal,
textAlignVertical: style?.textAlignVertical,
};
// Process line height
if (style?.lineHeightPx) {
const cssStyle = textStyleToCss(textStyle);
cssStyle.lineHeight = formatPxValue(style.lineHeightPx);
Object.assign(result.cssStyles!, cssStyle);
} else {
Object.assign(result.cssStyles!, textStyleToCss(textStyle));
}
}
/** Gradient paint type for type narrowing */
interface GradientPaint {
type: string;
gradientHandlePositions?: Array<{ x: number; y: number }>;
gradientStops?: Array<{
position: number;
color: { r: number; g: number; b: number; a: number };
}>;
}
/**
* Process gradient fills, convert to CSS linear-gradient
*
* Figma gradient coordinate system:
* - Origin (0,0) is at top-left
* - x-axis points right as positive
* - y-axis points down as positive
*
* CSS gradient angles:
* - 0deg from bottom to top
* - 90deg from left to right
* - 180deg from top to bottom
* - 270deg from right to left
*/
function processGradient(gradient: GradientPaint): string {
if (!gradient.gradientHandlePositions || !gradient.gradientStops) return "";
const stops = gradient.gradientStops
.map((stop) => {
const { hex, opacity } = convertColor(stop.color);
// Use rgba format if alpha < 1, otherwise use hex
const colorStr = opacity < 1 ? formatRGBAColor(stop.color) : hex;
return `${colorStr} ${Math.round(stop.position * 100)}%`;
})
.join(", ");
const [start, end] = gradient.gradientHandlePositions;
// Calculate the angle in Figma (x-axis positive direction is 0 degrees, counter-clockwise is positive)
const figmaAngle = Math.atan2(end.y - start.y, end.x - start.x) * (180 / Math.PI);
// Convert to CSS angle:
// CSS 0deg is upward, rotating clockwise
// Figma angle needs to add 90 degrees (because Figma 0 degree is rightward, CSS 0 degree is upward)
const cssAngle = Math.round((figmaAngle + 90 + 360) % 360);
return `linear-gradient(${cssAngle}deg, ${stops})`;
}
/**
* Process node's fill properties
*/
function processFills(node: FigmaDocumentNode, result: SimplifiedNode): void {
if (!hasValue("fills", node) || !Array.isArray(node.fills) || node.fills.length === 0) return;
// Skip image fills
if (hasImageFill(node)) {
return;
}
const fills = node.fills.filter(isVisible);
if (fills.length === 0) return;
const fill = fills[0];
if (fill.type === "SOLID" && fill.color) {
const { hex, opacity } = convertColor(fill.color, fill.opacity ?? 1);
const color = opacity === 1 ? hex : formatRGBAColor(fill.color, opacity);
if (node.type === "TEXT") {
result.cssStyles!.color = color;
} else {
result.cssStyles!.backgroundColor = color;
}
} else if (fill.type === "GRADIENT_LINEAR") {
const gradient = processGradient(fill as unknown as GradientPaint);
if (node.type === "TEXT") {
result.cssStyles!.background = gradient;
result.cssStyles!.webkitBackgroundClip = "text";
result.cssStyles!.backgroundClip = "text";
result.cssStyles!.webkitTextFillColor = "transparent";
} else {
result.cssStyles!.background = gradient;
}
}
}
/**
* Process node's stroke properties
*/
function processStrokes(node: FigmaDocumentNode, result: SimplifiedNode): void {
if ((node as any).type === "TEXT") return;
const strokes = buildSimplifiedStrokes(node);
if (strokes.colors.length === 0) return;
const stroke = strokes.colors[0];
// Handle string colors (hex or rgba) - already converted by parsePaint
if (typeof stroke === "string") {
result.cssStyles!.borderColor = stroke;
if (strokes.strokeWeight) {
result.cssStyles!.borderWidth = strokes.strokeWeight;
}
result.cssStyles!.borderStyle = "solid";
}
// Handle object fills
else if (typeof stroke === "object" && "type" in stroke) {
if (stroke.type === "SOLID" && "color" in stroke) {
// SimplifiedSolidFill - color is already a string
result.cssStyles!.borderColor = stroke.color;
if (strokes.strokeWeight) {
result.cssStyles!.borderWidth = strokes.strokeWeight;
}
result.cssStyles!.borderStyle = "solid";
} else if (stroke.type === "GRADIENT_LINEAR") {
// For gradient strokes, we need to build gradient from original data
// SimplifiedGradientFill doesn't have the raw color data anymore
// So we use border-image with a simple fallback
if ("gradientStops" in stroke && stroke.gradientStops && stroke.gradientStops.length > 0) {
const stops = stroke.gradientStops
.map((s) => `${s.color} ${Math.round(s.position * 100)}%`)
.join(", ");
result.cssStyles!.borderImage = `linear-gradient(90deg, ${stops})`;
result.cssStyles!.borderImageSlice = "1";
}
if (strokes.strokeWeight) {
result.cssStyles!.borderWidth = strokes.strokeWeight;
}
}
}
}
/**
* Process node's effects properties
*/
function processEffects(node: FigmaDocumentNode, result: SimplifiedNode): void {
const effects = buildSimplifiedEffects(node);
if (effects.boxShadow) result.cssStyles!.boxShadow = effects.boxShadow;
if (effects.filter) result.cssStyles!.filter = effects.filter;
if (effects.backdropFilter) result.cssStyles!.backdropFilter = effects.backdropFilter;
}
/**
* Process node's corner radius properties
*/
function processCornerRadius(node: FigmaDocumentNode, result: SimplifiedNode): void {
if (!hasValue("cornerRadius", node)) return;
if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
// Process uniform corner radius (rounded)
result.cssStyles!.borderRadius = formatPxValue(node.cornerRadius);
} else if (
node.cornerRadius === "mixed" &&
hasValue("rectangleCornerRadii", node, isRectangleCornerRadii)
) {
// Process non-uniform corner radius (top-left, top-right, bottom-right, bottom-left) - rounded
result.cssStyles!.borderRadius =
generateCSSShorthand({
top: Math.round(node.rectangleCornerRadii[0]),
right: Math.round(node.rectangleCornerRadii[1]),
bottom: Math.round(node.rectangleCornerRadii[2]),
left: Math.round(node.rectangleCornerRadii[3]),
}) || "0";
}
}
/**
* Convert text style to CSS style
* @param textStyle Figma text style
* @returns CSS style object (default values omitted)
*/
function textStyleToCss(textStyle: TextStyle): CSSStyle {
const cssStyle: CSSStyle = {};
if (textStyle.fontFamily) cssStyle.fontFamily = textStyle.fontFamily;
if (textStyle.fontSize) cssStyle.fontSize = formatPxValue(textStyle.fontSize);
// fontWeight: omit default value 400
if (textStyle.fontWeight && textStyle.fontWeight !== 400) {
cssStyle.fontWeight = textStyle.fontWeight;
}
// Process text alignment (omit default value 'left')
if (textStyle.textAlignHorizontal) {
switch (textStyle.textAlignHorizontal) {
case "LEFT":
// Omit default value
break;
case "CENTER":
cssStyle.textAlign = "center";
break;
case "RIGHT":
cssStyle.textAlign = "right";
break;
case "JUSTIFIED":
cssStyle.textAlign = "justify";
break;
}
}
// Process vertical alignment (omit default value 'top')
if (textStyle.textAlignVertical) {
switch (textStyle.textAlignVertical) {
case "TOP":
// Omit default value
break;
case "CENTER":
cssStyle.verticalAlign = "middle";
break;
case "BOTTOM":
cssStyle.verticalAlign = "bottom";
break;
}
}
return cssStyle;
}
```
--------------------------------------------------------------------------------
/tests/fixtures/expected/node-402-34955-optimized.json:
--------------------------------------------------------------------------------
```json
{
"name": "Untitled",
"lastModified": "2025-12-05T16:16:42Z",
"thumbnailUrl": "https://s3-alpha.figma.com/thumbnails/6d4c4440-1098-402c-b983-1bf44c182966?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAQ4GOSFWCXJW6HYPC%2F20251204%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251204T000000Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=d3f06b6fe775803929be1e1b720481975e85de86c48cc9eceead613e35ad8501",
"nodes": [
{
"id": "402:34955",
"name": "Group 1410104853",
"type": "GROUP",
"cssStyles": {
"width": "1580px",
"height": "895px",
"position": "absolute",
"left": "29147px",
"top": "88511px"
},
"children": [
{
"id": "398:31938",
"name": "Rectangle 34",
"type": "RECTANGLE",
"cssStyles": {
"width": "1580px",
"height": "895px",
"position": "absolute",
"left": "0px",
"top": "0px",
"backgroundColor": "#FFFFFF",
"borderRadius": "12px"
}
},
{
"id": "398:34954",
"name": "Group 1410104852",
"type": "GROUP",
"cssStyles": {
"width": "1580px",
"height": "340px",
"position": "absolute",
"left": "0px",
"top": "19px",
"display": "grid",
"gridTemplateColumns": "500px 500px 500px",
"gap": "12px",
"padding": "170px 26px 0px"
},
"children": [
{
"id": "398:31982",
"name": "Group 1410104526",
"type": "GROUP",
"cssStyles": {
"width": "320px",
"height": "41px",
"position": "absolute",
"left": "630px",
"top": "0px",
"display": "flex",
"justifyContent": "space-between",
"alignItems": "flex-start"
},
"children": [
{
"id": "398:31983",
"name": "Group 1410104524",
"type": "GROUP",
"cssStyles": {
"width": "160px",
"height": "41px",
"display": "flex",
"flexDirection": "column",
"gap": "16px",
"justifyContent": "space-between",
"alignItems": "center"
},
"children": [
{
"id": "398:31984",
"name": "Add Keywords",
"type": "TEXT",
"cssStyles": {
"width": "111px",
"height": "22px",
"color": "#009663",
"fontFamily": "PingFang SC",
"fontSize": "16px",
"fontWeight": 600,
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": "22px"
},
"text": "Add Keywords"
},
{
"id": "398:31985",
"name": "Rectangle 34626003",
"type": "RECTANGLE",
"cssStyles": {
"width": "160px",
"height": "3px",
"backgroundColor": "#24C790"
}
}
]
},
{
"id": "398:31986",
"name": "Group 1410104525",
"type": "GROUP",
"cssStyles": {
"width": "160px",
"height": "41px",
"display": "flex",
"flexDirection": "column",
"gap": "16px",
"justifyContent": "space-between",
"alignItems": "center"
},
"children": [
{
"id": "398:31987",
"name": "Detected Alerts",
"type": "TEXT",
"cssStyles": {
"width": "116px",
"height": "22px",
"color": "#666666",
"fontFamily": "PingFang SC",
"fontSize": "16px",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": "22px"
},
"text": "Detected Alerts"
},
{
"id": "398:31988",
"name": "Rectangle 34626001",
"type": "RECTANGLE",
"cssStyles": {
"width": "160px",
"height": "3px",
"backgroundColor": "#FFFFFF"
}
}
]
}
]
},
{
"id": "398:31981",
"name": "Vector 512 (Stroke)",
"type": "VECTOR",
"cssStyles": {
"width": "1580px",
"height": "1px",
"position": "absolute",
"left": "0px",
"top": "41px",
"backgroundColor": "rgba(66, 149, 136, 0.2)"
}
},
{
"id": "398:31966",
"name": "Group 1410104484",
"type": "GROUP",
"cssStyles": {
"width": "1528px",
"height": "88px",
"position": "absolute",
"left": "26px",
"top": "62px",
"backgroundColor": "#F4F3F6",
"borderRadius": "16px"
},
"children": [
{
"id": "398:31968",
"name": "Group 1410104477",
"type": "GROUP",
"cssStyles": {
"width": "109px",
"height": "22px",
"position": "absolute",
"left": "24px",
"top": "18px",
"borderRadius": "3px",
"display": "flex",
"gap": "8px",
"justifyContent": "space-between",
"alignItems": "flex-start"
},
"children": [
{
"id": "398:31970",
"name": "已添加关键词",
"type": "TEXT",
"cssStyles": {
"width": "96px",
"height": "22px",
"color": "#000000",
"fontFamily": "PingFang SC",
"fontSize": "16px",
"fontWeight": 600,
"verticalAlign": "middle",
"lineHeight": "22px"
},
"text": "已添加关键词"
},
{
"id": "398:31969",
"name": "Rectangle 34626011",
"type": "RECTANGLE",
"cssStyles": {
"width": "5px",
"height": "18px",
"backgroundColor": "#24C790",
"borderRadius": "3px"
}
}
]
},
{
"id": "398:31972",
"name": "4/20",
"type": "TEXT",
"cssStyles": {
"width": "33px",
"height": "20px",
"position": "absolute",
"left": "141px",
"top": "19px",
"color": "#999999",
"fontFamily": "PingFang SC",
"fontSize": "14px",
"verticalAlign": "middle",
"lineHeight": "20px"
},
"text": "4/20"
},
{
"id": "398:31978",
"name": "Link",
"type": "FRAME",
"cssStyles": {
"width": "200px",
"height": "42px",
"position": "absolute",
"left": "1108px",
"top": "23px",
"backgroundColor": "#FFFFFF",
"borderRadius": "8px"
},
"children": [
{
"id": "398:31980",
"name": "Container",
"type": "FRAME",
"cssStyles": {
"width": "200px",
"height": "42px",
"position": "absolute",
"left": "0px",
"top": "0px",
"borderRadius": "8px"
}
},
{
"id": "398:31979",
"name": "添加自定义关键词",
"type": "TEXT",
"cssStyles": {
"width": "115px",
"height": "16px",
"position": "absolute",
"left": "43px",
"top": "13px",
"color": "#333333",
"fontFamily": "Roboto",
"fontSize": "14px",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": "16px"
},
"text": "添加自定义关键词"
}
]
},
{
"id": "398:31973",
"name": "Link",
"type": "FRAME",
"cssStyles": {
"width": "170px",
"height": "42px",
"position": "absolute",
"left": "1328px",
"top": "23px",
"backgroundColor": "#24C790",
"borderRadius": "8px"
},
"children": [
{
"id": "398:31974",
"name": "Group 1410104508",
"type": "GROUP",
"cssStyles": {
"width": "112px",
"height": "20px",
"position": "absolute",
"left": "29px",
"top": "10px",
"display": "flex",
"gap": "6px",
"justifyContent": "space-between",
"alignItems": "flex-start"
},
"children": [
{
"id": "398:31976",
"name": "Frame",
"type": "FRAME",
"cssStyles": {
"width": "20px",
"height": "20px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "frame.svg"
}
},
{
"id": "398:31975",
"name": "AI生成关键词",
"type": "TEXT",
"cssStyles": {
"width": "86px",
"height": "16px",
"color": "#FFFFFF",
"fontFamily": "Roboto",
"fontSize": "14px",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": "16px"
},
"text": "AI生成关键词"
}
]
}
]
},
{
"id": "398:31971",
"name": "添加自定义关键词,启动分类即可检测分类下的关键词。",
"type": "TEXT",
"cssStyles": {
"width": "350px",
"height": "20px",
"position": "absolute",
"left": "24px",
"top": "50px",
"color": "#999999",
"fontFamily": "PingFang SC",
"fontSize": "14px",
"verticalAlign": "middle",
"lineHeight": "20px"
},
"text": "添加自定义关键词,启动分类即可检测分类下的关键词。"
}
]
},
{
"id": "398:31946",
"name": "Group 1410104481",
"type": "COMPONENT",
"cssStyles": {
"width": "500px",
"height": "78px",
"backgroundColor": "#FFFFFF",
"borderRadius": "16px",
"borderWidth": "1px",
"borderStyle": "solid",
"borderColor": "#C4C4C4"
},
"children": [
{
"id": "398:31950",
"name": "Frame 1410086320",
"type": "FRAME",
"cssStyles": {
"width": "70px",
"height": "42px",
"position": "absolute",
"left": "22px",
"top": "18px",
"display": "flex",
"flexDirection": "column",
"gap": "4px",
"justifyContent": "space-between",
"alignItems": "flex-start"
},
"children": [
{
"id": "398:31951",
"name": "Frame 1410086319",
"type": "FRAME",
"cssStyles": {
"width": "70px",
"height": "20px",
"display": "flex",
"gap": "10px",
"justifyContent": "space-between",
"alignItems": "center",
"padding": "0px 28px 0px 0px"
},
"children": [
{
"id": "398:31952",
"name": "早恋",
"type": "TEXT",
"cssStyles": {
"width": "28px",
"height": "20px",
"color": "#000000",
"fontFamily": "PingFang SC",
"fontSize": "14px",
"fontWeight": 500,
"verticalAlign": "middle",
"lineHeight": "20px"
},
"text": "早恋"
},
{
"id": "398:31953",
"name": "Vector 585",
"type": "VECTOR",
"cssStyles": {
"width": "4px",
"height": "8px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "vector_585.svg"
}
}
]
},
{
"id": "398:31954",
"name": "8 个关键词",
"type": "TEXT",
"cssStyles": {
"width": "70px",
"height": "17px",
"color": "#999999",
"fontFamily": "PingFang SC",
"fontSize": "12px",
"verticalAlign": "middle",
"lineHeight": "17px"
},
"text": "8 个关键词"
}
]
},
{
"id": "398:31948",
"name": "Frame",
"type": "FRAME",
"cssStyles": {
"width": "34px",
"height": "34px",
"position": "absolute",
"left": "442px",
"top": "22px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "frame.svg"
}
}
]
},
{
"id": "398:31956",
"name": "Group 1410104482",
"type": "FRAME",
"cssStyles": {
"width": "500px",
"height": "78px",
"backgroundColor": "#FFFFFF",
"borderRadius": "16px",
"borderWidth": "1px",
"borderStyle": "solid",
"borderColor": "#C4C4C4"
},
"children": [
{
"id": "398:31960",
"name": "Frame 1410086320",
"type": "FRAME",
"cssStyles": {
"width": "70px",
"height": "42px",
"position": "absolute",
"left": "22px",
"top": "18px",
"display": "flex",
"flexDirection": "column",
"gap": "4px",
"justifyContent": "space-between",
"alignItems": "flex-start"
},
"children": [
{
"id": "398:31961",
"name": "Frame 1410086319",
"type": "FRAME",
"cssStyles": {
"width": "70px",
"height": "20px",
"display": "flex",
"gap": "10px",
"justifyContent": "space-between",
"alignItems": "center"
},
"children": [
{
"id": "398:31962",
"name": "早恋",
"type": "TEXT",
"cssStyles": {
"width": "56px",
"height": "20px",
"color": "#000000",
"fontFamily": "PingFang SC",
"fontSize": "14px",
"fontWeight": 500,
"verticalAlign": "middle",
"lineHeight": "20px"
},
"text": "校园暴力"
},
{
"id": "398:31963",
"name": "Vector 585",
"type": "VECTOR",
"cssStyles": {
"width": "4px",
"height": "8px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "vector_585.svg"
}
}
]
},
{
"id": "398:31964",
"name": "8 个关键词",
"type": "TEXT",
"cssStyles": {
"width": "70px",
"height": "17px",
"color": "#999999",
"fontFamily": "PingFang SC",
"fontSize": "12px",
"verticalAlign": "middle",
"lineHeight": "17px"
},
"text": "67 个关键词"
}
]
},
{
"id": "398:31958",
"name": "Frame",
"type": "FRAME",
"cssStyles": {
"width": "34px",
"height": "34px",
"position": "absolute",
"left": "442px",
"top": "22px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "frame.svg"
}
}
]
},
{
"id": "398:31965",
"name": "Group 1410104483",
"type": "INSTANCE",
"cssStyles": {
"width": "500px",
"height": "78px",
"backgroundColor": "#FFFFFF",
"borderRadius": "16px",
"borderWidth": "1px",
"borderStyle": "solid",
"borderColor": "#C4C4C4"
},
"children": [
{
"id": "I398:31965;398:31950",
"name": "Frame 1410086320",
"type": "FRAME",
"cssStyles": {
"width": "70px",
"height": "42px",
"position": "absolute",
"left": "22px",
"top": "18px",
"display": "flex",
"flexDirection": "column",
"gap": "4px",
"justifyContent": "space-between",
"alignItems": "flex-start"
},
"children": [
{
"id": "I398:31965;398:31951",
"name": "Frame 1410086319",
"type": "FRAME",
"cssStyles": {
"width": "70px",
"height": "20px",
"display": "flex",
"gap": "10px",
"justifyContent": "space-between",
"alignItems": "center"
},
"children": [
{
"id": "I398:31965;398:31952",
"name": "早恋",
"type": "TEXT",
"cssStyles": {
"width": "56px",
"height": "20px",
"color": "#000000",
"fontFamily": "PingFang SC",
"fontSize": "14px",
"fontWeight": 500,
"verticalAlign": "middle",
"lineHeight": "20px"
},
"text": "露骨低俗"
},
{
"id": "I398:31965;398:31953",
"name": "Vector 585",
"type": "VECTOR",
"cssStyles": {
"width": "4px",
"height": "8px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "vector_585.svg"
}
}
]
},
{
"id": "I398:31965;398:31954",
"name": "8 个关键词",
"type": "TEXT",
"cssStyles": {
"width": "70px",
"height": "17px",
"color": "#999999",
"fontFamily": "PingFang SC",
"fontSize": "12px",
"verticalAlign": "middle",
"lineHeight": "17px"
},
"text": "13 个关键词"
}
]
},
{
"id": "I398:31965;398:31948",
"name": "Frame",
"type": "FRAME",
"cssStyles": {
"width": "34px",
"height": "34px",
"position": "absolute",
"left": "442px",
"top": "22px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "frame.svg"
}
}
]
},
{
"id": "398:31955",
"name": "Group 1410104485",
"type": "INSTANCE",
"cssStyles": {
"width": "500px",
"height": "78px",
"backgroundColor": "#FFFFFF",
"borderRadius": "16px",
"borderWidth": "1px",
"borderStyle": "solid",
"borderColor": "#C4C4C4"
},
"children": [
{
"id": "I398:31955;398:31950",
"name": "Frame 1410086320",
"type": "FRAME",
"cssStyles": {
"width": "70px",
"height": "42px",
"position": "absolute",
"left": "22px",
"top": "18px",
"display": "flex",
"flexDirection": "column",
"gap": "4px",
"justifyContent": "space-between",
"alignItems": "flex-start"
},
"children": [
{
"id": "I398:31955;398:31951",
"name": "Frame 1410086319",
"type": "FRAME",
"cssStyles": {
"width": "70px",
"height": "20px",
"display": "flex",
"gap": "10px",
"justifyContent": "space-between",
"alignItems": "center"
},
"children": [
{
"id": "I398:31955;398:31952",
"name": "早恋",
"type": "TEXT",
"cssStyles": {
"width": "56px",
"height": "20px",
"color": "#000000",
"fontFamily": "PingFang SC",
"fontSize": "14px",
"fontWeight": 500,
"verticalAlign": "middle",
"lineHeight": "20px"
},
"text": "色情违法"
},
{
"id": "I398:31955;398:31953",
"name": "Vector 585",
"type": "VECTOR",
"cssStyles": {
"width": "4px",
"height": "8px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "vector_585.svg"
}
}
]
},
{
"id": "I398:31955;398:31954",
"name": "8 个关键词",
"type": "TEXT",
"cssStyles": {
"width": "70px",
"height": "17px",
"color": "#999999",
"fontFamily": "PingFang SC",
"fontSize": "12px",
"verticalAlign": "middle",
"lineHeight": "17px"
},
"text": "32 个关键词"
}
]
},
{
"id": "I398:31955;398:31948",
"name": "Frame",
"type": "FRAME",
"cssStyles": {
"width": "34px",
"height": "34px",
"position": "absolute",
"left": "442px",
"top": "22px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "frame.svg"
}
}
]
}
]
}
]
}
]
}
```
--------------------------------------------------------------------------------
/docs/en/layout-detection-research.md:
--------------------------------------------------------------------------------
```markdown
# Design-to-Code Layout Detection Research
## Executive Summary
This document provides a comprehensive analysis of layout detection algorithms used in design-to-code (D2C) conversion tools. The research focuses on how various implementations distinguish between flow (flex) layouts, stacked (absolute) layouts, and grid layouts.
## Table of Contents
1. [Problem Definition](#problem-definition)
2. [Industry Implementations](#industry-implementations)
3. [Academic Research](#academic-research)
4. [Algorithm Comparison](#algorithm-comparison)
5. [Detection Criteria & Thresholds](#detection-criteria--thresholds)
6. [Overlap Detection](#overlap-detection)
7. [Implementation Recommendations](#implementation-recommendations)
---
## Problem Definition
### The Core Challenge
Design tools like Figma store element positions as absolute coordinates (x, y, width, height). Converting these to semantic CSS layouts requires inferring the designer's intent:
| Layout Type | CSS Property | Use Case |
| --------------------- | --------------------------------------- | -------------------------------------------- |
| **Flow (Horizontal)** | `display: flex; flex-direction: row` | Elements in a row with consistent spacing |
| **Flow (Vertical)** | `display: flex; flex-direction: column` | Elements stacked vertically |
| **Grid** | `display: grid` | 2D matrix of aligned elements |
| **Stacked/Absolute** | `position: absolute` | Overlapping elements, decorative positioning |
### Key Questions to Answer
1. How do we detect if elements form a **row** vs a **column**?
2. When should we use **Grid** instead of **nested Flex**?
3. How do we identify **overlapping/stacked** elements that need absolute positioning?
4. What **tolerance thresholds** work best for real-world designs?
---
## Industry Implementations
### 1. imgcook (Alibaba D2C Platform)
**Architecture**: Deterministic-first pipeline with ML fallback
```
Design JSON → Flatten → Row/Column Grouping → Layout Inference → Semantic Labels → Code
```
**Flow vs Stacked Detection Algorithm**:
```typescript
// Core concept: Axis overlap determines layout type
function detectLayoutDirection(elements: Element[]): 'row' | 'column' | 'stacked' {
// Step 1: Check Y-axis overlap (same horizontal row)
const yOverlapGroups = groupByAxisOverlap(elements, 'y', tolerance: 2);
// Step 2: If only one Y-group, check X-axis overlap (vertical column)
if (yOverlapGroups.length === 1) {
const xOverlapGroups = groupByAxisOverlap(elements, 'x', tolerance: 2);
if (xOverlapGroups.length > 1) return 'column';
}
// Step 3: Check for overlapping elements
const hasOverlap = elements.some((a, i) =>
elements.slice(i + 1).some(b => calculateIoU(a, b) > 0.1)
);
return hasOverlap ? 'stacked' : 'row';
}
```
**Grouping Algorithm**:
```typescript
function groupByAxisOverlap(elements: Element[], axis: "x" | "y", tolerance: number): Element[][] {
// Sort by axis position
const sorted = elements.sort((a, b) => a[axis] - b[axis]);
const groups: Element[][] = [[sorted[0]]];
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const lastGroup = groups[groups.length - 1];
const lastElement = lastGroup[lastGroup.length - 1];
// Check if current overlaps with last element on opposite axis
const overlaps =
axis === "y"
? rangesOverlap(lastElement.y, lastElement.bottom, current.y, current.bottom, tolerance)
: rangesOverlap(lastElement.x, lastElement.right, current.x, current.right, tolerance);
if (overlaps) {
lastGroup.push(current);
} else {
groups.push([current]);
}
}
return groups;
}
```
**Key Thresholds**:
| Parameter | Value | Purpose |
| --------------------- | -------- | ----------------------------- |
| Y-axis tolerance | 2px | Row grouping |
| X-axis tolerance | 2px | Column grouping |
| Gap variance | 20% | Detect consistent spacing |
| Alignment tolerance | 2px | Detect aligned edges |
| IoU overlap threshold | 0.1 | Mark for absolute positioning |
| Max recursion depth | 5 | Prevent infinite nesting |
| Gap rounding | 4px grid | Snap to common design values |
**Confidence Scoring**:
```typescript
interface LayoutConfidence {
patternCoverage: number; // How many elements fit the pattern
gapConsistency: number; // Standard deviation of gaps
alignmentAccuracy: number; // How well elements align
}
function calculateConfidence(analysis: LayoutAnalysis): number {
let score = 0;
score += analysis.patternCoverage * 0.4; // 40% weight
score += analysis.gapConsistency * 0.35; // 35% weight
score += analysis.alignmentAccuracy * 0.25; // 25% weight
return score;
}
// Only accept layout if confidence >= 0.3
const MIN_CONFIDENCE = 0.3;
```
---
### 2. FigmaToCode (Open Source)
**Approach**: Metadata-first (trusts Figma Auto Layout)
```typescript
// FigmaToCode does NOT infer layout from coordinates
// Instead, it reads Figma's native Auto Layout metadata
function convertLayout(node: FigmaNode): CSSLayout {
if (node.layoutMode === "HORIZONTAL") {
return { display: "flex", flexDirection: "row" };
}
if (node.layoutMode === "VERTICAL") {
return { display: "flex", flexDirection: "column" };
}
// No Auto Layout = fall back to absolute positioning
return { position: "absolute" };
}
```
**Limitations**:
- Requires designers to use Figma Auto Layout feature
- Legacy designs without Auto Layout get absolute positioning
- No coordinate-based layout inference
**Key Innovation**: AltNodes intermediate representation
- Converts Figma nodes to simplified virtual nodes
- Handles mixed positioning (absolute + auto-layout children)
- Intelligent decisions about code structure
---
### 3. Locofy
**Approach**: Auto Layout + Absolute positioning hybrid
Locofy uses LocoAI to analyze designs and apply appropriate CSS properties:
```typescript
// Locofy's approach to layout detection
function analyzeLayout(elements: Element[]): LayoutDecision {
// LocoAI groups elements for better structure
const groups = locoAI.groupElements(elements);
// Apply relevant CSS property (flex) for responsiveness
for (const group of groups) {
if (group.hasAutoLayout) {
// Auto layout corresponds to Flexbox in CSS
applyFlexLayout(group);
} else if (group.isFloating) {
// Floating elements use absolute positioning
applyAbsolutePosition(group);
}
}
}
```
**Key Features**:
- Change absolute position status and re-run algorithm for regrouping
- Floating elements use absolute property in auto layout setting
- Parent of absolutely positioned element determines positioning context
---
### 4. Anima Auto-Flexbox
**Approach**: Computer Vision algorithms for automatic Flexbox
```typescript
// Anima's Auto-Flexbox algorithm
// Reverse-engineered from developer thought process
function applyAutoFlexbox(design: Design): Layout {
// Without Auto-Flexbox: generates absolute layout
// With Auto-Flexbox: generates relative positioning (Flexbox)
// Computer Vision algorithms from CV world
const flexboxLayout = cvAlgorithm.analyzeAndApply(design);
// Relative positioning allows layers to push each other
return flexboxLayout;
}
```
**Key Insight**: "Absolute layout is great for design phase, but less so for end product. Flexbox layout means relative positioning."
---
### 5. Gridaco / Grida
**Approach**: Rules + ML hybrid (similar to imgcook)
```typescript
// High-availability layout direction via heuristics
// ML reserved for component/loop recognition
function detectLayout(elements: Element[]): LayoutType {
// Rule-based direction detection first
const direction = detectDirectionByRules(elements);
// ML for semantic understanding
const semantics = mlModel.predictSemantics(elements);
return combineResults(direction, semantics);
}
```
---
### 6. Phoenix Codie Position Detection System
**Key Innovation**: Explicit "position: absolute abuse" avoidance
```typescript
interface PositionDecision {
element: Element;
shouldBeAbsolute: boolean;
reason: "overlap" | "decorative" | "anchor" | "flow";
}
function analyzePosition(element: Element, siblings: Element[]): PositionDecision {
// Only use absolute for:
// 1. Overlapping elements
// 2. Decorative elements (badges, icons positioned freely)
// 3. Anchor elements (tooltips, modals)
const overlaps = siblings.some((s) => hasSignificantOverlap(element, s));
const isDecorative = element.type === "DECORATIVE";
const isAnchor = element.hasAnchorConstraint;
return {
element,
shouldBeAbsolute: overlaps || isDecorative || isAnchor,
reason: overlaps ? "overlap" : isDecorative ? "decorative" : isAnchor ? "anchor" : "flow",
};
}
```
---
## Academic Research
### 1. Allen's Interval Algebra (Foundational)
**Source**: "A Layout Inference Algorithm for GUIs" (ScienceDirect)
Allen's 13 interval relations describe 1D spatial relationships:
```
| Relation | Visual | Description |
|----------|--------|-------------|
| before | A---B | A completely before B |
| meets | A--B | A ends where B starts |
| overlaps | A-B- | A partially overlaps B |
| starts | AB-- | A starts at same position as B |
| during | -A-B | A completely inside B |
| equals | A=B | Same position and size |
```
**Application to Layout Detection**:
```typescript
function getAllenRelation(a: Interval, b: Interval): AllenRelation {
if (a.end < b.start) return "before";
if (a.end === b.start) return "meets";
if (a.start < b.start && a.end > b.start && a.end < b.end) return "overlaps";
if (a.start === b.start && a.end < b.end) return "starts";
if (a.start > b.start && a.end < b.end) return "during";
if (a.start === b.start && a.end === b.end) return "equals";
// ... inverse relations
}
// For 2D layouts, apply Allen relations to both X and Y axes
function get2DRelation(a: Rect, b: Rect): [AllenRelation, AllenRelation] {
return [
getAllenRelation({ start: a.x, end: a.right }, { start: b.x, end: b.right }),
getAllenRelation({ start: a.y, end: a.bottom }, { start: b.y, end: b.bottom }),
];
}
```
**Two-Phase Algorithm**:
1. **Phase 1**: Convert absolute coordinates to relative positioning using directed graphs
2. **Phase 2**: Apply pattern matching and graph rewriting for layout composition
**Results**: 97% layout faithfulness, 84% proportional retention on resize
---
### 2. GRIDS - MILP-Based Layout Inference (Aalto University)
**Approach**: Mathematical optimization for grid generation
```python
# Formulate grid inference as Mixed Integer Linear Programming
def solve_grid_layout(elements):
model = gp.Model("grid_layout")
# Variables: track sizes, element placements
track_widths = model.addVars(max_columns, lb=0, name="col_width")
track_heights = model.addVars(max_rows, lb=0, name="row_height")
placements = model.addVars(len(elements), max_rows, max_columns, vtype=GRB.BINARY)
# Constraints: elements must fit in tracks, no overlap
for i, elem in enumerate(elements):
# Element width must equal sum of spanned track widths
model.addConstr(sum(track_widths[c] * placements[i,r,c]
for r in range(max_rows)
for c in range(max_columns)) >= elem.width)
# Objective: minimize deviation from original positions
model.setObjective(sum_position_errors, GRB.MINIMIZE)
model.optimize()
return extract_grid_template(model)
```
**Advantages**:
- Mathematically optimal solutions
- Handles complex grid configurations
- Precise track size calculation
**Disadvantages**:
- Computationally expensive
- Requires Gurobi optimizer
- Overkill for simple layouts
---
### 3. UI Semantic Group Detection (arXiv 2024)
**Key Innovation**: Gestalt-based pre-clustering
```typescript
// Apply Gestalt principles before layout detection
interface GestaltAnalysis {
proximityGroups: Element[][]; // Close elements grouped
similarityGroups: Element[][]; // Similar-looking elements grouped
continuityChains: Element[][]; // Elements forming visual lines
closureGroups: Element[][]; // Elements forming enclosed shapes
}
function applyGestaltPrinciples(elements: Element[]): GestaltAnalysis {
return {
proximityGroups: clusterByProximity(elements, distanceThreshold: 20),
similarityGroups: clusterBySimilarity(elements, sizeVariance: 0.2),
continuityChains: detectVisualLines(elements),
closureGroups: detectEnclosures(elements)
};
}
// Use Gestalt groups to improve grid detection
function detectGridWithGestalt(elements: Element[]): GridResult {
const gestalt = applyGestaltPrinciples(elements);
// Only consider similarity groups for grid detection
for (const group of gestalt.similarityGroups) {
if (group.length >= 4) {
const gridResult = detectGridLayout(group);
if (gridResult.confidence >= 0.6) {
return gridResult;
}
}
}
return { isGrid: false };
}
```
---
### 4. Screen Parsing (CMU UIST 2021)
**Approach**: ML-based container type prediction
- Trained on 210K mobile screens (130K iOS + 80K Android)
- Predicts 7 container types including grids
- Uses visual features and spatial relationships
```typescript
interface ContainerPrediction {
type: "list" | "grid" | "carousel" | "tabs" | "form" | "navigation" | "content";
confidence: number;
children: Element[];
}
// ML model input features
interface ScreenFeatures {
elementBoundingBoxes: Rect[];
elementTypes: string[];
visualSimilarities: number[][]; // Pairwise visual similarity
spatialRelations: AllenRelation[][]; // Pairwise spatial relations
}
```
---
## Algorithm Comparison
### Detection Approaches Summary
| Tool/Research | Approach | Flow Detection | Grid Detection | Overlap Handling |
| --------------------- | ----------------- | ----------------------- | -------------------------- | --------------------------- |
| **imgcook** | Rule-based + ML | Y-axis overlap grouping | Row/column alignment check | IoU > 0.1 → absolute |
| **FigmaToCode** | Metadata-first | Read Figma Auto Layout | None (no inference) | N/A |
| **Grida** | Rules + ML | Similar to imgcook | Similar to imgcook | Similar to imgcook |
| **Phoenix Codie** | Rule-based | Position analysis | N/A | Explicit overlap check |
| **Allen's Algorithm** | Graph-based | Interval relations | Two-phase inference | Contains/overlaps relations |
| **GRIDS** | MILP optimization | N/A | Mathematical optimization | Constraint-based |
| **Screen Parsing** | ML-based | ML prediction | ML prediction | ML prediction |
### Strengths and Weaknesses
| Approach | Strengths | Weaknesses |
| ------------------ | ------------------------------ | ---------------------------------- |
| **Rule-based** | Predictable, explainable, fast | Limited flexibility, manual tuning |
| **Metadata-first** | Accurate when metadata exists | Fails without designer cooperation |
| **ML-based** | Handles complex patterns | Requires training data, black box |
| **MILP** | Mathematically optimal | Computationally expensive |
| **Hybrid** | Best of both worlds | Complex implementation |
---
## Detection Criteria & Thresholds
### Universal Thresholds (Industry Consensus)
```typescript
const LAYOUT_THRESHOLDS = {
// Axis overlap tolerances
ROW_GROUPING_TOLERANCE: 2, // px - Y-axis overlap for same row
COLUMN_GROUPING_TOLERANCE: 2, // px - X-axis overlap for same column
// Gap analysis
GAP_VARIANCE_THRESHOLD: 0.2, // 20% - Max coefficient of variation
GAP_ROUNDING_GRID: 4, // px - Snap gaps to multiples
// Alignment
ALIGNMENT_TOLERANCE: 2, // px - Edge alignment detection
// Size homogeneity
SIZE_CV_THRESHOLD: 0.2, // 20% - Max size variance for grid
// Overlap detection
IOU_OVERLAP_THRESHOLD: 0.1, // 10% - Mark as overlapping
IOU_SIGNIFICANT_OVERLAP: 0.5, // 50% - Definitely overlapping
// Confidence thresholds
MIN_FLOW_CONFIDENCE: 0.3, // Minimum to accept flow layout
MIN_GRID_CONFIDENCE: 0.6, // Minimum to accept grid layout
// Element counts
MIN_GRID_ELEMENTS: 4, // Minimum for grid detection
MIN_GRID_ROWS: 2, // Minimum rows for grid
MIN_GRID_COLUMNS: 2, // Minimum columns for grid
};
```
### Gap Rounding Values
```typescript
// Common design system spacing values
const COMMON_GAP_VALUES = [0, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64];
function roundGapToCommon(gap: number): number {
return COMMON_GAP_VALUES.reduce((prev, curr) =>
Math.abs(curr - gap) < Math.abs(prev - gap) ? curr : prev,
);
}
```
---
## Overlap Detection
### IoU (Intersection over Union) Calculation
```typescript
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
function calculateIoU(a: Rect, b: Rect): number {
// Calculate intersection
const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
const intersection = xOverlap * yOverlap;
// Calculate union
const areaA = a.width * a.height;
const areaB = b.width * b.height;
const union = areaA + areaB - intersection;
return union > 0 ? intersection / union : 0;
}
```
### Overlap Classification
```typescript
type OverlapType = "none" | "adjacent" | "partial" | "significant" | "contained";
function classifyOverlap(a: Rect, b: Rect): OverlapType {
const iou = calculateIoU(a, b);
if (iou === 0) {
// Check if adjacent (touching but not overlapping)
const gap = calculateGap(a, b);
return gap <= 2 ? "adjacent" : "none";
}
if (iou < 0.1) return "partial";
if (iou < 0.5) return "significant";
return "contained";
}
function shouldUseAbsolutePosition(element: Rect, siblings: Rect[]): boolean {
for (const sibling of siblings) {
const overlap = classifyOverlap(element, sibling);
if (overlap === "partial" || overlap === "significant" || overlap === "contained") {
return true;
}
}
return false;
}
```
### Z-Index Inference
```typescript
// When elements overlap, infer z-order from:
// 1. Figma layer order (later = on top)
// 2. Size (smaller elements usually on top)
// 3. Type (text/icons usually on top of backgrounds)
function inferZIndex(elements: Element[]): Map<Element, number> {
const zIndexMap = new Map<Element, number>();
// Sort by layer order (Figma provides this)
const sorted = [...elements].sort((a, b) => a.layerOrder - b.layerOrder);
sorted.forEach((el, index) => {
zIndexMap.set(el, index);
});
return zIndexMap;
}
```
---
## Implementation Recommendations
### For This Project (Figma-Context-MCP)
Based on the research, here's the recommended algorithm:
#### 1. Pre-filtering: Homogeneity Check (IMPLEMENTED)
```typescript
// Already implemented in detector.ts
export function filterHomogeneousForGrid(
rects: ElementRect[],
nodeTypes?: string[],
): ElementRect[] {
const homogeneity = analyzeHomogeneity(rects, nodeTypes, 0.2);
return homogeneity.homogeneousElements;
}
```
#### 2. Overlap Detection (TO IMPLEMENT)
```typescript
function detectOverlappingElements(nodes: SimplifiedNode[]): {
flowElements: SimplifiedNode[];
stackedElements: SimplifiedNode[];
} {
const rects = nodes.map(nodeToRect);
const flowElements: SimplifiedNode[] = [];
const stackedElements: SimplifiedNode[] = [];
for (let i = 0; i < nodes.length; i++) {
const hasOverlap = rects.some((other, j) => i !== j && calculateIoU(rects[i], other) > 0.1);
if (hasOverlap) {
stackedElements.push(nodes[i]);
} else {
flowElements.push(nodes[i]);
}
}
return { flowElements, stackedElements };
}
```
#### 3. Child Element Style Cleanup (TO IMPLEMENT)
```typescript
function cleanChildStylesForFlexParent(child: SimplifiedNode): void {
if (child.cssStyles) {
// Remove position: absolute when parent is flex/grid
if (child.cssStyles.position === "absolute") {
delete child.cssStyles.position;
}
// Remove left/top (now handled by flex/grid)
delete child.cssStyles.left;
delete child.cssStyles.top;
// Keep width/height for flex items (used by flex-basis)
}
}
function cleanChildStylesForGridParent(child: SimplifiedNode): void {
if (child.cssStyles) {
delete child.cssStyles.position;
delete child.cssStyles.left;
delete child.cssStyles.top;
// Width/height may be redundant if grid tracks define sizes
// But keep them for explicit sizing
}
}
```
#### 4. Default Value Removal (TO IMPLEMENT)
```typescript
const CSS_DEFAULT_VALUES: Record<string, string[]> = {
fontWeight: ["400", "normal"],
textAlign: ["left", "start"],
flexDirection: ["row"],
position: ["static"],
opacity: ["1"],
backgroundColor: ["transparent", "rgba(0,0,0,0)"],
borderWidth: ["0", "0px"],
};
function removeDefaultValues(cssStyles: CSSStyle): CSSStyle {
const cleaned: CSSStyle = {};
for (const [key, value] of Object.entries(cssStyles)) {
const defaults = CSS_DEFAULT_VALUES[key];
if (defaults && defaults.includes(String(value))) {
continue; // Skip default value
}
cleaned[key] = value;
}
return cleaned;
}
```
### Complete Detection Pipeline
```
┌─────────────────────────────────────────────────────────────────┐
│ Layout Detection Pipeline │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. INPUT: Array of child elements with absolute coordinates │
│ ↓ │
│ 2. OVERLAP CHECK: Separate overlapping elements │
│ - IoU > 0.1 → stackedElements (keep absolute) │
│ - IoU ≤ 0.1 → flowElements (continue analysis) │
│ ↓ │
│ 3. HOMOGENEITY CHECK: Filter similar elements │
│ - Size CV < 20% AND same types → homogeneous │
│ - Mixed sizes/types → heterogeneous │
│ ↓ │
│ 4. ROW GROUPING: Y-axis overlap (2px tolerance) │
│ - 1 row → horizontal flex │
│ - Multiple rows → continue │
│ ↓ │
│ 5. GRID CHECK (homogeneous only): │
│ - rows ≥ 2 AND columns ≥ 2 │
│ - Column alignment ≥ 80% │
│ - Confidence ≥ 0.6 │
│ → YES: display: grid │
│ → NO: continue │
│ ↓ │
│ 6. FLEX DIRECTION: │
│ - Dominant direction by element count │
│ - Row if most elements horizontal │
│ - Column if most elements vertical │
│ ↓ │
│ 7. CHILD CLEANUP: │
│ - Remove position: absolute │
│ - Remove left/top │
│ - Remove default CSS values │
│ ↓ │
│ 8. OUTPUT: LayoutInfo with clean child styles │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## References
### Academic Papers
1. **"A Layout Inference Algorithm for Graphical User Interfaces"** (2015)
- [ScienceDirect](https://www.sciencedirect.com/science/article/abs/pii/S0950584915001718)
- [ResearchGate PDF](https://www.researchgate.net/publication/283526120_A_layout_inference_algorithm_for_Graphical_User_Interfaces)
- Key: Allen's Interval Algebra, 97% layout faithfulness
2. **"GRIDS: Interactive Layout Design with Integer Programming"** (CHI 2020)
- [Project Page](https://userinterfaces.aalto.fi/grids/)
- [GitHub](https://github.com/aalto-ui/GRIDS)
- [Paper PDF](https://acris.aalto.fi/ws/portalfiles/portal/40720569/CHI2020_Dayama_GRIDS.pdf)
- Authors: Niraj Dayama, Kashyap Todi, Taru Saarelainen, Antti Oulasvirta (Aalto University)
3. **"Screen Parsing: Towards Reverse Engineering of UI Models from Screenshots"** (UIST 2021)
- [CMU ML Blog](https://blog.ml.cmu.edu/2021/12/10/understanding-user-interfaces-with-screen-parsing/)
- [Paper PDF](https://www.cs.cmu.edu/~jbigham/pubs/pdfs/2021/screen-parsing.pdf)
- [ACM Digital Library](https://dl.acm.org/doi/fullHtml/10.1145/3472749.3474763)
- Authors: Jason Wu, Xiaoyi Zhang, Jeff Nichols, Jeffrey P. Bigham
4. **"UI Semantic Group Detection"** (arXiv 2024) - arXiv:2403.04984v1
5. **"UIHASH: Grid-Based UI Similarity"** - Jun Zeng et al.
### Open Source Projects
1. [FigmaToCode](https://github.com/bernaferrari/FigmaToCode) - Generate responsive pages on HTML, Tailwind, Flutter, SwiftUI
2. [GRIDS](https://github.com/aalto-ui/GRIDS) - MILP-based grid layout generation (Python + Gurobi)
3. [Grida](https://github.com/gridaco/grida) - Design-to-code platform
4. [Yoga](https://github.com/facebook/yoga) - Facebook's cross-platform Flexbox layout engine
### Industry Resources
1. **imgcook (Alibaba)**
- [Layout Algorithm Blog](https://www.alibabacloud.com/blog/imgcook-3-0-series-layout-algorithm-design-based-code-generation_597856)
- [How imgcook Works](https://medium.com/imgcook/imgcook-how-are-codes-generated-intelligently-from-design-files-in-alibaba-98ba8e55246d)
- [100% Accuracy Rate](https://www.alibabacloud.com/blog/imgcook-intelligent-code-generation-from-design-drafts-with-a-100%25-accuracy-rate_598093)
2. **Locofy**
- [Design Optimiser Docs](https://www.locofy.ai/docs/lightning/design-optimiser/)
- [Auto Layout to Responsive Code](https://www.locofy.ai/docs/classic/design-structure/responsiveness/auto-layout/)
3. **Anima**
- [Auto-Flexbox Introduction](https://www.animaapp.com/blog/design-to-code/introducing-auto-flexbox/)
- [Flexbox from Constraints](https://www.animaapp.com/blog/product-updates/producing-flexbox-responsive-code-based-on-figma-adobe-xd-and-sketch-constraints/)
4. **CSS Standards**
- [CSS Grid Layout Module Level 1](https://www.w3.org/TR/css-grid-1/) - W3C
- [CSS Flexible Box Layout](https://www.w3.org/TR/css-flexbox-1/) - W3C
- [Understanding Layout Algorithms](https://www.joshwcomeau.com/css/understanding-layout-algorithms/) - Josh W. Comeau
5. **Foundational**
- [Allen's Interval Algebra](https://en.wikipedia.org/wiki/Allen's_interval_algebra) - Wikipedia
- [Figma Grid Auto-Layout](https://help.figma.com/hc/en-us/articles/31289469907863) - Figma Help
- [IoU Explained](https://www.v7labs.com/blog/intersection-over-union-guide) - V7 Labs
---
## Appendix: Code Examples
### A. Complete Flow Detection
```typescript
export function detectFlowLayout(elements: Element[]): LayoutInfo {
// Step 1: Check for overlaps
const { flowElements, stackedElements } = detectOverlappingElements(elements);
if (flowElements.length < 2) {
return { type: "absolute", elements: stackedElements };
}
// Step 2: Group into rows
const rows = groupIntoRows(flowElements, THRESHOLDS.ROW_GROUPING_TOLERANCE);
// Step 3: Determine direction
if (rows.length === 1) {
// Single row = horizontal flex
const gapAnalysis = analyzeGaps(rows[0], "horizontal");
return {
type: "flex",
direction: "row",
gap: gapAnalysis.averageGap,
alignment: detectAlignment(rows[0], "vertical"),
justifyContent: detectJustifyContent(rows[0], "horizontal"),
};
}
// Step 4: Check for grid (multiple rows)
const gridResult = detectGridLayout(flowElements);
if (gridResult.isGrid && gridResult.confidence >= 0.6) {
return {
type: "grid",
columns: gridResult.columnCount,
rows: gridResult.rowCount,
columnGap: gridResult.columnGap,
rowGap: gridResult.rowGap,
trackWidths: gridResult.trackWidths,
};
}
// Step 5: Fallback to column flex
const rowGapAnalysis = analyzeGaps(
rows.map((r) => r[0]),
"vertical",
);
return {
type: "flex",
direction: "column",
gap: rowGapAnalysis.averageGap,
alignment: detectAlignment(rows.flat(), "horizontal"),
};
}
```
### B. Complete Grid Detection
```typescript
export function detectGridLayout(elements: ElementRect[]): GridAnalysisResult {
// Step 1: Group into rows
const rows = groupIntoRows(elements, 2);
if (rows.length < 2) {
return { isGrid: false, confidence: 0 };
}
// Step 2: Check column count consistency
const columnCounts = rows.map((r) => r.length);
const countVariance = coefficientOfVariation(columnCounts);
if (countVariance > 0.2) {
return { isGrid: false, confidence: 0 };
}
// Step 3: Extract column positions
const columnPositions = extractColumnPositions(rows);
const alignmentResult = checkColumnAlignment(columnPositions, 4);
// Step 4: Calculate gaps
const columnGaps = calculateColumnGaps(rows);
const rowGaps = calculateRowGaps(rows);
// Step 5: Calculate confidence
let confidence = 0;
if (rows.length >= 2) confidence += 0.2;
if (rows.length >= 3) confidence += 0.1;
if (countVariance < 0.1) confidence += 0.2;
if (alignmentResult.isAligned) confidence += 0.25;
if (coefficientOfVariation(columnGaps) < 0.2) confidence += 0.1;
if (coefficientOfVariation(rowGaps) < 0.2) confidence += 0.1;
// Step 6: Calculate track sizes
const trackWidths = calculateTrackWidths(rows, alignmentResult.alignedPositions);
const trackHeights = rows.map((row) => Math.max(...row.map((el) => el.height)));
return {
isGrid: confidence >= 0.6,
confidence,
rowCount: rows.length,
columnCount: Math.max(...columnCounts),
rowGap: roundGapToCommon(average(rowGaps)),
columnGap: roundGapToCommon(average(columnGaps)),
trackWidths,
trackHeights,
};
}
```
```