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.
- imagesmacker-2.1.5/PKG-INFO +20 -0
- imagesmacker-2.1.5/imagesmacker/__init__.py +0 -0
- imagesmacker-2.1.5/imagesmacker/draw.py +358 -0
- imagesmacker-2.1.5/imagesmacker/fields.py +77 -0
- imagesmacker-2.1.5/imagesmacker/fonts.py +49 -0
- imagesmacker-2.1.5/imagesmacker/image.py +0 -0
- imagesmacker-2.1.5/imagesmacker/models/__init__.py +0 -0
- imagesmacker-2.1.5/imagesmacker/models/coordinates.py +130 -0
- imagesmacker-2.1.5/imagesmacker/models/draw.py +55 -0
- imagesmacker-2.1.5/imagesmacker/models/fields.py +43 -0
- imagesmacker-2.1.5/imagesmacker/utils.py +44 -0
- imagesmacker-2.1.5/pyproject.toml +169 -0
|
@@ -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
|