symdef 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- symdef/__init__.py +97 -0
- symdef/_version.py +3 -0
- symdef/boxes.py +112 -0
- symdef/build.py +207 -0
- symdef/docs/SYMBOL_INTERFACE.html +573 -0
- symdef/errors.py +23 -0
- symdef/gallery.py +85 -0
- symdef/geometry.py +209 -0
- symdef/lint/__init__.py +141 -0
- symdef/lint/anchors.py +40 -0
- symdef/lint/connectivity.py +186 -0
- symdef/lint/exemptions.py +56 -0
- symdef/lint/file.py +124 -0
- symdef/lint/geometry.py +269 -0
- symdef/lint/overlap.py +274 -0
- symdef/lint/ports.py +253 -0
- symdef/lint/registry.py +172 -0
- symdef/lint/slots.py +136 -0
- symdef/load.py +621 -0
- symdef/model.py +248 -0
- symdef/orient.py +151 -0
- symdef/py.typed +0 -0
- symdef/repeat.py +99 -0
- symdef/resolve.py +586 -0
- symdef/serialize.py +239 -0
- symdef/svg.py +505 -0
- symdef/units.py +18 -0
- symdef-0.4.0.dist-info/METADATA +114 -0
- symdef-0.4.0.dist-info/RECORD +31 -0
- symdef-0.4.0.dist-info/WHEEL +4 -0
- symdef-0.4.0.dist-info/licenses/LICENSE +21 -0
symdef/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Standard-agnostic core for graphical symbols."""
|
|
2
|
+
|
|
3
|
+
from symdef._version import LIBRARY_VERSION
|
|
4
|
+
from symdef.boxes import body_box, keepout_box, slot_box
|
|
5
|
+
from symdef.build import load_bundle, load_library, stale_build, write_build
|
|
6
|
+
from symdef.errors import GraphicalSymbolsError, LibraryError, UnknownSymbolError
|
|
7
|
+
from symdef.geometry import (
|
|
8
|
+
Arc,
|
|
9
|
+
Box,
|
|
10
|
+
Circle,
|
|
11
|
+
Direction,
|
|
12
|
+
Element,
|
|
13
|
+
Fill,
|
|
14
|
+
Line,
|
|
15
|
+
Orientation,
|
|
16
|
+
Point,
|
|
17
|
+
Polyline,
|
|
18
|
+
Style,
|
|
19
|
+
Text,
|
|
20
|
+
Weight,
|
|
21
|
+
)
|
|
22
|
+
from symdef.lint import lint
|
|
23
|
+
from symdef.model import (
|
|
24
|
+
Allow,
|
|
25
|
+
Anchor,
|
|
26
|
+
Finding,
|
|
27
|
+
Library,
|
|
28
|
+
LibraryConfig,
|
|
29
|
+
Node,
|
|
30
|
+
Path,
|
|
31
|
+
PathKind,
|
|
32
|
+
Port,
|
|
33
|
+
Potential,
|
|
34
|
+
Reference,
|
|
35
|
+
Severity,
|
|
36
|
+
Slot,
|
|
37
|
+
Status,
|
|
38
|
+
Symbol,
|
|
39
|
+
SymbolKind,
|
|
40
|
+
)
|
|
41
|
+
from symdef.orient import orient, translate
|
|
42
|
+
from symdef.repeat import repeat
|
|
43
|
+
from symdef.svg import to_fragment, to_svg
|
|
44
|
+
from symdef.units import DEFAULT_MODULE_MM, GRID_DIVISION, on_grid, snap
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"DEFAULT_MODULE_MM",
|
|
48
|
+
"GRID_DIVISION",
|
|
49
|
+
"LIBRARY_VERSION",
|
|
50
|
+
"Allow",
|
|
51
|
+
"Anchor",
|
|
52
|
+
"Arc",
|
|
53
|
+
"Box",
|
|
54
|
+
"Circle",
|
|
55
|
+
"Direction",
|
|
56
|
+
"Element",
|
|
57
|
+
"Fill",
|
|
58
|
+
"Finding",
|
|
59
|
+
"GraphicalSymbolsError",
|
|
60
|
+
"Library",
|
|
61
|
+
"LibraryConfig",
|
|
62
|
+
"LibraryError",
|
|
63
|
+
"Line",
|
|
64
|
+
"Node",
|
|
65
|
+
"Orientation",
|
|
66
|
+
"Path",
|
|
67
|
+
"PathKind",
|
|
68
|
+
"Point",
|
|
69
|
+
"Polyline",
|
|
70
|
+
"Port",
|
|
71
|
+
"Potential",
|
|
72
|
+
"Reference",
|
|
73
|
+
"Severity",
|
|
74
|
+
"Slot",
|
|
75
|
+
"Status",
|
|
76
|
+
"Style",
|
|
77
|
+
"Symbol",
|
|
78
|
+
"SymbolKind",
|
|
79
|
+
"Text",
|
|
80
|
+
"UnknownSymbolError",
|
|
81
|
+
"Weight",
|
|
82
|
+
"body_box",
|
|
83
|
+
"keepout_box",
|
|
84
|
+
"lint",
|
|
85
|
+
"load_bundle",
|
|
86
|
+
"load_library",
|
|
87
|
+
"on_grid",
|
|
88
|
+
"orient",
|
|
89
|
+
"repeat",
|
|
90
|
+
"slot_box",
|
|
91
|
+
"snap",
|
|
92
|
+
"stale_build",
|
|
93
|
+
"to_fragment",
|
|
94
|
+
"to_svg",
|
|
95
|
+
"translate",
|
|
96
|
+
"write_build",
|
|
97
|
+
]
|
symdef/_version.py
ADDED
symdef/boxes.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The body, slot and keep-out boxes of a symbol."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import deal
|
|
6
|
+
|
|
7
|
+
from symdef.geometry import (
|
|
8
|
+
Arc,
|
|
9
|
+
Box,
|
|
10
|
+
Circle,
|
|
11
|
+
Direction,
|
|
12
|
+
Element,
|
|
13
|
+
Line,
|
|
14
|
+
Point,
|
|
15
|
+
Polyline,
|
|
16
|
+
Text,
|
|
17
|
+
arc_point,
|
|
18
|
+
arc_sweep,
|
|
19
|
+
)
|
|
20
|
+
from symdef.model import Slot, Symbol
|
|
21
|
+
|
|
22
|
+
_AXIS_ANGLES = (0, 90, 180, 270)
|
|
23
|
+
_FULL_TURN = 360
|
|
24
|
+
_TEXT_WIDTH_PER_HEIGHT = 0.6
|
|
25
|
+
_ORIGIN_BOX = Box(Point(0, 0), Point(0, 0))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@deal.pure
|
|
29
|
+
def _extent_points(element: Element) -> tuple[Point, ...]:
|
|
30
|
+
"""Return points whose bounding box is the element's extent, ignoring stroke width.
|
|
31
|
+
|
|
32
|
+
An arc with a non-finite angle has no swept extent and counts as its centre.
|
|
33
|
+
"""
|
|
34
|
+
match element:
|
|
35
|
+
case Line(start=start, end=end):
|
|
36
|
+
return (start, end)
|
|
37
|
+
case Polyline(points=points):
|
|
38
|
+
return points
|
|
39
|
+
case Circle(center=c, radius=r):
|
|
40
|
+
return (Point(c.x - r, c.y - r), Point(c.x + r, c.y + r))
|
|
41
|
+
case Arc() as arc:
|
|
42
|
+
if not (math.isfinite(arc.start_deg) and math.isfinite(arc.end_deg)):
|
|
43
|
+
return (arc.center,)
|
|
44
|
+
start, end = arc.start_deg % _FULL_TURN, arc.end_deg % _FULL_TURN
|
|
45
|
+
sweep = arc_sweep(arc)
|
|
46
|
+
extremes = tuple(a for a in _AXIS_ANGLES if (a - start) % _FULL_TURN <= sweep)
|
|
47
|
+
return tuple(arc_point(arc, a) for a in (start, end, *extremes))
|
|
48
|
+
case Text(content=content, position=at, height=height):
|
|
49
|
+
half_w = _TEXT_WIDTH_PER_HEIGHT * height * len(content) / 2
|
|
50
|
+
return (
|
|
51
|
+
Point(at.x - half_w, at.y - height / 2),
|
|
52
|
+
Point(at.x + half_w, at.y + height / 2),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@deal.pure
|
|
57
|
+
def _bounding(points: tuple[Point, ...]) -> Box:
|
|
58
|
+
"""Return the smallest box holding every point; no points give the origin box."""
|
|
59
|
+
if not points:
|
|
60
|
+
return _ORIGIN_BOX
|
|
61
|
+
xs = [p.x for p in points]
|
|
62
|
+
ys = [p.y for p in points]
|
|
63
|
+
return Box(Point(min(xs), min(ys)), Point(max(xs), max(ys)))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@deal.pure
|
|
67
|
+
def element_box(element: Element) -> Box:
|
|
68
|
+
"""Return the extent of one element.
|
|
69
|
+
|
|
70
|
+
A line or polyline extends over its points, a circle over its full diameter, an arc over its
|
|
71
|
+
true swept extent, and text is a box centred on its position, `height` tall and `0.6 * height`
|
|
72
|
+
per character wide. Stroke width is ignored. A polyline without points has the origin box.
|
|
73
|
+
"""
|
|
74
|
+
return _bounding(_extent_points(element))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@deal.pure
|
|
78
|
+
def body_box(symbol: Symbol) -> Box:
|
|
79
|
+
"""Return the union of the element extents, in module units, ignoring stroke width.
|
|
80
|
+
|
|
81
|
+
Extents are as for text (`0.6 * height` per character), arcs (the true swept extent) and the
|
|
82
|
+
other elements. A symbol without elements has the box (0, 0) to (0, 0). Ports, anchors and
|
|
83
|
+
slots do not count.
|
|
84
|
+
"""
|
|
85
|
+
return _bounding(tuple(p for e in symbol.elements for p in _extent_points(e)))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@deal.pure
|
|
89
|
+
def slot_box(slot: Slot) -> Box:
|
|
90
|
+
"""Return the box a slot reserves: it grows from the slot point towards its side.
|
|
91
|
+
|
|
92
|
+
E and W grow horizontally and are vertically centred; N grows up and S grows down and both
|
|
93
|
+
are horizontally centred. The box is never rotated; only the point and side are.
|
|
94
|
+
"""
|
|
95
|
+
x, y = slot.position.x, slot.position.y
|
|
96
|
+
w, h = slot.box
|
|
97
|
+
match slot.side:
|
|
98
|
+
case Direction.E:
|
|
99
|
+
return Box(Point(x, y - h / 2), Point(x + w, y + h / 2))
|
|
100
|
+
case Direction.W:
|
|
101
|
+
return Box(Point(x - w, y - h / 2), Point(x, y + h / 2))
|
|
102
|
+
case Direction.N:
|
|
103
|
+
return Box(Point(x - w / 2, y - h), Point(x + w / 2, y))
|
|
104
|
+
case Direction.S:
|
|
105
|
+
return Box(Point(x - w / 2, y), Point(x + w / 2, y + h))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@deal.pure
|
|
109
|
+
def keepout_box(symbol: Symbol) -> Box:
|
|
110
|
+
"""Return the body box united with every slot box: the room the symbol needs kept clear."""
|
|
111
|
+
boxes = (body_box(symbol), *(slot_box(s) for s in symbol.slots))
|
|
112
|
+
return _bounding(tuple(p for b in boxes for p in (b.min, b.max)))
|
symdef/build.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""The impure shell: everything that reads or writes files. Pure work stays in the other modules."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from symdef.errors import LibraryError
|
|
8
|
+
from symdef.lint.registry import rule_finding
|
|
9
|
+
from symdef.load import (
|
|
10
|
+
is_file_stem,
|
|
11
|
+
library_from_bundle,
|
|
12
|
+
parse_config,
|
|
13
|
+
parse_json,
|
|
14
|
+
parse_toml,
|
|
15
|
+
validate_bundle,
|
|
16
|
+
)
|
|
17
|
+
from symdef.model import Finding, Library, LibraryConfig
|
|
18
|
+
from symdef.resolve import resolve_library
|
|
19
|
+
from symdef.serialize import GENERATED_DIRS, build_files, package_folder
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _named(name: str, findings: tuple[Finding, ...]) -> tuple[Finding, ...]:
|
|
23
|
+
"""Prefix each message with the file it is about, since a finding has no file of its own."""
|
|
24
|
+
return tuple(replace(f, message=f"{name}: {f.message}") for f in findings)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read(path: Path) -> tuple[str | None, tuple[Finding, ...]]:
|
|
28
|
+
"""Read a UTF-8 text file; one that cannot be read is a `schema` finding located at its name."""
|
|
29
|
+
try:
|
|
30
|
+
return path.read_text(encoding="utf-8"), ()
|
|
31
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
32
|
+
return None, _named(path.name, (rule_finding("schema", str(error), path.name),))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_config(path: Path) -> tuple[LibraryConfig | None, tuple[Finding, ...]]:
|
|
36
|
+
"""Read `library.toml`."""
|
|
37
|
+
text, found = _read(path)
|
|
38
|
+
if text is None:
|
|
39
|
+
return None, found
|
|
40
|
+
config, problems = parse_config(text)
|
|
41
|
+
return config, _named(path.name, problems)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_symbol(path: Path) -> tuple[dict[str, Any] | None, tuple[Finding, ...]]:
|
|
45
|
+
"""Read one symbol file's TOML."""
|
|
46
|
+
text, found = _read(path)
|
|
47
|
+
if text is None:
|
|
48
|
+
return None, found
|
|
49
|
+
data, problems = parse_toml(text)
|
|
50
|
+
return data, _named(path.name, problems)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_library(root: Path) -> Library:
|
|
54
|
+
"""Read `library.toml` and `symbols/*.toml` under `root`, validate, resolve and flatten them.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
root: The data repo's directory.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The library, its symbols keyed by reference number.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
LibraryError: If a file cannot be read or fails any check. `findings` holds every
|
|
64
|
+
finding, `library.toml` first and then the symbol files in name order, each message
|
|
65
|
+
prefixed with its file name.
|
|
66
|
+
"""
|
|
67
|
+
config, problems = _read_config(root / "library.toml")
|
|
68
|
+
directory = root / "symbols"
|
|
69
|
+
if not directory.is_dir():
|
|
70
|
+
missing = rule_finding("schema", "the directory does not exist", directory.name)
|
|
71
|
+
raise LibraryError((*problems, *_named(directory.name, (missing,))))
|
|
72
|
+
per_file: dict[str, tuple[Finding, ...]] = {}
|
|
73
|
+
sources: dict[str, dict[str, Any]] = {}
|
|
74
|
+
for path in sorted(directory.glob("*.toml"), key=lambda p: p.stem):
|
|
75
|
+
data, found = _read_symbol(path)
|
|
76
|
+
if data is None:
|
|
77
|
+
per_file[path.stem] = found
|
|
78
|
+
else:
|
|
79
|
+
sources[path.stem] = data
|
|
80
|
+
if config is None:
|
|
81
|
+
raise LibraryError((*problems, *(f for stem in sorted(per_file) for f in per_file[stem])))
|
|
82
|
+
resolution = resolve_library(config, sources)
|
|
83
|
+
per_file |= {stem: _named(f"{stem}.toml", found) for stem, found in resolution.findings.items()}
|
|
84
|
+
problems += tuple(f for stem in sorted(per_file) for f in per_file[stem])
|
|
85
|
+
if problems:
|
|
86
|
+
raise LibraryError(problems)
|
|
87
|
+
return Library(config.standard, config.title, config.number_pattern, resolution.symbols)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_bundle(json_path: Path) -> Library:
|
|
91
|
+
"""Read a resolved bundle (`bundle.json`) into a library.
|
|
92
|
+
|
|
93
|
+
The library has the bundle's standard for both `standard` and `title`, and no number pattern
|
|
94
|
+
(D7). Symbols are checked like a source file, plus the resolved form (`validate_bundle`).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
json_path: The bundle file.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The library, its symbols keyed by the bundle's keys.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
LibraryError: If the file cannot be read, is not JSON or is not a valid bundle. Every
|
|
104
|
+
finding is a `schema` finding, its message prefixed with the file name.
|
|
105
|
+
"""
|
|
106
|
+
text, found = _read(json_path)
|
|
107
|
+
if text is None:
|
|
108
|
+
raise LibraryError(found)
|
|
109
|
+
data, problems = parse_json(text)
|
|
110
|
+
if not problems:
|
|
111
|
+
problems = validate_bundle(data)
|
|
112
|
+
if problems:
|
|
113
|
+
raise LibraryError(_named(json_path.name, problems))
|
|
114
|
+
return library_from_bundle(data)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _package_key(root: Path) -> str:
|
|
118
|
+
"""Return the `package` key of `root/library.toml`, or `""` when it is absent or unreadable.
|
|
119
|
+
|
|
120
|
+
`load_library` is where a bad `library.toml` is reported; a build only reads the one key.
|
|
121
|
+
"""
|
|
122
|
+
config, _ = _read_config(root / "library.toml")
|
|
123
|
+
return config.package if config else ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_names(library: Library, package: str) -> None:
|
|
127
|
+
"""Refuse a library that would need an unsafe file or directory name, before any file is made.
|
|
128
|
+
|
|
129
|
+
A number must be a file stem, and the standard must give a package folder (D8, D35, D45).
|
|
130
|
+
"""
|
|
131
|
+
for number in library.symbols:
|
|
132
|
+
if not is_file_stem(number):
|
|
133
|
+
msg = f"symbol number {number!r} cannot be used as a file name"
|
|
134
|
+
raise ValueError(msg)
|
|
135
|
+
if not package_folder(library.standard, package):
|
|
136
|
+
msg = f"the standard {library.standard!r} has no letter or digit to make a package name of"
|
|
137
|
+
raise ValueError(msg)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_build(library: Library, root: Path) -> tuple[Path, ...]:
|
|
141
|
+
"""Write the build of a library under a data repo: `build/` and `src/<package>/bundle.json`.
|
|
142
|
+
|
|
143
|
+
`<package>` is the `package` key of `root/library.toml` when it has one, else made from the
|
|
144
|
+
standard (D8, D45).
|
|
145
|
+
|
|
146
|
+
Files are written as bytes, so line endings are LF on every platform, and missing directories
|
|
147
|
+
are created. Nothing is deleted: a file that an earlier build wrote and this one does not
|
|
148
|
+
(a removed symbol) stays, and `stale_build` reports it; removing it is the caller's job.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
library: The library to build.
|
|
152
|
+
root: The data repo's directory.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Every path written, as `root / <relative path>` (so relative if `root` is), in the order
|
|
156
|
+
of their `/`-separated relative paths.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If a symbol number is not a file stem (`is_file_stem`) or the standard has no
|
|
160
|
+
letter or digit to make a package name of; nothing is written.
|
|
161
|
+
OSError: If a path cannot be written; files already written stay.
|
|
162
|
+
"""
|
|
163
|
+
package = _package_key(root)
|
|
164
|
+
_check_names(library, package)
|
|
165
|
+
written = []
|
|
166
|
+
for relative, data in build_files(library, package).items():
|
|
167
|
+
target = root / relative
|
|
168
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
target.write_bytes(data)
|
|
170
|
+
written.append(target)
|
|
171
|
+
return tuple(written)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def stale_build(library: Library, root: Path) -> tuple[Path, ...]:
|
|
175
|
+
"""Return the files under `root` that differ from what `write_build` would write.
|
|
176
|
+
|
|
177
|
+
A file is stale if it is missing or has other bytes, or if it sits anywhere under
|
|
178
|
+
`build/resolved`, `build/svg` or `build/annotated` and the library does not produce it.
|
|
179
|
+
Nothing else under `root` is looked at.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
library: The library the build should match.
|
|
183
|
+
root: The data repo's directory.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
The stale paths, as `root / <relative path>` (so relative if `root` is), in the order of
|
|
187
|
+
their `/`-separated relative paths.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
ValueError: If a symbol number is not a file stem (`is_file_stem`) or the standard has no
|
|
191
|
+
letter or digit to make a package name of.
|
|
192
|
+
OSError: If a file cannot be read; it is not treated as stale.
|
|
193
|
+
"""
|
|
194
|
+
package = _package_key(root)
|
|
195
|
+
_check_names(library, package)
|
|
196
|
+
files = build_files(library, package)
|
|
197
|
+
stale = {
|
|
198
|
+
relative
|
|
199
|
+
for relative, data in files.items()
|
|
200
|
+
if not (root / relative).is_file() or (root / relative).read_bytes() != data
|
|
201
|
+
}
|
|
202
|
+
for directory in GENERATED_DIRS:
|
|
203
|
+
for path in (root / directory).rglob("*"):
|
|
204
|
+
relative = path.relative_to(root).as_posix()
|
|
205
|
+
if path.is_file() and relative not in files:
|
|
206
|
+
stale.add(relative)
|
|
207
|
+
return tuple(root / relative for relative in sorted(stale))
|