Source code for kicad_sch_api.core.texts

"""
Text element management for KiCAD schematics.

This module provides collection classes for managing text elements,
featuring fast lookup, bulk operations, and validation.
"""

import logging
import uuid
from typing import Any, Callable, Dict, List, Optional, Tuple, Union

from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
from .collections import BaseCollection
from .types import Point, Text

logger = logging.getLogger(__name__)


[docs] class TextElement: """ Enhanced wrapper for schematic text elements with modern API. Provides intuitive access to text properties and operations while maintaining exact format preservation. """
[docs] def __init__(self, text_data: Text, parent_collection: "TextCollection"): """ Initialize text element wrapper. Args: text_data: Underlying text data parent_collection: Parent collection for updates """ self._data = text_data self._collection = parent_collection self._validator = SchematicValidator()
# Core properties with validation @property def uuid(self) -> str: """Text element UUID.""" return self._data.uuid @property def text(self) -> str: """Text content.""" return self._data.text @text.setter def text(self, value: str): """Set text content with validation.""" if not isinstance(value, str): raise ValidationError(f"Text content must be string, got {type(value)}") self._data.text = value self._collection._mark_modified() @property def position(self) -> Point: """Text position.""" return self._data.position @position.setter def position(self, value: Union[Point, Tuple[float, float]]): """Set text position.""" if isinstance(value, tuple): value = Point(value[0], value[1]) elif not isinstance(value, Point): raise ValidationError(f"Position must be Point or tuple, got {type(value)}") self._data.position = value self._collection._mark_modified() @property def rotation(self) -> float: """Text rotation in degrees.""" return self._data.rotation @rotation.setter def rotation(self, value: float): """Set text rotation.""" self._data.rotation = float(value) self._collection._mark_modified() @property def size(self) -> float: """Text size.""" return self._data.size @size.setter def size(self, value: float): """Set text size with validation.""" if value <= 0: raise ValidationError(f"Text size must be positive, got {value}") self._data.size = float(value) self._collection._mark_modified() @property def exclude_from_sim(self) -> bool: """Whether text is excluded from simulation.""" return self._data.exclude_from_sim @exclude_from_sim.setter def exclude_from_sim(self, value: bool): """Set exclude from simulation flag.""" self._data.exclude_from_sim = bool(value) self._collection._mark_modified() @property def bold(self) -> bool: """Text bold flag.""" return getattr(self._data, "bold", False) @bold.setter def bold(self, value: bool) -> None: """Set text bold flag.""" self._data.bold = bool(value) self._collection._mark_modified() @property def italic(self) -> bool: """Text italic flag.""" return getattr(self._data, "italic", False) @italic.setter def italic(self, value: bool) -> None: """Set text italic flag.""" self._data.italic = bool(value) self._collection._mark_modified() @property def thickness(self) -> Optional[float]: """Text stroke thickness.""" return getattr(self._data, "thickness", None) @thickness.setter def thickness(self, value: Optional[float]) -> None: """Set text stroke thickness.""" if value is not None and value <= 0: raise ValidationError(f"Thickness must be positive, got {value}") self._data.thickness = float(value) if value is not None else None self._collection._mark_modified() @property def color(self) -> Optional[Tuple[int, int, int, float]]: """Text color (RGBA).""" return getattr(self._data, "color", None) @color.setter def color(self, value: Optional[Tuple[int, int, int, float]]) -> None: """Set text color.""" if value is not None: if len(value) != 4: raise ValidationError(f"Color must be RGBA tuple (4 values), got {len(value)}") r, g, b, a = value if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255 and 0 <= a <= 1): raise ValidationError("Color values out of range: RGB 0-255, A 0-1") value = (int(r), int(g), int(b), float(a)) self._data.color = value self._collection._mark_modified() @property def face(self) -> Optional[str]: """Font face name.""" return getattr(self._data, "face", None) @face.setter def face(self, value: Optional[str]) -> None: """Set font face name.""" self._data.face = str(value) if value is not None else None self._collection._mark_modified()
[docs] def validate(self) -> List[ValidationIssue]: """Validate this text element.""" return self._validator.validate_text(self._data.__dict__)
[docs] def to_dict(self) -> Dict[str, Any]: """Convert text element to dictionary representation.""" result = { "uuid": self.uuid, "text": self.text, "position": {"x": self.position.x, "y": self.position.y}, "rotation": self.rotation, "size": self.size, "exclude_from_sim": self.exclude_from_sim, } # Include optional font effects if set if self.bold: result["bold"] = self.bold if self.italic: result["italic"] = self.italic if self.thickness is not None: result["thickness"] = self.thickness if self.color is not None: result["color"] = self.color if self.face is not None: result["face"] = self.face return result
[docs] def __str__(self) -> str: """String representation.""" return f"<Text '{self.text}' @ {self.position}>"
[docs] class TextCollection(BaseCollection[TextElement]): """ Collection class for efficient text element management. Inherits from BaseCollection for standard operations and adds text-specific functionality including content-based indexing. Provides fast lookup, filtering, and bulk operations for schematic text elements. """
[docs] def __init__(self, texts: List[Text] = None): """ Initialize text collection. Args: texts: Initial list of text data """ # Initialize base collection with empty list (we'll add elements below) super().__init__([], collection_name="texts") # Additional text-specific index self._content_index: Dict[str, List[TextElement]] = {} # Add initial texts if texts: for text_data in texts: self._add_to_indexes(TextElement(text_data, self))
[docs] def add( self, text: str, position: Union[Point, Tuple[float, float]], rotation: float = 0.0, size: float = 1.27, exclude_from_sim: bool = False, text_uuid: Optional[str] = None, # Font effects (new parameters) bold: bool = False, italic: bool = False, thickness: Optional[float] = None, color: Optional[Tuple[int, int, int, float]] = None, face: Optional[str] = None, ) -> TextElement: """ Add a new text element to the schematic. Args: text: Text content position: Text position rotation: Text rotation in degrees size: Text size exclude_from_sim: Whether to exclude from simulation text_uuid: Specific UUID for text (auto-generated if None) bold: Bold font flag italic: Italic font flag thickness: Stroke width (None = use default) color: RGBA color tuple (None = use default) face: Font face name (None = use default) Returns: Newly created TextElement Raises: ValidationError: If text data is invalid """ # Validate inputs if not isinstance(text, str) or not text.strip(): raise ValidationError("Text content cannot be empty") if isinstance(position, tuple): position = Point(position[0], position[1]) elif not isinstance(position, Point): raise ValidationError(f"Position must be Point or tuple, got {type(position)}") if size <= 0: raise ValidationError(f"Text size must be positive, got {size}") # Validate color if provided if color is not None: if len(color) != 4: raise ValidationError(f"Color must be RGBA tuple (4 values), got {len(color)}") r, g, b, a = color if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255 and 0 <= a <= 1): raise ValidationError("Color values out of range: RGB 0-255, A 0-1") color = (int(r), int(g), int(b), float(a)) # Validate thickness if provided if thickness is not None and thickness <= 0: raise ValidationError(f"Thickness must be positive, got {thickness}") # Generate UUID if not provided if not text_uuid: text_uuid = str(uuid.uuid4()) # Check for duplicate UUID if text_uuid in self._uuid_index: raise ValidationError(f"Text UUID {text_uuid} already exists") # Create text data with all properties text_data = Text( uuid=text_uuid, position=position, text=text, rotation=rotation, size=size, exclude_from_sim=exclude_from_sim, bold=bold, italic=italic, thickness=thickness, color=color, face=face, ) # Create wrapper and add to collection text_element = TextElement(text_data, self) self._add_to_indexes(text_element) logger.debug(f"Added text: {text_element}") return text_element
[docs] def remove(self, text_uuid: str) -> bool: """ Remove text by UUID. Args: text_uuid: UUID of text to remove Returns: True if text was removed, False if not found """ text_element = self.get(text_uuid) if not text_element: return False # Remove from content index content = text_element.text if content in self._content_index: self._content_index[content].remove(text_element) if not self._content_index[content]: del self._content_index[content] # Remove using base class method super().remove(text_uuid) logger.debug(f"Removed text: {text_element}") return True
[docs] def find_by_content(self, content: str, exact: bool = True) -> List[TextElement]: """ Find texts by content. Args: content: Content to search for exact: If True, exact match; if False, substring match Returns: List of matching text elements """ if exact: return self._content_index.get(content, []).copy() else: matches = [] for text_element in self._items: if content.lower() in text_element.text.lower(): matches.append(text_element) return matches
[docs] def filter(self, predicate: Callable[[TextElement], bool]) -> List[TextElement]: """ Filter texts by predicate function (delegates to base class find). Args: predicate: Function that returns True for texts to include Returns: List of texts matching predicate """ return self.find(predicate)
[docs] def bulk_update(self, criteria: Callable[[TextElement], bool], updates: Dict[str, Any]): """ Update multiple texts matching criteria. Args: criteria: Function to select texts to update updates: Dictionary of property updates """ updated_count = 0 for text_element in self._items: if criteria(text_element): for prop, value in updates.items(): if hasattr(text_element, prop): setattr(text_element, prop, value) updated_count += 1 if updated_count > 0: self._mark_modified() logger.debug(f"Bulk updated {updated_count} text properties")
[docs] def clear(self): """Remove all texts from collection.""" self._content_index.clear() super().clear()
def _add_to_indexes(self, text_element: TextElement): """Add text to internal indexes (base + content index).""" self._add_item(text_element) # Add to content index content = text_element.text if content not in self._content_index: self._content_index[content] = [] self._content_index[content].append(text_element) # Collection interface methods - __len__, __iter__, __getitem__ inherited from BaseCollection
[docs] def __bool__(self) -> bool: """Return True if collection has texts.""" return len(self._items) > 0