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

207 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-08 20:34 +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 # None means "not specified": the page's default_alignment applies. 

65 text_align: Optional[TextAlign] = None 

66 line_height: float = 1.0 # Multiplier 

67 letter_spacing: float = 0.0 # In pixels 

68 word_spacing: float = 0.0 # In pixels 

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

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

71 

72 # Language and locale 

73 language: str = "en-US" 

74 min_hyphenation_width: int = 64 # In pixels 

75 

76 # Reference to source abstract style 

77 abstract_style: Optional[AbstractStyle] = None 

78 

79 def create_font(self) -> Font: 

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

81 return Font( 

82 font_path=self.font_path, 

83 font_size=self.font_size, 

84 colour=self.color, 

85 weight=self.weight, 

86 style=self.style, 

87 decoration=self.decoration, 

88 background=self.background_color, 

89 language=self.language, 

90 min_hyphenation_width=self.min_hyphenation_width 

91 ) 

92 

93 

94class StyleResolver: 

95 """ 

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

97 

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

99 rendering parameters, applying user preferences and device capabilities. 

100 """ 

101 

102 def __init__(self, context: RenderingContext): 

103 """ 

104 Initialize the style resolver with a rendering context. 

105 

106 Args: 

107 context: RenderingContext with user preferences and device info 

108 """ 

109 self.context = context 

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

111 

112 # Font size mapping for semantic sizes 

113 self._semantic_font_sizes = { 

114 FontSize.XX_SMALL: 0.6, 

115 FontSize.X_SMALL: 0.75, 

116 FontSize.SMALL: 0.89, 

117 FontSize.MEDIUM: 1.0, 

118 FontSize.LARGE: 1.2, 

119 FontSize.X_LARGE: 1.5, 

120 FontSize.XX_LARGE: 2.0, 

121 } 

122 

123 # Color name mapping 

124 self._color_names = { 

125 "black": (0, 0, 0), 

126 "white": (255, 255, 255), 

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

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

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

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

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

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

133 "silver": (192, 192, 192), 

134 "gray": (128, 128, 128), 

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

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

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

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

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

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

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

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

143 } 

144 

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

146 """ 

147 Resolve an abstract style to a concrete style. 

148 

149 Args: 

150 abstract_style: AbstractStyle to resolve 

151 

152 Returns: 

153 ConcreteStyle with concrete rendering parameters 

154 """ 

155 # Check cache first 

156 if abstract_style in self._concrete_cache: 

157 return self._concrete_cache[abstract_style] 

158 

159 # Resolve each property 

160 font_path = self._resolve_font_path(abstract_style.font_family) 

161 font_size = self._resolve_font_size(abstract_style.font_size) 

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

163 font_size = int(font_size) 

164 color = self._resolve_color(abstract_style.color) 

165 background_color = self._resolve_background_color( 

166 abstract_style.background_color) 

167 line_height = self._resolve_line_height(abstract_style.line_height) 

168 letter_spacing = self._resolve_letter_spacing( 

169 abstract_style.letter_spacing, font_size) 

170 word_spacing = self._resolve_word_spacing( 

171 abstract_style.word_spacing, font_size) 

172 word_spacing_min = self._resolve_word_spacing( 

173 abstract_style.word_spacing_min, font_size) 

174 word_spacing_max = self._resolve_word_spacing( 

175 abstract_style.word_spacing_max, font_size) 

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

177 

178 # Apply default logic for word spacing constraints 

179 if word_spacing_min == 0.0 and word_spacing_max == 0.0: 

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

181 if word_spacing > 0.0: 

182 word_spacing_min = word_spacing 

183 word_spacing_max = word_spacing * 2 

184 else: 

185 # Default constraints when no word spacing is specified 

186 word_spacing_min = 2.0 # Minimum 2 pixels 

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

188 elif word_spacing_min == 0.0: 

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

190 word_spacing_min = max(word_spacing, 2.0) 

191 elif word_spacing_max == 0.0: 

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

193 word_spacing_max = max(word_spacing, word_spacing_min * 2) 

194 

195 # Create concrete style 

196 concrete_style = ConcreteStyle( 

197 font_path=font_path, 

198 font_size=font_size, 

199 color=color, 

200 background_color=background_color, 

201 weight=abstract_style.font_weight, 

202 style=abstract_style.font_style, 

203 decoration=abstract_style.text_decoration, 

204 text_align=abstract_style.text_align, 

205 line_height=line_height, 

206 letter_spacing=letter_spacing, 

207 word_spacing=word_spacing, 

208 word_spacing_min=word_spacing_min, 

209 word_spacing_max=word_spacing_max, 

210 language=abstract_style.language, 

211 min_hyphenation_width=min_hyphenation_width, 

212 abstract_style=abstract_style 

213 ) 

214 

215 # Cache and return 

216 self._concrete_cache[abstract_style] = concrete_style 

217 return concrete_style 

218 

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

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

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

222 return self.context.preferred_serif_font 

223 elif font_family == FontFamily.SANS_SERIF: 

224 return self.context.preferred_sans_serif_font 

225 elif font_family == FontFamily.MONOSPACE: 

226 return self.context.preferred_monospace_font 

227 else: 

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

229 return self.context.preferred_sans_serif_font 

230 

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

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

233 # Ensure we handle FontSize enums properly 

234 if isinstance(font_size, FontSize): 

235 # Semantic size, convert to multiplier 

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

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

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

239 # Already a concrete size, apply scaling 

240 base_size = font_size 

241 else: 

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

243 try: 

244 base_size = int(font_size) 

245 except (ValueError, TypeError): 

246 # If conversion fails, use default 

247 base_size = self.context.base_font_size 

248 

249 # Apply global font scaling 

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

251 

252 # Apply accessibility adjustments 

253 if self.context.large_text: 

254 final_size = int(final_size * 1.2) 

255 

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

257 return max(int(final_size), 8) 

258 

259 def _resolve_color( 

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

261 """Resolve color to RGB tuple.""" 

262 if isinstance(color, tuple): 

263 return color 

264 

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

266 # Check if it's a named color 

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

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

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

270 # Parse hex color 

271 try: 

272 hex_color = color[1:] 

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

274 # Short hex format #RGB -> #RRGGBB 

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

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

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

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

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

280 base_color = (r, g, b) 

281 else: 

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

283 except ValueError: 

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

285 else: 

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

287 

288 # Apply high contrast if needed 

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

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

291 r, g, b = base_color 

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

293 if brightness < 128: 

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

295 else: 

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

297 

298 return base_color 

299 

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

301 

302 def _resolve_background_color(self, 

303 bg_color: Optional[Union[str, 

304 Tuple[int, 

305 int, 

306 int, 

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

308 int, 

309 int, 

310 int]]: 

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

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

313 return None 

314 

315 if isinstance(bg_color, tuple): 

316 if len(bg_color) == 3: 

317 # RGB -> RGBA 

318 return bg_color + (255,) 

319 return bg_color 

320 

321 if isinstance(bg_color, str): 

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

323 return None 

324 

325 # Resolve as RGB then add alpha 

326 rgb = self._resolve_color(bg_color) 

327 return rgb + (255,) 

328 

329 return None 

330 

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

332 """Resolve line height to multiplier.""" 

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

334 return 1.2 # Default line height 

335 

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

337 return float(line_height) 

338 

339 if isinstance(line_height, str): 

340 try: 

341 return float(line_height) 

342 except ValueError: 

343 return 1.2 # Fallback 

344 

345 return 1.2 

346 

347 def _resolve_letter_spacing( 

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

349 """Resolve letter spacing to pixels.""" 

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

351 return 0.0 

352 

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

354 return float(letter_spacing) 

355 

356 if isinstance(letter_spacing, str): 

357 if letter_spacing.endswith("em"): 

358 try: 

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

360 return em_value * font_size 

361 except ValueError: 

362 return 0.0 

363 else: 

364 try: 

365 return float(letter_spacing) 

366 except ValueError: 

367 return 0.0 

368 

369 return 0.0 

370 

371 def _resolve_word_spacing( 

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

373 """Resolve word spacing to pixels.""" 

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

375 return 0.0 

376 

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

378 return float(word_spacing) 

379 

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

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

382 try: 

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

384 return em_value * font_size 

385 except ValueError: 

386 return 0.0 

387 else: 

388 try: 

389 return float(word_spacing) 

390 except ValueError: 

391 return 0.0 

392 

393 return 0.0 

394 

395 def update_context(self, **kwargs): 

396 """ 

397 Update the rendering context and clear cache. 

398 

399 Args: 

400 **kwargs: Context properties to update 

401 """ 

402 # Create new context with updates 

403 context_dict = { 

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

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

406 } 

407 context_dict.update(kwargs) 

408 

409 self.context = RenderingContext(**context_dict) 

410 

411 # Clear cache since context changed 

412 self._concrete_cache.clear() 

413 

414 def clear_cache(self): 

415 """Clear the concrete style cache.""" 

416 self._concrete_cache.clear() 

417 

418 def get_cache_size(self) -> int: 

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

420 return len(self._concrete_cache) 

421 

422 

423class ConcreteStyleRegistry: 

424 """ 

425 Registry for managing concrete styles with efficient caching. 

426 

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

428 and provides efficient access to Font objects for rendering. 

429 """ 

430 

431 def __init__(self, resolver: StyleResolver): 

432 """ 

433 Initialize the concrete style registry. 

434 

435 Args: 

436 resolver: StyleResolver for converting abstract to concrete styles 

437 """ 

438 self.resolver = resolver 

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

440 

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

442 """ 

443 Get a concrete style for an abstract style. 

444 

445 Args: 

446 abstract_style: AbstractStyle to resolve 

447 

448 Returns: 

449 ConcreteStyle with rendering parameters 

450 """ 

451 return self.resolver.resolve_style(abstract_style) 

452 

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

454 """ 

455 Get a Font object for an abstract style. 

456 

457 Args: 

458 abstract_style: AbstractStyle to get font for 

459 

460 Returns: 

461 Font object ready for rendering 

462 """ 

463 concrete_style = self.get_concrete_style(abstract_style) 

464 

465 # Check font cache 

466 if concrete_style in self._font_cache: 

467 return self._font_cache[concrete_style] 

468 

469 # Create and cache font 

470 font = concrete_style.create_font() 

471 self._font_cache[concrete_style] = font 

472 

473 return font 

474 

475 def clear_caches(self): 

476 """Clear all caches.""" 

477 self.resolver.clear_cache() 

478 self._font_cache.clear() 

479 

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

481 """Get cache statistics.""" 

482 return { 

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

484 "fonts": len(self._font_cache) 

485 }