""" Symbol Library Management utilities for KiCad. Provides functionality to analyze, manage, and manipulate KiCad symbol libraries including library validation, symbol extraction, and library organization. """ import json import os import re from dataclasses import dataclass from typing import Dict, List, Optional, Any, Tuple import logging logger = logging.getLogger(__name__) @dataclass class SymbolPin: """Represents a symbol pin with electrical and geometric properties.""" number: str name: str position: Tuple[float, float] orientation: str # "L", "R", "U", "D" electrical_type: str # "input", "output", "bidirectional", "power_in", etc. graphic_style: str # "line", "inverted", "clock", etc. length: float = 2.54 # Default pin length in mm @dataclass class SymbolProperty: """Symbol property like reference, value, footprint, etc.""" name: str value: str position: Tuple[float, float] rotation: float = 0.0 visible: bool = True justify: str = "left" @dataclass class SymbolGraphics: """Graphical elements of a symbol.""" rectangles: List[Dict[str, Any]] circles: List[Dict[str, Any]] arcs: List[Dict[str, Any]] polylines: List[Dict[str, Any]] text: List[Dict[str, Any]] @dataclass class Symbol: """Represents a KiCad symbol with all its properties.""" name: str library_id: str description: str keywords: List[str] pins: List[SymbolPin] properties: List[SymbolProperty] graphics: SymbolGraphics footprint_filters: List[str] aliases: List[str] = None power_symbol: bool = False extends: Optional[str] = None # For derived symbols @dataclass class SymbolLibrary: """Represents a KiCad symbol library (.kicad_sym file).""" name: str file_path: str version: str symbols: List[Symbol] metadata: Dict[str, Any] class SymbolLibraryAnalyzer: """Analyzer for KiCad symbol libraries.""" def __init__(self): """Initialize the symbol library analyzer.""" self.libraries = {} self.symbol_cache = {} def load_library(self, library_path: str) -> SymbolLibrary: """Load a KiCad symbol library file.""" try: with open(library_path, 'r', encoding='utf-8') as f: content = f.read() # Parse library header library_name = os.path.basename(library_path).replace('.kicad_sym', '') version = self._extract_version(content) # Parse symbols symbols = self._parse_symbols(content) library = SymbolLibrary( name=library_name, file_path=library_path, version=version, symbols=symbols, metadata=self._extract_metadata(content) ) self.libraries[library_name] = library logger.info(f"Loaded library '{library_name}' with {len(symbols)} symbols") return library except Exception as e: logger.error(f"Failed to load library {library_path}: {e}") raise def _extract_version(self, content: str) -> str: """Extract version from library content.""" version_match = re.search(r'\(version\s+(\d+)\)', content) return version_match.group(1) if version_match else "unknown" def _extract_metadata(self, content: str) -> Dict[str, Any]: """Extract library metadata.""" metadata = {} # Extract generator info generator_match = re.search(r'\(generator\s+"([^"]+)"\)', content) if generator_match: metadata["generator"] = generator_match.group(1) return metadata def _parse_symbols(self, content: str) -> List[Symbol]: """Parse symbols from library content.""" symbols = [] # Find all symbol definitions symbol_pattern = r'\(symbol\s+"([^"]+)"[^)]*\)' symbol_matches = [] # Use a more sophisticated parser to handle nested parentheses level = 0 current_symbol = None symbol_start = 0 for i, char in enumerate(content): if char == '(': if level == 0 and content[i:i+8] == '(symbol ': symbol_start = i level += 1 elif char == ')': level -= 1 if level == 0 and current_symbol is not None: symbol_content = content[symbol_start:i+1] symbol = self._parse_single_symbol(symbol_content) if symbol: symbols.append(symbol) current_symbol = None # Check if we're starting a symbol if level == 1 and content[i:i+8] == '(symbol ' and current_symbol is None: # Extract symbol name name_match = re.search(r'\(symbol\s+"([^"]+)"', content[i:i+100]) if name_match: current_symbol = name_match.group(1) logger.info(f"Parsed {len(symbols)} symbols from library") return symbols def _parse_single_symbol(self, symbol_content: str) -> Optional[Symbol]: """Parse a single symbol definition.""" try: # Extract symbol name name_match = re.search(r'\(symbol\s+"([^"]+)"', symbol_content) if not name_match: return None name = name_match.group(1) # Parse basic properties description = self._extract_property(symbol_content, "description") or "" keywords = self._extract_keywords(symbol_content) # Parse pins pins = self._parse_pins(symbol_content) # Parse properties properties = self._parse_properties(symbol_content) # Parse graphics graphics = self._parse_graphics(symbol_content) # Parse footprint filters footprint_filters = self._parse_footprint_filters(symbol_content) # Check if it's a power symbol power_symbol = "(power)" in symbol_content # Check for extends (derived symbols) extends_match = re.search(r'\(extends\s+"([^"]+)"\)', symbol_content) extends = extends_match.group(1) if extends_match else None return Symbol( name=name, library_id=name, # Will be updated with library prefix description=description, keywords=keywords, pins=pins, properties=properties, graphics=graphics, footprint_filters=footprint_filters, aliases=[], power_symbol=power_symbol, extends=extends ) except Exception as e: logger.error(f"Failed to parse symbol: {e}") return None def _extract_property(self, content: str, prop_name: str) -> Optional[str]: """Extract a property value from symbol content.""" pattern = f'\\(property\\s+"{prop_name}"\\s+"([^"]*)"' match = re.search(pattern, content) return match.group(1) if match else None def _extract_keywords(self, content: str) -> List[str]: """Extract keywords from symbol content.""" keywords_match = re.search(r'\(keywords\s+"([^"]*)"\)', content) if keywords_match: return [k.strip() for k in keywords_match.group(1).split() if k.strip()] return [] def _parse_pins(self, content: str) -> List[SymbolPin]: """Parse pins from symbol content.""" pins = [] # Pin pattern - matches KiCad 6+ format pin_pattern = r'\(pin\s+(\w+)\s+(\w+)\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)\s+\(length\s+([-\d.]+)\)[^)]*\(name\s+"([^"]*)"\s+[^)]*\)\s+\(number\s+"([^"]*)"\s+[^)]*\)' for match in re.finditer(pin_pattern, content): electrical_type = match.group(1) graphic_style = match.group(2) x = float(match.group(3)) y = float(match.group(4)) orientation_angle = int(match.group(5)) length = float(match.group(6)) pin_name = match.group(7) pin_number = match.group(8) # Convert angle to orientation orientation_map = {0: "R", 90: "U", 180: "L", 270: "D"} orientation = orientation_map.get(orientation_angle, "R") pin = SymbolPin( number=pin_number, name=pin_name, position=(x, y), orientation=orientation, electrical_type=electrical_type, graphic_style=graphic_style, length=length ) pins.append(pin) return pins def _parse_properties(self, content: str) -> List[SymbolProperty]: """Parse symbol properties.""" properties = [] # Property pattern prop_pattern = r'\(property\s+"([^"]+)"\s+"([^"]*)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)' for match in re.finditer(prop_pattern, content): name = match.group(1) value = match.group(2) x = float(match.group(3)) y = float(match.group(4)) rotation = float(match.group(5)) prop = SymbolProperty( name=name, value=value, position=(x, y), rotation=rotation ) properties.append(prop) return properties def _parse_graphics(self, content: str) -> SymbolGraphics: """Parse graphical elements from symbol.""" rectangles = [] circles = [] arcs = [] polylines = [] text = [] # Parse rectangles rect_pattern = r'\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)' for match in re.finditer(rect_pattern, content): rectangles.append({ "start": (float(match.group(1)), float(match.group(2))), "end": (float(match.group(3)), float(match.group(4))) }) # Parse circles circle_pattern = r'\(circle\s+\(center\s+([-\d.]+)\s+([-\d.]+)\)\s+\(radius\s+([-\d.]+)\)' for match in re.finditer(circle_pattern, content): circles.append({ "center": (float(match.group(1)), float(match.group(2))), "radius": float(match.group(3)) }) # Parse polylines (simplified) poly_pattern = r'\(polyline[^)]*\(pts[^)]+\)' polylines = [{"data": match.group(0)} for match in re.finditer(poly_pattern, content)] return SymbolGraphics( rectangles=rectangles, circles=circles, arcs=arcs, polylines=polylines, text=text ) def _parse_footprint_filters(self, content: str) -> List[str]: """Parse footprint filters from symbol.""" filters = [] # Look for footprint filter section fp_filter_match = re.search(r'\(fp_filters[^)]*\)', content, re.DOTALL) if fp_filter_match: filter_content = fp_filter_match.group(0) filter_pattern = r'"([^"]+)"' filters = [match.group(1) for match in re.finditer(filter_pattern, filter_content)] return filters def analyze_library_coverage(self, library: SymbolLibrary) -> Dict[str, Any]: """Analyze symbol library coverage and statistics.""" analysis = { "total_symbols": len(library.symbols), "categories": {}, "electrical_types": {}, "pin_counts": {}, "missing_properties": [], "duplicate_symbols": [], "unused_symbols": [], "statistics": {} } # Analyze by categories (based on keywords/names) categories = {} electrical_types = {} pin_counts = {} for symbol in library.symbols: # Categorize by keywords for keyword in symbol.keywords: categories[keyword] = categories.get(keyword, 0) + 1 # Count pin types for pin in symbol.pins: electrical_types[pin.electrical_type] = electrical_types.get(pin.electrical_type, 0) + 1 # Pin count distribution pin_count = len(symbol.pins) pin_counts[pin_count] = pin_counts.get(pin_count, 0) + 1 # Check for missing essential properties essential_props = ["Reference", "Value", "Footprint"] symbol_props = [p.name for p in symbol.properties] for prop in essential_props: if prop not in symbol_props: analysis["missing_properties"].append({ "symbol": symbol.name, "missing_property": prop }) analysis.update({ "categories": categories, "electrical_types": electrical_types, "pin_counts": pin_counts, "statistics": { "avg_pins_per_symbol": sum(pin_counts.keys()) / len(library.symbols) if library.symbols else 0, "most_common_category": max(categories.items(), key=lambda x: x[1])[0] if categories else None, "symbols_with_footprint_filters": len([s for s in library.symbols if s.footprint_filters]), "power_symbols": len([s for s in library.symbols if s.power_symbol]) } }) return analysis def find_similar_symbols(self, symbol: Symbol, library: SymbolLibrary, threshold: float = 0.7) -> List[Tuple[Symbol, float]]: """Find symbols similar to the given symbol.""" similar = [] for candidate in library.symbols: if candidate.name == symbol.name: continue similarity = self._calculate_symbol_similarity(symbol, candidate) if similarity >= threshold: similar.append((candidate, similarity)) return sorted(similar, key=lambda x: x[1], reverse=True) def _calculate_symbol_similarity(self, symbol1: Symbol, symbol2: Symbol) -> float: """Calculate similarity score between two symbols.""" score = 0.0 factors = 0 # Pin count similarity if symbol1.pins and symbol2.pins: pin_diff = abs(len(symbol1.pins) - len(symbol2.pins)) max_pins = max(len(symbol1.pins), len(symbol2.pins)) pin_similarity = 1.0 - (pin_diff / max_pins) if max_pins > 0 else 1.0 score += pin_similarity * 0.4 factors += 0.4 # Keyword similarity keywords1 = set(symbol1.keywords) keywords2 = set(symbol2.keywords) if keywords1 or keywords2: keyword_intersection = len(keywords1.intersection(keywords2)) keyword_union = len(keywords1.union(keywords2)) keyword_similarity = keyword_intersection / keyword_union if keyword_union > 0 else 0.0 score += keyword_similarity * 0.3 factors += 0.3 # Name similarity (simple string comparison) name_similarity = self._string_similarity(symbol1.name, symbol2.name) score += name_similarity * 0.3 factors += 0.3 return score / factors if factors > 0 else 0.0 def _string_similarity(self, str1: str, str2: str) -> float: """Calculate string similarity using simple character overlap.""" if not str1 or not str2: return 0.0 str1_lower = str1.lower() str2_lower = str2.lower() # Simple character-based similarity intersection = len(set(str1_lower).intersection(set(str2_lower))) union = len(set(str1_lower).union(set(str2_lower))) return intersection / union if union > 0 else 0.0 def validate_symbol(self, symbol: Symbol) -> List[str]: """Validate a symbol and return list of issues.""" issues = [] # Check for essential properties prop_names = [p.name for p in symbol.properties] essential_props = ["Reference", "Value"] for prop in essential_props: if prop not in prop_names: issues.append(f"Missing essential property: {prop}") # Check pin consistency pin_numbers = [p.number for p in symbol.pins] if len(pin_numbers) != len(set(pin_numbers)): issues.append("Duplicate pin numbers found") # Check for pins without names unnamed_pins = [p.number for p in symbol.pins if not p.name] if unnamed_pins: issues.append(f"Pins without names: {', '.join(unnamed_pins)}") # Validate electrical types valid_types = ["input", "output", "bidirectional", "tri_state", "passive", "free", "unspecified", "power_in", "power_out", "open_collector", "open_emitter", "no_connect"] for pin in symbol.pins: if pin.electrical_type not in valid_types: issues.append(f"Invalid electrical type '{pin.electrical_type}' for pin {pin.number}") return issues def export_symbol_report(self, library: SymbolLibrary) -> Dict[str, Any]: """Export a comprehensive symbol library report.""" analysis = self.analyze_library_coverage(library) # Add validation results validation_results = [] for symbol in library.symbols: issues = self.validate_symbol(symbol) if issues: validation_results.append({ "symbol": symbol.name, "issues": issues }) return { "library_info": { "name": library.name, "file_path": library.file_path, "version": library.version, "total_symbols": len(library.symbols) }, "analysis": analysis, "validation": { "total_issues": len(validation_results), "symbols_with_issues": len(validation_results), "issues_by_symbol": validation_results }, "recommendations": self._generate_recommendations(library, analysis, validation_results) } def _generate_recommendations(self, library: SymbolLibrary, analysis: Dict[str, Any], validation_results: List[Dict[str, Any]]) -> List[str]: """Generate recommendations for library improvement.""" recommendations = [] # Check for missing footprint filters no_filters = [s for s in library.symbols if not s.footprint_filters] if len(no_filters) > len(library.symbols) * 0.5: recommendations.append("Consider adding footprint filters to more symbols for better component matching") # Check for validation issues if validation_results: recommendations.append(f"Address {len(validation_results)} symbols with validation issues") # Check pin distribution if analysis["statistics"]["avg_pins_per_symbol"] > 50: recommendations.append("Library contains many high-pin-count symbols - consider splitting complex symbols") # Check category distribution if len(analysis["categories"]) < 5: recommendations.append("Consider adding more keyword categories for better symbol organization") return recommendations def create_symbol_analyzer() -> SymbolLibraryAnalyzer: """Create and initialize a symbol library analyzer.""" return SymbolLibraryAnalyzer()