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 ADDED
@@ -0,0 +1,16 @@
1
+ from .canvas import Canvas
2
+ from .core import Shape, Bounds, Point
3
+ from .base import (
4
+ Rect,
5
+ Square,
6
+ Circle,
7
+ Ellipse,
8
+ Line,
9
+ Arrow,
10
+ Group,
11
+ Path,
12
+ Polyline,
13
+ Text,
14
+ )
15
+
16
+ __version__ = "0.5.1"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.