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.
- {decksmith-0.1.15 → decksmith-0.9.1}/PKG-INFO +22 -16
- decksmith-0.9.1/decksmith/card_builder.py +140 -0
- decksmith-0.9.1/decksmith/deck_builder.py +85 -0
- {decksmith-0.1.15 → decksmith-0.9.1}/decksmith/export.py +170 -168
- decksmith-0.9.1/decksmith/gui/__init__.py +0 -0
- decksmith-0.9.1/decksmith/gui/app.py +341 -0
- decksmith-0.9.1/decksmith/gui/static/css/style.css +656 -0
- decksmith-0.9.1/decksmith/gui/static/js/main.js +583 -0
- decksmith-0.9.1/decksmith/gui/templates/index.html +182 -0
- decksmith-0.9.1/decksmith/image_ops.py +121 -0
- decksmith-0.9.1/decksmith/logger.py +39 -0
- decksmith-0.9.1/decksmith/macro.py +46 -0
- {decksmith-0.1.15 → decksmith-0.9.1}/decksmith/main.py +31 -23
- decksmith-0.9.1/decksmith/project.py +111 -0
- decksmith-0.9.1/decksmith/renderers/__init__.py +3 -0
- decksmith-0.9.1/decksmith/renderers/image.py +76 -0
- decksmith-0.9.1/decksmith/renderers/shapes.py +237 -0
- decksmith-0.9.1/decksmith/renderers/text.py +127 -0
- decksmith-0.9.1/decksmith/templates/deck.csv +4 -0
- decksmith-0.9.1/decksmith/templates/deck.yaml +46 -0
- {decksmith-0.1.15 → decksmith-0.9.1}/decksmith/utils.py +75 -69
- {decksmith-0.1.15 → decksmith-0.9.1}/decksmith/validate.py +132 -132
- {decksmith-0.1.15 → decksmith-0.9.1}/docs/README.md +14 -12
- {decksmith-0.1.15 → decksmith-0.9.1}/pyproject.toml +19 -26
- decksmith-0.1.15/decksmith/card_builder.py +0 -627
- decksmith-0.1.15/decksmith/deck_builder.py +0 -101
- decksmith-0.1.15/decksmith/templates/deck.csv +0 -5
- decksmith-0.1.15/decksmith/templates/deck.json +0 -31
- {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
|
|
4
|
-
Summary: A command-line application to dynamically generate decks of cards from a
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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#
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
6
|
-
from
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
from reportlab.lib.
|
|
10
|
-
from reportlab.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
self.
|
|
31
|
-
self.
|
|
32
|
-
self.
|
|
33
|
-
self.
|
|
34
|
-
self.
|
|
35
|
-
self.
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"""
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
self.pdf.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|