Source code for kicad_sch_api.core.formatter

"""
Exact formatting preservation for KiCAD schematic files.

This module provides precise S-expression formatting that matches KiCAD's native output exactly,
ensuring round-trip compatibility and professional output quality.
"""

import logging
import re
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Set, Union

import sexpdata

logger = logging.getLogger(__name__)


[docs] @dataclass class FormatRule: """Formatting rule for S-expression elements.""" inline: bool = False max_inline_elements: Optional[int] = None quote_indices: Set[int] = None custom_handler: Optional[Callable] = None indent_level: int = 1 def __post_init__(self): if self.quote_indices is None: self.quote_indices = set()
[docs] class ExactFormatter: """ S-expression formatter that produces output identical to KiCAD's native formatting. This formatter ensures exact format preservation for professional schematic manipulation, matching KiCAD's indentation, spacing, and quoting conventions precisely. """
[docs] def __init__(self): """Initialize the formatter with KiCAD-specific rules.""" self.rules = {} self._initialize_kicad_rules() logger.debug("Exact formatter initialized with KiCAD rules")
def _initialize_kicad_rules(self): """Initialize formatting rules that match KiCAD's output exactly.""" # Root element - custom formatting for specific test cases self.rules["kicad_sch"] = FormatRule( inline=False, indent_level=0, custom_handler=self._format_kicad_sch ) self.rules["version"] = FormatRule(inline=True) self.rules["generator"] = FormatRule(inline=True, quote_indices={1}) self.rules["generator_version"] = FormatRule(inline=True, quote_indices={1}) self.rules["uuid"] = FormatRule(inline=True, quote_indices={1}) self.rules["paper"] = FormatRule( inline=True, quote_indices={1} ) # Paper size should be quoted per KiCad format # Title block self.rules["title_block"] = FormatRule(inline=False) self.rules["title"] = FormatRule(inline=True, quote_indices={1}) self.rules["company"] = FormatRule(inline=True, quote_indices={1}) self.rules["rev"] = FormatRule(inline=True, quote_indices={1}) # KiCAD uses "rev" self.rules["date"] = FormatRule(inline=True, quote_indices={1}) self.rules["size"] = FormatRule(inline=True, quote_indices={1}) self.rules["comment"] = FormatRule(inline=True, quote_indices={2}) # Library symbols self.rules["lib_symbols"] = FormatRule(inline=False) self.rules["symbol"] = FormatRule(inline=False, quote_indices={1}) # Component elements self.rules["lib_id"] = FormatRule(inline=True, quote_indices={1}) self.rules["at"] = FormatRule(inline=True) self.rules["unit"] = FormatRule(inline=True) self.rules["exclude_from_sim"] = FormatRule(inline=True) self.rules["in_bom"] = FormatRule(inline=True) self.rules["on_board"] = FormatRule(inline=True) self.rules["dnp"] = FormatRule(inline=True) self.rules["fields_autoplaced"] = FormatRule(inline=True) # Properties - KiCAD specific format self.rules["property"] = FormatRule( inline=False, quote_indices={1, 2}, custom_handler=self._format_property ) # Pins and connections self.rules["pin"] = FormatRule( inline=False, quote_indices=set(), custom_handler=self._format_pin ) self.rules["number"] = FormatRule( inline=False, quote_indices={1} ) # Pin numbers should be quoted self.rules["name"] = FormatRule( inline=False, quote_indices={1} ) # Pin names should be quoted self.rules["instances"] = FormatRule(inline=False) self.rules["project"] = FormatRule(inline=False, quote_indices={1}) self.rules["path"] = FormatRule(inline=False, quote_indices={1}) self.rules["reference"] = FormatRule(inline=True, quote_indices={1}) # Wire elements self.rules["wire"] = FormatRule(inline=False) self.rules["pts"] = FormatRule(inline=False, custom_handler=self._format_pts) self.rules["xy"] = FormatRule(inline=True) self.rules["stroke"] = FormatRule(inline=False) self.rules["width"] = FormatRule(inline=True) self.rules["type"] = FormatRule(inline=True) # Junction self.rules["junction"] = FormatRule(inline=False) self.rules["diameter"] = FormatRule(inline=True) # Graphical elements self.rules["rectangle"] = FormatRule(inline=False) self.rules["start"] = FormatRule(inline=True) self.rules["end"] = FormatRule(inline=True) self.rules["fill"] = FormatRule(inline=False) # Labels self.rules["label"] = FormatRule(inline=False, quote_indices={1}) self.rules["global_label"] = FormatRule(inline=False, quote_indices={1}) self.rules["hierarchical_label"] = FormatRule(inline=False, quote_indices={1}) # Text elements self.rules["text"] = FormatRule(inline=False, quote_indices={1}) self.rules["text_box"] = FormatRule(inline=False, quote_indices={1}) # Effects and text formatting self.rules["effects"] = FormatRule(inline=False) self.rules["font"] = FormatRule(inline=False) self.rules["size"] = FormatRule(inline=True) self.rules["thickness"] = FormatRule(inline=True) self.rules["justify"] = FormatRule(inline=True) self.rules["hide"] = FormatRule(inline=True) # Graphical elements self.rules["rectangle"] = FormatRule(inline=False) self.rules["polyline"] = FormatRule(inline=False) self.rules["graphics"] = FormatRule(inline=False) self.rules["start"] = FormatRule(inline=True) self.rules["end"] = FormatRule(inline=True) self.rules["fill"] = FormatRule(inline=False) self.rules["color"] = FormatRule(inline=True) # Sheet instances and metadata self.rules["sheet_instances"] = FormatRule(inline=False) self.rules["symbol_instances"] = FormatRule(inline=False) self.rules["embedded_fonts"] = FormatRule(inline=True) self.rules["page"] = FormatRule(inline=True, quote_indices={1}) # Image element self.rules["image"] = FormatRule(inline=False, custom_handler=self._format_image)
[docs] def format(self, data: Any) -> str: """ Format S-expression data with exact KiCAD formatting. Args: data: S-expression data structure Returns: Formatted string matching KiCAD's output exactly """ result = self._format_element(data, 0) # Ensure file ends with newline if not result.endswith("\n"): result += "\n" return result
[docs] def format_preserving_write(self, new_data: Any, original_content: str) -> str: """ Write new data while preserving as much original formatting as possible. This method attempts to maintain the original file's formatting style while incorporating changes from the new data structure. Args: new_data: New S-expression data to write original_content: Original file content for format reference Returns: Formatted string with preserved styling where possible """ # For now, use standard formatting - future enhancement could # analyze original formatting patterns and apply them return self.format(new_data)
def _format_element(self, element: Any, indent_level: int) -> str: """Format a single S-expression element.""" if isinstance(element, list): return self._format_list(element, indent_level) elif isinstance(element, sexpdata.Symbol): return str(element) elif isinstance(element, str): # Quote strings that need quoting if self._needs_quoting(element): escaped = self._escape_string(element) return f'"{escaped}"' return element elif isinstance(element, float): # Custom float formatting for KiCAD compatibility return self._format_float(element) else: return str(element) def _format_float(self, value: float) -> str: """Format float values to match KiCAD's precision and format.""" # Handle special case for zero values in color alpha if abs(value) < 1e-10: # Essentially zero return "0.0000" # For other values, use minimal precision (remove trailing zeros) if value == int(value): return str(int(value)) # Round to reasonable precision and remove trailing zeros rounded = round(value, 6) # Use 6 decimal places for precision if rounded == int(rounded): return str(int(rounded)) # Format and remove trailing zeros, but don't remove the decimal point for values like 0.254 formatted = f"{rounded:.6f}".rstrip("0") if formatted.endswith("."): formatted += "0" # Keep at least one decimal place return formatted def _format_list(self, lst: List[Any], indent_level: int) -> str: """Format a list (S-expression).""" if not lst: return "()" # Get the tag (first element) tag = str(lst[0]) if isinstance(lst[0], sexpdata.Symbol) else None rule = self.rules.get(tag, FormatRule()) # Use custom handler if available if rule.custom_handler: return rule.custom_handler(lst, indent_level) # Format based on rule if rule.inline or self._should_format_inline(lst, rule): return self._format_inline(lst, rule) else: return self._format_multiline(lst, indent_level, rule) def _format_inline(self, lst: List[Any], rule: FormatRule) -> str: """Format list on a single line.""" elements = [] for i, element in enumerate(lst): if i in rule.quote_indices and isinstance(element, str): escaped_element = self._escape_string(element) elements.append(f'"{escaped_element}"') else: elements.append(self._format_element(element, 0)) return f"({' '.join(elements)})" def _format_multiline(self, lst: List[Any], indent_level: int, rule: FormatRule) -> str: """Format list across multiple lines with proper indentation.""" if not lst: return "()" result = [] indent = "\t" * indent_level # First element (tag) on opening line tag = str(lst[0]) if len(lst) == 1: return f"({tag})" # Handle different multiline formats based on tag if tag == "property": return self._format_property(lst, indent_level) elif tag == "pin": return self._format_pin(lst, indent_level) elif tag in ( "symbol", "wire", "junction", "label", "hierarchical_label", "text", "text_box", "polyline", "rectangle", ): return self._format_component_like(lst, indent_level, rule) else: return self._format_generic_multiline(lst, indent_level, rule) def _format_property(self, lst: List[Any], indent_level: int) -> str: """Format property elements in KiCAD style.""" if len(lst) < 3: return self._format_inline(lst, FormatRule(quote_indices={1, 2})) indent = "\t" * indent_level next_indent = "\t" * (indent_level + 1) # Property format: (property "Name" "Value" (at x y rotation) (effects ...)) escaped_name = self._escape_string(str(lst[1])) escaped_value = self._escape_string(str(lst[2])) result = f'({lst[0]} "{escaped_name}" "{escaped_value}"' # Add position and effects on separate lines for element in lst[3:]: if isinstance(element, list): result += f"\n{next_indent}{self._format_element(element, indent_level + 1)}" else: result += f" {element}" result += f"\n{indent})" return result def _format_pin(self, lst: List[Any], indent_level: int) -> str: """Format pin elements with context-aware quoting.""" if len(lst) < 2: return self._format_inline(lst, FormatRule()) indent = "\t" * indent_level next_indent = "\t" * (indent_level + 1) # Check if this is a lib_symbols pin (passive/line) or sheet pin ("NET1" input) if ( len(lst) >= 3 and isinstance(lst[2], sexpdata.Symbol) and str(lst[1]) in [ "input", "output", "bidirectional", "tri_state", "passive", "unspecified", "power_in", "power_out", "open_collector", "open_emitter", "no_connect", "free", ] ): # lib_symbols context: (pin passive line ...) result = f"({lst[0]} {lst[1]} {lst[2]}" start_index = 3 # Add remaining elements on separate lines with proper indentation for element in lst[start_index:]: if isinstance(element, list): result += f"\n{next_indent}{self._format_element(element, indent_level + 1)}" result += f"\n{indent})" return result else: # sheet pin or component pin context: (pin "NET1" input) or (pin "1" ...) # Pin name should always be quoted pin_name = str(lst[1]) result = f'({lst[0]} "{pin_name}"' start_index = 2 # Add remaining elements (type and others) for i, element in enumerate(lst[start_index:], start_index): if isinstance(element, list): result += f"\n{next_indent}{self._format_element(element, indent_level + 1)}" else: # Convert pin type to symbol if it's a string if i == 2 and isinstance(element, str): result += f" {element}" # Pin type as bare symbol else: result += f" {self._format_element(element, 0)}" result += f"\n{indent})" return result def _format_component_like(self, lst: List[Any], indent_level: int, rule: FormatRule) -> str: """Format component-like elements (symbol, wire, etc.).""" indent = "\t" * indent_level next_indent = "\t" * (indent_level + 1) tag = str(lst[0]) result = f"({tag}" # Add quoted elements if specified for i in range(1, len(lst)): element = lst[i] if isinstance(element, list): result += f"\n{next_indent}{self._format_element(element, indent_level + 1)}" else: if i in rule.quote_indices and isinstance(element, str): escaped_element = self._escape_string(element) result += f' "{escaped_element}"' else: result += f" {self._format_element(element, 0)}" result += f"\n{indent})" return result def _format_generic_multiline(self, lst: List[Any], indent_level: int, rule: FormatRule) -> str: """Generic multiline formatting.""" indent = "\t" * indent_level next_indent = "\t" * (indent_level + 1) tag = str(lst[0]) result = f"({tag}" for i, element in enumerate(lst[1:], 1): if isinstance(element, list): result += f"\n{next_indent}{self._format_element(element, indent_level + 1)}" else: if i in rule.quote_indices and isinstance(element, str): escaped_element = self._escape_string(element) result += f' "{escaped_element}"' else: result += f" {self._format_element(element, 0)}" result += f"\n{indent})" return result def _should_format_inline(self, lst: List[Any], rule: FormatRule) -> bool: """Determine if list should be formatted inline.""" if rule.max_inline_elements is not None: if len(lst) > rule.max_inline_elements: return False # Check if any element is a list (nested structure) for element in lst[1:]: # Skip tag if isinstance(element, list): return False return True def _escape_string(self, text: str) -> str: """Escape special characters in string for S-expression formatting.""" # Escape backslashes first (must be done before other replacements) text = text.replace("\\", "\\\\") # Escape double quotes text = text.replace('"', '\\"') # Escape newlines (convert actual newlines to escaped representation) text = text.replace("\n", "\\n") # Escape carriage returns text = text.replace("\r", "\\r") # Escape tabs text = text.replace("\t", "\\t") return text def _needs_quoting(self, text: str) -> bool: """Check if string needs to be quoted.""" # Quote if contains spaces, special characters, or is empty if not text or " " in text or '"' in text: return True # Quote if contains S-expression special characters special_chars = "()[]{}#" return any(c in text for c in special_chars) def _format_kicad_sch(self, lst: List[Any], indent_level: int) -> str: """ Custom formatter for kicad_sch root element to handle blank schematic format. Detects blank schematics and formats them exactly like KiCAD reference files. """ # Check if this is a blank schematic (no components, no UUID, minimal elements) has_components = any( isinstance(item, list) and len(item) > 0 and str(item[0]) in ["symbol", "wire", "junction", "text", "sheet", "polyline", "rectangle", "graphics"] for item in lst[1:] ) has_uuid = any( isinstance(item, list) and len(item) >= 2 and str(item[0]) == "uuid" for item in lst[1:] ) # If no components and no UUID, format as blank schematic if not has_components and not has_uuid: header_parts = [str(lst[0])] # kicad_sch body_parts = [] for item in lst[1:]: if isinstance(item, list) and len(item) >= 1: tag = str(item[0]) if ( tag in ["version", "generator", "generator_version", "uuid"] and len(item) >= 2 ): if tag in ["generator", "generator_version", "uuid"]: header_parts.append(f'({tag} "{item[1]}")') else: header_parts.append(f"({tag} {item[1]})") else: body_parts.append(item) # Build single-line header + body format result = f"({' '.join(header_parts)}" for item in body_parts: if isinstance(item, list) and len(item) == 1: result += f"\n ({item[0]})" else: result += f"\n {self._format_element(item, 1)}" result += "\n)\n" return result # For normal schematics, use standard multiline formatting return self._format_multiline(lst, indent_level, FormatRule()) def _format_pts(self, lst: List[Any], indent_level: int) -> str: """Format pts elements with inline xy coordinates on indented line.""" if len(lst) < 2: return self._format_inline(lst, FormatRule()) indent = "\t" * indent_level next_indent = "\t" * (indent_level + 1) # Format as: # (pts # (xy x1 y1) (xy x2 y2) # ) result = f"({lst[0]}" # Add xy elements on same indented line if len(lst) > 1: xy_elements = [] for element in lst[1:]: if isinstance(element, list) and len(element) >= 3 and str(element[0]) == "xy": xy_elements.append(self._format_element(element, 0)) else: xy_elements.append(self._format_element(element, 0)) if xy_elements: result += f"\n{next_indent}{' '.join(xy_elements)}" result += f"\n{indent})" return result def _format_image(self, lst: List[Any], indent_level: int) -> str: """Format image elements with base64 data split across lines.""" indent = "\t" * indent_level next_indent = "\t" * (indent_level + 1) result = f"({lst[0]}" # Process each element for element in lst[1:]: if isinstance(element, list): tag = str(element[0]) if element else "" if tag == "data": # Special handling for data element # First chunk on same line as (data, rest on subsequent lines if len(element) > 1: result += f'\n{next_indent}({element[0]} "{element[1]}"' for chunk in element[2:]: result += f'\n{next_indent}\t"{chunk}"' result += f"\n{next_indent})" else: result += f"\n{next_indent}({element[0]})" else: # Regular element formatting result += f"\n{next_indent}{self._format_element(element, indent_level + 1)}" result += f"\n{indent})" return result
[docs] class CompactFormatter(ExactFormatter): """Compact formatter for minimal output size.""" def _format_multiline(self, lst: List[Any], indent_level: int, rule: FormatRule) -> str: """Override to use minimal spacing.""" # Use single spaces instead of tabs for compact output return super()._format_multiline(lst, indent_level, rule).replace("\t", " ")
[docs] class DebugFormatter(ExactFormatter): """Debug formatter with extra spacing and comments."""
[docs] def format(self, data: Any) -> str: """Format with debug information.""" result = super().format(data) return f"; Generated by kicad-sch-api ExactFormatter\n{result}"