Symbol interface design guide

Binding spec for the symdef toolkit and the data repos built on it. Agreed with the repo owner on 2026-09-19; amended 2026-10-07 for 0.5.0, 2026-10-13 for 0.6.0 and 2026-10-15 for 0.7.0 (see the change list).

This guide is the only design document. It changes only through a designer-approved spec, with a dated entry in the change list below and in docs/DECISIONS.md.

Change list

DateVersionChange
2026-10-150.7.0[[stretch]] and stretch() move everything beyond a cut line; table() places rows with their own ports and slots; lint rule stretch-cut makes 35 rules; [[wire]] in library.toml and wire() draw wires in a named style; fill = "tint" and the tint keyword of the output functions take the caller's colour (D53 to D57). bundle.json gains an optional wires key, the serialized [[wire]] styles, written only when the set has any.
2026-10-130.6.0to_fragment_mm(symbol, *, module_mm, weights_mm, dash_mm, font_family, font_mm, cap_ratio) writes a fragment in millimetres with no scale transform: coordinates times the module; stroke widths, dash lengths and font size are the caller's mm; text capitals centred on the anchor by the caller's capital ratio; to_svg and to_fragment output unchanged (D52).
2026-10-070.5.0A set declares its own words: library.toml gains [vocabulary] (path_kinds, potentials, links) and [rules] required_slots (D46). PathKind and Potential leave the API; Path.kind and Node.potential are strings. Library gains vocabulary and required_slots, and lint takes required_slots (sections 5, 6, 9, 11, 12).
2026-10-070.5.0The JSON Schema ships in the package and has an $id on the documentation site; RULES and finding_key join the API (D47).
2026-10-070.5.0The newcomer's path: symdef init, build and check, also functions (D48). The module build is now files.
2026-10-070.5.0No runtime dependency: the contract library is gone and a gate script checks purity (D49). repeat's preconditions raise ValueError.
2026-10-070.5.0Section 1 and sections 12 to 14 no longer describe predecessors or one data repo; the section 12 layout and gates are the generic ones. library.toml may carry package (D45).

1. Why: one object, three jobs

A symbol library that makes one object do three jobs has the same recurring problems. All of them trace back to that.

LayerWhat it isExampleOwner
Symbolthe graphic concept in the standardmake contact, S00227the symbol set (a data repo)
Device terminal markingnumbers printed on the real part (EN 50005 / EN 50011)13/14, 23/24, A1/A2the drawing's device or catalog model
Placementtag, position, orientation, rendered text-K1 at (40, 120)the drawing library

2. Principles

  1. A symbol is a definition. It has no tag, no terminal numbers, no position and no orientation. Those belong to the placement.
  2. Port ids are roles. Stable, lowercase, semantic, never terminal numbers, never dependent on configuration.
  3. Electrical function is data. Nodes and typed paths, so consumers netlist and check without per-symbol knowledge.
  4. Text goes in slots. The symbol reserves boxes for the tag and for each port's marking. The consumer renders the text.
  5. Data is the source of truth. One TOML file per symbol, validated by a published JSON Schema. Any language can consume the resolved JSON.
  6. Composition mirrors the standard. IEC 60617 builds symbols from elements and qualifying symbols. Composites reference parts by anchor and contain no copied coordinates.
  7. The wiring contract is strict and linted in all 8 orientations. A router never repairs a symbol.
  8. Every gate is proven able to fail with a deliberately broken fixture.

3. Conventions

ItemRule
UnitThe module M. Default M = 2.5 mm (ISO 81714-1, ISO 14617). Files never contain millimetres.
AxesSVG axes: x right, y down. Angles in degrees, clockwise on screen from +x.
Drawing grid0.125 M. Every shape coordinate, radius, text height, anchor, slot point and slot box size is a multiple of it. 0.125 is exact in binary floating point.
Wiring grid1 M. Every port position is a multiple of it. The origin is on it by definition.
Pole pitch4 M (10 mm). The only pitch repeat uses by default. A symbol too wide for it declares pole_pitch, a multiple of 4.
Same-side port spacingTwo ports with the same dir are a multiple of 2 M apart, measured along the side (x for N and S ports, y for E and W ports).
Base orientationThe through path (section 5) is vertical: its two ports are at (0, -a) pointing N and (0, a) pointing S, with a a whole number of M. Horizontal use is an orientation, never a second file.
Line weightnormal = 0.1 M (ISO 81714-1, width : module = 1 : 10), thick = 0.2 M (provisional).
Line stylesolid or dashed. Dashed means dash 0.5 M, gap 0.25 M; renderers implement the pattern, files never spell out dash segments.
Text elementsText that is part of the symbol itself (the M in a motor). Height at most 1 M. Stays upright in every orientation; only its position transforms.
ExtentsAll boxes ignore stroke width. A line or polyline extends over its points, a circle over its full diameter, an arc over its true swept extent. A text element counts as a box centred on at, height tall and 0.6 × height × character count wide.
OverlapTwo shapes overlap when their open interiors intersect. Touching along an edge or at a corner is not overlap. For overlap tests a stroke is a zero-width curve, a filled shape is its area, and an unfilled closed outline is only its outline (so a slot may sit inside an empty body rectangle).
IdsPort, anchor and part ids match ^[a-z][a-z0-9_]*$. The dot is reserved for namespacing by composition and repeat (contact.in, 2.out).

Orientations

There are exactly 8: R0 R90 R180 R270 MR0 MR90 MR180 MR270. M mirrors first (flip x: (x, y) -> (-x, y)), then Rn rotates clockwise on screen by n degrees. A 90 degree clockwise rotation is (x, y) -> (-y, x). Directions and slot sides transform the same way as vectors. Circle and arc centres transform as points and radii are unchanged. Rotation by n adds n to an arc's start and end. Mirroring maps an angle t to 180 - t and swaps start with end, so the arc is still swept clockwise. Angles are normalised to [0, 360). Text elements and slot boxes never rotate or mirror; only their at point moves. Flipping top to bottom is MR180. There is no other transform in the interface.

Port id vocabulary

CaseIds
Any two-terminal series symbol (contact, fuse, resistor, coil, lamp)in (top in base orientation), out. No polarity is implied.
Polarity carries meaninganode/cathode, plus/minus
Change-over contactcom, nc, no
Three-phase and protective earthu, v, w, n, pe
Windingspri_in, pri_out, sec_in, sec_out
Junction-like symbolsn, e, s, w by compass side

A1/A2, 13/14, X1/X2 are terminal markings, not roles. They never appear in a symbol file. Generic consumers should chain symbols using the through path's from and to, not by assuming the ids in/out.

4. The symbol file

One file per symbol: symbols/<number>.toml in the data repo. Symbol elements and qualifying symbols from IEC 60617 Part 2 are files too, with a different kind.

Top-level keys

KeyTypeRequiredMeaning
schemaintegeryesSchema version. Currently 1.
namestringyesThe standard's English name, verbatim.
kindsymbol | element | qualifieryessymbol is placeable on its own. element and qualifier exist to be used as parts; slot and through-path rules do not apply to them.
statusunverified | verifiedyesverified only after the geometry is compared with the standard's own drawing.
referencetableyesstandard, number, optional edition, form. Identity is number; it equals the file stem.
portsarray of tables, or a rename map in compositesnoAtomic: id, at, dir (N E S W), optional description. Composite: { new_id = "part.port" }.
nodesarray of tablesnoports (list of ids) and optional potential. See section 5.
pathsarray of tablesnofrom, to, kind, optional through. See section 5.
anchorsarray of tablesnoid, at, dir. Attachment points for composition.
elementsarray of tablesatomic: yesGeometry. See below.
partsarray of tablescomposite: yesSee section 7. A file has elements, parts, or both.
slotstable of tableskind = "symbol": yesSee section 6.
pole_pitchintegernoMultiple of 4. Only for symbols that cannot fit the 4 M pitch.
stretcharray of tablesnoaxis (x or y) and at (number). At most one per axis. The cut line that stretch() moves everything beyond; see section 7.
lint_allowarray of tablesnorule, reason. The only way to be exempt from a lint rule.

Elements

Each element is a table with exactly one shape key. Optional on all: weight (normal | thick). Optional on line, polyline, arc: style (solid | dashed). Optional on line: port, the id of the port the line leads to; see the wiring contract, section 8, and the lead-off-port rule, section 9.

ShapeForm
Line{ line = [[x1, y1], [x2, y2]], port = "in" } (port optional)
Polyline{ polyline = [[x, y], ...], closed = true, fill = "solid" } (closed default false, fill none | solid | tint, default none; tint only on a closed polyline, drawn in the colour the caller passes, D57)
Circle{ circle = [cx, cy], r = 0.5, fill = "none" }
Arc{ arc = [cx, cy], r = 0.5, start = 180, end = 0 } (clockwise from start to end)
Text{ text = "M", at = [0, 0], height = 1 } (always middle-anchored, always upright)

Example: make contact (atomic, simplest case)

schema = 1
name = "Make contact, general symbol"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00227" }

ports = [
  { id = "in",  at = [0, -2], dir = "N" },
  { id = "out", at = [0, 2],  dir = "S" },
]

paths = [
  { from = "in", to = "out", kind = "switch_open", through = true },
]

anchors = [
  { id = "link", at = [-0.5, 0], dir = "W" },
]

elements = [
  { line = [[0, -2], [0, -1]] },
  { line = [[0, 2], [0, 1]] },
  { line = [[0, 1], [-1, -1]] },
]

[slots]
tag           = { at = [-1.5, 0],    side = "W", box = [6, 1] }
"marking.in"  = { at = [0.25, -1.5], side = "E", box = [1.5, 1] }
"marking.out" = { at = [0.25, 1.5],  side = "E", box = [1.5, 1] }

Example: change-over contact (two paths, one through)

schema = 1
name = "Change-over break before make contact"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00230" }
pole_pitch = 8          # 5.5 M wide with its markings, so it cannot repeat at 4 M

ports = [
  { id = "com", at = [0, 2],   dir = "S", description = "common" },
  { id = "no",  at = [0, -2],  dir = "N", description = "closes on actuation" },
  { id = "nc",  at = [-2, -2], dir = "N", description = "opens on actuation" },
]

paths = [
  { from = "no", to = "com", kind = "switch_open", through = true },
  { from = "nc", to = "com", kind = "switch_closed" },
]

anchors = [
  { id = "link", at = [-0.5, 0], dir = "W" },
]

elements = [
  { line = [[0, 2], [0, 1]] },
  { line = [[0, -2], [0, -1]] },
  { line = [[-2, -2], [-2, -1]] },
  { line = [[-2, -1], [-1, -1]] },
  { line = [[0, 1], [-1.125, -1.25]] },
]

[slots]
tag           = { at = [-4, 0],        side = "W", box = [6, 1] }
"marking.com" = { at = [0.25, 1.5],    side = "E", box = [1.5, 1] }
"marking.no"  = { at = [0.25, -1.5],   side = "E", box = [1.5, 1] }
"marking.nc"  = { at = [-2.25, -1.5],  side = "W", box = [1.5, 1] }

Example: relay coil (impedance)

schema = 1
name = "Operating device, general symbol; relay coil, general symbol"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00305" }

ports = [
  { id = "in",  at = [0, -3], dir = "N" },
  { id = "out", at = [0, 3],  dir = "S" },
]

paths = [
  { from = "in", to = "out", kind = "impedance", through = true },
]

anchors = [
  { id = "link", at = [2, 0], dir = "E" },
]

elements = [
  { line = [[0, -3], [0, -1]] },
  { line = [[0, 3], [0, 1]] },
  { polyline = [[-2, -1], [2, -1], [2, 1], [-2, 1]], closed = true },
]

[slots]
tag           = { at = [-2.5, 0],  side = "W", box = [6, 1] }
"marking.in"  = { at = [0.25, -2], side = "E", box = [1.5, 1] }
"marking.out" = { at = [0.25, 2],  side = "E", box = [1.5, 1] }

Example: connection point (one node, declared exceptions)

schema = 1
name = "Connection point"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00016" }

ports = [
  { id = "n", at = [0, 0], dir = "N" },
  { id = "e", at = [0, 0], dir = "E" },
  { id = "s", at = [0, 0], dir = "S" },
  { id = "w", at = [0, 0], dir = "W" },
]

nodes = [
  { ports = ["n", "e", "s", "w"] },
]

elements = [
  { circle = [0, 0], r = 0.25, fill = "solid" },
]

lint_allow = [
  { rule = "port-on-body-edge", reason = "junction: wires meet at the centre of the dot" },
  { rule = "port-lane-clear",   reason = "junction: wires start inside the dot" },
  { rule = "slot-missing",      reason = "a junction carries no tag or markings" },
]

[slots]

The examples in this guide are normative for the format and have been machine-checked: they parse as TOML, every reference resolves, and the four atomic ones pass port-off-geometry, port-on-body-edge, port-lane-clear, port-spacing, through-axis, slot-overlap-body, slot-overlap-slot, pitch-overflow and allow-unused in all 8 orientations, using a throwaway checker written from the definitions in this guide. The toolkit's first test fixture is these five files: they must load and lint clean. If the real linter disagrees with an example, stop and resolve which one is wrong before going on. The shapes themselves are unverified against IEC drawings, like all geometry.

5. Connectivity model

This is what keeps the electrical function explicit. It is declarative data only; the toolkit never simulates anything.

The path kinds of an electrical set. The toolkit has no built-in list: a set declares its own words in [vocabulary] path_kinds (a pipe set might declare pipe), and a kind not declared is a load error naming the word, the file and the line.
Path kindMeaningDirection mattersExamples
conductoralways conductsnofuse, link, shunt
switch_openopen in the drawn (rest, de-energised) statenomake contact, circuit breaker
switch_closedclosed in the drawn statenobreak contact
impedancea load or passive element: consumes or storesnoresistor, capacitor, coil, lamp, motor winding, meter
sourcedelivers energy; to is the positive terminal where polarity existsyesbattery, generator
diodeconducts from from to to onlyyesdiode, LED, thyristor main path

What a consumer can do with this and nothing else: build a netlist across placed symbols, find nets with a source and no load, find nets that are shorted through conductors, trace which switch states complete a circuit, and know that a terminal's or junction's ports are one net. Tag letter codes (IEC 81346) and device behaviour stay in the consumer: they classify devices, not symbols.

6. Text slots and boxes

A slot reserves room for text the consumer will draw. A symbol never contains tag or marking text.

SlotMeaning
tagThe reference designation (-K1). Required for kind = "symbol" when the set lists it in [rules] required_slots.
"marking.<port id>"The terminal marking for that port (13). Required for every port of a kind = "symbol" file when the set lists "marking.<port>" in [rules] required_slots.
valueOptional. Rating or value text (10 A).

Each slot is { at = [x, y], side = "N|E|S|W", box = [w, h] } with w and h positive. Text is always upright. side says which way the box grows from at, and therefore the alignment:

sideBox, for at = (x, y), box = [w, h]Text alignment
Ex in [x, x + w], y in [y - h/2, y + h/2]start, vertically centred
Wx in [x - w, x], y in [y - h/2, y + h/2]end, vertically centred
Nx in [x - w/2, x + w/2], y in [y - h, y]middle, sitting above at
Sx in [x - w/2, x + w/2], y in [y, y + h]middle, hanging below at

Orientation. at transforms as a point and side as a direction. box stays [w, h], because the text stays upright. So one declaration covers all 8 orientations, and because the box does not rotate with the symbol, slot rules must be linted in every orientation, not just the base one.

Two boxes for layout engines. body_box is the extent of the elements. keepout_box is the body box united with every slot box. A layout engine that keeps keep-out boxes apart can never produce a label collision between symbols.

Consumer duty. The box is a maximum extent. A consumer that draws text larger than the box has broken the contract on its side; it should shrink or report, never overflow silently.

7. Composition

Parts

A composite lists parts. Each part is { as, use, ... }: a local id and the number of another file.

KeyMeaning
asLocal part id. Namespaces the part's ports, anchors and slots: contact.in.
useReference number of the part's file. Any kind.
orientOptional orientation applied to the part before placing. Default R0.
attach, to, length, viaPlace by anchor. The part's anchor attach is put at the point length M from the target anchor to ("part.anchor"), along the target's direction. The two anchors must face each other after orient; otherwise it is a lint error. via = "mechanical_link" adds a dashed line between them. length defaults to 0, via to none.
atAlternative to attach: place the part's origin at [x, y]. For qualifiers drawn inside a body. The first part defaults to at = [0, 0]. Every later part must have exactly one of attach or at.
repeatOptional pole count. Applied to the part first, before orient and placement. Its ports are then part.1.in, part.2.in, and so on.

Placement, exactly. Parts are resolved in file order, and to may only name an anchor of an earlier part. Let T and dT be the target anchor's position and unit direction in the composite's frame, and A and dA the attaching anchor's position and direction in the part's own frame after repeat and orient. It is an error unless dA = -dT. The part is translated by T + length × dT - A. With via set to a word the set declares in [vocabulary] links (for example "mechanical_link") the composite gains the element { line = [T, T + length × dT], style = "dashed" }; length must then be positive. The flattened result is linted like any other symbol, so a placement that pushes a port off the wiring grid fails there.

What a composite inherits

Example: push-button (no coordinates except the tag slot)

schema = 1
name = "Switch, manually operated, push-button, automatic return"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00254" }

parts = [
  { as = "contact",  use = "S00227" },
  { as = "actuator", use = "S00171", attach = "link", to = "contact.link", length = 2, via = "mechanical_link" },
]

ports = { in = "contact.in", out = "contact.out" }

[slots]
tag           = { at = [-3.5, 0], side = "W", box = [6, 1] }
"marking.in"  = "contact.marking.in"
"marking.out" = "contact.marking.out"

S00171 stands for the Part 2 qualifier "operated by pushing". Its number, like every number in this guide, must be confirmed against the IEC database overview before the file is committed.

repeat: the only multi-pole mechanism

repeat(symbol, n) places n copies along +x in base orientation and returns an ordinary symbol. Pole k (counting from 1) is the symbol translated by ((k - 1) × pitch, 0), where pitch is the symbol's pole_pitch, default 4. It requires n >= 1 and a symbol with a through path; repeat(symbol, 1) still renames the ports.

There are no poles=, pins= or inverted= parameters anywhere. Pole count is repeat, markings belong to the placement, and inversion is the orientation MR180.

table(row, n, axis, head=None): rows of cells

Returns n copies of row, copy k (from 1) stepped by the row's body extent along axis. Ports, nodes, paths and bound leads are prefixed k.. Every slot is kept: marking.<port> becomes marking.k.<port> and any other id becomes k.<id>. No through path is needed. head, if given, keeps its own ids and is placed once before the rows, as authored in the row's frame; with a stretch on axis it is first stretched to span the rows. n is at most 64. The result has no stretch and keeps the row's pole_pitch (D54).

stretch(symbol, axis, length, offsets=None): a cut line

Moves every point beyond the symbol's stretch cut on axis by length minus the natural extent: element points, ports, anchors and slots. A circle, arc or text across the cut raises ValueError, as does a length below the natural extent, a symbol with no stretch on axis, and an offset that names no port, a port not on the edge across the axis, or a place off the wiring grid or outside the stretched body. offsets places each named port, with its bound lead and its marking.<port> slot, at a coordinate along the axis. The result has no stretch on that axis, so a frame is stretched on both axes by two calls (D53).

8. Wiring contract

What every flattened symbol guarantees a router, in all 8 orientations:

  1. Every port is on the 1 M wiring grid.
  2. Every port is the free end of a lead (or lies on the outline) and sits on the side of the body_box that matches its direction. Its direction is that side's outward normal.
  3. The wire lane of every port is clear. For a port at P with direction d, the lane is the open, unbounded region of points P + t × d + u × n with t > 0 and |u| < 0.25, where n is perpendicular to d. No element and no slot box overlaps it. The port's own lead ends at t = 0 and a marking box 0.25 M beside the wire only touches the lane, so both are fine. A wire that leaves a port straight crosses nothing of its own symbol.
  4. The through path is collinear on x = 0 with ports at (0, -a) and (0, a). Series chains wire dead straight, and chained symbol heights are whole M.
  5. Same-side ports are a multiple of 2 M apart, and repeated poles are 4 M apart, so a 3-pole breaker, a 3-pole contactor and a motor's u v w line up without jogs.
  6. For a symbol with a through path, the x extent in base orientation of the body box united with every slot box except tag and value is at most its pole pitch, so repeated poles never overlap each other.

A symbol that cannot comply declares each exemption in lint_allow with a reason. An exemption whose rule would not have fired is itself an error, so exemptions cannot rot.

9. Lint rules

lint(symbol, required_slots=()) runs on a flattened symbol and returns findings: rule, severity, message, optional location, optional orientation. Geometric rules run in all 8 orientations. Composition rules run in the resolver and are reported the same way. All are errors unless marked. Data repo gates require zero findings of either severity, so a warning is simply a finding that more often deserves a lint_allow entry; exemptions work the same for both. slot-missing, through-missing and pitch-overflow apply only to kind = "symbol"; every other rule applies to every kind.

GroupRule idFires when
Fileschemathe file fails JSON Schema validation
metadataempty name, reference.standard differs from library.toml, number not matching its number_pattern, or number differs from the file stem
id-formata port, anchor or part id does not match the id pattern
Geometryoff-drawing-gridany coordinate, radius, text height, anchor, slot point or slot box size is not a multiple of 0.125
degeneratezero-length line, non-positive radius, polyline with fewer than 2 points (3 when closed), arc with equal angles, exact duplicate element, slot box with a non-positive side
text-too-largea text element is taller than 1 M
stretch-cuta circle, arc or text crosses a stretch cut, a cut lies outside body_min <= at < body_max on its axis, or a symbol has a second stretch on one axis
Portsport-duplicate-idtwo ports share an id
port-off-wiring-grida port position is not a multiple of 1 M
port-off-geometrya port is not at a line or polyline endpoint, on a circle or arc, or on a closed outline
port-on-body-edgea port is not on the body-box side matching its direction
port-lane-clearan element or slot box overlaps a port's wire lane (section 8)
port-spacingtwo ports with the same dir are not a multiple of 2 M apart along their side
port-position-sharedtwo ports of different nodes share a position
lead-off-porta line's port names a port that does not exist, or the line does not reach the port's position along its facing axis
Connectivitynode-invalida node names an unknown port, or a port is listed in two nodes
path-invalida path names an unknown port, joins two ports of one node, or duplicates another path between the same nodes
through-countmore than one through path
through-axisthe through path's ports are not at (0, -a) N and (0, a) S in base orientation
through-missing (warning)a kind = "symbol" file has two or more nodes, no potential, and no through path
port-isolated (warning)a port is in no path, in no multi-port node, and its node has no potential
Slotsslot-missinga kind = "symbol" file lacks a slot the set lists in [rules] required_slots, such as tag or a marking slot for some port
slot-unknown-porta marking slot names a port that does not exist
slot-overlap-bodya slot box overlaps an element, by the overlap definition in section 3
slot-overlap-slottwo slot boxes overlap
pitch-overflowthe extent defined in section 8 point 6 exceeds the pole pitch; or pole_pitch is not a positive multiple of 4
Anchorsanchor-duplicate-idtwo anchors share an id
anchor-off-geometry (warning)an anchor lies on no element
Compositionpart-unknownuse names a file that does not exist
part-cycleparts reference each other in a cycle
part-anchoran unknown anchor, or anchors that do not face each other
part-port-unexporteda part's port is missing from the rename map
export-unknownthe rename map or a slot reference names something that does not exist
Exemptionsallow-unknownlint_allow names a rule that does not exist, or has an empty reason
allow-unusedan exempted rule would not have fired

Every rule ships with a deliberately broken fixture file that makes it fire, and a test that asserts it does.

10. Resolved output

The build flattens every file and writes JSON. This is the interface for consumers that are not Python, and for simple consumers of any kind: they never implement composition.

A consumer in another language needs the bundle, the schema, and the orientation and slot-box rules from sections 3 and 6. That is about thirty lines of code.

11. Toolkit API and consumer usage

All types are frozen dataclasses. All functions are pure except those in files, project and cli. Transforms are free functions, never methods.

Point(x, y)   Box(min, max)   Direction: N E S W   Orientation: R0 .. MR270
Vocabulary(path_kinds, potentials, links)
Line | Polyline | Circle | Arc | Text                      = Element
Port(id, position, direction, description)
Node(ports, potential)          Path(from_port, to_port, kind, through)
Anchor(id, position, direction) Slot(id, position, side, box)
Reference(standard, number, edition, form)
Symbol(name, kind, status, reference, elements, ports, nodes, paths, anchors, slots, pole_pitch, stretch)
Library(standard, title, number_pattern, symbols, vocabulary, required_slots, wires)    get(number), __iter__ (sorted by number), __len__
Finding(rule, severity, message, location, orientation)
Stretch(axis, at)               WireStyle(id, weight, style)

load_library(root) -> Library             read library.toml and symbols/*.toml, validate, resolve, flatten
load_bundle(json_path) -> Library         read a resolved bundle
orient(symbol, orientation) -> Symbol       ports, nodes, paths, anchors and slots all carried along
translate(symbol, dx, dy) -> Symbol         used by composition and repeat; consumers place symbols themselves
repeat(symbol, n) -> Symbol
stretch(symbol, axis, length, offsets=None) -> Symbol    section 7
table(row, n, axis, head=None) -> Symbol    section 7
wire(library, style_id, points) -> Symbol    the wire styles of library.toml
body_box(symbol) -> Box    keepout_box(symbol) -> Box    slot_box(slot) -> Box
lint(symbol, required_slots=()) -> tuple[Finding, ...]
to_svg(symbol, *, module_mm=2.5, annotate=False, texts=None, tint=None) -> str
write_build(library, root) -> tuple[Path, ...]    writes build/ and the bundle
stale_build(library, root) -> tuple[Path, ...]    files that differ from what write_build would write, stale extras included
init(root) -> tuple[Path, ...]    build(root) -> tuple[Path, ...]    check(root) -> tuple[Finding, ...]    the three verbs of the symdef command

annotate=True draws the grid, ports with ids and lanes, anchors, slot boxes, and both boxes. texts maps slot ids to sample strings so the gallery can show a filled-in symbol.

to_fragment_mm writes the same placeable <g> fragment as to_fragment, in millimetres, with no scale transform (D52). Each coordinate and radius is multiplied by module_mm. Stroke widths come from weights_mm, one entry per weight, dash lengths from dash_mm, and the font size from font_mm; these are the caller's millimetres. Text capitals are centred on the anchor by cap_ratio, the capital height as a fraction of the font size. The caller passes the font family. to_svg and to_fragment are unchanged.

to_svg, to_fragment and to_fragment_mm take the keyword tint, the colour of every fill = "tint" shape. It must be #rgb, #rrggbb or a letters-only name, or the call raises ValueError; with None a tinted shape draws fill="none". Output without a tinted shape does not change (D57).

A set declares wire styles in library.toml as [[wire]] tables with id, weight (normal | thick) and style (solid | dashed). wire returns an element symbol of one open polyline in that style, with no ports; points are Points or (x, y) pairs, at least two (D56).

A data repo's Python package is a thin wrapper: from my_symbols import LIBRARY, loaded from the bundled JSON shipped as package data.

What a drawing library does

from symdef import Orientation, keepout_box, orient, repeat
from my_symbols import LIBRARY

breaker = orient(repeat(LIBRARY.get("S00287"), 3), Orientation.R0)

# The placement is the consumer's own type. The symbol stays a definition.
placed = Placement(
    symbol=breaker,
    at=Point(40, 120),
    tag="-Q1",
    markings={"1.in": "1", "1.out": "2", "2.in": "3", "2.out": "4", "3.in": "5", "3.out": "6"},
)

12. Repo layout, build and gates

symdef/                                  standard-agnostic toolkit
  src/symdef/
    schema/symbol.schema.json            the published contract
    model.py geometry.py units.py        types
    load.py resolve.py vocabulary.py     TOML and JSON in, flattening, declared words
    orient.py repeat.py boxes.py         pure operations
    lint/                                one module per rule group
    svg.py                               rendering
    files.py project.py cli.py           the impure modules: files, the three verbs, the command
  tests/fixtures/broken/                 one failing file per lint rule

my-symbols/                              a data repo: data only (`symdef init` writes it)
  library.toml                           standard, title, number_pattern, vocabulary, rules
  symbols/resistor.toml ...              source of truth
  src/mysymbols/bundle.json              generated, tracked
  build/                                 generated by `symdef build`, tracked
    resolved/  svg/  annotated/  README.md

The toolkit knows nothing about any standard. Everything standard-specific is in the data repo's library.toml, including its words and its required slots; an optional package key names the folder under src/ that holds the bundle:

standard = "IEC 60617"
title = "IEC 60617 symbols"
number_pattern = '^S\d{5}$'

[vocabulary]
path_kinds = ["conductor", "switch_open", "switch_closed", "impedance", "source", "diode"]
potentials = ["earth", "protective_earth", "functional_earth", "frame"]
links = ["mechanical_link"]

[rules]
required_slots = ["tag", "marking.<port>"]

Any other standard is a sibling data repo that reuses the toolkit and every gate.

Gates in a data repo (symdef check)

Gates in the toolkit

Constraints carried over

Python >=3.14, uv only, every dependency pinned to the exact latest stable version, ruff, ty, pytest with hypothesis, vulture. There is no runtime dependency. TOML is read with the standard library's tomllib. Geometry is unverified until compared with the IEC database drawings; the gallery shows the status next to each symbol.

13. What is not built

14. Deferred and open