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

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

91 

92 # Language and locale 

93 language: str = "en-US" 

94 

95 # Hierarchy properties 

96 parent_style_id: Optional[str] = None 

97 

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 

107 

108 def __hash__(self) -> int: 

109 """ 

110 Custom hash implementation to ensure consistent hashing. 

111 

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. 

115 

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 

125 

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 ) 

144 

145 result = hash(hashable_values) 

146 object.__setattr__(self, '_hash_cache', result) 

147 return result 

148 

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. 

153 

154 Args: 

155 other: AbstractStyle to merge with this one 

156 

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 } 

165 

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 } 

171 

172 # Merge dictionaries (other takes precedence) 

173 merged_dict = current_dict.copy() 

174 merged_dict.update(other_dict) 

175 

176 return AbstractStyle(**merged_dict) 

177 

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

179 """ 

180 Create a new AbstractStyle with specified modifications. 

181 

182 Args: 

183 **kwargs: Properties to modify 

184 

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 } 

192 

193 current_dict.update(kwargs) 

194 return AbstractStyle(**current_dict) 

195 

196 

197class AbstractStyleRegistry: 

198 """ 

199 Registry for managing abstract document styles. 

200 

201 This registry stores the semantic styling intent and provides 

202 deduplication and inheritance capabilities using hashable AbstractStyle objects. 

203 """ 

204 

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 

211 

212 # Create and register the default style 

213 self._default_style = self._create_default_style() 

214 

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 

222 

223 @property 

224 def default_style(self) -> AbstractStyle: 

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

226 return self._default_style 

227 

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 

233 

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. 

237 

238 Args: 

239 style: AbstractStyle to find 

240 

241 Returns: 

242 Style ID if found, None otherwise 

243 """ 

244 return self._style_to_id.get(style) 

245 

246 def register_style( 

247 self, 

248 style: AbstractStyle, 

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

250 """ 

251 Register a style in the registry. 

252 

253 Args: 

254 style: AbstractStyle to register 

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

256 

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 

264 

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

267 

268 self._styles[style_id] = style 

269 self._style_to_id[style] = style_id 

270 return style_id 

271 

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. 

278 

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) 

283 

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) 

294 

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 

299 

300 # Create new style 

301 style_id = self.register_style(style) 

302 return style_id, style 

303 

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) 

307 

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. 

312 

313 Args: 

314 base_style_id: ID of the base style 

315 **modifications: Properties to modify 

316 

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

323 

324 # Create derived style 

325 derived_style = base_style.with_modifications(**modifications) 

326 return self.get_or_create_style(derived_style) 

327 

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

329 """ 

330 Resolve the effective style including inheritance. 

331 

332 Args: 

333 style_id: Style ID to resolve 

334 

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 

341 

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 

344 

345 # Recursively resolve parent styles 

346 parent_style = self.resolve_effective_style(style.parent_style_id) 

347 return parent_style.merge_with(style) 

348 

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

350 """Get all registered styles.""" 

351 return self._styles.copy() 

352 

353 def get_style_count(self) -> int: 

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

355 return len(self._styles)