Coverage for pyWebLayout/style/abstract_style.py: 76%
135 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"""
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 # None means "not specified": the page's default_alignment applies.
85 text_align: Optional[TextAlign] = None
86 line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
87 letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
88 word_spacing: Optional[Union[str, float]] = None
89 word_spacing_min: Optional[Union[str, float]] = None # Minimum allowed word spacing
90 word_spacing_max: Optional[Union[str, float]] = None # Maximum allowed word spacing
92 # Language and locale
93 language: str = "en-US"
95 # Hierarchy properties
96 parent_style_id: Optional[str] = None
98 def __post_init__(self):
99 """Validate and normalize values after creation"""
100 # Normalize font_size if it's a string that could be a number
101 if isinstance(self.font_size, str): 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 try:
103 object.__setattr__(self, 'font_size', int(float(self.font_size)))
104 except ValueError:
105 # Keep as is if it's a semantic size name
106 pass
108 def __hash__(self) -> int:
109 """
110 Custom hash implementation to ensure consistent hashing.
112 Since this is a frozen dataclass, it should be hashable by default,
113 but we provide a custom implementation to ensure all fields are
114 properly considered and to handle the Union types correctly.
116 The result is memoised on first use. Styles are used as dictionary keys
117 throughout parsing and style resolution, and five of the fields are enum
118 members whose own __hash__ is a Python-level call, so rebuilding the
119 15-tuple on every lookup was a measurable share of document parsing. The
120 class is frozen, so the value cannot go stale.
121 """
122 cached = self.__dict__.get('_hash_cache')
123 if cached is not None:
124 return cached
126 # Convert all values to hashable forms
127 hashable_values = (
128 self.font_family,
129 self.font_size if isinstance(self.font_size, int) else self.font_size,
130 self.font_weight,
131 self.font_style,
132 self.text_decoration,
133 self.color if isinstance(self.color, (str, tuple)) else str(self.color),
134 self.background_color,
135 self.text_align,
136 self.line_height,
137 self.letter_spacing,
138 self.word_spacing,
139 self.word_spacing_min,
140 self.word_spacing_max,
141 self.language,
142 self.parent_style_id
143 )
145 result = hash(hashable_values)
146 object.__setattr__(self, '_hash_cache', result)
147 return result
149 def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
150 """
151 Create a new AbstractStyle by merging this one with another.
152 The other style's properties take precedence.
154 Args:
155 other: AbstractStyle to merge with this one
157 Returns:
158 New AbstractStyle with merged values
159 """
160 # Get all fields from both styles
161 current_dict = {
162 field.name: getattr(self, field.name)
163 for field in self.__dataclass_fields__.values()
164 }
166 other_dict = {
167 field.name: getattr(other, field.name)
168 for field in other.__dataclass_fields__.values()
169 if getattr(other, field.name) != field.default
170 }
172 # Merge dictionaries (other takes precedence)
173 merged_dict = current_dict.copy()
174 merged_dict.update(other_dict)
176 return AbstractStyle(**merged_dict)
178 def with_modifications(self, **kwargs) -> 'AbstractStyle':
179 """
180 Create a new AbstractStyle with specified modifications.
182 Args:
183 **kwargs: Properties to modify
185 Returns:
186 New AbstractStyle with modifications applied
187 """
188 current_dict = {
189 field.name: getattr(self, field.name)
190 for field in self.__dataclass_fields__.values()
191 }
193 current_dict.update(kwargs)
194 return AbstractStyle(**current_dict)
197class AbstractStyleRegistry:
198 """
199 Registry for managing abstract document styles.
201 This registry stores the semantic styling intent and provides
202 deduplication and inheritance capabilities using hashable AbstractStyle objects.
203 """
205 def __init__(self):
206 """Initialize an empty abstract style registry."""
207 self._styles: Dict[str, AbstractStyle] = {}
208 # Reverse mapping using hashable styles
209 self._style_to_id: Dict[AbstractStyle, str] = {}
210 self._next_id = 1
212 # Create and register the default style
213 self._default_style = self._create_default_style()
215 def _create_default_style(self) -> AbstractStyle:
216 """Create the default document style."""
217 default_style = AbstractStyle()
218 style_id = "default"
219 self._styles[style_id] = default_style
220 self._style_to_id[default_style] = style_id
221 return default_style
223 @property
224 def default_style(self) -> AbstractStyle:
225 """Get the default style for the document."""
226 return self._default_style
228 def _generate_style_id(self) -> str:
229 """Generate a unique style ID."""
230 style_id = f"abstract_style_{self._next_id}"
231 self._next_id += 1
232 return style_id
234 def get_style_id(self, style: AbstractStyle) -> Optional[str]:
235 """
236 Get the ID for a given style if it exists in the registry.
238 Args:
239 style: AbstractStyle to find
241 Returns:
242 Style ID if found, None otherwise
243 """
244 return self._style_to_id.get(style)
246 def register_style(
247 self,
248 style: AbstractStyle,
249 style_id: Optional[str] = None) -> str:
250 """
251 Register a style in the registry.
253 Args:
254 style: AbstractStyle to register
255 style_id: Optional style ID. If None, one will be generated
257 Returns:
258 The style ID
259 """
260 # Check if style already exists
261 existing_id = self.get_style_id(style)
262 if existing_id is not None: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true
263 return existing_id
265 if style_id is None: 265 ↛ 268line 265 didn't jump to line 268 because the condition on line 265 was always true
266 style_id = self._generate_style_id()
268 self._styles[style_id] = style
269 self._style_to_id[style] = style_id
270 return style_id
272 def get_or_create_style(self,
273 style: Optional[AbstractStyle] = None,
274 parent_id: Optional[str] = None,
275 **kwargs) -> Tuple[str, AbstractStyle]:
276 """
277 Get an existing style or create a new one.
279 Args:
280 style: AbstractStyle object. If None, created from kwargs
281 parent_id: Optional parent style ID
282 **kwargs: Individual style properties (used if style is None)
284 Returns:
285 Tuple of (style_id, AbstractStyle)
286 """
287 # Create style object if not provided
288 if style is None:
289 # Filter out None values from kwargs
290 filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None}
291 if parent_id: 291 ↛ 292line 291 didn't jump to line 292 because the condition on line 291 was never true
292 filtered_kwargs['parent_style_id'] = parent_id
293 style = AbstractStyle(**filtered_kwargs)
295 # Check if we already have this style (using hashable property)
296 existing_id = self.get_style_id(style)
297 if existing_id is not None:
298 return existing_id, style
300 # Create new style
301 style_id = self.register_style(style)
302 return style_id, style
304 def get_style_by_id(self, style_id: str) -> Optional[AbstractStyle]:
305 """Get a style by its ID."""
306 return self._styles.get(style_id)
308 def create_derived_style(self, base_style_id: str, **
309 modifications) -> Tuple[str, AbstractStyle]:
310 """
311 Create a new style derived from a base style.
313 Args:
314 base_style_id: ID of the base style
315 **modifications: Properties to modify
317 Returns:
318 Tuple of (new_style_id, new_AbstractStyle)
319 """
320 base_style = self.get_style_by_id(base_style_id)
321 if base_style is None: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 raise ValueError(f"Base style '{base_style_id}' not found")
324 # Create derived style
325 derived_style = base_style.with_modifications(**modifications)
326 return self.get_or_create_style(derived_style)
328 def resolve_effective_style(self, style_id: str) -> AbstractStyle:
329 """
330 Resolve the effective style including inheritance.
332 Args:
333 style_id: Style ID to resolve
335 Returns:
336 Effective AbstractStyle with inheritance applied
337 """
338 style = self.get_style_by_id(style_id)
339 if style is None: 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true
340 return self._default_style
342 if style.parent_style_id is None: 342 ↛ 346line 342 didn't jump to line 346 because the condition on line 342 was always true
343 return style
345 # Recursively resolve parent styles
346 parent_style = self.resolve_effective_style(style.parent_style_id)
347 return parent_style.merge_with(style)
349 def get_all_styles(self) -> Dict[str, AbstractStyle]:
350 """Get all registered styles."""
351 return self._styles.copy()
353 def get_style_count(self) -> int:
354 """Get the number of registered styles."""
355 return len(self._styles)