Coverage for pyWebLayout/style/abstract_style.py: 75%
130 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"""
2Abstract style system for storing document styling intent.
4This module defines styles in terms of semantic meaning rather than concrete
5rendering parameters, allowing for flexible interpretation by different
6rendering systems and user preferences.
7"""
9from .alignment import Alignment
10from typing import Dict, Optional, Tuple, Union
11from dataclasses import dataclass
12from enum import Enum
13from .fonts import FontWeight, FontStyle, TextDecoration
16class FontFamily(Enum):
17 """Semantic font family categories"""
18 SERIF = "serif"
19 SANS_SERIF = "sans-serif"
20 MONOSPACE = "monospace"
21 CURSIVE = "cursive"
22 FANTASY = "fantasy"
25class FontSize(Enum):
26 """Semantic font sizes"""
27 XX_SMALL = "xx-small"
28 X_SMALL = "x-small"
29 SMALL = "small"
30 MEDIUM = "medium"
31 LARGE = "large"
32 X_LARGE = "x-large"
33 XX_LARGE = "xx-large"
35 # Allow numeric values as well
36 @classmethod
37 def from_value(cls, value: Union[str, int, float]) -> Union['FontSize', int]:
38 """Convert a value to FontSize enum or return numeric value"""
39 if isinstance(value, (int, float)):
40 return int(value)
41 if isinstance(value, str):
42 try:
43 return cls(value)
44 except ValueError:
45 # Try to parse as number
46 try:
47 return int(float(value))
48 except ValueError:
49 return cls.MEDIUM
50 return cls.MEDIUM
53# Import Alignment from the centralized location
55# Use Alignment for text alignment
56TextAlign = Alignment
59@dataclass(frozen=True)
60class AbstractStyle:
61 """
62 Abstract representation of text styling that captures semantic intent
63 rather than concrete rendering parameters.
65 This allows the same document to be rendered differently based on
66 user preferences, device capabilities, or accessibility requirements.
68 Being frozen=True makes this class hashable and immutable, which is
69 perfect for use as dictionary keys and preventing accidental modification.
70 """
72 # Font properties (semantic)
73 font_family: FontFamily = FontFamily.SERIF
74 font_size: Union[FontSize, int] = FontSize.MEDIUM
75 font_weight: FontWeight = FontWeight.NORMAL
76 font_style: FontStyle = FontStyle.NORMAL
77 text_decoration: TextDecoration = TextDecoration.NONE
79 # Color (as semantic names or RGB)
80 color: Union[str, Tuple[int, int, int]] = "black"
81 background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
83 # Text properties
84 text_align: TextAlign = TextAlign.LEFT
85 line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
86 letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
87 word_spacing: Optional[Union[str, float]] = None
88 word_spacing_min: Optional[Union[str, float]] = None # Minimum allowed word spacing
89 word_spacing_max: Optional[Union[str, float]] = None # Maximum allowed word spacing
91 # Language and locale
92 language: str = "en-US"
94 # Hierarchy properties
95 parent_style_id: Optional[str] = None
97 def __post_init__(self):
98 """Validate and normalize values after creation"""
99 # Normalize font_size if it's a string that could be a number
100 if isinstance(self.font_size, str): 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 try:
102 object.__setattr__(self, 'font_size', int(float(self.font_size)))
103 except ValueError:
104 # Keep as is if it's a semantic size name
105 pass
107 def __hash__(self) -> int:
108 """
109 Custom hash implementation to ensure consistent hashing.
111 Since this is a frozen dataclass, it should be hashable by default,
112 but we provide a custom implementation to ensure all fields are
113 properly considered and to handle the Union types correctly.
114 """
115 # Convert all values to hashable forms
116 hashable_values = (
117 self.font_family,
118 self.font_size if isinstance(self.font_size, int) else self.font_size,
119 self.font_weight,
120 self.font_style,
121 self.text_decoration,
122 self.color if isinstance(self.color, (str, tuple)) else str(self.color),
123 self.background_color,
124 self.text_align,
125 self.line_height,
126 self.letter_spacing,
127 self.word_spacing,
128 self.word_spacing_min,
129 self.word_spacing_max,
130 self.language,
131 self.parent_style_id
132 )
134 return hash(hashable_values)
136 def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
137 """
138 Create a new AbstractStyle by merging this one with another.
139 The other style's properties take precedence.
141 Args:
142 other: AbstractStyle to merge with this one
144 Returns:
145 New AbstractStyle with merged values
146 """
147 # Get all fields from both styles
148 current_dict = {
149 field.name: getattr(self, field.name)
150 for field in self.__dataclass_fields__.values()
151 }
153 other_dict = {
154 field.name: getattr(other, field.name)
155 for field in other.__dataclass_fields__.values()
156 if getattr(other, field.name) != field.default
157 }
159 # Merge dictionaries (other takes precedence)
160 merged_dict = current_dict.copy()
161 merged_dict.update(other_dict)
163 return AbstractStyle(**merged_dict)
165 def with_modifications(self, **kwargs) -> 'AbstractStyle':
166 """
167 Create a new AbstractStyle with specified modifications.
169 Args:
170 **kwargs: Properties to modify
172 Returns:
173 New AbstractStyle with modifications applied
174 """
175 current_dict = {
176 field.name: getattr(self, field.name)
177 for field in self.__dataclass_fields__.values()
178 }
180 current_dict.update(kwargs)
181 return AbstractStyle(**current_dict)
184class AbstractStyleRegistry:
185 """
186 Registry for managing abstract document styles.
188 This registry stores the semantic styling intent and provides
189 deduplication and inheritance capabilities using hashable AbstractStyle objects.
190 """
192 def __init__(self):
193 """Initialize an empty abstract style registry."""
194 self._styles: Dict[str, AbstractStyle] = {}
195 # Reverse mapping using hashable styles
196 self._style_to_id: Dict[AbstractStyle, str] = {}
197 self._next_id = 1
199 # Create and register the default style
200 self._default_style = self._create_default_style()
202 def _create_default_style(self) -> AbstractStyle:
203 """Create the default document style."""
204 default_style = AbstractStyle()
205 style_id = "default"
206 self._styles[style_id] = default_style
207 self._style_to_id[default_style] = style_id
208 return default_style
210 @property
211 def default_style(self) -> AbstractStyle:
212 """Get the default style for the document."""
213 return self._default_style
215 def _generate_style_id(self) -> str:
216 """Generate a unique style ID."""
217 style_id = f"abstract_style_{self._next_id}"
218 self._next_id += 1
219 return style_id
221 def get_style_id(self, style: AbstractStyle) -> Optional[str]:
222 """
223 Get the ID for a given style if it exists in the registry.
225 Args:
226 style: AbstractStyle to find
228 Returns:
229 Style ID if found, None otherwise
230 """
231 return self._style_to_id.get(style)
233 def register_style(
234 self,
235 style: AbstractStyle,
236 style_id: Optional[str] = None) -> str:
237 """
238 Register a style in the registry.
240 Args:
241 style: AbstractStyle to register
242 style_id: Optional style ID. If None, one will be generated
244 Returns:
245 The style ID
246 """
247 # Check if style already exists
248 existing_id = self.get_style_id(style)
249 if existing_id is not None: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
250 return existing_id
252 if style_id is None: 252 ↛ 255line 252 didn't jump to line 255 because the condition on line 252 was always true
253 style_id = self._generate_style_id()
255 self._styles[style_id] = style
256 self._style_to_id[style] = style_id
257 return style_id
259 def get_or_create_style(self,
260 style: Optional[AbstractStyle] = None,
261 parent_id: Optional[str] = None,
262 **kwargs) -> Tuple[str, AbstractStyle]:
263 """
264 Get an existing style or create a new one.
266 Args:
267 style: AbstractStyle object. If None, created from kwargs
268 parent_id: Optional parent style ID
269 **kwargs: Individual style properties (used if style is None)
271 Returns:
272 Tuple of (style_id, AbstractStyle)
273 """
274 # Create style object if not provided
275 if style is None:
276 # Filter out None values from kwargs
277 filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None}
278 if parent_id: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 filtered_kwargs['parent_style_id'] = parent_id
280 style = AbstractStyle(**filtered_kwargs)
282 # Check if we already have this style (using hashable property)
283 existing_id = self.get_style_id(style)
284 if existing_id is not None:
285 return existing_id, style
287 # Create new style
288 style_id = self.register_style(style)
289 return style_id, style
291 def get_style_by_id(self, style_id: str) -> Optional[AbstractStyle]:
292 """Get a style by its ID."""
293 return self._styles.get(style_id)
295 def create_derived_style(self, base_style_id: str, **
296 modifications) -> Tuple[str, AbstractStyle]:
297 """
298 Create a new style derived from a base style.
300 Args:
301 base_style_id: ID of the base style
302 **modifications: Properties to modify
304 Returns:
305 Tuple of (new_style_id, new_AbstractStyle)
306 """
307 base_style = self.get_style_by_id(base_style_id)
308 if base_style is None: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true
309 raise ValueError(f"Base style '{base_style_id}' not found")
311 # Create derived style
312 derived_style = base_style.with_modifications(**modifications)
313 return self.get_or_create_style(derived_style)
315 def resolve_effective_style(self, style_id: str) -> AbstractStyle:
316 """
317 Resolve the effective style including inheritance.
319 Args:
320 style_id: Style ID to resolve
322 Returns:
323 Effective AbstractStyle with inheritance applied
324 """
325 style = self.get_style_by_id(style_id)
326 if style is None: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 return self._default_style
329 if style.parent_style_id is None: 329 ↛ 333line 329 didn't jump to line 333 because the condition on line 329 was always true
330 return style
332 # Recursively resolve parent styles
333 parent_style = self.resolve_effective_style(style.parent_style_id)
334 return parent_style.merge_with(style)
336 def get_all_styles(self) -> Dict[str, AbstractStyle]:
337 """Get all registered styles."""
338 return self._styles.copy()
340 def get_style_count(self) -> int:
341 """Get the number of registered styles."""
342 return len(self._styles)