""" Figma Element Extractor Extracts all UI elements from Figma with their dev-mode specifications. Traverses the node tree recursively and extracts properties for comparison. """ from typing import Dict, List, Optional, Any, Tuple from .element_schema import ( UIElement, ElementType, ElementBounds, ElementStyles, FIGMA_TYPE_MAP, rgb_to_hex ) class FigmaElementExtractor: """ Extracts UI elements from Figma file data. Mimics Figma Dev Mode by extracting all design specifications. """ # Keywords to detect element types from names BUTTON_KEYWORDS = ["button", "btn", "cta", "submit", "pay", "checkout", "continue", "add", "remove"] INPUT_KEYWORDS = ["input", "field", "textfield", "textarea", "email", "password", "card", "cvv", "expiry"] CHECKBOX_KEYWORDS = ["checkbox", "check", "toggle"] PRICE_KEYWORDS = ["price", "total", "subtotal", "amount", "$", "cost"] HEADING_KEYWORDS = ["heading", "title", "header", "h1", "h2", "h3"] def __init__(self, frame_bounds: Optional[Dict] = None): """ Initialize extractor. Args: frame_bounds: The bounds of the parent frame (for relative positioning) """ self.frame_bounds = frame_bounds or {"x": 0, "y": 0} self.elements: List[UIElement] = [] self.element_count = 0 def extract_from_frame(self, frame_node: Dict, viewport: str = "desktop") -> List[UIElement]: """ Extract all elements from a Figma frame. Args: frame_node: The frame node from Figma API viewport: "desktop" or "mobile" Returns: List of UIElement objects """ self.elements = [] self.element_count = 0 # Get frame bounds for relative positioning self.frame_bounds = frame_node.get("absoluteBoundingBox", {"x": 0, "y": 0}) # Start recursive extraction self._extract_node(frame_node, parent_id=None, depth=0) print(f" 📊 Extracted {len(self.elements)} elements from {viewport} frame") return self.elements def _extract_node(self, node: Dict, parent_id: Optional[str], depth: int) -> Optional[str]: """ Recursively extract a node and its children. Returns: The ID of the created element, or None if skipped """ node_type = node.get("type", "") node_name = node.get("name", "") node_id = node.get("id", "") # Skip invisible nodes if not node.get("visible", True): return None # Create element element = self._create_element(node, parent_id, depth) if element: self.elements.append(element) current_id = element.id # Process children children = node.get("children", []) child_ids = [] for child in children: child_id = self._extract_node(child, current_id, depth + 1) if child_id: child_ids.append(child_id) # Update children IDs element.children_ids = child_ids return current_id # If we skipped this node, still process children with same parent children = node.get("children", []) for child in children: self._extract_node(child, parent_id, depth) return None def _create_element(self, node: Dict, parent_id: Optional[str], depth: int) -> Optional[UIElement]: """ Create a UIElement from a Figma node. """ node_type = node.get("type", "") node_name = node.get("name", "") node_id = node.get("id", "") # Get bounds bounds_data = node.get("absoluteBoundingBox") if not bounds_data: return None # Calculate relative position (relative to frame) bounds = ElementBounds( x=bounds_data.get("x", 0) - self.frame_bounds.get("x", 0), y=bounds_data.get("y", 0) - self.frame_bounds.get("y", 0), width=bounds_data.get("width", 0), height=bounds_data.get("height", 0) ) # Skip very small elements (likely decorative) if bounds.width < 5 or bounds.height < 5: return None # Determine element type element_type = self._determine_element_type(node, node_name) # Extract styles styles = self._extract_styles(node) # Extract text content text_content = None if node_type == "TEXT": text_content = node.get("characters", "") # Check if interactive is_interactive = element_type in [ ElementType.BUTTON, ElementType.INPUT, ElementType.LINK, ElementType.CHECKBOX, ElementType.RADIO, ElementType.SELECT ] self.element_count += 1 return UIElement( id=f"figma_{node_id}", element_type=element_type, name=node_name, bounds=bounds, styles=styles, text_content=text_content, parent_id=parent_id, depth=depth, source="figma", original_type=node_type, is_interactive=is_interactive ) def _determine_element_type(self, node: Dict, name: str) -> ElementType: """ Determine the semantic element type from Figma node. Uses node type + name heuristics. """ node_type = node.get("type", "") name_lower = name.lower() # Check by Figma node type first if node_type == "TEXT": # Check if it's a heading or price font_size = node.get("style", {}).get("fontSize", 14) if any(kw in name_lower for kw in self.PRICE_KEYWORDS): return ElementType.PRICE if any(kw in name_lower for kw in self.HEADING_KEYWORDS) or font_size >= 24: return ElementType.HEADING if "label" in name_lower: return ElementType.LABEL return ElementType.TEXT # Check by name keywords if any(kw in name_lower for kw in self.BUTTON_KEYWORDS): return ElementType.BUTTON if any(kw in name_lower for kw in self.INPUT_KEYWORDS): return ElementType.INPUT if any(kw in name_lower for kw in self.CHECKBOX_KEYWORDS): return ElementType.CHECKBOX if any(kw in name_lower for kw in self.PRICE_KEYWORDS): return ElementType.PRICE # Check for image if node_type == "IMAGE" or "image" in name_lower or "img" in name_lower: return ElementType.IMAGE # Check for icon (small vectors or frames with icon in name) bounds = node.get("absoluteBoundingBox", {}) is_small = bounds.get("width", 0) < 50 and bounds.get("height", 0) < 50 if node_type == "VECTOR" or (is_small and ("icon" in name_lower or "ico" in name_lower)): return ElementType.ICON # Check for divider/line if node_type == "LINE" or "divider" in name_lower or "separator" in name_lower: return ElementType.DIVIDER # Check for card if "card" in name_lower: return ElementType.CARD # Check for link if "link" in name_lower: return ElementType.LINK # Default to container for frames/groups if node_type in ["FRAME", "GROUP", "COMPONENT", "INSTANCE"]: return ElementType.CONTAINER # Use mapping for other types return FIGMA_TYPE_MAP.get(node_type, ElementType.UNKNOWN) def _extract_styles(self, node: Dict) -> ElementStyles: """ Extract visual styles from a Figma node. """ styles = ElementStyles() # Background color (from fills) fills = node.get("fills", []) if fills and len(fills) > 0: fill = fills[0] if fill.get("type") == "SOLID" and fill.get("visible", True): color = fill.get("color", {}) styles.background_color = rgb_to_hex( color.get("r", 0), color.get("g", 0), color.get("b", 0) ) styles.opacity = fill.get("opacity", 1.0) # Border/stroke strokes = node.get("strokes", []) if strokes and len(strokes) > 0: stroke = strokes[0] if stroke.get("type") == "SOLID" and stroke.get("visible", True): color = stroke.get("color", {}) styles.border_color = rgb_to_hex( color.get("r", 0), color.get("g", 0), color.get("b", 0) ) # Border width stroke_weight = node.get("strokeWeight") if stroke_weight: styles.border_width = stroke_weight # Border radius corner_radius = node.get("cornerRadius") if corner_radius: styles.border_radius = corner_radius # Typography (for TEXT nodes) style_data = node.get("style", {}) if style_data: styles.font_family = style_data.get("fontFamily") styles.font_size = style_data.get("fontSize") styles.font_weight = style_data.get("fontWeight") styles.line_height = style_data.get("lineHeightPx") styles.text_align = style_data.get("textAlignHorizontal", "").lower() styles.letter_spacing = style_data.get("letterSpacing") # Text color (from fills for TEXT nodes) if node.get("type") == "TEXT" and fills: fill = fills[0] if fill.get("type") == "SOLID": color = fill.get("color", {}) styles.text_color = rgb_to_hex( color.get("r", 0), color.get("g", 0), color.get("b", 0) ) # Padding (from auto-layout) padding_left = node.get("paddingLeft") padding_right = node.get("paddingRight") padding_top = node.get("paddingTop") padding_bottom = node.get("paddingBottom") if padding_left is not None: styles.padding_left = padding_left if padding_right is not None: styles.padding_right = padding_right if padding_top is not None: styles.padding_top = padding_top if padding_bottom is not None: styles.padding_bottom = padding_bottom # Effects (shadows) effects = node.get("effects", []) for effect in effects: if effect.get("type") == "DROP_SHADOW" and effect.get("visible", True): shadow = effect color = shadow.get("color", {}) offset = shadow.get("offset", {}) radius = shadow.get("radius", 0) # Format as CSS-like box-shadow rgba = f"rgba({int(color.get('r', 0)*255)},{int(color.get('g', 0)*255)},{int(color.get('b', 0)*255)},{color.get('a', 1):.2f})" styles.box_shadow = f"{offset.get('x', 0)}px {offset.get('y', 0)}px {radius}px {rgba}" break return styles def get_interactive_elements(self) -> List[UIElement]: """Get only interactive elements (buttons, inputs, etc.).""" return [e for e in self.elements if e.is_interactive] def get_text_elements(self) -> List[UIElement]: """Get only text elements.""" return [e for e in self.elements if e.element_type in [ ElementType.TEXT, ElementType.HEADING, ElementType.LABEL, ElementType.PRICE ]] def get_elements_by_type(self, element_type: ElementType) -> List[UIElement]: """Get elements of a specific type.""" return [e for e in self.elements if e.element_type == element_type] def summarize(self) -> Dict[str, Any]: """Get a summary of extracted elements.""" type_counts = {} for element in self.elements: type_name = element.element_type.value type_counts[type_name] = type_counts.get(type_name, 0) + 1 return { "total_elements": len(self.elements), "interactive_elements": len(self.get_interactive_elements()), "text_elements": len(self.get_text_elements()), "by_type": type_counts, "max_depth": max((e.depth for e in self.elements), default=0) } def extract_figma_elements(file_data: Dict, frame_id: str, viewport: str = "desktop") -> Tuple[List[UIElement], Dict]: """ Convenience function to extract elements from Figma file data. Args: file_data: Complete Figma file data from API frame_id: ID of the frame to extract from viewport: "desktop" or "mobile" Returns: Tuple of (list of UIElements, summary dict) """ # Find the frame in the document frame_node = _find_node_by_id(file_data.get("document", {}), frame_id) if not frame_node: raise ValueError(f"Frame with ID {frame_id} not found in Figma file") extractor = FigmaElementExtractor() elements = extractor.extract_from_frame(frame_node, viewport) summary = extractor.summarize() return elements, summary def _find_node_by_id(node: Dict, target_id: str) -> Optional[Dict]: """Recursively find a node by ID in the Figma document tree.""" if node.get("id") == target_id: return node for child in node.get("children", []): result = _find_node_by_id(child, target_id) if result: return result return None