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", " ")