decksmith 0.1.14__py3-none-any.whl → 0.1.15__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 +627 -627
- decksmith/deck_builder.py +101 -101
- decksmith/export.py +168 -168
- decksmith/main.py +138 -138
- decksmith/templates/deck.csv +5 -5
- decksmith/templates/deck.json +31 -31
- decksmith/utils.py +69 -69
- decksmith/validate.py +132 -132
- {decksmith-0.1.14.dist-info → decksmith-0.1.15.dist-info}/METADATA +36 -8
- decksmith-0.1.15.dist-info/RECORD +13 -0
- decksmith-0.1.14.dist-info/RECORD +0 -13
- {decksmith-0.1.14.dist-info → decksmith-0.1.15.dist-info}/WHEEL +0 -0
- {decksmith-0.1.14.dist-info → decksmith-0.1.15.dist-info}/entry_points.txt +0 -0
decksmith/card_builder.py
CHANGED
|
@@ -1,627 +1,627 @@
|
|
|
1
|
-
"""
|
|
2
|
-
This module contains the CardBuilder class,
|
|
3
|
-
which is used to create card images based on a JSON specification.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import operator
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Dict, Any, Tuple, List
|
|
9
|
-
|
|
10
|
-
import pandas as pd
|
|
11
|
-
from PIL import Image, ImageDraw, ImageFont
|
|
12
|
-
|
|
13
|
-
from .utils import get_wrapped_text, apply_anchor
|
|
14
|
-
from .validate import validate_card, transform_card
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class CardBuilder:
|
|
18
|
-
"""
|
|
19
|
-
A class to build a card image based on a JSON specification.
|
|
20
|
-
Attributes:
|
|
21
|
-
spec (Dict[str, Any]): The JSON specification for the card.
|
|
22
|
-
card (Image.Image): The PIL Image object representing the card.
|
|
23
|
-
draw (ImageDraw.ImageDraw): The PIL ImageDraw object for drawing on the card.
|
|
24
|
-
element_positions (Dict[str, Tuple[int, int, int, int]]):
|
|
25
|
-
A dictionary mapping element IDs to their bounding boxes.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
def __init__(self, spec: Dict[str, Any]):
|
|
29
|
-
"""
|
|
30
|
-
Initializes the CardBuilder with a JSON specification.
|
|
31
|
-
Args:
|
|
32
|
-
spec (Dict[str, Any]): The JSON specification for the card.
|
|
33
|
-
"""
|
|
34
|
-
self.spec = spec
|
|
35
|
-
width = self.spec.get("width", 250)
|
|
36
|
-
height = self.spec.get("height", 350)
|
|
37
|
-
bg_color = tuple(self.spec.get("background_color", (255, 255, 255, 0)))
|
|
38
|
-
self.card: Image.Image = Image.new("RGBA", (width, height), bg_color)
|
|
39
|
-
self.draw: ImageDraw.ImageDraw = ImageDraw.Draw(self.card, "RGBA")
|
|
40
|
-
self.element_positions: Dict[str, Tuple[int, int, int, int]] = {}
|
|
41
|
-
if "id" in spec:
|
|
42
|
-
self.element_positions[self.spec["id"]] = (0, 0, width, height)
|
|
43
|
-
|
|
44
|
-
def _calculate_absolute_position(self, element: Dict[str, Any]) -> Tuple[int, int]:
|
|
45
|
-
"""
|
|
46
|
-
Calculates the absolute position of an element,
|
|
47
|
-
resolving relative positioning.
|
|
48
|
-
Args:
|
|
49
|
-
element (dict): The element dictionary.
|
|
50
|
-
Returns:
|
|
51
|
-
tuple: The absolute (x, y) position of the element.
|
|
52
|
-
"""
|
|
53
|
-
# If the element has no 'relative_to', return its position directly
|
|
54
|
-
if "relative_to" not in element:
|
|
55
|
-
return tuple(element.get("position", [0, 0]))
|
|
56
|
-
|
|
57
|
-
# If the element has 'relative_to', resolve based on the reference element and anchor
|
|
58
|
-
relative_id, anchor = element["relative_to"]
|
|
59
|
-
if relative_id not in self.element_positions:
|
|
60
|
-
raise ValueError(
|
|
61
|
-
f"Element with id '{relative_id}' not found for relative positioning."
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
parent_bbox = self.element_positions[relative_id]
|
|
65
|
-
anchor_point = apply_anchor(parent_bbox, anchor)
|
|
66
|
-
|
|
67
|
-
offset = tuple(element.get("position", [0, 0]))
|
|
68
|
-
return tuple(map(operator.add, anchor_point, offset))
|
|
69
|
-
|
|
70
|
-
def _draw_text(self, element: Dict[str, Any]):
|
|
71
|
-
"""
|
|
72
|
-
Draws text on the card based on the provided element dictionary.
|
|
73
|
-
Args:
|
|
74
|
-
element (Dict[str, Any]): A dictionary containing text properties.
|
|
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))
|
|
133
|
-
|
|
134
|
-
# Unpack the element dictionary and draw the text
|
|
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]):
|
|
256
|
-
"""
|
|
257
|
-
Draws an image on the card based on the provided element dictionary.
|
|
258
|
-
Args:
|
|
259
|
-
element (Dict[str, Any]): A dictionary containing image properties.
|
|
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.
|
|
542
|
-
"""
|
|
543
|
-
assert element.pop("type") == "rectangle", "Element type must be 'rectangle'"
|
|
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")
|
|
597
|
-
|
|
598
|
-
# Store position if id is provided
|
|
599
|
-
if "id" in element:
|
|
600
|
-
self.element_positions[element["id"]] = bounding_box
|
|
601
|
-
|
|
602
|
-
def build(self, output_path: Path):
|
|
603
|
-
"""
|
|
604
|
-
Builds the card image by drawing all elements specified in the JSON.
|
|
605
|
-
Args:
|
|
606
|
-
output_path (Path): The path where the card image will be saved.
|
|
607
|
-
"""
|
|
608
|
-
self.spec = transform_card(self.spec)
|
|
609
|
-
validate_card(self.spec)
|
|
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}")
|
|
1
|
+
"""
|
|
2
|
+
This module contains the CardBuilder class,
|
|
3
|
+
which is used to create card images based on a JSON specification.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import operator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Any, Tuple, List
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
12
|
+
|
|
13
|
+
from .utils import get_wrapped_text, apply_anchor
|
|
14
|
+
from .validate import validate_card, transform_card
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CardBuilder:
|
|
18
|
+
"""
|
|
19
|
+
A class to build a card image based on a JSON specification.
|
|
20
|
+
Attributes:
|
|
21
|
+
spec (Dict[str, Any]): The JSON specification for the card.
|
|
22
|
+
card (Image.Image): The PIL Image object representing the card.
|
|
23
|
+
draw (ImageDraw.ImageDraw): The PIL ImageDraw object for drawing on the card.
|
|
24
|
+
element_positions (Dict[str, Tuple[int, int, int, int]]):
|
|
25
|
+
A dictionary mapping element IDs to their bounding boxes.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, spec: Dict[str, Any]):
|
|
29
|
+
"""
|
|
30
|
+
Initializes the CardBuilder with a JSON specification.
|
|
31
|
+
Args:
|
|
32
|
+
spec (Dict[str, Any]): The JSON specification for the card.
|
|
33
|
+
"""
|
|
34
|
+
self.spec = spec
|
|
35
|
+
width = self.spec.get("width", 250)
|
|
36
|
+
height = self.spec.get("height", 350)
|
|
37
|
+
bg_color = tuple(self.spec.get("background_color", (255, 255, 255, 0)))
|
|
38
|
+
self.card: Image.Image = Image.new("RGBA", (width, height), bg_color)
|
|
39
|
+
self.draw: ImageDraw.ImageDraw = ImageDraw.Draw(self.card, "RGBA")
|
|
40
|
+
self.element_positions: Dict[str, Tuple[int, int, int, int]] = {}
|
|
41
|
+
if "id" in spec:
|
|
42
|
+
self.element_positions[self.spec["id"]] = (0, 0, width, height)
|
|
43
|
+
|
|
44
|
+
def _calculate_absolute_position(self, element: Dict[str, Any]) -> Tuple[int, int]:
|
|
45
|
+
"""
|
|
46
|
+
Calculates the absolute position of an element,
|
|
47
|
+
resolving relative positioning.
|
|
48
|
+
Args:
|
|
49
|
+
element (dict): The element dictionary.
|
|
50
|
+
Returns:
|
|
51
|
+
tuple: The absolute (x, y) position of the element.
|
|
52
|
+
"""
|
|
53
|
+
# If the element has no 'relative_to', return its position directly
|
|
54
|
+
if "relative_to" not in element:
|
|
55
|
+
return tuple(element.get("position", [0, 0]))
|
|
56
|
+
|
|
57
|
+
# If the element has 'relative_to', resolve based on the reference element and anchor
|
|
58
|
+
relative_id, anchor = element["relative_to"]
|
|
59
|
+
if relative_id not in self.element_positions:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Element with id '{relative_id}' not found for relative positioning."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
parent_bbox = self.element_positions[relative_id]
|
|
65
|
+
anchor_point = apply_anchor(parent_bbox, anchor)
|
|
66
|
+
|
|
67
|
+
offset = tuple(element.get("position", [0, 0]))
|
|
68
|
+
return tuple(map(operator.add, anchor_point, offset))
|
|
69
|
+
|
|
70
|
+
def _draw_text(self, element: Dict[str, Any]):
|
|
71
|
+
"""
|
|
72
|
+
Draws text on the card based on the provided element dictionary.
|
|
73
|
+
Args:
|
|
74
|
+
element (Dict[str, Any]): A dictionary containing text properties.
|
|
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))
|
|
133
|
+
|
|
134
|
+
# Unpack the element dictionary and draw the text
|
|
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]):
|
|
256
|
+
"""
|
|
257
|
+
Draws an image on the card based on the provided element dictionary.
|
|
258
|
+
Args:
|
|
259
|
+
element (Dict[str, Any]): A dictionary containing image properties.
|
|
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.
|
|
542
|
+
"""
|
|
543
|
+
assert element.pop("type") == "rectangle", "Element type must be 'rectangle'"
|
|
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")
|
|
597
|
+
|
|
598
|
+
# Store position if id is provided
|
|
599
|
+
if "id" in element:
|
|
600
|
+
self.element_positions[element["id"]] = bounding_box
|
|
601
|
+
|
|
602
|
+
def build(self, output_path: Path):
|
|
603
|
+
"""
|
|
604
|
+
Builds the card image by drawing all elements specified in the JSON.
|
|
605
|
+
Args:
|
|
606
|
+
output_path (Path): The path where the card image will be saved.
|
|
607
|
+
"""
|
|
608
|
+
self.spec = transform_card(self.spec)
|
|
609
|
+
validate_card(self.spec)
|
|
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}")
|