Files
2025-11-29 18:16:13 +08:00

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