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
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +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 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
71 # Language and locale
72 language: str = "en-US"
73 min_hyphenation_width: int = 64 # In pixels
75 # Reference to source abstract style
76 abstract_style: Optional[AbstractStyle] = None
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 )
93class StyleResolver:
94 """
95 Resolves abstract styles to concrete styles based on rendering context.
97 This class handles the conversion from semantic styling intent to actual
98 rendering parameters, applying user preferences and device capabilities.
99 """
101 def __init__(self, context: RenderingContext):
102 """
103 Initialize the style resolver with a rendering context.
105 Args:
106 context: RenderingContext with user preferences and device info
107 """
108 self.context = context
109 self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {}
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 }
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 }
144 def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
145 """
146 Resolve an abstract style to a concrete style.
148 Args:
149 abstract_style: AbstractStyle to resolve
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]
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
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)
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 )
214 # Cache and return
215 self._concrete_cache[abstract_style] = concrete_style
216 return concrete_style
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
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
248 # Apply global font scaling
249 final_size = int(base_size * self.context.font_scale_factor)
251 # Apply accessibility adjustments
252 if self.context.large_text:
253 final_size = int(final_size * 1.2)
255 # Ensure we always return an int, minimum 8pt font
256 return max(int(final_size), 8)
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
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
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
297 return base_color
299 return (0, 0, 0) # Fallback to black
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
314 if isinstance(bg_color, tuple):
315 if len(bg_color) == 3:
316 # RGB -> RGBA
317 return bg_color + (255,)
318 return bg_color
320 if isinstance(bg_color, str):
321 if bg_color.lower() == "transparent":
322 return None
324 # Resolve as RGB then add alpha
325 rgb = self._resolve_color(bg_color)
326 return rgb + (255,)
328 return None
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
335 if isinstance(line_height, (int, float)):
336 return float(line_height)
338 if isinstance(line_height, str):
339 try:
340 return float(line_height)
341 except ValueError:
342 return 1.2 # Fallback
344 return 1.2
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
352 if isinstance(letter_spacing, (int, float)):
353 return float(letter_spacing)
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
368 return 0.0
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
376 if isinstance(word_spacing, (int, float)):
377 return float(word_spacing)
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
392 return 0.0
394 def update_context(self, **kwargs):
395 """
396 Update the rendering context and clear cache.
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)
408 self.context = RenderingContext(**context_dict)
410 # Clear cache since context changed
411 self._concrete_cache.clear()
413 def clear_cache(self):
414 """Clear the concrete style cache."""
415 self._concrete_cache.clear()
417 def get_cache_size(self) -> int:
418 """Get the number of cached concrete styles."""
419 return len(self._concrete_cache)
422class ConcreteStyleRegistry:
423 """
424 Registry for managing concrete styles with efficient caching.
426 This registry manages the mapping between abstract and concrete styles,
427 and provides efficient access to Font objects for rendering.
428 """
430 def __init__(self, resolver: StyleResolver):
431 """
432 Initialize the concrete style registry.
434 Args:
435 resolver: StyleResolver for converting abstract to concrete styles
436 """
437 self.resolver = resolver
438 self._font_cache: Dict[ConcreteStyle, Font] = {}
440 def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
441 """
442 Get a concrete style for an abstract style.
444 Args:
445 abstract_style: AbstractStyle to resolve
447 Returns:
448 ConcreteStyle with rendering parameters
449 """
450 return self.resolver.resolve_style(abstract_style)
452 def get_font(self, abstract_style: AbstractStyle) -> Font:
453 """
454 Get a Font object for an abstract style.
456 Args:
457 abstract_style: AbstractStyle to get font for
459 Returns:
460 Font object ready for rendering
461 """
462 concrete_style = self.get_concrete_style(abstract_style)
464 # Check font cache
465 if concrete_style in self._font_cache:
466 return self._font_cache[concrete_style]
468 # Create and cache font
469 font = concrete_style.create_font()
470 self._font_cache[concrete_style] = font
472 return font
474 def clear_caches(self):
475 """Clear all caches."""
476 self.resolver.clear_cache()
477 self._font_cache.clear()
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 }