videopython 0.3.0__py3-none-any.whl → 0.4.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.
Potentially problematic release.
This version of videopython might be problematic. Click here for more details.
- videopython/ai/__init__.py +0 -0
- videopython/{generation → ai/generation}/image.py +0 -3
- videopython/ai/understanding/__init__.py +0 -0
- videopython/ai/understanding/transcribe.py +37 -0
- videopython/base/combine.py +45 -0
- videopython/base/effects.py +3 -3
- videopython/base/transcription.py +13 -0
- videopython/base/transforms.py +0 -2
- videopython/base/video.py +298 -158
- videopython/utils/__init__.py +3 -0
- videopython/utils/image.py +0 -232
- videopython/utils/text.py +727 -0
- {videopython-0.3.0.dist-info → videopython-0.4.1.dist-info}/METADATA +26 -13
- videopython-0.4.1.dist-info/RECORD +26 -0
- videopython-0.3.0.dist-info/RECORD +0 -20
- /videopython/{generation → ai/generation}/__init__.py +0 -0
- /videopython/{generation → ai/generation}/audio.py +0 -0
- /videopython/{generation → ai/generation}/video.py +0 -0
- {videopython-0.3.0.dist-info → videopython-0.4.1.dist-info}/WHEEL +0 -0
- {videopython-0.3.0.dist-info → videopython-0.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import TypeAlias, Union
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
+
|
|
7
|
+
from videopython.base.exceptions import OutOfBoundsError
|
|
8
|
+
|
|
9
|
+
# Type aliases for clarity
|
|
10
|
+
MarginType: TypeAlias = Union[int, tuple[int, int, int, int]]
|
|
11
|
+
RGBColor: TypeAlias = tuple[int, int, int]
|
|
12
|
+
RGBAColor: TypeAlias = tuple[int, int, int, int]
|
|
13
|
+
PositionType: TypeAlias = Union[tuple[int, int], tuple[float, float]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Text alignment enum
|
|
17
|
+
class TextAlign(str, Enum):
|
|
18
|
+
"""Defines text alignment options for positioning within containers."""
|
|
19
|
+
|
|
20
|
+
LEFT = "left"
|
|
21
|
+
RIGHT = "right"
|
|
22
|
+
CENTER = "center"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AnchorPoint(str, Enum):
|
|
26
|
+
"""Defines anchor points for positioning text elements."""
|
|
27
|
+
|
|
28
|
+
TOP_LEFT = "top-left"
|
|
29
|
+
TOP_CENTER = "top-center"
|
|
30
|
+
TOP_RIGHT = "top-right"
|
|
31
|
+
CENTER_LEFT = "center-left"
|
|
32
|
+
CENTER = "center"
|
|
33
|
+
CENTER_RIGHT = "center-right"
|
|
34
|
+
BOTTOM_LEFT = "bottom-left"
|
|
35
|
+
BOTTOM_CENTER = "bottom-center"
|
|
36
|
+
BOTTOM_RIGHT = "bottom-right"
|
|
37
|
+
|
|
38
|
+
# Group anchor points by their horizontal position
|
|
39
|
+
@classmethod
|
|
40
|
+
def left_anchors(cls) -> tuple["AnchorPoint", ...]:
|
|
41
|
+
return (cls.TOP_LEFT, cls.CENTER_LEFT, cls.BOTTOM_LEFT)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def center_anchors(cls) -> tuple["AnchorPoint", ...]:
|
|
45
|
+
return (cls.TOP_CENTER, cls.CENTER, cls.BOTTOM_CENTER)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def right_anchors(cls) -> tuple["AnchorPoint", ...]:
|
|
49
|
+
return (cls.TOP_RIGHT, cls.CENTER_RIGHT, cls.BOTTOM_RIGHT)
|
|
50
|
+
|
|
51
|
+
# Group anchor points by their vertical position
|
|
52
|
+
@classmethod
|
|
53
|
+
def top_anchors(cls) -> tuple["AnchorPoint", ...]:
|
|
54
|
+
return (cls.TOP_LEFT, cls.TOP_CENTER, cls.TOP_RIGHT)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def middle_anchors(cls) -> tuple["AnchorPoint", ...]:
|
|
58
|
+
return (cls.CENTER_LEFT, cls.CENTER, cls.CENTER_RIGHT)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def bottom_anchors(cls) -> tuple["AnchorPoint", ...]:
|
|
62
|
+
return (cls.BOTTOM_LEFT, cls.BOTTOM_CENTER, cls.BOTTOM_RIGHT)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ImageText:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
image_size: tuple[int, int] = (1080, 1920), # (width, height)
|
|
69
|
+
mode: str = "RGBA",
|
|
70
|
+
background: RGBAColor = (0, 0, 0, 0), # Transparent background
|
|
71
|
+
):
|
|
72
|
+
"""
|
|
73
|
+
Initialize an image for text rendering.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
image_size: Dimensions of the image (width, height)
|
|
77
|
+
mode: Image mode (RGB, RGBA, etc.)
|
|
78
|
+
background: Background color with alpha channel
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If image_size dimensions are not positive
|
|
82
|
+
"""
|
|
83
|
+
if image_size[0] <= 0 or image_size[1] <= 0:
|
|
84
|
+
raise ValueError("Image dimensions must be positive")
|
|
85
|
+
|
|
86
|
+
if len(background) != 4:
|
|
87
|
+
raise ValueError("Background color must be RGBA (4 values)")
|
|
88
|
+
|
|
89
|
+
self.image_size = image_size
|
|
90
|
+
self.image = Image.new(mode, image_size, color=background)
|
|
91
|
+
self._draw = ImageDraw.Draw(self.image)
|
|
92
|
+
self._font_cache: dict[tuple[str, int], ImageFont.FreeTypeFont] = {} # Cache for font objects
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def img_array(self) -> np.ndarray:
|
|
96
|
+
"""Convert the PIL Image to a numpy array."""
|
|
97
|
+
return np.array(self.image)
|
|
98
|
+
|
|
99
|
+
def save(self, filename: str) -> None:
|
|
100
|
+
"""Save the image to a file."""
|
|
101
|
+
if not filename:
|
|
102
|
+
raise ValueError("Filename cannot be empty")
|
|
103
|
+
self.image.save(filename)
|
|
104
|
+
|
|
105
|
+
def _fit_font_width(self, text: str, font: str, max_width: int) -> int:
|
|
106
|
+
"""
|
|
107
|
+
Find the maximum font size where the text width is less than or equal to max_width.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
text: The text to measure
|
|
111
|
+
font: Path to the font file
|
|
112
|
+
max_width: Maximum allowed width in pixels
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The maximum font size that fits within max_width
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If text is empty or max_width is too small for any font size
|
|
119
|
+
"""
|
|
120
|
+
if not text:
|
|
121
|
+
return 1 # Default to minimum size for empty text
|
|
122
|
+
|
|
123
|
+
if max_width <= 0:
|
|
124
|
+
raise ValueError("Maximum width must be positive")
|
|
125
|
+
|
|
126
|
+
font_size = 1
|
|
127
|
+
text_width = self.get_text_dimensions(font, font_size, text)[0]
|
|
128
|
+
while text_width < max_width:
|
|
129
|
+
font_size += 1
|
|
130
|
+
text_width = self.get_text_dimensions(font, font_size, text)[0]
|
|
131
|
+
max_font_size = font_size - 1
|
|
132
|
+
if max_font_size < 1:
|
|
133
|
+
raise ValueError(f"Max width {max_width} is too small for any font size!")
|
|
134
|
+
return max_font_size
|
|
135
|
+
|
|
136
|
+
def _fit_font_height(self, text: str, font: str, max_height: int) -> int:
|
|
137
|
+
"""
|
|
138
|
+
Find the maximum font size where the text height is less than or equal to max_height.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
text: The text to measure
|
|
142
|
+
font: Path to the font file
|
|
143
|
+
max_height: Maximum allowed height in pixels
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The maximum font size that fits within max_height
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If text is empty or max_height is too small for any font size
|
|
150
|
+
"""
|
|
151
|
+
if not text:
|
|
152
|
+
return 1 # Default to minimum size for empty text
|
|
153
|
+
|
|
154
|
+
if max_height <= 0:
|
|
155
|
+
raise ValueError("Maximum height must be positive")
|
|
156
|
+
|
|
157
|
+
font_size = 1
|
|
158
|
+
text_height = self.get_text_dimensions(font, font_size, text)[1]
|
|
159
|
+
while text_height < max_height:
|
|
160
|
+
font_size += 1
|
|
161
|
+
text_height = self.get_text_dimensions(font, font_size, text)[1]
|
|
162
|
+
max_font_size = font_size - 1
|
|
163
|
+
if max_font_size < 1:
|
|
164
|
+
raise ValueError(f"Max height {max_height} is too small for any font size!")
|
|
165
|
+
return max_font_size
|
|
166
|
+
|
|
167
|
+
def _get_font_size(
|
|
168
|
+
self,
|
|
169
|
+
text: str,
|
|
170
|
+
font: str,
|
|
171
|
+
max_width: int | None = None,
|
|
172
|
+
max_height: int | None = None,
|
|
173
|
+
) -> int:
|
|
174
|
+
"""
|
|
175
|
+
Get maximum font size for text to fit within given dimensions.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
text: The text to fit
|
|
179
|
+
font: Path to the font file
|
|
180
|
+
max_width: Maximum allowed width in pixels
|
|
181
|
+
max_height: Maximum allowed height in pixels
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The maximum font size that fits within constraints
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
ValueError: If neither max_width nor max_height is provided, or text is empty
|
|
188
|
+
"""
|
|
189
|
+
if not text:
|
|
190
|
+
raise ValueError("Text cannot be empty")
|
|
191
|
+
|
|
192
|
+
if max_width is None and max_height is None:
|
|
193
|
+
raise ValueError("You need to pass max_width or max_height")
|
|
194
|
+
|
|
195
|
+
if max_width is not None and max_width <= 0:
|
|
196
|
+
raise ValueError("Maximum width must be positive")
|
|
197
|
+
|
|
198
|
+
if max_height is not None and max_height <= 0:
|
|
199
|
+
raise ValueError("Maximum height must be positive")
|
|
200
|
+
|
|
201
|
+
width_font_size = self._fit_font_width(text, font, max_width) if max_width is not None else None
|
|
202
|
+
height_font_size = self._fit_font_height(text, font, max_height) if max_height is not None else None
|
|
203
|
+
|
|
204
|
+
sizes = [size for size in [width_font_size, height_font_size] if size is not None]
|
|
205
|
+
if not sizes:
|
|
206
|
+
raise ValueError("No valid font size could be calculated")
|
|
207
|
+
|
|
208
|
+
return min(sizes)
|
|
209
|
+
|
|
210
|
+
def _process_margin(self, margin: MarginType) -> tuple[int, int, int, int]:
|
|
211
|
+
"""
|
|
212
|
+
Process the margin parameter into individual top, right, bottom, left values.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
margin: A single int for all sides, or a tuple of 4 values for each side
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Tuple of (top, right, bottom, left) margin values
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ValueError: If margin tuple doesn't have exactly 4 values
|
|
222
|
+
"""
|
|
223
|
+
if isinstance(margin, int):
|
|
224
|
+
if margin < 0:
|
|
225
|
+
raise ValueError("Margin cannot be negative")
|
|
226
|
+
return margin, margin, margin, margin
|
|
227
|
+
elif isinstance(margin, tuple) and len(margin) == 4:
|
|
228
|
+
if any(m < 0 for m in margin):
|
|
229
|
+
raise ValueError("Margin values cannot be negative")
|
|
230
|
+
return margin
|
|
231
|
+
else:
|
|
232
|
+
raise ValueError("Margin must be an int or a tuple of 4 ints")
|
|
233
|
+
|
|
234
|
+
def _convert_position(
|
|
235
|
+
self, position: PositionType, margin_top: int, margin_left: int, available_width: int, available_height: int
|
|
236
|
+
) -> tuple[float, float]:
|
|
237
|
+
"""
|
|
238
|
+
Convert a position from relative (0-1) to absolute pixels.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
position: Position as (x, y) coordinates, either as pixels or relative (0-1)
|
|
242
|
+
margin_top: Top margin in pixels
|
|
243
|
+
margin_left: Left margin in pixels
|
|
244
|
+
available_width: Available width considering margins
|
|
245
|
+
available_height: Available height considering margins
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Position in absolute pixel coordinates (might still be float)
|
|
249
|
+
"""
|
|
250
|
+
x_pos, y_pos = position
|
|
251
|
+
|
|
252
|
+
# Convert relative position (0-1) to absolute pixels
|
|
253
|
+
if isinstance(x_pos, float) and 0 <= x_pos <= 1:
|
|
254
|
+
x_pos = margin_left + x_pos * available_width
|
|
255
|
+
if isinstance(y_pos, float) and 0 <= y_pos <= 1:
|
|
256
|
+
y_pos = margin_top + y_pos * available_height
|
|
257
|
+
|
|
258
|
+
return x_pos, y_pos
|
|
259
|
+
|
|
260
|
+
def _calculate_position(
|
|
261
|
+
self,
|
|
262
|
+
text_size: tuple[int, int],
|
|
263
|
+
position: PositionType,
|
|
264
|
+
anchor: AnchorPoint = AnchorPoint.TOP_LEFT,
|
|
265
|
+
margin: MarginType = 0,
|
|
266
|
+
) -> tuple[int, int]:
|
|
267
|
+
"""
|
|
268
|
+
Calculate the absolute position based on anchor point, relative positioning and margins.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
text_size: Width and height of the text in pixels
|
|
272
|
+
position: Either absolute coordinates (int) or relative to frame size (float 0-1)
|
|
273
|
+
anchor: Which part of the text to anchor at the position
|
|
274
|
+
margin: Margin in pixels (single value or [top, right, bottom, left])
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Absolute x, y coordinates for text placement
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: If position or margin values are invalid
|
|
281
|
+
"""
|
|
282
|
+
if not isinstance(text_size, tuple) or len(text_size) != 2:
|
|
283
|
+
raise ValueError("text_size must be a tuple of (width, height)")
|
|
284
|
+
|
|
285
|
+
text_width, text_height = text_size
|
|
286
|
+
|
|
287
|
+
# Process margins
|
|
288
|
+
margin_top, margin_right, margin_bottom, margin_left = self._process_margin(margin)
|
|
289
|
+
|
|
290
|
+
# Calculate available area considering margins
|
|
291
|
+
available_width = self.image_size[0] - margin_left - margin_right
|
|
292
|
+
available_height = self.image_size[1] - margin_top - margin_bottom
|
|
293
|
+
|
|
294
|
+
# Convert relative position to absolute if needed
|
|
295
|
+
x_pos, y_pos = self._convert_position(position, margin_top, margin_left, available_width, available_height)
|
|
296
|
+
|
|
297
|
+
# Apply margin to absolute position when using 0,0 as starting point
|
|
298
|
+
if x_pos == 0 and anchor in AnchorPoint.left_anchors():
|
|
299
|
+
x_pos = margin_left
|
|
300
|
+
if y_pos == 0 and anchor in AnchorPoint.top_anchors():
|
|
301
|
+
y_pos = margin_top
|
|
302
|
+
|
|
303
|
+
# Adjust position based on anchor point
|
|
304
|
+
if anchor in AnchorPoint.center_anchors():
|
|
305
|
+
x_pos -= text_width // 2
|
|
306
|
+
elif anchor in AnchorPoint.right_anchors():
|
|
307
|
+
x_pos -= text_width
|
|
308
|
+
|
|
309
|
+
if anchor in AnchorPoint.middle_anchors():
|
|
310
|
+
y_pos -= text_height // 2
|
|
311
|
+
elif anchor in AnchorPoint.bottom_anchors():
|
|
312
|
+
y_pos -= text_height
|
|
313
|
+
|
|
314
|
+
return int(x_pos), int(y_pos)
|
|
315
|
+
|
|
316
|
+
def write_text(
|
|
317
|
+
self,
|
|
318
|
+
text: str,
|
|
319
|
+
font_filename: str,
|
|
320
|
+
xy: PositionType,
|
|
321
|
+
font_size: int | None = 11,
|
|
322
|
+
color: RGBColor = (0, 0, 0),
|
|
323
|
+
max_width: int | None = None,
|
|
324
|
+
max_height: int | None = None,
|
|
325
|
+
anchor: AnchorPoint = AnchorPoint.TOP_LEFT,
|
|
326
|
+
margin: MarginType = 0,
|
|
327
|
+
) -> tuple[int, int]:
|
|
328
|
+
"""
|
|
329
|
+
Write text to the image with advanced positioning options.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
text: Text to be written
|
|
333
|
+
font_filename: Path to the font file
|
|
334
|
+
xy: Position (x,y) either as absolute pixels (int) or relative to frame (float 0-1)
|
|
335
|
+
font_size: Size of the font in points, or None to auto-calculate
|
|
336
|
+
color: RGB color of the text
|
|
337
|
+
max_width: Maximum width for auto font sizing
|
|
338
|
+
max_height: Maximum height for auto font sizing
|
|
339
|
+
anchor: Which part of the text to anchor at the position
|
|
340
|
+
margin: Margin in pixels (single value or [top, right, bottom, left])
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Dimensions of the rendered text (width, height)
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
ValueError: If text is empty or font parameters are invalid
|
|
347
|
+
OutOfBoundsError: If the text would be rendered outside the image bounds
|
|
348
|
+
"""
|
|
349
|
+
if not text:
|
|
350
|
+
raise ValueError("Text cannot be empty")
|
|
351
|
+
|
|
352
|
+
if not font_filename:
|
|
353
|
+
raise ValueError("Font filename cannot be empty")
|
|
354
|
+
|
|
355
|
+
if font_size is not None and font_size <= 0:
|
|
356
|
+
raise ValueError("Font size must be positive")
|
|
357
|
+
|
|
358
|
+
if font_size is None and (max_width is None or max_height is None):
|
|
359
|
+
raise ValueError("Must set either `font_size`, or both `max_width` and `max_height`!")
|
|
360
|
+
elif font_size is None:
|
|
361
|
+
font_size = self._get_font_size(text, font_filename, max_width, max_height)
|
|
362
|
+
|
|
363
|
+
# Get or create the font object (with caching)
|
|
364
|
+
font = self._get_font(font_filename, font_size)
|
|
365
|
+
text_dimensions = self.get_text_dimensions(font_filename, font_size, text)
|
|
366
|
+
|
|
367
|
+
# Calculate the position based on anchor point and margins
|
|
368
|
+
x, y = self._calculate_position(text_dimensions, xy, anchor, margin)
|
|
369
|
+
|
|
370
|
+
# Verify text will fit within bounds
|
|
371
|
+
if x < 0 or y < 0 or x + text_dimensions[0] > self.image_size[0] or y + text_dimensions[1] > self.image_size[1]:
|
|
372
|
+
raise OutOfBoundsError(f"Text with size {text_dimensions} at position ({x}, {y}) is out of bounds!")
|
|
373
|
+
|
|
374
|
+
self._draw.text((x, y), text, font=font, fill=color)
|
|
375
|
+
return text_dimensions
|
|
376
|
+
|
|
377
|
+
def _get_font(self, font_filename: str, font_size: int) -> ImageFont.FreeTypeFont:
|
|
378
|
+
"""
|
|
379
|
+
Get a font object, using cache if available.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
font_filename: Path to the font file
|
|
383
|
+
font_size: Size of the font in points
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Font object for rendering text
|
|
387
|
+
"""
|
|
388
|
+
key = (font_filename, font_size)
|
|
389
|
+
if key not in self._font_cache:
|
|
390
|
+
try:
|
|
391
|
+
self._font_cache[key] = ImageFont.truetype(font_filename, font_size)
|
|
392
|
+
except (OSError, IOError) as e:
|
|
393
|
+
raise ValueError(f"Error loading font '{font_filename}': {str(e)}")
|
|
394
|
+
return self._font_cache[key]
|
|
395
|
+
|
|
396
|
+
def get_text_dimensions(self, font_filename: str, font_size: int, text: str) -> tuple[int, int]:
|
|
397
|
+
"""
|
|
398
|
+
Return dimensions (width, height) of the rendered text.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
font_filename: Path to the font file
|
|
402
|
+
font_size: Size of the font in points
|
|
403
|
+
text: Text to measure
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Tuple of (width, height) for the rendered text
|
|
407
|
+
|
|
408
|
+
Raises:
|
|
409
|
+
ValueError: If font parameters are invalid or text is empty
|
|
410
|
+
"""
|
|
411
|
+
if not text:
|
|
412
|
+
return (0, 0) # Empty text has no dimensions
|
|
413
|
+
|
|
414
|
+
if font_size <= 0:
|
|
415
|
+
raise ValueError("Font size must be positive")
|
|
416
|
+
|
|
417
|
+
font = self._get_font(font_filename, font_size)
|
|
418
|
+
try:
|
|
419
|
+
bbox = font.getbbox(text)
|
|
420
|
+
if bbox is None:
|
|
421
|
+
return (0, 0) # Handle case where getbbox returns None
|
|
422
|
+
return bbox[2:] if len(bbox) >= 4 else (0, 0)
|
|
423
|
+
except Exception as e:
|
|
424
|
+
raise ValueError(f"Error measuring text: {str(e)}")
|
|
425
|
+
|
|
426
|
+
def _split_lines_by_width(
|
|
427
|
+
self,
|
|
428
|
+
text: str,
|
|
429
|
+
font_filename: str,
|
|
430
|
+
font_size: int,
|
|
431
|
+
box_width: int,
|
|
432
|
+
) -> list[str]:
|
|
433
|
+
"""
|
|
434
|
+
Split the text into lines that fit within the specified width.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
text: Text to split into lines
|
|
438
|
+
font_filename: Path to the font file
|
|
439
|
+
font_size: Size of the font in points
|
|
440
|
+
box_width: Maximum width for each line in pixels
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
List of text lines that fit within box_width
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
ValueError: If font parameters are invalid or box_width is too small
|
|
447
|
+
"""
|
|
448
|
+
if not text:
|
|
449
|
+
return [] # Empty text produces no lines
|
|
450
|
+
|
|
451
|
+
if box_width <= 0:
|
|
452
|
+
raise ValueError("Box width must be positive")
|
|
453
|
+
|
|
454
|
+
if font_size <= 0:
|
|
455
|
+
raise ValueError("Font size must be positive")
|
|
456
|
+
|
|
457
|
+
words = text.split()
|
|
458
|
+
if not words:
|
|
459
|
+
return [] # No words means no lines
|
|
460
|
+
|
|
461
|
+
# Handle single-word case efficiently
|
|
462
|
+
if len(words) == 1:
|
|
463
|
+
return [text]
|
|
464
|
+
|
|
465
|
+
split_lines: list[list[str]] = []
|
|
466
|
+
current_line: list[str] = []
|
|
467
|
+
|
|
468
|
+
for word in words:
|
|
469
|
+
# If current line is empty and this word is too long for box_width,
|
|
470
|
+
# we'll have to split the word itself (not implemented)
|
|
471
|
+
if not current_line and self.get_text_dimensions(font_filename, font_size, word)[0] > box_width:
|
|
472
|
+
# Just add the word anyway, it'll overflow but we can't do better without splitting words
|
|
473
|
+
split_lines.append([word])
|
|
474
|
+
continue
|
|
475
|
+
|
|
476
|
+
# Try adding the word to current line
|
|
477
|
+
new_line = " ".join(current_line + [word]) if current_line else word
|
|
478
|
+
size = self.get_text_dimensions(font_filename, font_size, new_line)
|
|
479
|
+
if size[0] <= box_width:
|
|
480
|
+
current_line.append(word)
|
|
481
|
+
else:
|
|
482
|
+
# This word doesn't fit, start new line
|
|
483
|
+
if current_line: # Only if we have a current line to add
|
|
484
|
+
split_lines.append(current_line)
|
|
485
|
+
current_line = [word]
|
|
486
|
+
|
|
487
|
+
# Add the last line if it has content
|
|
488
|
+
if current_line:
|
|
489
|
+
split_lines.append(current_line)
|
|
490
|
+
|
|
491
|
+
# Join the words in each line with spaces
|
|
492
|
+
lines = [" ".join(line) for line in split_lines]
|
|
493
|
+
return lines
|
|
494
|
+
|
|
495
|
+
def write_text_box(
|
|
496
|
+
self,
|
|
497
|
+
text: str,
|
|
498
|
+
font_filename: str,
|
|
499
|
+
xy: PositionType,
|
|
500
|
+
box_width: Union[int, float] | None = None,
|
|
501
|
+
font_size: int = 11,
|
|
502
|
+
text_color: RGBColor = (0, 0, 0),
|
|
503
|
+
background_color: RGBAColor | None = None,
|
|
504
|
+
background_padding: int = 0,
|
|
505
|
+
place: TextAlign = TextAlign.LEFT,
|
|
506
|
+
anchor: AnchorPoint = AnchorPoint.TOP_LEFT,
|
|
507
|
+
margin: MarginType = 0,
|
|
508
|
+
) -> tuple[int, int]:
|
|
509
|
+
"""
|
|
510
|
+
Write text in a box with advanced positioning and alignment options.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
text: Text to be written inside the box
|
|
514
|
+
font_filename: Path to the font file
|
|
515
|
+
xy: Position (x,y) either as absolute pixels (int) or relative to frame (float 0-1)
|
|
516
|
+
box_width: Width of the box in pixels (int) or relative to frame width (float 0-1)
|
|
517
|
+
font_size: Font size in points
|
|
518
|
+
text_color: RGB color of the text
|
|
519
|
+
background_color: If set, adds background color to the text box. Expects RGBA values.
|
|
520
|
+
background_padding: Number of padding pixels to add when adding text background color
|
|
521
|
+
place: Text alignment within the box (TextAlign.LEFT, TextAlign.RIGHT, TextAlign.CENTER)
|
|
522
|
+
anchor: Which part of the text box to anchor at the position
|
|
523
|
+
margin: Margin in pixels (single value or [top, right, bottom, left])
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
Coordinates of the lower-right corner of the written text box (x, y)
|
|
527
|
+
|
|
528
|
+
Raises:
|
|
529
|
+
ValueError: If text is empty or parameters are invalid
|
|
530
|
+
OutOfBoundsError: If text box would be outside image bounds
|
|
531
|
+
"""
|
|
532
|
+
if not text:
|
|
533
|
+
raise ValueError("Text cannot be empty")
|
|
534
|
+
|
|
535
|
+
if not font_filename:
|
|
536
|
+
raise ValueError("Font filename cannot be empty")
|
|
537
|
+
|
|
538
|
+
if font_size <= 0:
|
|
539
|
+
raise ValueError("Font size must be positive")
|
|
540
|
+
|
|
541
|
+
if background_padding < 0:
|
|
542
|
+
raise ValueError("Background padding cannot be negative")
|
|
543
|
+
|
|
544
|
+
# Process margins to determine available area
|
|
545
|
+
margin_top, margin_right, margin_bottom, margin_left = self._process_margin(margin)
|
|
546
|
+
available_width = self.image_size[0] - margin_left - margin_right
|
|
547
|
+
available_height = self.image_size[1] - margin_top - margin_bottom
|
|
548
|
+
|
|
549
|
+
# Handle relative box width
|
|
550
|
+
if box_width is None:
|
|
551
|
+
box_width = available_width
|
|
552
|
+
elif isinstance(box_width, float) and 0 < box_width <= 1:
|
|
553
|
+
box_width = int(available_width * box_width)
|
|
554
|
+
elif isinstance(box_width, int) and box_width <= 0:
|
|
555
|
+
raise ValueError("Box width must be positive")
|
|
556
|
+
|
|
557
|
+
# Calculate initial position based on margin and anchor before splitting text
|
|
558
|
+
x_pos, y_pos = self._convert_position(xy, margin_top, margin_left, available_width, available_height)
|
|
559
|
+
|
|
560
|
+
# Split text into lines that fit within box_width
|
|
561
|
+
lines = self._split_lines_by_width(text, font_filename, font_size, int(box_width))
|
|
562
|
+
|
|
563
|
+
# Calculate total height of all lines
|
|
564
|
+
lines_height = sum([self.get_text_dimensions(font_filename, font_size, line)[1] for line in lines])
|
|
565
|
+
if lines_height == 0:
|
|
566
|
+
# If we have no valid lines or zero height, return the position
|
|
567
|
+
return (int(x_pos), int(y_pos))
|
|
568
|
+
|
|
569
|
+
# Final position calculation based on anchor point
|
|
570
|
+
if anchor in AnchorPoint.center_anchors():
|
|
571
|
+
x_pos -= box_width // 2
|
|
572
|
+
elif anchor in AnchorPoint.right_anchors():
|
|
573
|
+
x_pos -= box_width
|
|
574
|
+
|
|
575
|
+
if anchor in AnchorPoint.middle_anchors():
|
|
576
|
+
y_pos -= lines_height // 2
|
|
577
|
+
elif anchor in AnchorPoint.bottom_anchors():
|
|
578
|
+
y_pos -= lines_height
|
|
579
|
+
|
|
580
|
+
# Verify box will fit within bounds
|
|
581
|
+
if (
|
|
582
|
+
x_pos < 0
|
|
583
|
+
or y_pos < 0
|
|
584
|
+
or x_pos + box_width > self.image_size[0]
|
|
585
|
+
or y_pos + lines_height > self.image_size[1]
|
|
586
|
+
):
|
|
587
|
+
raise OutOfBoundsError(
|
|
588
|
+
f"Text box with size ({box_width}x{lines_height}) at position ({x_pos}, {y_pos}) is out of bounds!"
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
# Write lines
|
|
592
|
+
current_text_height = y_pos
|
|
593
|
+
for line in lines:
|
|
594
|
+
line_dimensions = self.get_text_dimensions(font_filename, font_size, line)
|
|
595
|
+
|
|
596
|
+
# Calculate horizontal position based on alignment
|
|
597
|
+
if place == TextAlign.LEFT:
|
|
598
|
+
x_left = x_pos
|
|
599
|
+
elif place == TextAlign.RIGHT:
|
|
600
|
+
x_left = x_pos + box_width - line_dimensions[0]
|
|
601
|
+
elif place == TextAlign.CENTER:
|
|
602
|
+
x_left = int(x_pos + ((box_width - line_dimensions[0]) / 2))
|
|
603
|
+
else:
|
|
604
|
+
valid_places = [e.value for e in TextAlign]
|
|
605
|
+
raise ValueError(f"Place '{place}' is not supported. Must be one of: {', '.join(valid_places)}")
|
|
606
|
+
|
|
607
|
+
# Write the line
|
|
608
|
+
self.write_text(
|
|
609
|
+
text=line,
|
|
610
|
+
font_filename=font_filename,
|
|
611
|
+
xy=(x_left, current_text_height),
|
|
612
|
+
font_size=font_size,
|
|
613
|
+
color=text_color,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# Increment vertical position for next line
|
|
617
|
+
current_text_height += line_dimensions[1]
|
|
618
|
+
|
|
619
|
+
# Add background color for the text if specified
|
|
620
|
+
if background_color is not None:
|
|
621
|
+
if len(background_color) != 4:
|
|
622
|
+
raise ValueError(f"Text background color {background_color} must be RGBA (4 values)!")
|
|
623
|
+
|
|
624
|
+
img = self.img_array
|
|
625
|
+
|
|
626
|
+
# Find bounding rectangle for written text
|
|
627
|
+
# Skip if the box is empty
|
|
628
|
+
if y_pos >= current_text_height or x_pos >= x_pos + box_width:
|
|
629
|
+
return (int(x_pos + box_width), int(current_text_height))
|
|
630
|
+
|
|
631
|
+
# Get the slice of the image containing the text box
|
|
632
|
+
box_slice = img[int(y_pos) : int(current_text_height), int(x_pos) : int(x_pos + box_width)]
|
|
633
|
+
if box_slice.size == 0: # Empty slice
|
|
634
|
+
return (int(x_pos + box_width), int(current_text_height))
|
|
635
|
+
|
|
636
|
+
# Create mask of non-zero pixels (text)
|
|
637
|
+
text_mask = np.any(box_slice != 0, axis=2).astype(np.uint8)
|
|
638
|
+
if not isinstance(text_mask, np.ndarray):
|
|
639
|
+
raise TypeError(f"The returned text mask is of type {type(text_mask)}, but it should be numpy array!")
|
|
640
|
+
|
|
641
|
+
# If no text pixels found, return without background
|
|
642
|
+
if not np.any(text_mask):
|
|
643
|
+
return (int(x_pos + box_width), int(current_text_height))
|
|
644
|
+
|
|
645
|
+
# Find the smallest rectangle containing text
|
|
646
|
+
try:
|
|
647
|
+
xmin, xmax, ymin, ymax = self._find_smallest_bounding_rect(text_mask)
|
|
648
|
+
except Exception:
|
|
649
|
+
# If bounding rectangle calculation fails, use the whole box
|
|
650
|
+
xmin, xmax, ymin, ymax = 0, box_slice.shape[1] - 1, 0, box_slice.shape[0] - 1
|
|
651
|
+
|
|
652
|
+
# Get global bounding box position
|
|
653
|
+
xmin = int(xmin + x_pos - background_padding)
|
|
654
|
+
xmax = int(xmax + x_pos + background_padding)
|
|
655
|
+
ymin = int(ymin + y_pos - background_padding)
|
|
656
|
+
ymax = int(ymax + y_pos + background_padding)
|
|
657
|
+
|
|
658
|
+
# Make sure we are inside image bounds
|
|
659
|
+
xmin = max(0, xmin)
|
|
660
|
+
ymin = max(0, ymin)
|
|
661
|
+
xmax = min(xmax, self.image_size[0])
|
|
662
|
+
ymax = min(ymax, self.image_size[1])
|
|
663
|
+
|
|
664
|
+
# Skip if bounding box is invalid
|
|
665
|
+
if xmin >= xmax or ymin >= ymax:
|
|
666
|
+
return (int(x_pos + box_width), int(current_text_height))
|
|
667
|
+
|
|
668
|
+
# Slice the bounding box and find text mask
|
|
669
|
+
bbox_slice = img[ymin:ymax, xmin:xmax]
|
|
670
|
+
if bbox_slice.size == 0: # Empty slice
|
|
671
|
+
return (int(x_pos + box_width), int(current_text_height))
|
|
672
|
+
|
|
673
|
+
bbox_text_mask = np.any(bbox_slice != 0, axis=2).astype(np.uint8)
|
|
674
|
+
|
|
675
|
+
# Add background color outside of text
|
|
676
|
+
bbox_slice[~bbox_text_mask.astype(bool)] = background_color
|
|
677
|
+
|
|
678
|
+
# Handle semi-transparent pixels for smooth text blending
|
|
679
|
+
text_slice = bbox_slice[bbox_text_mask.astype(bool)]
|
|
680
|
+
if text_slice.size > 0:
|
|
681
|
+
text_background = text_slice[:, :3] * (np.expand_dims(text_slice[:, -1], axis=1) / 255)
|
|
682
|
+
color_background = (1 - (np.expand_dims(text_slice[:, -1], axis=1) / 255)) * background_color
|
|
683
|
+
faded_background = text_background[:, :3] + color_background[:, :3]
|
|
684
|
+
text_slice[:, :3] = faded_background
|
|
685
|
+
text_slice[:, -1] = 255 # Full opacity
|
|
686
|
+
bbox_slice[bbox_text_mask.astype(bool)] = text_slice
|
|
687
|
+
|
|
688
|
+
# Update the image with the background color
|
|
689
|
+
self.image = Image.fromarray(img)
|
|
690
|
+
|
|
691
|
+
return (int(x_pos + box_width), int(current_text_height))
|
|
692
|
+
|
|
693
|
+
def _find_smallest_bounding_rect(self, mask: np.ndarray) -> tuple[int, int, int, int]:
|
|
694
|
+
"""
|
|
695
|
+
Find the smallest bounding rectangle containing non-zero values in the mask.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
mask: 2D numpy array with non-zero values representing pixels of interest
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
Tuple of (xmin, xmax, ymin, ymax) coordinates
|
|
702
|
+
|
|
703
|
+
Raises:
|
|
704
|
+
ValueError: If mask is empty or has no non-zero values
|
|
705
|
+
"""
|
|
706
|
+
if mask.size == 0:
|
|
707
|
+
raise ValueError("Mask is empty")
|
|
708
|
+
|
|
709
|
+
# Check if mask has any non-zero values
|
|
710
|
+
if not np.any(mask):
|
|
711
|
+
raise ValueError("Mask has no non-zero values")
|
|
712
|
+
|
|
713
|
+
rows = np.any(mask, axis=1)
|
|
714
|
+
cols = np.any(mask, axis=0)
|
|
715
|
+
|
|
716
|
+
# Find indices of first and last True values
|
|
717
|
+
row_indices = np.where(rows)[0]
|
|
718
|
+
col_indices = np.where(cols)[0]
|
|
719
|
+
|
|
720
|
+
# Handle empty results
|
|
721
|
+
if len(row_indices) == 0 or len(col_indices) == 0:
|
|
722
|
+
raise ValueError("No bounding rectangle found")
|
|
723
|
+
|
|
724
|
+
ymin, ymax = row_indices[[0, -1]]
|
|
725
|
+
xmin, xmax = col_indices[[0, -1]]
|
|
726
|
+
|
|
727
|
+
return xmin, xmax, ymin, ymax
|