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.
- decksmith-0.1.0/PKG-INFO +46 -0
- decksmith-0.1.0/decksmith/__init__.py +0 -0
- decksmith-0.1.0/decksmith/card_builder.py +607 -0
- decksmith-0.1.0/decksmith/deck_builder.py +94 -0
- decksmith-0.1.0/decksmith/export.py +177 -0
- decksmith-0.1.0/decksmith/main.py +104 -0
- decksmith-0.1.0/decksmith/utils.py +83 -0
- decksmith-0.1.0/decksmith/validate.py +108 -0
- decksmith-0.1.0/docs/README.md +23 -0
- decksmith-0.1.0/pyproject.toml +58 -0
decksmith-0.1.0/PKG-INFO
ADDED
|
@@ -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"
|