decksmith 0.1.15__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.
@@ -0,0 +1,76 @@
1
+ """
2
+ This module contains the ImageRenderer class for drawing images on cards.
3
+ """
4
+
5
+ import operator
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+
9
+ from PIL import Image
10
+
11
+ from decksmith.image_ops import ImageOps
12
+ from decksmith.logger import logger
13
+ from decksmith.utils import apply_anchor
14
+
15
+
16
+ class ImageRenderer:
17
+ """
18
+ A class to render image elements on a card.
19
+ """
20
+
21
+ def __init__(self, base_path: Optional[Path] = None):
22
+ self.base_path = base_path
23
+
24
+ def render(
25
+ self,
26
+ card: Image.Image,
27
+ element: Dict[str, Any],
28
+ calculate_pos_func,
29
+ store_pos_func,
30
+ ):
31
+ """
32
+ Draws an image on the card.
33
+ Args:
34
+ card (Image.Image): The card image object.
35
+ element (Dict[str, Any]): The image element specification.
36
+ calculate_pos_func (callable): Function to calculate absolute position.
37
+ store_pos_func (callable): Function to store element position.
38
+ """
39
+ assert element.pop("type") == "image", "Element type must be 'image'"
40
+
41
+ path_str = element["path"]
42
+ path = Path(path_str)
43
+
44
+ if not path.is_absolute() and self.base_path:
45
+ potential_path = self.base_path / path
46
+ if potential_path.exists():
47
+ path = potential_path
48
+
49
+ try:
50
+ img = Image.open(path)
51
+ except FileNotFoundError:
52
+ logger.error("Image not found: %s", path)
53
+ return
54
+
55
+ img = ImageOps.apply_filters(img, element.get("filters", {}))
56
+
57
+ position = calculate_pos_func(element)
58
+ if "anchor" in element:
59
+ anchor_point = apply_anchor((img.width, img.height), element.pop("anchor"))
60
+ position = tuple(map(operator.sub, position, anchor_point))
61
+
62
+ if img.mode == "RGBA":
63
+ card.paste(img, position, mask=img)
64
+ else:
65
+ card.paste(img, position)
66
+
67
+ if "id" in element:
68
+ store_pos_func(
69
+ element["id"],
70
+ (
71
+ position[0],
72
+ position[1],
73
+ position[0] + img.width,
74
+ position[1] + img.height,
75
+ ),
76
+ )
@@ -0,0 +1,237 @@
1
+ """
2
+ This module contains the ShapeRenderer class for drawing shapes on cards.
3
+ """
4
+
5
+ import operator
6
+ from typing import Any, Dict
7
+
8
+ from PIL import Image, ImageDraw
9
+
10
+ from decksmith.utils import apply_anchor
11
+
12
+
13
+ class ShapeRenderer:
14
+ """
15
+ A class to render shape elements on a card.
16
+ """
17
+
18
+ def render(
19
+ self,
20
+ card: Image.Image,
21
+ element: Dict[str, Any],
22
+ calculate_pos_func,
23
+ store_pos_func,
24
+ ) -> Image.Image:
25
+ """
26
+ Draws a shape on the card.
27
+ Args:
28
+ card (Image.Image): The card image object.
29
+ element (Dict[str, Any]): The shape element specification.
30
+ calculate_pos_func (callable): Function to calculate absolute position.
31
+ store_pos_func (callable): Function to store element position.
32
+ Returns:
33
+ Image.Image: The updated card image.
34
+ """
35
+ element_type = element.get("type")
36
+ draw_methods = {
37
+ "circle": self._draw_shape_circle,
38
+ "ellipse": self._draw_shape_ellipse,
39
+ "polygon": self._draw_shape_polygon,
40
+ "regular-polygon": self._draw_shape_regular_polygon,
41
+ "rectangle": self._draw_shape_rectangle,
42
+ }
43
+
44
+ if draw_method := draw_methods.get(element_type):
45
+ return draw_method(card, element, calculate_pos_func, store_pos_func)
46
+ return card
47
+
48
+ def _draw_shape_generic(
49
+ self,
50
+ card: Image.Image,
51
+ element: Dict[str, Any],
52
+ draw_func,
53
+ size_func,
54
+ calculate_pos_func,
55
+ store_pos_func,
56
+ ) -> Image.Image:
57
+ """Generic method to draw shapes."""
58
+ size = size_func(element)
59
+ absolute_pos = calculate_pos_func(element)
60
+
61
+ if "color" in element:
62
+ element["fill"] = tuple(element["color"])
63
+ if "outline_color" in element:
64
+ element["outline_color"] = tuple(element["outline_color"])
65
+
66
+ if "anchor" in element:
67
+ anchor_offset = apply_anchor(size, element.pop("anchor"))
68
+ absolute_pos = tuple(map(operator.sub, absolute_pos, anchor_offset))
69
+
70
+ layer = Image.new("RGBA", card.size, (0, 0, 0, 0))
71
+ layer_draw = ImageDraw.Draw(layer, "RGBA")
72
+
73
+ draw_func(layer_draw, absolute_pos, element)
74
+
75
+ card = Image.alpha_composite(card, layer)
76
+
77
+ if "id" in element:
78
+ store_pos_func(
79
+ element["id"],
80
+ (
81
+ absolute_pos[0],
82
+ absolute_pos[1],
83
+ absolute_pos[0] + size[0],
84
+ absolute_pos[1] + size[1],
85
+ ),
86
+ )
87
+ return card
88
+
89
+ def _draw_shape_circle(
90
+ self,
91
+ card: Image.Image,
92
+ element: Dict[str, Any],
93
+ calculate_pos_func,
94
+ store_pos_func,
95
+ ) -> Image.Image:
96
+ assert element.pop("type") == "circle", "Element type must be 'circle'"
97
+ radius = element["radius"]
98
+
99
+ def draw(layer_draw, pos, element):
100
+ center_pos = (pos[0] + radius, pos[1] + radius)
101
+ layer_draw.circle(
102
+ center_pos,
103
+ radius,
104
+ fill=element.get("fill", None),
105
+ outline=element.get("outline_color", None),
106
+ width=element.get("outline_width", 1),
107
+ )
108
+
109
+ return self._draw_shape_generic(
110
+ card,
111
+ element,
112
+ draw,
113
+ lambda _: (radius * 2, radius * 2),
114
+ calculate_pos_func,
115
+ store_pos_func,
116
+ )
117
+
118
+ def _draw_shape_ellipse(
119
+ self,
120
+ card: Image.Image,
121
+ element: Dict[str, Any],
122
+ calculate_pos_func,
123
+ store_pos_func,
124
+ ) -> Image.Image:
125
+ assert element.pop("type") == "ellipse", "Element type must be 'ellipse'"
126
+ size = element["size"]
127
+
128
+ def draw(layer_draw, pos, element):
129
+ bbox = (pos[0], pos[1], pos[0] + size[0], pos[1] + size[1])
130
+ layer_draw.ellipse(
131
+ bbox,
132
+ fill=element.get("fill", None),
133
+ outline=element.get("outline_color", None),
134
+ width=element.get("outline_width", 1),
135
+ )
136
+
137
+ return self._draw_shape_generic(
138
+ card, element, draw, lambda _: size, calculate_pos_func, store_pos_func
139
+ )
140
+
141
+ def _draw_shape_polygon(
142
+ self,
143
+ card: Image.Image,
144
+ element: Dict[str, Any],
145
+ calculate_pos_func,
146
+ store_pos_func,
147
+ ) -> Image.Image:
148
+ assert element.pop("type") == "polygon", "Element type must be 'polygon'"
149
+ points = [tuple(point) for point in element.get("points", [])]
150
+ if not points:
151
+ return card
152
+
153
+ min_horizontal = min(point[0] for point in points)
154
+ max_horizontal = max(point[0] for point in points)
155
+ min_vertical = min(point[1] for point in points)
156
+ max_vertical = max(point[1] for point in points)
157
+ bbox_size = (max_horizontal - min_horizontal, max_vertical - min_vertical)
158
+
159
+ def draw(layer_draw, pos, element):
160
+ # pos is the top-left of the bounding box
161
+ # We need to shift points so that (min_horizontal, min_vertical) aligns with pos
162
+ offset_horizontal = pos[0] - min_horizontal
163
+ offset_vertical = pos[1] - min_vertical
164
+ final_points = [
165
+ (point[0] + offset_horizontal, point[1] + offset_vertical)
166
+ for point in points
167
+ ]
168
+
169
+ layer_draw.polygon(
170
+ final_points,
171
+ fill=element.get("fill", None),
172
+ outline=element.get("outline_color", None),
173
+ width=element.get("outline_width", 1),
174
+ )
175
+
176
+ return self._draw_shape_generic(
177
+ card, element, draw, lambda _: bbox_size, calculate_pos_func, store_pos_func
178
+ )
179
+
180
+ def _draw_shape_regular_polygon(
181
+ self,
182
+ card: Image.Image,
183
+ element: Dict[str, Any],
184
+ calculate_pos_func,
185
+ store_pos_func,
186
+ ) -> Image.Image:
187
+ assert element.pop("type") == "regular-polygon", (
188
+ "Element type must be 'regular-polygon'"
189
+ )
190
+ radius = element["radius"]
191
+
192
+ def draw(layer_draw, pos, element):
193
+ center_pos = (pos[0] + radius, pos[1] + radius)
194
+ layer_draw.regular_polygon(
195
+ (center_pos[0], center_pos[1], radius),
196
+ n_sides=element["sides"],
197
+ rotation=element.get("rotation", 0),
198
+ fill=element.get("fill", None),
199
+ outline=element.get("outline_color", None),
200
+ width=element.get("outline_width", 1),
201
+ )
202
+
203
+ return self._draw_shape_generic(
204
+ card,
205
+ element,
206
+ draw,
207
+ lambda _: (radius * 2, radius * 2),
208
+ calculate_pos_func,
209
+ store_pos_func,
210
+ )
211
+
212
+ def _draw_shape_rectangle(
213
+ self,
214
+ card: Image.Image,
215
+ element: Dict[str, Any],
216
+ calculate_pos_func,
217
+ store_pos_func,
218
+ ) -> Image.Image:
219
+ assert element.pop("type") == "rectangle", "Element type must be 'rectangle'"
220
+ size = element["size"]
221
+ if "corners" in element:
222
+ element["corners"] = tuple(element["corners"])
223
+
224
+ def draw(layer_draw, pos, element):
225
+ bbox = (pos[0], pos[1], pos[0] + size[0], pos[1] + size[1])
226
+ layer_draw.rounded_rectangle(
227
+ bbox,
228
+ radius=element.get("corner_radius", 0),
229
+ fill=element.get("fill", None),
230
+ outline=element.get("outline_color", None),
231
+ width=element.get("outline_width", 1),
232
+ corners=element.get("corners", None),
233
+ )
234
+
235
+ return self._draw_shape_generic(
236
+ card, element, draw, lambda _: size, calculate_pos_func, store_pos_func
237
+ )
@@ -0,0 +1,127 @@
1
+ """
2
+ This module contains the TextRenderer class for drawing text on cards.
3
+ """
4
+
5
+ import operator
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+
9
+ import pandas as pd
10
+ from PIL import ImageDraw, ImageFont
11
+
12
+ from decksmith.logger import logger
13
+ from decksmith.utils import apply_anchor, get_wrapped_text
14
+
15
+
16
+ class TextRenderer:
17
+ """
18
+ A class to render text elements on a card.
19
+ """
20
+
21
+ def __init__(self, base_path: Optional[Path] = None):
22
+ self.base_path = base_path
23
+
24
+ def render(
25
+ self,
26
+ draw: ImageDraw.ImageDraw,
27
+ element: Dict[str, Any],
28
+ calculate_pos_func,
29
+ store_pos_func,
30
+ ):
31
+ """
32
+ Draws text on the card.
33
+ Args:
34
+ draw (ImageDraw.ImageDraw): The PIL ImageDraw object.
35
+ element (Dict[str, Any]): The text element specification.
36
+ calculate_pos_func (callable): Function to calculate absolute position.
37
+ store_pos_func (callable): Function to store element position.
38
+ """
39
+ assert element.pop("type") == "text", "Element type must be 'text'"
40
+
41
+ element = self._prepare_text_element(element)
42
+
43
+ original_pos = calculate_pos_func(element)
44
+ element["position"] = original_pos
45
+
46
+ # Calculate anchor offset if needed
47
+ if "anchor" in element:
48
+ bbox = draw.textbbox(
49
+ xy=(0, 0),
50
+ text=element.get("text"),
51
+ font=element["font"],
52
+ spacing=element.get("line_spacing", 4),
53
+ align=element.get("align", "left"),
54
+ )
55
+ anchor_point = apply_anchor(bbox, element.pop("anchor"))
56
+ element["position"] = tuple(map(operator.sub, original_pos, anchor_point))
57
+
58
+ # Draw text
59
+ draw.text(
60
+ xy=element.get("position"),
61
+ text=element.get("text"),
62
+ fill=element.get("color", None),
63
+ font=element["font"],
64
+ spacing=element.get("line_spacing", 4),
65
+ align=element.get("align", "left"),
66
+ stroke_width=element.get("stroke_width", 0),
67
+ stroke_fill=element.get("stroke_color", None),
68
+ )
69
+
70
+ # Store position
71
+ if "id" in element:
72
+ bbox = draw.textbbox(
73
+ xy=element.get("position"),
74
+ text=element.get("text"),
75
+ font=element["font"],
76
+ spacing=element.get("line_spacing", 4),
77
+ align=element.get("align", "left"),
78
+ )
79
+ store_pos_func(element["id"], bbox)
80
+
81
+ def _prepare_text_element(self, element: Dict[str, Any]) -> Dict[str, Any]:
82
+ """Prepares text element properties."""
83
+ if pd.isna(element["text"]):
84
+ element["text"] = " "
85
+
86
+ # Font setup
87
+ font_size = element.pop("font_size", 10)
88
+ if font_path := element.pop("font_path", False):
89
+ # Resolve font path relative to base_path if provided
90
+ if self.base_path and not Path(font_path).is_absolute():
91
+ potential_path = self.base_path / font_path
92
+ if potential_path.exists():
93
+ font_path = str(potential_path)
94
+
95
+ try:
96
+ element["font"] = ImageFont.truetype(
97
+ font_path, font_size, encoding="unic"
98
+ )
99
+ except OSError:
100
+ logger.error("Could not load font: %s. Using default.", font_path)
101
+ element["font"] = ImageFont.load_default(font_size)
102
+ else:
103
+ element["font"] = ImageFont.load_default(font_size)
104
+
105
+ if font_variant := element.pop("font_variant", None):
106
+ try:
107
+ element["font"].set_variation_by_name(font_variant)
108
+ except AttributeError:
109
+ logger.warning(
110
+ "Font variant '%s' not supported for this font.", font_variant
111
+ )
112
+
113
+ # Text wrapping
114
+ if line_length := element.pop("width", False):
115
+ element["text"] = get_wrapped_text(
116
+ element["text"], element["font"], line_length
117
+ )
118
+
119
+ # Colors and position
120
+ if position := element.pop("position", [0, 0]):
121
+ element["position"] = tuple(position)
122
+ if color := element.pop("color", [0, 0, 0]):
123
+ element["color"] = tuple(color)
124
+ if stroke_color := element.pop("stroke_color", None):
125
+ element["stroke_color"] = tuple(stroke_color)
126
+
127
+ return element
@@ -1,5 +1,4 @@
1
- title;description
2
- Welcome to DeckSmith!;To get started, edit "deck.csv" to add new cards, or "deck.json" to change their structure.
3
- Building the deck;Run "decksmith build" when you are ready to save the cards as images.
4
- Exporting to PDF;You can create a printable PDF with "decksmith export --width 63.5 --height 88.9 output" after building.
5
- Check out the guide;Visit "github.com/julynx/decksmith" to learn all the things you can do using DeckSmith. Enjoy!
1
+ title;first-paragraph;second-paragraph
2
+ Welcome to DeckSmith!;This is a card. You can add as many cards as you need to your deck by adding a new line to the data file (CSV).;The layout file is a YAML file that holds the structure for all the cards in the deck.
3
+ Connecting the dots;Any column in the data file (CSV) can be referenced from the layout file as "%colname%".;Cards can have all kinds of elements, such as images or shapes, that are layered in the order they appear in the layout file.
4
+ Check out the docs;Shapes can have transparency, images can be transformed using filters, and elements can be placed relative to each other. You can also use custom fonts and much, much more!;Visit julynx.github.io/decksmith/docs.html to discover all you can do with DeckSmith. Enjoy!
@@ -0,0 +1,46 @@
1
+ id: canvas
2
+ width: 1000
3
+ height: 1400
4
+ background_color: [220, 220, 220]
5
+ elements:
6
+ - id: title
7
+ type: text
8
+ position: [0, 120]
9
+ relative_to: [canvas, top-center]
10
+ anchor: top-center
11
+ text: "%title%"
12
+ color: [50, 50, 100]
13
+ font_path: arial.ttf
14
+ font_size: 64
15
+ align: center
16
+ - id: line
17
+ type: rectangle
18
+ size: [600, 2]
19
+ position: [0, 100]
20
+ relative_to: [title, bottom-center]
21
+ anchor: top-center
22
+ color: [100, 100, 100]
23
+ - id: first-paragraph
24
+ type: text
25
+ position: [0, 200]
26
+ relative_to: [title, bottom-center]
27
+ anchor: top-center
28
+ text: "%first-paragraph%"
29
+ color: [50, 100, 100]
30
+ font_path: arial.ttf
31
+ font_size: 48
32
+ line_spacing: 20
33
+ width: 800
34
+ align: justify
35
+ - id: second-paragraph
36
+ type: text
37
+ position: [0, 100]
38
+ relative_to: [first-paragraph, bottom-center]
39
+ anchor: top-center
40
+ text: "%second-paragraph%"
41
+ color: [50, 100, 100]
42
+ font_path: arial.ttf
43
+ font_size: 48
44
+ line_spacing: 20
45
+ width: 800
46
+ align: justify
decksmith/utils.py CHANGED
@@ -1,69 +1,75 @@
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
+ """
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
+ width, height = size
47
+ position_horizontal, position_vertical = 0, 0
48
+ elif len(size) == 4:
49
+ position_horizontal, position_vertical, position_right, position_bottom = size
50
+ width, height = (
51
+ position_right - position_horizontal,
52
+ position_bottom - position_vertical,
53
+ )
54
+ else:
55
+ raise ValueError("Size must be a tuple of 2 or 4 integers.")
56
+
57
+ anchor_points = {
58
+ "top-left": (position_horizontal, position_vertical),
59
+ "top-center": (position_horizontal + width // 2, position_vertical),
60
+ "top-right": (position_horizontal + width, position_vertical),
61
+ "middle-left": (position_horizontal, position_vertical + height // 2),
62
+ "center": (
63
+ position_horizontal + width // 2,
64
+ position_vertical + height // 2,
65
+ ),
66
+ "middle-right": (position_horizontal + width, position_vertical + height // 2),
67
+ "bottom-left": (position_horizontal, position_vertical + height),
68
+ "bottom-center": (position_horizontal + width // 2, position_vertical + height),
69
+ "bottom-right": (position_horizontal + width, position_vertical + height),
70
+ }
71
+
72
+ if anchor not in anchor_points:
73
+ raise ValueError(f"Unknown anchor: {anchor}")
74
+
75
+ return anchor_points[anchor]