Implement Specctra DSN parser with typed board model

Ports the read path of FreeRouting's io/specctra/parser package to a
Java-free Python implementation:

- tokenizer: S-expression lexer mirroring SpecctraFileDescription.flex
  (comments, quoted strings, the string_quote IGNORE_QUOTE directive,
  case-insensitive keywords, and hash-prefixed names)
- sexp: nested S-expression tree builder
- shapes: rect/circle/polygon/path plus area-with-holes scopes
- model: typed dataclasses for layers, padstacks, images, placements,
  nets, net classes, rules, keepouts
- reader: recursive-descent scope readers producing a DsnBoard, one
  _read_* function per FreeRouting read_scope method

46 pytest cases cover the tokenizer, tree, shapes, and end-to-end
parsing against hand-crafted fixtures modeled on FreeRouting's own
test DSN files. Parses 90 of 91 upstream fixtures (the one failure is
a binary OLE file, not text DSN).
This commit is contained in:
2026-07-11 15:21:18 -06:00
parent dc44abc7f7
commit 3bf4f6bed2
13 changed files with 1847 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
"""Specctra DSN parsing for freeroute.
Public API::
from freeroute.dsn import parse_dsn
board = parse_dsn(dsn_text)
``parse_dsn`` returns a typed :class:`~freeroute.dsn.model.DsnBoard`. The
lower-level :func:`~freeroute.dsn.tokenizer.tokenize` and
:func:`~freeroute.dsn.sexp.parse` are exposed for tooling and tests.
"""
from __future__ import annotations
from .model import (
ClearanceRule,
ComponentPlace,
ComponentPlacement,
DsnBoard,
Image,
Keepout,
Layer,
Net,
NetClass,
NetPin,
Padstack,
ParserInfo,
Pin,
Resolution,
StructureRules,
WidthRule,
)
from .reader import DsnParseError, parse_dsn
from .sexp import SExp, parse
from .shapes import Area, Circle, Path, Polygon, Rectangle, Shape
from .tokenizer import DsnSyntaxError, Token, TokenKind, tokenize
__all__ = [
"parse_dsn",
"DsnParseError",
"parse",
"SExp",
"tokenize",
"Token",
"TokenKind",
"DsnSyntaxError",
"DsnBoard",
"ParserInfo",
"Resolution",
"Layer",
"WidthRule",
"ClearanceRule",
"StructureRules",
"Padstack",
"Pin",
"Image",
"ComponentPlace",
"ComponentPlacement",
"NetPin",
"Net",
"NetClass",
"Keepout",
"Shape",
"Rectangle",
"Circle",
"Polygon",
"Path",
"Area",
]
+218
View File
@@ -0,0 +1,218 @@
"""Typed board model produced by parsing a Specctra DSN file.
These dataclasses are the Python analogue of the data that FreeRouting's
``io/specctra/parser`` scope readers accumulate before constructing a
``RoutingBoard``. They stay at the DSN level (names, raw coordinates in DSN
units) — turning them into board geometry is a later port stage.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .shapes import Area, Shape
__all__ = [
"ParserInfo",
"Resolution",
"Layer",
"WidthRule",
"ClearanceRule",
"StructureRules",
"Padstack",
"Pin",
"Image",
"ComponentPlace",
"ComponentPlacement",
"NetPin",
"Net",
"NetClass",
"Keepout",
"DsnBoard",
]
@dataclass
class ParserInfo:
"""The ``(parser ...)`` scope."""
string_quote: str = '"'
host_cad: str | None = None
host_version: str | None = None
constants: list[tuple[str, str]] = field(default_factory=list)
generated_by_freerouting: bool = False
@dataclass
class Resolution:
"""The ``(resolution <unit> <value>)`` scope. Defaults match FreeRouting."""
unit: str = "mil"
value: int = 100
@dataclass
class Layer:
"""A ``(layer ...)`` entry. ``index`` is the physical layer number, 0 = top."""
name: str
index: int
is_signal: bool = True
net_names: list[str] = field(default_factory=list)
@dataclass
class WidthRule:
value: float
@dataclass
class ClearanceRule:
value: float
#: class-pair names from ``(type a-b)`` / ``(type smd_smd)``; empty = default
class_pairs: list[str] = field(default_factory=list)
@dataclass
class StructureRules:
"""The default ``(rule ...)`` block plus control/snap settings."""
width_rules: list[WidthRule] = field(default_factory=list)
clearance_rules: list[ClearanceRule] = field(default_factory=list)
@dataclass
class Padstack:
"""A ``(padstack ...)`` — one or more per-layer pad shapes plus flags."""
name: str
shapes: list[Shape] = field(default_factory=list)
attach_allowed: bool = True
placed_absolute: bool = False
@dataclass
class Pin:
"""A pin within an image: ``(pin <padstack> [ (rotate d) ] <name> x y)``."""
padstack_name: str
name: str
x: float
y: float
rotation: float = 0.0
@dataclass
class Image:
"""A ``(image ...)`` — a component footprint definition."""
name: str
is_front: bool = True
pins: list[Pin] = field(default_factory=list)
outlines: list[Shape] = field(default_factory=list)
keepouts: list[Area] = field(default_factory=list)
via_keepouts: list[Area] = field(default_factory=list)
place_keepouts: list[Area] = field(default_factory=list)
@dataclass
class ComponentPlace:
"""One placed instance: ``(place <refdes> x y front|back rot ...)``.
``x`` / ``y`` are ``None`` when the component is declared but not yet placed.
"""
name: str
x: float | None = None
y: float | None = None
is_front: bool = True
rotation: float = 0.0
position_fixed: bool = False
part_number: str | None = None
@dataclass
class ComponentPlacement:
"""A ``(component <libname> (place ...) ...)`` group."""
lib_name: str
places: list[ComponentPlace] = field(default_factory=list)
@dataclass
class NetPin:
"""A ``Comp-Pin`` reference inside a net's ``(pins ...)`` list."""
component: str
pin: str
@dataclass
class Net:
"""A ``(net <name> [subnet] (pins ...))`` entry."""
name: str
subnet: int = 1
pins: list[NetPin] = field(default_factory=list)
@dataclass
class NetClass:
"""A ``(class <name> net... (circuit (use_via ...)) (rule ...))`` entry."""
name: str
net_names: list[str] = field(default_factory=list)
width_rules: list[WidthRule] = field(default_factory=list)
clearance_rules: list[ClearanceRule] = field(default_factory=list)
use_via: list[str] = field(default_factory=list)
use_layer: list[str] = field(default_factory=list)
via_rule: str | None = None
@dataclass
class Keepout:
"""A structure-level keepout area with its kind."""
area: Area
kind: str = "keepout" # keepout | via_keepout | place_keepout
@dataclass
class DsnBoard:
"""The fully parsed DSN file."""
name: str = ""
parser: ParserInfo = field(default_factory=ParserInfo)
resolution: Resolution = field(default_factory=Resolution)
unit: str = "mil"
layers: list[Layer] = field(default_factory=list)
#: bounding-box boundary shape (layer ``pcb``), if present
boundary: Shape | None = None
#: outline shapes (layer ``signal``)
outlines: list[Shape] = field(default_factory=list)
via_padstack_names: list[str] = field(default_factory=list)
structure_rules: StructureRules = field(default_factory=StructureRules)
snap_angle: str = "fortyfive_degree"
via_at_smd_allowed: bool = False
keepouts: list[Keepout] = field(default_factory=list)
padstacks: list[Padstack] = field(default_factory=list)
images: list[Image] = field(default_factory=list)
placements: list[ComponentPlacement] = field(default_factory=list)
nets: list[Net] = field(default_factory=list)
net_classes: list[NetClass] = field(default_factory=list)
# Convenience lookups -----------------------------------------------------
def layer_names(self) -> list[str]:
return [layer.name for layer in self.layers]
def net(self, name: str) -> Net | None:
for n in self.nets:
if n.name == name:
return n
return None
def padstack(self, name: str) -> Padstack | None:
for p in self.padstacks:
if p.name == name:
return p
return None
+506
View File
@@ -0,0 +1,506 @@
"""Build a typed :class:`DsnBoard` from a Specctra DSN S-expression tree.
This is the Python analogue of FreeRouting's ``ScopeKeyword`` recursive-descent
readers (``Parser``, ``Resolution``, ``Structure``, ``Library``, ``Package``,
``Component``, ``Network`` ...). Each ``_read_*`` function here corresponds to a
``read_scope`` method there. Unknown scopes are ignored, matching FreeRouting's
tolerant ``skip_scope`` behaviour.
"""
from __future__ import annotations
from .model import (
ClearanceRule,
ComponentPlace,
ComponentPlacement,
DsnBoard,
Image,
Keepout,
Layer,
Net,
NetClass,
NetPin,
Padstack,
ParserInfo,
Pin,
Resolution,
WidthRule,
)
from .sexp import SExp, parse
from .shapes import Shape, read_area, read_shape
from .tokenizer import Token
__all__ = ["parse_dsn", "DsnParseError"]
class DsnParseError(ValueError):
"""Raised when the input is not a valid ``(pcb ...)`` DSN file."""
# --- small leaf helpers ------------------------------------------------------
def _first_value(node: SExp) -> str | None:
"""Text of the first leaf-token item of ``node`` (its name argument)."""
for it in node.items:
if isinstance(it, Token):
return it.text
return None
def _on_off(node: SExp, default: bool = False) -> bool:
"""Interpret an ``(... on|off)`` scope as a bool."""
val = _first_value(node)
if val is None:
return default
return val.lower() == "on"
def _split_pin_ref(ref: str) -> NetPin:
"""Split a ``Comp-Pin`` reference on the first hyphen (FreeRouting rule)."""
comp, sep, pin = ref.partition("-")
if not sep:
return NetPin(component=ref, pin="")
return NetPin(component=comp, pin=pin)
# --- scope readers -----------------------------------------------------------
def _read_parser(node: SExp) -> ParserInfo:
info = ParserInfo()
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "string_quote":
val = _first_value(it)
if val:
info.string_quote = val
elif it.head == "host_cad":
info.host_cad = _first_value(it)
elif it.head == "host_version":
info.host_version = _first_value(it)
elif it.head == "generated_by_freerouting":
info.generated_by_freerouting = True
elif it.head == "constant":
vals = [v.text for v in it.values()]
if len(vals) >= 2:
info.constants.append((vals[0], vals[1]))
return info
def _read_resolution(node: SExp) -> Resolution:
vals = node.values()
res = Resolution()
if vals:
res.unit = vals[0].text
if len(vals) > 1:
as_int = vals[1].as_int()
if as_int is not None:
res.value = as_int
return res
def _read_layer(node: SExp, index: int) -> Layer:
name = _first_value(node) or f"layer_{index}"
is_signal = True
net_names: list[str] = []
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "type":
t = _first_value(it)
if t is not None and t.lower() == "power":
is_signal = False
elif it.head == "use_net":
net_names.extend(v.text for v in it.values())
return Layer(name=name, index=index, is_signal=is_signal, net_names=net_names)
def _collect_via_names(node: SExp) -> list[str]:
"""Collect padstack names from a ``(via ...)`` scope, incl. ``(spare ...)``."""
names: list[str] = []
spare: list[str] = []
for it in node.items:
if isinstance(it, Token) and it.is_value:
names.append(it.text)
elif isinstance(it, SExp) and it.head == "spare":
spare.extend(_collect_via_names(it))
names.extend(spare)
return names
def _read_rules_into(
node: SExp, width_out: list[WidthRule], clear_out: list[ClearanceRule]
) -> None:
"""Read a ``(rule (width v) (clearance v (type ...)))`` block."""
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "width":
vals = it.values()
if vals and vals[0].as_float() is not None:
width_out.append(WidthRule(value=vals[0].as_float()))
elif it.head in ("clearance", "clear"):
vals = it.values()
if not vals or vals[0].as_float() is None:
continue
value = vals[0].as_float()
class_pairs: list[str] = []
type_scope = it.child("type")
if type_scope is not None:
class_pairs = [v.text for v in type_scope.values()]
clear_out.append(ClearanceRule(value=value, class_pairs=class_pairs))
def _read_structure(node: SExp, board: DsnBoard) -> None:
layer_index = 0
for it in node.items:
if not isinstance(it, SExp):
continue
head = it.head
if head == "layer":
board.layers.append(_read_layer(it, layer_index))
layer_index += 1
elif head == "boundary":
_read_boundary(it, board)
elif head == "via":
board.via_padstack_names = _collect_via_names(it)
elif head == "rule":
_read_rules_into(
it, board.structure_rules.width_rules, board.structure_rules.clearance_rules
)
elif head == "keepout":
area = read_area(it)
if area is not None:
board.keepouts.append(Keepout(area=area, kind="keepout"))
elif head == "via_keepout":
area = read_area(it)
if area is not None:
board.keepouts.append(Keepout(area=area, kind="via_keepout"))
elif head == "place_keepout":
area = read_area(it)
if area is not None:
board.keepouts.append(Keepout(area=area, kind="place_keepout"))
elif head == "control":
smd = it.child("via_at_smd")
if smd is not None:
board.via_at_smd_allowed = _on_off(smd)
elif head == "snap_angle":
val = _first_value(it)
if val:
board.snap_angle = val
def _read_boundary(node: SExp, board: DsnBoard) -> None:
for it in node.items:
if not isinstance(it, SExp):
continue
shape = read_shape(it)
if shape is None:
continue
if shape.layer == "pcb":
board.boundary = shape
else: # signal (or a named layer treated as outline)
board.outlines.append(shape)
def _read_padstack(node: SExp) -> tuple[str, list[Shape], bool, bool]:
name = _first_value(node) or ""
shapes: list[Shape] = []
attach_allowed = True
placed_absolute = False
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "shape":
for sub in it.items:
if isinstance(sub, SExp):
shape = read_shape(sub)
if shape is not None:
shapes.append(shape)
elif it.head == "attach":
attach_allowed = _on_off(it, default=True)
elif it.head == "absolute":
placed_absolute = _on_off(it, default=False)
return name, shapes, attach_allowed, placed_absolute
def _read_pin(node: SExp) -> Pin | None:
"""Read ``(pin <padstack> [ (rotate d) ] <name> x y [ (rotate d) ])``."""
padstack_name: str | None = None
pin_name: str | None = None
coords: list[float] = []
rotation = 0.0
for it in node.items:
if isinstance(it, SExp):
if it.head == "rotate":
rv = it.values()
if rv and rv[0].as_float() is not None:
rotation = rv[0].as_float()
continue
# leaf token: padstack, pin name, then two coordinates
if padstack_name is None:
padstack_name = it.text
elif it.is_number and len(coords) < 2 and pin_name is not None:
coords.append(float(it.text))
elif pin_name is None:
pin_name = it.text
elif len(coords) < 2 and it.is_number:
coords.append(float(it.text))
if padstack_name is None or pin_name is None or len(coords) < 2:
return None
return Pin(
padstack_name=padstack_name, name=pin_name, x=coords[0], y=coords[1], rotation=rotation
)
def _read_image(node: SExp) -> Image | None:
name = _first_value(node)
if name is None:
return None
image = Image(name=name)
for it in node.items:
if not isinstance(it, SExp):
continue
head = it.head
if head == "pin":
pin = _read_pin(it)
if pin is not None:
image.pins.append(pin)
elif head == "side":
side = _first_value(it)
image.is_front = side is None or side.lower() != "back"
elif head == "outline":
for sub in it.items:
if isinstance(sub, SExp):
shape = read_shape(sub)
if shape is not None:
image.outlines.append(shape)
elif head == "keepout":
area = read_area(it)
if area is not None:
image.keepouts.append(area)
elif head == "via_keepout":
area = read_area(it)
if area is not None:
image.via_keepouts.append(area)
elif head == "place_keepout":
area = read_area(it)
if area is not None:
image.place_keepouts.append(area)
return image
def _read_library(node: SExp, board: DsnBoard) -> None:
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "padstack":
name, shapes, attach, absolute = _read_padstack(it)
board.padstacks.append(
Padstack(name=name, shapes=shapes, attach_allowed=attach, placed_absolute=absolute)
)
elif it.head == "image":
image = _read_image(it)
if image is not None:
board.images.append(image)
def _read_place(node: SExp) -> ComponentPlace | None:
"""Read ``(place <refdes> x y front|back rot [ (lock_type position) ] ...)``."""
name: str | None = None
coords: list[float] = []
is_front = True
rotation = 0.0
position_fixed = False
part_number: str | None = None
seen_side = False
for it in node.items:
if isinstance(it, SExp):
if it.head == "lock_type":
position_fixed = any(
isinstance(v, Token) and v.text == "position" for v in it.items
)
elif it.head in ("PN", "pn"):
part_number = _first_value(it)
continue
if name is None:
name = it.text
elif not seen_side and it.is_number and len(coords) < 2:
coords.append(float(it.text))
elif it.text.lower() in ("front", "back"):
is_front = it.text.lower() != "back"
seen_side = True
elif seen_side and it.is_number:
rotation = float(it.text)
if name is None:
return None
x = coords[0] if len(coords) >= 2 else None
y = coords[1] if len(coords) >= 2 else None
return ComponentPlace(
name=name,
x=x,
y=y,
is_front=is_front,
rotation=rotation,
position_fixed=position_fixed,
part_number=part_number,
)
def _read_placement(node: SExp, board: DsnBoard) -> None:
for it in node.items:
if not isinstance(it, SExp) or it.head != "component":
continue
lib_name = _first_value(it) or ""
placement = ComponentPlacement(lib_name=lib_name)
for sub in it.items:
if isinstance(sub, SExp) and sub.head == "place":
place = _read_place(sub)
if place is not None:
placement.places.append(place)
board.placements.append(placement)
def _read_pins_list(node: SExp) -> list[NetPin]:
"""Read a ``(pins Comp-Pin ...)`` list into :class:`NetPin` entries.
The common case is a single atom per reference (``U1-1``) which splits on
its first hyphen. When component or pin names are quoted (Eagle exports with
``space_in_quoted_tokens``), the reference arrives as several tokens around
a standalone ``-`` separator (``"J3" - "GND"``); those are stitched back
together here.
"""
parts = [v.text for v in node.values() if v.text]
refs: list[NetPin] = []
i = 0
while i < len(parts):
p = parts[i]
if p == "-":
i += 1
continue
if p.endswith("-") and i + 1 < len(parts): # unquoted comp + quoted pin
refs.append(NetPin(component=p[:-1], pin=parts[i + 1]))
i += 2
continue
if "-" in p: # single-token Comp-Pin (the common case)
refs.append(_split_pin_ref(p))
i += 1
continue
if i + 1 < len(parts) and parts[i + 1] == "-" and i + 2 < len(parts):
refs.append(NetPin(component=p, pin=parts[i + 2])) # both quoted
i += 3
continue
refs.append(NetPin(component=p, pin="")) # name without a pin
i += 1
return refs
def _read_net(node: SExp) -> Net | None:
name = _first_value(node)
if name is None:
return None
net = Net(name=name)
for it in node.items:
if isinstance(it, Token):
as_int = it.as_int()
if as_int is not None and it.text == name:
continue # (defensive) name repeated
if as_int is not None:
net.subnet = as_int
continue
if it.head in ("pins", "order", "fromto"):
net.pins.extend(_read_pins_list(it))
return net
def _read_net_class(node: SExp) -> NetClass | None:
name = _first_value(node)
if name is None:
return None
net_class = NetClass(name=name)
# Leading bareword/string values after the name are member net names.
first_seen = False
for it in node.items:
if isinstance(it, Token):
if not first_seen:
first_seen = True # skip the class name itself
continue
net_class.net_names.append(it.text)
continue
head = it.head
if head == "rule":
_read_rules_into(it, net_class.width_rules, net_class.clearance_rules)
elif head == "circuit":
use_via = it.child("use_via")
if use_via is not None:
net_class.use_via.extend(v.text for v in use_via.values())
use_layer = it.child("use_layer")
if use_layer is not None:
net_class.use_layer.extend(v.text for v in use_layer.values())
elif head == "use_via":
net_class.use_via.extend(v.text for v in it.values())
elif head == "use_layer":
net_class.use_layer.extend(v.text for v in it.values())
elif head == "via_rule":
net_class.via_rule = _first_value(it)
return net_class
def _read_network(node: SExp, board: DsnBoard) -> None:
for it in node.items:
if not isinstance(it, SExp):
continue
if it.head == "net":
net = _read_net(it)
if net is not None:
board.nets.append(net)
elif it.head == "class":
net_class = _read_net_class(it)
if net_class is not None:
board.net_classes.append(net_class)
# --- top level ---------------------------------------------------------------
def parse_dsn(text: str) -> DsnBoard:
"""Parse DSN ``text`` into a :class:`DsnBoard`.
Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``.
"""
top = parse(text)
if top.head != "pcb":
raise DsnParseError(
f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'"
)
board = DsnBoard(name=_first_value(top) or "")
for it in top.items:
if not isinstance(it, SExp):
continue
head = it.head
if head == "parser":
board.parser = _read_parser(it)
elif head == "resolution":
board.resolution = _read_resolution(it)
board.unit = board.resolution.unit
elif head == "unit":
val = _first_value(it)
if val:
board.unit = val
elif head == "structure":
_read_structure(it, board)
elif head == "library":
_read_library(it, board)
elif head == "placement":
_read_placement(it, board)
elif head == "network":
_read_network(it, board)
# `wiring` and unknown scopes are intentionally ignored for now.
return board
+101
View File
@@ -0,0 +1,101 @@
"""Parse a flat token stream into a nested S-expression tree.
A DSN file is one top-level list ``(pcb ...)``. Each list's first element is
normally a keyword atom (its *head*). This module builds a generic tree; the
semantic interpretation (layers, nets, shapes ...) happens in :mod:`reader`,
which walks the tree scope by scope the way FreeRouting's ``ScopeKeyword``
recursive-descent readers walk the token stream.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .tokenizer import DsnSyntaxError, Token, TokenKind, tokenize
__all__ = ["SExp", "parse", "parse_tokens"]
@dataclass
class SExp:
"""A parenthesised list.
``head`` is the text of the first token when it is an atom (e.g. ``pcb``,
``layer``, ``net``), else ``None``. ``items`` holds the remaining elements,
each either a :class:`~freeroute.dsn.tokenizer.Token` (a leaf atom/string)
or a nested :class:`SExp`.
"""
head: str | None
items: list[Token | SExp] = field(default_factory=list)
line: int = 0
def children(self, head: str) -> list[SExp]:
"""Return all direct sub-lists whose head equals ``head``."""
return [it for it in self.items if isinstance(it, SExp) and it.head == head]
def child(self, head: str) -> SExp | None:
"""Return the first direct sub-list with the given head, or ``None``."""
for it in self.items:
if isinstance(it, SExp) and it.head == head:
return it
return None
def values(self) -> list[Token]:
"""Return the leaf-token items (skipping nested lists)."""
return [it for it in self.items if isinstance(it, Token)]
def parse(text: str, quote_chars: str = "\"'") -> SExp:
"""Tokenize and parse ``text``, returning the single top-level S-expression."""
return parse_tokens(tokenize(text, quote_chars))
def parse_tokens(tokens: list[Token]) -> SExp:
"""Parse a token list into one top-level :class:`SExp`.
Raises :class:`DsnSyntaxError` on unbalanced brackets or trailing junk.
"""
pos = 0
n = len(tokens)
def parse_list() -> SExp:
nonlocal pos
open_tok = tokens[pos] # the '('
pos += 1
head: str | None = None
items: list[Token | SExp] = []
# A leading atom is the scope head. Heads are keywords, which the DSN
# grammar matches case-insensitively (JFlex `%ignorecase`), so `(PCB`
# and `(pcb` are the same scope — normalize to lowercase for dispatch.
if pos < n and tokens[pos].kind is TokenKind.ATOM:
head = tokens[pos].text.lower()
pos += 1
while pos < n and tokens[pos].kind is not TokenKind.RPAREN:
tok = tokens[pos]
if tok.kind is TokenKind.LPAREN:
items.append(parse_list())
else:
items.append(tok)
pos += 1
if pos >= n:
raise DsnSyntaxError(f"unbalanced '(' opened at line {open_tok.line}: missing ')'")
pos += 1 # consume ')'
return SExp(head=head, items=items, line=open_tok.line)
# Skip to the first '('.
while pos < n and tokens[pos].kind is not TokenKind.LPAREN:
pos += 1
if pos >= n:
raise DsnSyntaxError("no S-expression found (expected a top-level '(')")
top = parse_list()
# Allow trailing whitespace-only tokens (there are none — tokenizer drops
# whitespace) but reject any further real tokens.
if pos != n:
extra = tokens[pos]
raise DsnSyntaxError(
f"unexpected token '{extra.text}' after top-level list at line {extra.line}"
)
return top
+215
View File
@@ -0,0 +1,215 @@
"""Specctra DSN shape primitives and their parsing.
Mirrors FreeRouting's ``io/specctra/parser/Shape.java`` grammar:
* ``(rect <layer> x1 y1 x2 y2)``
* ``(circle <layer> diameter [center_x center_y])``
* ``(polygon <layer> aperture_width x1 y1 x2 y2 ...)`` — a closed polygon
* ``(path <layer> width x1 y1 x2 y2 ...)`` — an open path / trace
An *area* (``Shape.read_area_scope``) is an optional name, a border shape, zero
or more ``(window <shape>)`` holes, and an optional ``(clearance_class name)``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .sexp import SExp
from .tokenizer import Token, TokenKind
__all__ = [
"Shape",
"Rectangle",
"Circle",
"Polygon",
"Path",
"Area",
"read_shape",
"read_area",
]
@dataclass
class Shape:
"""Base class for a DSN shape. ``layer`` is the layer name (or ``pcb`` /
``signal`` for the special layer-spanning pseudo-layers)."""
layer: str
@dataclass
class Rectangle(Shape):
"""Axis-parallel rectangle. ``coords`` is ``[x1, y1, x2, y2]``."""
coords: list[float] = field(default_factory=list)
@dataclass
class Circle(Shape):
"""Circle of the given ``diameter`` centred at ``(center_x, center_y)``."""
diameter: float = 0.0
center_x: float = 0.0
center_y: float = 0.0
@dataclass
class Polygon(Shape):
"""Closed polygon. ``aperture_width`` is the boundary aperture (usually 0).
``coords`` is a flat ``[x1, y1, x2, y2, ...]`` list."""
aperture_width: float = 0.0
coords: list[float] = field(default_factory=list)
@dataclass
class Path(Shape):
"""Open poly-path of the given ``width``. ``coords`` is flat
``[x1, y1, x2, y2, ...]`` — this is how traces (wires) are described."""
width: float = 0.0
coords: list[float] = field(default_factory=list)
@dataclass
class Area:
"""A border shape plus optional holes — the result of an area scope.
``name`` and ``clearance_class`` are optional (``None`` when absent), exactly
like FreeRouting's ``ReadAreaScopeResult``.
"""
border: Shape | None
holes: list[Shape] = field(default_factory=list)
name: str | None = None
clearance_class: str | None = None
@property
def layer(self) -> str | None:
return self.border.layer if self.border is not None else None
def _numbers(items: list[Token | SExp]) -> list[float]:
"""Collect the numeric leaf tokens from a list, skipping nested scopes.
FreeRouting's polygon/path readers skip unknown ``( ... )`` sub-scopes that
appear amid the coordinate list, so we drop nested ``SExp`` items here too.
"""
out: list[float] = []
for it in items:
if isinstance(it, Token) and it.is_number:
out.append(float(it.text))
return out
def _layer_name(tok: Token | SExp | None) -> str:
if isinstance(tok, Token):
return tok.text
return "signal"
def read_shape(node: SExp) -> Shape | None:
"""Build a :class:`Shape` from a shape scope such as ``(rect ...)``.
Returns ``None`` for an unrecognised head, matching FreeRouting's tolerant
``Shape.read_scope`` (which skips non-shape scopes).
"""
head = node.head
items = node.items
if not items:
return None
layer = _layer_name(items[0])
rest = items[1:]
if head in ("rect", "rectangle"):
nums = _numbers(rest)
if len(nums) < 4:
return None
return Rectangle(layer=layer, coords=nums[:4])
if head in ("circle", "circ"):
nums = _numbers(rest)
if not nums:
return None
diameter = nums[0]
cx = nums[1] if len(nums) > 1 else 0.0
cy = nums[2] if len(nums) > 2 else 0.0
return Circle(layer=layer, diameter=diameter, center_x=cx, center_y=cy)
if head in ("polygon", "poly"):
nums = _numbers(rest)
if not nums:
return None
return Polygon(layer=layer, aperture_width=nums[0], coords=nums[1:])
if head in ("path", "polygon_path", "polyline_path"):
nums = _numbers(rest)
if len(nums) < 5: # width + at least two points
return None
return Path(layer=layer, width=nums[0], coords=nums[1:])
return None
def read_area(node: SExp) -> Area | None:
"""Build an :class:`Area` from a keepout/plane-style scope.
``node`` is e.g. ``(keepout NAME (rect ...) (window (circle ...))
(clearance_class cls))``. The head has already been consumed by the caller;
``node.items`` is the body. An optional leading string is the area name.
"""
items = node.items
idx = 0
name: str | None = None
if items and isinstance(items[0], Token) and items[0].kind is TokenKind.STRING:
name = items[0].text or None
idx = 1
elif (
items
and isinstance(items[0], Token)
and items[0].kind is TokenKind.ATOM
and not items[0].is_number
):
# An unquoted bareword name (FreeRouting accepts a String here too).
name = items[0].text or None
idx = 1
border: Shape | None = None
holes: list[Shape] = []
clearance_class: str | None = None
for it in items[idx:]:
if not isinstance(it, SExp):
continue
if it.head in (
"rect",
"rectangle",
"circle",
"circ",
"polygon",
"poly",
"path",
"polygon_path",
"polyline_path",
):
shape = read_shape(it)
if border is None:
border = shape
else: # a bare extra shape without a window wrapper
if shape is not None:
holes.append(shape)
elif it.head == "window":
for sub in it.items:
if isinstance(sub, SExp):
hole = read_shape(sub)
if hole is not None:
holes.append(hole)
elif it.head == "clearance_class":
vals = it.values()
if vals:
clearance_class = vals[0].text
if border is None:
return None
return Area(border=border, holes=holes, name=name, clearance_class=clearance_class)
+191
View File
@@ -0,0 +1,191 @@
"""Tokenizer for the Specctra DSN/SES S-expression format.
Mirrors the lexical rules in FreeRouting's ``SpecctraFileDescription.flex``
(the human-readable source of the JFlex-generated
``SpecctraDsnStreamReader``). We deliberately do not reproduce the generated
DFA — the flex rules are what define the language:
* whitespace is ``\\r \\n \\f \\t`` and space,
* ``#`` starts an end-of-line comment, ``/* ... */`` a block comment,
* ``"`` and ``'`` quote strings; there is no escaping — a backslash inside a
quoted string is a literal backslash and the matching quote ends it,
* ``(`` and ``)`` are their own tokens,
* every other maximal run of characters is an atom.
FreeRouting's lexer flips between lexical states (``NAME``, ``LAYER_NAME`` ...)
so that the *next* atom is read as a string even when it looks like a number
(a net literally named ``0``). We reconcile that stateful behaviour with a
stateless tokenizer by keeping both the raw text and a best-effort numeric
value on every unquoted atom (see :meth:`Token.as_int` / :meth:`Token.as_float`)
and letting the higher-level reader choose which to use.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum, auto
__all__ = ["Token", "TokenKind", "tokenize", "DsnSyntaxError"]
_WHITESPACE = " \t\r\n\f"
_QUOTES = "\"'"
_BRACKETS = "()"
class DsnSyntaxError(ValueError):
"""Raised when the input cannot be tokenized (e.g. an unterminated string)."""
class TokenKind(Enum):
LPAREN = auto()
RPAREN = auto()
ATOM = auto() # unquoted; may be a number or a bareword
STRING = auto() # quoted; always a string
@dataclass(frozen=True)
class Token:
"""A single lexical token.
``text`` is the literal characters (without surrounding quotes for a
:attr:`TokenKind.STRING`). ``line`` is 1-based for error messages.
"""
kind: TokenKind
text: str
line: int
@property
def is_value(self) -> bool:
"""True for atoms and strings — anything that is not a bracket."""
return self.kind in (TokenKind.ATOM, TokenKind.STRING)
def as_int(self) -> int | None:
"""Return the token as an int, or ``None`` if it is not an integer atom."""
if self.kind is not TokenKind.ATOM:
return None
try:
return int(self.text)
except ValueError:
return None
def as_float(self) -> float | None:
"""Return the token as a float, or ``None`` if it is not a numeric atom.
Accepts integer atoms too, matching FreeRouting's ``read_float_scope``
which takes either an ``Integer`` or a ``Double``.
"""
if self.kind is not TokenKind.ATOM:
return None
try:
return float(self.text)
except ValueError:
return None
@property
def is_number(self) -> bool:
return self.as_float() is not None
def tokenize(text: str, quote_chars: str = _QUOTES) -> list[Token]:
"""Tokenize DSN/SES ``text`` into a flat list of :class:`Token`.
``quote_chars`` is the set of characters that open/close a quoted string.
It defaults to both ``"`` and ``'``; a reader that has seen a
``(string_quote ...)`` directive can retokenize with just that character,
but in practice KiCad always uses ``"`` and both are safe defaults.
"""
tokens: list[Token] = []
i = 0
n = len(text)
line = 1
# Mirrors FreeRouting's IGNORE_QUOTE lexical state: after the
# `string_quote` keyword the following value is read with quote characters
# treated as ordinary atom characters, so `(string_quote ")` yields the
# atom `"` rather than opening an unterminated string.
ignore_quote_next = False
while i < n:
ch = text[i]
if ch == "\n":
line += 1
i += 1
continue
if ch in _WHITESPACE:
i += 1
continue
# Comments -----------------------------------------------------------
# `#` starts an end-of-line comment only when followed by whitespace
# (the conventional `# text` form). A `#` glued to a name — e.g. the
# net `#WLTXD` or pin `#22` — is a name character: FreeRouting reads
# those in its NAME lexical state where `#` is not a comment, and its
# `#` is also a valid identifier character (SpecCharASCII).
if ch == "#" and (i + 1 >= n or text[i + 1] in _WHITESPACE):
eol = text.find("\n", i)
i = n if eol == -1 else eol
continue
if ch == "/" and i + 1 < n and text[i + 1] == "*": # block comment
end = text.find("*/", i + 2)
if end == -1:
raise DsnSyntaxError(f"unterminated /* */ comment at line {line}")
line += text.count("\n", i, end)
i = end + 2
continue
# Brackets -----------------------------------------------------------
if ch == "(":
tokens.append(Token(TokenKind.LPAREN, "(", line))
i += 1
continue
if ch == ")":
tokens.append(Token(TokenKind.RPAREN, ")", line))
i += 1
continue
# Quoted string ------------------------------------------------------
if ch in quote_chars and not ignore_quote_next:
start_line = line
j = i + 1
buf: list[str] = []
while j < n and text[j] != ch:
if text[j] == "\n":
line += 1
buf.append(text[j])
j += 1
if j >= n:
raise DsnSyntaxError(f"unterminated string starting at line {start_line}")
tokens.append(Token(TokenKind.STRING, "".join(buf), start_line))
i = j + 1
continue
# Bareword atom ------------------------------------------------------
# A bareword runs to the next whitespace, bracket, or quote character.
# Stopping at quotes matters for `space_in_quoted_tokens` pin refs such
# as `"comp"-"pin with spaces"`: the second quoted segment must be read
# as its own string, otherwise a `#` inside it would start a spurious
# end-of-line comment and swallow the closing brackets. In
# ignore-quote mode (right after `string_quote`) quotes are ordinary
# characters, so `(string_quote ")` yields the atom `"`.
j = i
if ignore_quote_next:
while j < n and text[j] not in _WHITESPACE and text[j] not in _BRACKETS:
j += 1
else:
while (
j < n
and text[j] not in _WHITESPACE
and text[j] not in _BRACKETS
and text[j] not in quote_chars
):
j += 1
atom = text[i:j]
tokens.append(Token(TokenKind.ATOM, atom, line))
i = j
if ignore_quote_next:
ignore_quote_next = False
elif atom.lower() == "string_quote":
ignore_quote_next = True
return tokens