pydreamplet 0.1.0__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.
- pydreamplet-0.1.0/LICENSE +21 -0
- pydreamplet-0.1.0/PKG-INFO +82 -0
- pydreamplet-0.1.0/README.md +64 -0
- pydreamplet-0.1.0/pydreamplet/__init__.py +19 -0
- pydreamplet-0.1.0/pydreamplet/colors.py +115 -0
- pydreamplet-0.1.0/pydreamplet/constants.py +6 -0
- pydreamplet-0.1.0/pydreamplet/core.py +266 -0
- pydreamplet-0.1.0/pydreamplet/scales.py +137 -0
- pydreamplet-0.1.0/pydreamplet/typography.py +160 -0
- pydreamplet-0.1.0/pydreamplet/utils.py +10 -0
- pydreamplet-0.1.0/pyproject.toml +24 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Marek Pilczuk
|
|
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.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pydreamplet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A library for assembling SVGs from Python
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: svg,graphics,design
|
|
7
|
+
Author: Marek Pilczuk
|
|
8
|
+
Author-email: user@mp76.pl
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Dist: fonttools (>=4.56.0,<5.0.0)
|
|
15
|
+
Requires-Dist: ipython (>=8.32.0,<9.0.0)
|
|
16
|
+
Requires-Dist: pillow (>=11.1.0,<12.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# pyDreamplet
|
|
20
|
+
|
|
21
|
+
low level library for SVG image generation. Perfect for data visualization with Python.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Recommended using poetry
|
|
26
|
+
|
|
27
|
+
```schell
|
|
28
|
+
poetry add pydreamplet
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage example
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from pydreamplet import SVG, SvgElement
|
|
36
|
+
from pydreamplet.colors import randomColor
|
|
37
|
+
|
|
38
|
+
data = [130, 65, 108]
|
|
39
|
+
|
|
40
|
+
def waffle_chart(data, side=100, rows=10, cols=10, gutter=2, colors=[]):
|
|
41
|
+
sorted_data = sorted(data, reverse=True)
|
|
42
|
+
while len(colors) < len(sorted_data):
|
|
43
|
+
colors.append(randomColor())
|
|
44
|
+
|
|
45
|
+
svg = SVG(dimensions=(side, side))
|
|
46
|
+
|
|
47
|
+
total_cells = rows * cols
|
|
48
|
+
total = sum(data)
|
|
49
|
+
proportions = [int(round(d / total * total_cells, 0)) for d in sorted_data]
|
|
50
|
+
cell_side = (side - (cols + 1) * gutter) / cols
|
|
51
|
+
cell_group_map = []
|
|
52
|
+
for group_index, count in enumerate(proportions):
|
|
53
|
+
cell_group_map.extend([group_index] * count)
|
|
54
|
+
|
|
55
|
+
if len(cell_group_map) < total_cells:
|
|
56
|
+
cell_group_map.extend([None] * (total_cells - len(cell_group_map)))
|
|
57
|
+
|
|
58
|
+
paths = {i: "" for i in range(len(sorted_data))}
|
|
59
|
+
|
|
60
|
+
for i in range(total_cells):
|
|
61
|
+
col = i % cols
|
|
62
|
+
row = i // cols
|
|
63
|
+
|
|
64
|
+
x = gutter + col * (cell_side + gutter)
|
|
65
|
+
y = gutter + row * (cell_side + gutter)
|
|
66
|
+
|
|
67
|
+
group = cell_group_map[i]
|
|
68
|
+
if group is not None:
|
|
69
|
+
paths[group] += f"M {x} {y} h {cell_side} v {cell_side} h -{cell_side} Z "
|
|
70
|
+
|
|
71
|
+
for group_index, d_str in paths.items():
|
|
72
|
+
if d_str:
|
|
73
|
+
path = SvgElement("path", fill=colors[group_index], d=d_str)
|
|
74
|
+
svg.append(path)
|
|
75
|
+
|
|
76
|
+
return svg
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
svg = waffle_chart(data)
|
|
80
|
+
svg.display() # in jupyter notebook
|
|
81
|
+
svg.save("waffle_chart.svg")
|
|
82
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pyDreamplet
|
|
2
|
+
|
|
3
|
+
low level library for SVG image generation. Perfect for data visualization with Python.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Recommended using poetry
|
|
8
|
+
|
|
9
|
+
```schell
|
|
10
|
+
poetry add pydreamplet
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage example
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from pydreamplet import SVG, SvgElement
|
|
18
|
+
from pydreamplet.colors import randomColor
|
|
19
|
+
|
|
20
|
+
data = [130, 65, 108]
|
|
21
|
+
|
|
22
|
+
def waffle_chart(data, side=100, rows=10, cols=10, gutter=2, colors=[]):
|
|
23
|
+
sorted_data = sorted(data, reverse=True)
|
|
24
|
+
while len(colors) < len(sorted_data):
|
|
25
|
+
colors.append(randomColor())
|
|
26
|
+
|
|
27
|
+
svg = SVG(dimensions=(side, side))
|
|
28
|
+
|
|
29
|
+
total_cells = rows * cols
|
|
30
|
+
total = sum(data)
|
|
31
|
+
proportions = [int(round(d / total * total_cells, 0)) for d in sorted_data]
|
|
32
|
+
cell_side = (side - (cols + 1) * gutter) / cols
|
|
33
|
+
cell_group_map = []
|
|
34
|
+
for group_index, count in enumerate(proportions):
|
|
35
|
+
cell_group_map.extend([group_index] * count)
|
|
36
|
+
|
|
37
|
+
if len(cell_group_map) < total_cells:
|
|
38
|
+
cell_group_map.extend([None] * (total_cells - len(cell_group_map)))
|
|
39
|
+
|
|
40
|
+
paths = {i: "" for i in range(len(sorted_data))}
|
|
41
|
+
|
|
42
|
+
for i in range(total_cells):
|
|
43
|
+
col = i % cols
|
|
44
|
+
row = i // cols
|
|
45
|
+
|
|
46
|
+
x = gutter + col * (cell_side + gutter)
|
|
47
|
+
y = gutter + row * (cell_side + gutter)
|
|
48
|
+
|
|
49
|
+
group = cell_group_map[i]
|
|
50
|
+
if group is not None:
|
|
51
|
+
paths[group] += f"M {x} {y} h {cell_side} v {cell_side} h -{cell_side} Z "
|
|
52
|
+
|
|
53
|
+
for group_index, d_str in paths.items():
|
|
54
|
+
if d_str:
|
|
55
|
+
path = SvgElement("path", fill=colors[group_index], d=d_str)
|
|
56
|
+
svg.append(path)
|
|
57
|
+
|
|
58
|
+
return svg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
svg = waffle_chart(data)
|
|
62
|
+
svg.display() # in jupyter notebook
|
|
63
|
+
svg.save("waffle_chart.svg")
|
|
64
|
+
```
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from pydreamplet.utils import constrain, math_round
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def hexStr(n):
|
|
8
|
+
"""
|
|
9
|
+
Converts an integer (0-255) to a two-digit hexadecimal string.
|
|
10
|
+
"""
|
|
11
|
+
return format(n, "02x")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def randomInt(min_val, max_val):
|
|
15
|
+
"""Returns a random integer N such that min_val <= N <= max_val."""
|
|
16
|
+
return random.randint(min_val, max_val)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def str2rgb(col):
|
|
20
|
+
"""
|
|
21
|
+
Converts a hex color string to an RGB dictionary.
|
|
22
|
+
Accepts strings in the format "#RRGGBB" or "#RGB".
|
|
23
|
+
If the input doesn't match, returns {'r': 0, 'g': 0, 'b': 0}.
|
|
24
|
+
"""
|
|
25
|
+
rgb = {"r": 0, "g": 0, "b": 0}
|
|
26
|
+
# Regex matches a string starting with one or more '#' and then either 6 or 3 hex digits.
|
|
27
|
+
rgx = re.compile(r"^#+([a-fA-F\d]{6}|[a-fA-F\d]{3})$")
|
|
28
|
+
if rgx.match(col):
|
|
29
|
+
# Expand shorthand (e.g. "#abc" -> "#aabbcc")
|
|
30
|
+
if len(col) == 4:
|
|
31
|
+
col = "#" + col[1] * 2 + col[2] * 2 + col[3] * 2
|
|
32
|
+
try:
|
|
33
|
+
rgb["r"] = int(col[1:3], 16)
|
|
34
|
+
rgb["g"] = int(col[3:5], 16)
|
|
35
|
+
rgb["b"] = int(col[5:7], 16)
|
|
36
|
+
except ValueError:
|
|
37
|
+
# In case of conversion error, keep default (0,0,0)
|
|
38
|
+
pass
|
|
39
|
+
return rgb
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def color2rgba(c, alpha=1):
|
|
43
|
+
"""
|
|
44
|
+
Converts an input color (which can be a list/tuple of three numbers,
|
|
45
|
+
an integer, or a hex string) and an alpha value to an "rgba(r, g, b, a)" string.
|
|
46
|
+
"""
|
|
47
|
+
r = g = b = 0
|
|
48
|
+
a = 1
|
|
49
|
+
if isinstance(c, (list, tuple)):
|
|
50
|
+
if len(c) == 3:
|
|
51
|
+
r = constrain(c[0], 0, 255)
|
|
52
|
+
g = constrain(c[1], 0, 255)
|
|
53
|
+
b = constrain(c[2], 0, 255)
|
|
54
|
+
a = constrain(alpha, 0, 1)
|
|
55
|
+
else:
|
|
56
|
+
r = g = b = 0
|
|
57
|
+
a = 1
|
|
58
|
+
elif isinstance(c, int):
|
|
59
|
+
r = g = b = constrain(c, 0, 255)
|
|
60
|
+
a = constrain(alpha, 0, 1)
|
|
61
|
+
elif isinstance(c, str):
|
|
62
|
+
rgb = str2rgb(c)
|
|
63
|
+
r = rgb.get("r", 0)
|
|
64
|
+
g = rgb.get("g", 0)
|
|
65
|
+
b = rgb.get("b", 0)
|
|
66
|
+
a = constrain(alpha, 0, 1)
|
|
67
|
+
return f"rgba({r}, {g}, {b}, {a})"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def blend(color1, color2, proportion):
|
|
71
|
+
"""
|
|
72
|
+
Blends two hex color strings by the given proportion.
|
|
73
|
+
proportion: 0 returns color1, 1 returns color2.
|
|
74
|
+
Returns the blended color as a hex string.
|
|
75
|
+
"""
|
|
76
|
+
proportion = constrain(proportion, 0, 1)
|
|
77
|
+
# Ensure the colors start with '#'
|
|
78
|
+
c1 = color1 if color1.startswith("#") else "#" + color1
|
|
79
|
+
c2 = color2 if color2.startswith("#") else "#" + color2
|
|
80
|
+
|
|
81
|
+
# Regex to test for valid hex color (3 or 6 hex digits)
|
|
82
|
+
rgx = re.compile(r"^#+([a-fA-F\d]{6}|[a-fA-F\d]{3})$")
|
|
83
|
+
if rgx.match(c1) and rgx.match(c2):
|
|
84
|
+
# Remove leading '#' and expand shorthand if necessary.
|
|
85
|
+
col1 = c1[1:]
|
|
86
|
+
col2 = c2[1:]
|
|
87
|
+
if len(col1) == 3:
|
|
88
|
+
col1 = "".join([ch * 2 for ch in col1])
|
|
89
|
+
if len(col2) == 3:
|
|
90
|
+
col2 = "".join([ch * 2 for ch in col2])
|
|
91
|
+
try:
|
|
92
|
+
r1 = int(col1[0:2], 16)
|
|
93
|
+
r2 = int(col2[0:2], 16)
|
|
94
|
+
r = math_round((1 - proportion) * r1 + proportion * r2)
|
|
95
|
+
g1 = int(col1[2:4], 16)
|
|
96
|
+
g2 = int(col2[2:4], 16)
|
|
97
|
+
g = math_round((1 - proportion) * g1 + proportion * g2)
|
|
98
|
+
b1 = int(col1[4:6], 16)
|
|
99
|
+
b2 = int(col2[4:6], 16)
|
|
100
|
+
b = math_round((1 - proportion) * b1 + proportion * b2)
|
|
101
|
+
return "#" + hexStr(r) + hexStr(g) + hexStr(b)
|
|
102
|
+
except Exception:
|
|
103
|
+
return "#000000"
|
|
104
|
+
else:
|
|
105
|
+
return "#000000"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def randomColor():
|
|
109
|
+
"""
|
|
110
|
+
Generates a random hex color string.
|
|
111
|
+
"""
|
|
112
|
+
r = hexStr(randomInt(0, 255))
|
|
113
|
+
g = hexStr(randomInt(0, 255))
|
|
114
|
+
b = hexStr(randomInt(0, 255))
|
|
115
|
+
return "#" + r + g + b
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import xml.etree.ElementTree as ET
|
|
2
|
+
|
|
3
|
+
from IPython.display import SVG as IPythonSVG
|
|
4
|
+
from IPython.display import display
|
|
5
|
+
|
|
6
|
+
from pydreamplet.constants import PI
|
|
7
|
+
|
|
8
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
9
|
+
ET.register_namespace("", SVG_NS)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def qname(tag):
|
|
13
|
+
return f"{{{SVG_NS}}}{tag}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SvgElement:
|
|
17
|
+
def __init__(self, tag, **kwargs):
|
|
18
|
+
# Use object.__setattr__ to avoid triggering our custom __setattr__
|
|
19
|
+
object.__setattr__(self, "element", ET.Element(qname(tag)))
|
|
20
|
+
for k, v in self.normalize_attrs(kwargs).items():
|
|
21
|
+
self.element.set(k, str(v))
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def normalize_attrs(attrs):
|
|
25
|
+
"""Convert keys replacing underscores with hyphens."""
|
|
26
|
+
return {k.replace("_", "-"): str(v) for k, v in attrs.items()}
|
|
27
|
+
|
|
28
|
+
def attrs(self, attributes):
|
|
29
|
+
for key, value in self.normalize_attrs(attributes).items():
|
|
30
|
+
self.element.set(key, value)
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def append(self, child):
|
|
34
|
+
if hasattr(child, "element"):
|
|
35
|
+
self.element.append(child.element)
|
|
36
|
+
else:
|
|
37
|
+
self.element.append(child)
|
|
38
|
+
|
|
39
|
+
def remove(self, child):
|
|
40
|
+
if hasattr(child, "element"):
|
|
41
|
+
self.element.remove(child.element)
|
|
42
|
+
else:
|
|
43
|
+
self.element.remove(child)
|
|
44
|
+
|
|
45
|
+
def tostring(self):
|
|
46
|
+
return ET.tostring(self.element, encoding="unicode")
|
|
47
|
+
|
|
48
|
+
def __str__(self):
|
|
49
|
+
return self.tostring()
|
|
50
|
+
|
|
51
|
+
def __getattr__(self, name):
|
|
52
|
+
# This is only called if the attribute wasn't found normally.
|
|
53
|
+
attr_name = name.replace("_", "-")
|
|
54
|
+
if attr_name in self.element.attrib:
|
|
55
|
+
val = self.element.attrib[attr_name]
|
|
56
|
+
# Try to convert to a number if possible.
|
|
57
|
+
try:
|
|
58
|
+
# If there's no decimal point or exponent, convert to int.
|
|
59
|
+
if "." not in val and "e" not in val.lower():
|
|
60
|
+
return int(val)
|
|
61
|
+
else:
|
|
62
|
+
return float(val)
|
|
63
|
+
except ValueError:
|
|
64
|
+
# If conversion fails, return the original string.
|
|
65
|
+
return val
|
|
66
|
+
raise AttributeError(
|
|
67
|
+
f"{type(self).__name__!r} object has no attribute {name!r}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def __setattr__(self, name, value):
|
|
71
|
+
# If the attribute is one of our internal attributes or methods,
|
|
72
|
+
# set it normally. Otherwise, store it as an SVG attribute.
|
|
73
|
+
if name == "element" or name.startswith("_") or hasattr(type(self), name):
|
|
74
|
+
object.__setattr__(self, name, value)
|
|
75
|
+
else:
|
|
76
|
+
# Convert underscores to hyphens for attribute names.
|
|
77
|
+
self.element.set(name.replace("_", "-"), str(value))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SVG(SvgElement):
|
|
81
|
+
def __init__(
|
|
82
|
+
self, dimensions=(300, 300), viewbox: tuple[int] | None = None, **kwargs
|
|
83
|
+
):
|
|
84
|
+
"""
|
|
85
|
+
Create an SVG root element.
|
|
86
|
+
|
|
87
|
+
Parameters:
|
|
88
|
+
dimensions: tuple with 2 numbers [width, height] (e.g. (300, 300))
|
|
89
|
+
viewbox: tuple of 2 or 4 numbers.
|
|
90
|
+
- If 2 numbers, treated as [width, height] with origin (0, 0).
|
|
91
|
+
- If 4 numbers, treated as [minX, minY, width, height].
|
|
92
|
+
"""
|
|
93
|
+
super().__init__("svg", **kwargs)
|
|
94
|
+
self.attrs({"width": dimensions[0], "height": dimensions[1]})
|
|
95
|
+
if not viewbox:
|
|
96
|
+
vb = f"0 0 {dimensions[0]} {dimensions[1]}"
|
|
97
|
+
else:
|
|
98
|
+
if len(viewbox) == 4:
|
|
99
|
+
vb = f"{viewbox[0]} {viewbox[1]} {viewbox[2]} {viewbox[3]}"
|
|
100
|
+
elif len(viewbox) == 2:
|
|
101
|
+
vb = f"0 0 {viewbox[0]} {viewbox[1]}"
|
|
102
|
+
else:
|
|
103
|
+
raise ValueError("viewbox must be a list or tuple of 2 or 4 numbers")
|
|
104
|
+
self.attrs({"viewBox": vb})
|
|
105
|
+
|
|
106
|
+
def display(self):
|
|
107
|
+
display(IPythonSVG(self.tostring()))
|
|
108
|
+
|
|
109
|
+
def save(self, filename):
|
|
110
|
+
with open(filename, "w") as f:
|
|
111
|
+
f.write(self.tostring())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Animate(SvgElement):
|
|
115
|
+
def __init__(self, **kwargs):
|
|
116
|
+
"""
|
|
117
|
+
Create an animate element. Accepts attributes like attributeName, from, to, etc.
|
|
118
|
+
"""
|
|
119
|
+
super().__init__("animate", **kwargs)
|
|
120
|
+
self._repeat_count: int | str = "indefinite"
|
|
121
|
+
self._values: list[int] = []
|
|
122
|
+
self.attrs(
|
|
123
|
+
{
|
|
124
|
+
"attributeType": "XML",
|
|
125
|
+
"repeatCount": self._repeat_count,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def repeat_count(self) -> int | str:
|
|
131
|
+
return self._repeat_count
|
|
132
|
+
|
|
133
|
+
@repeat_count.setter
|
|
134
|
+
def repeat_count(self, value):
|
|
135
|
+
self._repeat_count = value
|
|
136
|
+
self.attrs({"repeatCount": value})
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def values(self) -> list[str]:
|
|
140
|
+
return self._values
|
|
141
|
+
|
|
142
|
+
@values.setter
|
|
143
|
+
def values(self, value):
|
|
144
|
+
self._values = value
|
|
145
|
+
self.attrs({"values": ";".join([str(v) for v in value])})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Circle(SvgElement):
|
|
149
|
+
def __init__(self, **kwargs):
|
|
150
|
+
"""
|
|
151
|
+
Create a circle element. Accepts attributes like cx, cy, r, etc.
|
|
152
|
+
"""
|
|
153
|
+
super().__init__("circle", **kwargs)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def radius(self):
|
|
157
|
+
return float(self.element.get("r", 0))
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def center(self):
|
|
161
|
+
return (float(self.element.get("cx", 0)), float(self.element.get("cy", 0)))
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def diameter(self):
|
|
165
|
+
return self.radius * 2
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def area(self):
|
|
169
|
+
return PI * self.radius**2
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Ellipse(SvgElement):
|
|
173
|
+
def __init__(self, **kwargs):
|
|
174
|
+
"""
|
|
175
|
+
Create an ellipse element. Accepts attributes like cx, cy, rx, ry, etc.
|
|
176
|
+
"""
|
|
177
|
+
super().__init__("ellipse", **kwargs)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Rect(SvgElement):
|
|
181
|
+
def __init__(self, **kwargs):
|
|
182
|
+
"""
|
|
183
|
+
Create a rectangle element. Accepts attributes like x, y, width, height, etc.
|
|
184
|
+
"""
|
|
185
|
+
super().__init__("rect", **kwargs)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Text(SvgElement):
|
|
189
|
+
def __init__(self, initial_text="", **kwargs):
|
|
190
|
+
"""
|
|
191
|
+
Create a text element.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
initial_text: The initial text to display.
|
|
195
|
+
kwargs: Additional SVG attributes (e.g. font_family, font_size, font_weight).
|
|
196
|
+
"""
|
|
197
|
+
super().__init__("text", **kwargs)
|
|
198
|
+
self._raw_text = initial_text
|
|
199
|
+
if initial_text:
|
|
200
|
+
self.content = initial_text # Use the property setter
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def content(self) -> str:
|
|
204
|
+
"""Return the current text content (raw string)."""
|
|
205
|
+
return self._raw_text
|
|
206
|
+
|
|
207
|
+
@content.setter
|
|
208
|
+
def content(self, new_text: str):
|
|
209
|
+
"""Update the text content and rebuild child <tspan> elements as needed."""
|
|
210
|
+
self._raw_text = new_text
|
|
211
|
+
# Clear any existing child elements.
|
|
212
|
+
for child in list(self.element):
|
|
213
|
+
self.element.remove(child)
|
|
214
|
+
|
|
215
|
+
if "\n" in new_text:
|
|
216
|
+
self.element.text = None
|
|
217
|
+
lines = new_text.split("\n")
|
|
218
|
+
for i, line in enumerate(lines):
|
|
219
|
+
tspan = ET.Element(qname("tspan"))
|
|
220
|
+
# For the first line, if x and y exist on the parent, set them.
|
|
221
|
+
if i == 0:
|
|
222
|
+
if "x" in self.element.attrib:
|
|
223
|
+
tspan.set("x", self.element.attrib["x"])
|
|
224
|
+
if "y" in self.element.attrib:
|
|
225
|
+
tspan.set("y", self.element.attrib["y"])
|
|
226
|
+
else:
|
|
227
|
+
# For subsequent lines, copy x and add a dy offset.
|
|
228
|
+
if "x" in self.element.attrib:
|
|
229
|
+
tspan.set("x", self.element.attrib["x"])
|
|
230
|
+
try:
|
|
231
|
+
dy_val = float(self.element.attrib.get("font-size", 16))
|
|
232
|
+
except ValueError:
|
|
233
|
+
dy_val = 16
|
|
234
|
+
tspan.set("dy", str(dy_val))
|
|
235
|
+
tspan.text = line
|
|
236
|
+
self.element.append(tspan)
|
|
237
|
+
else:
|
|
238
|
+
# For single-line text, set the text directly.
|
|
239
|
+
self.element.text = new_text
|
|
240
|
+
|
|
241
|
+
# def dimensions(self, measurer: TypographyMeasurer) -> tuple[float, float]:
|
|
242
|
+
# """
|
|
243
|
+
# Returns the (width, height) of the text element using the TypographyMeasurer.
|
|
244
|
+
# Uses the stored raw text and relevant font attributes.
|
|
245
|
+
# """
|
|
246
|
+
# font_family = self.element.attrib.get("font-family", "Arial")
|
|
247
|
+
# try:
|
|
248
|
+
# font_size = float(self.element.attrib.get("font-size", "16"))
|
|
249
|
+
# except ValueError:
|
|
250
|
+
# font_size = 16
|
|
251
|
+
# try:
|
|
252
|
+
# font_weight = int(self.element.attrib.get("font-weight", 400))
|
|
253
|
+
# except ValueError:
|
|
254
|
+
# font_weight = 400
|
|
255
|
+
|
|
256
|
+
# return measurer.measure_text(
|
|
257
|
+
# self._raw_text, font_family, font_weight, font_size
|
|
258
|
+
# )
|
|
259
|
+
|
|
260
|
+
# def width(self, measurer: TypographyMeasurer) -> float:
|
|
261
|
+
# """Return the width of the text element."""
|
|
262
|
+
# return self.dimensions(measurer)[0]
|
|
263
|
+
|
|
264
|
+
# def height(self, measurer: TypographyMeasurer) -> float:
|
|
265
|
+
# """Return the height of the text element."""
|
|
266
|
+
# return self.dimensions(measurer)[1]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def linear_scale(
|
|
6
|
+
domain: tuple[float, float], range_: tuple[float, float]
|
|
7
|
+
) -> Callable[[float], float]:
|
|
8
|
+
"""
|
|
9
|
+
Linearly maps a value from the input domain to the output range.
|
|
10
|
+
"""
|
|
11
|
+
d0, d1 = domain
|
|
12
|
+
r0, r1 = range_
|
|
13
|
+
|
|
14
|
+
def scale(value: float) -> float:
|
|
15
|
+
return ((value - d0) / (d1 - d0)) * (r1 - r0) + r0
|
|
16
|
+
|
|
17
|
+
return scale
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def band_scale(
|
|
21
|
+
domain: list[str], range_: tuple[float, float], padding: float = 0.1
|
|
22
|
+
) -> Callable[[str], float | None]:
|
|
23
|
+
"""
|
|
24
|
+
Maps categorical values (strings) to evenly spaced positions in the range.
|
|
25
|
+
The returned function also carries a `bandwidth` attribute that gives the computed band width.
|
|
26
|
+
"""
|
|
27
|
+
r0, r1 = range_
|
|
28
|
+
n = len(domain)
|
|
29
|
+
if n == 0:
|
|
30
|
+
raise ValueError("Domain must contain at least one value")
|
|
31
|
+
# Compute the step: total range divided by number of bands plus gaps
|
|
32
|
+
step = (r1 - r0) / (n + padding * (n - 1))
|
|
33
|
+
# Each band takes (1 - padding) portion of the step.
|
|
34
|
+
band_width = step * (1 - padding)
|
|
35
|
+
|
|
36
|
+
def scale(value: str) -> float | None:
|
|
37
|
+
try:
|
|
38
|
+
index = domain.index(value)
|
|
39
|
+
# Position is offset by index * step * (1 + padding)
|
|
40
|
+
return r0 + index * step * (1 + padding)
|
|
41
|
+
except ValueError:
|
|
42
|
+
return None # value not found in the domain
|
|
43
|
+
|
|
44
|
+
# Attach a bandwidth method to the scale function.
|
|
45
|
+
scale.bandwidth = lambda: band_width # type: ignore
|
|
46
|
+
return scale
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def point_scale(
|
|
50
|
+
domain: list[str], range_: tuple[float, float], padding: float = 0.5
|
|
51
|
+
) -> Callable[[str], float | None]:
|
|
52
|
+
"""
|
|
53
|
+
Maps categorical values to points in the range, placing padding at both ends.
|
|
54
|
+
"""
|
|
55
|
+
r0, r1 = range_
|
|
56
|
+
n = len(domain)
|
|
57
|
+
if n == 0:
|
|
58
|
+
raise ValueError("Domain must contain at least one value")
|
|
59
|
+
# Compute step based on (n - 1) intervals plus padding on each end.
|
|
60
|
+
step = (r1 - r0) / (n - 1 + 2 * padding)
|
|
61
|
+
|
|
62
|
+
def scale(value: str) -> float | None:
|
|
63
|
+
try:
|
|
64
|
+
index = domain.index(value)
|
|
65
|
+
return r0 + step * (index + padding)
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
return scale
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ordinal_scale(domain: list[str], range_: list) -> Callable[[str], object]:
|
|
73
|
+
"""
|
|
74
|
+
Maps categorical values to a set of output values in a cyclic fashion.
|
|
75
|
+
"""
|
|
76
|
+
if not range_:
|
|
77
|
+
raise ValueError("Range must contain at least one value")
|
|
78
|
+
mapping = {d: range_[i % len(range_)] for i, d in enumerate(domain)}
|
|
79
|
+
|
|
80
|
+
def scale(value: str) -> object:
|
|
81
|
+
return mapping.get(value)
|
|
82
|
+
|
|
83
|
+
return scale
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def square_scale(
|
|
87
|
+
domain: tuple[float, float], range_: tuple[float, float]
|
|
88
|
+
) -> Callable[[float], float]:
|
|
89
|
+
"""
|
|
90
|
+
Maps an input value (e.g. an area) to an output using a square-root transformation.
|
|
91
|
+
|
|
92
|
+
This is useful when the visual representation (like the side length of a square)
|
|
93
|
+
should be proportional to the square root of the area so that the actual area
|
|
94
|
+
is proportional to the input value.
|
|
95
|
+
|
|
96
|
+
The transformation is defined as:
|
|
97
|
+
scale(value) = r0 + ((sqrt(value) - sqrt(d0)) / (sqrt(d1) - sqrt(d0))) * (r1 - r0)
|
|
98
|
+
"""
|
|
99
|
+
d0, d1 = domain
|
|
100
|
+
r0, r1 = range_
|
|
101
|
+
if d0 < 0 or d1 < 0:
|
|
102
|
+
raise ValueError("Domain values must be non-negative for square scale")
|
|
103
|
+
sqrt_d0, sqrt_d1 = math.sqrt(d0), math.sqrt(d1)
|
|
104
|
+
if sqrt_d1 == sqrt_d0:
|
|
105
|
+
raise ValueError("Invalid domain: sqrt(d1) and sqrt(d0) cannot be equal")
|
|
106
|
+
|
|
107
|
+
def scale(value: float) -> float:
|
|
108
|
+
return r0 + ((math.sqrt(value) - sqrt_d0) / (sqrt_d1 - sqrt_d0)) * (r1 - r0)
|
|
109
|
+
|
|
110
|
+
return scale
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def circle_scale(
|
|
114
|
+
domain: tuple[float, float], range_: tuple[float, float]
|
|
115
|
+
) -> Callable[[float], float]:
|
|
116
|
+
"""
|
|
117
|
+
Maps an input value to the radius of a circle such that the circle's area is linearly
|
|
118
|
+
proportional to the input value.
|
|
119
|
+
|
|
120
|
+
If the input domain is (d0, d1) and the desired radius range is (r0, r1), then the area of the circle
|
|
121
|
+
will vary from π·r0² to π·r1². The mapping is given by:
|
|
122
|
+
|
|
123
|
+
radius(v) = sqrt( ((v - d0) / (d1 - d0)) * (r1² - r0²) + r0² )
|
|
124
|
+
|
|
125
|
+
This way, if you use π·radius(v)² as the circle’s area, it will be proportional to v.
|
|
126
|
+
"""
|
|
127
|
+
d0, d1 = domain
|
|
128
|
+
r0, r1 = range_
|
|
129
|
+
if d1 == d0:
|
|
130
|
+
raise ValueError("Domain values must be distinct")
|
|
131
|
+
|
|
132
|
+
def scale(value: float) -> float:
|
|
133
|
+
# Linearly interpolate between r0^2 and r1^2, then take the square root.
|
|
134
|
+
r_squared = ((value - d0) / (d1 - d0)) * (r1 * r1 - r0 * r0) + r0 * r0
|
|
135
|
+
return math.sqrt(r_squared)
|
|
136
|
+
|
|
137
|
+
return scale
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
|
|
4
|
+
from fontTools.ttLib import TTFont
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_system_font_path(
|
|
9
|
+
font_family: str, weight: int = 400, weight_tolerance: int = 100
|
|
10
|
+
) -> str | None:
|
|
11
|
+
"""
|
|
12
|
+
Search common system directories for a TrueType or OpenType font file (.ttf/.otf)
|
|
13
|
+
that matches the requested font_family and is within a specified tolerance of the desired weight.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
font_family: The desired system font name (e.g. "Arial").
|
|
17
|
+
weight: Numeric weight (e.g., 400 for regular, 700 for bold).
|
|
18
|
+
weight_tolerance: Allowed difference between the desired weight and the font's actual weight.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The full path to the matching font file, or None if no match is found.
|
|
22
|
+
"""
|
|
23
|
+
system = platform.system()
|
|
24
|
+
font_dirs = []
|
|
25
|
+
|
|
26
|
+
if system == "Windows":
|
|
27
|
+
# System-wide fonts directory.
|
|
28
|
+
system_fonts = os.path.join(os.environ.get("WINDIR", "C:\\Windows"), "Fonts")
|
|
29
|
+
font_dirs.append(system_fonts)
|
|
30
|
+
# User-specific fonts directory.
|
|
31
|
+
local_fonts = os.path.join(
|
|
32
|
+
os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Windows", "Fonts"
|
|
33
|
+
)
|
|
34
|
+
if local_fonts and os.path.exists(local_fonts):
|
|
35
|
+
font_dirs.append(local_fonts)
|
|
36
|
+
elif system == "Darwin":
|
|
37
|
+
font_dirs = [
|
|
38
|
+
"/System/Library/Fonts",
|
|
39
|
+
"/Library/Fonts",
|
|
40
|
+
os.path.expanduser("~/Library/Fonts"),
|
|
41
|
+
]
|
|
42
|
+
else:
|
|
43
|
+
# Assume Linux/Unix.
|
|
44
|
+
font_dirs = [
|
|
45
|
+
"/usr/share/fonts",
|
|
46
|
+
os.path.expanduser("~/.fonts"),
|
|
47
|
+
"/usr/local/share/fonts",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Consider both TTF and OTF files.
|
|
51
|
+
extensions = (".ttf", ".otf")
|
|
52
|
+
|
|
53
|
+
for font_dir in font_dirs:
|
|
54
|
+
if not os.path.exists(font_dir):
|
|
55
|
+
continue
|
|
56
|
+
for root, dirs, files in os.walk(font_dir):
|
|
57
|
+
for file in files:
|
|
58
|
+
if not file.lower().endswith(extensions):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
file_path = os.path.join(root, file)
|
|
62
|
+
try:
|
|
63
|
+
font = TTFont(file_path)
|
|
64
|
+
except Exception:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Loop over all name records for a looser match.
|
|
68
|
+
family_matches = False
|
|
69
|
+
for record in font["name"].names:
|
|
70
|
+
try:
|
|
71
|
+
record_value = record.toUnicode().strip()
|
|
72
|
+
except Exception:
|
|
73
|
+
record_value = record.string.decode(
|
|
74
|
+
"utf-8", errors="ignore"
|
|
75
|
+
).strip()
|
|
76
|
+
if font_family.lower() in record_value.lower():
|
|
77
|
+
family_matches = True
|
|
78
|
+
break
|
|
79
|
+
if not family_matches:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# If the OS/2 table exists, check the weight.
|
|
83
|
+
if "OS/2" in font:
|
|
84
|
+
os2_table = font["OS/2"]
|
|
85
|
+
font_weight = getattr(os2_table, "usWeightClass", 400)
|
|
86
|
+
if abs(font_weight - weight) <= weight_tolerance:
|
|
87
|
+
return file_path
|
|
88
|
+
else:
|
|
89
|
+
continue # Weight doesn't match, keep searching.
|
|
90
|
+
else:
|
|
91
|
+
# If no OS/2 table exists, return the first matching family.
|
|
92
|
+
return file_path
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TypographyMeasurer:
|
|
97
|
+
def __init__(self, dpi: float = 72.0, font_path: str | None = None):
|
|
98
|
+
"""
|
|
99
|
+
Initialize with a given DPI (dots per inch). The default is 72 DPI,
|
|
100
|
+
meaning 1 point equals 1 pixel. With higher DPI values, the point-to-pixel
|
|
101
|
+
conversion increases accordingly.
|
|
102
|
+
|
|
103
|
+
If a font_path is provided, it will be used; otherwise the system is searched.
|
|
104
|
+
"""
|
|
105
|
+
self.dpi = dpi
|
|
106
|
+
self.font_path = font_path
|
|
107
|
+
|
|
108
|
+
def measure_text(
|
|
109
|
+
self,
|
|
110
|
+
text: str,
|
|
111
|
+
font_family: str | None = None,
|
|
112
|
+
weight: int | None = None,
|
|
113
|
+
font_size: float = 12.0,
|
|
114
|
+
) -> tuple[float, float]:
|
|
115
|
+
"""
|
|
116
|
+
Measure the width and height of the given text rendered in the specified font.
|
|
117
|
+
Supports multiline text if newline characters are present.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
text: The text to measure.
|
|
121
|
+
font_family: The system font name (e.g., "Arial"). Optional if self.font_path is provided.
|
|
122
|
+
weight: Numeric weight (e.g., 400 for regular, 700 for bold). Optional if self.font_path is provided.
|
|
123
|
+
font_size: The desired font size in points.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A tuple (width, height) in pixels.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ValueError: If the specified font cannot be found and font_family or weight are missing.
|
|
130
|
+
"""
|
|
131
|
+
# If no font_path is already set, require font_family and weight.
|
|
132
|
+
if not self.font_path:
|
|
133
|
+
if font_family is None or weight is None:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"A font path was not provided and font_family and weight are required to search for a font."
|
|
136
|
+
)
|
|
137
|
+
self.font_path = get_system_font_path(font_family, weight)
|
|
138
|
+
if self.font_path is None:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Font '{font_family}' with weight {weight} not found on the system."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Convert point size to pixel size using DPI conversion.
|
|
144
|
+
pixel_size = font_size * self.dpi / 72.0
|
|
145
|
+
# ImageFont.truetype expects an integer size.
|
|
146
|
+
font = ImageFont.truetype(self.font_path, int(pixel_size))
|
|
147
|
+
|
|
148
|
+
# Create a dummy image for measurement.
|
|
149
|
+
dummy_img = Image.new("RGB", (1000, 1000))
|
|
150
|
+
draw = ImageDraw.Draw(dummy_img)
|
|
151
|
+
|
|
152
|
+
# Use multiline_textbbox if there are newline characters.
|
|
153
|
+
if "\n" in text:
|
|
154
|
+
bbox = draw.multiline_textbbox((0, 0), text, font=font)
|
|
155
|
+
else:
|
|
156
|
+
bbox = draw.textbbox((0, 0), text, font=font)
|
|
157
|
+
|
|
158
|
+
width = bbox[2] - bbox[0]
|
|
159
|
+
height = bbox[3] - bbox[1]
|
|
160
|
+
return float(width), float(height)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pydreamplet"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A library for assembling SVGs from Python"
|
|
5
|
+
authors = [{ name = "Marek Pilczuk", email = "user@mp76.pl" }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
keywords = ["svg", "graphics", "design"]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pillow (>=11.1.0,<12.0.0)",
|
|
12
|
+
"fonttools (>=4.56.0,<5.0.0)",
|
|
13
|
+
"ipython (>=8.32.0,<9.0.0)",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
19
|
+
build-backend = "poetry.core.masonry.api"
|
|
20
|
+
|
|
21
|
+
[tool.poetry.group.dev.dependencies]
|
|
22
|
+
jupyter = "^1.1.1"
|
|
23
|
+
ruff = "^0.9.5"
|
|
24
|
+
pytest = "^8.3.4"
|