decksmith 0.1.15__py3-none-any.whl → 0.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
decksmith/deck_builder.py CHANGED
@@ -1,87 +1,69 @@
1
1
  """
2
2
  This module contains the DeckBuilder class,
3
- which is used to create a deck of cards based on a JSON specification
3
+ which is used to create a deck of cards based on a YAML specification
4
4
  and a CSV file.
5
5
  """
6
6
 
7
7
  import concurrent.futures
8
- import json
9
8
  from pathlib import Path
10
- from typing import Union, List, Dict, Any
9
+ from typing import Any, Dict, List, Optional
11
10
 
12
11
  import pandas as pd
13
12
  from pandas import Series
13
+ from ruamel.yaml import YAML
14
14
 
15
- from .card_builder import CardBuilder
15
+ from decksmith.card_builder import CardBuilder
16
+ from decksmith.logger import logger
17
+ from decksmith.macro import MacroResolver
16
18
 
17
19
 
18
20
  class DeckBuilder:
19
21
  """
20
- A class to build a deck of cards based on a JSON specification and a CSV file.
22
+ A class to build a deck of cards based on a YAML specification and a CSV file.
21
23
  Attributes:
22
- spec_path (Path): Path to the JSON specification file.
24
+ spec_path (Path): Path to the YAML specification file.
23
25
  csv_path (Union[Path, None]): Path to the CSV file containing card data.
24
26
  cards (list): List of CardBuilder instances for each card in the deck.
25
27
  """
26
28
 
27
- def __init__(self, spec_path: Path, csv_path: Union[Path, None] = None):
29
+ def __init__(self, spec_path: Path, csv_path: Optional[Path] = None):
28
30
  """
29
- Initializes the DeckBuilder with a JSON specification file and a CSV file.
31
+ Initializes the DeckBuilder with a YAML specification file and a CSV file.
30
32
  Args:
31
- spec_path (Path): Path to the JSON specification file.
33
+ spec_path (Path): Path to the YAML specification file.
32
34
  csv_path (Union[Path, None]): Path to the CSV file containing card data.
33
35
  """
34
36
  self.spec_path = spec_path
35
37
  self.csv_path = csv_path
36
38
  self.cards: List[CardBuilder] = []
39
+ self._spec_cache: Optional[Dict[str, Any]] = None
37
40
 
38
- def _replace_macros(self, row: Dict[str, Any]) -> Dict[str, Any]:
39
- """
40
- Replaces %colname% macros in the card specification with values from the row.
41
- Works recursively for nested structures.
42
- Args:
43
- row (dict): A dictionary representing a row from the CSV file.
44
- Returns:
45
- dict: The updated card specification with macros replaced.
46
- """
47
-
48
- def replace_in_value(value: Any) -> Any:
49
- if isinstance(value, str):
50
- stripped_value = value.strip()
51
- # First, check for an exact macro match to preserve type
52
- for key in row:
53
- if stripped_value == f"%{key}%":
54
- return row[key] # Return the raw value, preserving type
55
-
56
- # If no exact match, perform standard string replacement for all macros
57
- for key, val in row.items():
58
- value = value.replace(f"%{key}%", str(val))
59
- return value
60
-
61
- if isinstance(value, list):
62
- return [replace_in_value(v) for v in value]
63
-
64
- if isinstance(value, dict):
65
- return {k: replace_in_value(v) for k, v in value.items()}
66
-
67
- return value
68
-
69
- with open(self.spec_path, "r", encoding="utf-8") as f:
70
- spec = json.load(f)
71
- return replace_in_value(spec)
41
+ @property
42
+ def spec(self) -> Dict[str, Any]:
43
+ """Loads and caches the spec file."""
44
+ if self._spec_cache is None:
45
+ yaml = YAML()
46
+ with open(self.spec_path, "r", encoding="utf-8") as spec_file:
47
+ self._spec_cache = yaml.load(spec_file)
48
+ return self._spec_cache
72
49
 
73
50
  def build_deck(self, output_path: Path):
74
51
  """
75
52
  Builds the deck of cards by reading the CSV file and creating CardBuilder instances.
76
53
  """
54
+ base_path = self.spec_path.parent if self.spec_path else None
55
+
77
56
  if not self.csv_path or not self.csv_path.exists():
78
- with open(self.spec_path, "r", encoding="utf-8") as f:
79
- spec = json.load(f)
80
- card_builder = CardBuilder(spec)
57
+ logger.info("No CSV file found. Building single card from spec.")
58
+ card_builder = CardBuilder(self.spec, base_path=base_path)
81
59
  card_builder.build(output_path / "card_1.png")
82
60
  return
83
61
 
84
- df = pd.read_csv(self.csv_path, encoding="utf-8", sep=";", header=0)
62
+ try:
63
+ dataframe = pd.read_csv(self.csv_path, encoding="utf-8", sep=";", header=0)
64
+ except Exception as e:
65
+ logger.error("Error reading CSV file: %s", e)
66
+ return
85
67
 
86
68
  def build_card(row_tuple: tuple[int, Series]):
87
69
  """
@@ -90,12 +72,14 @@ class DeckBuilder:
90
72
  row_tuple (tuple[int, Series]): A tuple containing the row index and the row data.
91
73
  """
92
74
  idx, row = row_tuple
93
- spec = self._replace_macros(row.to_dict())
94
- card_builder = CardBuilder(spec)
95
- card_builder.build(output_path / f"card_{idx + 1}.png")
75
+ try:
76
+ # We need a deep copy of the spec for each card to avoid side effects
77
+ # But resolve_macros creates a new structure, so it should be fine
78
+ spec = MacroResolver.resolve(self.spec, row.to_dict())
79
+ card_builder = CardBuilder(spec, base_path=base_path)
80
+ card_builder.build(output_path / f"card_{idx + 1}.png")
81
+ except Exception as e:
82
+ logger.error("Error building card %s: %s", idx + 1, e)
96
83
 
97
84
  with concurrent.futures.ThreadPoolExecutor() as executor:
98
- list(executor.map(build_card, df.iterrows()))
99
-
100
- # for row_tuple in df.iterrows():
101
- # build_card(row_tuple)
85
+ list(executor.map(build_card, dataframe.iterrows()))
decksmith/export.py CHANGED
@@ -1,168 +1,170 @@
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
-
19
- def __init__(
20
- self,
21
- image_folder: Path,
22
- output_path: Path,
23
- page_size_str: str = "A4",
24
- image_width: float = 63,
25
- image_height: float = 88,
26
- gap: float = 0,
27
- margins: Tuple[float, float] = (2, 2),
28
- ):
29
- self.image_folder = image_folder
30
- self.image_paths = self._get_image_paths()
31
- self.output_path = output_path
32
- self.page_size = self._get_page_size(page_size_str)
33
- self.image_width = image_width * mm
34
- self.image_height = image_height * mm
35
- self.gap = gap * mm
36
- self.margins = (margins[0] * mm, margins[1] * mm)
37
- self.pdf = canvas.Canvas(str(self.output_path), pagesize=self.page_size)
38
-
39
- def _get_image_paths(self) -> List[Path]:
40
- """
41
- Scans the image folder and returns a list of image paths.
42
-
43
- Returns:
44
- List[str]: A sorted list of image paths.
45
- """
46
- image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
47
- return sorted(
48
- [
49
- p
50
- for p in self.image_folder.iterdir()
51
- if p.suffix.lower() in image_extensions
52
- ]
53
- )
54
-
55
- def _get_page_size(self, page_size_str: str) -> Tuple[float, float]:
56
- """
57
- Returns the page size from a string.
58
-
59
- Args:
60
- page_size_str (str): The string representing the page size.
61
-
62
- Returns:
63
- Tuple[float, float]: The page size in points.
64
- """
65
- if page_size_str.lower() == "a4":
66
- return A4
67
- # Add other page sizes here if needed
68
- return A4
69
-
70
- def _calculate_layout(self, page_width: float, page_height: float):
71
- """
72
- Calculates the optimal layout for the images on the page.
73
-
74
- Args:
75
- page_width (float): The width of the page.
76
- page_height (float): The height of the page.
77
-
78
- Returns:
79
- Tuple[int, int, bool]: The number of columns, rows, and if the layout is rotated.
80
- """
81
- best_fit = 0
82
- best_layout = (0, 0, False)
83
-
84
- for rotated in [False, True]:
85
- img_w, img_h = (
86
- (self.image_width, self.image_height)
87
- if not rotated
88
- else (self.image_height, self.image_width)
89
- )
90
-
91
- cols = int(
92
- (page_width - 2 * self.margins[0] + self.gap) / (img_w + self.gap)
93
- )
94
- rows = int(
95
- (page_height - 2 * self.margins[1] + self.gap) / (img_h + self.gap)
96
- )
97
-
98
- if cols * rows > best_fit:
99
- best_fit = cols * rows
100
- best_layout = (cols, rows, rotated)
101
-
102
- return best_layout
103
-
104
- def export(self):
105
- """
106
- Exports the images to a PDF file.
107
- """
108
- try:
109
- page_width, page_height = self.page_size
110
- cols, rows, rotated = self._calculate_layout(page_width, page_height)
111
-
112
- if cols == 0 or rows == 0:
113
- raise ValueError("The images are too large to fit on the page.")
114
-
115
- img_w, img_h = (
116
- (self.image_width, self.image_height)
117
- if not rotated
118
- else (self.image_height, self.image_width)
119
- )
120
-
121
- total_width = cols * img_w + (cols - 1) * self.gap
122
- total_height = rows * img_h + (rows - 1) * self.gap
123
- start_x = (page_width - total_width) / 2
124
- start_y = (page_height - total_height) / 2
125
-
126
- images_on_page = 0
127
- for image_path in self.image_paths:
128
- if images_on_page > 0 and images_on_page % (cols * rows) == 0:
129
- self.pdf.showPage()
130
- images_on_page = 0
131
-
132
- row = images_on_page // cols
133
- col = images_on_page % cols
134
-
135
- x = start_x + col * (img_w + self.gap)
136
- y = start_y + row * (img_h + self.gap)
137
-
138
- if not rotated:
139
- self.pdf.drawImage(
140
- str(image_path),
141
- x,
142
- y,
143
- width=img_w,
144
- height=img_h,
145
- preserveAspectRatio=True,
146
- )
147
- else:
148
- self.pdf.saveState()
149
- center_x = x + img_w / 2
150
- center_y = y + img_h / 2
151
- self.pdf.translate(center_x, center_y)
152
- self.pdf.rotate(90)
153
- self.pdf.drawImage(
154
- str(image_path),
155
- -img_h / 2,
156
- -img_w / 2,
157
- width=img_h,
158
- height=img_w,
159
- preserveAspectRatio=True,
160
- )
161
- self.pdf.restoreState()
162
- images_on_page += 1
163
-
164
- self.pdf.save()
165
- logging.info("Successfully exported PDF to %s", self.output_path)
166
- except Exception as e:
167
- logging.error("An error occurred during PDF export: %s", e)
168
- raise
1
+ """
2
+ This module provides the functionality to export images from a folder to a PDF file.
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import List, Tuple
7
+
8
+ from reportlab.lib.pagesizes import A4
9
+ from reportlab.lib.units import mm
10
+ from reportlab.pdfgen import canvas
11
+
12
+ from decksmith.logger import logger
13
+
14
+
15
+ class PdfExporter:
16
+ """
17
+ A class to export images from a folder to a PDF file.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ image_folder: Path,
23
+ output_path: Path,
24
+ page_size_str: str = "A4",
25
+ image_width: float = 63,
26
+ image_height: float = 88,
27
+ gap: float = 0,
28
+ margins: Tuple[float, float] = (2, 2),
29
+ ):
30
+ self.image_folder = image_folder
31
+ self.image_paths = self._get_image_paths()
32
+ self.output_path = output_path
33
+ self.page_size = self._get_page_size(page_size_str)
34
+ self.image_width = image_width * mm
35
+ self.image_height = image_height * mm
36
+ self.gap = gap * mm
37
+ self.margins = (margins[0] * mm, margins[1] * mm)
38
+ self.pdf = canvas.Canvas(str(self.output_path), pagesize=self.page_size)
39
+
40
+ def _get_image_paths(self) -> List[Path]:
41
+ """
42
+ Scans the image folder and returns a list of image paths.
43
+
44
+ Returns:
45
+ List[str]: A sorted list of image paths.
46
+ """
47
+ image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
48
+ return sorted(
49
+ [
50
+ image_path
51
+ for image_path in self.image_folder.iterdir()
52
+ if image_path.suffix.lower() in image_extensions
53
+ ]
54
+ )
55
+
56
+ def _get_page_size(self, page_size_str: str) -> Tuple[float, float]:
57
+ """
58
+ Returns the page size from a string.
59
+
60
+ Args:
61
+ page_size_str (str): The string representing the page size.
62
+
63
+ Returns:
64
+ Tuple[float, float]: The page size in points.
65
+ """
66
+ if page_size_str.lower() == "a4":
67
+ return A4
68
+ # Add other page sizes here if needed
69
+ return A4
70
+
71
+ def _calculate_layout(self, page_width: float, page_height: float):
72
+ """
73
+ Calculates the optimal layout for the images on the page.
74
+
75
+ Args:
76
+ page_width (float): The width of the page.
77
+ page_height (float): The height of the page.
78
+
79
+ Returns:
80
+ Tuple[int, int, bool]: The number of columns, rows, and if the layout is rotated.
81
+ """
82
+ best_fit = 0
83
+ best_layout = (0, 0, False)
84
+
85
+ for rotated in [False, True]:
86
+ image_width, image_height = (
87
+ (self.image_width, self.image_height)
88
+ if not rotated
89
+ else (self.image_height, self.image_width)
90
+ )
91
+
92
+ cols = int(
93
+ (page_width - 2 * self.margins[0] + self.gap) / (image_width + self.gap)
94
+ )
95
+ rows = int(
96
+ (page_height - 2 * self.margins[1] + self.gap)
97
+ / (image_height + self.gap)
98
+ )
99
+
100
+ if cols * rows > best_fit:
101
+ best_fit = cols * rows
102
+ best_layout = (cols, rows, rotated)
103
+
104
+ return best_layout
105
+
106
+ def export(self):
107
+ """
108
+ Exports the images to a PDF file.
109
+ """
110
+ try:
111
+ page_width, page_height = self.page_size
112
+ cols, rows, rotated = self._calculate_layout(page_width, page_height)
113
+
114
+ if cols == 0 or rows == 0:
115
+ raise ValueError("The images are too large to fit on the page.")
116
+
117
+ image_width, image_height = (
118
+ (self.image_width, self.image_height)
119
+ if not rotated
120
+ else (self.image_height, self.image_width)
121
+ )
122
+
123
+ total_width = cols * image_width + (cols - 1) * self.gap
124
+ total_height = rows * image_height + (rows - 1) * self.gap
125
+ start_horizontal = (page_width - total_width) / 2
126
+ start_vertical = (page_height - total_height) / 2
127
+
128
+ images_on_page = 0
129
+ for image_path in self.image_paths:
130
+ if images_on_page > 0 and images_on_page % (cols * rows) == 0:
131
+ self.pdf.showPage()
132
+ images_on_page = 0
133
+
134
+ row = images_on_page // cols
135
+ col = images_on_page % cols
136
+
137
+ position_horizontal = start_horizontal + col * (image_width + self.gap)
138
+ position_vertical = start_vertical + row * (image_height + self.gap)
139
+
140
+ if not rotated:
141
+ self.pdf.drawImage(
142
+ str(image_path),
143
+ position_horizontal,
144
+ position_vertical,
145
+ width=image_width,
146
+ height=image_height,
147
+ preserveAspectRatio=True,
148
+ )
149
+ else:
150
+ self.pdf.saveState()
151
+ center_horizontal = position_horizontal + image_width / 2
152
+ center_vertical = position_vertical + image_height / 2
153
+ self.pdf.translate(center_horizontal, center_vertical)
154
+ self.pdf.rotate(90)
155
+ self.pdf.drawImage(
156
+ str(image_path),
157
+ -image_height / 2,
158
+ -image_width / 2,
159
+ width=image_height,
160
+ height=image_width,
161
+ preserveAspectRatio=True,
162
+ )
163
+ self.pdf.restoreState()
164
+ images_on_page += 1
165
+
166
+ self.pdf.save()
167
+ logger.info("Successfully exported PDF to %s", self.output_path)
168
+ except Exception as e:
169
+ logger.error("An error occurred during PDF export: %s", e)
170
+ raise
File without changes