cad: Add KiCad symbol/footprint parser

This commit is contained in:
jaseg 2023-04-15 17:09:20 +02:00
parent b43e4e2eec
commit 2400ff8e5f
12 changed files with 1703 additions and 0 deletions

View file

@ -0,0 +1,122 @@
from .sexp import *
from .sexp_mapper import *
import time
from dataclasses import field
import math
import uuid
@sexp_type('color')
class Color:
r: int = None
g: int = None
b: int = None
a: int = None
@sexp_type('stroke')
class Stroke:
width: Named(float) = 0.254
type: Named(AtomChoice(Atom.dash, Atom.dot, Atom.dash_dot_dot, Atom.dash_dot, Atom.default, Atom.solid)) = Atom.default
color: Color = None
@property
def width_mil(self):
return mm_to_mil(self.width)
@width_mil.setter
def width_mil(self, value):
self.width = mil_to_mm(value)
@sexp_type('xy')
class XYCoord:
x: float = 0
y: float = 0
def isclose(self, other, tol=1e-6):
return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol)
@sexp_type('pts')
class PointList:
xy : List(XYCoord) = field(default_factory=list)
@sexp_type('xyz')
class XYZCoord:
x: float = 0
y: float = 0
z: float = 0
@sexp_type('at')
class AtPos(XYCoord):
x: float = 0 # in millimeter
y: float = 0 # in millimeter
rotation: int = 0 # in degrees, can only be 0, 90, 180 or 270.
unlocked: Flag() = False
def __before_sexp__(self):
self.rotation = int(round(self.rotation % 360))
@property
def rotation_rad(self):
return math.radians(self.rotation)
@rotation_rad.setter
def rotation_rad(self, value):
self.rotation = math.degrees(value)
@sexp_type('font')
class FontSpec:
face: Named(str) = None
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
thickness: Named(float) = None
bold: Flag() = False
italic: Flag() = False
line_spacing: Named(float) = None
@sexp_type('justify')
class Justify:
h: AtomChoice(Atom.left, Atom.right) = None
v: AtomChoice(Atom.top, Atom.bottom) = None
mirror: Flag() = False
@sexp_type('effects')
class TextEffect:
font: FontSpec = field(default_factory=FontSpec)
justify: OmitDefault(Justify) = field(default_factory=Justify)
hide: Flag() = False
@sexp_type('tstamp')
class Timestamp:
value: str = field(default_factory=uuid.uuid4)
def __after_parse__(self, parent):
self.value = str(self.value)
def before_sexp(self):
self.value = Atom(str(self.value))
def bump(self):
self.value = uuid.uuid4()
@sexp_type('tedit')
class EditTime:
value: str = field(default_factory=time.time)
def __after_parse__(self, parent):
self.value = int(str(self.value), 16)
def __before_sexp__(self):
self.value = Atom(f'{int(self.value):08X}')
def bump(self):
self.value = time.time()

View file

View file

@ -0,0 +1,316 @@
"""
Library for handling KiCad's footprint files (`*.kicad_mod`).
"""
import copy
import enum
import datetime
import math
import time
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
from .sexp import *
from .base_types import *
from .primitives import *
from . import graphical_primitives as gr
@sexp_type('property')
class Property:
key: str = ''
value: str = ''
@sexp_type('attr')
class Attribute:
type: AtomChoice(Atom.smd, Atom.through_hole) = None
board_only: Flag() = False
exclude_from_pos_files: Flag() = False
exclude_from_bom: Flag() = False
@sexp_type('fp_text')
class Text:
type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user
text: str = ""
at: AtPos = field(default_factory=AtPos)
unlocked: Flag() = False
layer: Named(str) = None
hide: Flag() = False
effects: TextEffect = field(default_factory=TextEffect)
tstamp: Timestamp = None
@sexp_type('fp_text_box')
class TextBox:
locked: Flag() = False
text: str = None
start: Rename(XYCoord) = None
end: Named(XYCoord) = None
pts: PointList = None
angle: Named(float) = 0.0
layer: Named(str) = None
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
@sexp_type('fp_line')
class Line:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = None
locked: Flag() = False
tstamp: Timestamp = None
@sexp_type('fp_rect')
class Rectangle:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
locked: Flag() = False
tstamp: Timestamp = None
@sexp_type('fp_circle')
class Circle:
center: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
locked: Flag() = False
tstamp: Timestamp = None
@sexp_type('fp_arc')
class Arc:
start: Rename(XYCoord) = None
mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = None
locked: Flag() = False
tstamp: Timestamp = None
@sexp_type('fp_poly')
class Polygon:
pts: PointList = field(default_factory=PointList)
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = None
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
locked: Flag() = False
tstamp: Timestamp = None
@sexp_type('fp_curve')
class Curve:
pts: PointList = field(default_factory=PointList)
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = None
locked: Flag() = False
tstamp: Timestamp = None
@sexp_type('format')
class DimensionFormat:
prefix: Named(str) = None
suffix: Named(str) = None
units: Named(int) = 3
units_format: Named(int) = 0
precision: Named(int) = 3
override_value: Named(str) = None
suppress_zeros: Flag() = False
@sexp_type('style')
class DimensionStyle:
thickness: Named(float) = None
arrow_length: Named(float) = None
text_position_mode: Named(int) = 0
extension_height: Named(float) = None
text_frame: Named(int) = 0
extension_offset: Named(str) = None
keep_text_aligned: Flag() = False
@sexp_type('dimension')
class Dimension:
locked: Flag() = False
type: AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial) = None
layer: Named(str) = None
tstamp: Timestamp = None
pts: PointList = field(default_factory=PointList)
height: Named(float) = None
orientation: Named(int) = 0
leader_length: Named(float) = None
gr_text: Named(Text) = None
format: DimensionFormat = field(default_factory=DimensionFormat)
style: DimensionStyle = field(default_factory=DimensionStyle)
@sexp_type('drill')
class Drill:
oval: Flag() = False
diameter: float = 0
width: float = None
offset: Rename(XYCoord) = None
@sexp_type('net')
class NetDef:
number: int = None
name: str = None
@sexp_type('options')
class CustomPadOptions:
clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline
anchor: Named(AtomChoice(Atom.rect, Atom.circle)) = Atom.rect
@sexp_type('primitives')
class CustomPadPrimitives:
annotation_bboxes: List(gr.AnnotationBBox) = field(default_factory=list)
lines: List(gr.Line) = field(default_factory=list)
rectangles: List(gr.Rectangle) = field(default_factory=list)
circles: List(gr.Circle) = field(default_factory=list)
arcs: List(gr.Arc) = field(default_factory=list)
polygons: List(gr.Polygon) = field(default_factory=list)
curves: List(gr.Curve) = field(default_factory=list)
width: Named(float) = None
fill: Named(YesNoAtom()) = True
@sexp_type('chamfer')
class Chamfer:
top_left: Flag() = False
top_right: Flag() = False
bottom_left: Flag() = False
bottom_right: Flag() = False
@sexp_type('pad')
class Pad:
number: str = None
type: AtomChoice(Atom.thru_hole, Atom.smd, Atom.connect, Atom.np_thru_hole) = None
shape: AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom) = None
at: AtPos = field(default_factory=AtPos)
locked: Wrap(Flag()) = False
size: Rename(XYCoord) = field(default_factory=XYCoord)
drill: Drill = None
layers: Named(Array(str)) = field(default_factory=list)
properties: List(Property) = field(default_factory=list)
remove_unused_layers: Wrap(Flag()) = False
keep_end_layers: Wrap(Flag()) = False
rect_delta: Rename(XYCoord) = None
roundrect_rratio: Named(float) = None
thermal_bridge_angle: Named(int) = 45
chamfer_ratio: Named(float) = None
chamfer: Chamfer = None
net: NetDef = None
tstamp: Timestamp = None
pin_function: Named(str) = None
pintype: Named(str) = None
die_length: Named(float) = None
solder_mask_margin: Named(float) = None
solder_paste_margin: Named(float) = None
solder_paste_margin_ratio: Named(float) = None
clearance: Named(float) = None
zone_connect: Named(int) = None
thermal_width: Named(float) = None
thermal_gap: Named(float) = None
options: OmitDefault(CustomPadOptions) = None
primitives: OmitDefault(CustomPadPrimitives) = None
@sexp_type('group')
class Group:
name: str = ""
id: Named(str) = ""
members: Named(List(str)) = field(default_factory=list)
@sexp_type('model')
class Model:
name: str = ''
at: Named(XYZCoord) = field(default_factory=XYZCoord)
offset: Named(XYZCoord) = field(default_factory=XYZCoord)
scale: Named(XYZCoord) = field(default_factory=XYZCoord)
rotate: Named(XYZCoord) = field(default_factory=XYZCoord)
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018]
@sexp_type('footprint')
class Footprint:
name: str = None
_version: Named(int, name='version') = 20210108
generator: Named(Atom) = Atom.kicad_library_utils
locked: Flag() = False
placed: Flag() = False
layer: Named(str) = 'F.Cu'
tedit: EditTime = field(default_factory=EditTime)
tstamp: Timestamp = None
at: AtPos = field(default_factory=AtPos)
descr: Named(str) = None
tags: Named(str) = None
properties: List(Property) = field(default_factory=list)
path: Named(str) = None
autoplace_cost90: Named(float) = None
autoplace_cost180: Named(float) = None
solder_mask_margin: Named(float) = None
solder_paste_margin: Named(float) = None
solder_paste_ratio: Named(float) = None
clearance: Named(float) = None
zone_connect: Named(int) = None
thermal_width: Named(float) = None
thermal_gap: Named(float) = None
attributes: List(Attribute) = field(default_factory=list)
private_layers: Named(str) = None
net_tie_pad_groups: Named(str) = None
texts: List(Text) = field(default_factory=list)
text_boxes: List(TextBox) = field(default_factory=list)
lines: List(Line) = field(default_factory=list)
rectangles: List(Rectangle) = field(default_factory=list)
circles: List(Circle) = field(default_factory=list)
arcs: List(Arc) = field(default_factory=list)
polygons: List(Polygon) = field(default_factory=list)
curves: List(Curve) = field(default_factory=list)
dimensions: List(Dimension) = field(default_factory=list)
pads: List(Pad) = field(default_factory=list)
zones: List(Zone) = field(default_factory=list)
groups: List(Group) = field(default_factory=list)
models: List(Model) = field(default_factory=list)
_ : SEXP_END = None
original_filename: str = None
@property
def version(self):
return self._version
@version.setter
def version(self, value):
if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
@classmethod
def open(cls, filename: str) -> 'Library':
with open(filename) as f:
return cls.parse(f.read())
def write(self, filename=None) -> None:
with open(filename or self.original_filename, 'w') as f:
f.write(build_sexp(sexp(self)))

View file

@ -0,0 +1,111 @@
from .sexp import *
from .base_types import *
from .primitives import *
@sexp_type('layer')
class TextLayer:
layer: str = ''
knockout: Flag() = False
@sexp_type('gr_text')
class Text:
text: str = ''
at: AtPos = field(default_factory=AtPos)
layer: TextLayer = field(default_factory=TextLayer)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('gr_text_box')
class TextBox:
locked: Flag() = False
text: str = ''
start: Named(XYCoord) = None
end: Named(XYCoord) = None
pts: PointList = field(default_factory=PointList)
angle: OmitDefault(Named(float)) = 0.0
layer: Named(str) = ""
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
@sexp_type('gr_line')
class Line:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
angle: Named(float) = None
layer: Named(str) = None
width: Named(float) = None
tstamp: Timestamp = None
@sexp_type('fill')
class FillMode:
# Needed for compatibility with weird files
fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False
@classmethod
def __map__(self, obj, parent=None):
return obj[0] in (Atom.solid, Atom.yes)
@classmethod
def __sexp__(self, value):
yield [Atom.fill, Atom.solid if value else Atom.none]
@sexp_type('gr_rect')
class Rectangle:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
fill: FillMode = False
tstamp: Timestamp = None
@sexp_type('gr_circle')
class Circle:
center: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
fill: FillMode = False
tstamp: Timestamp = None
@sexp_type('gr_arc')
class Arc:
start: Rename(XYCoord) = None
mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
tstamp: Timestamp = None
@sexp_type('gr_poly')
class Polygon:
pts: PointList = field(default_factory=PointList)
layer: Named(str) = None
width: Named(float) = None
fill: FillMode= False
tstamp: Timestamp = None
@sexp_type('gr_curve')
class Curve:
pts: PointList = field(default_factory=PointList)
layer: Named(str) = None
width: Named(float) = None
tstamp: Timestamp = None
@sexp_type('gr_bbox')
class AnnotationBBox:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None

View file

@ -0,0 +1,97 @@
import enum
from .sexp import *
from .base_types import *
@sexp_type('hatch')
class Hatch:
style: AtomChoice(Atom.none, Atom.edge, Atom.full) = Atom.edge
pitch: float = 0.5
@sexp_type('connect_pads')
class PadConnection:
type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None
clearance: Named(float) = 0
@sexp_type('keepout')
class ZoneKeepout:
tracks_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='tracks') = True
vias_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='vias') = True
pads_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='pads') = True
copperpour_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='copperpour') = True
footprints_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='footprints') = True
@sexp_type('smoothing')
class ZoneSmoothing:
style: AtomChoice(Atom.chamfer, Atom.fillet) = Atom.chamfer
radius: Named(float) = None
@sexp_type('fill')
class ZoneFill:
yes: Flag() = False
mode: Flag(atom=Atom.hatched) = False
thermal_gap: Named(float) = 0.508
thermal_bridge_width: Named(float) = 0.508
smoothing: ZoneSmoothing = None
island_removal_node: Named(int) = None
islan_area_min: Named(float) = None
hatch_thickness: Named(float) = None
hatch_gap: Named(float) = None
hatch_orientation: Named(int) = None
hatch_smoothing_level: Named(int) = None
hatch_smoothing_value: Named(float) = None
hatch_border_algorithm: Named(int) = None
hatch_min_hole_area: Named(float) = None
@sexp_type('filled_polygon')
class FillPolygon:
layer: Named(str) = ""
pts: PointList = field(default_factory=PointList)
@sexp_type('fill_segments')
class FillSegment:
layer: Named(str) = ""
pts: PointList = field(default_factory=PointList)
@sexp_type('zone')
class Zone:
net: Named(int) = 0
net_name: Named(str) = ""
layer: Named(str) = None
layers: Named(Array(str)) = None
tstamp: Timestamp = None
name: Named(str) = None
hatch: Hatch = None
priority: OmitDefault(Named(int)) = 0
connect_pads: PadConnection = field(default_factory=PadConnection)
min_thickness: Named(float) = 0.254
filled_areas_thickness: Flag() = True
keepouts: List(ZoneKeepout) = field(default_factory=list)
fill: ZoneFill = field(default_factory=ZoneFill)
polygon: Named(PointList) = field(default_factory=PointList)
fill_polygons: List(FillPolygon) = field(default_factory=list)
fill_segments: List(FillSegment) = field(default_factory=list)
@sexp_type('polygon')
class RenderCachePolygon:
pts: PointList = field(default_factory=PointList)
@sexp_type('render_cache')
class RenderCache:
text: str = None
rotation: int = 0
polygons: List(RenderCachePolygon) = field(default_factory=list)

152
gerbonara/cad/kicad/sexp.py Normal file
View file

@ -0,0 +1,152 @@
import math
import re
import functools
from typing import Any, Optional
import uuid
from dataclasses import dataclass, fields, field
from copy import deepcopy
class SexpError(ValueError):
""" Low-level error parsing S-Expression format """
pass
class FormatError(ValueError):
""" Semantic error in S-Expression structure """
pass
class AtomType(type):
def __getattr__(cls, key):
return cls(key)
@functools.total_ordering
class Atom(metaclass=AtomType):
def __init__(self, obj=''):
if isinstance(obj, str):
self.value = obj
elif isinstance(obj, Atom):
self.value = obj.value
else:
raise TypeError(f'Atom argument must be str, not {type(obj)}')
def __str__(self):
return self.value
def __repr__(self):
return f'@{self.value}'
def __hash__(self):
return hash(self.value)
def __eq__(self, other):
if not isinstance(other, (Atom, str)):
return self.value == other
return self.value == str(other)
def __lt__(self, other):
if not isinstance(other, (Atom, str)):
raise TypeError(f'Cannot compare Atom and {type(other)}')
return self.value < str(other)
def __gt__(self, other):
if not isinstance(other, (Atom, str)):
raise TypeError(f'Cannot compare Atom and {type(other)}')
return self.value > str(other)
term_regex = r"""(?mx)
\s*(?:
"((?:\\\\|\\"|[^"])*)"|
(\()|
(\))|
([+-]?\d+\.\d+(?=[\s\)]))|
(\-?\d+(?=[\s\)]))|
([^0-9"\s()][^"\s)]*)
)"""
def parse_sexp(sexp: str) -> Any:
re_iter = re.finditer(term_regex, sexp)
rv = list(_parse_sexp_internal(re_iter))
for leftover in re_iter:
quoted_str, lparen, rparen, *rest = leftover.groups()
if quoted_str or lparen or any(rest):
raise SexpError(f'Leftover garbage after end of expression at position {leftover.start()}') # noqa: E501
elif rparen:
raise SexpError(f'Unbalanced closing parenthesis at position {leftover.start()}')
if len(rv) == 0:
raise SexpError('No or empty expression')
if len(rv) > 1:
print(rv[0])
print(rv[1])
raise SexpError('Missing initial opening parenthesis')
return rv[0]
def _parse_sexp_internal(re_iter) -> Any:
for match in re_iter:
quoted_str, lparen, rparen, float_num, integer_num, bare_str = match.groups()
if lparen:
yield list(_parse_sexp_internal(re_iter))
elif rparen:
break
elif bare_str is not None:
yield Atom(bare_str)
elif quoted_str is not None:
yield quoted_str.replace('\\"', '"')
elif float_num:
yield float(float_num)
elif integer_num:
yield int(integer_num)
def build_sexp(exp, indent=' ') -> str:
# Special case for multi-values
if isinstance(exp, (list, tuple)):
joined = '('
for i, elem in enumerate(exp):
if 1 <= i <= 5 and len(joined) < 120 and not isinstance(elem, (list, tuple)):
joined += ' '
elif i >= 1:
joined += '\n' + indent
joined += build_sexp(elem, indent=f'{indent} ')
return joined + ')'
if exp == '':
return '""'
if isinstance(exp, str):
exp = exp.replace('"', r'\"')
return f'"{exp}"'
if isinstance(exp, float):
# python whyyyy
val = f'{exp:.6f}'
val = val.rstrip('0')
if val[-1] == '.':
val += '0'
return val
else:
return str(exp)
if __name__ == "__main__":
sexp = """ ( ( Winson_GM-402B_5x5mm_P1.27mm data "quoted data" 123 4.5)
(data "with \\"escaped quotes\\"")
(data (123 (4.5) "(more" "data)")))"""
print("Input S-expression:")
print(sexp)
parsed = parse_sexp(sexp)
print("\nParsed to Python:", parsed)
print("\nThen back to: '%s'" % build_sexp(parsed))

View file

@ -0,0 +1,289 @@
from dataclasses import MISSING
from .sexp import *
SEXP_END = type('SEXP_END', (), {})
class AtomChoice:
def __init__(self, *choices):
self.choices = choices
def __contains__(self, value):
return value in self.choices
def __atoms__(self):
return self.choices
def __map__(self, obj, parent=None):
obj, = obj
if obj not in self:
raise TypeError(f'Invalid atom {obj} for {type(self)}, valid choices are: {", ".join(map(str, self.choices))}')
return obj
def __sexp__(self, value):
yield value
class Flag:
def __init__(self, atom=None, invert=None):
self.atom, self.invert = atom, invert
def __bind_field__(self, field):
if self.atom is None:
self.atom = Atom(field.name)
if self.invert is None:
self.invert = bool(field.default)
def __atoms__(self):
return [self.atom]
def __map__(self, obj, parent=None):
return not self.invert
def __sexp__(self, value):
if bool(value) == (not self.invert):
yield self.atom
def sexp(t, v):
if v is None:
return []
elif t in (int, float, str, Atom):
return [t(v)]
elif hasattr(t, '__sexp__'):
return list(t.__sexp__(v))
elif isinstance(t, list):
t, = t
return [sexp(t, elem) for elem in v]
else:
raise TypeError(f'Python type {t} has no defined s-expression serialization')
def map_sexp(t, v, parent=None):
if t is not Atom and hasattr(t, '__map__'):
return t.__map__(v, parent=parent)
elif t in (int, float, str, Atom):
v, = v
if not isinstance(v, t):
types = set({type(v), t})
if types == {int, float} or types == {str, Atom}:
v = t(v)
else:
raise TypeError(f'Cannot map s-expression value {v} of type {type(v)} to Python type {t}')
return v
elif isinstance(t, list):
t, = t
return [map_sexp(t, elem, parent=parent) for elem in v]
else:
raise TypeError(f'Python type {t} has no defined s-expression deserialization')
class WrapperType:
def __init__(self, next_type):
self.next_type = next_type
def __bind_field__(self, field):
self.field = field
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
def __atoms__(self):
if hasattr(self, 'name_atom'):
return [self.name_atom]
elif self.next_type is Atom:
return []
else:
return getattr(self.next_type, '__atoms__', lambda: [])()
class Named(WrapperType):
def __init__(self, next_type, name=None):
super().__init__(next_type)
self.name_atom = Atom(name) if name else None
def __bind_field__(self, field):
if self.next_type is not Atom:
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
if self.name_atom is None:
self.name_atom = Atom(field.name)
def __map__(self, obj, parent=None):
k, *obj = obj
if self.next_type in (int, float, str, Atom) or isinstance(self.next_type, AtomChoice):
return map_sexp(self.next_type, [*obj], parent=parent)
else:
return map_sexp(self.next_type, obj, parent=parent)
def __sexp__(self, value):
value = sexp(self.next_type, value)
if value is not None:
yield [self.name_atom, *value]
class Rename(WrapperType):
def __init__(self, next_type, name=None):
super().__init__(next_type)
self.name_atom = Atom(name) if name else None
def __bind_field__(self, field):
if self.name_atom is None:
self.name_atom = Atom(field.name)
def __map__(self, obj, parent=None):
return map_sexp(self.next_type, obj, parent=parent)
def __sexp__(self, value):
value, = sexp(self.next_type, value)
if self.next_type in (str, float, int, Atom):
yield [self.name_atom, *value]
else:
key, *rest = value
yield [self.name_atom, *rest]
class OmitDefault(WrapperType):
def __bind_field__(self, field):
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
if field.default_factory != MISSING:
self.default = field.default_factory()
else:
self.default = field.default
def __map__(self, obj, parent=None):
return map_sexp(self.next_type, obj, parent=parent)
def __sexp__(self, value):
if value != self.default:
yield from sexp(self.next_type, value)
class YesNoAtom:
def __init__(self, yes=Atom.yes, no=Atom.no):
self.yes, self.no = yes, no
def __map__(self, value, parent=None):
value, = value
return value == self.yes
def __sexp__(self, value):
yield self.yes if value else self.no
class Wrap(WrapperType):
def __map__(self, value, parent=None):
value, = value
return map_sexp(self.next_type, value, parent=parent)
def __sexp__(self, value):
for inner in sexp(self.next_type, value):
yield [inner]
class Array(WrapperType):
def __map__(self, value, parent=None):
return [map_sexp(self.next_type, [elem], parent=parent) for elem in value]
def __sexp__(self, value):
for e in value:
yield from sexp(self.next_type, e)
class List(WrapperType):
def __bind_field__(self, field):
self.attr = field.name
def __map__(self, value, parent):
l = getattr(parent, self.attr, [])
mapped = map_sexp(self.next_type, value, parent=parent)
l.append(mapped)
setattr(parent, self.attr, l)
def __sexp__(self, value):
for elem in value:
yield from sexp(self.next_type, elem)
class _SexpTemplate:
@staticmethod
def __atoms__(kls):
return [kls.name_atom]
@staticmethod
def __map__(kls, value, parent=None):
positional = iter(kls.positional)
inst = kls()
for v in value[1:]: # skip key
if isinstance(v, Atom) and v in kls.keys:
name, etype = kls.keys[v]
mapped = map_sexp(etype, [v], parent=inst)
if mapped is not None:
setattr(inst, name, mapped)
elif isinstance(v, list):
name, etype = kls.keys[v[0]]
mapped = map_sexp(etype, v, parent=inst)
if mapped is not None:
setattr(inst, name, mapped)
else:
try:
pos_key = next(positional)
setattr(inst, pos_key.name, v)
except StopIteration:
raise TypeError(f'Unhandled positional argument {v!r} while parsing {kls}')
getattr(inst, '__after_parse__', lambda x: None)(parent)
return inst
@staticmethod
def __sexp__(kls, value):
getattr(value, '__before_sexp__', lambda: None)()
out = [kls.name_atom]
for f in fields(kls):
if f.type is SEXP_END:
break
out += sexp(f.type, getattr(value, f.name))
yield out
@staticmethod
def parse(kls, data):
return kls.__map__(parse_sexp(data))
@staticmethod
def sexp(self):
return next(self.__sexp__(self))
def sexp_type(name=None):
def register(cls):
cls = dataclass(cls)
cls.name_atom = Atom(name) if name is not None else None
for key in '__sexp__', '__map__', '__atoms__', 'parse':
if not hasattr(cls, key):
setattr(cls, key, classmethod(getattr(_SexpTemplate, key)))
if not hasattr(cls, 'sexp'):
setattr(cls, 'sexp', getattr(_SexpTemplate, 'sexp'))
cls.positional = []
cls.keys = {}
for f in fields(cls):
f_type = f.type
if f_type is SEXP_END:
break
if hasattr(f_type, '__bind_field__'):
f_type.__bind_field__(f)
atoms = getattr(f_type, '__atoms__', lambda: [])
atoms = list(atoms())
for atom in atoms:
cls.keys[atom] = (f.name, f_type)
if not atoms:
cls.positional.append(f)
return cls
return register

View file

@ -0,0 +1,446 @@
"""
Library for processing KiCad's symbol files.
"""
import json
import string
import math
import re
import sys
import itertools
from fnmatch import fnmatch
from collections import defaultdict
from dataclasses import field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from .sexp import *
from .sexp_mapper import *
from .base_types import *
PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free,
Atom.unspecified, Atom.power_in, Atom.power_out, Atom.open_collector, Atom.open_emitter,
Atom.no_connect)
PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low,
Atom.output_low, Atom.edge_clock_high, Atom.non_logic)
@sexp_type('alternate')
class AltFunction:
name: str = None
etype: PIN_ETYPE = Atom.unspecified
shape: PIN_STYLE = Atom.line
@sexp_type('__styled_text')
class StyledText:
value: str = None
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('pin')
class Pin:
etype: PIN_ETYPE = Atom.unspecified
style: PIN_STYLE = Atom.line
at: AtPos = field(default_factory=AtPos)
length: Named(float) = 2.54
hide: Flag() = False
name: Rename(StyledText) = field(default_factory=StyledText)
number: Rename(StyledText) = field(default_factory=StyledText)
alternates: List(AltFunction) = field(default_factory=list)
@property
def direction(self):
return {0: 'R', 90: 'U', 180: 'L', 270: 'D'}.get(self.at.rotation, 'R')
@direction.setter
def direction(self, value):
self.at.rotation = {0: 'R', 90: 'U', 180: 'L', 270: 'D'}[value[0].upper()]
@sexp_type('fill')
class Fill:
type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background)) = Atom.none
@sexp_type('circle')
class Circle:
center: Rename(XYCoord) = field(default_factory=XYCoord)
radius: Named(float) = 0.0
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
@sexp_type('arc')
class Arc:
start: Rename(XYCoord) = field(default_factory=XYCoord)
mid: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
# TODO add function to calculate center, bounding box
@sexp_type('polyline')
class Polyline:
pts: PointList = field(default_factory=PointList)
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
@property
def points(self):
return self.pts.xy
@points.setter
def points(self, value):
self.pts.xy = value
@property
def closed(self):
# if the last and first point are the same, we consider the polyline closed
# a closed triangle will have 4 points (A-B-C-A) stored in the list of points
return len(self.points) > 3 and self.points[0] == self.points[-1]
@property
def bbox(self):
if not self.points:
return (0.0, 0.0, 0.0, 0.0)
return (min(p.x for p in self.points),
min(p.y for p in self.points),
max(p.x for p in self.points),
max(p.y for p in self.points))
def as_rectangle(self):
(maxx, maxy, minx, miny) = self.get_boundingbox()
return Rectangle(
minx,
maxy,
maxx,
miny,
self.stroke_width,
self.stroke_color,
self.fill_type,
self.fill_color,
unit=self.unit,
demorgan=self.demorgan,
)
def get_center_of_boundingbox(self):
(maxx, maxy, minx, miny) = self.get_boundingbox()
return ((minx + maxx) / 2, ((miny + maxy) / 2))
def is_rectangle(self):
# a rectangle has 5 points and is closed
if len(self.points) != 5 or not self.is_closed():
return False
# construct lines between the points
p0 = self.points[0]
for p1_idx in range(1, len(self.points)):
p1 = self.points[p1_idx]
dx = p1.x - p0.x
dy = p1.y - p0.y
if dx != 0 and dy != 0:
# if a line is neither horizontal or vertical its not
# part of a rectangle
return False
# select next point
p0 = p1
return True
@sexp_type('at')
class TextPos(XYCoord):
x: float = 0 # in millimeter
y: float = 0 # in millimeter
rotation: int = 0 # in degrees
def __after_parse__(self, parent):
self.rotation = self.rotation / 10
def __before_sexp__(self):
self.rotation = round((self.rotation % 360) * 10)
@property
def rotation_rad(self):
return math.radians(self.rotation)
@rotation_rad.setter
def rotation_rad(self, value):
self.rotation = math.degrees(value)
@sexp_type('text')
class Text:
text: str = None
at: TextPos = field(default_factory=TextPos)
rotation: float = None
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('rectangle')
class Rectangle:
"""
Some v6 symbols use rectangles, newer ones encode them as polylines.
At some point in time we can most likely remove this class since its not used anymore
"""
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
def as_polyline(self):
x1, y1 = self.start
x2, y2 = self.end
return Polyline([Point(x1, y1), Point(x2, y1), Point(x2, y2), Point(x1, y2), Point(x1, y1)],
self.stroke, self.fill)
@sexp_type('property')
class Property:
name: str = None
value: str = None
id: Named(int) = None
at: AtPos = field(default_factory=AtPos)
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('pin_numbers')
class PinNumberSpec:
hide: Flag() = False
@sexp_type('pin_names')
class PinNameSpec:
offset: OmitDefault(Named(float)) = 0.508
hide: Flag() = False
@sexp_type('symbol')
class Unit:
name: str = None
circles: List(Circle) = field(default_factory=list)
arcs: List(Arc) = field(default_factory=list)
polylines: List(Polyline) = field(default_factory=list)
rectangles: List(Rectangle) = field(default_factory=list)
texts: List(Text) = field(default_factory=list)
pins: List(Pin) = field(default_factory=list)
unit_name: Named(str) = None
_ : SEXP_END = None
global_units: list = field(default_factory=list)
unit_global: Flag() = False
style_global: Flag() = False
demorgan_style: int = 1
unit_index: int = 1
symbol = None
def __after_parse__(self, parent):
self.symbol = parent
if not (m := re.fullmatch(r'(.*)_([0-9]+)_([0-9]+)', self.name)):
raise FormatError(f'Invalid unit name "{self.name}"')
sym_name, unit_index, demorgan_style = m.groups()
if sym_name != self.symbol.name:
raise FormatError(f'Unit name "{self.name}" does not match symbol name "{self.symbol.name}"')
self.demorgan_style = int(demorgan_style)
self.unit_index = int(unit_index)
self.style_global = self._demorgan_style == 0
self.unit_global = self.unit_index == 0
def __before_sexp__(self):
self.name = f'{self.symbol.name}_{self.unit_index}_{self.demorgan_style}'
def __getattr__(self, name):
if name.startswith('all_'):
name = name[4:]
return itertools.chain(getattr(self.global_units, name, []), getattr(self, name, []))
def pin_stacks(self):
stacks = defaultdict(lambda: set())
for pin in self.all_pins():
stacks[(pin.at.x, pin.at.y)].add(pin)
return stacks
@sexp_type('symbol')
class Symbol:
name: str = None
extends: Named(str) = None
power: Wrap(Flag()) = False
pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec)
pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec)
in_bom: Named(YesNoAtom()) = True
on_board: Named(YesNoAtom()) = True
properties: List(Property) = field(default_factory=list)
raw_units: List(Unit) = field(default_factory=list)
_ : SEXP_END = None
styles: {str: {str: Unit}} = None
global_units: {str: {str: Unit}} = None
library = None
def __after_parse__(self, parent):
self.library = parent
self.global_units = {}
self.styles = {}
if self.extends:
self.in_bom = None
self.on_board = None
self.properties = {prop.name: prop for prop in self.properties}
if (prop := self.properties.get('ki_fp_filters')):
prop.value = prop.value.split() if prop.value else []
for unit in self.raw_units:
if unit.unit_global or unit.style_global:
d = self.global_units.get(unit.demorgan_style, {})
d[unit.name] = unit
self.global_units[unit.demorgan_style] = d
for other in self.raw_units:
if other.unit_global or other.style_global or other == unit:
continue
if not (unit.unit_global or other.name == unit.name):
continue
if not (unit.style_global or other.demorgan_style == unit.demorgan_style):
continue
other.global_units.append(unit)
else:
d = self.styles.get(unit.demorgan_style, {})
d[unit.name] = unit
self.styles[unit.demorgan_style] = d
def __before_sexp__(self):
self.raw_units = ([unit for style in self.global_units.values() for unit in style.values()] +
[unit for style in self.styles.values() for unit in style.values()])
if (prop := self.properties.get('ki_fp_filters')):
if not isinstance(prop.value, str):
prop.value = ' '.join(prop.value)
self.properties = list(self.properties.values())
def default_properties(self):
for i, (name, value, hide) in enumerate([
('Reference', 'U', False),
('Value', None, False),
('Footprint', None, True),
('Datasheet', None, True),
('ki_locked', None, True),
('ki_keywords', None, True),
('ki_description', None, True),
('ki_fp_filters', None, False),
]):
self.properties[name] = Property(name=name, value=value, id=i, effects=TextEffect(hide=hide))
def units(self, demorgan_style=None):
if self.extends:
return self.library[self.extends].units(demorgan_style)
else:
return self.styles.get(demorgan_style or 'default', {})
def get_center_rectangle(self, units):
# return a polyline for the requested unit that is a rectangle
# and is closest to the center
candidates = {}
# building a dict with floats as keys.. there needs to be a rule against that^^
pl_rects = [i.as_polyline() for i in self.rectangles]
pl_rects.extend(pl for pl in self.polylines if pl.is_rectangle())
for pl in pl_rects:
if pl.unit in units:
# extract the center, calculate the distance to origin
(x, y) = pl.get_center_of_boundingbox()
dist = math.sqrt(x * x + y * y)
candidates[dist] = pl
if candidates:
# sort the list return the first (smallest) item
return candidates[sorted(candidates.keys())[0]]
return None
def is_graphic_symbol(self):
return self.extends is None and (
not self.pins or self.get_property("Reference").value == "#SYM"
)
def pins_by_name(self, demorgan_style=None):
pins = defaultdict(lambda: set())
for unit in self.units(demorgan_style):
for pin in unit.all_pins:
pins[pin.name].add(pin)
return pins
def pins_by_number(self, demorgan_style=None):
pins = defaultdict(lambda: set())
for unit in self.units(demorgan_style):
for pin in unit.all_pins:
pins[pin.number].add(pin)
return pins
def __getattr__(self, name):
if name.startswith('all_'):
return itertools.chain(getattr(unit, name) for unit in self.raw_units)
def filter_pins(self, name=None, direction=None, electrical_type=None):
for pin in self.all_pins:
if name and not fnmatch(pin.name, name):
continue
if direction and not pin.direction in direction:
continue
if electrical_type and not pin.etype in electical_type:
continue
yield pin
def heuristically_small(self):
""" Heuristically try to determine whether this is a "small" component like a resistor, capacitor, LED, diode,
or transistor etc. When we have at most two pins, or there is no filled rectangle as symbol outline and we have
3 or 4 pins, we assume this is a small symbol.
"""
if len(self.all_pins) <= 2:
return True
if len(self.all_pins) > 4:
return False
return bool(self.get_center_rectangle(range(self.unit_count)))
SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914]
@sexp_type('kicad_symbol_lib')
class Library:
_version: Named(int, name='version') = 20211014
generator: Named(Atom) = Atom.kicad_library_utils
symbols: List(Symbol) = field(default_factory=list)
_ : SEXP_END = None
original_filename: str = None
@property
def version(self):
return self._version
@version.setter
def version(self, value):
if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
@classmethod
def open(cls, filename: str):
with open(filename) as f:
return cls.parse(f.read())
def write(self, filename=None):
with open(filename or self.original_filename, 'w') as f:
f.write(build_sexp(sexp(self)))
if __name__ == "__main__":
if len(sys.argv) >= 2:
a = Library.open(sys.argv[1])
print(build_sexp(sexp(a)))
else:
print("pass a .kicad_sym file please")

View file

@ -1,4 +1,5 @@
import os
from pathlib import Path
import pytest
@ -33,3 +34,30 @@ def pytest_sessionstart(session):
run_cargo_cmd('resvg', '--help')
except FileNotFoundError:
pytest.exit('resvg binary not found, aborting test.', 2)
def pytest_addoption(parser):
parser.addoption('--kicad-symbol-library', nargs='*', help='Run symbol library tests on given symbol libraries. May be given multiple times.')
parser.addoption('--kicad-footprint-files', nargs='*', help='Run footprint library tests on given footprint files. May be given multiple times.')
def pytest_generate_tests(metafunc):
if 'kicad_library_file' in metafunc.fixturenames:
if not (library_files := metafunc.config.getoption('symbol_library', None)):
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
lib_dir = Path(lib_dir).expanduser()
if not lib_dir.is_dir():
raise ValueError(f'Path "{lib_dir}" given by KICAD_SYMBOLS environment variable does not exist or is not a directory.')
library_files = list(lib_dir.glob('*.kicad_sym'))
else:
raise ValueError('Either --kicad-symbol-library command line parameter or KICAD_SYMBOLS environment variable must be given.')
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
if 'kicad_mod_file' in metafunc.fixturenames:
if not (mod_files := metafunc.config.getoption('footprint_files', None)):
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
lib_dir = Path(lib_dir).expanduser()
if not lib_dir.is_dir():
raise ValueError(f'Path "{lib_dir}" given by KICAD_FOOTPRINTS environment variable does not exist or is not a directory.')
mod_files = list(lib_dir.glob('**/*.kicad_mod'))
else:
raise ValueError('Either --kicad-footprint-files command line parameter or KICAD_FOOTPRINTS environment variable must be given.')
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))

View file

@ -0,0 +1,57 @@
from itertools import zip_longest
import re
from ..cad.kicad.sexp import build_sexp
from ..cad.kicad.sexp_mapper import sexp
from ..cad.kicad.footprints import Footprint
def test_parse(kicad_mod_file):
Footprint.open(kicad_mod_file)
def test_round_trip(kicad_mod_file):
print('========== Stage 1 load ==========')
orig_fp = Footprint.open(kicad_mod_file)
print('========== Stage 1 save ==========')
stage1_sexp = build_sexp(orig_fp.sexp())
with open('/tmp/foo.sexp', 'w') as f:
f.write(stage1_sexp)
print('========== Stage 2 load ==========')
reparsed_fp = Footprint.parse(stage1_sexp)
print('========== Stage 2 save ==========')
stage2_sexp = build_sexp(reparsed_fp.sexp())
print('========== Checks ==========')
for stage1, stage2 in zip_longest(stage1_sexp.splitlines(), stage2_sexp.splitlines()):
assert stage1 == stage2
return
original = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', kicad_mod_file.read_text()))
original = re.sub(r'\) \)', '))', original)
original = re.sub(r'\) \)', '))', original)
original = re.sub(r'\) \)', '))', original)
original = re.sub(r'\) \)', '))', original)
stage1 = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', stage1_sexp))
for original, stage1 in zip_longest(original.splitlines(), stage1.splitlines()):
if original.startswith('(version'):
continue
original, stage1 = original.strip(), stage1.strip()
if original != stage1:
if any(original.startswith(f'({foo}') for foo in ['arc', 'circle', 'rectangle', 'polyline', 'text']):
# These files have symbols with graphic primitives in non-standard order
return
if original.startswith('(symbol') and stage1.startswith('(symbol'):
# Re-export can change symbol order. This is ok.
return
if original.startswith('(at') and stage1.startswith('(at'):
# There is some disagreement as to whether rotation angles are ints or floats, and the spec doesn't say.
return
assert original == stage1

View file

@ -0,0 +1,26 @@
from ..cad.kicad.sexp import parse_sexp, build_sexp
def test_sexp_round_trip():
test_sexp = '''(()() (foo) (23)\t(foo 23) (foo 23 bar baz) (foo bar baz) ("foo bar") (" foo " bar) (23 " baz ")
(foo ( bar ( baz 23) 42) frob) (() (foo) ()()) foo 23 23.0 23.000001 "foo \\"( ))bar" "foo\\"bar\\"baz" "23" "23foo"
"" "" ("") ("" 0 0.0)
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data")
'''
parsed = parse_sexp(test_sexp)
sexp1 = build_sexp(parsed)
re_parsed = parse_sexp(sexp1)
sexp2 = build_sexp(parsed)
assert re_parsed == parsed
assert sexp1 == sexp2

View file

@ -0,0 +1,59 @@
from itertools import zip_longest
import re
from ..cad.kicad.sexp import build_sexp
from ..cad.kicad.sexp_mapper import sexp
from ..cad.kicad.symbols import Library
def test_parse(kicad_library_file):
Library.open(kicad_library_file)
def test_round_trip(kicad_library_file):
print('========== Stage 1 load ==========')
orig_lib = Library.open(kicad_library_file)
print('========== Stage 1 save ==========')
stage1_sexp = build_sexp(orig_lib.sexp())
print('========== Stage 2 load ==========')
reparsed_lib = Library.parse(stage1_sexp)
print('========== Stage 2 save ==========')
stage2_sexp = build_sexp(reparsed_lib.sexp())
print('========== Checks ==========')
for stage1, stage2 in zip_longest(stage1_sexp.splitlines(), stage2_sexp.splitlines()):
assert stage1 == stage2
original = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', kicad_library_file.read_text()))
original = re.sub(r'\) \)', '))', original)
original = re.sub(r'\) \)', '))', original)
original = re.sub(r'\) \)', '))', original)
original = re.sub(r'\) \)', '))', original)
stage1 = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', stage1_sexp))
for original, stage1 in zip_longest(original.splitlines(), stage1.splitlines()):
if original.startswith('(version'):
continue
original, stage1 = original.strip(), stage1.strip()
if original != stage1:
if any(original.startswith(f'({foo}') for foo in ['arc', 'circle', 'rectangle', 'polyline', 'text']):
# These files have symbols with graphic primitives in non-standard order
return
if original.startswith('(offset') and stage1.startswith('(offset'):
# Some symbol files contain ints where floats should be.
return
if original.startswith('(symbol') and stage1.startswith('(symbol'):
# Re-export can change symbol order. This is ok.
return
if original.startswith('(at') and stage1.startswith('(at'):
# There is some disagreement as to whether rotation angles are ints or floats, and the spec doesn't say.
return
assert original == stage1