tesserax 0.5.1__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.
- tesserax/__init__.py +16 -0
- tesserax/align.py +0 -0
- tesserax/base.py +459 -0
- tesserax/canvas.py +109 -0
- tesserax/core.py +236 -0
- tesserax/layout.py +355 -0
- tesserax/py.typed +0 -0
- tesserax-0.5.1.dist-info/METADATA +135 -0
- tesserax-0.5.1.dist-info/RECORD +11 -0
- tesserax-0.5.1.dist-info/WHEEL +4 -0
- tesserax-0.5.1.dist-info/licenses/LICENSE +21 -0
tesserax/__init__.py
ADDED
tesserax/align.py
ADDED
|
File without changes
|
tesserax/base.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Callable, Literal, Self
|
|
3
|
+
from .core import Anchor, Point, Shape, Bounds
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Rect(Shape):
|
|
7
|
+
"""A rectangular shape, the foundation for arrays and memory blocks."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
w: float,
|
|
12
|
+
h: float,
|
|
13
|
+
stroke: str = "black",
|
|
14
|
+
fill: str = "none",
|
|
15
|
+
) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.w, self.h = w, h
|
|
18
|
+
self.stroke, self.fill = stroke, fill
|
|
19
|
+
|
|
20
|
+
def local(self) -> Bounds:
|
|
21
|
+
return Bounds(0, 0, self.w, self.h)
|
|
22
|
+
|
|
23
|
+
def _render(self) -> str:
|
|
24
|
+
return f'<rect x="0" y="0" width="{self.w}" height="{self.h}" stroke="{self.stroke}" fill="{self.fill}" />'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Square(Rect):
|
|
28
|
+
"""A specialized Rect where width equals height."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, size: float, stroke: str = "black", fill: str = "none") -> None:
|
|
31
|
+
super().__init__(size, size, stroke, fill)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Circle(Shape):
|
|
35
|
+
"""A circle, ideal for nodes in trees or states in automata."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, r: float, stroke: str = "black", fill: str = "none") -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.r = r
|
|
40
|
+
self.stroke, self.fill = stroke, fill
|
|
41
|
+
|
|
42
|
+
def local(self) -> Bounds:
|
|
43
|
+
return Bounds(-self.r, -self.r, self.r * 2, self.r * 2)
|
|
44
|
+
|
|
45
|
+
def _render(self) -> str:
|
|
46
|
+
return f'<circle cx="0" cy="0" r="{self.r}" stroke="{self.stroke}" fill="{self.fill}" />'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Ellipse(Shape):
|
|
50
|
+
"""An ellipse for when text labels are wider than they are tall."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
rx: float,
|
|
55
|
+
ry: float,
|
|
56
|
+
stroke: str = "black",
|
|
57
|
+
fill: str = "none",
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__()
|
|
60
|
+
self.rx, self.ry = rx, ry
|
|
61
|
+
self.stroke, self.fill = stroke, fill
|
|
62
|
+
|
|
63
|
+
def local(self) -> Bounds:
|
|
64
|
+
return Bounds(-self.rx, -self.ry, self.rx * 2, self.ry * 2)
|
|
65
|
+
|
|
66
|
+
def _render(self) -> str:
|
|
67
|
+
return f'<ellipse cx="0" cy="0" rx="{self.rx}" ry="{self.ry}" stroke="{self.stroke}" fill="{self.fill}" />'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Line(Shape):
|
|
71
|
+
"""A basic connection between two points, supports dynamic point resolution."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
p1: Point | Callable[[], Point],
|
|
76
|
+
p2: Point | Callable[[], Point],
|
|
77
|
+
stroke: str = "black",
|
|
78
|
+
width: float = 1.0,
|
|
79
|
+
) -> None:
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.p1, self.p2 = p1, p2
|
|
82
|
+
self.stroke, self.width = stroke, width
|
|
83
|
+
|
|
84
|
+
def _resolve(self) -> tuple[Point, Point]:
|
|
85
|
+
"""Resolves coordinates if they are provided as callables."""
|
|
86
|
+
p1 = self.p1() if callable(self.p1) else self.p1
|
|
87
|
+
p2 = self.p2() if callable(self.p2) else self.p2
|
|
88
|
+
return p1, p2
|
|
89
|
+
|
|
90
|
+
def local(self) -> Bounds:
|
|
91
|
+
p1, p2 = self._resolve()
|
|
92
|
+
x = min(p1.x, p2.x)
|
|
93
|
+
y = min(p1.y, p2.y)
|
|
94
|
+
return Bounds(x, y, abs(p1.x - p2.x), abs(p1.y - p2.y))
|
|
95
|
+
|
|
96
|
+
def _render(self) -> str:
|
|
97
|
+
p1, p2 = self._resolve()
|
|
98
|
+
return (
|
|
99
|
+
f'<line x1="{p1.x}" y1="{p1.y}" x2="{p2.x}" y2="{p2.y}" '
|
|
100
|
+
f'stroke="{self.stroke}" stroke-width="{self.width}" />'
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Arrow(Line):
|
|
105
|
+
"""A line with an arrowhead, resolving points dynamically during render."""
|
|
106
|
+
|
|
107
|
+
def _render(self) -> str:
|
|
108
|
+
p1, p2 = self._resolve()
|
|
109
|
+
return (
|
|
110
|
+
f'<line x1="{p1.x}" y1="{p1.y}" x2="{p2.x}" y2="{p2.y}" '
|
|
111
|
+
f'stroke="{self.stroke}" stroke-width="{self.width}" marker-end="url(#arrowhead)" />'
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Group(Shape):
|
|
116
|
+
stack: list[list[Shape]] = []
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def current(cls) -> list[Shape] | None:
|
|
120
|
+
if cls.stack:
|
|
121
|
+
return cls.stack[-1]
|
|
122
|
+
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
"""A collection of shapes that behaves as a single unit."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, shapes: list[Shape] | None = None) -> None:
|
|
128
|
+
super().__init__()
|
|
129
|
+
self.shapes: list[Shape] = []
|
|
130
|
+
|
|
131
|
+
if shapes:
|
|
132
|
+
self.add(*shapes)
|
|
133
|
+
|
|
134
|
+
def add(self, *shapes: Shape) -> Group:
|
|
135
|
+
"""Adds a shape and returns self for chaining."""
|
|
136
|
+
for shape in shapes:
|
|
137
|
+
if shape.parent:
|
|
138
|
+
raise ValueError("Cannot add one object to more than one group.")
|
|
139
|
+
|
|
140
|
+
self.shapes.append(shape)
|
|
141
|
+
shape.parent = self
|
|
142
|
+
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def local(self) -> Bounds:
|
|
146
|
+
"""Computes the union of all child bounds."""
|
|
147
|
+
if not self.shapes:
|
|
148
|
+
return Bounds(0, 0, 0, 0)
|
|
149
|
+
|
|
150
|
+
return Bounds.union(*[s.bounds() for s in self.shapes])
|
|
151
|
+
|
|
152
|
+
def _render(self) -> str:
|
|
153
|
+
return "\n".join(s.render() for s in self.shapes)
|
|
154
|
+
|
|
155
|
+
def __iadd__(self, other: Shape) -> Self:
|
|
156
|
+
"""Enables 'group += shape'."""
|
|
157
|
+
self.shapes.append(other)
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def __enter__(self):
|
|
161
|
+
self.stack.append([])
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
165
|
+
self.add(*self.stack.pop())
|
|
166
|
+
|
|
167
|
+
def align(
|
|
168
|
+
self,
|
|
169
|
+
axis: Literal["horizontal", "vertical", "both"] = "both",
|
|
170
|
+
anchor: Anchor = "center",
|
|
171
|
+
) -> Self:
|
|
172
|
+
"""
|
|
173
|
+
Aligns all children in the group relative to the anchor of the first child.
|
|
174
|
+
|
|
175
|
+
The alignment is performed in the group's local coordinate system by
|
|
176
|
+
adjusting the translation (tx, ty) of each shape.
|
|
177
|
+
"""
|
|
178
|
+
if not self.shapes:
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
# The first shape acts as the reference datum for the alignment
|
|
182
|
+
first = self.shapes[0]
|
|
183
|
+
ref_p = first.transform.map(first.local().anchor(anchor))
|
|
184
|
+
|
|
185
|
+
for shape in self.shapes[1:]:
|
|
186
|
+
# Calculate the child's anchor point in the group's coordinate system
|
|
187
|
+
curr_p = shape.transform.map(shape.local().anchor(anchor))
|
|
188
|
+
|
|
189
|
+
if axis in ("horizontal", "both"):
|
|
190
|
+
shape.transform.tx += ref_p.x - curr_p.x
|
|
191
|
+
|
|
192
|
+
if axis in ("vertical", "both"):
|
|
193
|
+
shape.transform.ty += ref_p.y - curr_p.y
|
|
194
|
+
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Path(Shape):
|
|
199
|
+
"""
|
|
200
|
+
A shape defined by an SVG path data string.
|
|
201
|
+
Maintains an internal cursor to support relative movements and
|
|
202
|
+
layout bounding box calculations.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(self, stroke: str = "black", width: float = 1) -> None:
|
|
206
|
+
super().__init__()
|
|
207
|
+
self.stroke = stroke
|
|
208
|
+
self.width = width
|
|
209
|
+
self._reset()
|
|
210
|
+
|
|
211
|
+
def _reset(self):
|
|
212
|
+
self._commands: list[str] = []
|
|
213
|
+
self._cursor: tuple[float, float] = (0.0, 0.0)
|
|
214
|
+
|
|
215
|
+
self._min_x: float = float("inf")
|
|
216
|
+
self._min_y: float = float("inf")
|
|
217
|
+
self._max_x: float = float("-inf")
|
|
218
|
+
self._max_y: float = float("-inf")
|
|
219
|
+
|
|
220
|
+
def local(self) -> Bounds:
|
|
221
|
+
"""
|
|
222
|
+
Returns the bounding box of the path in its local coordinate system.
|
|
223
|
+
"""
|
|
224
|
+
if not self._commands:
|
|
225
|
+
return Bounds(0, 0, 0, 0)
|
|
226
|
+
|
|
227
|
+
width = self._max_x - self._min_x
|
|
228
|
+
height = self._max_y - self._min_y
|
|
229
|
+
|
|
230
|
+
return Bounds(self._min_x, self._min_y, width, height)
|
|
231
|
+
|
|
232
|
+
def move_to(self, x: float, y: float) -> Self:
|
|
233
|
+
"""Moves the pen to the absolute coordinates (x, y)."""
|
|
234
|
+
self._commands.append(f"M {x} {y}")
|
|
235
|
+
self._update_cursor(x, y)
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
def move_by(self, dx: float, dy: float) -> Self:
|
|
239
|
+
"""Moves the pen relative to the current position."""
|
|
240
|
+
x, y = self._cursor
|
|
241
|
+
return self.move_to(x + dx, y + dy)
|
|
242
|
+
|
|
243
|
+
def line_to(self, x: float, y: float) -> Self:
|
|
244
|
+
"""Draws a straight line to the absolute coordinates (x, y)."""
|
|
245
|
+
self._commands.append(f"L {x} {y}")
|
|
246
|
+
self._update_cursor(x, y)
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
def line_by(self, dx: float, dy: float) -> Self:
|
|
250
|
+
"""Draws a line relative to the current position."""
|
|
251
|
+
x, y = self._cursor
|
|
252
|
+
return self.line_to(x + dx, y + dy)
|
|
253
|
+
|
|
254
|
+
def cubic_to(
|
|
255
|
+
self,
|
|
256
|
+
cp1_x: float,
|
|
257
|
+
cp1_y: float,
|
|
258
|
+
cp2_x: float,
|
|
259
|
+
cp2_y: float,
|
|
260
|
+
end_x: float,
|
|
261
|
+
end_y: float,
|
|
262
|
+
) -> Self:
|
|
263
|
+
"""
|
|
264
|
+
Draws a cubic Bezier curve to (end_x, end_y) using two control points.
|
|
265
|
+
"""
|
|
266
|
+
self._commands.append(f"C {cp1_x} {cp1_y}, {cp2_x} {cp2_y}, {end_x} {end_y}")
|
|
267
|
+
|
|
268
|
+
# We include control points in bounds to ensure the curve is
|
|
269
|
+
# roughly contained, even though this is a loose approximation.
|
|
270
|
+
self._expand_bounds(cp1_x, cp1_y)
|
|
271
|
+
self._expand_bounds(cp2_x, cp2_y)
|
|
272
|
+
self._update_cursor(end_x, end_y)
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
def quadratic_to(self, cx: float, cy: float, ex: float, ey: float) -> Self:
|
|
276
|
+
"""
|
|
277
|
+
Draws a quadratic Bezier curve to (ex, ey) with control point (cx, cy).
|
|
278
|
+
"""
|
|
279
|
+
self._commands.append(f"Q {cx} {cy}, {ex} {ey}")
|
|
280
|
+
self._expand_bounds(cx, cy) # Approximate bounds including control point
|
|
281
|
+
self._update_cursor(ex, ey)
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def close(self) -> Self:
|
|
285
|
+
"""Closes the path by drawing a line back to the start."""
|
|
286
|
+
self._commands.append("Z")
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
def _update_cursor(self, x: float, y: float) -> None:
|
|
290
|
+
"""Updates the internal cursor and expands the bounding box."""
|
|
291
|
+
self._cursor = (x, y)
|
|
292
|
+
self._expand_bounds(x, y)
|
|
293
|
+
|
|
294
|
+
def _expand_bounds(self, x: float, y: float) -> None:
|
|
295
|
+
"""Updates the min/max bounds of the shape."""
|
|
296
|
+
# Initialize bounds on first move if logic dictates,
|
|
297
|
+
# or rely on 0,0 default if paths always start at origin.
|
|
298
|
+
self._min_x = min(self._min_x, x)
|
|
299
|
+
self._min_y = min(self._min_y, y)
|
|
300
|
+
self._max_x = max(self._max_x, x)
|
|
301
|
+
self._max_y = max(self._max_y, y)
|
|
302
|
+
|
|
303
|
+
def _render(self) -> str:
|
|
304
|
+
"""Renders the standard SVG path element."""
|
|
305
|
+
# You might want to offset commands by self.x/self.y if
|
|
306
|
+
# this shape is moved by a Layout.
|
|
307
|
+
d_attr = " ".join(self._commands)
|
|
308
|
+
return f'<path d="{d_attr}" fill="none" stroke="{self.stroke}" stroke-width="{self.width}" />'
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class Polyline(Path):
|
|
312
|
+
"""
|
|
313
|
+
A sequence of connected lines with optional corner rounding.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
points: List of vertices.
|
|
317
|
+
smoothness: 0.0 (sharp) to 1.0 (fully rounded/spline-like).
|
|
318
|
+
closed: If True, connects the last point back to the first.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
def __init__(
|
|
322
|
+
self,
|
|
323
|
+
points: list[Point],
|
|
324
|
+
smoothness: float = 0.0,
|
|
325
|
+
closed: bool = False,
|
|
326
|
+
stroke: str = "black",
|
|
327
|
+
width: float = 1.0,
|
|
328
|
+
) -> None:
|
|
329
|
+
super().__init__(stroke=stroke, width=width)
|
|
330
|
+
|
|
331
|
+
self.points = points or []
|
|
332
|
+
self.smoothness = smoothness
|
|
333
|
+
self.closed = closed
|
|
334
|
+
self._build()
|
|
335
|
+
|
|
336
|
+
def add(self, p: Point) -> Self:
|
|
337
|
+
self.points.append(p)
|
|
338
|
+
return self
|
|
339
|
+
|
|
340
|
+
def _build(self):
|
|
341
|
+
self._reset()
|
|
342
|
+
|
|
343
|
+
if not self.points:
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
# Clamp smoothness to 0-1 range
|
|
347
|
+
s = max(0.0, min(1.0, self.smoothness))
|
|
348
|
+
|
|
349
|
+
# Determine effective loop of points
|
|
350
|
+
# If closed, we wrap around; if not, we handle start/end differently
|
|
351
|
+
verts = self.points + ([self.points[0], self.points[1]] if self.closed else [])
|
|
352
|
+
|
|
353
|
+
# 1. Move to the geometric start
|
|
354
|
+
# If smoothing is on and not self.closed, we start exactly at P0
|
|
355
|
+
# If closed, we start at the midpoint of the last segment (handled by loop)
|
|
356
|
+
self.move_to(verts[0].x, verts[0].y)
|
|
357
|
+
|
|
358
|
+
# We iterate through triplets: (Prev, Curr, Next)
|
|
359
|
+
# But for an open polyline, we only round the *internal* corners.
|
|
360
|
+
|
|
361
|
+
if len(verts) < 3:
|
|
362
|
+
# Fallback for simple line
|
|
363
|
+
for p in verts[1:]:
|
|
364
|
+
self.line_to(p.x, p.y)
|
|
365
|
+
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
# Logic for Open Polyline
|
|
369
|
+
# P0 -> ... -> Pn
|
|
370
|
+
# We start at P0.
|
|
371
|
+
# For every corner P_i, we draw a line to "Start of Curve", then curve to "End of Curve".
|
|
372
|
+
|
|
373
|
+
# Start
|
|
374
|
+
curr_p = verts[0]
|
|
375
|
+
self.move_to(curr_p.x, curr_p.y)
|
|
376
|
+
|
|
377
|
+
for i in range(1, len(verts) - 1):
|
|
378
|
+
prev_p = verts[i - 1]
|
|
379
|
+
curr_p = verts[i]
|
|
380
|
+
next_p = verts[i + 1]
|
|
381
|
+
|
|
382
|
+
# Vectors
|
|
383
|
+
vec_in = curr_p - prev_p
|
|
384
|
+
vec_out = next_p - curr_p
|
|
385
|
+
|
|
386
|
+
len_in = vec_in.magnitude()
|
|
387
|
+
len_out = vec_out.magnitude()
|
|
388
|
+
|
|
389
|
+
# Corner Radius determination
|
|
390
|
+
# We can't exceed 50% of the shortest leg, or curves will overlap
|
|
391
|
+
max_r = min(len_in, len_out) / 2.0
|
|
392
|
+
radius = max_r * s
|
|
393
|
+
|
|
394
|
+
# Calculate geometric points
|
|
395
|
+
# "Start of Curve" is back along the incoming vector
|
|
396
|
+
p_start = curr_p - vec_in.normalize() * radius
|
|
397
|
+
|
|
398
|
+
# "End of Curve" is forward along the outgoing vector
|
|
399
|
+
p_end = curr_p + vec_out.normalize() * radius
|
|
400
|
+
|
|
401
|
+
# Draw
|
|
402
|
+
self.line_to(p_start.x, p_start.y)
|
|
403
|
+
self.quadratic_to(curr_p.x, curr_p.y, p_end.x, p_end.y)
|
|
404
|
+
|
|
405
|
+
# Finish at the last point
|
|
406
|
+
last = verts[-1]
|
|
407
|
+
self.line_to(last.x, last.y)
|
|
408
|
+
|
|
409
|
+
if self.closed:
|
|
410
|
+
self.close()
|
|
411
|
+
|
|
412
|
+
def _render(self) -> str:
|
|
413
|
+
self._build()
|
|
414
|
+
return super()._render()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class Text(Shape):
|
|
418
|
+
"""
|
|
419
|
+
A text primitive with heuristic-based bounding box calculation.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
def __init__(
|
|
423
|
+
self,
|
|
424
|
+
content: str,
|
|
425
|
+
size: float = 12,
|
|
426
|
+
font: str = "sans-serif",
|
|
427
|
+
fill: str = "black",
|
|
428
|
+
anchor: Literal["start", "middle", "end"] = "middle",
|
|
429
|
+
) -> None:
|
|
430
|
+
super().__init__()
|
|
431
|
+
self.content = content
|
|
432
|
+
self.size = size
|
|
433
|
+
self.font = font
|
|
434
|
+
self.fill = fill
|
|
435
|
+
self._anchor = anchor
|
|
436
|
+
|
|
437
|
+
def local(self) -> Bounds:
|
|
438
|
+
# Heuristic: average character width is ~60% of font size
|
|
439
|
+
width = len(self.content) * self.size * 0.6
|
|
440
|
+
height = self.size
|
|
441
|
+
|
|
442
|
+
match self._anchor:
|
|
443
|
+
case "start":
|
|
444
|
+
return Bounds(0, -height + 2, width, height)
|
|
445
|
+
case "middle":
|
|
446
|
+
return Bounds(-width / 2, -height + 2, width, height)
|
|
447
|
+
case "end":
|
|
448
|
+
return Bounds(-width, -height + 2, width, height)
|
|
449
|
+
case _:
|
|
450
|
+
raise ValueError(f"Invalid anchor: {self._anchor}")
|
|
451
|
+
|
|
452
|
+
def _render(self) -> str:
|
|
453
|
+
# dominant-baseline="middle" or "alphabetic" helps vertical alignment
|
|
454
|
+
# but "central" is often more predictable for layout centers.
|
|
455
|
+
return (
|
|
456
|
+
f'<text x="0" y="0" font-family="{self.font}" font-size="{self.size}" '
|
|
457
|
+
f'fill="{self.fill}" text-anchor="{self._anchor}" dominant-baseline="middle">'
|
|
458
|
+
f"{self.content}</text>"
|
|
459
|
+
)
|
tesserax/canvas.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .core import Shape, Bounds
|
|
4
|
+
from .base import Group
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Canvas(Group):
|
|
8
|
+
def __init__(self, width: float = 1000, height: float = 1000) -> None:
|
|
9
|
+
super().__init__()
|
|
10
|
+
|
|
11
|
+
self.width = width
|
|
12
|
+
self.height = height
|
|
13
|
+
self._defs: list[str] = [
|
|
14
|
+
"""<marker id="arrowhead" markerWidth="10" markerHeight="7"
|
|
15
|
+
refX="9" refY="3.5" orient="auto">
|
|
16
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="black" />
|
|
17
|
+
</marker>"""
|
|
18
|
+
]
|
|
19
|
+
# Default viewbox is the full canvas size
|
|
20
|
+
self._viewbox: tuple[float, float, float, float] = (0, 0, width, height)
|
|
21
|
+
|
|
22
|
+
def _repr_svg_(self) -> str:
|
|
23
|
+
"""Enables automatic rendering in Jupyter/Quarto environments."""
|
|
24
|
+
return self._build_svg()
|
|
25
|
+
|
|
26
|
+
def display(self) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Explicitly renders the SVG in supported interactive environments.
|
|
29
|
+
|
|
30
|
+
Uses IPython.display to render the SVG. If the environment does
|
|
31
|
+
not support rich display, it falls back to printing the SVG string.
|
|
32
|
+
"""
|
|
33
|
+
from IPython.display import SVG, display as ipy_display
|
|
34
|
+
|
|
35
|
+
ipy_display(SVG(self._build_svg()))
|
|
36
|
+
|
|
37
|
+
def fit(self, padding: float = 0, crop: bool = True) -> Canvas:
|
|
38
|
+
"""
|
|
39
|
+
Reduces the viewBox to perfectly fit all added shapes.
|
|
40
|
+
If crop is True (default), the width and height will also be adjusted.
|
|
41
|
+
"""
|
|
42
|
+
all_bounds = [s.bounds() for s in self.shapes]
|
|
43
|
+
tight_bounds = Bounds.union(*all_bounds).padded(padding)
|
|
44
|
+
|
|
45
|
+
self._viewbox = (
|
|
46
|
+
tight_bounds.x,
|
|
47
|
+
tight_bounds.y,
|
|
48
|
+
tight_bounds.width,
|
|
49
|
+
tight_bounds.height,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if crop:
|
|
53
|
+
self.width = tight_bounds.width
|
|
54
|
+
self.height = tight_bounds.height
|
|
55
|
+
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def _build_svg(self) -> str:
|
|
59
|
+
content = "\n ".join(s.render() for s in self.shapes)
|
|
60
|
+
defs_content = "\n ".join(self._defs)
|
|
61
|
+
|
|
62
|
+
vx, vy, vw, vh = self._viewbox
|
|
63
|
+
return (
|
|
64
|
+
f'<svg width="{self.width}" height="{self.height}" '
|
|
65
|
+
f'viewBox="{vx} {vy} {vw} {vh}" '
|
|
66
|
+
'xmlns="http://www.w3.org/2000/svg">\n'
|
|
67
|
+
f" <defs>\n {defs_content}\n </defs>\n"
|
|
68
|
+
f" {content}\n"
|
|
69
|
+
"</svg>"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def save(self, path: str | Path, dpi: int = 300) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Exports the canvas to a raster or vector format with a transparent background.
|
|
75
|
+
|
|
76
|
+
Supported formats: .png, .pdf, .svg, .ps.
|
|
77
|
+
Requires the 'export' extra (cairosvg).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
svg_data = self._build_svg().encode("utf-8")
|
|
81
|
+
target = str(path)
|
|
82
|
+
extension = Path(path).suffix.lower()
|
|
83
|
+
|
|
84
|
+
if extension == ".svg":
|
|
85
|
+
with open(path, "wb") as fp:
|
|
86
|
+
fp.write(svg_data)
|
|
87
|
+
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
import cairosvg
|
|
92
|
+
except ImportError:
|
|
93
|
+
raise ImportError(
|
|
94
|
+
"Export requires 'cairosvg'. Install with: pip install tesserax[export]"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
match extension:
|
|
98
|
+
case ".png":
|
|
99
|
+
# CairoSVG handles transparency by default if the SVG has no background rect
|
|
100
|
+
cairosvg.svg2png(bytestring=svg_data, write_to=target, dpi=dpi)
|
|
101
|
+
case ".pdf":
|
|
102
|
+
cairosvg.svg2pdf(bytestring=svg_data, write_to=target, dpi=dpi)
|
|
103
|
+
case ".ps":
|
|
104
|
+
cairosvg.svg2ps(bytestring=svg_data, write_to=target, dpi=dpi)
|
|
105
|
+
case _:
|
|
106
|
+
raise ValueError(f"Unsupported export format: {extension}")
|
|
107
|
+
|
|
108
|
+
def __str__(self) -> str:
|
|
109
|
+
return self._build_svg()
|
tesserax/core.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import copy
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
import math
|
|
6
|
+
from typing import Literal, Self, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .base import Group
|
|
10
|
+
|
|
11
|
+
type Anchor = Literal[
|
|
12
|
+
"top",
|
|
13
|
+
"bottom",
|
|
14
|
+
"left",
|
|
15
|
+
"right",
|
|
16
|
+
"center",
|
|
17
|
+
"topleft",
|
|
18
|
+
"topright",
|
|
19
|
+
"bottomleft",
|
|
20
|
+
"bottomright",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Point:
|
|
26
|
+
x: float
|
|
27
|
+
y: float
|
|
28
|
+
|
|
29
|
+
def apply(self, tx=0.0, ty=0.0, r=0.0, s=1.0) -> Point:
|
|
30
|
+
rad = math.radians(r)
|
|
31
|
+
nx, ny = self.x * s, self.y * s
|
|
32
|
+
rx = nx * math.cos(rad) - ny * math.sin(rad)
|
|
33
|
+
ry = nx * math.sin(rad) + ny * math.cos(rad)
|
|
34
|
+
return Point(rx + tx, ry + ty)
|
|
35
|
+
|
|
36
|
+
def __add__(self, other: Point) -> Point:
|
|
37
|
+
return Point(self.x + other.x, self.y + other.y)
|
|
38
|
+
|
|
39
|
+
def __sub__(self, other: Point) -> Point:
|
|
40
|
+
return Point(self.x - other.x, self.y - other.y)
|
|
41
|
+
|
|
42
|
+
def magnitude(self) -> float:
|
|
43
|
+
return math.sqrt(self.x**2 + self.y**2)
|
|
44
|
+
|
|
45
|
+
def normalize(self) -> Point:
|
|
46
|
+
m = self.magnitude()
|
|
47
|
+
|
|
48
|
+
if m == 0:
|
|
49
|
+
return Point(0, 0)
|
|
50
|
+
|
|
51
|
+
return Point(self.x / m, self.y / m)
|
|
52
|
+
|
|
53
|
+
def __mul__(self, scalar: float) -> Point:
|
|
54
|
+
return Point(self.x * scalar, self.y * scalar)
|
|
55
|
+
|
|
56
|
+
def __truediv__(self, scalar: float) -> Point:
|
|
57
|
+
return Point(self.x / scalar, self.y / scalar)
|
|
58
|
+
|
|
59
|
+
def dx(self, dx: float) -> Point:
|
|
60
|
+
return self + Point(dx, 0)
|
|
61
|
+
|
|
62
|
+
def dy(self, dy: float) -> Point:
|
|
63
|
+
return self + Point(0, dy)
|
|
64
|
+
|
|
65
|
+
def d(self, dx: float, dy: float) -> Point:
|
|
66
|
+
return self + Point(dx, dy)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Transform:
|
|
71
|
+
tx: float = 0.0
|
|
72
|
+
ty: float = 0.0
|
|
73
|
+
rotation: float = 0.0
|
|
74
|
+
scale: float = 1.0
|
|
75
|
+
|
|
76
|
+
def map(self, p: Point) -> Point:
|
|
77
|
+
return p.apply(self.tx, self.ty, self.rotation, self.scale)
|
|
78
|
+
|
|
79
|
+
def reset(self) -> None:
|
|
80
|
+
self.tx = 0.0
|
|
81
|
+
self.ty = 0.0
|
|
82
|
+
self.rotation = 0.0
|
|
83
|
+
self.scale = 1.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Bounds:
|
|
88
|
+
x: float
|
|
89
|
+
y: float
|
|
90
|
+
width: float
|
|
91
|
+
height: float
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def left(self) -> Point:
|
|
95
|
+
return Point(self.x, self.y + self.height / 2)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def right(self) -> Point:
|
|
99
|
+
return Point(self.x + self.width, self.y + self.height / 2)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def top(self) -> Point:
|
|
103
|
+
return Point(self.x + self.width / 2, self.y)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def bottom(self) -> Point:
|
|
107
|
+
return Point(self.x + self.width / 2, self.y + self.height)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def topleft(self) -> Point:
|
|
111
|
+
return Point(self.x, self.y)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def topright(self) -> Point:
|
|
115
|
+
return Point(self.x + self.width, self.y)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def bottomleft(self) -> Point:
|
|
119
|
+
return Point(self.x, self.y + self.height)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def bottomright(self) -> Point:
|
|
123
|
+
return Point(self.x + self.width, self.y + self.height)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def center(self) -> Point:
|
|
127
|
+
return Point(self.x + self.width / 2, self.y + self.height / 2)
|
|
128
|
+
|
|
129
|
+
def padded(self, amount: float) -> Bounds:
|
|
130
|
+
return Bounds(
|
|
131
|
+
self.x - amount,
|
|
132
|
+
self.y - amount,
|
|
133
|
+
self.width + 2 * amount,
|
|
134
|
+
self.height + 2 * amount,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def anchor(self, name: Anchor) -> Point:
|
|
138
|
+
match name:
|
|
139
|
+
case "top":
|
|
140
|
+
return self.top
|
|
141
|
+
case "bottom":
|
|
142
|
+
return self.bottom
|
|
143
|
+
case "left":
|
|
144
|
+
return self.left
|
|
145
|
+
case "right":
|
|
146
|
+
return self.right
|
|
147
|
+
case "center":
|
|
148
|
+
return self.center
|
|
149
|
+
case "topleft":
|
|
150
|
+
return self.topleft
|
|
151
|
+
case "topright":
|
|
152
|
+
return self.topright
|
|
153
|
+
case "bottomleft":
|
|
154
|
+
return self.bottomleft
|
|
155
|
+
case "bottomright":
|
|
156
|
+
return self.bottomright
|
|
157
|
+
case _:
|
|
158
|
+
raise ValueError(f"Unknown anchor: {name}")
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def union(cls, *bounds: Bounds) -> Bounds:
|
|
162
|
+
if not bounds:
|
|
163
|
+
return Bounds(0, 0, 0, 0)
|
|
164
|
+
|
|
165
|
+
x_min = min(b.x for b in bounds)
|
|
166
|
+
y_min = min(b.y for b in bounds)
|
|
167
|
+
x_max = max(b.x + b.width for b in bounds)
|
|
168
|
+
y_max = max(b.y + b.height for b in bounds)
|
|
169
|
+
|
|
170
|
+
return Bounds(x_min, y_min, x_max - x_min, y_max - y_min)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Shape(ABC):
|
|
174
|
+
def __init__(self) -> None:
|
|
175
|
+
from .base import Group
|
|
176
|
+
|
|
177
|
+
self.transform = Transform()
|
|
178
|
+
self.parent: Group | None = None
|
|
179
|
+
|
|
180
|
+
if (gp := Group.current()) is not None:
|
|
181
|
+
gp.append(self)
|
|
182
|
+
|
|
183
|
+
@abstractmethod
|
|
184
|
+
def local(self) -> Bounds:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
def bounds(self) -> Bounds:
|
|
188
|
+
base = self.local()
|
|
189
|
+
|
|
190
|
+
corners = [base.topleft, base.topright, base.bottomleft, base.bottomright]
|
|
191
|
+
transformed = [self.transform.map(p) for p in corners]
|
|
192
|
+
xs = [p.x for p in transformed]
|
|
193
|
+
ys = [p.y for p in transformed]
|
|
194
|
+
|
|
195
|
+
return Bounds(min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))
|
|
196
|
+
|
|
197
|
+
@abstractmethod
|
|
198
|
+
def _render(self) -> str:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
def render(self) -> str:
|
|
202
|
+
"""Wraps the inner content in a transform group."""
|
|
203
|
+
t = self.transform
|
|
204
|
+
ts = f' transform="translate({t.tx} {t.ty}) rotate({t.rotation}) scale({t.scale})"'
|
|
205
|
+
return f"<g{ts}>\n{self._render()}\n</g>"
|
|
206
|
+
|
|
207
|
+
def resolve(self, p: Point) -> Point:
|
|
208
|
+
world_p = self.transform.map(p)
|
|
209
|
+
if self.parent:
|
|
210
|
+
return self.parent.resolve(world_p)
|
|
211
|
+
return world_p
|
|
212
|
+
|
|
213
|
+
def anchor(self, name: Anchor) -> Point:
|
|
214
|
+
return self.resolve(self.local().anchor(name))
|
|
215
|
+
|
|
216
|
+
def translated(self, dx: float, dy: float) -> Self:
|
|
217
|
+
self.transform.tx += dx
|
|
218
|
+
self.transform.ty += dy
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def rotated(self, r: float) -> Self:
|
|
222
|
+
self.transform.rotation += r
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
def scaled(self, s: float) -> Self:
|
|
226
|
+
self.transform.scale += s
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def clone(self) -> Self:
|
|
230
|
+
return copy.deepcopy(self)
|
|
231
|
+
|
|
232
|
+
def __add__(self, other: Shape) -> Group:
|
|
233
|
+
# Import internally to avoid circular import with base.py
|
|
234
|
+
from .base import Group
|
|
235
|
+
|
|
236
|
+
return Group().add(self, other)
|
tesserax/layout.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
import math
|
|
4
|
+
from typing import Literal, Self
|
|
5
|
+
from .core import Shape, Bounds
|
|
6
|
+
from .base import Group
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Layout(Group):
|
|
10
|
+
def __init__(self, shapes: list[Shape] | None = None) -> None:
|
|
11
|
+
super().__init__(shapes)
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def do_layout(self) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Implementation must iterate over self.shapes, RESET their transforms,
|
|
17
|
+
and then apply new translations.
|
|
18
|
+
"""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def add(self, *shapes: Shape) -> "Layout":
|
|
22
|
+
super().add(*shapes)
|
|
23
|
+
self.do_layout()
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
type Align = Literal["start", "middle", "end"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Row(Layout):
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
shapes: list[Shape] | None = None,
|
|
34
|
+
align: Align = "middle",
|
|
35
|
+
gap: float = 0,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.align = align
|
|
38
|
+
self.gap = gap
|
|
39
|
+
super().__init__(shapes)
|
|
40
|
+
|
|
41
|
+
def do_layout(self) -> None:
|
|
42
|
+
if not self.shapes:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# 1. First pass: Reset transforms so we get pure local bounds
|
|
46
|
+
for s in self.shapes:
|
|
47
|
+
s.transform.reset()
|
|
48
|
+
|
|
49
|
+
# 2. Calculate offsets based on the 'clean' shapes
|
|
50
|
+
max_h = max(s.local().height for s in self.shapes)
|
|
51
|
+
current_x = 0.0
|
|
52
|
+
|
|
53
|
+
for shape in self.shapes:
|
|
54
|
+
b = shape.local()
|
|
55
|
+
|
|
56
|
+
# Calculate Y based on baseline
|
|
57
|
+
match self.align:
|
|
58
|
+
case "start":
|
|
59
|
+
dy = -b.y
|
|
60
|
+
case "middle":
|
|
61
|
+
dy = (max_h / 2) - (b.y + b.height / 2)
|
|
62
|
+
case "end":
|
|
63
|
+
dy = max_h - (b.y + b.height)
|
|
64
|
+
case _:
|
|
65
|
+
dy = 0
|
|
66
|
+
|
|
67
|
+
# 3. Apply the strict layout position
|
|
68
|
+
shape.transform.tx = current_x - b.x
|
|
69
|
+
shape.transform.ty = dy
|
|
70
|
+
|
|
71
|
+
current_x += b.width + self.gap
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Column(Row):
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
shapes: list[Shape] | None = None,
|
|
78
|
+
align: Align = "middle",
|
|
79
|
+
gap: float = 0,
|
|
80
|
+
) -> None:
|
|
81
|
+
super().__init__(shapes, align, gap)
|
|
82
|
+
|
|
83
|
+
def do_layout(self) -> None:
|
|
84
|
+
if not self.shapes:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
for s in self.shapes:
|
|
88
|
+
s.transform.reset()
|
|
89
|
+
|
|
90
|
+
max_w = max(s.local().width for s in self.shapes)
|
|
91
|
+
current_y = 0.0
|
|
92
|
+
|
|
93
|
+
for shape in self.shapes:
|
|
94
|
+
b = shape.local()
|
|
95
|
+
|
|
96
|
+
match self.align:
|
|
97
|
+
case "start":
|
|
98
|
+
dx = -b.x
|
|
99
|
+
case "end":
|
|
100
|
+
dx = max_w - (b.x + b.width)
|
|
101
|
+
case "middle":
|
|
102
|
+
dx = (max_w / 2) - (b.x + b.width / 2)
|
|
103
|
+
case _:
|
|
104
|
+
dx = 0
|
|
105
|
+
|
|
106
|
+
shape.transform.tx = dx
|
|
107
|
+
shape.transform.ty = current_y - b.y
|
|
108
|
+
|
|
109
|
+
current_y += b.height + self.gap
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ForceLayout(Layout):
|
|
113
|
+
"""
|
|
114
|
+
A force-directed layout for graph visualization.
|
|
115
|
+
|
|
116
|
+
Nodes are positioned using a physical simulation where connections act
|
|
117
|
+
as springs (attraction) and all nodes repel each other (repulsion).
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
shapes: list[Shape] | None = None,
|
|
123
|
+
iterations: int = 100,
|
|
124
|
+
k: float | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
super().__init__(shapes)
|
|
127
|
+
self.connections: list[tuple[Shape, Shape]] = []
|
|
128
|
+
self.iterations = iterations
|
|
129
|
+
self.k_const = k
|
|
130
|
+
|
|
131
|
+
def connect(self, u: Shape, v: Shape) -> Self:
|
|
132
|
+
"""
|
|
133
|
+
Defines an undirected connection between two shapes.
|
|
134
|
+
The layout will use this connection to apply attractive forces.
|
|
135
|
+
"""
|
|
136
|
+
self.connections.append((u, v))
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def do_layout(self) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Executes the Fruchterman-Reingold force-directed simulation.
|
|
142
|
+
"""
|
|
143
|
+
if not self.shapes:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# 1. Initialize positions in a circle to avoid overlapping origins
|
|
147
|
+
for i, shape in enumerate(self.shapes):
|
|
148
|
+
if shape.transform.tx == 0 and shape.transform.ty == 0:
|
|
149
|
+
angle = (2 * math.pi * i) / len(self.shapes)
|
|
150
|
+
shape.transform.tx = 100 * math.cos(angle)
|
|
151
|
+
shape.transform.ty = 100 * math.sin(angle)
|
|
152
|
+
|
|
153
|
+
# 2. Simulation parameters
|
|
154
|
+
# k is the optimal distance between nodes
|
|
155
|
+
area = 600 * 600
|
|
156
|
+
k = self.k_const or math.sqrt(area / len(self.shapes))
|
|
157
|
+
t = 100.0 # Temperature (max displacement per step)
|
|
158
|
+
dt = t / self.iterations
|
|
159
|
+
|
|
160
|
+
for _ in range(self.iterations):
|
|
161
|
+
# Store displacement for each shape ID
|
|
162
|
+
disp = {id(s): [0.0, 0.0] for s in self.shapes}
|
|
163
|
+
|
|
164
|
+
# Repulsion Force (between all pairs)
|
|
165
|
+
for i, v in enumerate(self.shapes):
|
|
166
|
+
for j, u in enumerate(self.shapes):
|
|
167
|
+
if i == j:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
dx = v.transform.tx - u.transform.tx
|
|
171
|
+
dy = v.transform.ty - u.transform.ty
|
|
172
|
+
dist = math.sqrt(dx * dx + dy * dy) + 0.01
|
|
173
|
+
|
|
174
|
+
# fr(d) = k^2 / d
|
|
175
|
+
mag = (k * k) / dist
|
|
176
|
+
disp[id(v)][0] += (dx / dist) * mag
|
|
177
|
+
disp[id(v)][1] += (dy / dist) * mag
|
|
178
|
+
|
|
179
|
+
# Attraction Force (only between connected nodes)
|
|
180
|
+
for u, v in self.connections:
|
|
181
|
+
dx = v.transform.tx - u.transform.tx
|
|
182
|
+
dy = v.transform.ty - u.transform.ty
|
|
183
|
+
dist = math.sqrt(dx * dx + dy * dy) + 0.01
|
|
184
|
+
|
|
185
|
+
# fa(d) = d^2 / k
|
|
186
|
+
mag = (dist * dist) / k
|
|
187
|
+
fx, fy = (dx / dist) * mag, (dy / dist) * mag
|
|
188
|
+
|
|
189
|
+
disp[id(v)][0] -= fx
|
|
190
|
+
disp[id(v)][1] -= fy
|
|
191
|
+
disp[id(u)][0] += fx
|
|
192
|
+
disp[id(u)][1] += fy
|
|
193
|
+
|
|
194
|
+
# Apply displacement limited by temperature
|
|
195
|
+
for shape in self.shapes:
|
|
196
|
+
dx, dy = disp[id(shape)]
|
|
197
|
+
dist = math.sqrt(dx * dx + dy * dy) + 0.01
|
|
198
|
+
|
|
199
|
+
shape.transform.tx += (dx / dist) * min(dist, t)
|
|
200
|
+
shape.transform.ty += (dy / dist) * min(dist, t)
|
|
201
|
+
|
|
202
|
+
# Cool the simulation
|
|
203
|
+
t -= dt
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class HierarchicalLayout(Layout):
|
|
207
|
+
"""
|
|
208
|
+
Arranges nodes in distinct layers based on directed connections.
|
|
209
|
+
Supports both Vertical (Top-Bottom) and Horizontal (Left-Right) flows.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(
|
|
213
|
+
self,
|
|
214
|
+
shapes: list[Shape] | None = None,
|
|
215
|
+
roots: list[Shape] | None = None,
|
|
216
|
+
rank_sep: float = 50.0,
|
|
217
|
+
node_sep: float = 20.0,
|
|
218
|
+
orientation: Literal["vertical", "horizontal"] = "vertical",
|
|
219
|
+
) -> None:
|
|
220
|
+
super().__init__(shapes)
|
|
221
|
+
self.rank_sep = rank_sep
|
|
222
|
+
self.node_sep = node_sep
|
|
223
|
+
self.orientation = orientation
|
|
224
|
+
self.roots = set(roots or [])
|
|
225
|
+
self.adj: dict[Shape, list[Shape]] = defaultdict(list)
|
|
226
|
+
self.rev_adj: dict[Shape, list[Shape]] = defaultdict(list)
|
|
227
|
+
|
|
228
|
+
def root(self, n: Shape) -> Self:
|
|
229
|
+
self.roots.add(n)
|
|
230
|
+
return self
|
|
231
|
+
|
|
232
|
+
def connect(self, u: Shape, v: Shape) -> Self:
|
|
233
|
+
"""Defines a directed dependency u -> v."""
|
|
234
|
+
self.adj[u].append(v)
|
|
235
|
+
self.rev_adj[v].append(u)
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
def do_layout(self) -> None:
|
|
239
|
+
if not self.shapes:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
# 1. Ranking Phase: Assign layers (ignoring back-edges)
|
|
243
|
+
ranks = self._assign_ranks()
|
|
244
|
+
|
|
245
|
+
layers: dict[int, list[Shape]] = defaultdict(list)
|
|
246
|
+
for s, r in ranks.items():
|
|
247
|
+
layers[r].append(s)
|
|
248
|
+
|
|
249
|
+
max_rank = max(layers.keys()) if layers else 0
|
|
250
|
+
|
|
251
|
+
# 2. Ordering Phase: Minimize crossings (Barycenter Method)
|
|
252
|
+
for r in range(1, max_rank + 1):
|
|
253
|
+
layers[r].sort(key=lambda node: self._barycenter(node, layers[r - 1]))
|
|
254
|
+
|
|
255
|
+
# 3. Positioning Phase: Assign physical coordinates
|
|
256
|
+
# 'current_flow' tracks the position along the main axis (Y for vert, X for horz)
|
|
257
|
+
current_flow = 0.0
|
|
258
|
+
|
|
259
|
+
for r in sorted(layers.keys()):
|
|
260
|
+
layer = layers[r]
|
|
261
|
+
|
|
262
|
+
# Reset transforms to get clean local bounds
|
|
263
|
+
for s in layer:
|
|
264
|
+
s.transform.reset()
|
|
265
|
+
|
|
266
|
+
# Calculate metrics for centering this layer
|
|
267
|
+
if self.orientation == "horizontal":
|
|
268
|
+
# In horizontal, 'breadth' is the height of the nodes
|
|
269
|
+
breadths = [s.local().height for s in layer]
|
|
270
|
+
# 'depth' is the width of the nodes (rank thickness)
|
|
271
|
+
depths = [s.local().width for s in layer]
|
|
272
|
+
else:
|
|
273
|
+
# In vertical, 'breadth' is the width of the nodes
|
|
274
|
+
breadths = [s.local().width for s in layer]
|
|
275
|
+
# 'depth' is the height of the nodes (rank thickness)
|
|
276
|
+
depths = [s.local().height for s in layer]
|
|
277
|
+
|
|
278
|
+
# Center the layer along the cross-axis
|
|
279
|
+
total_breadth = sum(breadths) + self.node_sep * (len(layer) - 1)
|
|
280
|
+
current_cross = -total_breadth / 2
|
|
281
|
+
|
|
282
|
+
# The thickness of this rank is determined by the tallest/widest node
|
|
283
|
+
max_depth_in_rank = 0.0
|
|
284
|
+
|
|
285
|
+
for i, s in enumerate(layer):
|
|
286
|
+
b = s.local()
|
|
287
|
+
|
|
288
|
+
if self.orientation == "horizontal":
|
|
289
|
+
# Flow is X, Cross is Y
|
|
290
|
+
# Align Left edge to current_flow
|
|
291
|
+
s.transform.tx = current_flow - b.x
|
|
292
|
+
# Align Top edge to current_cross
|
|
293
|
+
s.transform.ty = current_cross - b.y
|
|
294
|
+
|
|
295
|
+
max_depth_in_rank = max(max_depth_in_rank, b.width)
|
|
296
|
+
current_cross += b.height + self.node_sep
|
|
297
|
+
else:
|
|
298
|
+
# Flow is Y, Cross is X
|
|
299
|
+
# Align Left edge to current_cross
|
|
300
|
+
s.transform.tx = current_cross - b.x
|
|
301
|
+
# Align Top edge to current_flow
|
|
302
|
+
s.transform.ty = current_flow - b.y
|
|
303
|
+
|
|
304
|
+
max_depth_in_rank = max(max_depth_in_rank, b.height)
|
|
305
|
+
current_cross += b.width + self.node_sep
|
|
306
|
+
|
|
307
|
+
# Advance the main flow axis
|
|
308
|
+
current_flow += max_depth_in_rank + self.rank_sep
|
|
309
|
+
|
|
310
|
+
def _assign_ranks(self) -> dict[Shape, int]:
|
|
311
|
+
"""
|
|
312
|
+
Computes the layer index for each node using DFS.
|
|
313
|
+
Detects back-edges (cycles) and ignores them for rank calculation.
|
|
314
|
+
"""
|
|
315
|
+
ranks: dict[Shape, int] = {}
|
|
316
|
+
visiting = set()
|
|
317
|
+
|
|
318
|
+
def get_rank(node: Shape) -> int:
|
|
319
|
+
if node in ranks:
|
|
320
|
+
return ranks[node]
|
|
321
|
+
|
|
322
|
+
# Cycle detection: We are currently visiting this node's descendant
|
|
323
|
+
if node in visiting:
|
|
324
|
+
return -1 # Signal to ignore this parent
|
|
325
|
+
|
|
326
|
+
visiting.add(node)
|
|
327
|
+
|
|
328
|
+
parents = self.rev_adj[node]
|
|
329
|
+
if not parents:
|
|
330
|
+
r = 0
|
|
331
|
+
else:
|
|
332
|
+
parent_ranks = [get_rank(p) for p in parents]
|
|
333
|
+
# Filter out back-edges (-1s)
|
|
334
|
+
valid_ranks = [pr for pr in parent_ranks if pr != -1]
|
|
335
|
+
# If all parents were back-edges, treat as root (0)
|
|
336
|
+
r = 1 + max(valid_ranks, default=-1)
|
|
337
|
+
|
|
338
|
+
visiting.remove(node)
|
|
339
|
+
ranks[node] = r
|
|
340
|
+
return r
|
|
341
|
+
|
|
342
|
+
for r in self.roots:
|
|
343
|
+
ranks[r] = 0
|
|
344
|
+
|
|
345
|
+
for s in self.shapes:
|
|
346
|
+
get_rank(s)
|
|
347
|
+
|
|
348
|
+
return ranks
|
|
349
|
+
|
|
350
|
+
def _barycenter(self, node: Shape, prev_layer: list[Shape]) -> float:
|
|
351
|
+
parents = [p for p in self.rev_adj[node] if p in prev_layer]
|
|
352
|
+
if not parents:
|
|
353
|
+
return 0.0
|
|
354
|
+
indices = [prev_layer.index(p) for p in parents]
|
|
355
|
+
return sum(indices) / len(indices)
|
tesserax/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tesserax
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: A pure-Python library for rendering professional CS graphics.
|
|
5
|
+
Author-email: Alejandro Piad <apiad@apiad.net>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Tesserax: A Lightweight SVG Rendering Library
|
|
11
|
+
|
|
12
|
+
Tesserax is a modern Python 3.12 library designed for programmatic SVG generation with a focus on ease of use, layout management, and flexible geometric primitives. It is particularly well-suited for visualizing data structures, algorithms, and technical diagrams.
|
|
13
|
+
|
|
14
|
+
> [**Read the full documentation**](https://apiad.github.io/tesserax).
|
|
15
|
+
|
|
16
|
+
## Key Features
|
|
17
|
+
|
|
18
|
+
* **Declarative Layouts**: Effortlessly arrange shapes in `Row` or `Column` containers with automatic alignment and spacing.
|
|
19
|
+
* **Anchor System**: Connect shapes using semantic anchors like `top`, `bottom`, `left`, `right`, and `center`.
|
|
20
|
+
* **Context Manager Support**: Use `with` statements to group shapes naturally within the code.
|
|
21
|
+
* **Smart Canvas**: Automatically fit the canvas viewport to the content with adjustable padding.
|
|
22
|
+
* **Rich Primitives**: Includes `Rect`, `Square`, `Circle`, `Ellipse`, `Line`, `Arrow`, and `Path`.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
Tesserax has zero dependencies (literally). It's 100% pure Python, and can be easily installed with `pip`:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install tesserax
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or if you're one of the cool kids, using `uv`:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv add tesserax
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
The following example demonstrates how to create two shapes in a row and connect them with an arrow using the anchor system.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from tesserax import Canvas, Rect, Arrow, Circle
|
|
44
|
+
from tesserax.layout import Row
|
|
45
|
+
|
|
46
|
+
# Initialize a canvas
|
|
47
|
+
with Canvas() as canvas:
|
|
48
|
+
# Arrange a circle and a rectangle in a row with a 50px gap
|
|
49
|
+
with Row(gap=50):
|
|
50
|
+
circle = Circle(30, fill="#fee")
|
|
51
|
+
rect = Rect(100, 60, fill="#eef")
|
|
52
|
+
|
|
53
|
+
# Draw an arrow between the two shapes using anchors
|
|
54
|
+
# .dx() provides a small offset for better visual spacing
|
|
55
|
+
Arrow(
|
|
56
|
+
circle.anchor("right").dx(5),
|
|
57
|
+
rect.anchor("left").dx(-5)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Fit the viewport to the shapes and render
|
|
61
|
+
canvas.fit(padding=10).display()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The `display()` method in the `Canvas` class is an IPython/Jupyter/Quarto compatible shortcut to automatically include the rendered SVG (in all its beautiful vectorial glory) directly in a notebook. But you can also use `Canvas.save()` to generate a plain old, boring SVG file on this, and `str(canvas)` to get the actual SVG code as a plain string.
|
|
65
|
+
|
|
66
|
+
## Core Components
|
|
67
|
+
|
|
68
|
+
Tesserax comes with all basic components you need to draw the spectrum of SVG shapes.
|
|
69
|
+
All shapes support standard SVG attributes like `stroke` and `fill`.
|
|
70
|
+
|
|
71
|
+
* **Rect & Square**: Defined by width/height or a single size.
|
|
72
|
+
* **Circle & Ellipse**: Defined by radii.
|
|
73
|
+
* **Groups**: For grouping shapes and applying transforms to them as a single shape.
|
|
74
|
+
* **Arrow**: A specialized line that automatically includes an arrowhead marker.
|
|
75
|
+
* **Path**: Supports a fluent API for complex paths using `move_to`, `line_to`, `cubic_to`, and `close`.
|
|
76
|
+
|
|
77
|
+
### Layouts
|
|
78
|
+
|
|
79
|
+
Layouts are a unique feature of Tesserax to automate the positioning of child elements. We currently have three layouts, but these are very easy to extend:
|
|
80
|
+
|
|
81
|
+
* **Row**: Aligns shapes horizontally. Baselines can be set to `start`, `middle`, or `end`.
|
|
82
|
+
* **Column**: Aligns shapes vertically with `start`, `middle`, or `end` alignment.
|
|
83
|
+
* **HierarchicalLayout**: Useful for drawing trees, DAGs, automata, etc.
|
|
84
|
+
* **ForceLayout**: Typically used to draw arbitrary graphs with a force-directed algorithm.
|
|
85
|
+
|
|
86
|
+
### Transforms
|
|
87
|
+
|
|
88
|
+
Every shape has a `Transform` object allowing for:
|
|
89
|
+
|
|
90
|
+
* **Translation**: `shape.translated(dx, dy)`.
|
|
91
|
+
* **Rotation**: `shape.rotated(degrees)`.
|
|
92
|
+
* **Scaling**: `shape.scaled(factor)`.
|
|
93
|
+
|
|
94
|
+
Groups of shapes also have their own transform, and this can be composed _ad-infinitum_ to create complex drawing.
|
|
95
|
+
|
|
96
|
+
## Why Tesserax?
|
|
97
|
+
|
|
98
|
+
In the Python ecosystem, there is a clear divide between **data visualization** (plotting numbers) and **diagrammatic representation** (drawing concepts). Tesserax is built for the latter.
|
|
99
|
+
|
|
100
|
+
It is designed for researchers, educators, and authors who need the geometric precision of a professional drafting tool combined with the power of a modern programming language.
|
|
101
|
+
|
|
102
|
+
### Tesserax vs. The Alternatives
|
|
103
|
+
|
|
104
|
+
#### Precision over Statistics
|
|
105
|
+
|
|
106
|
+
Libraries like **Matplotlib**, **Seaborn**, or **Altair** are designed to map data points to visual encodings (bars, lines, scatter points).
|
|
107
|
+
|
|
108
|
+
**The Difference**: Tesserax does not compete with these libraries because it does not render data graphs. You wouldn't use Tesserax to plot a CSV. Instead, Tesserax is for "the rest" of the figures in a paper: the schematics, the geometric proofs, the architectural diagrams, and the algorithmic walkthroughs where exact spatial relationships convey the meaning.
|
|
109
|
+
|
|
110
|
+
#### Control over Constraints
|
|
111
|
+
|
|
112
|
+
**Mermaid** and **Graphviz** are excellent for quickly rendering flowcharts using "black-box" layout engines.
|
|
113
|
+
|
|
114
|
+
**The Difference**: These tools sacrifice control for convenience. If you need an arrow to point exactly at the tangent of a rotated ellipse, or a shape to be sized exactly according to a geometric ratio, Mermaid cannot help you. Tesserax is for **Scientific Drawing**—providing the low-level primitives needed for total layout authority.
|
|
115
|
+
|
|
116
|
+
#### The "TikZ for Python" Philosophy
|
|
117
|
+
|
|
118
|
+
**TikZ** is the industry standard for academic figures, but it requires learning a specialized, often cryptic macro language.
|
|
119
|
+
|
|
120
|
+
**The Difference**: Tesserax brings the "low-level, total-control" philosophy of TikZ into **Python 3.12**. You get coordinate-invariant precision and semantic anchoring while using Python’s loops, logic, and types. We are building from the bottom up: starting with geometric atoms and moving toward high-level scientific abstractions (like automated neural network architectures or commutative diagrams) that maintain the ability to "drop down" and tweak a single pixel.
|
|
121
|
+
|
|
122
|
+
### The SVG Advantage
|
|
123
|
+
|
|
124
|
+
While TikZ is the gold standard for LaTeX-based PDF generation, it belongs to a "print-first" era. Tesserax leverages **SVG (Scalable Vector Graphics)** as its native format, offering a portability that TikZ cannot match without significant friction.
|
|
125
|
+
|
|
126
|
+
* **Native Web Rendering**: Tesserax figures are native SVGs. They render instantly in any browser, remain crisp at any zoom level, and can be embedded directly into HTML or Markdown (via Quarto) without conversion.
|
|
127
|
+
* **WYSIWYG Portability**: Converting TikZ to SVG for blog posts or online journals often results in broken fonts or misaligned elements. Because Tesserax *starts* with SVG, what you see in your development notebook is exactly what appears in your final PDF and your website.
|
|
128
|
+
* **Accessibility & Interaction**: Unlike static PDFs, Tesserax SVGs can include metadata and ARIA labels for screen readers. Since they are part of the DOM, they can also be styled with CSS or even animated for interactive educational content.
|
|
129
|
+
* **Perfect Print**: SVG is fully convertible to high-quality, vector-perfect PDF, meeting the highest standards for academic journals and book publishing.
|
|
130
|
+
|
|
131
|
+
## Contribution
|
|
132
|
+
|
|
133
|
+
Tesserax is free as in both free beer and free speech. License is MIT.
|
|
134
|
+
|
|
135
|
+
Contributions are always welcomed! Fork, clone, and submit a pull request.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
tesserax/__init__.py,sha256=XitPTo-_7JHiHKO9XipLgu_HnijDja0q_lmeTLfaUeQ,224
|
|
2
|
+
tesserax/align.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
tesserax/base.py,sha256=o7ZQ0ED7EPPCvas78OBq4fE5w38iWdMwl7WYxzFlWyw,14404
|
|
4
|
+
tesserax/canvas.py,sha256=uY-L3DvIDwciDWQ1l9QLEH-unVUk8Am0k1ykeMIxqG0,3663
|
|
5
|
+
tesserax/core.py,sha256=7t_UxMl0cRJYspCIYPPUc0aXIdqcaHa_o01MrM_9IaY,6180
|
|
6
|
+
tesserax/layout.py,sha256=C_enRYUUizCQpnQy_MK8hPbZUT7gJ7sa8l7mJwp_Kak,11591
|
|
7
|
+
tesserax/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
tesserax-0.5.1.dist-info/METADATA,sha256=8mfM0_-XroM93xd13EO6x_FAsmZnBNKD8RMIXBVR4CE,7320
|
|
9
|
+
tesserax-0.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
tesserax-0.5.1.dist-info/licenses/LICENSE,sha256=MqaKRdqJfordIyistXMUlS7MVRj7fnJABGUD0Q3Fy14,1071
|
|
11
|
+
tesserax-0.5.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alejandro Piad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|