decksmith 0.1.15__tar.gz → 0.9.1__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.
Files changed (29) hide show
  1. {decksmith-0.1.15 → decksmith-0.9.1}/PKG-INFO +22 -16
  2. decksmith-0.9.1/decksmith/card_builder.py +140 -0
  3. decksmith-0.9.1/decksmith/deck_builder.py +85 -0
  4. {decksmith-0.1.15 → decksmith-0.9.1}/decksmith/export.py +170 -168
  5. decksmith-0.9.1/decksmith/gui/__init__.py +0 -0
  6. decksmith-0.9.1/decksmith/gui/app.py +341 -0
  7. decksmith-0.9.1/decksmith/gui/static/css/style.css +656 -0
  8. decksmith-0.9.1/decksmith/gui/static/js/main.js +583 -0
  9. decksmith-0.9.1/decksmith/gui/templates/index.html +182 -0
  10. decksmith-0.9.1/decksmith/image_ops.py +121 -0
  11. decksmith-0.9.1/decksmith/logger.py +39 -0
  12. decksmith-0.9.1/decksmith/macro.py +46 -0
  13. {decksmith-0.1.15 → decksmith-0.9.1}/decksmith/main.py +31 -23
  14. decksmith-0.9.1/decksmith/project.py +111 -0
  15. decksmith-0.9.1/decksmith/renderers/__init__.py +3 -0
  16. decksmith-0.9.1/decksmith/renderers/image.py +76 -0
  17. decksmith-0.9.1/decksmith/renderers/shapes.py +237 -0
  18. decksmith-0.9.1/decksmith/renderers/text.py +127 -0
  19. decksmith-0.9.1/decksmith/templates/deck.csv +4 -0
  20. decksmith-0.9.1/decksmith/templates/deck.yaml +46 -0
  21. {decksmith-0.1.15 → decksmith-0.9.1}/decksmith/utils.py +75 -69
  22. {decksmith-0.1.15 → decksmith-0.9.1}/decksmith/validate.py +132 -132
  23. {decksmith-0.1.15 → decksmith-0.9.1}/docs/README.md +14 -12
  24. {decksmith-0.1.15 → decksmith-0.9.1}/pyproject.toml +19 -26
  25. decksmith-0.1.15/decksmith/card_builder.py +0 -627
  26. decksmith-0.1.15/decksmith/deck_builder.py +0 -101
  27. decksmith-0.1.15/decksmith/templates/deck.csv +0 -5
  28. decksmith-0.1.15/decksmith/templates/deck.json +0 -31
  29. {decksmith-0.1.15 → decksmith-0.9.1}/decksmith/__init__.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: decksmith
3
- Version: 0.1.15
4
- Summary: A command-line application to dynamically generate decks of cards from a JSON specification and a CSV data file, inspired by nandeck.
3
+ Version: 0.9.1
4
+ Summary: A command-line application to dynamically generate decks of cards from a YAML specification and a CSV data file, inspired by nandeck.
5
5
  License-Expression: GPL-2.0-only
6
6
  Author: Julio Cabria
7
7
  Author-email: juliocabria@tutanota.com
@@ -13,27 +13,26 @@ Classifier: Programming Language :: Python :: 3.13
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Provides-Extra: dev
15
15
  Requires-Dist: click
16
+ Requires-Dist: crossfiledialog (>=1.1.0)
17
+ Requires-Dist: flask (>=3.1.2)
16
18
  Requires-Dist: jval (==1.0.6)
17
19
  Requires-Dist: pandas
18
20
  Requires-Dist: pillow (>=11.3.0)
19
- Requires-Dist: poetry ; extra == "dev"
21
+ Requires-Dist: platformdirs (>=4.5.1)
20
22
  Requires-Dist: pytest ; extra == "dev"
23
+ Requires-Dist: pywin32 (>=311) ; sys_platform == "win32"
21
24
  Requires-Dist: reportlab (>=4.4.3)
22
- Project-URL: Homepage, https://github.com/Julynx/decksmith
25
+ Requires-Dist: ruamel-yaml (>=0.19.1)
26
+ Requires-Dist: waitress (>=3.0.2)
23
27
  Description-Content-Type: text/markdown
24
28
 
25
29
  # DeckSmith
26
30
 
27
- *A command-line application to dynamically generate decks of cards from a JSON specification and a CSV data file, inspired by nandeck.*
31
+ *A powerful application to dynamically generate decks of cards from a YAML specification and a CSV data file.*
28
32
 
29
33
  <br>
30
34
  <p align="center">
31
- <img width="600" src="https://raw.githubusercontent.com/Julynx/decksmith/refs/heads/main/docs/assets/decksmith.png">
32
- </p>
33
-
34
- <br>
35
- <p align="center">
36
- <img width="600" src="https://raw.githubusercontent.com/Julynx/decksmith/refs/heads/main/docs/assets/banner.png">
35
+ <img src="https://raw.githubusercontent.com/Julynx/decksmith/refs/heads/main/docs/assets/screenshot.png" width='100%'>
37
36
  </p>
38
37
 
39
38
  <br>
@@ -44,12 +43,13 @@ DeckSmith is ideal for automating the creation of all kinds of decks, including
44
43
 
45
44
  - ✨ Consistent layout and formatting across all cards. Define once, edit anytime, generate as many cards as you need.
46
45
  - 🍳 Pure python, with easy installation via pip.
46
+ - 🖥️ User-friendly GUI for easy project management and generation.
47
47
  - ⚡ Highly performant card generation using parallel processing.
48
48
  - 📖 Intuitive syntax and extensive [documentation](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md) with examples to help you get started.
49
49
  - 🧰 Tons of powerful features such as:
50
50
  - [Start from a sample project and edit it instead of starting from scratch](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md#creating-a-project)
51
51
  - [Extensive support for images](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md#images), [text](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md#text), [and all kinds of different shapes](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md#shapes)
52
- - [Link any field to a column in the CSV file](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md#basic-example-with-deckcsv)
52
+ - [Link any field to a column in the CSV file](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md#example-with-deckcsv)
53
53
  - [Position elements absolutely or relative to other elements, using anchors to simplify placement](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md#positioning)
54
54
  - [Powerful image transformations using filters like crop, resize, rotate, or flip](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md#images)
55
55
  - [Export your deck as images or as a PDF for printing](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md#building-the-deck)
@@ -64,23 +64,29 @@ DeckSmith is ideal for automating the creation of all kinds of decks, including
64
64
  pip install decksmith
65
65
  ```
66
66
 
67
+ - To launch the GUI, run:
68
+
69
+ ```bash
70
+ decksmith --gui
71
+ ```
72
+
67
73
  ### Creating a project
68
74
 
69
- - Run the following command to start from sample `deck.json` and `deck.csv` files:
75
+ - Run the following command to start from sample `deck.yaml` and `deck.csv` files:
70
76
 
71
77
  ```text
72
78
  decksmith init
73
79
  ```
74
80
 
75
- - `deck.json` defines the layout for the cards in the deck.
81
+ - `deck.yaml` defines the layout for the cards in the deck.
76
82
  - `deck.csv` holds the data for each card, like the content of the text fields and the image paths.
77
83
 
78
84
  ### Defining the layout
79
85
 
80
- - Edit `deck.json` to include all the elements you want on your cards.
86
+ - Edit `deck.yaml` to include all the elements you want on your cards.
81
87
  You can find a complete list of all the available elements and their properties in the [documentation](https://github.com/Julynx/decksmith/blob/main/docs/DOCS.md).
82
88
 
83
- - You can reference any column from `deck.csv` in the `deck.json` file as `%column_name%`.
89
+ - You can reference any column from `deck.csv` in the `deck.yaml` file as `%column_name%`.
84
90
 
85
91
  ### Building the deck
86
92
 
@@ -0,0 +1,140 @@
1
+ """
2
+ This module contains the CardBuilder class,
3
+ which is used to create card images based on a YAML specification.
4
+ """
5
+
6
+ import operator
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional, Tuple
9
+
10
+ from PIL import Image, ImageDraw
11
+
12
+ from decksmith.logger import logger
13
+ from decksmith.renderers.image import ImageRenderer
14
+ from decksmith.renderers.shapes import ShapeRenderer
15
+ from decksmith.renderers.text import TextRenderer
16
+ from decksmith.utils import apply_anchor
17
+ from decksmith.validate import transform_card, validate_card
18
+
19
+
20
+ class CardBuilder:
21
+ """
22
+ A class to build a card image based on a YAML specification.
23
+ Attributes:
24
+ spec (Dict[str, Any]): The YAML specification for the card.
25
+ card (Image.Image): The PIL Image object representing the card.
26
+ draw (ImageDraw.ImageDraw): The PIL ImageDraw object for drawing on the card.
27
+ element_positions (Dict[str, Tuple[int, int, int, int]]):
28
+ A dictionary mapping element IDs to their bounding boxes.
29
+ """
30
+
31
+ def __init__(self, spec: Dict[str, Any], base_path: Optional[Path] = None):
32
+ """
33
+ Initializes the CardBuilder with a YAML specification.
34
+ Args:
35
+ spec (Dict[str, Any]): The YAML specification for the card.
36
+ base_path (Optional[Path]): The base path for resolving relative file paths.
37
+ """
38
+ self.spec = spec
39
+ self.base_path = base_path
40
+ width = self.spec.get("width", 250)
41
+ height = self.spec.get("height", 350)
42
+ background_color = tuple(self.spec.get("background_color", (255, 255, 255, 0)))
43
+ self.card: Image.Image = Image.new("RGBA", (width, height), background_color)
44
+ self.draw: ImageDraw.ImageDraw = ImageDraw.Draw(self.card, "RGBA")
45
+ self.element_positions: Dict[str, Tuple[int, int, int, int]] = {}
46
+ if "id" in spec:
47
+ self.element_positions[self.spec["id"]] = (0, 0, width, height)
48
+
49
+ self.text_renderer = TextRenderer(base_path)
50
+ self.image_renderer = ImageRenderer(base_path)
51
+ self.shape_renderer = ShapeRenderer()
52
+
53
+ def _calculate_absolute_position(self, element: Dict[str, Any]) -> Tuple[int, int]:
54
+ """
55
+ Calculates the absolute position of an element,
56
+ resolving relative positioning.
57
+ Args:
58
+ element (dict): The element dictionary.
59
+ Returns:
60
+ tuple: The absolute (x, y) position of the element.
61
+ """
62
+ # If the element has no 'relative_to', return its position directly
63
+ if "relative_to" not in element:
64
+ return tuple(element.get("position", [0, 0]))
65
+
66
+ # If the element has 'relative_to', resolve based on the reference element and anchor
67
+ relative_identifier, anchor = element["relative_to"]
68
+ if relative_identifier not in self.element_positions:
69
+ raise ValueError(
70
+ f"Element with id '{relative_identifier}' not found for relative positioning."
71
+ )
72
+
73
+ parent_bbox = self.element_positions[relative_identifier]
74
+ anchor_point = apply_anchor(parent_bbox, anchor)
75
+
76
+ offset = tuple(element.get("position", [0, 0]))
77
+ return tuple(map(operator.add, anchor_point, offset))
78
+
79
+ def _store_element_position(
80
+ self, element_identifier: str, bbox: Tuple[int, int, int, int]
81
+ ):
82
+ """Stores the bounding box of an element."""
83
+ self.element_positions[element_identifier] = bbox
84
+
85
+ def render(self) -> Image.Image:
86
+ """
87
+ Renders the card image by drawing all elements specified in the YAML.
88
+ Returns:
89
+ Image.Image: The rendered card image.
90
+ """
91
+ self.spec = transform_card(self.spec)
92
+ validate_card(self.spec)
93
+
94
+ for element in self.spec.get("elements", []):
95
+ element_type = element.get("type")
96
+ try:
97
+ if element_type == "text":
98
+ self.text_renderer.render(
99
+ self.draw,
100
+ element,
101
+ self._calculate_absolute_position,
102
+ self._store_element_position,
103
+ )
104
+ elif element_type == "image":
105
+ self.image_renderer.render(
106
+ self.card,
107
+ element,
108
+ self._calculate_absolute_position,
109
+ self._store_element_position,
110
+ )
111
+ elif element_type in [
112
+ "circle",
113
+ "ellipse",
114
+ "polygon",
115
+ "regular-polygon",
116
+ "rectangle",
117
+ ]:
118
+ self.card = self.shape_renderer.render(
119
+ self.card,
120
+ element,
121
+ self._calculate_absolute_position,
122
+ self._store_element_position,
123
+ )
124
+ # Re-create draw object because shape renderer might have composited a new image
125
+ self.draw = ImageDraw.Draw(self.card, "RGBA")
126
+ except Exception as e:
127
+ logger.error("Error drawing element %s: %s", element_type, e)
128
+ # Continue drawing other elements
129
+
130
+ return self.card
131
+
132
+ def build(self, output_path: Path):
133
+ """
134
+ Builds the card image and saves it to the specified path.
135
+ Args:
136
+ output_path (Path): The path where the card image will be saved.
137
+ """
138
+ card = self.render()
139
+ card.save(output_path)
140
+ logger.info("(✔) Card saved to %s", output_path)
@@ -0,0 +1,85 @@
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()))
@@ -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