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

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 

8 

9# Set up logging for font loading 

10logger = logging.getLogger(__name__) 

11 

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] = {} 

15 

16# Cache for bundled font path to avoid repeated filesystem lookups 

17_BUNDLED_FONT_PATH: Optional[str] = None 

18 

19# Cache for bundled fonts directory 

20_BUNDLED_FONTS_DIR: Optional[str] = None 

21 

22 

23class FontWeight(Enum): 

24 NORMAL = "normal" 

25 BOLD = "bold" 

26 

27 

28class FontStyle(Enum): 

29 NORMAL = "normal" 

30 ITALIC = "italic" 

31 

32 

33class TextDecoration(Enum): 

34 NONE = "none" 

35 UNDERLINE = "underline" 

36 STRIKETHROUGH = "strikethrough" 

37 

38 

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 

44 

45 

46def get_bundled_fonts_dir(): 

47 """ 

48 Get the directory containing bundled fonts (cached). 

49 

50 Returns: 

51 str: Path to the fonts directory, or None if not found 

52 """ 

53 global _BUNDLED_FONTS_DIR 

54 

55 # Return cached path if available 

56 if _BUNDLED_FONTS_DIR is not None: 

57 return _BUNDLED_FONTS_DIR 

58 

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

62 

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 

71 

72 

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. 

80 

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) 

85 

86 Returns: 

87 str: Full path to the font file, or None if not found 

88 

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 

97 

98 # Map font parameters to filename 

99 family_map = { 

100 BundledFont.SANS: "DejaVuSans", 

101 BundledFont.SERIF: "DejaVuSerif", 

102 BundledFont.MONOSPACE: "DejaVuSansMono" 

103 } 

104 

105 base_name = family_map.get(family, "DejaVuSans") 

106 

107 # Build the font file name 

108 parts = [base_name] 

109 

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

126 

127 filename = "-".join(parts) + ".ttf" 

128 font_path = os.path.join(fonts_dir, filename) 

129 

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 

136 

137 

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

143 

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. 

156 

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

180 

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. 

194 

195 This is a convenient way to use the bundled DejaVu fonts without needing to 

196 specify paths manually. 

197 

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. 

208 

209 Returns: 

210 Font object configured with the bundled font 

211 

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 ) 

231 

232 def _get_bundled_font_path(self): 

233 """Get the path to the bundled font (cached)""" 

234 global _BUNDLED_FONT_PATH 

235 

236 # Return cached path if available 

237 if _BUNDLED_FONT_PATH is not None: 

238 return _BUNDLED_FONT_PATH 

239 

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

246 

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 ) 

253 

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 

263 

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

270 

271 # Create cache key 

272 cache_key = (font_path_to_use, self._font_size) 

273 

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 

279 

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

293 

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

306 

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

310 

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 

316 

317 @property 

318 def font(self): 

319 """Get the PIL ImageFont object""" 

320 return self._font 

321 

322 @property 

323 def font_size(self): 

324 """Get the font size""" 

325 return self._font_size 

326 

327 @property 

328 def colour(self): 

329 """Get the text color""" 

330 return self._colour 

331 

332 @property 

333 def color(self): 

334 """Alias for colour (American spelling)""" 

335 return self._colour 

336 

337 @property 

338 def background(self): 

339 """Get the background color""" 

340 return self._background 

341 

342 @property 

343 def weight(self): 

344 """Get the font weight""" 

345 return self._weight 

346 

347 @property 

348 def style(self): 

349 """Get the font style""" 

350 return self._style 

351 

352 @property 

353 def decoration(self): 

354 """Get the text decoration""" 

355 return self._decoration 

356 

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 

361 

362 def _with_modified(self, **kwargs): 

363 """ 

364 Internal helper to create a new Font with modified parameters. 

365 

366 This consolidates the duplication across all with_* methods. 

367 

368 Args: 

369 **kwargs: Parameters to override (e.g., font_size=20, colour=(255,0,0)) 

370 

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) 

387 

388 def with_size(self, size: int): 

389 """Create a new Font object with modified size""" 

390 return self._with_modified(font_size=size) 

391 

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) 

395 

396 def with_weight(self, weight: FontWeight): 

397 """Create a new Font object with modified weight""" 

398 return self._with_modified(weight=weight) 

399 

400 def with_style(self, style: FontStyle): 

401 """Create a new Font object with modified style""" 

402 return self._with_modified(style=style) 

403 

404 def with_decoration(self, decoration: TextDecoration): 

405 """Create a new Font object with modified decoration""" 

406 return self._with_modified(decoration=decoration)