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, 2026-10-15 for 0.7.0, 2026-10-15 for 0.8.0, 2026-10-16 for 0.9.0 and 2026-10-16 for 0.10.0 and 2026-10-16 for 0.10.1 and 2026-10-16 for 0.11.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-160.13.0New measure_font (a regular and a bold TTF, as bytes, into FontMetrics); [font] may name files, which symdef build measures; Library.with_settings replaces module, font, heights and line widths and runs text-outside-body; [lines] and Library.lines give a width in millimetres per weight (D87 to D89).
2026-10-160.12.0symdef raises its own errors under SymdefError (InvalidValueError, UnknownNameError, UnknownSymbolError, LibraryError; none subclasses a builtin) (D80). New whole-division types GridPoint, GridBox and GridPort (D81). New measure(symbol, orientation, library, texts=None, text_height=None) returning Measure; FontMetrics.advances is one table per weight and thick_factor is gone (D82). New to_placed_fragment; to_fragment_mm is listed for removal in 0.13 (D83). The authoring names (build, check, init, lint, write_build, stale_build, RULES, finding_key, LibraryConfig) move to symdef.authoring (D84). surface.toml and docs/CHANGELOG.md (D85). Grid.wire_pitch and wire_pitch_fine with the loader checks, and FontMetrics.heights from [font.heights] (D86).
2026-10-160.11.0A library declares [grid] (module_mm, divisions_per_module) and [font] (name, metrics and advances) in library.toml, and the bundle carries them: Library.font and Library.grid. New: FontMetrics, text_width, Grid, slot_text, TextPlacement, to_divisions, from_divisions, ceil_divisions, orient_box, orient_point and orient_direction (now public), Box.union, slots_box, pole_pitch, pole_port and nodes_of (now public). body_box, keepout_box and slot_box take font=None (slot_box also text and height), and lint takes grid; with none, output is byte-identical. load_library and load_bundle return read-only symbols (D73 to D78).
2026-10-160.10.1A non-C slot keeps its position when its box spans a cut: the position moves by the shares of the cuts it lies beyond and the box grows, and only a C slot sits at the middle of its stretched box (D72).
2026-10-160.10.0A symbol may carry several [[stretch]] cuts per axis, each with a weight (default 1) that sets its share of the growth; slots follow the cuts, so a slot box across a cut grows by that cut's share; slot side C centres the box and the text on at; a line or polyline may say between = true, and table() draws it in rows 1 to n-1 only (D67 to D71). The rule count stays 35.
2026-10-160.9.0to_fragment and to_fragment_mm take texts, the string to draw in each slot, and required_slots, the slots that must have one; a slot may say turn = true to read bottom-up. symdef never measures, shrinks or wraps a string (D63 to D66). The rule count stays 35.
2026-10-150.8.0leads(symbol, ports) drops the bound leads of the ports the caller leaves unwired; style = "dash_dot" draws a dash-dot line, and to_fragment_mm takes its four lengths in dash_dot_mm; to_fragment and to_fragment_mm take muted, the colour of a symbol drawn as not installed (D59 to D62). The rule count stays 35.
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, dashed or dash_dot. Dashed means dash 0.5 M, gap 0.25 M; dash-dot means dash 1 M, gap 0.25 M, dot 0.0625 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), at (number) and optional weight (number above 0, default 1). Several per axis are allowed, each at its own at. The cut lines 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 | dash_dot). 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. Optional on line and polyline: between (boolean, default false), for a tick that table() draws in rows 1 to n-1 and leaves out of row n; a symbol drawn on its own shows it as authored (section 7).

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|C", box = [w, h] } with w and h positive, plus an optional turn = true. Text is upright unless the slot is turned (below). 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
Cx in [x - w/2, x + w/2], y in [y - h/2, y + h/2]middle, the capital height centred on at

Drawing slot text. to_fragment and to_fragment_mm draw the strings the caller passes in texts (slot id to string), one <text> per slot in slot id order, after the elements. The alignment is the table above, and the baseline of a string sits half a capital below at for E and W, on at for N one capital below it for S and half a capital below it for C, which centres the string on at both ways. The font is the caller's: to_fragment_mm draws at font_mm in font_family, and symdef never measures, shrinks or wraps a string. to_fragment sizes a string to fit the box, at most 1 M. An empty string, or no string, draws nothing. A key that is no slot id of the symbol raises KeyError; a slot named in required_slots (entries as in lint, marking.<port> meaning one per port) that has no key raises ValueError, and an empty string satisfies it. The text is black, or in the muted colour, with no class.

Turned slots. With turn = true the string reads bottom-up, along the height of box, which is the footprint as drawn. Along the reading direction the side that points along it anchors the text: N starts at at, S ends there, E and W centre the text on it, with the box beside it, and C centres it across the reading line as well, the baseline half a capital east of at. The text element carries rotate(-90) about its baseline point, the only transform symdef writes. turn is fixed to the page: orient moves at and side but never changes turn, and text is never mirrored. A text that must follow a rotated symbol is a variant symbol with its own slot. A turned box wider than it is tall is allowed; fitting is the caller's check.

Orientation. at transforms as a point and side as a direction; C has no direction and stays. 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. A between element of the row is drawn in rows 1 to n-1 and left out of row n, so a single row shows none; a between element of the head is drawn as authored. The result has no stretch and keeps the row's pole_pitch (D54, D70).

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

Shares length minus the natural extent among the symbol's stretch cuts on axis in proportion to their weight, and moves each point by the shares of the cuts it lies beyond: element points, ports, anchors and slot corners. A line or polyline across a cut grows by that cut's share. A slot box is two corners: each moves like a point, so a box across a cut grows by that cut's share, and the position of a C slot stays the centre of its box. With one cut the whole growth goes to it, whatever its weight. offsets is unchanged and applies after the shares. A circle, arc or text across any 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. Two cuts at the same place on one axis are a stretch-cut finding. The result has no stretch on that axis, so a frame is stretched on both axes by two calls (D53).

leads(symbol, ports): the wired ports' leads

Returns the symbol without every Line whose port is set and is not in ports, an iterable of port ids. A line with no port, a line bound to a listed port and every other element stay, in order; ports, anchors, slots and every other field are unchanged. A listed id that names no port of the symbol raises KeyError naming it; a listed port with no bound lead is not an error. After table or repeat the ids are the prefixed ones (1.a). The caller decides which ports are wired; the toolkit knows no net (D59).

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 two cuts on one axis lie at the same at
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   Centre: C   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, weight)      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
leads(symbol, ports) -> 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

to_fragment and to_fragment_mm also take the keywords texts: Mapping[str, str] | None = None and required_slots: Sequence[str] = (); section 6 says what they draw and raise (D63 to D65). With texts=None the output is that of 0.8.0.

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, but for the rotate(-90) of a turned slot's text (D52, D65). 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. A dash_dot element needs the keyword dash_dot_mm, the four lengths in mm of dash, gap, dot and gap; to_fragment_mm raises ValueError when one is drawn without it (D60). 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).

to_fragment and to_fragment_mm also take the keyword muted, a colour string checked like tint, or None (the default, which draws as before). A muted symbol is drawn as not installed: every stroke, every text and every solid or tint fill in that colour, every line style replaced by a dash and a gap each as long as the normal stroke width, and no stroke on a text; an unfilled shape stays unfilled. dash_mm and dash_dot_mm are then not used (D61).

A set declares wire styles in library.toml as [[wire]] tables with id, weight (normal | thick) and style (solid | dashed | dash_dot). 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