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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
1"""
2Concrete style system for actual rendering parameters.
4This module converts abstract styles to concrete rendering parameters based on
5user preferences, device capabilities, and rendering context.
6"""
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
15@dataclass(frozen=True)
16class RenderingContext:
17 """
18 Context information for style resolution.
19 Contains user preferences and device capabilities.
20 """
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
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
34 # Accessibility preferences
35 high_contrast: bool = False
36 large_text: bool = False
37 reduce_motion: bool = False
39 # Language and locale
40 default_language: str = "en-US"
43@dataclass(frozen=True)
44class ConcreteStyle:
45 """
46 Concrete representation of text styling with actual rendering parameters.
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 """
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
58 # Font attributes
59 weight: FontWeight = FontWeight.NORMAL
60 style: FontStyle = FontStyle.NORMAL
61 decoration: TextDecoration = TextDecoration.NONE
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
72 # Language and locale
73 language: str = "en-US"
74 min_hyphenation_width: int = 64 # In pixels
76 # Reference to source abstract style
77 abstract_style: Optional[AbstractStyle] = None
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 )
94class StyleResolver:
95 """
96 Resolves abstract styles to concrete styles based on rendering context.
98 This class handles the conversion from semantic styling intent to actual
99 rendering parameters, applying user preferences and device capabilities.
100 """
102 def __init__(self, context: RenderingContext):
103 """
104 Initialize the style resolver with a rendering context.
106 Args:
107 context: RenderingContext with user preferences and device info
108 """
109 self.context = context
110 self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {}
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 }
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 }
145 def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
146 """
147 Resolve an abstract style to a concrete style.
149 Args:
150 abstract_style: AbstractStyle to resolve
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]
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
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)
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 )
215 # Cache and return
216 self._concrete_cache[abstract_style] = concrete_style
217 return concrete_style
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
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
249 # Apply global font scaling
250 final_size = int(base_size * self.context.font_scale_factor)
252 # Apply accessibility adjustments
253 if self.context.large_text:
254 final_size = int(final_size * 1.2)
256 # Ensure we always return an int, minimum 8pt font
257 return max(int(final_size), 8)
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
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
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
298 return base_color
300 return (0, 0, 0) # Fallback to black
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
315 if isinstance(bg_color, tuple):
316 if len(bg_color) == 3:
317 # RGB -> RGBA
318 return bg_color + (255,)
319 return bg_color
321 if isinstance(bg_color, str):
322 if bg_color.lower() == "transparent":
323 return None
325 # Resolve as RGB then add alpha
326 rgb = self._resolve_color(bg_color)
327 return rgb + (255,)
329 return None
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
336 if isinstance(line_height, (int, float)):
337 return float(line_height)
339 if isinstance(line_height, str):
340 try:
341 return float(line_height)
342 except ValueError:
343 return 1.2 # Fallback
345 return 1.2
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
353 if isinstance(letter_spacing, (int, float)):
354 return float(letter_spacing)
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
369 return 0.0
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
377 if isinstance(word_spacing, (int, float)):
378 return float(word_spacing)
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
393 return 0.0
395 def update_context(self, **kwargs):
396 """
397 Update the rendering context and clear cache.
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)
409 self.context = RenderingContext(**context_dict)
411 # Clear cache since context changed
412 self._concrete_cache.clear()
414 def clear_cache(self):
415 """Clear the concrete style cache."""
416 self._concrete_cache.clear()
418 def get_cache_size(self) -> int:
419 """Get the number of cached concrete styles."""
420 return len(self._concrete_cache)
423class ConcreteStyleRegistry:
424 """
425 Registry for managing concrete styles with efficient caching.
427 This registry manages the mapping between abstract and concrete styles,
428 and provides efficient access to Font objects for rendering.
429 """
431 def __init__(self, resolver: StyleResolver):
432 """
433 Initialize the concrete style registry.
435 Args:
436 resolver: StyleResolver for converting abstract to concrete styles
437 """
438 self.resolver = resolver
439 self._font_cache: Dict[ConcreteStyle, Font] = {}
441 def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
442 """
443 Get a concrete style for an abstract style.
445 Args:
446 abstract_style: AbstractStyle to resolve
448 Returns:
449 ConcreteStyle with rendering parameters
450 """
451 return self.resolver.resolve_style(abstract_style)
453 def get_font(self, abstract_style: AbstractStyle) -> Font:
454 """
455 Get a Font object for an abstract style.
457 Args:
458 abstract_style: AbstractStyle to get font for
460 Returns:
461 Font object ready for rendering
462 """
463 concrete_style = self.get_concrete_style(abstract_style)
465 # Check font cache
466 if concrete_style in self._font_cache:
467 return self._font_cache[concrete_style]
469 # Create and cache font
470 font = concrete_style.create_font()
471 self._font_cache[concrete_style] = font
473 return font
475 def clear_caches(self):
476 """Clear all caches."""
477 self.resolver.clear_cache()
478 self._font_cache.clear()
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 }