Coverage for pyWebLayout/style/fonts.py: 66%
161 statements
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
1# this should contain classes for how different object can be rendered,
2# e.g. bold, italic, regular
3from PIL import ImageFont
4from enum import Enum
5from typing import Tuple, Optional, Dict
6import os
7import logging
9# Set up logging for font loading
10logger = logging.getLogger(__name__)
12# Global cache for PIL ImageFont objects to avoid reloading fonts from disk
13# Key: (font_path, font_size), Value: PIL ImageFont object
14_FONT_CACHE: Dict[Tuple[Optional[str], int], ImageFont.FreeTypeFont] = {}
16# Cache for bundled font path to avoid repeated filesystem lookups
17_BUNDLED_FONT_PATH: Optional[str] = None
19# Cache for bundled fonts directory
20_BUNDLED_FONTS_DIR: Optional[str] = None
23class FontWeight(Enum):
24 NORMAL = "normal"
25 BOLD = "bold"
28class FontStyle(Enum):
29 NORMAL = "normal"
30 ITALIC = "italic"
33class TextDecoration(Enum):
34 NONE = "none"
35 UNDERLINE = "underline"
36 STRIKETHROUGH = "strikethrough"
39class BundledFont(Enum):
40 """Bundled font families available in pyWebLayout"""
41 SANS = "sans" # DejaVu Sans - modern sans-serif
42 SERIF = "serif" # DejaVu Serif - classic serif
43 MONOSPACE = "monospace" # DejaVu Sans Mono - fixed-width
46def get_bundled_fonts_dir():
47 """
48 Get the directory containing bundled fonts (cached).
50 Returns:
51 str: Path to the fonts directory, or None if not found
52 """
53 global _BUNDLED_FONTS_DIR
55 # Return cached path if available
56 if _BUNDLED_FONTS_DIR is not None:
57 return _BUNDLED_FONTS_DIR
59 # First time - determine the path and cache it
60 current_dir = os.path.dirname(os.path.abspath(__file__))
61 fonts_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
63 if os.path.exists(fonts_dir) and os.path.isdir(fonts_dir):
64 _BUNDLED_FONTS_DIR = fonts_dir
65 logger.debug(f"Found bundled fonts directory at: {fonts_dir}")
66 return fonts_dir
67 else:
68 logger.warning(f"Bundled fonts directory not found at: {fonts_dir}")
69 _BUNDLED_FONTS_DIR = "" # Empty string to indicate "checked but not found"
70 return None
73def get_bundled_font_path(
74 family: BundledFont = BundledFont.SANS,
75 weight: FontWeight = FontWeight.NORMAL,
76 style: FontStyle = FontStyle.NORMAL
77) -> Optional[str]:
78 """
79 Get the path to a specific bundled font file.
81 Args:
82 family: The font family (SANS, SERIF, or MONOSPACE)
83 weight: The font weight (NORMAL or BOLD)
84 style: The font style (NORMAL or ITALIC)
86 Returns:
87 str: Full path to the font file, or None if not found
89 Example:
90 >>> # Get bold italic sans font
91 >>> path = get_bundled_font_path(BundledFont.SANS, FontWeight.BOLD, FontStyle.ITALIC)
92 >>> font = Font(font_path=path, font_size=16)
93 """
94 fonts_dir = get_bundled_fonts_dir()
95 if not fonts_dir:
96 return None
98 # Map font parameters to filename
99 family_map = {
100 BundledFont.SANS: "DejaVuSans",
101 BundledFont.SERIF: "DejaVuSerif",
102 BundledFont.MONOSPACE: "DejaVuSansMono"
103 }
105 base_name = family_map.get(family, "DejaVuSans")
107 # Build the font file name
108 parts = [base_name]
110 if weight == FontWeight.BOLD and style == FontStyle.ITALIC:
111 # Special case: both bold and italic
112 if family == BundledFont.MONOSPACE:
113 parts.append("BoldOblique")
114 elif family == BundledFont.SERIF:
115 parts.append("BoldItalic")
116 else: # SANS
117 parts.append("BoldOblique")
118 elif weight == FontWeight.BOLD:
119 parts.append("Bold")
120 elif style == FontStyle.ITALIC:
121 # Italic naming differs by family
122 if family == BundledFont.MONOSPACE or family == BundledFont.SANS:
123 parts.append("Oblique")
124 else: # SERIF
125 parts.append("Italic")
127 filename = "-".join(parts) + ".ttf"
128 font_path = os.path.join(fonts_dir, filename)
130 if os.path.exists(font_path):
131 logger.debug(f"Found bundled font: {filename}")
132 return font_path
133 else:
134 logger.warning(f"Bundled font not found: {filename}")
135 return None
138class Font:
139 """
140 Font class to manage text rendering properties including font face, size, color, and styling.
141 This class is used by the text renderer to determine how to render text.
142 """
144 def __init__(self,
145 font_path: Optional[str] = None,
146 font_size: int = 16,
147 colour: Tuple[int, int, int] = (0, 0, 0),
148 weight: FontWeight = FontWeight.NORMAL,
149 style: FontStyle = FontStyle.NORMAL,
150 decoration: TextDecoration = TextDecoration.NONE,
151 background: Optional[Tuple[int, int, int, int]] = None,
152 language="en_EN",
153 min_hyphenation_width: Optional[int] = None):
154 """
155 Initialize a Font object with the specified properties.
157 Args:
158 font_path: Path to the font file (.ttf, .otf). If None, uses default bundled font.
159 font_size: Size of the font in points.
160 colour: RGB color tuple for the text.
161 weight: Font weight (normal or bold).
162 style: Font style (normal or italic).
163 decoration: Text decoration (none, underline, or strikethrough).
164 background: RGBA background color for the text. If None, transparent background.
165 language: Language code for hyphenation and text processing.
166 min_hyphenation_width: Minimum width in pixels required for hyphenation to be considered.
167 If None, defaults to 4 times the font size.
168 """
169 self._font_path = font_path
170 self._font_size = font_size
171 self._colour = colour
172 self._weight = weight
173 self._style = style
174 self._decoration = decoration
175 self._background = background if background else (255, 255, 255, 0)
176 self.language = language
177 self._min_hyphenation_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
178 # Load the font file or use default
179 self._load_font()
181 @classmethod
182 def from_family(cls,
183 family: BundledFont = BundledFont.SANS,
184 font_size: int = 16,
185 colour: Tuple[int, int, int] = (0, 0, 0),
186 weight: FontWeight = FontWeight.NORMAL,
187 style: FontStyle = FontStyle.NORMAL,
188 decoration: TextDecoration = TextDecoration.NONE,
189 background: Optional[Tuple[int, int, int, int]] = None,
190 language: str = "en_EN",
191 min_hyphenation_width: Optional[int] = None) -> 'Font':
192 """
193 Create a Font using a bundled font family.
195 This is a convenient way to use the bundled DejaVu fonts without needing to
196 specify paths manually.
198 Args:
199 family: The font family to use (SANS, SERIF, or MONOSPACE)
200 font_size: Size of the font in points.
201 colour: RGB color tuple for the text.
202 weight: Font weight (normal or bold).
203 style: Font style (normal or italic).
204 decoration: Text decoration (none, underline, or strikethrough).
205 background: RGBA background color for the text. If None, transparent background.
206 language: Language code for hyphenation and text processing.
207 min_hyphenation_width: Minimum width in pixels required for hyphenation.
209 Returns:
210 Font object configured with the bundled font
212 Example:
213 >>> # Create a bold serif font
214 >>> font = Font.from_family(BundledFont.SERIF, font_size=18, weight=FontWeight.BOLD)
215 >>>
216 >>> # Create an italic monospace font
217 >>> code_font = Font.from_family(BundledFont.MONOSPACE, style=FontStyle.ITALIC)
218 """
219 font_path = get_bundled_font_path(family, weight, style)
220 return cls(
221 font_path=font_path,
222 font_size=font_size,
223 colour=colour,
224 weight=weight,
225 style=style,
226 decoration=decoration,
227 background=background,
228 language=language,
229 min_hyphenation_width=min_hyphenation_width
230 )
232 def _get_bundled_font_path(self):
233 """Get the path to the bundled font (cached)"""
234 global _BUNDLED_FONT_PATH
236 # Return cached path if available
237 if _BUNDLED_FONT_PATH is not None:
238 return _BUNDLED_FONT_PATH
240 # First time - determine the path and cache it
241 # Get the directory containing this module
242 current_dir = os.path.dirname(os.path.abspath(__file__))
243 # Navigate to the assets/fonts directory
244 assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
245 bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf')
247 logger.debug(f"Font loading: current_dir = {current_dir}")
248 logger.debug(f"Font loading: assets_dir = {assets_dir}")
249 logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}")
250 logger.debug(
251 f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}"
252 )
254 if os.path.exists(bundled_font_path): 254 ↛ 259line 254 didn't jump to line 259 because the condition on line 254 was always true
255 logger.info(f"Found bundled font at: {bundled_font_path}")
256 _BUNDLED_FONT_PATH = bundled_font_path
257 return bundled_font_path
258 else:
259 logger.warning(f"Bundled font not found at: {bundled_font_path}")
260 # Cache None to indicate bundled font is not available
261 _BUNDLED_FONT_PATH = "" # Use empty string instead of None to differentiate from "not checked yet"
262 return None
264 def _load_font(self):
265 """Load the font using PIL's ImageFont with consistent bundled font and caching"""
266 # Determine the actual font path to use
267 font_path_to_use = self._font_path
268 if not font_path_to_use:
269 font_path_to_use = self._get_bundled_font_path()
271 # Create cache key
272 cache_key = (font_path_to_use, self._font_size)
274 # Check if font is already cached
275 if cache_key in _FONT_CACHE:
276 self._font = _FONT_CACHE[cache_key]
277 logger.debug(f"Reusing cached font: {font_path_to_use} at size {self._font_size}")
278 return
280 # Font not cached, need to load it
281 try:
282 if self._font_path:
283 # Use specified font path
284 logger.info(f"Loading font from specified path: {self._font_path}")
285 self._font = ImageFont.truetype(
286 self._font_path,
287 self._font_size
288 )
289 logger.info(f"Successfully loaded font from: {self._font_path}")
290 else:
291 # Use bundled font for consistency across environments
292 bundled_font_path = self._get_bundled_font_path()
294 if bundled_font_path: 294 ↛ 303line 294 didn't jump to line 303 because the condition on line 294 was always true
295 logger.info(f"Loading bundled font from: {bundled_font_path}")
296 self._font = ImageFont.truetype(bundled_font_path, self._font_size)
297 logger.info(
298 f"Successfully loaded bundled font at size {self._font_size}"
299 )
300 else:
301 # Only fall back to PIL's default font if bundled font is not
302 # available
303 logger.warning(
304 "Bundled font not available, falling back to PIL default font")
305 self._font = ImageFont.load_default()
307 # Cache the loaded font
308 _FONT_CACHE[cache_key] = self._font
309 logger.debug(f"Cached font: {font_path_to_use} at size {self._font_size}")
311 except Exception as e:
312 # Ultimate fallback to default font
313 logger.error(f"Failed to load font: {e}, falling back to PIL default font")
314 self._font = ImageFont.load_default()
315 # Don't cache the default font as it doesn't have a path
317 @property
318 def font(self):
319 """Get the PIL ImageFont object"""
320 return self._font
322 @property
323 def font_size(self):
324 """Get the font size"""
325 return self._font_size
327 @property
328 def colour(self):
329 """Get the text color"""
330 return self._colour
332 @property
333 def color(self):
334 """Alias for colour (American spelling)"""
335 return self._colour
337 @property
338 def background(self):
339 """Get the background color"""
340 return self._background
342 @property
343 def weight(self):
344 """Get the font weight"""
345 return self._weight
347 @property
348 def style(self):
349 """Get the font style"""
350 return self._style
352 @property
353 def decoration(self):
354 """Get the text decoration"""
355 return self._decoration
357 @property
358 def min_hyphenation_width(self):
359 """Get the minimum width required for hyphenation to be considered"""
360 return self._min_hyphenation_width
362 def _with_modified(self, **kwargs):
363 """
364 Internal helper to create a new Font with modified parameters.
366 This consolidates the duplication across all with_* methods.
368 Args:
369 **kwargs: Parameters to override (e.g., font_size=20, colour=(255,0,0))
371 Returns:
372 New Font object with modified parameters
373 """
374 params = {
375 'font_path': self._font_path,
376 'font_size': self._font_size,
377 'colour': self._colour,
378 'weight': self._weight,
379 'style': self._style,
380 'decoration': self._decoration,
381 'background': self._background,
382 'language': self.language,
383 'min_hyphenation_width': self._min_hyphenation_width
384 }
385 params.update(kwargs)
386 return Font(**params)
388 def with_size(self, size: int):
389 """Create a new Font object with modified size"""
390 return self._with_modified(font_size=size)
392 def with_colour(self, colour: Tuple[int, int, int]):
393 """Create a new Font object with modified colour"""
394 return self._with_modified(colour=colour)
396 def with_weight(self, weight: FontWeight):
397 """Create a new Font object with modified weight"""
398 return self._with_modified(weight=weight)
400 def with_style(self, style: FontStyle):
401 """Create a new Font object with modified style"""
402 return self._with_modified(style=style)
404 def with_decoration(self, decoration: TextDecoration):
405 """Create a new Font object with modified decoration"""
406 return self._with_modified(decoration=decoration)