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