#!/usr/bin/env python3 """ Figma Export Manager - Batch asset export with intelligent organization Handles multiple formats, naming conventions, and export workflows. """ import os import sys import json import asyncio import aiohttp from pathlib import Path from typing import Dict, List, Optional, Union, Any from dataclasses import dataclass, field from concurrent.futures import ThreadPoolExecutor import argparse import time try: from figma_client import FigmaClient except ImportError: # Handle case where script is run directly import sys from pathlib import Path sys.path.append(str(Path(__file__).parent)) from figma_client import FigmaClient @dataclass class ExportConfig: """Configuration for export operations""" formats: List[str] = field(default_factory=lambda: ['png']) scales: List[float] = field(default_factory=lambda: [1.0]) output_dir: str = './figma-exports' naming_pattern: str = '{name}_{id}.{format}' create_manifest: bool = True skip_existing: bool = False max_concurrent: int = 5 organize_by_format: bool = True class ExportManager: """Professional-grade Figma asset export manager""" def __init__(self, figma_client: FigmaClient, config: ExportConfig = None): self.client = figma_client self.config = config or ExportConfig() # Create output directory Path(self.config.output_dir).mkdir(parents=True, exist_ok=True) def export_frames(self, file_key: str, frame_ids: List[str] = None, frame_names: List[str] = None) -> Dict[str, Any]: """Export all frames or specific frames from a file""" # Get file data to identify frames file_data = self.client.get_file(file_key) if not frame_ids and not frame_names: # Export all frames frame_nodes = self._find_frames(file_data) else: # Filter specific frames all_frames = self._find_frames(file_data) frame_nodes = [] for frame in all_frames: if frame_ids and frame['id'] in frame_ids: frame_nodes.append(frame) elif frame_names and frame['name'] in frame_names: frame_nodes.append(frame) if not frame_nodes: print("No frames found to export") return {'exported': 0, 'files': []} print(f"Found {len(frame_nodes)} frames to export") return self._export_nodes(file_key, frame_nodes) def export_components(self, file_key: str, component_names: List[str] = None) -> Dict[str, Any]: """Export all components or specific components from a file""" file_data = self.client.get_file(file_key) component_nodes = self._find_components(file_data) if component_names: component_nodes = [c for c in component_nodes if c['name'] in component_names] if not component_nodes: print("No components found to export") return {'exported': 0, 'files': []} print(f"Found {len(component_nodes)} components to export") return self._export_nodes(file_key, component_nodes) def export_pages(self, file_key: str, page_names: List[str] = None) -> Dict[str, Any]: """Export all pages or specific pages as complete images""" file_data = self.client.get_file(file_key) pages = [] for child in file_data.get('document', {}).get('children', []): if child.get('type') == 'CANVAS': if not page_names or child.get('name') in page_names: pages.append(child) if not pages: print("No pages found to export") return {'exported': 0, 'files': []} print(f"Found {len(pages)} pages to export") return self._export_nodes(file_key, pages) def export_custom_selection(self, file_key: str, node_ids: List[str]) -> Dict[str, Any]: """Export specific nodes by ID""" # Get node information nodes_data = self.client.get_file_nodes(file_key, node_ids) if 'nodes' not in nodes_data: print("No nodes found with provided IDs") return {'exported': 0, 'files': []} nodes = [] for node_id, node_info in nodes_data['nodes'].items(): if 'document' in node_info: node = node_info['document'] node['id'] = node_id # Ensure ID is present nodes.append(node) print(f"Found {len(nodes)} nodes to export") return self._export_nodes(file_key, nodes) def export_design_tokens(self, file_key: str, output_format: str = 'json') -> str: """Export design tokens (colors, typography, effects) in various formats""" file_data = self.client.get_file(file_key) # Extract design tokens tokens = { 'colors': self._extract_color_tokens(file_data), 'typography': self._extract_typography_tokens(file_data), 'effects': self._extract_effect_tokens(file_data), 'spacing': self._extract_spacing_tokens(file_data) } # Format output if output_format == 'css': output_content = self._tokens_to_css(tokens) output_file = Path(self.config.output_dir) / 'design-tokens.css' elif output_format == 'scss': output_content = self._tokens_to_scss(tokens) output_file = Path(self.config.output_dir) / 'design-tokens.scss' elif output_format == 'js': output_content = self._tokens_to_js(tokens) output_file = Path(self.config.output_dir) / 'design-tokens.js' else: # json output_content = json.dumps(tokens, indent=2) output_file = Path(self.config.output_dir) / 'design-tokens.json' # Write output file with open(output_file, 'w') as f: f.write(output_content) print(f"Design tokens exported to {output_file}") return str(output_file) def create_client_package(self, file_key: str, package_name: str = None) -> str: """Create a complete client delivery package with all assets""" if not package_name: file_data = self.client.get_file(file_key) package_name = file_data.get('name', 'figma-package').replace(' ', '-').lower() package_dir = Path(self.config.output_dir) / package_name package_dir.mkdir(parents=True, exist_ok=True) # Export all asset types results = {} # 1. Export all frames self.config.output_dir = str(package_dir / 'frames') Path(self.config.output_dir).mkdir(exist_ok=True) results['frames'] = self.export_frames(file_key) # 2. Export all components self.config.output_dir = str(package_dir / 'components') Path(self.config.output_dir).mkdir(exist_ok=True) results['components'] = self.export_components(file_key) # 3. Export design tokens self.config.output_dir = str(package_dir) results['tokens'] = { 'json': self.export_design_tokens(file_key, 'json'), 'css': self.export_design_tokens(file_key, 'css'), 'scss': self.export_design_tokens(file_key, 'scss') } # 4. Create documentation doc_file = package_dir / 'README.md' self._create_package_documentation(file_key, doc_file, results) print(f"Client package created at {package_dir}") return str(package_dir) def _export_nodes(self, file_key: str, nodes: List[Dict[str, Any]]) -> Dict[str, Any]: """Export nodes with all configured formats and scales""" exported_files = [] total_exports = len(nodes) * len(self.config.formats) * len(self.config.scales) current_export = 0 for node in nodes: node_id = node['id'] node_name = self._sanitize_filename(node.get('name', 'untitled')) for format in self.config.formats: for scale in self.config.scales: current_export += 1 print(f"Exporting {current_export}/{total_exports}: {node_name} ({format} @ {scale}x)") # Get export URLs from Figma try: export_data = self.client.export_images( file_key, [node_id], format=format, scale=scale ) if 'images' in export_data and node_id in export_data['images']: image_url = export_data['images'][node_id] if image_url: # Generate filename filename = self.config.naming_pattern.format( name=node_name, id=node_id, format=format, scale=f'{scale}x' if scale != 1.0 else '' ) # Organize by format if configured if self.config.organize_by_format: format_dir = Path(self.config.output_dir) / format format_dir.mkdir(exist_ok=True) output_path = format_dir / filename else: output_path = Path(self.config.output_dir) / filename # Skip if file exists and skip_existing is True if self.config.skip_existing and output_path.exists(): print(f" Skipping existing file: {output_path}") continue # Download the image self.client.download_image(str(image_url), str(output_path)) exported_files.append({ 'path': str(output_path), 'node_id': node_id, 'node_name': node_name, 'format': format, 'scale': scale, 'url': image_url }) print(f" Saved: {output_path}") else: print(f" Warning: No image URL returned for {node_name}") except Exception as e: print(f" Error exporting {node_name}: {e}") continue # Rate limiting time.sleep(0.5) # Create manifest file if self.config.create_manifest: manifest_path = Path(self.config.output_dir) / 'export-manifest.json' manifest_data = { 'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'), 'file_key': file_key, 'total_files': len(exported_files), 'config': { 'formats': self.config.formats, 'scales': self.config.scales, 'naming_pattern': self.config.naming_pattern }, 'files': exported_files } with open(manifest_path, 'w') as f: json.dump(manifest_data, f, indent=2) print(f"Manifest created: {manifest_path}") return { 'exported': len(exported_files), 'files': exported_files } def _find_frames(self, file_data: Dict[str, Any]) -> List[Dict[str, Any]]: """Find all frames in the file""" frames = [] def traverse_node(node): if node.get('type') == 'FRAME': frames.append(node) for child in node.get('children', []): traverse_node(child) if 'document' in file_data: traverse_node(file_data['document']) return frames def _find_components(self, file_data: Dict[str, Any]) -> List[Dict[str, Any]]: """Find all components in the file""" components = [] def traverse_node(node): if node.get('type') == 'COMPONENT': components.append(node) for child in node.get('children', []): traverse_node(child) if 'document' in file_data: traverse_node(file_data['document']) return components def _extract_color_tokens(self, file_data: Dict[str, Any]) -> Dict[str, str]: """Extract color design tokens from file styles""" colors = {} for style_id, style in file_data.get('styles', {}).items(): if style.get('styleType') == 'FILL': name = style.get('name', '').replace('/', '-').lower() # This would need to be enhanced with actual color values # from the style definition colors[name] = f"#{style_id[:6]}" # Placeholder return colors def _extract_typography_tokens(self, file_data: Dict[str, Any]) -> Dict[str, Dict]: """Extract typography design tokens""" typography = {} for style_id, style in file_data.get('styles', {}).items(): if style.get('styleType') == 'TEXT': name = style.get('name', '').replace('/', '-').lower() typography[name] = { 'fontSize': '16px', # Placeholder - would need actual values 'fontWeight': '400', 'lineHeight': '1.5', 'fontFamily': 'Inter' } return typography def _extract_effect_tokens(self, file_data: Dict[str, Any]) -> Dict[str, str]: """Extract effect design tokens (shadows, etc.)""" effects = {} for style_id, style in file_data.get('styles', {}).items(): if style.get('styleType') == 'EFFECT': name = style.get('name', '').replace('/', '-').lower() effects[name] = "0 2px 4px rgba(0,0,0,0.1)" # Placeholder return effects def _extract_spacing_tokens(self, file_data: Dict[str, Any]) -> Dict[str, str]: """Extract spacing tokens from layout patterns""" # This would analyze common spacing patterns in the design return { 'xs': '4px', 'sm': '8px', 'md': '16px', 'lg': '24px', 'xl': '32px' } def _tokens_to_css(self, tokens: Dict[str, Any]) -> str: """Convert tokens to CSS custom properties""" css_content = ":root {\n" # Colors for name, value in tokens['colors'].items(): css_content += f" --color-{name}: {value};\n" # Typography for name, values in tokens['typography'].items(): for prop, value in values.items(): css_content += f" --{name}-{prop.lower()}: {value};\n" # Effects for name, value in tokens['effects'].items(): css_content += f" --effect-{name}: {value};\n" # Spacing for name, value in tokens['spacing'].items(): css_content += f" --spacing-{name}: {value};\n" css_content += "}\n" return css_content def _tokens_to_scss(self, tokens: Dict[str, Any]) -> str: """Convert tokens to SCSS variables""" scss_content = "// Design Tokens\n\n" # Colors scss_content += "// Colors\n" for name, value in tokens['colors'].items(): scss_content += f"${name.replace('-', '_')}: {value};\n" scss_content += "\n// Typography\n" for name, values in tokens['typography'].items(): for prop, value in values.items(): scss_content += f"${name.replace('-', '_')}_{prop.lower()}: {value};\n" return scss_content def _tokens_to_js(self, tokens: Dict[str, Any]) -> str: """Convert tokens to JavaScript/JSON module""" return f"export const designTokens = {json.dumps(tokens, indent=2)};\n\nexport default designTokens;\n" def _sanitize_filename(self, name: str) -> str: """Convert name to safe filename""" # Remove/replace invalid characters safe_name = "".join(c for c in name if c.isalnum() or c in (' ', '-', '_')) safe_name = safe_name.strip().replace(' ', '-').lower() return safe_name or 'untitled' def _create_package_documentation(self, file_key: str, doc_path: Path, results: Dict[str, Any]): """Create documentation for the exported package""" file_data = self.client.get_file(file_key) doc_content = f"""# {file_data.get('name', 'Figma Export')} Exported from Figma on {time.strftime('%Y-%m-%d %H:%M:%S')} ## File Information - **File Key**: {file_key} - **Last Modified**: {file_data.get('lastModified', 'Unknown')} - **Version**: {file_data.get('version', 'Unknown')} ## Package Contents ### Frames ({results.get('frames', {}).get('exported', 0)} files) All page frames exported in configured formats Location: `./frames/` ### Components ({results.get('components', {}).get('exported', 0)} files) All reusable components exported for development handoff Location: `./components/` ### Design Tokens Design system tokens in multiple formats: - `design-tokens.json` - Raw token data - `design-tokens.css` - CSS custom properties - `design-tokens.scss` - SCSS variables ## Usage ### Web Development ```css /* Import CSS tokens */ @import './design-tokens.css'; .my-component {{ color: var(--color-primary); font-size: var(--typography-body-fontsize); }} ``` ### React/JavaScript ```javascript import tokens from './design-tokens.js'; const MyComponent = () => (
Content
); ``` ## Support For questions about this export or design implementation, contact your design team. """ with open(doc_path, 'w') as f: f.write(doc_content) def main(): """CLI interface for export operations""" parser = argparse.ArgumentParser(description='Figma Export Manager') parser.add_argument('command', choices=[ 'export-frames', 'export-components', 'export-pages', 'export-nodes', 'export-tokens', 'client-package' ]) parser.add_argument('file_key', help='Figma file key or URL') parser.add_argument('node_ids', nargs='?', help='Comma-separated node IDs for export-nodes') parser.add_argument('--formats', default='png', help='Export formats (comma-separated)') parser.add_argument('--scales', default='1.0', help='Export scales (comma-separated)') parser.add_argument('--output-dir', default='./figma-exports', help='Output directory') parser.add_argument('--token-format', default='json', choices=['json', 'css', 'scss', 'js']) parser.add_argument('--package-name', help='Name for client package') parser.add_argument('--frame-names', help='Specific frame names to export (comma-separated)') parser.add_argument('--component-names', help='Specific component names to export (comma-separated)') args = parser.parse_args() try: client = FigmaClient() file_key = client.parse_file_url(args.file_key) # Configure export settings config = ExportConfig( formats=args.formats.split(','), scales=[float(s) for s in args.scales.split(',')], output_dir=args.output_dir ) manager = ExportManager(client, config) if args.command == 'export-frames': frame_names = args.frame_names.split(',') if args.frame_names else None result = manager.export_frames(file_key, frame_names=frame_names) elif args.command == 'export-components': component_names = args.component_names.split(',') if args.component_names else None result = manager.export_components(file_key, component_names=component_names) elif args.command == 'export-pages': result = manager.export_pages(file_key) elif args.command == 'export-nodes': if not args.node_ids: parser.error('node_ids required for export-nodes command') result = manager.export_custom_selection(file_key, args.node_ids.split(',')) elif args.command == 'export-tokens': result = manager.export_design_tokens(file_key, args.token_format) print(f"Design tokens exported: {result}") return elif args.command == 'client-package': result = manager.create_client_package(file_key, args.package_name) print(f"Client package created: {result}") return print(f"Export completed: {result['exported']} files exported") except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) if __name__ == '__main__': main()