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