pydreamplet 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ from pydreamplet.core import (
2
+ SVG,
3
+ Animate,
4
+ Circle,
5
+ Ellipse,
6
+ Rect,
7
+ SvgElement,
8
+ Text,
9
+ )
10
+
11
+ __all__ = [
12
+ "SVG",
13
+ "Animate",
14
+ "Circle",
15
+ "Ellipse",
16
+ "Rect",
17
+ "SvgElement",
18
+ "Text",
19
+ ]
pydreamplet/colors.py ADDED
@@ -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,6 @@
1
+ import math
2
+
3
+ PI = math.pi
4
+ HALF_PI = math.pi / 2
5
+ TWO_PI = math.pi * 2
6
+ E = math.e
pydreamplet/core.py ADDED
@@ -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]
pydreamplet/scales.py ADDED
@@ -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)
pydreamplet/utils.py ADDED
@@ -0,0 +1,10 @@
1
+ def math_round(x):
2
+ """
3
+ Rounds x to the nearest integer using round half up.
4
+ """
5
+ return int(x + 0.5)
6
+
7
+
8
+ def constrain(value, min_val, max_val):
9
+ """Constrain value between min_val and max_val."""
10
+ return max(min_val, min(value, max_val))
@@ -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,11 @@
1
+ pydreamplet/__init__.py,sha256=vJK7ThjR4ENAevkFmI8bHaHLhK5vsen2UoYZs61WVR0,247
2
+ pydreamplet/colors.py,sha256=-TqTO-THgvp2wysi_9Pec6qT6gSUjhwiYBFxEZxknCw,3753
3
+ pydreamplet/constants.py,sha256=jqWwtL8Rk0yLu2jzcwDIDcHJf-umGz9GOsDi7L0X3sI,86
4
+ pydreamplet/core.py,sha256=OzQvbXTdS6ZT4f644IHl0UU1HnhfYt9g6AZhK5LBvhw,9168
5
+ pydreamplet/scales.py,sha256=EGKreZpH2ZoybmqJGxfa4sWrcb-chhcM9R4UT9baUOg,4696
6
+ pydreamplet/typography.py,sha256=CJ_pmhF0IaXJBikg632KTfknE1jfv1U_jKixXccMehI,6184
7
+ pydreamplet/utils.py,sha256=DDcHzwXBS3fyL0vurqz3KZQwN6e7P1yU54uU6FilQ-s,268
8
+ pydreamplet-0.1.0.dist-info/LICENSE,sha256=mfqifx8hPFgBSy-cOcdQBs0Eg8rTGUspxonEv9-EtUI,1089
9
+ pydreamplet-0.1.0.dist-info/METADATA,sha256=ceOtLwC4xYpkwWMWdjveHSaoVVRnYdcobDm2tHH8iAA,2293
10
+ pydreamplet-0.1.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
11
+ pydreamplet-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.0.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any