imagesmacker 2.1.5__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.
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.1
2
+ Name: imagesmacker
3
+ Version: 2.1.5
4
+ Summary:
5
+ Author: whinee
6
+ Requires-Python: >=3.10,<3.13
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Dist: alltheutils (>=1.0.1,<2.0.0)
12
+ Requires-Dist: msgpack (>=1.0.5,<2.0.0)
13
+ Requires-Dist: openpyxl (>=3.1.2,<4.0.0)
14
+ Requires-Dist: pillow (>=10.0.1,<11.0.0)
15
+ Requires-Dist: pydantic (>=2.8.2,<3.0.0)
16
+ Requires-Dist: python-barcode (>=0.15.1,<0.16.0)
17
+ Requires-Dist: pyyaml (>=6.0,<7.0)
18
+ Requires-Dist: qrcode[pil] (>=7.4.2,<8.0.0)
19
+ Requires-Dist: regex (>=2023.8.8,<2024.0.0)
20
+ Requires-Dist: rich (>=13.3.5,<14.0.0)
File without changes
@@ -0,0 +1,358 @@
1
+ from textwrap import wrap
2
+ from typing import Any, Literal
3
+
4
+ from barcode import Code128
5
+ from barcode.writer import ImageWriter as BarcodeImageWriter
6
+ from PIL import Image, ImageDraw
7
+ from qrcode.image.pil import PilImage
8
+ from qrcode.main import QRCode
9
+
10
+ from imagesmacker.fonts import FontSizeCalculator, font_loader
11
+ from imagesmacker.models.coordinates import XYXY, RectangleCoordinates
12
+ from imagesmacker.models.draw import Code128Config, QRCodeConfig, TextConfig
13
+ from imagesmacker.models.fields import BarcodeFieldAttributes, TextFieldAttributes
14
+ from imagesmacker.utils import scale_and_center_rect
15
+
16
+
17
+ class Barcode:
18
+ @staticmethod
19
+ def code128(
20
+ data: str,
21
+ code_128_config: Code128Config,
22
+ field_coords: RectangleCoordinates,
23
+ ) -> PilImage:
24
+ if code_128_config.options is None:
25
+ code_128_config.options = {
26
+ "module_width": 0.2,
27
+ "module_height": 15,
28
+ "quiet_zone": 1,
29
+ "text_distance": 2,
30
+ }
31
+ barcode_class = Code128(data, writer=BarcodeImageWriter())
32
+
33
+ module_height = code_128_config.options.get("module_height", 15)
34
+ bc_width, bc_height = barcode_class.render(
35
+ writer_options=code_128_config.options,
36
+ text="",
37
+ ).size
38
+ field_width, field_height = field_coords.xywh()[2:4]
39
+
40
+ bc_aspect_ratio = bc_height / bc_width
41
+ field_aspect_ratio = field_height / field_width
42
+ bc_factor = module_height / bc_aspect_ratio
43
+
44
+ code_128_config.options["module_height"] = field_aspect_ratio * bc_factor
45
+
46
+ return barcode_class.render(writer_options=code_128_config.options, text="")
47
+
48
+ @staticmethod
49
+ def qr(
50
+ data: str,
51
+ qr_code_config: QRCodeConfig,
52
+ field_coords: RectangleCoordinates,
53
+ ) -> PilImage:
54
+ qr = QRCode(
55
+ border=qr_code_config.border,
56
+ box_size=qr_code_config.box_size,
57
+ )
58
+ qr.add_data(data)
59
+ qr.make(fit=True)
60
+ return qr.make_image(
61
+ # image_factory=StyledPilImage,
62
+ # module_drawer=RoundedModuleDrawer(),
63
+ )
64
+
65
+
66
+ class Draw:
67
+ """
68
+ An abstraction of `ImageDraw.Draw` specifically for drawing texts
69
+ and barcodes in images.
70
+ """
71
+
72
+ def __init__(self, image: Image.Image) -> None:
73
+ self.image = image
74
+ self.draw = ImageDraw.Draw(image)
75
+
76
+ def text( # noqa: C901
77
+ self,
78
+ text: str,
79
+ field_coords: RectangleCoordinates,
80
+ field_attributes: TextFieldAttributes,
81
+ ) -> None:
82
+ """
83
+ This method will try to fit the text within the field.
84
+
85
+ Args:
86
+ - text (`str`): Text to be drawn
87
+ - field_coords (`RectangleCoordinates`): _description_
88
+ - field_attributes (`FieldAttributes`): _description_
89
+ """
90
+
91
+ # If there is no text to draw, return immediately.
92
+ if (text is None) or (str(text).strip() == ""):
93
+ return
94
+
95
+ text_config = field_attributes.text_config
96
+
97
+ font_size = text_config.font_size
98
+ anchor = text_config.anchor
99
+ text_style = text_config.style
100
+
101
+ fsc = FontSizeCalculator(self.draw, text_config.font_filepath)
102
+
103
+ field_x1, field_y1, field_x2, field_y2 = field_coords.xyxy()
104
+ field_x, field_y, field_width, field_height = field_coords.xywh()
105
+
106
+ # If the text is multiline or is allowed to be broken into multiple lines, then
107
+ # we try to break it into multiple lines so that it can fit into the field
108
+ if ("\n" in text) or text_config.break_text:
109
+ font_size, text_lines_list, text_height = self.break_text(
110
+ text=text,
111
+ text_config=text_config,
112
+ font_size=font_size,
113
+ fsc=fsc,
114
+ field_width=field_width,
115
+ field_height=field_height,
116
+ )
117
+ # Else, we just try to fit a single line of text in the field
118
+ else:
119
+ while True:
120
+ text_width, text_height = fsc.get_text_bbox(font_size, text)
121
+ # If `text_width` is greater than `field_width` or if `text_height` is
122
+ # greater than `field_height`, then the `font_size` will be decremented
123
+ # by 1, and the loop will continue until the condition is no longer
124
+ # satisfied.
125
+ if (text_width > field_width) or (text_height > field_height):
126
+ font_size -= 1
127
+ else:
128
+ break
129
+
130
+ # If the text needs to be inverted (ie. turned upside down), then, it needs to
131
+ # undergo the following steps:
132
+ if text_config.inverted:
133
+ # I dont know why this code is here, so I just commented it out
134
+ # # Get the half of the text height, then round that
135
+ # halved_text_height = round(text_height / 2) # `hth` in short
136
+ # # Subtract `hth` to the `text_height` to get the larger half of the text
137
+ # # height. This is for pixel accuracy
138
+ # larger_hth = text_height - halved_text_height
139
+
140
+ # # field_height += text_height
141
+
142
+ # Create a new image with the same width and height as the field to draw the
143
+ # soon-to-be inverted text on
144
+ inverted_text_image = Image.new(
145
+ "RGBA",
146
+ (field_width, field_height),
147
+ color=(0, 0, 0, 0),
148
+ )
149
+ draw = ImageDraw.Draw(inverted_text_image)
150
+
151
+ horizontal_anchor, vertical_anchor = anchor # type: ignore
152
+
153
+ # Reverse horizontal anchor
154
+ match horizontal_anchor:
155
+ case "l":
156
+ horizontal_anchor = "r"
157
+ case "r":
158
+ horizontal_anchor = "l"
159
+
160
+ # I DO NOT FUCKING UNDERSTAND WHY I NEED TO DO THIS
161
+ # edit: I don't care anymore
162
+ if not text_config.break_text:
163
+ match vertical_anchor:
164
+ case "t":
165
+ vertical_anchor = "b"
166
+ case "b":
167
+ vertical_anchor = "t"
168
+
169
+ anchor = horizontal_anchor + vertical_anchor
170
+ else:
171
+ draw = self.draw
172
+
173
+ draw_text_common_kwargs: dict[str, Any] = {
174
+ "font": font_loader(
175
+ text_config.font_filepath,
176
+ font_size,
177
+ ),
178
+ "anchor": anchor,
179
+ "fill": text_style.fill,
180
+ }
181
+
182
+ # If the text is multiline or is allowed to be broken into multiple lines, then
183
+ # we do the following:
184
+ if ("\n" in text) or text_config.break_text:
185
+ # We just approximate where to place the lines of text vertically.
186
+ # That's why there are overlaps in glyphs when the line height is set at 1.
187
+ # That's also why line height's default is set at `1.2`.
188
+ # If we don't approximate this, then we can have non-overlapping glyphs,
189
+ # but I have a deadline to meet, yknow?
190
+
191
+ # text_height over length of text_lines_list
192
+ tholtlt = text_height / len(text_lines_list)
193
+
194
+ # there is a `vertical_additive` to place the multiline text exactly where
195
+ # it is aligned to
196
+ match anchor[1]:
197
+ case "t":
198
+ vertical_additive = (
199
+ field_height - text_height if text_config.inverted else field_y
200
+ )
201
+ case "m":
202
+ vertical_additive = round(
203
+ (field_height - text_height) / 2
204
+ if text_config.inverted
205
+ else field_y + ((field_height - text_height) / 2),
206
+ )
207
+ case "b":
208
+ vertical_additive = (
209
+ 0
210
+ if text_config.inverted
211
+ else field_y + field_height - text_height
212
+ )
213
+
214
+ text_x = field_coords.text_coordinates(anchor=anchor)[0] # type: ignore
215
+
216
+ if text_config.inverted:
217
+ text_x =- field_x
218
+
219
+ draw_text_common_kwargs["anchor"] = anchor[0] + "m"
220
+
221
+ for text_line, text_y in zip(
222
+ text_lines_list,
223
+ range(round(tholtlt / 2), text_height, round(tholtlt)),
224
+ strict=True,
225
+ ):
226
+ text_xy = (text_x, vertical_additive + text_y)
227
+ draw.text(
228
+ text=text_line,
229
+ xy=text_xy,
230
+ **draw_text_common_kwargs,
231
+ )
232
+
233
+ # # WARNING: remove in production
234
+ # text_width, text_height = fsc.get_text_bbox(font_size, text_line)
235
+ # rect_coordinates = (
236
+ # text_xy[0] - (text_width / 2),
237
+ # text_xy[1] - (text_height / 2),
238
+ # text_xy[0] + (text_width / 2),
239
+ # text_xy[1] + (text_height / 2),
240
+ # )
241
+ # self.draw.rectangle(
242
+ # rect_coordinates,
243
+ # outline="white",
244
+ # width=1,
245
+ # )
246
+
247
+ # # WARNING: remove in production
248
+ # self.draw.rectangle(
249
+ # field_coords.xyxy(),
250
+ # outline="red",
251
+ # width=1,
252
+ # )
253
+ else:
254
+ draw_field_coords: RectangleCoordinates
255
+ if text_config.inverted:
256
+ draw_field_coords = XYXY(0, 0, field_x2 - field_x1, field_y2 - field_y1)
257
+ else:
258
+ draw_field_coords = field_coords
259
+
260
+ draw.text(
261
+ text=text,
262
+ xy=draw_field_coords.text_coordinates(anchor=anchor), # type: ignore
263
+ **draw_text_common_kwargs,
264
+ )
265
+
266
+ if text_config.inverted:
267
+ inverted_text_image = inverted_text_image.rotate(180)
268
+ self.image.paste(
269
+ inverted_text_image,
270
+ (field_x1, field_y1, field_x2, field_y2),
271
+ inverted_text_image,
272
+ )
273
+
274
+ def break_text(
275
+ self,
276
+ text: str,
277
+ text_config: TextConfig,
278
+ font_size: int,
279
+ fsc: FontSizeCalculator,
280
+ field_width: int,
281
+ field_height: int,
282
+ ) -> tuple[int, list[str], int]:
283
+ while True:
284
+ # Initialize variables
285
+ text_width = 0
286
+ text_line_width_list = []
287
+ text_line_height_list = []
288
+
289
+ text_lines_list = text.splitlines() # tlt
290
+ # Count the number of characters in the longest line of the multiline text
291
+ max_char_length_in_tlt = max(len(i) for i in text_lines_list)
292
+
293
+ # We can deduce the number of characters that fit in the field width
294
+ # by multiplying the field width by `max_char_length_in_tlt`, all over the
295
+ # width of the text is when drawn on the image.
296
+ # This uses the fact that `(a/(b/c)) = ((a * c)/b)` to simplify the equation
297
+ characters_per_field_width = round(
298
+ (field_width * max_char_length_in_tlt)
299
+ / fsc.get_text_bbox(font_size, text)[0],
300
+ )
301
+
302
+ # Rewrite the `text_lines_list` to have the longest line of text be
303
+ # the value of `characters_per_field_width`
304
+ text_lines_list = [
305
+ j for i in text_lines_list for j in wrap(i, characters_per_field_width)
306
+ ]
307
+ length_tlt = len(text_lines_list)
308
+
309
+ # Measure the width and height of each line of text, then appened it
310
+ # to the intialized lists earlier
311
+ for text_line in text_lines_list:
312
+ text_line_width, text_line_height = fsc.get_text_bbox(
313
+ font_size,
314
+ text_line,
315
+ )
316
+ text_line_width_list.append(text_line_width)
317
+ text_line_height_list.append(text_line_height)
318
+
319
+ # set `text_width` to be the width of the widest line of text
320
+ text_width = max(text_line_width_list)
321
+
322
+ text_height = round(
323
+ length_tlt
324
+ * text_config.line_height
325
+ * (sum(text_line_height_list) / length_tlt),
326
+ )
327
+
328
+ # If `text_width` is greater than `field_width`, or if `text_height` is
329
+ # greater than `field_height`, then the `font_size` will be decremented
330
+ # by 1, and the loop will continue until the condition is no longer
331
+ # satisfied.
332
+ if (text_width > field_width) or (text_height > field_height):
333
+ font_size -= 1
334
+ else:
335
+ return (font_size, text_lines_list, text_height)
336
+
337
+ def barcode(
338
+ self,
339
+ data: str,
340
+ type: Literal["Code128", "QR"],
341
+ field_coords: RectangleCoordinates,
342
+ field_attributes: BarcodeFieldAttributes,
343
+ ) -> None:
344
+ # If there is no text to draw, return immediately.
345
+ if (data is None) or (str(data).strip() == ""):
346
+ return
347
+
348
+ barcode_config = field_attributes.barcode_config
349
+
350
+ barcode = getattr(Barcode, type.lower())(data, barcode_config, field_coords)
351
+
352
+ barcode_coords = scale_and_center_rect(field_coords, barcode.size)
353
+ barcode_wh = barcode_coords.xywh()[2:4]
354
+
355
+ self.image.paste(
356
+ barcode.resize(barcode_wh, Image.Resampling.LANCZOS),
357
+ barcode_coords.xyxy(),
358
+ )
@@ -0,0 +1,77 @@
1
+ # import multiprocessing.dummy as mp
2
+ # import os
3
+ # from typing import Any
4
+
5
+
6
+ # from imagesmacker.draw import Draw
7
+ from imagesmacker.models.coordinates import XYXY, RectangleCoordinates
8
+ from imagesmacker.models.fields import (
9
+ FieldsCoords,
10
+ RelativeDataFieldFormat,
11
+ )
12
+
13
+
14
+ def relative_field_formatting(
15
+ data_field_format: RelativeDataFieldFormat,
16
+ dimensions: RectangleCoordinates,
17
+ ) -> FieldsCoords:
18
+ """
19
+ _summary_.
20
+
21
+ Args:
22
+ data_field_format (RelativeDataFieldFormat): _description_
23
+ dimensions (Coordinates): Coordinate mode agnostic dimensions.
24
+
25
+ Returns:
26
+ dict[str, tuple[float, float, float, float]]: _description_
27
+ """
28
+
29
+ initial_field_x, field_y, field_width, field_height = dimensions.xywh()
30
+
31
+ total_field_fractional_height: float = 0
32
+ ls_cell_fractional_widths: list[float] = []
33
+
34
+ for row in data_field_format.rows:
35
+ total_row_fractional_width: float = 0
36
+
37
+ row_fractional_height = row.fr
38
+ row_cells = row.cells
39
+
40
+ total_field_fractional_height += row_fractional_height
41
+
42
+ for cell in row_cells:
43
+ total_row_fractional_width += cell.fr
44
+
45
+ ls_cell_fractional_widths.append(total_row_fractional_width)
46
+
47
+ output: dict[str, XYXY] = {}
48
+
49
+ for row, total_row_fractional_width in zip(
50
+ data_field_format.rows,
51
+ ls_cell_fractional_widths,
52
+ strict=True,
53
+ ):
54
+ row_fractional_height = row.fr
55
+ row_cells = row.cells
56
+
57
+ row_height = round(
58
+ field_height * (row_fractional_height / total_field_fractional_height),
59
+ )
60
+
61
+ field_x = initial_field_x
62
+
63
+ for cell in row_cells:
64
+ cell_fractional_width = cell.fr
65
+ cell_width = round(
66
+ field_width * (cell_fractional_width / total_row_fractional_width),
67
+ )
68
+ output[cell.name] = XYXY(
69
+ field_x,
70
+ field_y,
71
+ field_x + cell_width,
72
+ field_y + row_height,
73
+ )
74
+ field_x += cell_width
75
+ field_y += row_height
76
+
77
+ return output
@@ -0,0 +1,49 @@
1
+ import os
2
+
3
+ from alltheutils.utils import file_exists
4
+ from PIL import ImageDraw, ImageFont
5
+ from PIL.ImageFont import FreeTypeFont
6
+
7
+
8
+ def font_loader(
9
+ filepath: str,
10
+ size: int = 10,
11
+ ) -> FreeTypeFont:
12
+ filepath = os.path.abspath(filepath)
13
+ return ImageFont.truetype(file_exists(filepath), size)
14
+
15
+
16
+ class FontSizeCalculator:
17
+ def __init__(
18
+ self,
19
+ draw: ImageDraw.ImageDraw,
20
+ font: str,
21
+ ) -> None:
22
+ """
23
+ Initialize the FontSizeCalculator class.
24
+
25
+ Args:
26
+ - draw (`ImageDraw.ImageDraw`): The ImageDraw object to use for text measurement.
27
+ - font (`str`): The font name or path to use.
28
+ - kwargs (`dict[str, Any]`): Additional keyword arguments.
29
+ """
30
+ self.draw = draw
31
+ self.font = font
32
+
33
+ def get_text_bbox(self, size: int, text: str) -> tuple[int, int]:
34
+ """
35
+ Get the width and height of the text for a given font size.
36
+
37
+ Args:
38
+ - size (`int`): The font size to measure.
39
+ - text (`str`): The text string to measure.
40
+
41
+ Returns:
42
+ `list[int]`: A list containing the width and height of the text.
43
+ """
44
+ x1, y1, x2, y2 = self.draw.multiline_textbbox(
45
+ xy=(0, 0),
46
+ text=text,
47
+ font=font_loader(self.font, size),
48
+ )
49
+ return (x2 - x1, y2 - y1)
File without changes
File without changes
@@ -0,0 +1,130 @@
1
+ import abc
2
+ from collections.abc import Iterator
3
+ from typing import NamedTuple, cast
4
+
5
+ from imagesmacker.models.draw import TextAnchor, validate_text_anchor
6
+
7
+
8
+ class XYXYNamedTuple(NamedTuple):
9
+ x1: int
10
+ y1: int
11
+ x2: int
12
+ y2: int
13
+
14
+
15
+ class XYWHNamedTuple(NamedTuple):
16
+ x: int
17
+ y: int
18
+ w: int
19
+ h: int
20
+
21
+
22
+ class RectangleCoordinates(metaclass=abc.ABCMeta):
23
+ """
24
+ Rectangle Coordinates.
25
+
26
+ You can have an `xyxy` or `xywh` mode coordinates and it can be read by any
27
+ methods that knows how to read `RectangleCoordinates`.
28
+
29
+ Args:
30
+ metaclass (_type_, optional): _description_. Defaults to abc.ABCMeta.
31
+ """
32
+
33
+ coords: NamedTuple
34
+
35
+ @abc.abstractmethod
36
+ def xyxy(self) -> XYXYNamedTuple:
37
+ pass
38
+
39
+ @abc.abstractmethod
40
+ def xywh(self) -> XYWHNamedTuple:
41
+ pass
42
+
43
+ def text_coordinates(self, anchor: TextAnchor = "mm") -> tuple[int, int]:
44
+ """
45
+ `RectangleCoordinates` is often used as.
46
+
47
+ Args:
48
+ - anchor (`TextAnchor`, optional): _description_. Defaults to `"mm"`.
49
+
50
+ Returns:
51
+ `tuple[int, int]`: _description_
52
+ """
53
+ validate_text_anchor(anchor)
54
+ x1, y1, x2, y2 = self.xyxy()
55
+ horizontal_anchor, vertical_anchor = anchor # type: ignore
56
+
57
+ match horizontal_anchor:
58
+ case "l":
59
+ x = x1
60
+ case "m":
61
+ x = round((x1 + x2) / 2)
62
+ case "r":
63
+ x = x2
64
+
65
+ match vertical_anchor:
66
+ case "t":
67
+ y = y1
68
+ case "m":
69
+ y = round((y1 + y2) / 2)
70
+ case "b":
71
+ y = y2
72
+
73
+ return (x, y)
74
+
75
+ def __iter__(self) -> Iterator[int]:
76
+ return iter(self.coords)
77
+
78
+ def __str__(self) -> str:
79
+ return str(self.tuple())
80
+
81
+ def __repr__(self) -> str:
82
+ return repr(self.coords)
83
+
84
+ def list(self) -> list[int]:
85
+ return list(self.coords)
86
+
87
+ def tuple(self) -> tuple[int, int, int, int]:
88
+ return cast(tuple[int, int, int, int], tuple(self.coords))
89
+
90
+ def dict(self) -> dict[str, int]:
91
+ return self.coords._asdict()
92
+
93
+
94
+ class XYXY(RectangleCoordinates):
95
+ def __init__(self, x1: int, y1: int, x2: int, y2: int) -> None:
96
+ self.coords = XYXYNamedTuple(x1=x1, y1=y1, x2=x2, y2=y2)
97
+
98
+ def xyxy(self) -> XYXYNamedTuple:
99
+ return cast(XYXYNamedTuple, self.coords)
100
+
101
+ def xywh(self) -> XYWHNamedTuple:
102
+ """
103
+ Convert `XYXYNamedTuple` to `XYWHNamedTuple`.
104
+
105
+ Returns:
106
+ `XYWHNamedTuple`
107
+ """
108
+
109
+ x1, y1, x2, y2 = self.coords
110
+ return XYWHNamedTuple(x=x1, y=y1, w=x2 - x1, h=y2 - y1)
111
+
112
+
113
+ class XYWH(RectangleCoordinates):
114
+ def __init__(self, x: int, y: int, w: int, h: int) -> None:
115
+ self.coords = XYWHNamedTuple(x=x, y=y, w=w, h=h)
116
+
117
+ def xyxy(self) -> XYXYNamedTuple:
118
+ """
119
+ Convert `XYWHNamedTuple` to `XYXYNamedTuple`.
120
+
121
+ Returns:
122
+ `XYXYNamedTuple`
123
+ """
124
+
125
+ x, y, w, h = self.coords
126
+
127
+ return XYXYNamedTuple(x1=x, y1=y, x2=x + w, y2=y + h)
128
+
129
+ def xywh(self) -> XYWHNamedTuple:
130
+ return cast(XYWHNamedTuple, self.coords)
@@ -0,0 +1,55 @@
1
+ from typing import Any, Literal, Optional, TypeAlias
2
+
3
+ from PIL.ImageDraw import _Ink
4
+ from pydantic import BaseModel, ConfigDict
5
+
6
+ TextAnchor: TypeAlias = Literal["lt", "mt", "rt", "lm", "mm", "rm", "lb", "mb", "rb"]
7
+
8
+
9
+ class TextStyle(BaseModel):
10
+ model_config = ConfigDict(extra="forbid")
11
+ fill: _Ink = "#000"
12
+ italics: bool = False # not in use
13
+ underline: bool = False # not in use
14
+
15
+
16
+ class TextConfig(BaseModel):
17
+ model_config = ConfigDict(extra="forbid")
18
+ font_filepath: str
19
+ font_size: int = 100
20
+ anchor: TextAnchor = "mm"
21
+ break_text: bool = False
22
+ line_height: float | int = 1.2
23
+ inverted: bool = False
24
+ style: TextStyle = TextStyle()
25
+
26
+
27
+ class BarcodeConfig(BaseModel):
28
+ pass
29
+
30
+
31
+ class Code128Config(BarcodeConfig):
32
+ model_config = ConfigDict(extra="forbid")
33
+ options: Optional[dict[str, Any]] = None
34
+
35
+
36
+ class QRCodeConfig(BarcodeConfig):
37
+ model_config = ConfigDict(extra="forbid")
38
+ box_size: int = 20
39
+ border: int = 1
40
+
41
+
42
+ def validate_text_anchor(anchor: str):
43
+ if len(anchor) != 2:
44
+ raise ValueError("The string must be exactly 2 characters long.")
45
+
46
+ anchor_tuple: tuple[str, str] = tuple(anchor) # type: ignore
47
+ horizontal_anchor, vertical_anchor = anchor_tuple # type: ignore[misc]
48
+ if horizontal_anchor not in {"l", "m", "r"}:
49
+ raise ValueError(
50
+ "The horizontal anchor must be one of the following: 'l', 'm', or 'r'.",
51
+ )
52
+ if vertical_anchor not in {"t", "m", "b"}:
53
+ raise ValueError(
54
+ "The vertical anchor must be one of the following: 't', 'm', or 'b'.",
55
+ )
@@ -0,0 +1,43 @@
1
+ from typing import TypeAlias
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from imagesmacker.models.coordinates import XYXY
6
+ from imagesmacker.models.draw import BarcodeConfig, TextConfig
7
+
8
+
9
+ class RelativeFieldCell(BaseModel):
10
+ model_config = ConfigDict(extra="forbid")
11
+ fr: float
12
+ name: str
13
+
14
+
15
+ class RelativeRow(BaseModel):
16
+ model_config = ConfigDict(extra="forbid")
17
+ fr: float
18
+ cells: list[RelativeFieldCell]
19
+
20
+
21
+ class RelativeDataFieldFormat(BaseModel):
22
+ model_config = ConfigDict(extra="forbid")
23
+ rows: list[RelativeRow]
24
+
25
+
26
+ class FieldAttributes(BaseModel):
27
+ pass
28
+
29
+
30
+ class TextFieldAttributes(FieldAttributes):
31
+ model_config = ConfigDict(extra="forbid")
32
+ text_config: TextConfig
33
+
34
+
35
+ class BarcodeFieldAttributes(FieldAttributes):
36
+ model_config = ConfigDict(extra="forbid")
37
+ barcode_config: BarcodeConfig
38
+
39
+
40
+ FieldsCoords: TypeAlias = dict[str, XYXY]
41
+ FieldsConfig: TypeAlias = (
42
+ dict[str, TextFieldAttributes] | dict[str, BarcodeFieldAttributes]
43
+ )
@@ -0,0 +1,44 @@
1
+ from imagesmacker.models.coordinates import XYXY, RectangleCoordinates
2
+
3
+
4
+ def rect_coords_middle_point(
5
+ coordinates: RectangleCoordinates,
6
+ ) -> tuple[int, float]:
7
+ x1, y1, x2, y2 = coordinates.xyxy()
8
+ return (x2 - x1, y2 - y1)
9
+
10
+
11
+ def scale_and_center_rect(
12
+ br_coords: RectangleCoordinates,
13
+ sr_wh: tuple[int, int],
14
+ ) -> XYXY:
15
+ """
16
+ Given a big rectangle's xyxy and small rectangle's width and height, fit and center the small rectangle in the big rectangle, retaining the small rectangle's aspect ratio. Then, return the xyxy for the small rectangle to make that happen.
17
+
18
+ Args:
19
+ - br_xyxy (tuple[int, int, int, int]): Big rectangle's xyxy.
20
+ - sr_wh (tuple[int, int]): Small rectangle's width and height.
21
+
22
+ Returns:
23
+ XYXY: Resulting xyxy.
24
+ """
25
+ br_x1, br_y1, br_x2, br_y2 = br_coords.xyxy()
26
+ br_w = br_x2 - br_x1
27
+ br_h = br_y2 - br_y1
28
+
29
+ sr_w, sr_h = sr_wh
30
+
31
+ scale_w = br_w / sr_w
32
+ scale_h = br_h / sr_h
33
+
34
+ scale = min(scale_w, scale_h)
35
+
36
+ scaled_sr_w = int(sr_w * scale)
37
+ scaled_sr_h = int(sr_h * scale)
38
+
39
+ new_x1 = br_x1 + (br_w - scaled_sr_w) // 2
40
+ new_y1 = br_y1 + (br_h - scaled_sr_h) // 2
41
+ new_x2 = new_x1 + scaled_sr_w
42
+ new_y2 = new_y1 + scaled_sr_h
43
+
44
+ return XYXY(new_x1, new_y1, new_x2, new_y2)
@@ -0,0 +1,169 @@
1
+ [tool.poetry]
2
+ name = "imagesmacker"
3
+ version = "2.1.5"
4
+ description = ""
5
+ authors = ["whinee"]
6
+
7
+ packages = [
8
+ { include = "imagesmacker" }
9
+ ]
10
+
11
+ [tool.poetry.dependencies]
12
+ python = ">=3.10,<3.13"
13
+ rich = "^13.3.5"
14
+ msgpack = "^1.0.5"
15
+ openpyxl = "^3.1.2"
16
+ pyyaml = "^6.0"
17
+ regex = "^2023.8.8"
18
+ pillow = "^10.0.1"
19
+ qrcode = {extras = ["pil"], version = "^7.4.2"}
20
+ pydantic = "^2.8.2"
21
+ python-barcode = "^0.15.1"
22
+ alltheutils = "^1.0.1"
23
+
24
+ [tool.poetry.group.dev]
25
+ optional = true
26
+
27
+ [tool.poetry.group.dev.dependencies]
28
+ black = "^23.3.0"
29
+ mypy = "^1.1.1"
30
+ no-implicit-optional = "^1.3"
31
+ ruff = "^0.0.291"
32
+
33
+ [build-system]
34
+ requires = ["poetry-core>=1.0.0"]
35
+ build-backend = "poetry.core.masonry.api"
36
+
37
+ [tool.mypy]
38
+ disable_error_code = [
39
+ "import",
40
+ "annotation-unchecked",
41
+ "attr-defined",
42
+ "no-untyped-call",
43
+ "no-untyped-def",
44
+ "type-arg",
45
+ "unused-ignore",
46
+ ]
47
+ strict = true
48
+ exclude = "dev"
49
+
50
+ [tool.ruff.per-file-ignores]
51
+ "examples/*" = [
52
+ "INP001",
53
+ "N999"
54
+ ]
55
+
56
+ [tool.ruff]
57
+ select = [
58
+ "ANN",
59
+ "B",
60
+ "BLE",
61
+ "C4",
62
+ "C90",
63
+ "COM",
64
+ "D",
65
+ # "DTZ",
66
+ "E",
67
+ "F",
68
+ "I",
69
+ "INP",
70
+ "N",
71
+ "PIE",
72
+ "Q",
73
+ "RET",
74
+ "RSE",
75
+ "RUF",
76
+ "S",
77
+ "UP"
78
+ ]
79
+
80
+ # Allow autofix for all enabled rules (when `--fix`) is provided.
81
+ fixable = [
82
+ "ANN",
83
+ "B",
84
+ "C4",
85
+ "COM",
86
+ "D",
87
+ "E",
88
+ "F",
89
+ "I",
90
+ "PIE",
91
+ "Q",
92
+ "RET",
93
+ "RSE",
94
+ "RUF",
95
+ "UP"
96
+ ]
97
+ unfixable = []
98
+
99
+ ignore = [
100
+ "ANN001",
101
+ "ANN002",
102
+ "ANN003",
103
+ "ANN101",
104
+ "ANN201",
105
+ "ANN202",
106
+ "ANN401",
107
+ "B008",
108
+ "D100",
109
+ "D101",
110
+ "D102",
111
+ "D103",
112
+ "D104",
113
+ "D105",
114
+ "D106",
115
+ "D107",
116
+ "D202",
117
+ "D203",
118
+ "D205",
119
+ "D212",
120
+ "D401",
121
+ "D404",
122
+ "D406",
123
+ "D407",
124
+ "D417",
125
+ "E501",
126
+ "F722",
127
+ "I002",
128
+ "N812",
129
+ "N818",
130
+ "Q000",
131
+ "RET503",
132
+ "S101",
133
+ "UP007",
134
+ "UP032",
135
+ ]
136
+
137
+ exclude = [
138
+ ".bzr",
139
+ ".direnv",
140
+ ".eggs",
141
+ ".git",
142
+ ".hg",
143
+ ".mypy_cache",
144
+ ".nox",
145
+ ".pants.d",
146
+ ".pytype",
147
+ ".ruff_cache",
148
+ ".svn",
149
+ ".tox",
150
+ ".venv",
151
+ "__pypackages__",
152
+ "_build",
153
+ "buck-out",
154
+ "build",
155
+ "dist",
156
+ "node_modules",
157
+ "pyenv",
158
+ "venv",
159
+ ]
160
+
161
+ line-length = 88
162
+
163
+ target-version = "py310"
164
+
165
+ [tool.ruff.mccabe]
166
+ max-complexity = 5
167
+
168
+ [tool.ruff.pycodestyle]
169
+ ignore-overlong-task-comments = false