decksmith 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.3
2
+ Name: decksmith
3
+ Version: 0.1.0
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
+ License: GPL-2.0-only
6
+ Author: Julio Cabria
7
+ Author-email: juliocabria@tutanota.com
8
+ Requires-Python: >=3.13
9
+ Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2)
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Provides-Extra: dev
13
+ Requires-Dist: click
14
+ Requires-Dist: jval (==1.0.6)
15
+ Requires-Dist: pandas
16
+ Requires-Dist: pillow (>=11.3.0)
17
+ Requires-Dist: poetry ; extra == "dev"
18
+ Requires-Dist: pytest ; extra == "dev"
19
+ Requires-Dist: reportlab (>=4.4.3)
20
+ Project-URL: Homepage, https://github.com/Julynx/decksmith
21
+ Description-Content-Type: text/markdown
22
+
23
+ # DeckSmith
24
+
25
+ A command-line application to dynamically generate decks of cards from a JSON specification and a CSV data file, inspired by nandeck.
26
+
27
+ DeckSmith is ideal for automating the creation of all kinds of decks, including TCG decks, tarot decks, business cards, and even slides.
28
+
29
+ ## Features
30
+
31
+ - Initialize a sample project and edit it instead of starting from scratch.
32
+
33
+ - Include images, text, and different kinds of shapes.
34
+
35
+ - Link any field to a column in the CSV file.
36
+
37
+ - Position elements absolutely or relative to other elements, using anchors to simplify placement
38
+
39
+ - Transform images using filters like crop, resize, rotate, or flip.
40
+
41
+ - Build card images and export to PDF for printing.
42
+
43
+ ## Getting started
44
+
45
+ To start creating decks, check out [Getting Started](DOCS.md/#getting-started).
46
+
File without changes
@@ -0,0 +1,607 @@
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
+ import pandas as pd
8
+ from PIL import Image, ImageDraw, ImageFont
9
+ from .utils import get_wrapped_text, apply_anchor
10
+ from .validate import validate_card
11
+
12
+
13
+ class CardBuilder:
14
+ """
15
+ A class to build a card image based on a JSON specification.
16
+ Attributes:
17
+ spec (dict): The JSON specification for the card.
18
+ card (Image): The PIL Image object representing the card.
19
+ draw (ImageDraw): The PIL ImageDraw object for drawing on the card.
20
+ """
21
+
22
+ def __init__(self, spec: dict):
23
+ """
24
+ Initializes the CardBuilder with a JSON specification file.
25
+ Args:
26
+ spec_path (str): Path to the JSON specification file.
27
+ """
28
+ self.spec = spec
29
+ width = self.spec.get("width", 250)
30
+ height = self.spec.get("height", 350)
31
+ 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
+
36
+ def _calculate_absolute_position(self, element: dict) -> tuple:
37
+ """
38
+ Calculates the absolute position of an element,
39
+ resolving relative positioning.
40
+ Args:
41
+ element (dict): The element dictionary.
42
+ Returns:
43
+ tuple: The absolute (x, y) position of the element.
44
+ """
45
+ # If the element has no 'relative_to', return its position directly
46
+ if "relative_to" not in element:
47
+ return tuple(element.get("position", [0, 0]))
48
+
49
+ # If the element has 'relative_to', resolve based on the reference element and anchor
50
+ relative_id, anchor = element["relative_to"]
51
+ if relative_id not in self.element_positions:
52
+ raise ValueError(
53
+ f"Element with id '{relative_id}' not found for relative positioning."
54
+ )
55
+
56
+ parent_bbox = self.element_positions[relative_id]
57
+ anchor_point = apply_anchor(parent_bbox, anchor)
58
+
59
+ offset = tuple(element.get("position", [0, 0]))
60
+ return tuple(map(operator.add, anchor_point, offset))
61
+
62
+ def _draw_text(self, element: dict):
63
+ """
64
+ Draws text on the card based on the provided element dictionary.
65
+ Args:
66
+ element (dict): A dictionary containing text properties such as
67
+ 'text', 'font_path', 'font_size', 'position',
68
+ 'color', and 'width'.
69
+ """
70
+ assert element.pop("type") == "text", "Element type must be 'text'"
71
+
72
+ # print(f"DEBUG: {element["text"]=}")
73
+
74
+ if pd.isna(element["text"]):
75
+ element["text"] = " "
76
+
77
+ # Convert font_path to a font object
78
+ font_size = element.pop("font_size", 10)
79
+ if font_path := element.pop("font_path", False):
80
+ element["font"] = ImageFont.truetype(
81
+ font_path,
82
+ font_size,
83
+ encoding="unic",
84
+ )
85
+ else:
86
+ element["font"] = ImageFont.load_default(font_size)
87
+
88
+ # Apply font_variant
89
+ if font_variant := element.pop("font_variant", None):
90
+ element["font"].set_variation_by_name(font_variant)
91
+
92
+ # Split text according to the specified width
93
+ if line_length := element.pop("width", False):
94
+ element["text"] = get_wrapped_text(
95
+ element["text"], element["font"], line_length
96
+ )
97
+
98
+ # Convert position and color to tuples
99
+ if position := element.pop("position", [0, 0]):
100
+ element["position"] = tuple(position)
101
+ if color := element.pop("color", [0, 0, 0]):
102
+ element["color"] = tuple(color)
103
+ if stroke_color := element.pop("stroke_color", None):
104
+ element["stroke_color"] = (
105
+ tuple(stroke_color) if stroke_color is not None else stroke_color
106
+ )
107
+
108
+ # Apply anchor manually (because PIL does not support anchor for multiline text)
109
+ original_pos = self._calculate_absolute_position(element)
110
+ element["position"] = original_pos
111
+
112
+ if "anchor" in element:
113
+ bbox = self.draw.textbbox(
114
+ xy=(0, 0),
115
+ text=element.get("text"),
116
+ font=element["font"],
117
+ spacing=element.get("line_spacing", 4),
118
+ align=element.get("align", "left"),
119
+ direction=element.get("direction", None),
120
+ features=element.get("features", None),
121
+ language=element.get("language", None),
122
+ stroke_width=element.get("stroke_width", 0),
123
+ embedded_color=element.get("embedded_color", False),
124
+ )
125
+ anchor_point = apply_anchor(bbox, element.pop("anchor"))
126
+ element["position"] = tuple(map(operator.sub, original_pos, anchor_point))
127
+
128
+ # Unpack the element dictionary and draw the text
129
+ self.draw.text(
130
+ xy=element.get("position"),
131
+ text=element.get("text"),
132
+ fill=element.get("color", None),
133
+ font=element["font"],
134
+ spacing=element.get("line_spacing", 4),
135
+ align=element.get("align", "left"),
136
+ direction=element.get("direction", None),
137
+ features=element.get("features", None),
138
+ language=element.get("language", None),
139
+ stroke_width=element.get("stroke_width", 0),
140
+ stroke_fill=element.get("stroke_color", None),
141
+ embedded_color=element.get("embedded_color", False),
142
+ )
143
+
144
+ # Store position if id is provided
145
+ if "id" in element:
146
+ bbox = self.draw.textbbox(
147
+ xy=element.get("position"),
148
+ text=element.get("text"),
149
+ font=element["font"],
150
+ spacing=element.get("line_spacing", 4),
151
+ align=element.get("align", "left"),
152
+ direction=element.get("direction", None),
153
+ features=element.get("features", None),
154
+ language=element.get("language", None),
155
+ stroke_width=element.get("stroke_width", 0),
156
+ embedded_color=element.get("embedded_color", False),
157
+ )
158
+ self.element_positions[element["id"]] = bbox
159
+
160
+ def _draw_image(self, element):
161
+ """
162
+ Draws an image on the card based on the provided element dictionary.
163
+ Args:
164
+ element (dict): A dictionary containing image properties such as
165
+ 'path', 'filters', and 'position'.
166
+ """
167
+ # Ensure the element type is 'image'
168
+ assert element.pop("type") == "image", "Element type must be 'image'"
169
+
170
+ # Load the image from the specified path
171
+ path = element["path"]
172
+ img = Image.open(path)
173
+
174
+ # Apply filters if specified
175
+ if "filters" in element:
176
+ for filter_name, filter_value in element["filters"].items():
177
+ if filter_name == "crop_top":
178
+ if filter_value < 0:
179
+ img = img.convert("RGBA")
180
+ new_img = Image.new(
181
+ "RGBA",
182
+ (img.width, img.height - filter_value),
183
+ (0, 0, 0, 0),
184
+ )
185
+ new_img.paste(img, (0, -filter_value))
186
+ img = new_img
187
+ else:
188
+ img = img.crop((0, filter_value, img.width, img.height))
189
+ elif filter_name == "crop_bottom":
190
+ if filter_value < 0:
191
+ img = img.convert("RGBA")
192
+ new_img = Image.new(
193
+ "RGBA",
194
+ (img.width, img.height - filter_value),
195
+ (0, 0, 0, 0),
196
+ )
197
+ new_img.paste(img, (0, 0))
198
+ img = new_img
199
+ else:
200
+ img = img.crop((0, 0, img.width, img.height - filter_value))
201
+ elif filter_name == "crop_left":
202
+ if filter_value < 0:
203
+ img = img.convert("RGBA")
204
+ new_img = Image.new(
205
+ "RGBA",
206
+ (img.width - filter_value, img.height),
207
+ (0, 0, 0, 0),
208
+ )
209
+ new_img.paste(img, (-filter_value, 0))
210
+ img = new_img
211
+ else:
212
+ img = img.crop((filter_value, 0, img.width, img.height))
213
+ elif filter_name == "crop_right":
214
+ if filter_value < 0:
215
+ img = img.convert("RGBA")
216
+ new_img = Image.new(
217
+ "RGBA",
218
+ (img.width - filter_value, img.height),
219
+ (0, 0, 0, 0),
220
+ )
221
+ new_img.paste(img, (0, 0))
222
+ img = new_img
223
+ else:
224
+ img = img.crop((0, 0, img.width - filter_value, img.height))
225
+ elif filter_name == "crop_box":
226
+ img = img.convert("RGBA")
227
+ x, y, w, h = filter_value
228
+ new_img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
229
+ src_x1 = max(0, x)
230
+ src_y1 = max(0, y)
231
+ src_x2 = min(img.width, x + w)
232
+ src_y2 = min(img.height, y + h)
233
+ if src_x1 < src_x2 and src_y1 < src_y2:
234
+ src_w = src_x2 - src_x1
235
+ src_h = src_y2 - src_y1
236
+ src_img = img.crop(
237
+ (src_x1, src_y1, src_x1 + src_w, src_y1 + src_h)
238
+ )
239
+ dst_x = src_x1 - x
240
+ dst_y = src_y1 - y
241
+ new_img.paste(src_img, (dst_x, dst_y))
242
+ img = new_img
243
+ elif filter_name == "resize":
244
+ new_width, new_height = filter_value
245
+ if new_width is None and new_height is None:
246
+ continue
247
+ if new_width is None or new_height is None:
248
+ original_width, original_height = img.size
249
+ aspect_ratio = original_width / float(original_height)
250
+ if new_width is None:
251
+ new_width = int(new_height * aspect_ratio)
252
+ else: # new_height is None
253
+ new_height = int(new_width / aspect_ratio)
254
+ img = img.resize((new_width, new_height))
255
+ elif filter_name == "rotate":
256
+ img = img.rotate(filter_value, expand=True)
257
+ elif filter_name == "flip":
258
+ if filter_value == "horizontal":
259
+ # pylint: disable=E1101
260
+ img = img.transpose(Image.FLIP_LEFT_RIGHT)
261
+ elif filter_value == "vertical":
262
+ # pylint: disable=E1101
263
+ img = img.transpose(Image.FLIP_TOP_BOTTOM)
264
+ else:
265
+ raise ValueError(f"Unknown filter: {filter_name}")
266
+
267
+ # Convert position to a tuple
268
+ position = tuple(element.get("position", [0, 0]))
269
+
270
+ # Apply anchor if specified (because PIL does not support anchor for images)
271
+ position = self._calculate_absolute_position(element)
272
+ if "anchor" in element:
273
+ anchor_point = apply_anchor((img.width, img.height), element.pop("anchor"))
274
+ position = tuple(map(operator.sub, position, anchor_point))
275
+
276
+ # Paste the image onto the card at the specified position
277
+ if img.mode == "RGBA":
278
+ self.card.paste(img, position, mask=img)
279
+ else:
280
+ self.card.paste(img, position)
281
+
282
+ # Store position if id is provided
283
+ if "id" in element:
284
+ self.element_positions[element["id"]] = (
285
+ position[0],
286
+ position[1],
287
+ position[0] + img.width,
288
+ position[1] + img.height,
289
+ )
290
+
291
+ def _draw_shape_circle(self, element):
292
+ """
293
+ Draws a circle on the card based on the provided element dictionary.
294
+ Args:
295
+ element (dict): A dictionary containing circle properties such as
296
+ 'position', 'radius', 'color', 'outline', 'width', and 'anchor'.
297
+
298
+ Raises:
299
+ AssertionError: If the element type is not 'circle'.
300
+ """
301
+ assert element.pop("type") == "circle", "Element type must be 'circle'"
302
+
303
+ radius = element["radius"]
304
+ size = (radius * 2, radius * 2)
305
+
306
+ # Calculate absolute position for the element's anchor
307
+ absolute_pos = self._calculate_absolute_position(element)
308
+
309
+ # Convert color and outline to a tuple if specified
310
+ if "color" in element:
311
+ element["fill"] = tuple(element["color"])
312
+ if "outline_color" in element:
313
+ element["outline_color"] = tuple(element["outline_color"])
314
+
315
+ # Apply anchor if specified
316
+ if "anchor" in element:
317
+ # anchor_offset is the offset of the anchor from the top-left corner
318
+ anchor_offset = apply_anchor(size, element.pop("anchor"))
319
+ # top_left is the target position minus the anchor offset
320
+ absolute_pos = tuple(map(operator.sub, absolute_pos, anchor_offset))
321
+
322
+ # The center of the circle is the top-left position + radius
323
+ center_pos = (absolute_pos[0] + radius, absolute_pos[1] + radius)
324
+
325
+ # Draw the circle
326
+ self.draw.circle(
327
+ center_pos,
328
+ radius,
329
+ fill=element.get("fill", None),
330
+ outline=element.get("outline_color", None),
331
+ width=element.get("outline_width", 1),
332
+ )
333
+
334
+ # Store position if id is provided
335
+ if "id" in element:
336
+ # The stored bbox is based on the top-left position
337
+ self.element_positions[element["id"]] = (
338
+ absolute_pos[0],
339
+ absolute_pos[1],
340
+ absolute_pos[0] + size[0],
341
+ absolute_pos[1] + size[1],
342
+ )
343
+
344
+ def _draw_shape_ellipse(self, element):
345
+ """
346
+ Draws an ellipse on the card based on the provided element dictionary.
347
+
348
+ Args:
349
+ element (dict): A dictionary containing ellipse properties such as
350
+ 'position', 'size', 'color', 'outline', 'width', and 'anchor'.
351
+
352
+ Raises:
353
+ AssertionError: If the element type is not 'ellipse'.
354
+ """
355
+ assert element.pop("type") == "ellipse", "Element type must be 'ellipse'"
356
+
357
+ # Get size
358
+ size = element["size"]
359
+
360
+ # Calculate absolute position
361
+ position = self._calculate_absolute_position(element)
362
+
363
+ # Convert color and outline to a tuple if specified
364
+ if "color" in element:
365
+ element["fill"] = tuple(element["color"])
366
+ if "outline_color" in element:
367
+ element["outline_color"] = tuple(element["outline_color"])
368
+
369
+ # Apply anchor if specified
370
+ if "anchor" in element:
371
+ # For anchoring, we need an offset from the top-left corner.
372
+ # We calculate this offset based on the element's size.
373
+ anchor_offset = apply_anchor(size, element.pop("anchor"))
374
+ # We subtract the offset from the calculated absolute position
375
+ # to get the top-left corner of the bounding box.
376
+ position = tuple(map(operator.sub, position, anchor_offset))
377
+
378
+ # Compute bounding box from the final position and size
379
+ bounding_box = (
380
+ position[0],
381
+ position[1],
382
+ position[0] + size[0],
383
+ position[1] + size[1],
384
+ )
385
+
386
+ # Draw the ellipse
387
+ self.draw.ellipse(
388
+ bounding_box,
389
+ fill=element.get("fill", None),
390
+ outline=element.get("outline_color", None),
391
+ width=element.get("outline_width", 1),
392
+ )
393
+
394
+ # Store position if id is provided
395
+ if "id" in element:
396
+ self.element_positions[element["id"]] = bounding_box
397
+
398
+ def _draw_shape_polygon(self, element):
399
+ """
400
+ Draws a polygon on the card based on the provided element dictionary.
401
+ Args:
402
+ element (dict): A dictionary containing polygon properties such as
403
+ 'position', 'points', 'color', 'outline', 'width', and 'anchor'.
404
+ Raises:
405
+ AssertionError: If the element type is not 'polygon'.
406
+ """
407
+ assert element.pop("type") == "polygon", "Element type must be 'polygon'"
408
+
409
+ # Get points and convert to tuples
410
+ points = element.get("points", [])
411
+ if not points:
412
+ return
413
+ points = [tuple(p) for p in points]
414
+
415
+ # Compute bounding box relative to (0,0)
416
+ min_x = min(p[0] for p in points)
417
+ max_x = max(p[0] for p in points)
418
+ min_y = min(p[1] for p in points)
419
+ max_y = max(p[1] for p in points)
420
+ bounding_box = (min_x, min_y, max_x, max_y)
421
+
422
+ # Calculate absolute position for the element's anchor
423
+ absolute_pos = self._calculate_absolute_position(element)
424
+
425
+ # Convert color and outline to a tuple if specified
426
+ if "color" in element:
427
+ element["fill"] = tuple(element["color"])
428
+ if "outline_color" in element:
429
+ element["outline_color"] = tuple(element["outline_color"])
430
+
431
+ # This will be the top-left offset for the points
432
+ offset = absolute_pos
433
+
434
+ # Apply anchor if specified
435
+ if "anchor" in element:
436
+ # anchor_point is the coordinate of the anchor within the relative bbox
437
+ anchor_point = apply_anchor(bounding_box, element.pop("anchor"))
438
+ # The final offset is the target position minus the anchor point's relative coord
439
+ offset = tuple(map(operator.sub, absolute_pos, anchor_point))
440
+
441
+ # Translate points by the final offset
442
+ final_points = [(p[0] + offset[0], p[1] + offset[1]) for p in points]
443
+
444
+ # Draw the polygon
445
+ self.draw.polygon(
446
+ final_points,
447
+ fill=element.get("fill", None),
448
+ outline=element.get("outline_color", None),
449
+ width=element.get("outline_width", 1),
450
+ )
451
+
452
+ # Store position if id is provided
453
+ if "id" in element:
454
+ # The stored bbox is the relative bbox translated by the offset
455
+ self.element_positions[element["id"]] = (
456
+ min_x + offset[0],
457
+ min_y + offset[1],
458
+ max_x + offset[0],
459
+ max_y + offset[1],
460
+ )
461
+
462
+ def _draw_shape_regular_polygon(self, element):
463
+ """
464
+ Draws a regular polygon on the card based on the provided element dictionary.
465
+
466
+ Args:
467
+ element (dict): A dictionary containing regular polygon properties such as
468
+ 'position', 'radius', 'sides', 'rotation', 'color', 'outline',
469
+ 'width', and 'anchor'.
470
+
471
+ Raises:
472
+ AssertionError: If the element type is not 'regular-polygon'.
473
+ """
474
+ assert (
475
+ element.pop("type") == "regular-polygon"
476
+ ), "Element type must be 'regular-polygon'"
477
+
478
+ radius = element["radius"]
479
+ size = (radius * 2, radius * 2)
480
+
481
+ # Calculate absolute position for the element's anchor
482
+ absolute_pos = self._calculate_absolute_position(element)
483
+
484
+ # Convert color and outline to a tuple if specified
485
+ if "color" in element:
486
+ element["fill"] = tuple(element["color"])
487
+ if "outline_color" in element:
488
+ element["outline_color"] = tuple(element["outline_color"])
489
+
490
+ # Apply anchor if specified
491
+ if "anchor" in element:
492
+ # anchor_offset is the offset of the anchor from the top-left corner
493
+ anchor_offset = apply_anchor(size, element.pop("anchor"))
494
+ # top_left is the target position minus the anchor offset
495
+ absolute_pos = tuple(map(operator.sub, absolute_pos, anchor_offset))
496
+
497
+ # The center of the polygon is the top-left position + radius
498
+ center_pos = (absolute_pos[0] + radius, absolute_pos[1] + radius)
499
+
500
+ # Draw the regular polygon
501
+ self.draw.regular_polygon(
502
+ (center_pos[0], center_pos[1], radius),
503
+ n_sides=element["sides"],
504
+ rotation=element.get("rotation", 0),
505
+ fill=element.get("fill", None),
506
+ outline=element.get("outline_color", None),
507
+ width=element.get("outline_width", 1),
508
+ )
509
+
510
+ # Store position if id is provided
511
+ if "id" in element:
512
+ # The stored bbox is based on the top-left position
513
+ self.element_positions[element["id"]] = (
514
+ absolute_pos[0],
515
+ absolute_pos[1],
516
+ absolute_pos[0] + size[0],
517
+ absolute_pos[1] + size[1],
518
+ )
519
+
520
+ def _draw_shape_rectangle(self, element):
521
+ """
522
+ Draws a rectangle on the card based on the provided element dictionary.
523
+
524
+ Args:
525
+ element (dict): A dictionary containing rectangle properties such as
526
+ 'size', 'color', 'outline_color', 'width', 'radius',
527
+ 'corners', 'position', and 'anchor'.
528
+ Raises:
529
+ AssertionError: If the element type is not 'rectangle'.
530
+ """
531
+ assert element.pop("type") == "rectangle", "Element type must be 'rectangle'"
532
+
533
+ # print(f"DEBUG: {element=}")
534
+
535
+ # Get size
536
+ size = element["size"]
537
+
538
+ # Calculate absolute position
539
+ position = self._calculate_absolute_position(element)
540
+
541
+ # Convert color, outline and corners to a tuple if specified
542
+ if "color" in element:
543
+ element["fill"] = tuple(element["color"])
544
+ if "outline_color" in element:
545
+ element["outline_color"] = tuple(element["outline_color"])
546
+ if "corners" in element:
547
+ element["corners"] = tuple(element["corners"])
548
+
549
+ # Apply anchor if specified
550
+ if "anchor" in element:
551
+ # For anchoring, we need an offset from the top-left corner.
552
+ # We calculate this offset based on the element's size.
553
+ anchor_offset = apply_anchor(size, element.pop("anchor"))
554
+ # We subtract the offset from the calculated absolute position
555
+ # to get the top-left corner of the bounding box.
556
+ position = tuple(map(operator.sub, position, anchor_offset))
557
+
558
+ # Compute bounding box from the final position and size
559
+ bounding_box = (
560
+ position[0],
561
+ position[1],
562
+ position[0] + size[0],
563
+ position[1] + size[1],
564
+ )
565
+
566
+ # print(f"DEBUG: Transformed {element=}")
567
+
568
+ # Draw the rectangle
569
+ self.draw.rounded_rectangle(
570
+ bounding_box,
571
+ radius=element.get("corner_radius", 0),
572
+ fill=element.get("fill", None),
573
+ outline=element.get("outline_color", None),
574
+ width=element.get("outline_width", 1),
575
+ corners=element.get("corners", None),
576
+ )
577
+
578
+ # Store position if id is provided
579
+ if "id" in element:
580
+ self.element_positions[element["id"]] = bounding_box
581
+
582
+ def build(self, output_path):
583
+ """
584
+ Builds the card image by drawing all elements specified in the JSON.
585
+ Args:
586
+ output_path (str): The path where the card image will be saved.
587
+ """
588
+ validate_card(self.spec)
589
+
590
+ for el in self.spec.get("elements", []):
591
+ el_type = el.get("type")
592
+ if el_type == "text":
593
+ self._draw_text(el)
594
+ elif el_type == "image":
595
+ self._draw_image(el)
596
+ elif el_type == "circle":
597
+ self._draw_shape_circle(el)
598
+ elif el_type == "ellipse":
599
+ self._draw_shape_ellipse(el)
600
+ elif el_type == "polygon":
601
+ self._draw_shape_polygon(el)
602
+ elif el_type == "regular-polygon":
603
+ self._draw_shape_regular_polygon(el)
604
+ elif el_type == "rectangle":
605
+ self._draw_shape_rectangle(el)
606
+ self.card.save(output_path)
607
+ print(f"(✔) Card saved to {output_path}")
@@ -0,0 +1,94 @@
1
+ """
2
+ This module contains the DeckBuilder class,
3
+ which is used to create a deck of cards based on a JSON specification
4
+ and a CSV file.
5
+ """
6
+
7
+ import concurrent.futures
8
+ import json
9
+ from pathlib import Path
10
+
11
+ import pandas as pd
12
+
13
+ from .card_builder import CardBuilder
14
+
15
+
16
+ class DeckBuilder:
17
+ """
18
+ A class to build a deck of cards based on a JSON specification and a CSV file.
19
+ Attributes:
20
+ spec_path (str): Path to the JSON specification file.
21
+ csv_path (str): Path to the CSV file containing card data.
22
+ cards (list): List of CardBuilder instances for each card in the deck.
23
+ """
24
+
25
+ def __init__(self, spec_path: str, csv_path: str = None):
26
+ """
27
+ Initializes the DeckBuilder with a JSON specification file and a CSV file.
28
+ Args:
29
+ spec_path (str): Path to the JSON specification file.
30
+ csv_path (str): Path to the CSV file containing card data.
31
+ """
32
+ self.spec_path = spec_path
33
+ self.csv_path = csv_path
34
+ self.cards = []
35
+
36
+ def _replace_macros(self, row: dict) -> dict:
37
+ """
38
+ Replaces %colname% macros in the card specification with values from the row.
39
+ Works recursively for nested structures.
40
+ Args:
41
+ row (dict): A dictionary representing a row from the CSV file.
42
+ Returns:
43
+ dict: The updated card specification with macros replaced.
44
+ """
45
+
46
+ def replace_in_value(value):
47
+ if isinstance(value, str):
48
+ stripped_value = value.strip()
49
+ # First, check for an exact macro match to preserve type
50
+ for key in row:
51
+ if stripped_value == f"%{key}%":
52
+ return row[key] # Return the raw value, preserving type
53
+
54
+ # If no exact match, perform standard string replacement for all macros
55
+ for key, val in row.items():
56
+ value = value.replace(f"%{key}%", str(val))
57
+ return value
58
+
59
+ if isinstance(value, list):
60
+ return [replace_in_value(v) for v in value]
61
+
62
+ if isinstance(value, dict):
63
+ return {k: replace_in_value(v) for k, v in value.items()}
64
+
65
+ return value
66
+
67
+ with open(self.spec_path, "r", encoding="utf-8") as f:
68
+ spec = json.load(f)
69
+ return replace_in_value(spec)
70
+
71
+ def build_deck(self, output_path: str):
72
+ """
73
+ Builds the deck of cards by reading the CSV file and creating CardBuilder instances.
74
+ """
75
+ if not self.csv_path:
76
+ with open(self.spec_path, "r", encoding="utf-8") as f:
77
+ spec = json.load(f)
78
+ card_builder = CardBuilder(spec)
79
+ card_builder.build(Path(output_path) / "card_1.png")
80
+ return
81
+
82
+ df = pd.read_csv(self.csv_path, encoding="utf-8", sep=";", header=0)
83
+
84
+ def build_card(row_tuple):
85
+ idx, row = row_tuple
86
+ spec = self._replace_macros(row.to_dict())
87
+ card_builder = CardBuilder(spec)
88
+ card_builder.build(Path(output_path) / f"card_{idx + 1}.png")
89
+
90
+ with concurrent.futures.ThreadPoolExecutor() as executor:
91
+ list(executor.map(build_card, df.iterrows()))
92
+
93
+ # for row_tuple in df.iterrows():
94
+ # build_card(row_tuple)
@@ -0,0 +1,177 @@
1
+ """
2
+ This module provides the functionality to export images from a folder to a PDF file.
3
+ """
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import List, Tuple
8
+
9
+ from reportlab.lib.pagesizes import A4
10
+ from reportlab.lib.units import mm
11
+ from reportlab.pdfgen import canvas
12
+
13
+
14
+ class PdfExporter:
15
+ """
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
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ image_folder: str,
32
+ output_path: str,
33
+ page_size_str: str = "A4",
34
+ image_width: float = 63,
35
+ image_height: float = 88,
36
+ gap: float = 0,
37
+ margins: Tuple[float, float] = (2, 2),
38
+ ):
39
+ self.image_folder = Path(image_folder)
40
+ self.image_paths = self._get_image_paths()
41
+ self.output_path = output_path
42
+ self.page_size = self._get_page_size(page_size_str)
43
+ self.image_width = image_width * mm
44
+ self.image_height = image_height * mm
45
+ self.gap = gap * mm
46
+ self.margins = (margins[0] * mm, margins[1] * mm)
47
+ self.pdf = canvas.Canvas(self.output_path, pagesize=self.page_size)
48
+
49
+ def _get_image_paths(self) -> List[str]:
50
+ """
51
+ Scans the image folder and returns a list of image paths.
52
+
53
+ Returns:
54
+ List[str]: A sorted list of image paths.
55
+ """
56
+ image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
57
+ image_paths = []
58
+ for file_path in self.image_folder.iterdir():
59
+ if file_path.suffix.lower() in image_extensions:
60
+ image_paths.append(str(file_path))
61
+ return sorted(image_paths)
62
+
63
+ def _get_page_size(self, page_size_str: str) -> Tuple[float, float]:
64
+ """
65
+ Returns the page size from a string.
66
+
67
+ Args:
68
+ page_size_str (str): The string representing the page size.
69
+
70
+ Returns:
71
+ Tuple[float, float]: The page size in points.
72
+ """
73
+ if page_size_str.lower() == "a4":
74
+ return A4
75
+ # Add other page sizes here if needed
76
+ return A4
77
+
78
+ def _calculate_layout(self, page_width: float, page_height: float):
79
+ """
80
+ Calculates the optimal layout for the images on the page.
81
+
82
+ Args:
83
+ page_width (float): The width of the page.
84
+ page_height (float): The height of the page.
85
+
86
+ Returns:
87
+ Tuple[int, int, bool]: The number of columns, rows, and if the layout is rotated.
88
+ """
89
+ best_fit = 0
90
+ best_layout = (0, 0, False)
91
+
92
+ for rotated in [False, True]:
93
+ img_w, img_h = (
94
+ (self.image_width, self.image_height)
95
+ if not rotated
96
+ else (self.image_height, self.image_width)
97
+ )
98
+
99
+ cols = int(
100
+ (page_width - 2 * self.margins[0] + self.gap) / (img_w + self.gap)
101
+ )
102
+ rows = int(
103
+ (page_height - 2 * self.margins[1] + self.gap) / (img_h + self.gap)
104
+ )
105
+
106
+ if cols * rows > best_fit:
107
+ best_fit = cols * rows
108
+ best_layout = (cols, rows, rotated)
109
+
110
+ return best_layout
111
+
112
+ def export(self):
113
+ """
114
+ Exports the images to a PDF file.
115
+ """
116
+ try:
117
+ page_width, page_height = self.page_size
118
+ cols, rows, rotated = self._calculate_layout(page_width, page_height)
119
+
120
+ if cols == 0 or rows == 0:
121
+ logging.error("The images are too large to fit on the page.")
122
+ return
123
+
124
+ img_w, img_h = (
125
+ (self.image_width, self.image_height)
126
+ if not rotated
127
+ else (self.image_height, self.image_width)
128
+ )
129
+
130
+ total_width = cols * img_w + (cols - 1) * self.gap
131
+ total_height = rows * img_h + (rows - 1) * self.gap
132
+ start_x = (page_width - total_width) / 2
133
+ start_y = (page_height - total_height) / 2
134
+
135
+ images_on_page = 0
136
+ for image_path in self.image_paths:
137
+ if images_on_page == cols * rows:
138
+ self.pdf.showPage()
139
+ images_on_page = 0
140
+
141
+ row = images_on_page // cols
142
+ col = images_on_page % cols
143
+
144
+ x = start_x + col * (img_w + self.gap)
145
+ y = start_y + row * (img_h + self.gap)
146
+
147
+ if not rotated:
148
+ self.pdf.drawImage(
149
+ image_path,
150
+ x,
151
+ y,
152
+ width=img_w,
153
+ height=img_h,
154
+ preserveAspectRatio=True,
155
+ )
156
+ else:
157
+ self.pdf.saveState()
158
+ center_x = x + img_w / 2
159
+ center_y = y + img_h / 2
160
+ self.pdf.translate(center_x, center_y)
161
+ self.pdf.rotate(90)
162
+ self.pdf.drawImage(
163
+ image_path,
164
+ -img_h / 2,
165
+ -img_w / 2,
166
+ width=img_h,
167
+ height=img_w,
168
+ preserveAspectRatio=True,
169
+ )
170
+ self.pdf.restoreState()
171
+ images_on_page += 1
172
+
173
+ self.pdf.save()
174
+ logging.info("Successfully exported PDF to %s", self.output_path)
175
+ except Exception as e:
176
+ logging.error("An error occurred during PDF export: %s", e)
177
+ raise
@@ -0,0 +1,104 @@
1
+ """
2
+ This module provides a command-line tool for building decks of cards.
3
+ """
4
+
5
+ import os
6
+ import shutil
7
+ from importlib import resources
8
+
9
+ import traceback
10
+
11
+ import click
12
+ from decksmith.deck_builder import DeckBuilder
13
+ from decksmith.export import PdfExporter
14
+
15
+
16
+ @click.group()
17
+ def cli():
18
+ """A command-line tool for building decks of cards."""
19
+
20
+
21
+ @cli.command()
22
+ def init():
23
+ """Initializes a new project by creating deck.json and deck.csv."""
24
+ if os.path.exists("deck.json") or os.path.exists("deck.csv"):
25
+ click.echo("(!) Project already initialized.")
26
+ return
27
+
28
+ with resources.path("templates", "deck.json") as template_path:
29
+ shutil.copy(template_path, "deck.json")
30
+ with resources.path("templates", "deck.csv") as template_path:
31
+ shutil.copy(template_path, "deck.csv")
32
+
33
+ click.echo("(✔) Initialized new project from templates.")
34
+
35
+
36
+ @cli.command()
37
+ @click.option("--output", default="output", help="The output directory for the deck.")
38
+ @click.option(
39
+ "--spec", default="deck.json", help="The path to the deck specification file."
40
+ )
41
+ @click.option("--data", default="deck.csv", help="The path to the data file.")
42
+ def build(output, spec, data):
43
+ """Builds the deck of cards."""
44
+ if not os.path.exists(output):
45
+ os.makedirs(output)
46
+
47
+ click.echo(f"(i) Building deck in {output}...")
48
+ try:
49
+ builder = DeckBuilder(spec, data)
50
+ builder.build_deck(output)
51
+ # pylint: disable=W0718
52
+ except Exception as exc:
53
+ with open("log.txt", "w", encoding="utf-8") as log:
54
+ log.write(traceback.format_exc())
55
+ # print(f"{traceback.format_exc()}", end="\n")
56
+ print(f"(x) Error building deck '{data}' from spec '{spec}':")
57
+ print(" " * 4 + f"{exc}")
58
+ return
59
+
60
+ click.echo("(✔) Deck built successfully.")
61
+
62
+
63
+ @cli.command()
64
+ @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
+ @click.option(
68
+ "--width", type=float, default=63.5, help="The width of the images in mm."
69
+ )
70
+ @click.option(
71
+ "--height", type=float, default=88.9, help="The height of the images in mm."
72
+ )
73
+ @click.option("--gap", type=float, default=0, help="The gap between images in pixels.")
74
+ @click.option(
75
+ "--margins",
76
+ type=float,
77
+ nargs=2,
78
+ default=[2, 2],
79
+ help="The horizontal and vertical margins in mm.",
80
+ )
81
+ def export(image_folder, output, page_size, width, height, gap, margins):
82
+ """Exports images from a folder to a PDF file."""
83
+ try:
84
+ exporter = PdfExporter(
85
+ image_folder=image_folder,
86
+ output_path=output,
87
+ page_size_str=page_size,
88
+ image_width=width,
89
+ image_height=height,
90
+ gap=gap,
91
+ margins=margins,
92
+ )
93
+ exporter.export()
94
+ click.echo(f"(✔) Successfully exported PDF to {output}")
95
+ # pylint: disable=W0718
96
+ except Exception as exc:
97
+ with open("log.txt", "w", encoding="utf-8") as log:
98
+ log.write(traceback.format_exc())
99
+ print(f"(x) Error exporting images to '{output}':")
100
+ print(" " * 4 + f"{exc}")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ cli()
@@ -0,0 +1,83 @@
1
+ """
2
+ This module provides utility functions for text wrapping and positioning.
3
+ """
4
+
5
+ from PIL import ImageFont
6
+
7
+
8
+ def get_wrapped_text(text: str, font: ImageFont.ImageFont, line_length: int):
9
+ """
10
+ Wraps text to fit within a specified line length using the given font.
11
+ Args:
12
+ text (str): The text to wrap.
13
+ font (ImageFont.ImageFont): The font to use for measuring text length.
14
+ line_length (int): The maximum length of each line in pixels.
15
+
16
+ Returns:
17
+ str: The wrapped text with newlines inserted where necessary.
18
+ """
19
+ lines = [""]
20
+ for word in text.split():
21
+ line = f"{lines[-1]} {word}".strip()
22
+ if font.getlength(line) <= line_length:
23
+ lines[-1] = line
24
+ else:
25
+ lines.append(word)
26
+ return "\n".join(lines)
27
+
28
+
29
+ def apply_anchor(size: tuple, anchor: str):
30
+ """
31
+ Applies an anchor to a size tuple to determine the position of an element.
32
+ Args:
33
+ size (tuple): A tuple representing the size (width, height).
34
+ anchor (str): The anchor position, e.g., "center", "top-left",
35
+ "top-right", "bottom-left", "bottom-right".
36
+ Returns:
37
+ tuple: A tuple representing the position (x, y) based on the anchor.
38
+ """
39
+ if len(size) == 2:
40
+ x, y = size
41
+ if anchor == "top-left":
42
+ return (0, 0)
43
+ if anchor == "top-center":
44
+ return (x // 2, 0)
45
+ if anchor == "top-right":
46
+ return (x, 0)
47
+ if anchor == "middle-left":
48
+ return (0, y // 2)
49
+ if anchor == "center":
50
+ return (x // 2, y // 2)
51
+ if anchor == "middle-right":
52
+ return (x, y // 2)
53
+ if anchor == "bottom-left":
54
+ return (0, y)
55
+ if anchor == "bottom-center":
56
+ return (x // 2, y)
57
+ if anchor == "bottom-right":
58
+ return (x, y)
59
+ raise ValueError(f"Unknown anchor: {anchor}")
60
+ if len(size) == 4:
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)
82
+ raise ValueError(f"Unknown anchor: {anchor}")
83
+ return None
@@ -0,0 +1,108 @@
1
+ """
2
+ Python module for validating a dictionar
3
+ """
4
+
5
+ from jval import validate
6
+
7
+ ELEMENT_SPEC = {
8
+ "?*id": "<str>",
9
+ "*type": "<str>",
10
+ "?*position": ["<float>"],
11
+ "?*relative_to": ["<str>"],
12
+ "?*anchor": "<str>",
13
+ }
14
+
15
+ SPECS_FOR_TYPE = {
16
+ "text": {
17
+ "*text": "<str>",
18
+ "?*color": ["<int>"],
19
+ "?*font_path": "<str>",
20
+ "?*font_size": "<int>",
21
+ "?*font_variant": "<str>",
22
+ "?*line_spacing": "<int>",
23
+ "?*width": "<int>",
24
+ "?*align": "<str>",
25
+ "?*stroke_width": "<int>",
26
+ "?*stroke_color": ["<int>"],
27
+ },
28
+ "image": {
29
+ "*path": "<str>",
30
+ "?*filters": {
31
+ "?*crop_top": "<int>",
32
+ "?*crop_bottom": "<int>",
33
+ "?*crop_left": "<int>",
34
+ "?*crop_right": "<int>",
35
+ "?*crop_box": ["<int>"],
36
+ "?*rotate": "<int>",
37
+ "?*flip": "<str>",
38
+ "?*resize": ["<?int>"],
39
+ },
40
+ },
41
+ "circle": {
42
+ "*radius": "<int>",
43
+ "?*color": ["<int>"],
44
+ "?*outline_color": ["<int>"],
45
+ "?*outline_width": "<int>",
46
+ },
47
+ "ellipse": {
48
+ "*size": ["<int>"],
49
+ "?*color": ["<int>"],
50
+ "?*outline_color": ["<int>"],
51
+ "?*outline_width": "<int>",
52
+ },
53
+ "polygon": {
54
+ "*points": [["<int>"]],
55
+ "?*color": ["<int>"],
56
+ "?*outline_color": ["<int>"],
57
+ "?*outline_width": "<int>",
58
+ },
59
+ "regular-polygon": {
60
+ "*radius": "<int>",
61
+ "*sides": "<int>",
62
+ "?*rotation": "<int>",
63
+ "?*color": ["<int>"],
64
+ "?*outline_color": ["<int>"],
65
+ "?*outline_width": "<int>",
66
+ },
67
+ "rectangle": {
68
+ "*size": ["<int>"],
69
+ "?*corners": ["<bool>"],
70
+ "?*corner_radius": "<int>",
71
+ "?*color": ["<int>"],
72
+ "?*outline_color": ["<int>"],
73
+ "?*outline_width": "<int>",
74
+ },
75
+ }
76
+
77
+ CARD_SPEC = {
78
+ "*width": "<int>",
79
+ "*height": "<int>",
80
+ "?*background_color": ["<int>"],
81
+ "*elements": [],
82
+ }
83
+
84
+
85
+ def validate_element(element, element_type):
86
+ """
87
+ Validates an element of a card against a spec, raising an exception
88
+ if it does not meet the spec.
89
+ Args:
90
+ element (dict): The card element.
91
+ element_type (str): The type of the element
92
+ """
93
+ spec = ELEMENT_SPEC | SPECS_FOR_TYPE[element_type]
94
+ validate(element, spec)
95
+
96
+
97
+ def validate_card(card):
98
+ """
99
+ Validates a card against a spec, raising an exception
100
+ if it does not meet the spec.
101
+ Args:
102
+ card (dict): The card.
103
+ """
104
+ # print(f"DEBUG:\n{card=}")
105
+ validate(card, CARD_SPEC)
106
+ for element in card["elements"]:
107
+ # print(f"DEBUG: {element['type']}")
108
+ validate_element(element, element["type"])
@@ -0,0 +1,23 @@
1
+ # DeckSmith
2
+
3
+ A command-line application to dynamically generate decks of cards from a JSON specification and a CSV data file, inspired by nandeck.
4
+
5
+ DeckSmith is ideal for automating the creation of all kinds of decks, including TCG decks, tarot decks, business cards, and even slides.
6
+
7
+ ## Features
8
+
9
+ - Initialize a sample project and edit it instead of starting from scratch.
10
+
11
+ - Include images, text, and different kinds of shapes.
12
+
13
+ - Link any field to a column in the CSV file.
14
+
15
+ - Position elements absolutely or relative to other elements, using anchors to simplify placement
16
+
17
+ - Transform images using filters like crop, resize, rotate, or flip.
18
+
19
+ - Build card images and export to PDF for printing.
20
+
21
+ ## Getting started
22
+
23
+ To start creating decks, check out [Getting Started](DOCS.md/#getting-started).
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "decksmith"
3
+ version = "0.1.0"
4
+ description = "A command-line application to dynamically generate decks of cards from a JSON specification and a CSV data file, inspired by nandeck."
5
+ authors = [
6
+ {name = "Julio Cabria", email = "juliocabria@tutanota.com"},
7
+ ]
8
+ license = "GPL-2.0-only"
9
+ readme = "docs/README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "click",
13
+ "jval==1.0.6",
14
+ "pandas",
15
+ "pillow>=11.3.0",
16
+ "reportlab>=4.4.3",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest",
22
+ "poetry",
23
+ ]
24
+
25
+ [tool.poetry]
26
+ name = "decksmith"
27
+ version = "0.1.0"
28
+ description = "A command-line application to dynamically generate decks of cards from a JSON specification and a CSV data file, inspired by nandeck."
29
+ authors = ["Julio Cabria <juliocabria@tutanota.com>"]
30
+ license = "GPL-2.0-only"
31
+ readme = "docs/README.md"
32
+ homepage = "https://github.com/Julynx/decksmith"
33
+
34
+ [tool.poetry.dependencies]
35
+ python = ">=3.13"
36
+ click = "*"
37
+ jval = "1.0.6"
38
+ pandas = "*"
39
+ pillow = ">=11.3.0"
40
+ reportlab = ">=4.4.3"
41
+
42
+ [tool.poetry.scripts]
43
+ decksmith = "decksmith.main:cli"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["."]
47
+ include = ["decksmith*", "test*"]
48
+ namespaces = false
49
+
50
+ [tool.setuptools.package-data]
51
+ decksmith = ["../templates/*"]
52
+
53
+ [project.scripts]
54
+ decksmith = "decksmith.main:cli"
55
+
56
+ [build-system]
57
+ requires = ["poetry-core>=1.0.0"]
58
+ build-backend = "poetry.core.masonry.api"