""" 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