357 lines
10 KiB
Python
Executable File
357 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Typography System - Professional text rendering with outlines, shadows, and effects.
|
|
|
|
This module provides high-quality text rendering that looks crisp and professional
|
|
in GIFs, with outlines for readability and effects for visual impact.
|
|
"""
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from typing import Optional
|
|
|
|
|
|
# Typography scale - proportional sizing system
|
|
TYPOGRAPHY_SCALE = {
|
|
'h1': 60, # Large headers
|
|
'h2': 48, # Medium headers
|
|
'h3': 36, # Small headers
|
|
'title': 50, # Title text
|
|
'body': 28, # Body text
|
|
'small': 20, # Small text
|
|
'tiny': 16, # Tiny text
|
|
}
|
|
|
|
|
|
def get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
|
"""
|
|
Get a font with fallback support.
|
|
|
|
Args:
|
|
size: Font size in pixels
|
|
bold: Use bold variant if available
|
|
|
|
Returns:
|
|
ImageFont object
|
|
"""
|
|
# Try multiple font paths for cross-platform support
|
|
font_paths = [
|
|
# macOS fonts
|
|
"/System/Library/Fonts/Helvetica.ttc",
|
|
"/System/Library/Fonts/SF-Pro.ttf",
|
|
"/Library/Fonts/Arial Bold.ttf" if bold else "/Library/Fonts/Arial.ttf",
|
|
# Linux fonts
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
# Windows fonts
|
|
"C:\\Windows\\Fonts\\arialbd.ttf" if bold else "C:\\Windows\\Fonts\\arial.ttf",
|
|
]
|
|
|
|
for font_path in font_paths:
|
|
try:
|
|
return ImageFont.truetype(font_path, size)
|
|
except:
|
|
continue
|
|
|
|
# Ultimate fallback
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def draw_text_with_outline(
|
|
frame: Image.Image,
|
|
text: str,
|
|
position: tuple[int, int],
|
|
font_size: int = 40,
|
|
text_color: tuple[int, int, int] = (255, 255, 255),
|
|
outline_color: tuple[int, int, int] = (0, 0, 0),
|
|
outline_width: int = 3,
|
|
centered: bool = False,
|
|
bold: bool = True
|
|
) -> Image.Image:
|
|
"""
|
|
Draw text with outline for maximum readability.
|
|
|
|
This is THE most important function for professional-looking text in GIFs.
|
|
The outline ensures text is readable on any background.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
text: Text to draw
|
|
position: (x, y) position
|
|
font_size: Font size in pixels
|
|
text_color: RGB color for text fill
|
|
outline_color: RGB color for outline
|
|
outline_width: Width of outline in pixels (2-4 recommended)
|
|
centered: If True, center text at position
|
|
bold: Use bold font variant
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
font = get_font(font_size, bold=bold)
|
|
|
|
# Calculate position for centering
|
|
if centered:
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
x = position[0] - text_width // 2
|
|
y = position[1] - text_height // 2
|
|
position = (x, y)
|
|
|
|
# Draw outline by drawing text multiple times offset in all directions
|
|
x, y = position
|
|
for offset_x in range(-outline_width, outline_width + 1):
|
|
for offset_y in range(-outline_width, outline_width + 1):
|
|
if offset_x != 0 or offset_y != 0:
|
|
draw.text((x + offset_x, y + offset_y), text, fill=outline_color, font=font)
|
|
|
|
# Draw main text on top
|
|
draw.text(position, text, fill=text_color, font=font)
|
|
|
|
return frame
|
|
|
|
|
|
def draw_text_with_shadow(
|
|
frame: Image.Image,
|
|
text: str,
|
|
position: tuple[int, int],
|
|
font_size: int = 40,
|
|
text_color: tuple[int, int, int] = (255, 255, 255),
|
|
shadow_color: tuple[int, int, int] = (0, 0, 0),
|
|
shadow_offset: tuple[int, int] = (3, 3),
|
|
centered: bool = False,
|
|
bold: bool = True
|
|
) -> Image.Image:
|
|
"""
|
|
Draw text with drop shadow for depth.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
text: Text to draw
|
|
position: (x, y) position
|
|
font_size: Font size in pixels
|
|
text_color: RGB color for text
|
|
shadow_color: RGB color for shadow
|
|
shadow_offset: (x, y) offset for shadow
|
|
centered: If True, center text at position
|
|
bold: Use bold font variant
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
font = get_font(font_size, bold=bold)
|
|
|
|
# Calculate position for centering
|
|
if centered:
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
x = position[0] - text_width // 2
|
|
y = position[1] - text_height // 2
|
|
position = (x, y)
|
|
|
|
# Draw shadow
|
|
shadow_pos = (position[0] + shadow_offset[0], position[1] + shadow_offset[1])
|
|
draw.text(shadow_pos, text, fill=shadow_color, font=font)
|
|
|
|
# Draw main text
|
|
draw.text(position, text, fill=text_color, font=font)
|
|
|
|
return frame
|
|
|
|
|
|
def draw_text_with_glow(
|
|
frame: Image.Image,
|
|
text: str,
|
|
position: tuple[int, int],
|
|
font_size: int = 40,
|
|
text_color: tuple[int, int, int] = (255, 255, 255),
|
|
glow_color: tuple[int, int, int] = (255, 200, 0),
|
|
glow_radius: int = 5,
|
|
centered: bool = False,
|
|
bold: bool = True
|
|
) -> Image.Image:
|
|
"""
|
|
Draw text with glow effect for emphasis.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
text: Text to draw
|
|
position: (x, y) position
|
|
font_size: Font size in pixels
|
|
text_color: RGB color for text
|
|
glow_color: RGB color for glow
|
|
glow_radius: Radius of glow effect
|
|
centered: If True, center text at position
|
|
bold: Use bold font variant
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
font = get_font(font_size, bold=bold)
|
|
|
|
# Calculate position for centering
|
|
if centered:
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
x = position[0] - text_width // 2
|
|
y = position[1] - text_height // 2
|
|
position = (x, y)
|
|
|
|
# Draw glow layers with decreasing opacity (simulated with same color at different offsets)
|
|
x, y = position
|
|
for radius in range(glow_radius, 0, -1):
|
|
for offset_x in range(-radius, radius + 1):
|
|
for offset_y in range(-radius, radius + 1):
|
|
if offset_x != 0 or offset_y != 0:
|
|
draw.text((x + offset_x, y + offset_y), text, fill=glow_color, font=font)
|
|
|
|
# Draw main text
|
|
draw.text(position, text, fill=text_color, font=font)
|
|
|
|
return frame
|
|
|
|
|
|
def draw_text_in_box(
|
|
frame: Image.Image,
|
|
text: str,
|
|
position: tuple[int, int],
|
|
font_size: int = 40,
|
|
text_color: tuple[int, int, int] = (255, 255, 255),
|
|
box_color: tuple[int, int, int] = (0, 0, 0),
|
|
box_alpha: float = 0.7,
|
|
padding: int = 10,
|
|
centered: bool = True,
|
|
bold: bool = True
|
|
) -> Image.Image:
|
|
"""
|
|
Draw text in a semi-transparent box for guaranteed readability.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
text: Text to draw
|
|
position: (x, y) position
|
|
font_size: Font size in pixels
|
|
text_color: RGB color for text
|
|
box_color: RGB color for background box
|
|
box_alpha: Opacity of box (0.0-1.0)
|
|
padding: Padding around text in pixels
|
|
centered: If True, center at position
|
|
bold: Use bold font variant
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
# Create a separate layer for the box with alpha
|
|
overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
|
|
draw_overlay = ImageDraw.Draw(overlay)
|
|
draw = ImageDraw.Draw(frame)
|
|
|
|
font = get_font(font_size, bold=bold)
|
|
|
|
# Get text dimensions
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
|
|
# Calculate box position
|
|
if centered:
|
|
box_x = position[0] - (text_width + padding * 2) // 2
|
|
box_y = position[1] - (text_height + padding * 2) // 2
|
|
text_x = position[0] - text_width // 2
|
|
text_y = position[1] - text_height // 2
|
|
else:
|
|
box_x = position[0] - padding
|
|
box_y = position[1] - padding
|
|
text_x = position[0]
|
|
text_y = position[1]
|
|
|
|
# Draw semi-transparent box
|
|
box_coords = [
|
|
box_x,
|
|
box_y,
|
|
box_x + text_width + padding * 2,
|
|
box_y + text_height + padding * 2
|
|
]
|
|
alpha_value = int(255 * box_alpha)
|
|
draw_overlay.rectangle(box_coords, fill=(*box_color, alpha_value))
|
|
|
|
# Composite overlay onto frame
|
|
frame_rgba = frame.convert('RGBA')
|
|
frame_rgba = Image.alpha_composite(frame_rgba, overlay)
|
|
frame = frame_rgba.convert('RGB')
|
|
|
|
# Draw text on top
|
|
draw = ImageDraw.Draw(frame)
|
|
draw.text((text_x, text_y), text, fill=text_color, font=font)
|
|
|
|
return frame
|
|
|
|
|
|
def get_text_size(text: str, font_size: int, bold: bool = True) -> tuple[int, int]:
|
|
"""
|
|
Get the dimensions of text without drawing it.
|
|
|
|
Args:
|
|
text: Text to measure
|
|
font_size: Font size in pixels
|
|
bold: Use bold font variant
|
|
|
|
Returns:
|
|
(width, height) tuple
|
|
"""
|
|
font = get_font(font_size, bold=bold)
|
|
# Create temporary image to measure
|
|
temp_img = Image.new('RGB', (1, 1))
|
|
draw = ImageDraw.Draw(temp_img)
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
width = bbox[2] - bbox[0]
|
|
height = bbox[3] - bbox[1]
|
|
return (width, height)
|
|
|
|
|
|
def get_optimal_font_size(text: str, max_width: int, max_height: int,
|
|
start_size: int = 60) -> int:
|
|
"""
|
|
Find the largest font size that fits within given dimensions.
|
|
|
|
Args:
|
|
text: Text to size
|
|
max_width: Maximum width in pixels
|
|
max_height: Maximum height in pixels
|
|
start_size: Starting font size to try
|
|
|
|
Returns:
|
|
Optimal font size
|
|
"""
|
|
font_size = start_size
|
|
while font_size > 10:
|
|
width, height = get_text_size(text, font_size)
|
|
if width <= max_width and height <= max_height:
|
|
return font_size
|
|
font_size -= 2
|
|
return 10 # Minimum font size
|
|
|
|
|
|
def scale_font_for_frame(base_size: int, frame_width: int, frame_height: int) -> int:
|
|
"""
|
|
Scale font size proportionally to frame dimensions.
|
|
|
|
Useful for maintaining relative text size across different GIF dimensions.
|
|
|
|
Args:
|
|
base_size: Base font size for 480x480 frame
|
|
frame_width: Actual frame width
|
|
frame_height: Actual frame height
|
|
|
|
Returns:
|
|
Scaled font size
|
|
"""
|
|
# Use average dimension for scaling
|
|
avg_dimension = (frame_width + frame_height) / 2
|
|
base_dimension = 480 # Reference dimension
|
|
scale_factor = avg_dimension / base_dimension
|
|
return max(10, int(base_size * scale_factor)) |