main
  1#!/usr/bin/env python3
  2"""
  3Typography System - Professional text rendering with outlines, shadows, and effects.
  4
  5This module provides high-quality text rendering that looks crisp and professional
  6in GIFs, with outlines for readability and effects for visual impact.
  7"""
  8
  9from PIL import Image, ImageDraw, ImageFont
 10from typing import Optional
 11
 12
 13# Typography scale - proportional sizing system
 14TYPOGRAPHY_SCALE = {
 15    'h1': 60,      # Large headers
 16    'h2': 48,      # Medium headers
 17    'h3': 36,      # Small headers
 18    'title': 50,   # Title text
 19    'body': 28,    # Body text
 20    'small': 20,   # Small text
 21    'tiny': 16,    # Tiny text
 22}
 23
 24
 25def get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
 26    """
 27    Get a font with fallback support.
 28
 29    Args:
 30        size: Font size in pixels
 31        bold: Use bold variant if available
 32
 33    Returns:
 34        ImageFont object
 35    """
 36    # Try multiple font paths for cross-platform support
 37    font_paths = [
 38        # macOS fonts
 39        "/System/Library/Fonts/Helvetica.ttc",
 40        "/System/Library/Fonts/SF-Pro.ttf",
 41        "/Library/Fonts/Arial Bold.ttf" if bold else "/Library/Fonts/Arial.ttf",
 42        # Linux fonts
 43        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
 44        # Windows fonts
 45        "C:\\Windows\\Fonts\\arialbd.ttf" if bold else "C:\\Windows\\Fonts\\arial.ttf",
 46    ]
 47
 48    for font_path in font_paths:
 49        try:
 50            return ImageFont.truetype(font_path, size)
 51        except:
 52            continue
 53
 54    # Ultimate fallback
 55    return ImageFont.load_default()
 56
 57
 58def draw_text_with_outline(
 59    frame: Image.Image,
 60    text: str,
 61    position: tuple[int, int],
 62    font_size: int = 40,
 63    text_color: tuple[int, int, int] = (255, 255, 255),
 64    outline_color: tuple[int, int, int] = (0, 0, 0),
 65    outline_width: int = 3,
 66    centered: bool = False,
 67    bold: bool = True
 68) -> Image.Image:
 69    """
 70    Draw text with outline for maximum readability.
 71
 72    This is THE most important function for professional-looking text in GIFs.
 73    The outline ensures text is readable on any background.
 74
 75    Args:
 76        frame: PIL Image to draw on
 77        text: Text to draw
 78        position: (x, y) position
 79        font_size: Font size in pixels
 80        text_color: RGB color for text fill
 81        outline_color: RGB color for outline
 82        outline_width: Width of outline in pixels (2-4 recommended)
 83        centered: If True, center text at position
 84        bold: Use bold font variant
 85
 86    Returns:
 87        Modified frame
 88    """
 89    draw = ImageDraw.Draw(frame)
 90    font = get_font(font_size, bold=bold)
 91
 92    # Calculate position for centering
 93    if centered:
 94        bbox = draw.textbbox((0, 0), text, font=font)
 95        text_width = bbox[2] - bbox[0]
 96        text_height = bbox[3] - bbox[1]
 97        x = position[0] - text_width // 2
 98        y = position[1] - text_height // 2
 99        position = (x, y)
100
101    # Draw outline by drawing text multiple times offset in all directions
102    x, y = position
103    for offset_x in range(-outline_width, outline_width + 1):
104        for offset_y in range(-outline_width, outline_width + 1):
105            if offset_x != 0 or offset_y != 0:
106                draw.text((x + offset_x, y + offset_y), text, fill=outline_color, font=font)
107
108    # Draw main text on top
109    draw.text(position, text, fill=text_color, font=font)
110
111    return frame
112
113
114def draw_text_with_shadow(
115    frame: Image.Image,
116    text: str,
117    position: tuple[int, int],
118    font_size: int = 40,
119    text_color: tuple[int, int, int] = (255, 255, 255),
120    shadow_color: tuple[int, int, int] = (0, 0, 0),
121    shadow_offset: tuple[int, int] = (3, 3),
122    centered: bool = False,
123    bold: bool = True
124) -> Image.Image:
125    """
126    Draw text with drop shadow for depth.
127
128    Args:
129        frame: PIL Image to draw on
130        text: Text to draw
131        position: (x, y) position
132        font_size: Font size in pixels
133        text_color: RGB color for text
134        shadow_color: RGB color for shadow
135        shadow_offset: (x, y) offset for shadow
136        centered: If True, center text at position
137        bold: Use bold font variant
138
139    Returns:
140        Modified frame
141    """
142    draw = ImageDraw.Draw(frame)
143    font = get_font(font_size, bold=bold)
144
145    # Calculate position for centering
146    if centered:
147        bbox = draw.textbbox((0, 0), text, font=font)
148        text_width = bbox[2] - bbox[0]
149        text_height = bbox[3] - bbox[1]
150        x = position[0] - text_width // 2
151        y = position[1] - text_height // 2
152        position = (x, y)
153
154    # Draw shadow
155    shadow_pos = (position[0] + shadow_offset[0], position[1] + shadow_offset[1])
156    draw.text(shadow_pos, text, fill=shadow_color, font=font)
157
158    # Draw main text
159    draw.text(position, text, fill=text_color, font=font)
160
161    return frame
162
163
164def draw_text_with_glow(
165    frame: Image.Image,
166    text: str,
167    position: tuple[int, int],
168    font_size: int = 40,
169    text_color: tuple[int, int, int] = (255, 255, 255),
170    glow_color: tuple[int, int, int] = (255, 200, 0),
171    glow_radius: int = 5,
172    centered: bool = False,
173    bold: bool = True
174) -> Image.Image:
175    """
176    Draw text with glow effect for emphasis.
177
178    Args:
179        frame: PIL Image to draw on
180        text: Text to draw
181        position: (x, y) position
182        font_size: Font size in pixels
183        text_color: RGB color for text
184        glow_color: RGB color for glow
185        glow_radius: Radius of glow effect
186        centered: If True, center text at position
187        bold: Use bold font variant
188
189    Returns:
190        Modified frame
191    """
192    draw = ImageDraw.Draw(frame)
193    font = get_font(font_size, bold=bold)
194
195    # Calculate position for centering
196    if centered:
197        bbox = draw.textbbox((0, 0), text, font=font)
198        text_width = bbox[2] - bbox[0]
199        text_height = bbox[3] - bbox[1]
200        x = position[0] - text_width // 2
201        y = position[1] - text_height // 2
202        position = (x, y)
203
204    # Draw glow layers with decreasing opacity (simulated with same color at different offsets)
205    x, y = position
206    for radius in range(glow_radius, 0, -1):
207        for offset_x in range(-radius, radius + 1):
208            for offset_y in range(-radius, radius + 1):
209                if offset_x != 0 or offset_y != 0:
210                    draw.text((x + offset_x, y + offset_y), text, fill=glow_color, font=font)
211
212    # Draw main text
213    draw.text(position, text, fill=text_color, font=font)
214
215    return frame
216
217
218def draw_text_in_box(
219    frame: Image.Image,
220    text: str,
221    position: tuple[int, int],
222    font_size: int = 40,
223    text_color: tuple[int, int, int] = (255, 255, 255),
224    box_color: tuple[int, int, int] = (0, 0, 0),
225    box_alpha: float = 0.7,
226    padding: int = 10,
227    centered: bool = True,
228    bold: bool = True
229) -> Image.Image:
230    """
231    Draw text in a semi-transparent box for guaranteed readability.
232
233    Args:
234        frame: PIL Image to draw on
235        text: Text to draw
236        position: (x, y) position
237        font_size: Font size in pixels
238        text_color: RGB color for text
239        box_color: RGB color for background box
240        box_alpha: Opacity of box (0.0-1.0)
241        padding: Padding around text in pixels
242        centered: If True, center at position
243        bold: Use bold font variant
244
245    Returns:
246        Modified frame
247    """
248    # Create a separate layer for the box with alpha
249    overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
250    draw_overlay = ImageDraw.Draw(overlay)
251    draw = ImageDraw.Draw(frame)
252
253    font = get_font(font_size, bold=bold)
254
255    # Get text dimensions
256    bbox = draw.textbbox((0, 0), text, font=font)
257    text_width = bbox[2] - bbox[0]
258    text_height = bbox[3] - bbox[1]
259
260    # Calculate box position
261    if centered:
262        box_x = position[0] - (text_width + padding * 2) // 2
263        box_y = position[1] - (text_height + padding * 2) // 2
264        text_x = position[0] - text_width // 2
265        text_y = position[1] - text_height // 2
266    else:
267        box_x = position[0] - padding
268        box_y = position[1] - padding
269        text_x = position[0]
270        text_y = position[1]
271
272    # Draw semi-transparent box
273    box_coords = [
274        box_x,
275        box_y,
276        box_x + text_width + padding * 2,
277        box_y + text_height + padding * 2
278    ]
279    alpha_value = int(255 * box_alpha)
280    draw_overlay.rectangle(box_coords, fill=(*box_color, alpha_value))
281
282    # Composite overlay onto frame
283    frame_rgba = frame.convert('RGBA')
284    frame_rgba = Image.alpha_composite(frame_rgba, overlay)
285    frame = frame_rgba.convert('RGB')
286
287    # Draw text on top
288    draw = ImageDraw.Draw(frame)
289    draw.text((text_x, text_y), text, fill=text_color, font=font)
290
291    return frame
292
293
294def get_text_size(text: str, font_size: int, bold: bool = True) -> tuple[int, int]:
295    """
296    Get the dimensions of text without drawing it.
297
298    Args:
299        text: Text to measure
300        font_size: Font size in pixels
301        bold: Use bold font variant
302
303    Returns:
304        (width, height) tuple
305    """
306    font = get_font(font_size, bold=bold)
307    # Create temporary image to measure
308    temp_img = Image.new('RGB', (1, 1))
309    draw = ImageDraw.Draw(temp_img)
310    bbox = draw.textbbox((0, 0), text, font=font)
311    width = bbox[2] - bbox[0]
312    height = bbox[3] - bbox[1]
313    return (width, height)
314
315
316def get_optimal_font_size(text: str, max_width: int, max_height: int,
317                          start_size: int = 60) -> int:
318    """
319    Find the largest font size that fits within given dimensions.
320
321    Args:
322        text: Text to size
323        max_width: Maximum width in pixels
324        max_height: Maximum height in pixels
325        start_size: Starting font size to try
326
327    Returns:
328        Optimal font size
329    """
330    font_size = start_size
331    while font_size > 10:
332        width, height = get_text_size(text, font_size)
333        if width <= max_width and height <= max_height:
334            return font_size
335        font_size -= 2
336    return 10  # Minimum font size
337
338
339def scale_font_for_frame(base_size: int, frame_width: int, frame_height: int) -> int:
340    """
341    Scale font size proportionally to frame dimensions.
342
343    Useful for maintaining relative text size across different GIF dimensions.
344
345    Args:
346        base_size: Base font size for 480x480 frame
347        frame_width: Actual frame width
348        frame_height: Actual frame height
349
350    Returns:
351        Scaled font size
352    """
353    # Use average dimension for scaling
354    avg_dimension = (frame_width + frame_height) / 2
355    base_dimension = 480  # Reference dimension
356    scale_factor = avg_dimension / base_dimension
357    return max(10, int(base_size * scale_factor))