File size: 16,432 Bytes
9834af3 db23a91 9834af3 db23a91 699416a 9834af3 db23a91 9834af3 db23a91 9834af3 800faa1 9834af3 db23a91 8c58782 db23a91 8c58782 d4f61bc 8c58782 699416a 9834af3 699416a db23a91 9834af3 699416a db23a91 9834af3 699416a db23a91 9834af3 db23a91 9834af3 db23a91 9834af3 db23a91 9834af3 db23a91 9834af3 ee36c8e 9834af3 ee36c8e 9834af3 ee36c8e 9834af3 ee36c8e 9834af3 ee36c8e 9834af3 db23a91 9834af3 db23a91 9834af3 db23a91 9834af3 db23a91 9834af3 db23a91 9834af3 db23a91 9834af3 db23a91 9834af3 db23a91 9834af3 699416a 9834af3 db23a91 9834af3 ee36c8e db23a91 9834af3 ee36c8e 699416a ee36c8e 9834af3 ee36c8e db23a91 d4f61bc 9834af3 db23a91 8c58782 db23a91 c8b39c5 db23a91 c8b39c5 db23a91 8c58782 db23a91 699416a 46f20a7 8c58782 46f20a7 8c58782 db23a91 46f20a7 db23a91 46f20a7 db23a91 46f20a7 db23a91 46f20a7 db23a91 8c58782 db23a91 8c58782 db23a91 d4f61bc c8b39c5 db23a91 d4f61bc db23a91 c8b39c5 db23a91 9834af3 c8b39c5 9834af3 db23a91 9834af3 c8b39c5 9834af3 db23a91 9834af3 db23a91 9834af3 db23a91 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 | """
Text Overlay Service
Renders text on images using PIL/Pillow
Supports heading with background + fact text with shadow
"""
import logging
import re
import unicodedata
from pathlib import Path
from typing import Tuple, Optional, Dict, Any
from PIL import Image, ImageDraw, ImageFont
logger = logging.getLogger(__name__)
class TextOverlay:
"""
Service for adding text overlay to images.
Optimized for fact/motivational content on vertical videos.
Layout:
- Heading (bold, with background) - centered
- Fact text (with shadow/outline) - centered below heading
- All content vertically centered in middle of image
"""
# Default settings
TARGET_WIDTH = 1080
TARGET_HEIGHT = 1920
PADDING_X = 88 # Horizontal padding (left/right margin) - 8px added each side
# Font sizes
HEADING_FONT_SIZE = 80
TEXT_FONT_SIZE = 60
LINE_SPACING = 1.3
# Layout settings
HEADING_TOP_PERCENT = 0.30 # Heading starts at 30% from top (more visible)
TEXT_TOP_PERCENT = 0.45 # Fact text starts at 45% from top
GAP_HEADING_TEXT = 60 # Gap between heading and fact text (fallback)
# Path to Bengali font (bundled in static/fonts/)
BENGALI_FONT = Path(__file__).parent.parent.parent.parent / "static" / "fonts" / "Bangla_Unicode.ttf"
def __init__(self, font_path: Optional[str] = None):
"""
Initialize text overlay service.
Args:
font_path: Path to custom font file (optional)
"""
self.font_path = font_path
self._font_cache = {}
@staticmethod
def _has_bengali(text: str) -> bool:
"""
Check if text contains any Bengali script characters.
Bengali Unicode range: U+0980 - U+09FF
Auto-detects — works for pure Bengali, pure English, or mixed.
"""
if not text:
return False
for ch in text:
if '\u0980' <= ch <= '\u09FF':
return True
return False
def _get_font(self, size: int, bold: bool = False, bengali: bool = False) -> ImageFont.FreeTypeFont:
"""Get font at specified size (cached). Uses Bengali font when bengali=True."""
cache_key = (size, bold, bengali)
if cache_key not in self._font_cache:
try:
# Bengali font path
if bengali and self.BENGALI_FONT.exists():
self._font_cache[cache_key] = ImageFont.truetype(str(self.BENGALI_FONT), size)
logger.debug(f"Loaded Bengali font at size {size}")
elif self.font_path and Path(self.font_path).exists():
self._font_cache[cache_key] = ImageFont.truetype(self.font_path, size)
else:
# Try common system fonts (Latin)
if bold:
font_names = ['DejaVuSans-Bold.ttf', 'Arial-Bold.ttf', 'Roboto-Bold.ttf', 'FreeSansBold.ttf']
else:
font_names = ['DejaVuSans.ttf', 'Arial.ttf', 'Roboto-Regular.ttf', 'FreeSans.ttf']
for font_name in font_names:
try:
self._font_cache[cache_key] = ImageFont.truetype(font_name, size)
break
except:
continue
else:
# Fallback to default
self._font_cache[cache_key] = ImageFont.load_default()
except Exception as e:
logger.warning(f"Font loading failed: {e}, using default")
self._font_cache[cache_key] = ImageFont.load_default()
return self._font_cache[cache_key]
def _wrap_text(self, text: str, max_words_per_line: int = 4, max_chars_per_line: int = 25) -> str:
"""
Wrap text for optimal display.
Rules:
- Max characters per line to prevent overflow
- Max words per line for readability
- Natural line breaks
"""
words = text.split()
lines = []
current_line = []
current_char_count = 0
for word in words:
word_len = len(word)
# Check if adding this word would exceed limits
new_char_count = current_char_count + word_len + (1 if current_line else 0)
if (len(current_line) >= max_words_per_line or
new_char_count > max_chars_per_line) and current_line:
lines.append(' '.join(current_line))
current_line = [word]
current_char_count = word_len
else:
current_line.append(word)
current_char_count = new_char_count
if current_line:
lines.append(' '.join(current_line))
return '\n'.join(lines)
def _parse_rgba(self, color_str: str) -> Tuple[int, int, int, int]:
"""Parse rgba color string to tuple"""
if color_str.startswith('rgba'):
# Parse rgba(r, g, b, a)
match = re.match(r'rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)', color_str)
if match:
r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3))
a = int(float(match.group(4)) * 255)
return (r, g, b, a)
elif color_str.startswith('#'):
# Parse hex color
hex_color = color_str.lstrip('#')
if len(hex_color) == 6:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
return (r, g, b, 255)
# Default: semi-transparent black
return (0, 0, 0, 115)
def _draw_rounded_rect(
self,
draw: ImageDraw.ImageDraw,
xy: Tuple[int, int, int, int],
radius: int,
fill: Tuple[int, int, int, int]
):
"""Draw a rounded rectangle"""
x1, y1, x2, y2 = xy
# For RGBA support, we need to create a separate image
# and composite it
draw.rounded_rectangle(xy, radius=radius, fill=fill)
def add_text(
self,
image_path: Path,
text: str,
output_path: Path,
heading: Optional[str] = None,
heading_background: Optional[Dict[str, Any]] = None,
text_color: Tuple[int, int, int] = (255, 255, 255),
shadow_color: Tuple[int, int, int] = (0, 0, 0),
shadow_offset: int = 3,
outline_width: int = 2
) -> Path:
"""
Add text overlay to image.
Args:
image_path: Path to input image
text: Fact text to overlay
output_path: Path for output image
heading: Optional heading text
heading_background: Heading background config {enabled, color, padding, corner_radius}
text_color: RGB color for text (default: white)
shadow_color: RGB color for shadow (default: black)
shadow_offset: Shadow offset in pixels
outline_width: Text outline width
Returns:
Path to output image
"""
# Detect Bengali INDEPENDENTLY for heading and text
# So any combination works: BN heading + EN text, EN heading + BN text, etc.
heading_is_bengali = self._has_bengali(heading or "")
text_is_bengali = self._has_bengali(text)
logger.info(f"Adding text overlay: heading='{heading}' (bn={heading_is_bengali}), text='{text[:30]}...' (bn={text_is_bengali})")
# Load image
img = Image.open(image_path).convert('RGBA')
# Resize if needed
if img.size != (self.TARGET_WIDTH, self.TARGET_HEIGHT):
img = img.resize((self.TARGET_WIDTH, self.TARGET_HEIGHT), Image.LANCZOS)
# Create overlay layer for transparency support
overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Calculate max width (with padding on sides)
max_width = self.TARGET_WIDTH - (2 * self.PADDING_X)
# Dynamic font size for HEADING based on text length
heading_text_length = len(heading) if heading else 0
if heading_text_length <= 15:
heading_font_size = 80 # Large for short heading
elif heading_text_length <= 25:
heading_font_size = 70
elif heading_text_length <= 35:
heading_font_size = 60
elif heading_text_length <= 50:
heading_font_size = 50
else:
heading_font_size = 42 # Minimum for very long heading
# Dynamic font size for FACT TEXT based on text length
text_length = len(text)
if text_length <= 50:
text_font_size = 60 # Large for short text
elif text_length <= 100:
text_font_size = 52
elif text_length <= 150:
text_font_size = 46
elif text_length <= 200:
text_font_size = 40
elif text_length <= 300:
text_font_size = 36
else:
text_font_size = 32 # Minimum for very long text
# Get fonts — each uses its OWN Bengali detection
heading_font = self._get_font(heading_font_size, bold=True, bengali=heading_is_bengali)
text_font = self._get_font(text_font_size, bold=True, bengali=text_is_bengali)
# Word wrap text with dynamic font to fit max_width
words = text.split()
lines = []
current_line = ""
for word in words:
test_line = current_line + " " + word if current_line else word
bbox = draw.textbbox((0, 0), test_line, font=text_font)
if bbox[2] - bbox[0] <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
wrapped_text = "\n".join(lines)
# Calculate text dimensions with wrapped text
text_bbox = draw.multiline_textbbox((0, 0), wrapped_text, font=text_font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
# Calculate heading dimensions if present
heading_height = 0
heading_width = 0
heading_bg_padding = 12 # Tight padding - close to text
heading_bg_radius = 15 # Smaller radius for tighter look
if heading:
heading_bbox = draw.textbbox((0, 0), heading, font=heading_font)
heading_width = heading_bbox[2] - heading_bbox[0]
heading_height = heading_bbox[3] - heading_bbox[1]
if heading_background and heading_background.get('enabled', True):
heading_bg_padding = heading_background.get('padding', 22)
heading_bg_radius = heading_background.get('corner_radius', 28)
# Calculate total content height
gap_between = self.GAP_HEADING_TEXT if heading else 0 # Gap between heading and text
total_height = text_height + (heading_height + heading_bg_padding * 2 + gap_between if heading else 0)
# Position heading at 40% from top
heading_y = int(self.TARGET_HEIGHT * self.HEADING_TOP_PERCENT)
# Position fact text at 50% from top
text_y = int(self.TARGET_HEIGHT * self.TEXT_TOP_PERCENT)
current_y = heading_y
# Draw heading with background (UPPERCASE)
if heading:
# Bengali has no uppercase — skip .upper() for Bengali text
heading_upper = heading if self._has_bengali(heading) else heading.upper()
# Get text bounding box - returns (left, top, right, bottom)
heading_bbox = draw.textbbox((0, 0), heading_upper, font=heading_font)
# The bbox includes font metrics offset
text_y_offset = heading_bbox[1] # Top offset from baseline
heading_width = heading_bbox[2] - heading_bbox[0]
heading_height = heading_bbox[3] - heading_bbox[1]
heading_x = (self.TARGET_WIDTH - heading_width) // 2
# Draw background - ALWAYS show when heading exists
show_bg = True
if heading_background and heading_background.get('enabled') is False:
show_bg = False
if show_bg:
# Get color from config or use default coral
if heading_background and heading_background.get('color'):
bg_color = self._parse_rgba(heading_background.get('color'))
else:
bg_color = (255, 109, 128, 242) # Default coral color with 95% opacity
# Background position - account for text_y_offset to align with actual text
actual_text_y = current_y + text_y_offset
bg_x1 = heading_x - heading_bg_padding
bg_y1 = actual_text_y - heading_bg_padding
bg_x2 = heading_x + heading_width + heading_bg_padding
bg_y2 = actual_text_y + heading_height + heading_bg_padding
self._draw_rounded_rect(
draw,
(bg_x1, bg_y1, bg_x2, bg_y2),
heading_bg_radius,
bg_color
)
# Draw heading text (white, UPPERCASE)
draw.text(
(heading_x, current_y),
heading_upper,
font=heading_font,
fill=(255, 255, 255, 255)
)
current_y += heading_height + heading_bg_padding + gap_between
# Draw fact text with glass background
# Use fixed position at 50% from top
fact_text_y = text_y if heading else int(self.TARGET_HEIGHT * 0.40)
text_x = (self.TARGET_WIDTH - text_width) // 2
# Draw glass background behind fact text
glass_padding = 35
glass_radius = 25
glass_x1 = text_x - glass_padding
glass_y1 = fact_text_y - glass_padding
glass_x2 = text_x + text_width + glass_padding
glass_y2 = fact_text_y + text_height + glass_padding
# Glassmorphism effect - semi-transparent dark with slight blur effect
glass_color = (0, 0, 0, 100) # Dark glass with 40% opacity
self._draw_rounded_rect(
draw,
(glass_x1, glass_y1, glass_x2, glass_y2),
glass_radius,
glass_color
)
# Draw subtle border for glass effect
draw.rounded_rectangle(
(glass_x1, glass_y1, glass_x2, glass_y2),
radius=glass_radius,
outline=(255, 255, 255, 40), # Subtle white border
width=2
)
# Draw outline (multiple directions for thickness)
for dx in range(-outline_width, outline_width + 1):
for dy in range(-outline_width, outline_width + 1):
if dx != 0 or dy != 0:
draw.multiline_text(
(text_x + dx, fact_text_y + dy),
wrapped_text,
font=text_font,
fill=(0, 0, 0, 200),
align='center'
)
# Draw shadow
draw.multiline_text(
(text_x + shadow_offset, fact_text_y + shadow_offset),
wrapped_text,
font=text_font,
fill=(0, 0, 0, 150),
align='center'
)
# Draw main text
draw.multiline_text(
(text_x, fact_text_y),
wrapped_text,
font=text_font,
fill=(255, 255, 255, 255),
align='center'
)
# Composite overlay onto image
img = Image.alpha_composite(img, overlay)
# Save output
img = img.convert('RGB')
img.save(output_path, 'PNG', quality=95)
logger.info(f"Text overlay saved: {output_path}")
return output_path
|