NCAkit / modules /fact_image /services /text_overlay.py
ismdrobiul489's picture
feat(fact_image): add Bengali text auto-detection and font support
699416a
"""
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