decksmith 0.1.9__py3-none-any.whl → 0.1.11__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 +137 -161
- decksmith/deck_builder.py +20 -13
- decksmith/export.py +16 -25
- decksmith/main.py +56 -22
- decksmith/utils.py +44 -58
- decksmith/validate.py +12 -10
- {decksmith-0.1.9.dist-info → decksmith-0.1.11.dist-info}/METADATA +4 -2
- decksmith-0.1.11.dist-info/RECORD +13 -0
- decksmith-0.1.9.dist-info/RECORD +0 -13
- {decksmith-0.1.9.dist-info → decksmith-0.1.11.dist-info}/WHEEL +0 -0
- {decksmith-0.1.9.dist-info → decksmith-0.1.11.dist-info}/entry_points.txt +0 -0
decksmith/card_builder.py
CHANGED
|
@@ -4,8 +4,12 @@ which is used to create card images based on a JSON specification.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import operator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Any, Tuple, List
|
|
9
|
+
|
|
7
10
|
import pandas as pd
|
|
8
11
|
from PIL import Image, ImageDraw, ImageFont
|
|
12
|
+
|
|
9
13
|
from .utils import get_wrapped_text, apply_anchor
|
|
10
14
|
from .validate import validate_card, transform_card
|
|
11
15
|
|
|
@@ -14,29 +18,30 @@ class CardBuilder:
|
|
|
14
18
|
"""
|
|
15
19
|
A class to build a card image based on a JSON specification.
|
|
16
20
|
Attributes:
|
|
17
|
-
spec (
|
|
18
|
-
card (Image): The PIL Image object representing the card.
|
|
19
|
-
draw (ImageDraw): The PIL ImageDraw object for drawing on the card.
|
|
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.
|
|
20
26
|
"""
|
|
21
27
|
|
|
22
|
-
def __init__(self, spec:
|
|
28
|
+
def __init__(self, spec: Dict[str, Any]):
|
|
23
29
|
"""
|
|
24
|
-
Initializes the CardBuilder with a JSON specification
|
|
30
|
+
Initializes the CardBuilder with a JSON specification.
|
|
25
31
|
Args:
|
|
26
|
-
|
|
32
|
+
spec (Dict[str, Any]): The JSON specification for the card.
|
|
27
33
|
"""
|
|
28
34
|
self.spec = spec
|
|
29
35
|
width = self.spec.get("width", 250)
|
|
30
36
|
height = self.spec.get("height", 350)
|
|
31
37
|
bg_color = tuple(self.spec.get("background_color", (255, 255, 255, 0)))
|
|
32
|
-
self.card = Image.new("RGBA", (width, height), bg_color)
|
|
33
|
-
self.draw = ImageDraw.Draw(self.card, "RGBA")
|
|
34
|
-
self.element_positions = {}
|
|
35
|
-
# Store position if id is provided
|
|
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]] = {}
|
|
36
41
|
if "id" in spec:
|
|
37
42
|
self.element_positions[self.spec["id"]] = (0, 0, width, height)
|
|
38
43
|
|
|
39
|
-
def _calculate_absolute_position(self, element:
|
|
44
|
+
def _calculate_absolute_position(self, element: Dict[str, Any]) -> Tuple[int, int]:
|
|
40
45
|
"""
|
|
41
46
|
Calculates the absolute position of an element,
|
|
42
47
|
resolving relative positioning.
|
|
@@ -62,13 +67,11 @@ class CardBuilder:
|
|
|
62
67
|
offset = tuple(element.get("position", [0, 0]))
|
|
63
68
|
return tuple(map(operator.add, anchor_point, offset))
|
|
64
69
|
|
|
65
|
-
def _draw_text(self, element:
|
|
70
|
+
def _draw_text(self, element: Dict[str, Any]):
|
|
66
71
|
"""
|
|
67
72
|
Draws text on the card based on the provided element dictionary.
|
|
68
73
|
Args:
|
|
69
|
-
element (
|
|
70
|
-
'text', 'font_path', 'font_size', 'position',
|
|
71
|
-
'color', and 'width'.
|
|
74
|
+
element (Dict[str, Any]): A dictionary containing text properties.
|
|
72
75
|
"""
|
|
73
76
|
assert element.pop("type") == "text", "Element type must be 'text'"
|
|
74
77
|
|
|
@@ -160,12 +163,100 @@ class CardBuilder:
|
|
|
160
163
|
)
|
|
161
164
|
self.element_positions[element["id"]] = bbox
|
|
162
165
|
|
|
163
|
-
def
|
|
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]):
|
|
164
256
|
"""
|
|
165
257
|
Draws an image on the card based on the provided element dictionary.
|
|
166
258
|
Args:
|
|
167
|
-
element (
|
|
168
|
-
'path', 'filters', and 'position'.
|
|
259
|
+
element (Dict[str, Any]): A dictionary containing image properties.
|
|
169
260
|
"""
|
|
170
261
|
# Ensure the element type is 'image'
|
|
171
262
|
assert element.pop("type") == "image", "Element type must be 'image'"
|
|
@@ -174,98 +265,7 @@ class CardBuilder:
|
|
|
174
265
|
path = element["path"]
|
|
175
266
|
img = Image.open(path)
|
|
176
267
|
|
|
177
|
-
|
|
178
|
-
if "filters" in element:
|
|
179
|
-
for filter_name, filter_value in element["filters"].items():
|
|
180
|
-
if filter_name == "crop_top":
|
|
181
|
-
if filter_value < 0:
|
|
182
|
-
img = img.convert("RGBA")
|
|
183
|
-
new_img = Image.new(
|
|
184
|
-
"RGBA",
|
|
185
|
-
(img.width, img.height - filter_value),
|
|
186
|
-
(0, 0, 0, 0),
|
|
187
|
-
)
|
|
188
|
-
new_img.paste(img, (0, -filter_value))
|
|
189
|
-
img = new_img
|
|
190
|
-
else:
|
|
191
|
-
img = img.crop((0, filter_value, img.width, img.height))
|
|
192
|
-
elif filter_name == "crop_bottom":
|
|
193
|
-
if filter_value < 0:
|
|
194
|
-
img = img.convert("RGBA")
|
|
195
|
-
new_img = Image.new(
|
|
196
|
-
"RGBA",
|
|
197
|
-
(img.width, img.height - filter_value),
|
|
198
|
-
(0, 0, 0, 0),
|
|
199
|
-
)
|
|
200
|
-
new_img.paste(img, (0, 0))
|
|
201
|
-
img = new_img
|
|
202
|
-
else:
|
|
203
|
-
img = img.crop((0, 0, img.width, img.height - filter_value))
|
|
204
|
-
elif filter_name == "crop_left":
|
|
205
|
-
if filter_value < 0:
|
|
206
|
-
img = img.convert("RGBA")
|
|
207
|
-
new_img = Image.new(
|
|
208
|
-
"RGBA",
|
|
209
|
-
(img.width - filter_value, img.height),
|
|
210
|
-
(0, 0, 0, 0),
|
|
211
|
-
)
|
|
212
|
-
new_img.paste(img, (-filter_value, 0))
|
|
213
|
-
img = new_img
|
|
214
|
-
else:
|
|
215
|
-
img = img.crop((filter_value, 0, img.width, img.height))
|
|
216
|
-
elif filter_name == "crop_right":
|
|
217
|
-
if filter_value < 0:
|
|
218
|
-
img = img.convert("RGBA")
|
|
219
|
-
new_img = Image.new(
|
|
220
|
-
"RGBA",
|
|
221
|
-
(img.width - filter_value, img.height),
|
|
222
|
-
(0, 0, 0, 0),
|
|
223
|
-
)
|
|
224
|
-
new_img.paste(img, (0, 0))
|
|
225
|
-
img = new_img
|
|
226
|
-
else:
|
|
227
|
-
img = img.crop((0, 0, img.width - filter_value, img.height))
|
|
228
|
-
elif filter_name == "crop_box":
|
|
229
|
-
img = img.convert("RGBA")
|
|
230
|
-
x, y, w, h = filter_value
|
|
231
|
-
new_img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
232
|
-
src_x1 = max(0, x)
|
|
233
|
-
src_y1 = max(0, y)
|
|
234
|
-
src_x2 = min(img.width, x + w)
|
|
235
|
-
src_y2 = min(img.height, y + h)
|
|
236
|
-
if src_x1 < src_x2 and src_y1 < src_y2:
|
|
237
|
-
src_w = src_x2 - src_x1
|
|
238
|
-
src_h = src_y2 - src_y1
|
|
239
|
-
src_img = img.crop(
|
|
240
|
-
(src_x1, src_y1, src_x1 + src_w, src_y1 + src_h)
|
|
241
|
-
)
|
|
242
|
-
dst_x = src_x1 - x
|
|
243
|
-
dst_y = src_y1 - y
|
|
244
|
-
new_img.paste(src_img, (dst_x, dst_y))
|
|
245
|
-
img = new_img
|
|
246
|
-
elif filter_name == "resize":
|
|
247
|
-
new_width, new_height = filter_value
|
|
248
|
-
if new_width is None and new_height is None:
|
|
249
|
-
continue
|
|
250
|
-
if new_width is None or new_height is None:
|
|
251
|
-
original_width, original_height = img.size
|
|
252
|
-
aspect_ratio = original_width / float(original_height)
|
|
253
|
-
if new_width is None:
|
|
254
|
-
new_width = int(new_height * aspect_ratio)
|
|
255
|
-
else: # new_height is None
|
|
256
|
-
new_height = int(new_width / aspect_ratio)
|
|
257
|
-
img = img.resize((new_width, new_height))
|
|
258
|
-
elif filter_name == "rotate":
|
|
259
|
-
img = img.rotate(filter_value, expand=True)
|
|
260
|
-
elif filter_name == "flip":
|
|
261
|
-
if filter_value == "horizontal":
|
|
262
|
-
# pylint: disable=E1101
|
|
263
|
-
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
|
264
|
-
elif filter_value == "vertical":
|
|
265
|
-
# pylint: disable=E1101
|
|
266
|
-
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
|
267
|
-
else:
|
|
268
|
-
raise ValueError(f"Unknown filter: {filter_name}")
|
|
268
|
+
img = self._apply_image_filters(img, element.get("filters", {}))
|
|
269
269
|
|
|
270
270
|
# Convert position to a tuple
|
|
271
271
|
position = tuple(element.get("position", [0, 0]))
|
|
@@ -291,15 +291,11 @@ class CardBuilder:
|
|
|
291
291
|
position[1] + img.height,
|
|
292
292
|
)
|
|
293
293
|
|
|
294
|
-
def _draw_shape_circle(self, element):
|
|
294
|
+
def _draw_shape_circle(self, element: Dict[str, Any]):
|
|
295
295
|
"""
|
|
296
296
|
Draws a circle on the card based on the provided element dictionary.
|
|
297
297
|
Args:
|
|
298
|
-
element (
|
|
299
|
-
'position', 'radius', 'color', 'outline', 'width', and 'anchor'.
|
|
300
|
-
|
|
301
|
-
Raises:
|
|
302
|
-
AssertionError: If the element type is not 'circle'.
|
|
298
|
+
element (Dict[str, Any]): A dictionary containing circle properties.
|
|
303
299
|
"""
|
|
304
300
|
assert element.pop("type") == "circle", "Element type must be 'circle'"
|
|
305
301
|
|
|
@@ -344,16 +340,11 @@ class CardBuilder:
|
|
|
344
340
|
absolute_pos[1] + size[1],
|
|
345
341
|
)
|
|
346
342
|
|
|
347
|
-
def _draw_shape_ellipse(self, element):
|
|
343
|
+
def _draw_shape_ellipse(self, element: Dict[str, Any]):
|
|
348
344
|
"""
|
|
349
345
|
Draws an ellipse on the card based on the provided element dictionary.
|
|
350
|
-
|
|
351
346
|
Args:
|
|
352
|
-
element (
|
|
353
|
-
'position', 'size', 'color', 'outline', 'width', and 'anchor'.
|
|
354
|
-
|
|
355
|
-
Raises:
|
|
356
|
-
AssertionError: If the element type is not 'ellipse'.
|
|
347
|
+
element (Dict[str, Any]): A dictionary containing ellipse properties.
|
|
357
348
|
"""
|
|
358
349
|
assert element.pop("type") == "ellipse", "Element type must be 'ellipse'"
|
|
359
350
|
|
|
@@ -398,14 +389,11 @@ class CardBuilder:
|
|
|
398
389
|
if "id" in element:
|
|
399
390
|
self.element_positions[element["id"]] = bounding_box
|
|
400
391
|
|
|
401
|
-
def _draw_shape_polygon(self, element):
|
|
392
|
+
def _draw_shape_polygon(self, element: Dict[str, Any]):
|
|
402
393
|
"""
|
|
403
394
|
Draws a polygon on the card based on the provided element dictionary.
|
|
404
395
|
Args:
|
|
405
|
-
element (
|
|
406
|
-
'position', 'points', 'color', 'outline', 'width', and 'anchor'.
|
|
407
|
-
Raises:
|
|
408
|
-
AssertionError: If the element type is not 'polygon'.
|
|
396
|
+
element (Dict[str, Any]): A dictionary containing polygon properties.
|
|
409
397
|
"""
|
|
410
398
|
assert element.pop("type") == "polygon", "Element type must be 'polygon'"
|
|
411
399
|
|
|
@@ -462,17 +450,11 @@ class CardBuilder:
|
|
|
462
450
|
max_y + offset[1],
|
|
463
451
|
)
|
|
464
452
|
|
|
465
|
-
def _draw_shape_regular_polygon(self, element):
|
|
453
|
+
def _draw_shape_regular_polygon(self, element: Dict[str, Any]):
|
|
466
454
|
"""
|
|
467
455
|
Draws a regular polygon on the card based on the provided element dictionary.
|
|
468
|
-
|
|
469
456
|
Args:
|
|
470
|
-
element (
|
|
471
|
-
'position', 'radius', 'sides', 'rotation', 'color', 'outline',
|
|
472
|
-
'width', and 'anchor'.
|
|
473
|
-
|
|
474
|
-
Raises:
|
|
475
|
-
AssertionError: If the element type is not 'regular-polygon'.
|
|
457
|
+
element (Dict[str, Any]): A dictionary containing regular polygon properties.
|
|
476
458
|
"""
|
|
477
459
|
assert (
|
|
478
460
|
element.pop("type") == "regular-polygon"
|
|
@@ -520,16 +502,11 @@ class CardBuilder:
|
|
|
520
502
|
absolute_pos[1] + size[1],
|
|
521
503
|
)
|
|
522
504
|
|
|
523
|
-
def _draw_shape_rectangle(self, element):
|
|
505
|
+
def _draw_shape_rectangle(self, element: Dict[str, Any]):
|
|
524
506
|
"""
|
|
525
507
|
Draws a rectangle on the card based on the provided element dictionary.
|
|
526
|
-
|
|
527
508
|
Args:
|
|
528
|
-
element (
|
|
529
|
-
'size', 'color', 'outline_color', 'width', 'radius',
|
|
530
|
-
'corners', 'position', and 'anchor'.
|
|
531
|
-
Raises:
|
|
532
|
-
AssertionError: If the element type is not 'rectangle'.
|
|
509
|
+
element (Dict[str, Any]): A dictionary containing rectangle properties.
|
|
533
510
|
"""
|
|
534
511
|
assert element.pop("type") == "rectangle", "Element type must be 'rectangle'"
|
|
535
512
|
|
|
@@ -582,30 +559,29 @@ class CardBuilder:
|
|
|
582
559
|
if "id" in element:
|
|
583
560
|
self.element_positions[element["id"]] = bounding_box
|
|
584
561
|
|
|
585
|
-
def build(self, output_path):
|
|
562
|
+
def build(self, output_path: Path):
|
|
586
563
|
"""
|
|
587
564
|
Builds the card image by drawing all elements specified in the JSON.
|
|
588
565
|
Args:
|
|
589
|
-
output_path (
|
|
566
|
+
output_path (Path): The path where the card image will be saved.
|
|
590
567
|
"""
|
|
591
568
|
self.spec = transform_card(self.spec)
|
|
592
569
|
validate_card(self.spec)
|
|
593
570
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
self._draw_shape_rectangle(el)
|
|
571
|
+
draw_methods = {
|
|
572
|
+
"text": self._draw_text,
|
|
573
|
+
"image": self._draw_image,
|
|
574
|
+
"circle": self._draw_shape_circle,
|
|
575
|
+
"ellipse": self._draw_shape_ellipse,
|
|
576
|
+
"polygon": self._draw_shape_polygon,
|
|
577
|
+
"regular-polygon": self._draw_shape_regular_polygon,
|
|
578
|
+
"rectangle": self._draw_shape_rectangle,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
for element in self.spec.get("elements", []):
|
|
582
|
+
element_type = element.get("type")
|
|
583
|
+
if draw_method := draw_methods.get(element_type):
|
|
584
|
+
draw_method(element)
|
|
585
|
+
|
|
610
586
|
self.card.save(output_path)
|
|
611
587
|
print(f"(✔) Card saved to {output_path}")
|
decksmith/deck_builder.py
CHANGED
|
@@ -7,8 +7,10 @@ and a CSV file.
|
|
|
7
7
|
import concurrent.futures
|
|
8
8
|
import json
|
|
9
9
|
from pathlib import Path
|
|
10
|
+
from typing import Union, List, Dict, Any
|
|
10
11
|
|
|
11
12
|
import pandas as pd
|
|
13
|
+
from pandas import Series
|
|
12
14
|
|
|
13
15
|
from .card_builder import CardBuilder
|
|
14
16
|
|
|
@@ -17,23 +19,23 @@ class DeckBuilder:
|
|
|
17
19
|
"""
|
|
18
20
|
A class to build a deck of cards based on a JSON specification and a CSV file.
|
|
19
21
|
Attributes:
|
|
20
|
-
spec_path (
|
|
21
|
-
csv_path (
|
|
22
|
+
spec_path (Path): Path to the JSON specification file.
|
|
23
|
+
csv_path (Union[Path, None]): Path to the CSV file containing card data.
|
|
22
24
|
cards (list): List of CardBuilder instances for each card in the deck.
|
|
23
25
|
"""
|
|
24
26
|
|
|
25
|
-
def __init__(self, spec_path:
|
|
27
|
+
def __init__(self, spec_path: Path, csv_path: Union[Path, None] = None):
|
|
26
28
|
"""
|
|
27
29
|
Initializes the DeckBuilder with a JSON specification file and a CSV file.
|
|
28
30
|
Args:
|
|
29
|
-
spec_path (
|
|
30
|
-
csv_path (
|
|
31
|
+
spec_path (Path): Path to the JSON specification file.
|
|
32
|
+
csv_path (Union[Path, None]): Path to the CSV file containing card data.
|
|
31
33
|
"""
|
|
32
34
|
self.spec_path = spec_path
|
|
33
35
|
self.csv_path = csv_path
|
|
34
|
-
self.cards = []
|
|
36
|
+
self.cards: List[CardBuilder] = []
|
|
35
37
|
|
|
36
|
-
def _replace_macros(self, row:
|
|
38
|
+
def _replace_macros(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
|
37
39
|
"""
|
|
38
40
|
Replaces %colname% macros in the card specification with values from the row.
|
|
39
41
|
Works recursively for nested structures.
|
|
@@ -43,7 +45,7 @@ class DeckBuilder:
|
|
|
43
45
|
dict: The updated card specification with macros replaced.
|
|
44
46
|
"""
|
|
45
47
|
|
|
46
|
-
def replace_in_value(value):
|
|
48
|
+
def replace_in_value(value: Any) -> Any:
|
|
47
49
|
if isinstance(value, str):
|
|
48
50
|
stripped_value = value.strip()
|
|
49
51
|
# First, check for an exact macro match to preserve type
|
|
@@ -68,24 +70,29 @@ class DeckBuilder:
|
|
|
68
70
|
spec = json.load(f)
|
|
69
71
|
return replace_in_value(spec)
|
|
70
72
|
|
|
71
|
-
def build_deck(self, output_path:
|
|
73
|
+
def build_deck(self, output_path: Path):
|
|
72
74
|
"""
|
|
73
75
|
Builds the deck of cards by reading the CSV file and creating CardBuilder instances.
|
|
74
76
|
"""
|
|
75
|
-
if not self.csv_path:
|
|
77
|
+
if not self.csv_path or not self.csv_path.exists():
|
|
76
78
|
with open(self.spec_path, "r", encoding="utf-8") as f:
|
|
77
79
|
spec = json.load(f)
|
|
78
80
|
card_builder = CardBuilder(spec)
|
|
79
|
-
card_builder.build(
|
|
81
|
+
card_builder.build(output_path / "card_1.png")
|
|
80
82
|
return
|
|
81
83
|
|
|
82
84
|
df = pd.read_csv(self.csv_path, encoding="utf-8", sep=";", header=0)
|
|
83
85
|
|
|
84
|
-
def build_card(row_tuple):
|
|
86
|
+
def build_card(row_tuple: tuple[int, Series]):
|
|
87
|
+
"""
|
|
88
|
+
Builds a single card from a row of the CSV file.
|
|
89
|
+
Args:
|
|
90
|
+
row_tuple (tuple[int, Series]): A tuple containing the row index and the row data.
|
|
91
|
+
"""
|
|
85
92
|
idx, row = row_tuple
|
|
86
93
|
spec = self._replace_macros(row.to_dict())
|
|
87
94
|
card_builder = CardBuilder(spec)
|
|
88
|
-
card_builder.build(
|
|
95
|
+
card_builder.build(output_path / f"card_{idx + 1}.png")
|
|
89
96
|
|
|
90
97
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
91
98
|
list(executor.map(build_card, df.iterrows()))
|
decksmith/export.py
CHANGED
|
@@ -14,29 +14,19 @@ from reportlab.pdfgen import canvas
|
|
|
14
14
|
class PdfExporter:
|
|
15
15
|
"""
|
|
16
16
|
A class to export images from a folder to a PDF file.
|
|
17
|
-
|
|
18
|
-
Attributes:
|
|
19
|
-
image_folder (Path): The folder containing the images to be exported.
|
|
20
|
-
image_paths (List[str]): A list of paths to the images to be exported.
|
|
21
|
-
output_path (str): The path to the output PDF file.
|
|
22
|
-
page_size (Tuple[float, float]): The size of the PDF pages.
|
|
23
|
-
image_width (float): The width of the images in mm.
|
|
24
|
-
image_height (float): The height of the images in mm.
|
|
25
|
-
gap (float): The gap between images in pixels.
|
|
26
|
-
margins (Tuple[float, float]): The horizontal and vertical margins of the pages.
|
|
27
17
|
"""
|
|
28
18
|
|
|
29
19
|
def __init__(
|
|
30
20
|
self,
|
|
31
|
-
image_folder:
|
|
32
|
-
output_path:
|
|
21
|
+
image_folder: Path,
|
|
22
|
+
output_path: Path,
|
|
33
23
|
page_size_str: str = "A4",
|
|
34
24
|
image_width: float = 63,
|
|
35
25
|
image_height: float = 88,
|
|
36
26
|
gap: float = 0,
|
|
37
27
|
margins: Tuple[float, float] = (2, 2),
|
|
38
28
|
):
|
|
39
|
-
self.image_folder =
|
|
29
|
+
self.image_folder = image_folder
|
|
40
30
|
self.image_paths = self._get_image_paths()
|
|
41
31
|
self.output_path = output_path
|
|
42
32
|
self.page_size = self._get_page_size(page_size_str)
|
|
@@ -44,9 +34,9 @@ class PdfExporter:
|
|
|
44
34
|
self.image_height = image_height * mm
|
|
45
35
|
self.gap = gap * mm
|
|
46
36
|
self.margins = (margins[0] * mm, margins[1] * mm)
|
|
47
|
-
self.pdf = canvas.Canvas(self.output_path, pagesize=self.page_size)
|
|
37
|
+
self.pdf = canvas.Canvas(str(self.output_path), pagesize=self.page_size)
|
|
48
38
|
|
|
49
|
-
def _get_image_paths(self) -> List[
|
|
39
|
+
def _get_image_paths(self) -> List[Path]:
|
|
50
40
|
"""
|
|
51
41
|
Scans the image folder and returns a list of image paths.
|
|
52
42
|
|
|
@@ -54,11 +44,13 @@ class PdfExporter:
|
|
|
54
44
|
List[str]: A sorted list of image paths.
|
|
55
45
|
"""
|
|
56
46
|
image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
47
|
+
return sorted(
|
|
48
|
+
[
|
|
49
|
+
p
|
|
50
|
+
for p in self.image_folder.iterdir()
|
|
51
|
+
if p.suffix.lower() in image_extensions
|
|
52
|
+
]
|
|
53
|
+
)
|
|
62
54
|
|
|
63
55
|
def _get_page_size(self, page_size_str: str) -> Tuple[float, float]:
|
|
64
56
|
"""
|
|
@@ -118,8 +110,7 @@ class PdfExporter:
|
|
|
118
110
|
cols, rows, rotated = self._calculate_layout(page_width, page_height)
|
|
119
111
|
|
|
120
112
|
if cols == 0 or rows == 0:
|
|
121
|
-
|
|
122
|
-
return
|
|
113
|
+
raise ValueError("The images are too large to fit on the page.")
|
|
123
114
|
|
|
124
115
|
img_w, img_h = (
|
|
125
116
|
(self.image_width, self.image_height)
|
|
@@ -134,7 +125,7 @@ class PdfExporter:
|
|
|
134
125
|
|
|
135
126
|
images_on_page = 0
|
|
136
127
|
for image_path in self.image_paths:
|
|
137
|
-
if images_on_page
|
|
128
|
+
if images_on_page > 0 and images_on_page % (cols * rows) == 0:
|
|
138
129
|
self.pdf.showPage()
|
|
139
130
|
images_on_page = 0
|
|
140
131
|
|
|
@@ -146,7 +137,7 @@ class PdfExporter:
|
|
|
146
137
|
|
|
147
138
|
if not rotated:
|
|
148
139
|
self.pdf.drawImage(
|
|
149
|
-
image_path,
|
|
140
|
+
str(image_path),
|
|
150
141
|
x,
|
|
151
142
|
y,
|
|
152
143
|
width=img_w,
|
|
@@ -160,7 +151,7 @@ class PdfExporter:
|
|
|
160
151
|
self.pdf.translate(center_x, center_y)
|
|
161
152
|
self.pdf.rotate(90)
|
|
162
153
|
self.pdf.drawImage(
|
|
163
|
-
image_path,
|
|
154
|
+
str(image_path),
|
|
164
155
|
-img_h / 2,
|
|
165
156
|
-img_w / 2,
|
|
166
157
|
width=img_h,
|
decksmith/main.py
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
This module provides a command-line tool for building decks of cards.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
import os
|
|
6
5
|
import shutil
|
|
7
6
|
from importlib import resources
|
|
8
|
-
|
|
7
|
+
from pathlib import Path
|
|
9
8
|
import traceback
|
|
10
9
|
|
|
11
10
|
import click
|
|
@@ -21,7 +20,7 @@ def cli():
|
|
|
21
20
|
@cli.command()
|
|
22
21
|
def init():
|
|
23
22
|
"""Initializes a new project by creating deck.json and deck.csv."""
|
|
24
|
-
if
|
|
23
|
+
if Path("deck.json").exists() or Path("deck.csv").exists():
|
|
25
24
|
click.echo("(!) Project already initialized.")
|
|
26
25
|
return
|
|
27
26
|
|
|
@@ -33,57 +32,90 @@ def init():
|
|
|
33
32
|
click.echo("(✔) Initialized new project from templates.")
|
|
34
33
|
|
|
35
34
|
|
|
36
|
-
@cli.command()
|
|
35
|
+
@cli.command(context_settings={"show_default": True})
|
|
37
36
|
@click.option("--output", default="output", help="The output directory for the deck.")
|
|
38
37
|
@click.option(
|
|
39
38
|
"--spec", default="deck.json", help="The path to the deck specification file."
|
|
40
39
|
)
|
|
41
40
|
@click.option("--data", default="deck.csv", help="The path to the data file.")
|
|
42
|
-
|
|
41
|
+
@click.pass_context
|
|
42
|
+
def build(ctx, output, spec, data):
|
|
43
43
|
"""Builds the deck of cards."""
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
output_path = Path(output)
|
|
45
|
+
output_path.mkdir(exist_ok=True)
|
|
46
|
+
|
|
47
|
+
click.echo(f"(i) Building deck in {output_path}...")
|
|
46
48
|
|
|
47
|
-
click.echo(f"(i) Building deck in {output}...")
|
|
48
49
|
try:
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
spec_path = Path(spec)
|
|
51
|
+
if not spec_path.exists():
|
|
52
|
+
raise FileNotFoundError(f"Spec file not found: {spec_path}")
|
|
53
|
+
|
|
54
|
+
csv_path = Path(data)
|
|
55
|
+
if not csv_path.exists():
|
|
56
|
+
source = ctx.get_parameter_source("data")
|
|
57
|
+
if source.name == "DEFAULT":
|
|
58
|
+
click.echo(
|
|
59
|
+
f"(i) Building a single card deck because '{csv_path}' was not found"
|
|
60
|
+
)
|
|
61
|
+
csv_path = None
|
|
62
|
+
else:
|
|
63
|
+
raise FileNotFoundError(f"Data file not found: {csv_path}")
|
|
64
|
+
|
|
65
|
+
builder = DeckBuilder(spec_path, csv_path)
|
|
66
|
+
builder.build_deck(output_path)
|
|
67
|
+
except FileNotFoundError as exc:
|
|
68
|
+
click.echo(f"(x) {exc}")
|
|
69
|
+
ctx.exit(1)
|
|
51
70
|
# pylint: disable=W0718
|
|
52
71
|
except Exception as exc:
|
|
53
|
-
with open("log.txt", "
|
|
72
|
+
with open("log.txt", "a", encoding="utf-8") as log:
|
|
54
73
|
log.write(traceback.format_exc())
|
|
55
74
|
# print(f"{traceback.format_exc()}", end="\n")
|
|
56
75
|
print(f"(x) Error building deck '{data}' from spec '{spec}':")
|
|
57
76
|
print(" " * 4 + f"{exc}")
|
|
58
|
-
|
|
77
|
+
ctx.exit(1)
|
|
59
78
|
|
|
60
79
|
click.echo("(✔) Deck built successfully.")
|
|
61
80
|
|
|
62
81
|
|
|
63
|
-
@cli.command()
|
|
82
|
+
@cli.command(context_settings={"show_default": True})
|
|
64
83
|
@click.argument("image_folder")
|
|
65
|
-
@click.option("--output", default="output.pdf", help="The output PDF file path.")
|
|
66
|
-
@click.option("--page-size", default="A4", help="The page size (e.g., A4).")
|
|
67
84
|
@click.option(
|
|
68
|
-
"--
|
|
85
|
+
"--output", default="output.pdf", help="The path for the output PDF file."
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--page-size", default="A4", help="The page size for the PDF (e.g., A4, Letter)."
|
|
69
89
|
)
|
|
70
90
|
@click.option(
|
|
71
|
-
"--
|
|
91
|
+
"--width", type=float, default=63.5, help="The width for each image in millimeters."
|
|
92
|
+
)
|
|
93
|
+
@click.option(
|
|
94
|
+
"--height",
|
|
95
|
+
type=float,
|
|
96
|
+
default=88.9,
|
|
97
|
+
help="The height for each image in millimeters.",
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--gap", type=float, default=0, help="The gap between images in millimeters."
|
|
72
101
|
)
|
|
73
|
-
@click.option("--gap", type=float, default=0, help="The gap between images in pixels.")
|
|
74
102
|
@click.option(
|
|
75
103
|
"--margins",
|
|
76
104
|
type=float,
|
|
77
105
|
nargs=2,
|
|
78
106
|
default=[2, 2],
|
|
79
|
-
help="The horizontal and vertical margins in
|
|
107
|
+
help="The horizontal and vertical page margins in millimeters.",
|
|
80
108
|
)
|
|
81
109
|
def export(image_folder, output, page_size, width, height, gap, margins):
|
|
82
110
|
"""Exports images from a folder to a PDF file."""
|
|
83
111
|
try:
|
|
112
|
+
image_folder_path = Path(image_folder)
|
|
113
|
+
if not image_folder_path.exists():
|
|
114
|
+
raise FileNotFoundError(f"Image folder not found: {image_folder_path}")
|
|
115
|
+
|
|
84
116
|
exporter = PdfExporter(
|
|
85
|
-
image_folder=
|
|
86
|
-
output_path=output,
|
|
117
|
+
image_folder=image_folder_path,
|
|
118
|
+
output_path=Path(output),
|
|
87
119
|
page_size_str=page_size,
|
|
88
120
|
image_width=width,
|
|
89
121
|
image_height=height,
|
|
@@ -92,9 +124,11 @@ def export(image_folder, output, page_size, width, height, gap, margins):
|
|
|
92
124
|
)
|
|
93
125
|
exporter.export()
|
|
94
126
|
click.echo(f"(✔) Successfully exported PDF to {output}")
|
|
127
|
+
except FileNotFoundError as exc:
|
|
128
|
+
click.echo(f"(x) {exc}")
|
|
95
129
|
# pylint: disable=W0718
|
|
96
130
|
except Exception as exc:
|
|
97
|
-
with open("log.txt", "
|
|
131
|
+
with open("log.txt", "a", encoding="utf-8") as log:
|
|
98
132
|
log.write(traceback.format_exc())
|
|
99
133
|
print(f"(x) Error exporting images to '{output}':")
|
|
100
134
|
print(" " * 4 + f"{exc}")
|
decksmith/utils.py
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
This module provides utility functions for text wrapping and positioning.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
5
7
|
from PIL import ImageFont
|
|
6
8
|
|
|
7
9
|
|
|
8
|
-
def get_wrapped_text(text: str, font: ImageFont.ImageFont, line_length: int):
|
|
10
|
+
def get_wrapped_text(text: str, font: ImageFont.ImageFont, line_length: int) -> str:
|
|
9
11
|
"""
|
|
10
|
-
Wraps text to fit within a specified line length using the given font
|
|
12
|
+
Wraps text to fit within a specified line length using the given font,
|
|
13
|
+
preserving existing newlines.
|
|
11
14
|
Args:
|
|
12
15
|
text (str): The text to wrap.
|
|
13
16
|
font (ImageFont.ImageFont): The font to use for measuring text length.
|
|
@@ -16,68 +19,51 @@ def get_wrapped_text(text: str, font: ImageFont.ImageFont, line_length: int):
|
|
|
16
19
|
Returns:
|
|
17
20
|
str: The wrapped text with newlines inserted where necessary.
|
|
18
21
|
"""
|
|
19
|
-
|
|
20
|
-
for
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
lines[-1]
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
wrapped_lines = []
|
|
23
|
+
for line in text.split("\n"):
|
|
24
|
+
lines = [""]
|
|
25
|
+
for word in line.split():
|
|
26
|
+
line_to_check = f"{lines[-1]} {word}".strip()
|
|
27
|
+
if font.getlength(line_to_check) <= line_length:
|
|
28
|
+
lines[-1] = line_to_check
|
|
29
|
+
else:
|
|
30
|
+
lines.append(word)
|
|
31
|
+
wrapped_lines.extend(lines)
|
|
32
|
+
return "\n".join(wrapped_lines)
|
|
27
33
|
|
|
28
34
|
|
|
29
|
-
def apply_anchor(size:
|
|
35
|
+
def apply_anchor(size: Tuple[int, ...], anchor: str) -> Tuple[int, int]:
|
|
30
36
|
"""
|
|
31
37
|
Applies an anchor to a size tuple to determine the position of an element.
|
|
32
38
|
Args:
|
|
33
|
-
size (
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
size (Tuple[int, ...]): A tuple representing the size (width, height)
|
|
40
|
+
or a bounding box (x1, y1, x2, y2).
|
|
41
|
+
anchor (str): The anchor position, e.g., "center", "top-left".
|
|
36
42
|
Returns:
|
|
37
|
-
|
|
43
|
+
Tuple[int, int]: A tuple representing the position (x, y) based on the anchor.
|
|
38
44
|
"""
|
|
39
45
|
if len(size) == 2:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if
|
|
61
|
-
x1, y1, x2, y2 = size
|
|
62
|
-
width = x2 - x1
|
|
63
|
-
height = y2 - y1
|
|
64
|
-
if anchor == "top-left":
|
|
65
|
-
return (x1, y1)
|
|
66
|
-
if anchor == "top-center":
|
|
67
|
-
return (x1 + width // 2, y1)
|
|
68
|
-
if anchor == "top-right":
|
|
69
|
-
return (x2, y1)
|
|
70
|
-
if anchor == "middle-left":
|
|
71
|
-
return (x1, y1 + height // 2)
|
|
72
|
-
if anchor == "center":
|
|
73
|
-
return (x1 + width // 2, y1 + height // 2)
|
|
74
|
-
if anchor == "middle-right":
|
|
75
|
-
return (x2, y1 + height // 2)
|
|
76
|
-
if anchor == "bottom-left":
|
|
77
|
-
return (x1, y2)
|
|
78
|
-
if anchor == "bottom-center":
|
|
79
|
-
return (x1 + width // 2, y2)
|
|
80
|
-
if anchor == "bottom-right":
|
|
81
|
-
return (x2, y2)
|
|
46
|
+
w, h = size
|
|
47
|
+
x, y = 0, 0
|
|
48
|
+
elif len(size) == 4:
|
|
49
|
+
x, y, x2, y2 = size
|
|
50
|
+
w, h = x2 - x, y2 - y
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError("Size must be a tuple of 2 or 4 integers.")
|
|
53
|
+
|
|
54
|
+
anchor_points = {
|
|
55
|
+
"top-left": (x, y),
|
|
56
|
+
"top-center": (x + w // 2, y),
|
|
57
|
+
"top-right": (x + w, y),
|
|
58
|
+
"middle-left": (x, y + h // 2),
|
|
59
|
+
"center": (x + w // 2, y + h // 2),
|
|
60
|
+
"middle-right": (x + w, y + h // 2),
|
|
61
|
+
"bottom-left": (x, y + h),
|
|
62
|
+
"bottom-center": (x + w // 2, y + h),
|
|
63
|
+
"bottom-right": (x + w, y + h),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if anchor not in anchor_points:
|
|
82
67
|
raise ValueError(f"Unknown anchor: {anchor}")
|
|
83
|
-
|
|
68
|
+
|
|
69
|
+
return anchor_points[anchor]
|
decksmith/validate.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
This module provides functions for validating and transforming card specifications.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from typing import Dict, Any
|
|
6
|
+
|
|
5
7
|
import pandas as pd
|
|
6
8
|
from jval import validate
|
|
7
9
|
|
|
8
|
-
ELEMENT_SPEC = {
|
|
10
|
+
ELEMENT_SPEC: Dict[str, Any] = {
|
|
9
11
|
"?*id": "<?str>",
|
|
10
12
|
"*type": "<?str>",
|
|
11
13
|
"?*position": ["<?float>"],
|
|
@@ -13,7 +15,7 @@ ELEMENT_SPEC = {
|
|
|
13
15
|
"?*anchor": "<?str>",
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
SPECS_FOR_TYPE = {
|
|
18
|
+
SPECS_FOR_TYPE: Dict[str, Dict[str, Any]] = {
|
|
17
19
|
"text": {
|
|
18
20
|
"*text": "<?str>",
|
|
19
21
|
"?*color": ["<?int>"],
|
|
@@ -75,7 +77,7 @@ SPECS_FOR_TYPE = {
|
|
|
75
77
|
},
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
CARD_SPEC = {
|
|
80
|
+
CARD_SPEC: Dict[str, Any] = {
|
|
79
81
|
"?*id": "<?str>",
|
|
80
82
|
"*width": "<?int>",
|
|
81
83
|
"*height": "<?int>",
|
|
@@ -84,7 +86,7 @@ CARD_SPEC = {
|
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
|
|
87
|
-
def validate_element(element, element_type):
|
|
89
|
+
def validate_element(element: Dict[str, Any], element_type: str):
|
|
88
90
|
"""
|
|
89
91
|
Validates an element of a card against a spec, raising an exception
|
|
90
92
|
if it does not meet the spec.
|
|
@@ -96,12 +98,12 @@ def validate_element(element, element_type):
|
|
|
96
98
|
validate(element, spec)
|
|
97
99
|
|
|
98
100
|
|
|
99
|
-
def validate_card(card):
|
|
101
|
+
def validate_card(card: Dict[str, Any]):
|
|
100
102
|
"""
|
|
101
103
|
Validates a card against a spec, raising an exception
|
|
102
104
|
if it does not meet the spec.
|
|
103
105
|
Args:
|
|
104
|
-
card (
|
|
106
|
+
card (Dict[str, Any]): The card.
|
|
105
107
|
"""
|
|
106
108
|
# print(f"DEBUG:\n{card=}")
|
|
107
109
|
validate(card, CARD_SPEC)
|
|
@@ -110,15 +112,15 @@ def validate_card(card):
|
|
|
110
112
|
validate_element(element, element["type"])
|
|
111
113
|
|
|
112
114
|
|
|
113
|
-
def transform_card(card):
|
|
115
|
+
def transform_card(card: Dict[str, Any]) -> Dict[str, Any]:
|
|
114
116
|
"""
|
|
115
117
|
Perform certain automatic type casts on the card and its
|
|
116
118
|
elements. For example, cast the "text" property of elements
|
|
117
119
|
of type "text" to str, to support painting numbers as text.
|
|
118
120
|
Args:
|
|
119
|
-
card (
|
|
121
|
+
card (Dict[str, Any]): The card.
|
|
120
122
|
Return:
|
|
121
|
-
|
|
123
|
+
Dict[str, Any]: The transformed card with all automatic casts applied.
|
|
122
124
|
"""
|
|
123
125
|
for element in card.get("elements", []):
|
|
124
126
|
if element.get("type") == "text" and "text" in element:
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: decksmith
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.11
|
|
4
4
|
Summary: A command-line application to dynamically generate decks of cards from a JSON specification and a CSV data file, inspired by nandeck.
|
|
5
5
|
License: GPL-2.0-only
|
|
6
6
|
Author: Julio Cabria
|
|
7
7
|
Author-email: juliocabria@tutanota.com
|
|
8
|
-
Requires-Python: >=3.
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
9
|
Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2)
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
12
14
|
Provides-Extra: dev
|
|
13
15
|
Requires-Dist: click
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
decksmith/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
decksmith/card_builder.py,sha256=rDj2uFaA22XVXYInod4d9ljEk1OBeXh_ES0WdLcmrcs,23935
|
|
3
|
+
decksmith/deck_builder.py,sha256=WoaY2A0RDrYNvWZtBckcsR5MKHOXdCpNHaI2J5JZlKU,3835
|
|
4
|
+
decksmith/export.py,sha256=55TaK1fSL0EvtKCoAvUx-47ljNOL5zPeFJguDqL4BVU,5631
|
|
5
|
+
decksmith/main.py,sha256=upBSH8yCOiwCPjW-zf9QpqPTO-RIIaAuEACkWhUTgEw,4542
|
|
6
|
+
decksmith/templates/deck.csv,sha256=pNJebNxoDIfM8m0-aj05YrANHih1BTKOMry1kspIpPI,462
|
|
7
|
+
decksmith/templates/deck.json,sha256=BTTnmaFP5AkbbC_B7uMF6R4bOM7JizW6EATcsjjJrT4,695
|
|
8
|
+
decksmith/utils.py,sha256=vwRrzQEjpZq4x0GJLgJc7TqrC9LLJJqcHAsz1ar-vVs,2301
|
|
9
|
+
decksmith/validate.py,sha256=zA3ygyzOpx3ai6AhkSw611Qik3syZvR5ZmI_w3Pj_No,3813
|
|
10
|
+
decksmith-0.1.11.dist-info/entry_points.txt,sha256=-usRztjj2gnfmPubb8nFYHD22drzThAmSfM6geWI98Y,48
|
|
11
|
+
decksmith-0.1.11.dist-info/METADATA,sha256=L5sn3x0uhVFGYvvQMtRLMmiRY9bfevuQvOLxWhGoSL8,2522
|
|
12
|
+
decksmith-0.1.11.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
13
|
+
decksmith-0.1.11.dist-info/RECORD,,
|
decksmith-0.1.9.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
decksmith/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
decksmith/card_builder.py,sha256=oXD6XvtbNaUW-WF47v6y7GjIyg_g2giu4SEEEqq8D_c,25521
|
|
3
|
-
decksmith/deck_builder.py,sha256=8YK4ds51-z9f-BUyQ8jyh15BY9f16UQBYFI6Z87yFfU,3415
|
|
4
|
-
decksmith/export.py,sha256=aMMoGdBDQ-Gr4QJ_ziiV3F673kEyXzM0phVq14uDDJg,6225
|
|
5
|
-
decksmith/main.py,sha256=AzDq1aXziQmYz2IWdWSoBFskUeAXfOmcfYnIQUNIYp0,3357
|
|
6
|
-
decksmith/templates/deck.csv,sha256=pNJebNxoDIfM8m0-aj05YrANHih1BTKOMry1kspIpPI,462
|
|
7
|
-
decksmith/templates/deck.json,sha256=BTTnmaFP5AkbbC_B7uMF6R4bOM7JizW6EATcsjjJrT4,695
|
|
8
|
-
decksmith/utils.py,sha256=XKSapVB1_zZvYcl3LgZcHAob4Pk5HUT9AnlAQBAdeTs,2833
|
|
9
|
-
decksmith/validate.py,sha256=uqnNs8n6IeYfZcRrQKOaZzTMZM6gGP3MS_HPyWJ9vrQ,3578
|
|
10
|
-
decksmith-0.1.9.dist-info/entry_points.txt,sha256=-usRztjj2gnfmPubb8nFYHD22drzThAmSfM6geWI98Y,48
|
|
11
|
-
decksmith-0.1.9.dist-info/METADATA,sha256=Uq5NLeafcSXqufooG6g8GTcihnyPsci0VCB-kGWkbrs,2419
|
|
12
|
-
decksmith-0.1.9.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
13
|
-
decksmith-0.1.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|