tesserax 0.2.1__tar.gz → 0.5.1__tar.gz
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-0.2.1 → tesserax-0.5.1}/PKG-INFO +3 -2
- {tesserax-0.2.1 → tesserax-0.5.1}/README.md +2 -1
- {tesserax-0.2.1 → tesserax-0.5.1}/docs/_quarto.yml +1 -1
- {tesserax-0.2.1 → tesserax-0.5.1}/docs/core.qmd +89 -1
- {tesserax-0.2.1 → tesserax-0.5.1}/docs/gallery.qmd +6 -4
- {tesserax-0.2.1 → tesserax-0.5.1}/docs/index.qmd +2 -1
- {tesserax-0.2.1 → tesserax-0.5.1}/makefile +1 -1
- {tesserax-0.2.1 → tesserax-0.5.1}/pyproject.toml +4 -2
- tesserax-0.5.1/src/tesserax/__init__.py +16 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/src/tesserax/base.py +199 -4
- {tesserax-0.2.1 → tesserax-0.5.1}/src/tesserax/canvas.py +35 -4
- {tesserax-0.2.1 → tesserax-0.5.1}/src/tesserax/core.py +17 -0
- tesserax-0.5.1/src/tesserax/layout.py +355 -0
- tesserax-0.5.1/src/tesserax/py.typed +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/uv.lock +115 -1
- tesserax-0.2.1/src/tesserax/__init__.py +0 -5
- tesserax-0.2.1/src/tesserax/force.py +0 -98
- tesserax-0.2.1/src/tesserax/layout.py +0 -107
- {tesserax-0.2.1 → tesserax-0.5.1}/.github/workflows/release.yaml +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/.github/workflows/tests.yaml +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/.gitignore +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/.python-version +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/.vscode/settings.json +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/AGENT.md +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/LICENSE +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/docs/.gitignore +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/docs/styles.css +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/examples/basic.py +0 -0
- /tesserax-0.2.1/src/tesserax/py.typed → /tesserax-0.5.1/src/tesserax/align.py +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/tests/test_geometry.py +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/tests/test_layout.py +0 -0
- {tesserax-0.2.1 → tesserax-0.5.1}/tests/test_shapes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tesserax
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: A pure-Python library for rendering professional CS graphics.
|
|
5
5
|
Author-email: Alejandro Piad <apiad@apiad.net>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -80,7 +80,8 @@ Layouts are a unique feature of Tesserax to automate the positioning of child el
|
|
|
80
80
|
|
|
81
81
|
* **Row**: Aligns shapes horizontally. Baselines can be set to `start`, `middle`, or `end`.
|
|
82
82
|
* **Column**: Aligns shapes vertically with `start`, `middle`, or `end` alignment.
|
|
83
|
-
* **
|
|
83
|
+
* **HierarchicalLayout**: Useful for drawing trees, DAGs, automata, etc.
|
|
84
|
+
* **ForceLayout**: Typically used to draw arbitrary graphs with a force-directed algorithm.
|
|
84
85
|
|
|
85
86
|
### Transforms
|
|
86
87
|
|
|
@@ -71,7 +71,8 @@ Layouts are a unique feature of Tesserax to automate the positioning of child el
|
|
|
71
71
|
|
|
72
72
|
* **Row**: Aligns shapes horizontally. Baselines can be set to `start`, `middle`, or `end`.
|
|
73
73
|
* **Column**: Aligns shapes vertically with `start`, `middle`, or `end` alignment.
|
|
74
|
-
* **
|
|
74
|
+
* **HierarchicalLayout**: Useful for drawing trees, DAGs, automata, etc.
|
|
75
|
+
* **ForceLayout**: Typically used to draw arbitrary graphs with a force-directed algorithm.
|
|
75
76
|
|
|
76
77
|
### Transforms
|
|
77
78
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
This
|
|
1
|
+
This guide will walk you through building a scene from scratch, demonstrating how **Tesserax** handles shapes, positioning, and composition.
|
|
2
2
|
|
|
3
3
|
## The Canvas and the First Shape
|
|
4
4
|
|
|
@@ -21,6 +21,20 @@ canvas.fit(padding=10).display()
|
|
|
21
21
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
## Drawing Text
|
|
25
|
+
|
|
26
|
+
Text behaves exactly like any other shape in Tesserax.
|
|
27
|
+
|
|
28
|
+
```{python}
|
|
29
|
+
from tesserax import Canvas, Text
|
|
30
|
+
|
|
31
|
+
with Canvas() as canvas:
|
|
32
|
+
Rect(150, 40, fill="lightblue")
|
|
33
|
+
Text("Hello World", size=24, font="serif").translated(0, 22)
|
|
34
|
+
|
|
35
|
+
canvas.align("horizontal").fit(10).display()
|
|
36
|
+
```
|
|
37
|
+
|
|
24
38
|
## Adding and Transforming Shapes
|
|
25
39
|
|
|
26
40
|
While you can add shapes and manually set their coordinates, Tesserax provides a fluent API for transformations. Here, we add a `Circle` and use `translated()` to move it into position.
|
|
@@ -141,3 +155,77 @@ When you call `shape.anchor(name)`, Tesserax performs the following behind the s
|
|
|
141
155
|
This means you never have to manually calculate `sin()` or `cos()` to find where a rotated object's edge is located—you just ask for the anchor.
|
|
142
156
|
|
|
143
157
|
For explicit anchoring, you can use `Shape.resolve(p: Point)` to map a point in local space to the global space, this way you can, e.g., get the point at 2/3rds of the way inside a rectangle and map it to global space.
|
|
158
|
+
|
|
159
|
+
## Drawing Paths and Lines
|
|
160
|
+
|
|
161
|
+
While shapes like `Rect` and `Circle` cover many use cases, sometimes you need to draw arbitrary lines, custom shapes, or connectors. Tesserax offers two ways to do this: the low-level `Path` for precise control and the high-level `Polyline` for rapid sequences.
|
|
162
|
+
|
|
163
|
+
### The Low-Level `Path` Object
|
|
164
|
+
|
|
165
|
+
The `Path` class roughly corresponds to the SVG `<path>` element. It operates like a pen: you move it to a location, then draw lines or curves to subsequent points.
|
|
166
|
+
|
|
167
|
+
This is ideal for creating custom glyphs or specific geometric curves.
|
|
168
|
+
|
|
169
|
+
```{python}
|
|
170
|
+
from tesserax import Canvas, Path
|
|
171
|
+
|
|
172
|
+
with Canvas() as canvas:
|
|
173
|
+
# 1. A simple custom shape (a triangle)
|
|
174
|
+
p = Path()
|
|
175
|
+
p.move_to(0, 0).line_to(50, 50).line_to(0, 50).close()
|
|
176
|
+
p.translated(20, 20)
|
|
177
|
+
|
|
178
|
+
# 2. A curved path using Bezier curves
|
|
179
|
+
curve = Path()
|
|
180
|
+
curve.move_to(100, 20)
|
|
181
|
+
# Cubic Bezier: 2 control points, 1 end point
|
|
182
|
+
curve.cubic_to(
|
|
183
|
+
150, 20, # Control Point 1
|
|
184
|
+
150, 80, # Control Point 2
|
|
185
|
+
200, 80 # End Point
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Quadratic Bezier: 1 control point, 1 end point
|
|
189
|
+
curve.quadratic_to(
|
|
190
|
+
250, 80, # Control Point
|
|
191
|
+
300, 20 # End Point
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
canvas.fit(padding=10).display()
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### The High-Level `Polyline`
|
|
198
|
+
|
|
199
|
+
For many diagrams, you simply want to connect a sequence of points. The `Polyline` shape automates this.
|
|
200
|
+
|
|
201
|
+
Its most powerful feature is the `smoothness` parameter. Instead of manually calculating Bezier control points to round a corner, you can simply tell `Polyline` to blend the corners for you.
|
|
202
|
+
|
|
203
|
+
* `smoothness=0`: Sharp corners (standard polygon).
|
|
204
|
+
* `smoothness=0.2`: Subtle rounded corners (like a modern UI box).
|
|
205
|
+
* `smoothness=1`: Maximum rounding (spline-like).
|
|
206
|
+
|
|
207
|
+
```{python}
|
|
208
|
+
from tesserax import Canvas, Polyline, Point
|
|
209
|
+
|
|
210
|
+
# Helper to generate a zigzag pattern
|
|
211
|
+
points = [
|
|
212
|
+
Point(0, 50), Point(50, 0), Point(100, 50),
|
|
213
|
+
Point(150, 0), Point(200, 50)
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
with Canvas() as canvas:
|
|
217
|
+
# 1. Sharp Polyline (Default)
|
|
218
|
+
Polyline(points, smoothness=0).translated(0, 0)
|
|
219
|
+
|
|
220
|
+
# 2. Slightly Rounded (20% smoothing)
|
|
221
|
+
# Note how it preserves most of the straight line but rounds the tip.
|
|
222
|
+
Polyline(points, smoothness=0.2, stroke="blue").translated(0, 60)
|
|
223
|
+
|
|
224
|
+
# 3. Fully Smooth (100% smoothing)
|
|
225
|
+
# This creates a flowing wave-like appearance.
|
|
226
|
+
Polyline(points, smoothness=1.0, stroke="red").translated(0, 120)
|
|
227
|
+
|
|
228
|
+
canvas.fit(10).display()
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
This makes `Polyline` an excellent tool for drawing graph edges, wiring diagrams, or "hand-drawn" style annotations where sharp vertices look unnatural.
|
|
@@ -58,7 +58,7 @@ This example uses a force layout to draw a simple graph that represents an autom
|
|
|
58
58
|
```{python}
|
|
59
59
|
import math
|
|
60
60
|
from tesserax import Canvas, Circle, Arrow
|
|
61
|
-
from tesserax.
|
|
61
|
+
from tesserax.layout import HierarchicalLayout
|
|
62
62
|
from tesserax.core import Point
|
|
63
63
|
|
|
64
64
|
def get_boundary_point(center: Point, target: Point, radius: float) -> Point:
|
|
@@ -76,10 +76,10 @@ def get_boundary_point(center: Point, target: Point, radius: float) -> Point:
|
|
|
76
76
|
|
|
77
77
|
with Canvas() as canvas:
|
|
78
78
|
states: list[Shape] = []
|
|
79
|
-
radius =
|
|
79
|
+
radius = 20
|
|
80
80
|
|
|
81
81
|
# 1. Define the Graph Structure
|
|
82
|
-
with
|
|
82
|
+
with HierarchicalLayout(orientation="horizontal") as graph:
|
|
83
83
|
# Create 5 states
|
|
84
84
|
for i in range(4):
|
|
85
85
|
states.append(Circle(r=radius))
|
|
@@ -87,11 +87,13 @@ with Canvas() as canvas:
|
|
|
87
87
|
# Connect them (Topology)
|
|
88
88
|
# q0 -> q1 -> q2
|
|
89
89
|
graph.connect(states[0], states[1])
|
|
90
|
-
graph.connect(states[
|
|
90
|
+
graph.connect(states[0], states[2])
|
|
91
91
|
# q2 -> q0 (cycle)
|
|
92
92
|
graph.connect(states[2], states[0])
|
|
93
93
|
# q2 -> q3 -> q4
|
|
94
94
|
graph.connect(states[2], states[3])
|
|
95
|
+
# Set the root
|
|
96
|
+
graph.root(states[0])
|
|
95
97
|
|
|
96
98
|
# 2. Draw Transitions (Visuals)
|
|
97
99
|
# We define edges manually to ensure directionality (ForceLayout is undirected)
|
|
@@ -69,7 +69,8 @@ Layouts are a unique feature of Tesserax to automate the positioning of child el
|
|
|
69
69
|
|
|
70
70
|
* **Row**: Aligns shapes horizontally. Baselines can be set to `start`, `middle`, or `end`.
|
|
71
71
|
* **Column**: Aligns shapes vertically with `start`, `middle`, or `end` alignment.
|
|
72
|
-
* **
|
|
72
|
+
* **HierarchicalLayout**: Useful for drawing trees, DAGs, automata, etc.
|
|
73
|
+
* **ForceLayout**: Typically used to draw arbitrary graphs with a force-directed algorithm.
|
|
73
74
|
|
|
74
75
|
### Transforms
|
|
75
76
|
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tesserax"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.1"
|
|
4
4
|
description = "A pure-Python library for rendering professional CS graphics."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "Alejandro Piad", email = "apiad@apiad.net" }
|
|
8
8
|
]
|
|
9
9
|
requires-python = ">=3.12"
|
|
10
|
-
dependencies = []
|
|
11
10
|
|
|
12
11
|
[build-system]
|
|
13
12
|
requires = ["hatchling"]
|
|
@@ -22,3 +21,6 @@ dev = [
|
|
|
22
21
|
"pytest-coverage>=0.0",
|
|
23
22
|
"ruff>=0.14.14",
|
|
24
23
|
]
|
|
24
|
+
export = [
|
|
25
|
+
"cairosvg>=2.8.2",
|
|
26
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import Callable, Self
|
|
3
|
-
from .core import Point, Shape, Bounds
|
|
2
|
+
from typing import Callable, Literal, Self
|
|
3
|
+
from .core import Anchor, Point, Shape, Bounds
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class Rect(Shape):
|
|
@@ -164,6 +164,36 @@ class Group(Shape):
|
|
|
164
164
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
165
165
|
self.add(*self.stack.pop())
|
|
166
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
|
+
|
|
167
197
|
|
|
168
198
|
class Path(Shape):
|
|
169
199
|
"""
|
|
@@ -172,8 +202,13 @@ class Path(Shape):
|
|
|
172
202
|
layout bounding box calculations.
|
|
173
203
|
"""
|
|
174
204
|
|
|
175
|
-
def __init__(self) -> None:
|
|
205
|
+
def __init__(self, stroke: str = "black", width: float = 1) -> None:
|
|
176
206
|
super().__init__()
|
|
207
|
+
self.stroke = stroke
|
|
208
|
+
self.width = width
|
|
209
|
+
self._reset()
|
|
210
|
+
|
|
211
|
+
def _reset(self):
|
|
177
212
|
self._commands: list[str] = []
|
|
178
213
|
self._cursor: tuple[float, float] = (0.0, 0.0)
|
|
179
214
|
|
|
@@ -237,6 +272,15 @@ class Path(Shape):
|
|
|
237
272
|
self._update_cursor(end_x, end_y)
|
|
238
273
|
return self
|
|
239
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
|
+
|
|
240
284
|
def close(self) -> Self:
|
|
241
285
|
"""Closes the path by drawing a line back to the start."""
|
|
242
286
|
self._commands.append("Z")
|
|
@@ -261,4 +305,155 @@ class Path(Shape):
|
|
|
261
305
|
# You might want to offset commands by self.x/self.y if
|
|
262
306
|
# this shape is moved by a Layout.
|
|
263
307
|
d_attr = " ".join(self._commands)
|
|
264
|
-
return f'<path d="{d_attr}" fill="none" stroke="
|
|
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
|
+
)
|
|
@@ -69,10 +69,41 @@ class Canvas(Group):
|
|
|
69
69
|
"</svg>"
|
|
70
70
|
)
|
|
71
71
|
|
|
72
|
-
def save(self, path: str | Path) -> None:
|
|
73
|
-
"""
|
|
74
|
-
|
|
75
|
-
|
|
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}")
|
|
76
107
|
|
|
77
108
|
def __str__(self) -> str:
|
|
78
109
|
return self._build_svg()
|
|
@@ -39,6 +39,23 @@ class Point:
|
|
|
39
39
|
def __sub__(self, other: Point) -> Point:
|
|
40
40
|
return Point(self.x - other.x, self.y - other.y)
|
|
41
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
|
+
|
|
42
59
|
def dx(self, dx: float) -> Point:
|
|
43
60
|
return self + Point(dx, 0)
|
|
44
61
|
|