decksmith 0.1.14__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 CHANGED
@@ -1,627 +1,140 @@
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 YAML specification.
4
+ """
5
+
6
+ import operator
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional, Tuple
9
+
10
+ from PIL import Image, ImageDraw
11
+
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
18
+
19
+
20
+ class CardBuilder:
21
+ """
22
+ A class to build a card image based on a YAML specification.
23
+ Attributes:
24
+ spec (Dict[str, Any]): The YAML specification for the card.
25
+ card (Image.Image): The PIL Image object representing the card.
26
+ draw (ImageDraw.ImageDraw): The PIL ImageDraw object for drawing on the card.
27
+ element_positions (Dict[str, Tuple[int, int, int, int]]):
28
+ A dictionary mapping element IDs to their bounding boxes.
29
+ """
30
+
31
+ def __init__(self, spec: Dict[str, Any], base_path: Optional[Path] = None):
32
+ """
33
+ Initializes the CardBuilder with a YAML specification.
34
+ Args:
35
+ spec (Dict[str, Any]): The YAML specification for the card.
36
+ base_path (Optional[Path]): The base path for resolving relative file paths.
37
+ """
38
+ self.spec = spec
39
+ self.base_path = base_path
40
+ width = self.spec.get("width", 250)
41
+ height = self.spec.get("height", 350)
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)
44
+ self.draw: ImageDraw.ImageDraw = ImageDraw.Draw(self.card, "RGBA")
45
+ self.element_positions: Dict[str, Tuple[int, int, int, int]] = {}
46
+ if "id" in spec:
47
+ self.element_positions[self.spec["id"]] = (0, 0, width, height)
48
+
49
+ self.text_renderer = TextRenderer(base_path)
50
+ self.image_renderer = ImageRenderer(base_path)
51
+ self.shape_renderer = ShapeRenderer()
52
+
53
+ def _calculate_absolute_position(self, element: Dict[str, Any]) -> Tuple[int, int]:
54
+ """
55
+ Calculates the absolute position of an element,
56
+ resolving relative positioning.
57
+ Args:
58
+ element (dict): The element dictionary.
59
+ Returns:
60
+ tuple: The absolute (x, y) position of the element.
61
+ """
62
+ # If the element has no 'relative_to', return its position directly
63
+ if "relative_to" not in element:
64
+ return tuple(element.get("position", [0, 0]))
65
+
66
+ # If the element has 'relative_to', resolve based on the reference element and anchor
67
+ relative_identifier, anchor = element["relative_to"]
68
+ if relative_identifier not in self.element_positions:
69
+ raise ValueError(
70
+ f"Element with id '{relative_identifier}' not found for relative positioning."
71
+ )
72
+
73
+ parent_bbox = self.element_positions[relative_identifier]
74
+ anchor_point = apply_anchor(parent_bbox, anchor)
75
+
76
+ offset = tuple(element.get("position", [0, 0]))
77
+ return tuple(map(operator.add, anchor_point, offset))
78
+
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
84
+
85
+ def render(self) -> Image.Image:
86
+ """
87
+ Renders the card image by drawing all elements specified in the YAML.
88
+ Returns:
89
+ Image.Image: The rendered card image.
90
+ """
91
+ self.spec = transform_card(self.spec)
92
+ validate_card(self.spec)
93
+
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
131
+
132
+ def build(self, output_path: Path):
133
+ """
134
+ Builds the card image and saves it to the specified path.
135
+ Args:
136
+ output_path (Path): The path where the card image will be saved.
137
+ """
138
+ card = self.render()
139
+ card.save(output_path)
140
+ logger.info("(✔) Card saved to %s", output_path)