| """ |
| 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 |
| """ |
| |
| |
| TARGET_WIDTH = 1080 |
| TARGET_HEIGHT = 1920 |
| PADDING_X = 88 |
| |
| |
| HEADING_FONT_SIZE = 80 |
| TEXT_FONT_SIZE = 60 |
| LINE_SPACING = 1.3 |
| |
| |
| HEADING_TOP_PERCENT = 0.30 |
| TEXT_TOP_PERCENT = 0.45 |
| GAP_HEADING_TEXT = 60 |
| |
| |
| 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: |
| |
| 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: |
| |
| 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: |
| |
| 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) |
| |
| 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'): |
| |
| 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('#'): |
| |
| 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) |
| |
| |
| 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 |
| |
| |
| |
| 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 |
| """ |
| |
| |
| 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})") |
| |
| |
| img = Image.open(image_path).convert('RGBA') |
| |
| |
| if img.size != (self.TARGET_WIDTH, self.TARGET_HEIGHT): |
| img = img.resize((self.TARGET_WIDTH, self.TARGET_HEIGHT), Image.LANCZOS) |
| |
| |
| overlay = Image.new('RGBA', img.size, (0, 0, 0, 0)) |
| draw = ImageDraw.Draw(overlay) |
| |
| |
| max_width = self.TARGET_WIDTH - (2 * self.PADDING_X) |
| |
| |
| heading_text_length = len(heading) if heading else 0 |
| if heading_text_length <= 15: |
| heading_font_size = 80 |
| 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 |
| |
| |
| text_length = len(text) |
| if text_length <= 50: |
| text_font_size = 60 |
| 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 |
| |
| |
| 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) |
| |
| |
| 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) |
| |
| |
| 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] |
| |
| |
| heading_height = 0 |
| heading_width = 0 |
| heading_bg_padding = 12 |
| heading_bg_radius = 15 |
| |
| 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) |
| |
| |
| gap_between = self.GAP_HEADING_TEXT if heading else 0 |
| total_height = text_height + (heading_height + heading_bg_padding * 2 + gap_between if heading else 0) |
| |
| |
| heading_y = int(self.TARGET_HEIGHT * self.HEADING_TOP_PERCENT) |
| |
| text_y = int(self.TARGET_HEIGHT * self.TEXT_TOP_PERCENT) |
| |
| current_y = heading_y |
| |
| |
| if heading: |
| |
| heading_upper = heading if self._has_bengali(heading) else heading.upper() |
| |
| heading_bbox = draw.textbbox((0, 0), heading_upper, font=heading_font) |
| |
| text_y_offset = heading_bbox[1] |
| heading_width = heading_bbox[2] - heading_bbox[0] |
| heading_height = heading_bbox[3] - heading_bbox[1] |
| heading_x = (self.TARGET_WIDTH - heading_width) // 2 |
| |
| |
| show_bg = True |
| if heading_background and heading_background.get('enabled') is False: |
| show_bg = False |
| |
| if show_bg: |
| |
| if heading_background and heading_background.get('color'): |
| bg_color = self._parse_rgba(heading_background.get('color')) |
| else: |
| bg_color = (255, 109, 128, 242) |
| |
| |
| 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.text( |
| (heading_x, current_y), |
| heading_upper, |
| font=heading_font, |
| fill=(255, 255, 255, 255) |
| ) |
| |
| current_y += heading_height + heading_bg_padding + gap_between |
| |
| |
| |
| fact_text_y = text_y if heading else int(self.TARGET_HEIGHT * 0.40) |
| text_x = (self.TARGET_WIDTH - text_width) // 2 |
| |
| |
| 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 |
| |
| |
| glass_color = (0, 0, 0, 100) |
| self._draw_rounded_rect( |
| draw, |
| (glass_x1, glass_y1, glass_x2, glass_y2), |
| glass_radius, |
| glass_color |
| ) |
| |
| |
| draw.rounded_rectangle( |
| (glass_x1, glass_y1, glass_x2, glass_y2), |
| radius=glass_radius, |
| outline=(255, 255, 255, 40), |
| width=2 |
| ) |
| |
| |
| 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.multiline_text( |
| (text_x + shadow_offset, fact_text_y + shadow_offset), |
| wrapped_text, |
| font=text_font, |
| fill=(0, 0, 0, 150), |
| align='center' |
| ) |
| |
| |
| draw.multiline_text( |
| (text_x, fact_text_y), |
| wrapped_text, |
| font=text_font, |
| fill=(255, 255, 255, 255), |
| align='center' |
| ) |
| |
| |
| img = Image.alpha_composite(img, overlay) |
| |
| |
| img = img.convert('RGB') |
| img.save(output_path, 'PNG', quality=95) |
| |
| logger.info(f"Text overlay saved: {output_path}") |
| return output_path |
|
|
|
|