Initial commit
This commit is contained in:
357
core/typography.py
Executable file
357
core/typography.py
Executable file
@@ -0,0 +1,357 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user