decksmith 0.1.14__py3-none-any.whl → 0.9.1__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,101 +1,85 @@
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
- from typing import Union, List, Dict, Any
11
-
12
- import pandas as pd
13
- from pandas import Series
14
-
15
- from .card_builder import CardBuilder
16
-
17
-
18
- class DeckBuilder:
19
- """
20
- A class to build a deck of cards based on a JSON specification and a CSV file.
21
- Attributes:
22
- spec_path (Path): Path to the JSON specification file.
23
- csv_path (Union[Path, None]): Path to the CSV file containing card data.
24
- cards (list): List of CardBuilder instances for each card in the deck.
25
- """
26
-
27
- def __init__(self, spec_path: Path, csv_path: Union[Path, None] = None):
28
- """
29
- Initializes the DeckBuilder with a JSON specification file and a CSV file.
30
- Args:
31
- spec_path (Path): Path to the JSON specification file.
32
- csv_path (Union[Path, None]): Path to the CSV file containing card data.
33
- """
34
- self.spec_path = spec_path
35
- self.csv_path = csv_path
36
- self.cards: List[CardBuilder] = []
37
-
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)
72
-
73
- def build_deck(self, output_path: Path):
74
- """
75
- Builds the deck of cards by reading the CSV file and creating CardBuilder instances.
76
- """
77
- 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)
81
- card_builder.build(output_path / "card_1.png")
82
- return
83
-
84
- df = pd.read_csv(self.csv_path, encoding="utf-8", sep=";", header=0)
85
-
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
- """
92
- 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")
96
-
97
- 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)
1
+ """
2
+ This module contains the DeckBuilder class,
3
+ which is used to create a deck of cards based on a YAML specification
4
+ and a CSV file.
5
+ """
6
+
7
+ import concurrent.futures
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ import pandas as pd
12
+ from pandas import Series
13
+ from ruamel.yaml import YAML
14
+
15
+ from decksmith.card_builder import CardBuilder
16
+ from decksmith.logger import logger
17
+ from decksmith.macro import MacroResolver
18
+
19
+
20
+ class DeckBuilder:
21
+ """
22
+ A class to build a deck of cards based on a YAML specification and a CSV file.
23
+ Attributes:
24
+ spec_path (Path): Path to the YAML specification file.
25
+ csv_path (Union[Path, None]): Path to the CSV file containing card data.
26
+ cards (list): List of CardBuilder instances for each card in the deck.
27
+ """
28
+
29
+ def __init__(self, spec_path: Path, csv_path: Optional[Path] = None):
30
+ """
31
+ Initializes the DeckBuilder with a YAML specification file and a CSV file.
32
+ Args:
33
+ spec_path (Path): Path to the YAML specification file.
34
+ csv_path (Union[Path, None]): Path to the CSV file containing card data.
35
+ """
36
+ self.spec_path = spec_path
37
+ self.csv_path = csv_path
38
+ self.cards: List[CardBuilder] = []
39
+ self._spec_cache: Optional[Dict[str, Any]] = None
40
+
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
49
+
50
+ def build_deck(self, output_path: Path):
51
+ """
52
+ Builds the deck of cards by reading the CSV file and creating CardBuilder instances.
53
+ """
54
+ base_path = self.spec_path.parent if self.spec_path else None
55
+
56
+ if not self.csv_path or not self.csv_path.exists():
57
+ logger.info("No CSV file found. Building single card from spec.")
58
+ card_builder = CardBuilder(self.spec, base_path=base_path)
59
+ card_builder.build(output_path / "card_1.png")
60
+ return
61
+
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
67
+
68
+ def build_card(row_tuple: tuple[int, Series]):
69
+ """
70
+ Builds a single card from a row of the CSV file.
71
+ Args:
72
+ row_tuple (tuple[int, Series]): A tuple containing the row index and the row data.
73
+ """
74
+ idx, row = row_tuple
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)
83
+
84
+ with concurrent.futures.ThreadPoolExecutor() as executor:
85
+ list(executor.map(build_card, dataframe.iterrows()))
decksmith/export.py CHANGED
@@ -2,7 +2,6 @@
2
2
  This module provides the functionality to export images from a folder to a PDF file.
3
3
  """
4
4
 
5
- import logging
6
5
  from pathlib import Path
7
6
  from typing import List, Tuple
8
7
 
@@ -10,6 +9,8 @@ from reportlab.lib.pagesizes import A4
10
9
  from reportlab.lib.units import mm
11
10
  from reportlab.pdfgen import canvas
12
11
 
12
+ from decksmith.logger import logger
13
+
13
14
 
14
15
  class PdfExporter:
15
16
  """
@@ -46,9 +47,9 @@ class PdfExporter:
46
47
  image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
47
48
  return sorted(
48
49
  [
49
- p
50
- for p in self.image_folder.iterdir()
51
- if p.suffix.lower() in image_extensions
50
+ image_path
51
+ for image_path in self.image_folder.iterdir()
52
+ if image_path.suffix.lower() in image_extensions
52
53
  ]
53
54
  )
54
55
 
@@ -82,17 +83,18 @@ class PdfExporter:
82
83
  best_layout = (0, 0, False)
83
84
 
84
85
  for rotated in [False, True]:
85
- img_w, img_h = (
86
+ image_width, image_height = (
86
87
  (self.image_width, self.image_height)
87
88
  if not rotated
88
89
  else (self.image_height, self.image_width)
89
90
  )
90
91
 
91
92
  cols = int(
92
- (page_width - 2 * self.margins[0] + self.gap) / (img_w + self.gap)
93
+ (page_width - 2 * self.margins[0] + self.gap) / (image_width + self.gap)
93
94
  )
94
95
  rows = int(
95
- (page_height - 2 * self.margins[1] + self.gap) / (img_h + self.gap)
96
+ (page_height - 2 * self.margins[1] + self.gap)
97
+ / (image_height + self.gap)
96
98
  )
97
99
 
98
100
  if cols * rows > best_fit:
@@ -112,16 +114,16 @@ class PdfExporter:
112
114
  if cols == 0 or rows == 0:
113
115
  raise ValueError("The images are too large to fit on the page.")
114
116
 
115
- img_w, img_h = (
117
+ image_width, image_height = (
116
118
  (self.image_width, self.image_height)
117
119
  if not rotated
118
120
  else (self.image_height, self.image_width)
119
121
  )
120
122
 
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
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
125
127
 
126
128
  images_on_page = 0
127
129
  for image_path in self.image_paths:
@@ -132,37 +134,37 @@ class PdfExporter:
132
134
  row = images_on_page // cols
133
135
  col = images_on_page % cols
134
136
 
135
- x = start_x + col * (img_w + self.gap)
136
- y = start_y + row * (img_h + self.gap)
137
+ position_horizontal = start_horizontal + col * (image_width + self.gap)
138
+ position_vertical = start_vertical + row * (image_height + self.gap)
137
139
 
138
140
  if not rotated:
139
141
  self.pdf.drawImage(
140
142
  str(image_path),
141
- x,
142
- y,
143
- width=img_w,
144
- height=img_h,
143
+ position_horizontal,
144
+ position_vertical,
145
+ width=image_width,
146
+ height=image_height,
145
147
  preserveAspectRatio=True,
146
148
  )
147
149
  else:
148
150
  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)
151
+ center_horizontal = position_horizontal + image_width / 2
152
+ center_vertical = position_vertical + image_height / 2
153
+ self.pdf.translate(center_horizontal, center_vertical)
152
154
  self.pdf.rotate(90)
153
155
  self.pdf.drawImage(
154
156
  str(image_path),
155
- -img_h / 2,
156
- -img_w / 2,
157
- width=img_h,
158
- height=img_w,
157
+ -image_height / 2,
158
+ -image_width / 2,
159
+ width=image_height,
160
+ height=image_width,
159
161
  preserveAspectRatio=True,
160
162
  )
161
163
  self.pdf.restoreState()
162
164
  images_on_page += 1
163
165
 
164
166
  self.pdf.save()
165
- logging.info("Successfully exported PDF to %s", self.output_path)
167
+ logger.info("Successfully exported PDF to %s", self.output_path)
166
168
  except Exception as e:
167
- logging.error("An error occurred during PDF export: %s", e)
169
+ logger.error("An error occurred during PDF export: %s", e)
168
170
  raise
File without changes
decksmith/gui/app.py ADDED
@@ -0,0 +1,341 @@
1
+ """
2
+ This module contains the Flask application for the DeckSmith GUI.
3
+ """
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ import signal
9
+ import threading
10
+ import time
11
+ import webbrowser
12
+ from io import StringIO
13
+ from pathlib import Path
14
+ from threading import Timer
15
+
16
+ import pandas as pd
17
+ from flask import (
18
+ Flask,
19
+ Response,
20
+ jsonify,
21
+ render_template,
22
+ request,
23
+ send_file,
24
+ stream_with_context,
25
+ )
26
+ from platformdirs import user_documents_dir
27
+ from ruamel.yaml import YAML
28
+ from waitress import serve
29
+
30
+ from decksmith.card_builder import CardBuilder
31
+ from decksmith.deck_builder import DeckBuilder
32
+ from decksmith.export import PdfExporter
33
+ from decksmith.logger import logger
34
+ from decksmith.macro import MacroResolver
35
+ from decksmith.project import ProjectManager
36
+
37
+ app = Flask(__name__)
38
+
39
+ shutdown_event = threading.Event()
40
+ SHUTDOWN_REASON = "Server stopped."
41
+
42
+
43
+ def signal_handler(sig, frame): # pylint: disable=unused-argument
44
+ """Handles system signals (e.g., SIGINT)."""
45
+ global SHUTDOWN_REASON # pylint: disable=global-statement
46
+ SHUTDOWN_REASON = "Server stopped by user (Ctrl+C)."
47
+ logger.info(SHUTDOWN_REASON)
48
+ shutdown_event.set()
49
+
50
+ def delayed_exit():
51
+ time.sleep(1)
52
+ os._exit(0)
53
+
54
+ threading.Thread(target=delayed_exit).start()
55
+
56
+
57
+ signal.signal(signal.SIGINT, signal_handler)
58
+
59
+
60
+ @app.route("/api/events")
61
+ def events():
62
+ """Streams server events to the client."""
63
+
64
+ def stream():
65
+ while not shutdown_event.is_set():
66
+ time.sleep(0.5)
67
+ yield ": keepalive\n\n"
68
+
69
+ yield f"data: {json.dumps({'type': 'shutdown', 'reason': SHUTDOWN_REASON})}\n\n"
70
+
71
+ return Response(stream_with_context(stream()), mimetype="text/event-stream")
72
+
73
+
74
+ # Configuration
75
+ project_manager = ProjectManager()
76
+
77
+
78
+ @app.route("/")
79
+ def index():
80
+ """Renders the main page."""
81
+ return render_template("index.html")
82
+
83
+
84
+ @app.route("/api/project/current", methods=["GET"])
85
+ def get_current_project():
86
+ """Returns the current project path."""
87
+ working_dir = project_manager.get_working_dir()
88
+ return jsonify({"path": str(working_dir) if working_dir else None})
89
+
90
+
91
+ @app.route("/api/system/default-path", methods=["GET"])
92
+ def get_default_path():
93
+ """Returns the default project path."""
94
+ default_path = Path(user_documents_dir()) / "DeckSmith"
95
+ try:
96
+ default_path.mkdir(parents=True, exist_ok=True)
97
+ return jsonify({"path": str(default_path)})
98
+ except Exception as e:
99
+ logger.error("Error creating default path: %s", e)
100
+ return jsonify({"error": str(e)}), 500
101
+
102
+
103
+ @app.route("/api/system/browse", methods=["POST"])
104
+ def browse_folder():
105
+ """Opens a folder selection dialog."""
106
+ try:
107
+ import crossfiledialog # pylint: disable=import-outside-toplevel
108
+
109
+ folder_path = crossfiledialog.choose_folder(
110
+ title="Select Project Folder",
111
+ start_dir=str(Path.home()),
112
+ )
113
+
114
+ if folder_path:
115
+ return jsonify({"path": folder_path})
116
+ return jsonify({"path": None})
117
+ except ImportError as e:
118
+ logger.error("crossfiledialog import failed: %s", e)
119
+ return jsonify({"error": f"Browse feature unavailable: {e}"}), 501
120
+ except Exception as e:
121
+ logger.error("Error browsing folder: %s", e)
122
+ return jsonify({"error": str(e)}), 500
123
+
124
+
125
+ @app.route("/api/project/select", methods=["POST"])
126
+ def select_project():
127
+ """Selects a project directory."""
128
+ path_str = request.json.get("path")
129
+ if not path_str:
130
+ return jsonify({"error": "Path is required"}), 400
131
+
132
+ path = Path(path_str)
133
+ if not path.exists() or not path.is_dir():
134
+ return jsonify({"error": "Directory does not exist"}), 400
135
+
136
+ project_manager.set_working_dir(path)
137
+ return jsonify({"status": "success", "path": str(path)})
138
+
139
+
140
+ @app.route("/api/project/close", methods=["POST"])
141
+ def close_project():
142
+ """Closes the current project."""
143
+ project_manager.close_project()
144
+ return jsonify({"status": "success"})
145
+
146
+
147
+ @app.route("/api/project/create", methods=["POST"])
148
+ def create_project():
149
+ """Creates a new project."""
150
+ path_str = request.json.get("path")
151
+ if not path_str:
152
+ return jsonify({"error": "Path is required"}), 400
153
+
154
+ path = Path(path_str)
155
+
156
+ try:
157
+ project_manager.create_project(path)
158
+ return jsonify({"status": "success", "path": str(path)})
159
+ except Exception as e:
160
+ logger.error("Error creating project: %s", e)
161
+ return jsonify({"error": str(e)}), 500
162
+
163
+
164
+ @app.route("/api/load", methods=["GET"])
165
+ def load_files():
166
+ """Loads project files."""
167
+ data = project_manager.load_files()
168
+ return jsonify(data)
169
+
170
+
171
+ @app.route("/api/save", methods=["POST"])
172
+ def save_files():
173
+ """Saves project files."""
174
+ if project_manager.get_working_dir() is None:
175
+ return jsonify({"error": "No project selected"}), 400
176
+
177
+ data = request.json
178
+ yaml_content = data.get("yaml")
179
+ csv_content = data.get("csv")
180
+
181
+ try:
182
+ project_manager.save_files(yaml_content, csv_content)
183
+ return jsonify({"status": "success"})
184
+ except Exception as e:
185
+ logger.error("Error saving files: %s", e)
186
+ return jsonify({"error": str(e)}), 500
187
+
188
+
189
+ @app.route("/api/cards", methods=["GET"])
190
+ def list_cards():
191
+ """Lists cards in the project."""
192
+ working_dir = project_manager.get_working_dir()
193
+ if working_dir is None:
194
+ return jsonify([])
195
+
196
+ csv_path = working_dir / "deck.csv"
197
+ if not csv_path.exists():
198
+ return jsonify([])
199
+
200
+ try:
201
+ csv_table = pd.read_csv(csv_path, sep=";")
202
+ return jsonify(csv_table.to_dict(orient="records"))
203
+ except Exception as e:
204
+ logger.error("Error listing cards: %s", e)
205
+ return jsonify({"error": str(e)}), 400
206
+
207
+
208
+ @app.route("/api/preview/<int:card_index>", methods=["POST"])
209
+ def preview_card(card_index):
210
+ """Previews a specific card."""
211
+ working_dir = project_manager.get_working_dir()
212
+ # Allow preview even without project, but base_path will be None
213
+
214
+ data = request.json
215
+ yaml_content = data.get("yaml")
216
+ csv_content = data.get("csv")
217
+
218
+ try:
219
+ yaml = YAML()
220
+ spec = yaml.load(yaml_content)
221
+ except Exception as e:
222
+ return jsonify({"error": f"Invalid YAML: {e}"}), 400
223
+
224
+ try:
225
+ csv_table = pd.read_csv(StringIO(csv_content), sep=";")
226
+ if card_index < 0 or card_index >= len(csv_table):
227
+ return jsonify({"error": "Index out of bounds"}), 400
228
+ row = csv_table.iloc[card_index]
229
+ except Exception as e:
230
+ return jsonify({"error": f"Invalid CSV: {e}"}), 400
231
+
232
+ try:
233
+ row_dict = row.to_dict()
234
+ resolved_spec = MacroResolver.resolve(spec, row_dict)
235
+
236
+ builder = CardBuilder(resolved_spec, base_path=working_dir)
237
+ card_image = builder.render()
238
+
239
+ image_buffer = io.BytesIO()
240
+ card_image.save(image_buffer, "PNG")
241
+ image_buffer.seek(0)
242
+ return send_file(image_buffer, mimetype="image/png")
243
+
244
+ except Exception as e:
245
+ logger.error("Error previewing card: %s", e)
246
+ return jsonify({"error": str(e)}), 500
247
+
248
+
249
+ @app.route("/api/build", methods=["POST"])
250
+ def build_deck():
251
+ """Builds the deck."""
252
+ working_dir = project_manager.get_working_dir()
253
+ if working_dir is None:
254
+ return jsonify({"error": "No project selected"}), 400
255
+
256
+ # Save current state first
257
+ data = request.json
258
+ yaml_content = data.get("yaml")
259
+ csv_content = data.get("csv")
260
+
261
+ try:
262
+ project_manager.save_files(yaml_content, csv_content)
263
+
264
+ yaml_path = working_dir / "deck.yaml"
265
+ csv_path = working_dir / "deck.csv"
266
+ output_path = working_dir / "output"
267
+ output_path.mkdir(exist_ok=True)
268
+
269
+ builder = DeckBuilder(yaml_path, csv_path)
270
+ builder.build_deck(output_path)
271
+
272
+ return jsonify({"status": "success", "message": f"Deck built in {output_path}"})
273
+ except Exception as e:
274
+ logger.error("Error building deck: %s", e)
275
+ return jsonify({"error": str(e)}), 500
276
+
277
+
278
+ @app.route("/api/export", methods=["POST"])
279
+ def export_pdf():
280
+ """Exports the deck to PDF."""
281
+ working_dir = project_manager.get_working_dir()
282
+ if working_dir is None:
283
+ return jsonify({"error": "No project selected"}), 400
284
+
285
+ data = request.json or {}
286
+
287
+ try:
288
+ image_folder = working_dir / "output"
289
+
290
+ filename = data.get("filename", "deck.pdf")
291
+ if not filename.endswith(".pdf"):
292
+ filename += ".pdf"
293
+
294
+ output_pdf = working_dir / filename
295
+
296
+ if not image_folder.exists():
297
+ return jsonify(
298
+ {"error": "Output folder does not exist. Build deck first."}
299
+ ), 400
300
+
301
+ exporter = PdfExporter(
302
+ image_folder=image_folder,
303
+ output_path=output_pdf,
304
+ page_size_str=data.get("page_size", "A4"),
305
+ image_width=float(data.get("width", 63.5)),
306
+ image_height=float(data.get("height", 88.9)),
307
+ gap=float(data.get("gap", 0)),
308
+ margins=(float(data.get("margin_x", 2)), float(data.get("margin_y", 2))),
309
+ )
310
+ exporter.export()
311
+
312
+ return jsonify(
313
+ {"status": "success", "message": f"PDF exported to {output_pdf}"}
314
+ )
315
+ except Exception as e:
316
+ logger.error("Error exporting PDF: %s", e)
317
+ return jsonify({"error": str(e)}), 500
318
+
319
+
320
+ def open_browser():
321
+ """Opens the browser."""
322
+ webbrowser.open_new("http://127.0.0.1:5000/")
323
+
324
+
325
+ @app.route("/api/shutdown", methods=["POST"])
326
+ def shutdown():
327
+ """Shuts down the server."""
328
+ logger.info("Shutdown signal received. Shutting down.")
329
+ os._exit(0)
330
+
331
+
332
+ def main():
333
+ """Main entry point for the GUI."""
334
+ # Open browser after a short delay to ensure server is running
335
+ Timer(1, open_browser).start()
336
+
337
+ serve(app, host="127.0.0.1", port=5000)
338
+
339
+
340
+ if __name__ == "__main__":
341
+ main()