Coverage for pyWebLayout/style/concrete_style.py: 63%

207 statements  

« prev     ^ index     » next       coverage.py v7.11.2, created at 2025-11-12 12:02 +0000

1""" 

2Concrete style system for actual rendering parameters. 

3 

4This module converts abstract styles to concrete rendering parameters based on 

5user preferences, device capabilities, and rendering context. 

6""" 

7 

8from typing import Dict, Optional, Tuple, Union 

9from dataclasses import dataclass 

10from .abstract_style import AbstractStyle, FontFamily, FontSize 

11from pyWebLayout.style.alignment import Alignment as TextAlign 

12from .fonts import Font, FontWeight, FontStyle, TextDecoration 

13 

14 

15@dataclass(frozen=True) 

16class RenderingContext: 

17 """ 

18 Context information for style resolution. 

19 Contains user preferences and device capabilities. 

20 """ 

21 

22 # User preferences 

23 base_font_size: int = 16 # Base font size in points 

24 font_scale_factor: float = 1.0 # Global font scaling 

25 preferred_serif_font: Optional[str] = None 

26 preferred_sans_serif_font: Optional[str] = None 

27 preferred_monospace_font: Optional[str] = None 

28 

29 # Device/environment info 

30 dpi: int = 96 # Dots per inch 

31 available_width: Optional[int] = None # Available width in pixels 

32 available_height: Optional[int] = None # Available height in pixels 

33 

34 # Accessibility preferences 

35 high_contrast: bool = False 

36 large_text: bool = False 

37 reduce_motion: bool = False 

38 

39 # Language and locale 

40 default_language: str = "en-US" 

41 

42 

43@dataclass(frozen=True) 

44class ConcreteStyle: 

45 """ 

46 Concrete representation of text styling with actual rendering parameters. 

47 

48 This contains the resolved font files, pixel sizes, actual colors, etc. 

49 that will be used for rendering. This is also hashable for efficient caching. 

50 """ 

51 

52 # Concrete font properties 

53 font_path: Optional[str] = None 

54 font_size: int = 16 # Always in points/pixels 

55 color: Tuple[int, int, int] = (0, 0, 0) # Always RGB 

56 background_color: Optional[Tuple[int, int, int, int]] = None # Always RGBA or None 

57 

58 # Font attributes 

59 weight: FontWeight = FontWeight.NORMAL 

60 style: FontStyle = FontStyle.NORMAL 

61 decoration: TextDecoration = TextDecoration.NONE 

62 

63 # Layout properties 

64 text_align: TextAlign = TextAlign.LEFT 

65 line_height: float = 1.0 # Multiplier 

66 letter_spacing: float = 0.0 # In pixels 

67 word_spacing: float = 0.0 # In pixels 

68 word_spacing_min: float = 0.0 # Minimum word spacing in pixels 

69 word_spacing_max: float = 0.0 # Maximum word spacing in pixels 

70 

71 # Language and locale 

72 language: str = "en-US" 

73 min_hyphenation_width: int = 64 # In pixels 

74 

75 # Reference to source abstract style 

76 abstract_style: Optional[AbstractStyle] = None 

77 

78 def create_font(self) -> Font: 

79 """Create a Font object from this concrete style.""" 

80 return Font( 

81 font_path=self.font_path, 

82 font_size=self.font_size, 

83 colour=self.color, 

84 weight=self.weight, 

85 style=self.style, 

86 decoration=self.decoration, 

87 background=self.background_color, 

88 language=self.language, 

89 min_hyphenation_width=self.min_hyphenation_width 

90 ) 

91 

92 

93class StyleResolver: 

94 """ 

95 Resolves abstract styles to concrete styles based on rendering context. 

96 

97 This class handles the conversion from semantic styling intent to actual 

98 rendering parameters, applying user preferences and device capabilities. 

99 """ 

100 

101 def __init__(self, context: RenderingContext): 

102 """ 

103 Initialize the style resolver with a rendering context. 

104 

105 Args: 

106 context: RenderingContext with user preferences and device info 

107 """ 

108 self.context = context 

109 self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {} 

110 

111 # Font size mapping for semantic sizes 

112 self._semantic_font_sizes = { 

113 FontSize.XX_SMALL: 0.6, 

114 FontSize.X_SMALL: 0.75, 

115 FontSize.SMALL: 0.89, 

116 FontSize.MEDIUM: 1.0, 

117 FontSize.LARGE: 1.2, 

118 FontSize.X_LARGE: 1.5, 

119 FontSize.XX_LARGE: 2.0, 

120 } 

121 

122 # Color name mapping 

123 self._color_names = { 

124 "black": (0, 0, 0), 

125 "white": (255, 255, 255), 

126 "red": (255, 0, 0), 

127 "green": (0, 128, 0), 

128 "blue": (0, 0, 255), 

129 "yellow": (255, 255, 0), 

130 "cyan": (0, 255, 255), 

131 "magenta": (255, 0, 255), 

132 "silver": (192, 192, 192), 

133 "gray": (128, 128, 128), 

134 "maroon": (128, 0, 0), 

135 "olive": (128, 128, 0), 

136 "lime": (0, 255, 0), 

137 "aqua": (0, 255, 255), 

138 "teal": (0, 128, 128), 

139 "navy": (0, 0, 128), 

140 "fuchsia": (255, 0, 255), 

141 "purple": (128, 0, 128), 

142 } 

143 

144 def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle: 

145 """ 

146 Resolve an abstract style to a concrete style. 

147 

148 Args: 

149 abstract_style: AbstractStyle to resolve 

150 

151 Returns: 

152 ConcreteStyle with concrete rendering parameters 

153 """ 

154 # Check cache first 

155 if abstract_style in self._concrete_cache: 

156 return self._concrete_cache[abstract_style] 

157 

158 # Resolve each property 

159 font_path = self._resolve_font_path(abstract_style.font_family) 

160 font_size = self._resolve_font_size(abstract_style.font_size) 

161 # Ensure font_size is always an int before using in arithmetic 

162 font_size = int(font_size) 

163 color = self._resolve_color(abstract_style.color) 

164 background_color = self._resolve_background_color( 

165 abstract_style.background_color) 

166 line_height = self._resolve_line_height(abstract_style.line_height) 

167 letter_spacing = self._resolve_letter_spacing( 

168 abstract_style.letter_spacing, font_size) 

169 word_spacing = self._resolve_word_spacing( 

170 abstract_style.word_spacing, font_size) 

171 word_spacing_min = self._resolve_word_spacing( 

172 abstract_style.word_spacing_min, font_size) 

173 word_spacing_max = self._resolve_word_spacing( 

174 abstract_style.word_spacing_max, font_size) 

175 min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels 

176 

177 # Apply default logic for word spacing constraints 

178 if word_spacing_min == 0.0 and word_spacing_max == 0.0: 

179 # If no constraints specified, use base word_spacing as reference 

180 if word_spacing > 0.0: 

181 word_spacing_min = word_spacing 

182 word_spacing_max = word_spacing * 2 

183 else: 

184 # Default constraints when no word spacing is specified 

185 word_spacing_min = 2.0 # Minimum 2 pixels 

186 word_spacing_max = font_size * 0.5 # Maximum 50% of font size 

187 elif word_spacing_min == 0.0: 

188 # Only max specified, use base word_spacing or min default 

189 word_spacing_min = max(word_spacing, 2.0) 

190 elif word_spacing_max == 0.0: 

191 # Only min specified, use base word_spacing or reasonable multiple 

192 word_spacing_max = max(word_spacing, word_spacing_min * 2) 

193 

194 # Create concrete style 

195 concrete_style = ConcreteStyle( 

196 font_path=font_path, 

197 font_size=font_size, 

198 color=color, 

199 background_color=background_color, 

200 weight=abstract_style.font_weight, 

201 style=abstract_style.font_style, 

202 decoration=abstract_style.text_decoration, 

203 text_align=abstract_style.text_align, 

204 line_height=line_height, 

205 letter_spacing=letter_spacing, 

206 word_spacing=word_spacing, 

207 word_spacing_min=word_spacing_min, 

208 word_spacing_max=word_spacing_max, 

209 language=abstract_style.language, 

210 min_hyphenation_width=min_hyphenation_width, 

211 abstract_style=abstract_style 

212 ) 

213 

214 # Cache and return 

215 self._concrete_cache[abstract_style] = concrete_style 

216 return concrete_style 

217 

218 def _resolve_font_path(self, font_family: FontFamily) -> Optional[str]: 

219 """Resolve font family to actual font file path.""" 

220 if font_family == FontFamily.SERIF: 220 ↛ 222line 220 didn't jump to line 222 because the condition on line 220 was always true

221 return self.context.preferred_serif_font 

222 elif font_family == FontFamily.SANS_SERIF: 

223 return self.context.preferred_sans_serif_font 

224 elif font_family == FontFamily.MONOSPACE: 

225 return self.context.preferred_monospace_font 

226 else: 

227 # For cursive and fantasy, fall back to sans-serif 

228 return self.context.preferred_sans_serif_font 

229 

230 def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int: 

231 """Resolve font size to actual pixel/point size.""" 

232 # Ensure we handle FontSize enums properly 

233 if isinstance(font_size, FontSize): 

234 # Semantic size, convert to multiplier 

235 multiplier = self._semantic_font_sizes.get(font_size, 1.0) 

236 base_size = int(self.context.base_font_size * multiplier) 

237 elif isinstance(font_size, int): 237 ↛ 242line 237 didn't jump to line 242 because the condition on line 237 was always true

238 # Already a concrete size, apply scaling 

239 base_size = font_size 

240 else: 

241 # Fallback for any other type - try to convert to int 

242 try: 

243 base_size = int(font_size) 

244 except (ValueError, TypeError): 

245 # If conversion fails, use default 

246 base_size = self.context.base_font_size 

247 

248 # Apply global font scaling 

249 final_size = int(base_size * self.context.font_scale_factor) 

250 

251 # Apply accessibility adjustments 

252 if self.context.large_text: 

253 final_size = int(final_size * 1.2) 

254 

255 # Ensure we always return an int, minimum 8pt font 

256 return max(int(final_size), 8) 

257 

258 def _resolve_color( 

259 self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]: 

260 """Resolve color to RGB tuple.""" 

261 if isinstance(color, tuple): 

262 return color 

263 

264 if isinstance(color, str): 264 ↛ 299line 264 didn't jump to line 299 because the condition on line 264 was always true

265 # Check if it's a named color 

266 if color.lower() in self._color_names: 

267 base_color = self._color_names[color.lower()] 

268 elif color.startswith('#'): 268 ↛ 285line 268 didn't jump to line 285 because the condition on line 268 was always true

269 # Parse hex color 

270 try: 

271 hex_color = color[1:] 

272 if len(hex_color) == 3: 272 ↛ 274line 272 didn't jump to line 274 because the condition on line 272 was never true

273 # Short hex format #RGB -> #RRGGBB 

274 hex_color = ''.join(c * 2 for c in hex_color) 

275 if len(hex_color) == 6: 275 ↛ 281line 275 didn't jump to line 281 because the condition on line 275 was always true

276 r = int(hex_color[0:2], 16) 

277 g = int(hex_color[2:4], 16) 

278 b = int(hex_color[4:6], 16) 

279 base_color = (r, g, b) 

280 else: 

281 base_color = (0, 0, 0) # Fallback to black 

282 except ValueError: 

283 base_color = (0, 0, 0) # Fallback to black 

284 else: 

285 base_color = (0, 0, 0) # Fallback to black 

286 

287 # Apply high contrast if needed 

288 if self.context.high_contrast: 288 ↛ 290line 288 didn't jump to line 290 because the condition on line 288 was never true

289 # Simple high contrast: make dark colors black, light colors white 

290 r, g, b = base_color 

291 brightness = (r + g + b) / 3 

292 if brightness < 128: 

293 base_color = (0, 0, 0) # Black 

294 else: 

295 base_color = (255, 255, 255) # White 

296 

297 return base_color 

298 

299 return (0, 0, 0) # Fallback to black 

300 

301 def _resolve_background_color(self, 

302 bg_color: Optional[Union[str, 

303 Tuple[int, 

304 int, 

305 int, 

306 int]]]) -> Optional[Tuple[int, 

307 int, 

308 int, 

309 int]]: 

310 """Resolve background color to RGBA tuple or None.""" 

311 if bg_color is None: 311 ↛ 314line 311 didn't jump to line 314 because the condition on line 311 was always true

312 return None 

313 

314 if isinstance(bg_color, tuple): 

315 if len(bg_color) == 3: 

316 # RGB -> RGBA 

317 return bg_color + (255,) 

318 return bg_color 

319 

320 if isinstance(bg_color, str): 

321 if bg_color.lower() == "transparent": 

322 return None 

323 

324 # Resolve as RGB then add alpha 

325 rgb = self._resolve_color(bg_color) 

326 return rgb + (255,) 

327 

328 return None 

329 

330 def _resolve_line_height(self, line_height: Optional[Union[str, float]]) -> float: 

331 """Resolve line height to multiplier.""" 

332 if line_height is None or line_height == "normal": 332 ↛ 335line 332 didn't jump to line 335 because the condition on line 332 was always true

333 return 1.2 # Default line height 

334 

335 if isinstance(line_height, (int, float)): 

336 return float(line_height) 

337 

338 if isinstance(line_height, str): 

339 try: 

340 return float(line_height) 

341 except ValueError: 

342 return 1.2 # Fallback 

343 

344 return 1.2 

345 

346 def _resolve_letter_spacing( 

347 self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float: 

348 """Resolve letter spacing to pixels.""" 

349 if letter_spacing is None or letter_spacing == "normal": 349 ↛ 352line 349 didn't jump to line 352 because the condition on line 349 was always true

350 return 0.0 

351 

352 if isinstance(letter_spacing, (int, float)): 

353 return float(letter_spacing) 

354 

355 if isinstance(letter_spacing, str): 

356 if letter_spacing.endswith("em"): 

357 try: 

358 em_value = float(letter_spacing[:-2]) 

359 return em_value * font_size 

360 except ValueError: 

361 return 0.0 

362 else: 

363 try: 

364 return float(letter_spacing) 

365 except ValueError: 

366 return 0.0 

367 

368 return 0.0 

369 

370 def _resolve_word_spacing( 

371 self, word_spacing: Optional[Union[str, float]], font_size: int) -> float: 

372 """Resolve word spacing to pixels.""" 

373 if word_spacing is None or word_spacing == "normal": 

374 return 0.0 

375 

376 if isinstance(word_spacing, (int, float)): 

377 return float(word_spacing) 

378 

379 if isinstance(word_spacing, str): 379 ↛ 392line 379 didn't jump to line 392 because the condition on line 379 was always true

380 if word_spacing.endswith("em"): 380 ↛ 387line 380 didn't jump to line 387 because the condition on line 380 was always true

381 try: 

382 em_value = float(word_spacing[:-2]) 

383 return em_value * font_size 

384 except ValueError: 

385 return 0.0 

386 else: 

387 try: 

388 return float(word_spacing) 

389 except ValueError: 

390 return 0.0 

391 

392 return 0.0 

393 

394 def update_context(self, **kwargs): 

395 """ 

396 Update the rendering context and clear cache. 

397 

398 Args: 

399 **kwargs: Context properties to update 

400 """ 

401 # Create new context with updates 

402 context_dict = { 

403 field.name: getattr(self.context, field.name) 

404 for field in self.context.__dataclass_fields__.values() 

405 } 

406 context_dict.update(kwargs) 

407 

408 self.context = RenderingContext(**context_dict) 

409 

410 # Clear cache since context changed 

411 self._concrete_cache.clear() 

412 

413 def clear_cache(self): 

414 """Clear the concrete style cache.""" 

415 self._concrete_cache.clear() 

416 

417 def get_cache_size(self) -> int: 

418 """Get the number of cached concrete styles.""" 

419 return len(self._concrete_cache) 

420 

421 

422class ConcreteStyleRegistry: 

423 """ 

424 Registry for managing concrete styles with efficient caching. 

425 

426 This registry manages the mapping between abstract and concrete styles, 

427 and provides efficient access to Font objects for rendering. 

428 """ 

429 

430 def __init__(self, resolver: StyleResolver): 

431 """ 

432 Initialize the concrete style registry. 

433 

434 Args: 

435 resolver: StyleResolver for converting abstract to concrete styles 

436 """ 

437 self.resolver = resolver 

438 self._font_cache: Dict[ConcreteStyle, Font] = {} 

439 

440 def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle: 

441 """ 

442 Get a concrete style for an abstract style. 

443 

444 Args: 

445 abstract_style: AbstractStyle to resolve 

446 

447 Returns: 

448 ConcreteStyle with rendering parameters 

449 """ 

450 return self.resolver.resolve_style(abstract_style) 

451 

452 def get_font(self, abstract_style: AbstractStyle) -> Font: 

453 """ 

454 Get a Font object for an abstract style. 

455 

456 Args: 

457 abstract_style: AbstractStyle to get font for 

458 

459 Returns: 

460 Font object ready for rendering 

461 """ 

462 concrete_style = self.get_concrete_style(abstract_style) 

463 

464 # Check font cache 

465 if concrete_style in self._font_cache: 

466 return self._font_cache[concrete_style] 

467 

468 # Create and cache font 

469 font = concrete_style.create_font() 

470 self._font_cache[concrete_style] = font 

471 

472 return font 

473 

474 def clear_caches(self): 

475 """Clear all caches.""" 

476 self.resolver.clear_cache() 

477 self._font_cache.clear() 

478 

479 def get_cache_stats(self) -> Dict[str, int]: 

480 """Get cache statistics.""" 

481 return { 

482 "concrete_styles": self.resolver.get_cache_size(), 

483 "fonts": len(self._font_cache) 

484 }