decksmith 0.1.15__py3-none-any.whl → 0.9.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.
- decksmith/card_builder.py +78 -565
- decksmith/deck_builder.py +38 -54
- decksmith/export.py +170 -168
- decksmith/gui/__init__.py +0 -0
- decksmith/gui/app.py +341 -0
- decksmith/gui/static/css/style.css +656 -0
- decksmith/gui/static/js/main.js +583 -0
- decksmith/gui/templates/index.html +182 -0
- decksmith/image_ops.py +121 -0
- decksmith/logger.py +39 -0
- decksmith/macro.py +46 -0
- decksmith/main.py +31 -23
- decksmith/project.py +111 -0
- decksmith/renderers/__init__.py +3 -0
- decksmith/renderers/image.py +76 -0
- decksmith/renderers/shapes.py +237 -0
- decksmith/renderers/text.py +127 -0
- decksmith/templates/deck.csv +4 -5
- decksmith/templates/deck.yaml +46 -0
- decksmith/utils.py +75 -69
- decksmith/validate.py +132 -132
- {decksmith-0.1.15.dist-info → decksmith-0.9.1.dist-info}/METADATA +22 -16
- decksmith-0.9.1.dist-info/RECORD +26 -0
- {decksmith-0.1.15.dist-info → decksmith-0.9.1.dist-info}/WHEEL +1 -1
- decksmith/templates/deck.json +0 -31
- decksmith-0.1.15.dist-info/RECORD +0 -13
- {decksmith-0.1.15.dist-info → decksmith-0.9.1.dist-info}/entry_points.txt +0 -0
decksmith/card_builder.py
CHANGED
|
@@ -1,46 +1,55 @@
|
|
|
1
1
|
"""
|
|
2
2
|
This module contains the CardBuilder class,
|
|
3
|
-
which is used to create card images based on a
|
|
3
|
+
which is used to create card images based on a YAML specification.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import operator
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Dict,
|
|
8
|
+
from typing import Any, Dict, Optional, Tuple
|
|
9
9
|
|
|
10
|
-
import
|
|
11
|
-
from PIL import Image, ImageDraw, ImageFont
|
|
10
|
+
from PIL import Image, ImageDraw
|
|
12
11
|
|
|
13
|
-
from .
|
|
14
|
-
from .
|
|
12
|
+
from decksmith.logger import logger
|
|
13
|
+
from decksmith.renderers.image import ImageRenderer
|
|
14
|
+
from decksmith.renderers.shapes import ShapeRenderer
|
|
15
|
+
from decksmith.renderers.text import TextRenderer
|
|
16
|
+
from decksmith.utils import apply_anchor
|
|
17
|
+
from decksmith.validate import transform_card, validate_card
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
class CardBuilder:
|
|
18
21
|
"""
|
|
19
|
-
A class to build a card image based on a
|
|
22
|
+
A class to build a card image based on a YAML specification.
|
|
20
23
|
Attributes:
|
|
21
|
-
spec (Dict[str, Any]): The
|
|
24
|
+
spec (Dict[str, Any]): The YAML specification for the card.
|
|
22
25
|
card (Image.Image): The PIL Image object representing the card.
|
|
23
26
|
draw (ImageDraw.ImageDraw): The PIL ImageDraw object for drawing on the card.
|
|
24
27
|
element_positions (Dict[str, Tuple[int, int, int, int]]):
|
|
25
28
|
A dictionary mapping element IDs to their bounding boxes.
|
|
26
29
|
"""
|
|
27
30
|
|
|
28
|
-
def __init__(self, spec: Dict[str, Any]):
|
|
31
|
+
def __init__(self, spec: Dict[str, Any], base_path: Optional[Path] = None):
|
|
29
32
|
"""
|
|
30
|
-
Initializes the CardBuilder with a
|
|
33
|
+
Initializes the CardBuilder with a YAML specification.
|
|
31
34
|
Args:
|
|
32
|
-
spec (Dict[str, Any]): The
|
|
35
|
+
spec (Dict[str, Any]): The YAML specification for the card.
|
|
36
|
+
base_path (Optional[Path]): The base path for resolving relative file paths.
|
|
33
37
|
"""
|
|
34
38
|
self.spec = spec
|
|
39
|
+
self.base_path = base_path
|
|
35
40
|
width = self.spec.get("width", 250)
|
|
36
41
|
height = self.spec.get("height", 350)
|
|
37
|
-
|
|
38
|
-
self.card: Image.Image = Image.new("RGBA", (width, height),
|
|
42
|
+
background_color = tuple(self.spec.get("background_color", (255, 255, 255, 0)))
|
|
43
|
+
self.card: Image.Image = Image.new("RGBA", (width, height), background_color)
|
|
39
44
|
self.draw: ImageDraw.ImageDraw = ImageDraw.Draw(self.card, "RGBA")
|
|
40
45
|
self.element_positions: Dict[str, Tuple[int, int, int, int]] = {}
|
|
41
46
|
if "id" in spec:
|
|
42
47
|
self.element_positions[self.spec["id"]] = (0, 0, width, height)
|
|
43
48
|
|
|
49
|
+
self.text_renderer = TextRenderer(base_path)
|
|
50
|
+
self.image_renderer = ImageRenderer(base_path)
|
|
51
|
+
self.shape_renderer = ShapeRenderer()
|
|
52
|
+
|
|
44
53
|
def _calculate_absolute_position(self, element: Dict[str, Any]) -> Tuple[int, int]:
|
|
45
54
|
"""
|
|
46
55
|
Calculates the absolute position of an element,
|
|
@@ -55,573 +64,77 @@ class CardBuilder:
|
|
|
55
64
|
return tuple(element.get("position", [0, 0]))
|
|
56
65
|
|
|
57
66
|
# If the element has 'relative_to', resolve based on the reference element and anchor
|
|
58
|
-
|
|
59
|
-
if
|
|
67
|
+
relative_identifier, anchor = element["relative_to"]
|
|
68
|
+
if relative_identifier not in self.element_positions:
|
|
60
69
|
raise ValueError(
|
|
61
|
-
f"Element with id '{
|
|
70
|
+
f"Element with id '{relative_identifier}' not found for relative positioning."
|
|
62
71
|
)
|
|
63
72
|
|
|
64
|
-
parent_bbox = self.element_positions[
|
|
73
|
+
parent_bbox = self.element_positions[relative_identifier]
|
|
65
74
|
anchor_point = apply_anchor(parent_bbox, anchor)
|
|
66
75
|
|
|
67
76
|
offset = tuple(element.get("position", [0, 0]))
|
|
68
77
|
return tuple(map(operator.add, anchor_point, offset))
|
|
69
78
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"""
|
|
76
|
-
assert element.pop("type") == "text", "Element type must be 'text'"
|
|
77
|
-
|
|
78
|
-
# print(f"DEBUG: {element["text"]=}")
|
|
79
|
-
|
|
80
|
-
if pd.isna(element["text"]):
|
|
81
|
-
element["text"] = " "
|
|
82
|
-
|
|
83
|
-
# Convert font_path to a font object
|
|
84
|
-
font_size = element.pop("font_size", 10)
|
|
85
|
-
if font_path := element.pop("font_path", False):
|
|
86
|
-
element["font"] = ImageFont.truetype(
|
|
87
|
-
font_path,
|
|
88
|
-
font_size,
|
|
89
|
-
encoding="unic",
|
|
90
|
-
)
|
|
91
|
-
else:
|
|
92
|
-
element["font"] = ImageFont.load_default(font_size)
|
|
93
|
-
|
|
94
|
-
# Apply font_variant
|
|
95
|
-
if font_variant := element.pop("font_variant", None):
|
|
96
|
-
element["font"].set_variation_by_name(font_variant)
|
|
97
|
-
|
|
98
|
-
# Split text according to the specified width
|
|
99
|
-
if line_length := element.pop("width", False):
|
|
100
|
-
element["text"] = get_wrapped_text(
|
|
101
|
-
element["text"], element["font"], line_length
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
# Convert position and color to tuples
|
|
105
|
-
if position := element.pop("position", [0, 0]):
|
|
106
|
-
element["position"] = tuple(position)
|
|
107
|
-
if color := element.pop("color", [0, 0, 0]):
|
|
108
|
-
element["color"] = tuple(color)
|
|
109
|
-
if stroke_color := element.pop("stroke_color", None):
|
|
110
|
-
element["stroke_color"] = (
|
|
111
|
-
tuple(stroke_color) if stroke_color is not None else stroke_color
|
|
112
|
-
)
|
|
113
|
-
|
|
114
|
-
# Apply anchor manually (because PIL does not support anchor for multiline text)
|
|
115
|
-
original_pos = self._calculate_absolute_position(element)
|
|
116
|
-
element["position"] = original_pos
|
|
117
|
-
|
|
118
|
-
if "anchor" in element:
|
|
119
|
-
bbox = self.draw.textbbox(
|
|
120
|
-
xy=(0, 0),
|
|
121
|
-
text=element.get("text"),
|
|
122
|
-
font=element["font"],
|
|
123
|
-
spacing=element.get("line_spacing", 4),
|
|
124
|
-
align=element.get("align", "left"),
|
|
125
|
-
direction=element.get("direction", None),
|
|
126
|
-
features=element.get("features", None),
|
|
127
|
-
language=element.get("language", None),
|
|
128
|
-
stroke_width=element.get("stroke_width", 0),
|
|
129
|
-
embedded_color=element.get("embedded_color", False),
|
|
130
|
-
)
|
|
131
|
-
anchor_point = apply_anchor(bbox, element.pop("anchor"))
|
|
132
|
-
element["position"] = tuple(map(operator.sub, original_pos, anchor_point))
|
|
79
|
+
def _store_element_position(
|
|
80
|
+
self, element_identifier: str, bbox: Tuple[int, int, int, int]
|
|
81
|
+
):
|
|
82
|
+
"""Stores the bounding box of an element."""
|
|
83
|
+
self.element_positions[element_identifier] = bbox
|
|
133
84
|
|
|
134
|
-
|
|
135
|
-
self.draw.text(
|
|
136
|
-
xy=element.get("position"),
|
|
137
|
-
text=element.get("text"),
|
|
138
|
-
fill=element.get("color", None),
|
|
139
|
-
font=element["font"],
|
|
140
|
-
spacing=element.get("line_spacing", 4),
|
|
141
|
-
align=element.get("align", "left"),
|
|
142
|
-
direction=element.get("direction", None),
|
|
143
|
-
features=element.get("features", None),
|
|
144
|
-
language=element.get("language", None),
|
|
145
|
-
stroke_width=element.get("stroke_width", 0),
|
|
146
|
-
stroke_fill=element.get("stroke_color", None),
|
|
147
|
-
embedded_color=element.get("embedded_color", False),
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
# Store position if id is provided
|
|
151
|
-
if "id" in element:
|
|
152
|
-
bbox = self.draw.textbbox(
|
|
153
|
-
xy=element.get("position"),
|
|
154
|
-
text=element.get("text"),
|
|
155
|
-
font=element["font"],
|
|
156
|
-
spacing=element.get("line_spacing", 4),
|
|
157
|
-
align=element.get("align", "left"),
|
|
158
|
-
direction=element.get("direction", None),
|
|
159
|
-
features=element.get("features", None),
|
|
160
|
-
language=element.get("language", None),
|
|
161
|
-
stroke_width=element.get("stroke_width", 0),
|
|
162
|
-
embedded_color=element.get("embedded_color", False),
|
|
163
|
-
)
|
|
164
|
-
self.element_positions[element["id"]] = bbox
|
|
165
|
-
|
|
166
|
-
def _apply_image_filters(
|
|
167
|
-
self, img: Image.Image, filters: Dict[str, Any]
|
|
168
|
-
) -> Image.Image:
|
|
169
|
-
for filter_name, filter_value in filters.items():
|
|
170
|
-
filter_method_name = f"_filter_{filter_name}"
|
|
171
|
-
filter_method = getattr(self, filter_method_name, self._filter_unsupported)
|
|
172
|
-
img = filter_method(img, filter_value)
|
|
173
|
-
return img
|
|
174
|
-
|
|
175
|
-
def _filter_unsupported(self, img: Image.Image, _: Any) -> Image.Image:
|
|
176
|
-
return img
|
|
177
|
-
|
|
178
|
-
def _filter_crop(self, img: Image.Image, crop_values: List[int]) -> Image.Image:
|
|
179
|
-
return img.crop(crop_values)
|
|
180
|
-
|
|
181
|
-
def _filter_crop_top(self, img: Image.Image, value: int) -> Image.Image:
|
|
182
|
-
if value < 0:
|
|
183
|
-
img = img.convert("RGBA")
|
|
184
|
-
new_img = Image.new("RGBA", (img.width, img.height - value), (0, 0, 0, 0))
|
|
185
|
-
new_img.paste(img, (0, -value))
|
|
186
|
-
return new_img
|
|
187
|
-
return img.crop((0, value, img.width, img.height))
|
|
188
|
-
|
|
189
|
-
def _filter_crop_bottom(self, img: Image.Image, value: int) -> Image.Image:
|
|
190
|
-
if value < 0:
|
|
191
|
-
img = img.convert("RGBA")
|
|
192
|
-
new_img = Image.new("RGBA", (img.width, img.height - value), (0, 0, 0, 0))
|
|
193
|
-
new_img.paste(img, (0, 0))
|
|
194
|
-
return new_img
|
|
195
|
-
return img.crop((0, 0, img.width, img.height - value))
|
|
196
|
-
|
|
197
|
-
def _filter_crop_left(self, img: Image.Image, value: int) -> Image.Image:
|
|
198
|
-
if value < 0:
|
|
199
|
-
img = img.convert("RGBA")
|
|
200
|
-
new_img = Image.new("RGBA", (img.width - value, img.height), (0, 0, 0, 0))
|
|
201
|
-
new_img.paste(img, (-value, 0))
|
|
202
|
-
return new_img
|
|
203
|
-
return img.crop((value, 0, img.width, img.height))
|
|
204
|
-
|
|
205
|
-
def _filter_crop_right(self, img: Image.Image, value: int) -> Image.Image:
|
|
206
|
-
if value < 0:
|
|
207
|
-
img = img.convert("RGBA")
|
|
208
|
-
new_img = Image.new("RGBA", (img.width - value, img.height), (0, 0, 0, 0))
|
|
209
|
-
new_img.paste(img, (0, 0))
|
|
210
|
-
return new_img
|
|
211
|
-
return img.crop((0, 0, img.width - value, img.height))
|
|
212
|
-
|
|
213
|
-
def _filter_crop_box(self, img: Image.Image, box: List[int]) -> Image.Image:
|
|
214
|
-
img = img.convert("RGBA")
|
|
215
|
-
x, y, w, h = box
|
|
216
|
-
new_img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
217
|
-
src_x1 = max(0, x)
|
|
218
|
-
src_y1 = max(0, y)
|
|
219
|
-
src_x2 = min(img.width, x + w)
|
|
220
|
-
src_y2 = min(img.height, y + h)
|
|
221
|
-
if src_x1 < src_x2 and src_y1 < src_y2:
|
|
222
|
-
src_w = src_x2 - src_x1
|
|
223
|
-
src_h = src_y2 - src_y1
|
|
224
|
-
src_img = img.crop((src_x1, src_y1, src_x1 + src_w, src_y1 + src_h))
|
|
225
|
-
dst_x = src_x1 - x
|
|
226
|
-
dst_y = src_y1 - y
|
|
227
|
-
new_img.paste(src_img, (dst_x, dst_y))
|
|
228
|
-
return new_img
|
|
229
|
-
|
|
230
|
-
def _filter_resize(self, img: Image.Image, size: Tuple[int, int]) -> Image.Image:
|
|
231
|
-
new_width, new_height = size
|
|
232
|
-
if new_width is None and new_height is None:
|
|
233
|
-
return img
|
|
234
|
-
if new_width is None or new_height is None:
|
|
235
|
-
original_width, original_height = img.size
|
|
236
|
-
aspect_ratio = original_width / float(original_height)
|
|
237
|
-
if new_width is None:
|
|
238
|
-
new_width = int(new_height * aspect_ratio)
|
|
239
|
-
else:
|
|
240
|
-
new_height = int(new_width / aspect_ratio)
|
|
241
|
-
return img.resize((new_width, new_height))
|
|
242
|
-
|
|
243
|
-
def _filter_rotate(self, img: Image.Image, angle: float) -> Image.Image:
|
|
244
|
-
return img.rotate(angle, expand=True)
|
|
245
|
-
|
|
246
|
-
def _filter_flip(self, img: Image.Image, direction: str) -> Image.Image:
|
|
247
|
-
if direction == "horizontal":
|
|
248
|
-
# pylint: disable=E1101
|
|
249
|
-
return img.transpose(Image.FLIP_LEFT_RIGHT)
|
|
250
|
-
if direction == "vertical":
|
|
251
|
-
# pylint: disable=E1101
|
|
252
|
-
return img.transpose(Image.FLIP_TOP_BOTTOM)
|
|
253
|
-
return img
|
|
254
|
-
|
|
255
|
-
def _draw_image(self, element: Dict[str, Any]):
|
|
85
|
+
def render(self) -> Image.Image:
|
|
256
86
|
"""
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
"""
|
|
261
|
-
# Ensure the element type is 'image'
|
|
262
|
-
assert element.pop("type") == "image", "Element type must be 'image'"
|
|
263
|
-
|
|
264
|
-
# Load the image from the specified path
|
|
265
|
-
path = element["path"]
|
|
266
|
-
img = Image.open(path)
|
|
267
|
-
|
|
268
|
-
img = self._apply_image_filters(img, element.get("filters", {}))
|
|
269
|
-
|
|
270
|
-
# Convert position to a tuple
|
|
271
|
-
position = tuple(element.get("position", [0, 0]))
|
|
272
|
-
|
|
273
|
-
# Apply anchor if specified (because PIL does not support anchor for images)
|
|
274
|
-
position = self._calculate_absolute_position(element)
|
|
275
|
-
if "anchor" in element:
|
|
276
|
-
anchor_point = apply_anchor((img.width, img.height), element.pop("anchor"))
|
|
277
|
-
position = tuple(map(operator.sub, position, anchor_point))
|
|
278
|
-
|
|
279
|
-
# Paste the image onto the card at the specified position
|
|
280
|
-
if img.mode == "RGBA":
|
|
281
|
-
self.card.paste(img, position, mask=img)
|
|
282
|
-
else:
|
|
283
|
-
self.card.paste(img, position)
|
|
284
|
-
|
|
285
|
-
# Store position if id is provided
|
|
286
|
-
if "id" in element:
|
|
287
|
-
self.element_positions[element["id"]] = (
|
|
288
|
-
position[0],
|
|
289
|
-
position[1],
|
|
290
|
-
position[0] + img.width,
|
|
291
|
-
position[1] + img.height,
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
def _draw_shape_circle(self, element: Dict[str, Any]):
|
|
295
|
-
"""
|
|
296
|
-
Draws a circle on the card based on the provided element dictionary.
|
|
297
|
-
Args:
|
|
298
|
-
element (Dict[str, Any]): A dictionary containing circle properties.
|
|
299
|
-
"""
|
|
300
|
-
assert element.pop("type") == "circle", "Element type must be 'circle'"
|
|
301
|
-
|
|
302
|
-
radius = element["radius"]
|
|
303
|
-
size = (radius * 2, radius * 2)
|
|
304
|
-
|
|
305
|
-
# Calculate absolute position for the element's anchor
|
|
306
|
-
absolute_pos = self._calculate_absolute_position(element)
|
|
307
|
-
|
|
308
|
-
# Convert color and outline to a tuple if specified
|
|
309
|
-
if "color" in element:
|
|
310
|
-
element["fill"] = tuple(element["color"])
|
|
311
|
-
if "outline_color" in element:
|
|
312
|
-
element["outline_color"] = tuple(element["outline_color"])
|
|
313
|
-
|
|
314
|
-
# Apply anchor if specified
|
|
315
|
-
if "anchor" in element:
|
|
316
|
-
# anchor_offset is the offset of the anchor from the top-left corner
|
|
317
|
-
anchor_offset = apply_anchor(size, element.pop("anchor"))
|
|
318
|
-
# top_left is the target position minus the anchor offset
|
|
319
|
-
absolute_pos = tuple(map(operator.sub, absolute_pos, anchor_offset))
|
|
320
|
-
|
|
321
|
-
# The center of the circle is the top-left position + radius
|
|
322
|
-
center_pos = (absolute_pos[0] + radius, absolute_pos[1] + radius)
|
|
323
|
-
|
|
324
|
-
# Create a temporary layer for proper alpha compositing
|
|
325
|
-
layer = Image.new("RGBA", self.card.size, (0, 0, 0, 0))
|
|
326
|
-
layer_draw = ImageDraw.Draw(layer, "RGBA")
|
|
327
|
-
|
|
328
|
-
# Draw the circle on the temporary layer
|
|
329
|
-
layer_draw.circle(
|
|
330
|
-
center_pos,
|
|
331
|
-
radius,
|
|
332
|
-
fill=element.get("fill", None),
|
|
333
|
-
outline=element.get("outline_color", None),
|
|
334
|
-
width=element.get("outline_width", 1),
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
# Composite the layer onto the card
|
|
338
|
-
self.card = Image.alpha_composite(self.card, layer)
|
|
339
|
-
self.draw = ImageDraw.Draw(self.card, "RGBA")
|
|
340
|
-
|
|
341
|
-
# Store position if id is provided
|
|
342
|
-
if "id" in element:
|
|
343
|
-
# The stored bbox is based on the top-left position
|
|
344
|
-
self.element_positions[element["id"]] = (
|
|
345
|
-
absolute_pos[0],
|
|
346
|
-
absolute_pos[1],
|
|
347
|
-
absolute_pos[0] + size[0],
|
|
348
|
-
absolute_pos[1] + size[1],
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
def _draw_shape_ellipse(self, element: Dict[str, Any]):
|
|
352
|
-
"""
|
|
353
|
-
Draws an ellipse on the card based on the provided element dictionary.
|
|
354
|
-
Args:
|
|
355
|
-
element (Dict[str, Any]): A dictionary containing ellipse properties.
|
|
356
|
-
"""
|
|
357
|
-
assert element.pop("type") == "ellipse", "Element type must be 'ellipse'"
|
|
358
|
-
|
|
359
|
-
# Get size
|
|
360
|
-
size = element["size"]
|
|
361
|
-
|
|
362
|
-
# Calculate absolute position
|
|
363
|
-
position = self._calculate_absolute_position(element)
|
|
364
|
-
|
|
365
|
-
# Convert color and outline to a tuple if specified
|
|
366
|
-
if "color" in element:
|
|
367
|
-
element["fill"] = tuple(element["color"])
|
|
368
|
-
if "outline_color" in element:
|
|
369
|
-
element["outline_color"] = tuple(element["outline_color"])
|
|
370
|
-
|
|
371
|
-
# Apply anchor if specified
|
|
372
|
-
if "anchor" in element:
|
|
373
|
-
# For anchoring, we need an offset from the top-left corner.
|
|
374
|
-
# We calculate this offset based on the element's size.
|
|
375
|
-
anchor_offset = apply_anchor(size, element.pop("anchor"))
|
|
376
|
-
# We subtract the offset from the calculated absolute position
|
|
377
|
-
# to get the top-left corner of the bounding box.
|
|
378
|
-
position = tuple(map(operator.sub, position, anchor_offset))
|
|
379
|
-
|
|
380
|
-
# Compute bounding box from the final position and size
|
|
381
|
-
bounding_box = (
|
|
382
|
-
position[0],
|
|
383
|
-
position[1],
|
|
384
|
-
position[0] + size[0],
|
|
385
|
-
position[1] + size[1],
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
# Create a temporary layer for proper alpha compositing
|
|
389
|
-
layer = Image.new("RGBA", self.card.size, (0, 0, 0, 0))
|
|
390
|
-
layer_draw = ImageDraw.Draw(layer, "RGBA")
|
|
391
|
-
|
|
392
|
-
# Draw the ellipse on the temporary layer
|
|
393
|
-
layer_draw.ellipse(
|
|
394
|
-
bounding_box,
|
|
395
|
-
fill=element.get("fill", None),
|
|
396
|
-
outline=element.get("outline_color", None),
|
|
397
|
-
width=element.get("outline_width", 1),
|
|
398
|
-
)
|
|
399
|
-
|
|
400
|
-
# Composite the layer onto the card
|
|
401
|
-
self.card = Image.alpha_composite(self.card, layer)
|
|
402
|
-
self.draw = ImageDraw.Draw(self.card, "RGBA")
|
|
403
|
-
|
|
404
|
-
# Store position if id is provided
|
|
405
|
-
if "id" in element:
|
|
406
|
-
self.element_positions[element["id"]] = bounding_box
|
|
407
|
-
|
|
408
|
-
def _draw_shape_polygon(self, element: Dict[str, Any]):
|
|
409
|
-
"""
|
|
410
|
-
Draws a polygon on the card based on the provided element dictionary.
|
|
411
|
-
Args:
|
|
412
|
-
element (Dict[str, Any]): A dictionary containing polygon properties.
|
|
413
|
-
"""
|
|
414
|
-
assert element.pop("type") == "polygon", "Element type must be 'polygon'"
|
|
415
|
-
|
|
416
|
-
# Get points and convert to tuples
|
|
417
|
-
points = element.get("points", [])
|
|
418
|
-
if not points:
|
|
419
|
-
return
|
|
420
|
-
points = [tuple(p) for p in points]
|
|
421
|
-
|
|
422
|
-
# Compute bounding box relative to (0,0)
|
|
423
|
-
min_x = min(p[0] for p in points)
|
|
424
|
-
max_x = max(p[0] for p in points)
|
|
425
|
-
min_y = min(p[1] for p in points)
|
|
426
|
-
max_y = max(p[1] for p in points)
|
|
427
|
-
bounding_box = (min_x, min_y, max_x, max_y)
|
|
428
|
-
|
|
429
|
-
# Calculate absolute position for the element's anchor
|
|
430
|
-
absolute_pos = self._calculate_absolute_position(element)
|
|
431
|
-
|
|
432
|
-
# Convert color and outline to a tuple if specified
|
|
433
|
-
if "color" in element:
|
|
434
|
-
element["fill"] = tuple(element["color"])
|
|
435
|
-
if "outline_color" in element:
|
|
436
|
-
element["outline_color"] = tuple(element["outline_color"])
|
|
437
|
-
|
|
438
|
-
# This will be the top-left offset for the points
|
|
439
|
-
offset = absolute_pos
|
|
440
|
-
|
|
441
|
-
# Apply anchor if specified
|
|
442
|
-
if "anchor" in element:
|
|
443
|
-
# anchor_point is the coordinate of the anchor within the relative bbox
|
|
444
|
-
anchor_point = apply_anchor(bounding_box, element.pop("anchor"))
|
|
445
|
-
# The final offset is the target position minus the anchor point's relative coord
|
|
446
|
-
offset = tuple(map(operator.sub, absolute_pos, anchor_point))
|
|
447
|
-
|
|
448
|
-
# Translate points by the final offset
|
|
449
|
-
final_points = [(p[0] + offset[0], p[1] + offset[1]) for p in points]
|
|
450
|
-
|
|
451
|
-
# Create a temporary layer for proper alpha compositing
|
|
452
|
-
layer = Image.new("RGBA", self.card.size, (0, 0, 0, 0))
|
|
453
|
-
layer_draw = ImageDraw.Draw(layer, "RGBA")
|
|
454
|
-
|
|
455
|
-
# Draw the polygon on the temporary layer
|
|
456
|
-
layer_draw.polygon(
|
|
457
|
-
final_points,
|
|
458
|
-
fill=element.get("fill", None),
|
|
459
|
-
outline=element.get("outline_color", None),
|
|
460
|
-
width=element.get("outline_width", 1),
|
|
461
|
-
)
|
|
462
|
-
|
|
463
|
-
# Composite the layer onto the card
|
|
464
|
-
self.card = Image.alpha_composite(self.card, layer)
|
|
465
|
-
self.draw = ImageDraw.Draw(self.card, "RGBA")
|
|
466
|
-
|
|
467
|
-
# Store position if id is provided
|
|
468
|
-
if "id" in element:
|
|
469
|
-
# The stored bbox is the relative bbox translated by the offset
|
|
470
|
-
self.element_positions[element["id"]] = (
|
|
471
|
-
min_x + offset[0],
|
|
472
|
-
min_y + offset[1],
|
|
473
|
-
max_x + offset[0],
|
|
474
|
-
max_y + offset[1],
|
|
475
|
-
)
|
|
476
|
-
|
|
477
|
-
def _draw_shape_regular_polygon(self, element: Dict[str, Any]):
|
|
478
|
-
"""
|
|
479
|
-
Draws a regular polygon on the card based on the provided element dictionary.
|
|
480
|
-
Args:
|
|
481
|
-
element (Dict[str, Any]): A dictionary containing regular polygon properties.
|
|
482
|
-
"""
|
|
483
|
-
assert (
|
|
484
|
-
element.pop("type") == "regular-polygon"
|
|
485
|
-
), "Element type must be 'regular-polygon'"
|
|
486
|
-
|
|
487
|
-
radius = element["radius"]
|
|
488
|
-
size = (radius * 2, radius * 2)
|
|
489
|
-
|
|
490
|
-
# Calculate absolute position for the element's anchor
|
|
491
|
-
absolute_pos = self._calculate_absolute_position(element)
|
|
492
|
-
|
|
493
|
-
# Convert color and outline to a tuple if specified
|
|
494
|
-
if "color" in element:
|
|
495
|
-
element["fill"] = tuple(element["color"])
|
|
496
|
-
if "outline_color" in element:
|
|
497
|
-
element["outline_color"] = tuple(element["outline_color"])
|
|
498
|
-
|
|
499
|
-
# Apply anchor if specified
|
|
500
|
-
if "anchor" in element:
|
|
501
|
-
# anchor_offset is the offset of the anchor from the top-left corner
|
|
502
|
-
anchor_offset = apply_anchor(size, element.pop("anchor"))
|
|
503
|
-
# top_left is the target position minus the anchor offset
|
|
504
|
-
absolute_pos = tuple(map(operator.sub, absolute_pos, anchor_offset))
|
|
505
|
-
|
|
506
|
-
# The center of the polygon is the top-left position + radius
|
|
507
|
-
center_pos = (absolute_pos[0] + radius, absolute_pos[1] + radius)
|
|
508
|
-
|
|
509
|
-
# Create a temporary layer for proper alpha compositing
|
|
510
|
-
layer = Image.new("RGBA", self.card.size, (0, 0, 0, 0))
|
|
511
|
-
layer_draw = ImageDraw.Draw(layer, "RGBA")
|
|
512
|
-
|
|
513
|
-
# Draw the regular polygon on the temporary layer
|
|
514
|
-
layer_draw.regular_polygon(
|
|
515
|
-
(center_pos[0], center_pos[1], radius),
|
|
516
|
-
n_sides=element["sides"],
|
|
517
|
-
rotation=element.get("rotation", 0),
|
|
518
|
-
fill=element.get("fill", None),
|
|
519
|
-
outline=element.get("outline_color", None),
|
|
520
|
-
width=element.get("outline_width", 1),
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
# Composite the layer onto the card
|
|
524
|
-
self.card = Image.alpha_composite(self.card, layer)
|
|
525
|
-
self.draw = ImageDraw.Draw(self.card, "RGBA")
|
|
526
|
-
|
|
527
|
-
# Store position if id is provided
|
|
528
|
-
if "id" in element:
|
|
529
|
-
# The stored bbox is based on the top-left position
|
|
530
|
-
self.element_positions[element["id"]] = (
|
|
531
|
-
absolute_pos[0],
|
|
532
|
-
absolute_pos[1],
|
|
533
|
-
absolute_pos[0] + size[0],
|
|
534
|
-
absolute_pos[1] + size[1],
|
|
535
|
-
)
|
|
536
|
-
|
|
537
|
-
def _draw_shape_rectangle(self, element: Dict[str, Any]):
|
|
538
|
-
"""
|
|
539
|
-
Draws a rectangle on the card based on the provided element dictionary.
|
|
540
|
-
Args:
|
|
541
|
-
element (Dict[str, Any]): A dictionary containing rectangle properties.
|
|
87
|
+
Renders the card image by drawing all elements specified in the YAML.
|
|
88
|
+
Returns:
|
|
89
|
+
Image.Image: The rendered card image.
|
|
542
90
|
"""
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
# print(f"DEBUG: {element=}")
|
|
546
|
-
|
|
547
|
-
# Get size
|
|
548
|
-
size = element["size"]
|
|
549
|
-
|
|
550
|
-
# Calculate absolute position
|
|
551
|
-
position = self._calculate_absolute_position(element)
|
|
552
|
-
|
|
553
|
-
# Convert color, outline and corners to a tuple if specified
|
|
554
|
-
if "color" in element:
|
|
555
|
-
element["fill"] = tuple(element["color"])
|
|
556
|
-
if "outline_color" in element:
|
|
557
|
-
element["outline_color"] = tuple(element["outline_color"])
|
|
558
|
-
if "corners" in element:
|
|
559
|
-
element["corners"] = tuple(element["corners"])
|
|
560
|
-
|
|
561
|
-
# Apply anchor if specified
|
|
562
|
-
if "anchor" in element:
|
|
563
|
-
# For anchoring, we need an offset from the top-left corner.
|
|
564
|
-
# We calculate this offset based on the element's size.
|
|
565
|
-
anchor_offset = apply_anchor(size, element.pop("anchor"))
|
|
566
|
-
# We subtract the offset from the calculated absolute position
|
|
567
|
-
# to get the top-left corner of the bounding box.
|
|
568
|
-
position = tuple(map(operator.sub, position, anchor_offset))
|
|
569
|
-
|
|
570
|
-
# Compute bounding box from the final position and size
|
|
571
|
-
bounding_box = (
|
|
572
|
-
position[0],
|
|
573
|
-
position[1],
|
|
574
|
-
position[0] + size[0],
|
|
575
|
-
position[1] + size[1],
|
|
576
|
-
)
|
|
577
|
-
|
|
578
|
-
# print(f"DEBUG: Transformed {element=}")
|
|
579
|
-
|
|
580
|
-
# Create a temporary layer for proper alpha compositing
|
|
581
|
-
layer = Image.new("RGBA", self.card.size, (0, 0, 0, 0))
|
|
582
|
-
layer_draw = ImageDraw.Draw(layer, "RGBA")
|
|
583
|
-
|
|
584
|
-
# Draw the rectangle on the temporary layer
|
|
585
|
-
layer_draw.rounded_rectangle(
|
|
586
|
-
bounding_box,
|
|
587
|
-
radius=element.get("corner_radius", 0),
|
|
588
|
-
fill=element.get("fill", None),
|
|
589
|
-
outline=element.get("outline_color", None),
|
|
590
|
-
width=element.get("outline_width", 1),
|
|
591
|
-
corners=element.get("corners", None),
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
# Composite the layer onto the card
|
|
595
|
-
self.card = Image.alpha_composite(self.card, layer)
|
|
596
|
-
self.draw = ImageDraw.Draw(self.card, "RGBA")
|
|
91
|
+
self.spec = transform_card(self.spec)
|
|
92
|
+
validate_card(self.spec)
|
|
597
93
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
94
|
+
for element in self.spec.get("elements", []):
|
|
95
|
+
element_type = element.get("type")
|
|
96
|
+
try:
|
|
97
|
+
if element_type == "text":
|
|
98
|
+
self.text_renderer.render(
|
|
99
|
+
self.draw,
|
|
100
|
+
element,
|
|
101
|
+
self._calculate_absolute_position,
|
|
102
|
+
self._store_element_position,
|
|
103
|
+
)
|
|
104
|
+
elif element_type == "image":
|
|
105
|
+
self.image_renderer.render(
|
|
106
|
+
self.card,
|
|
107
|
+
element,
|
|
108
|
+
self._calculate_absolute_position,
|
|
109
|
+
self._store_element_position,
|
|
110
|
+
)
|
|
111
|
+
elif element_type in [
|
|
112
|
+
"circle",
|
|
113
|
+
"ellipse",
|
|
114
|
+
"polygon",
|
|
115
|
+
"regular-polygon",
|
|
116
|
+
"rectangle",
|
|
117
|
+
]:
|
|
118
|
+
self.card = self.shape_renderer.render(
|
|
119
|
+
self.card,
|
|
120
|
+
element,
|
|
121
|
+
self._calculate_absolute_position,
|
|
122
|
+
self._store_element_position,
|
|
123
|
+
)
|
|
124
|
+
# Re-create draw object because shape renderer might have composited a new image
|
|
125
|
+
self.draw = ImageDraw.Draw(self.card, "RGBA")
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error("Error drawing element %s: %s", element_type, e)
|
|
128
|
+
# Continue drawing other elements
|
|
129
|
+
|
|
130
|
+
return self.card
|
|
601
131
|
|
|
602
132
|
def build(self, output_path: Path):
|
|
603
133
|
"""
|
|
604
|
-
Builds the card image
|
|
134
|
+
Builds the card image and saves it to the specified path.
|
|
605
135
|
Args:
|
|
606
136
|
output_path (Path): The path where the card image will be saved.
|
|
607
137
|
"""
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
draw_methods = {
|
|
612
|
-
"text": self._draw_text,
|
|
613
|
-
"image": self._draw_image,
|
|
614
|
-
"circle": self._draw_shape_circle,
|
|
615
|
-
"ellipse": self._draw_shape_ellipse,
|
|
616
|
-
"polygon": self._draw_shape_polygon,
|
|
617
|
-
"regular-polygon": self._draw_shape_regular_polygon,
|
|
618
|
-
"rectangle": self._draw_shape_rectangle,
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
for element in self.spec.get("elements", []):
|
|
622
|
-
element_type = element.get("type")
|
|
623
|
-
if draw_method := draw_methods.get(element_type):
|
|
624
|
-
draw_method(element)
|
|
625
|
-
|
|
626
|
-
self.card.save(output_path)
|
|
627
|
-
print(f"(✔) Card saved to {output_path}")
|
|
138
|
+
card = self.render()
|
|
139
|
+
card.save(output_path)
|
|
140
|
+
logger.info("(✔) Card saved to %s", output_path)
|