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

1""" 

2Abstract style system for storing document styling intent. 

3 

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""" 

8 

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 

14 

15 

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" 

23 

24 

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" 

34 

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 

51 

52 

53# Import Alignment from the centralized location 

54 

55# Use Alignment for text alignment 

56TextAlign = Alignment 

57 

58 

59@dataclass(frozen=True) 

60class AbstractStyle: 

61 """ 

62 Abstract representation of text styling that captures semantic intent 

63 rather than concrete rendering parameters. 

64 

65 This allows the same document to be rendered differently based on 

66 user preferences, device capabilities, or accessibility requirements. 

67 

68 Being frozen=True makes this class hashable and immutable, which is 

69 perfect for use as dictionary keys and preventing accidental modification. 

70 """ 

71 

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 

78 

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 

82 

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 

90 

91 # Language and locale 

92 language: str = "en-US" 

93 

94 # Hierarchy properties 

95 parent_style_id: Optional[str] = None 

96 

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 

106 

107 def __hash__(self) -> int: 

108 """ 

109 Custom hash implementation to ensure consistent hashing. 

110 

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 ) 

133 

134 return hash(hashable_values) 

135 

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. 

140 

141 Args: 

142 other: AbstractStyle to merge with this one 

143 

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 } 

152 

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 } 

158 

159 # Merge dictionaries (other takes precedence) 

160 merged_dict = current_dict.copy() 

161 merged_dict.update(other_dict) 

162 

163 return AbstractStyle(**merged_dict) 

164 

165 def with_modifications(self, **kwargs) -> 'AbstractStyle': 

166 """ 

167 Create a new AbstractStyle with specified modifications. 

168 

169 Args: 

170 **kwargs: Properties to modify 

171 

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 } 

179 

180 current_dict.update(kwargs) 

181 return AbstractStyle(**current_dict) 

182 

183 

184class AbstractStyleRegistry: 

185 """ 

186 Registry for managing abstract document styles. 

187 

188 This registry stores the semantic styling intent and provides 

189 deduplication and inheritance capabilities using hashable AbstractStyle objects. 

190 """ 

191 

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 

198 

199 # Create and register the default style 

200 self._default_style = self._create_default_style() 

201 

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 

209 

210 @property 

211 def default_style(self) -> AbstractStyle: 

212 """Get the default style for the document.""" 

213 return self._default_style 

214 

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 

220 

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. 

224 

225 Args: 

226 style: AbstractStyle to find 

227 

228 Returns: 

229 Style ID if found, None otherwise 

230 """ 

231 return self._style_to_id.get(style) 

232 

233 def register_style( 

234 self, 

235 style: AbstractStyle, 

236 style_id: Optional[str] = None) -> str: 

237 """ 

238 Register a style in the registry. 

239 

240 Args: 

241 style: AbstractStyle to register 

242 style_id: Optional style ID. If None, one will be generated 

243 

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 

251 

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() 

254 

255 self._styles[style_id] = style 

256 self._style_to_id[style] = style_id 

257 return style_id 

258 

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. 

265 

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) 

270 

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) 

281 

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 

286 

287 # Create new style 

288 style_id = self.register_style(style) 

289 return style_id, style 

290 

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) 

294 

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. 

299 

300 Args: 

301 base_style_id: ID of the base style 

302 **modifications: Properties to modify 

303 

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") 

310 

311 # Create derived style 

312 derived_style = base_style.with_modifications(**modifications) 

313 return self.get_or_create_style(derived_style) 

314 

315 def resolve_effective_style(self, style_id: str) -> AbstractStyle: 

316 """ 

317 Resolve the effective style including inheritance. 

318 

319 Args: 

320 style_id: Style ID to resolve 

321 

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 

328 

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 

331 

332 # Recursively resolve parent styles 

333 parent_style = self.resolve_effective_style(style.parent_style_id) 

334 return parent_style.merge_with(style) 

335 

336 def get_all_styles(self) -> Dict[str, AbstractStyle]: 

337 """Get all registered styles.""" 

338 return self._styles.copy() 

339 

340 def get_style_count(self) -> int: 

341 """Get the number of registered styles.""" 

342 return len(self._styles)