Coverage for pyWebLayout/core/base.py: 72%

134 statements  

« prev     ^ index     » next       coverage.py v7.11.2, created at 2025-11-12 12:02 +0000

1from abc import ABC 

2from typing import Optional, Tuple, TYPE_CHECKING, Any, Dict 

3import numpy as np 

4 

5 

6if TYPE_CHECKING: 6 ↛ 7line 6 didn't jump to line 7 because the condition on line 6 was never true

7 from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration 

8 

9 

10class Renderable(ABC): 

11 """ 

12 Abstract base class for any object that can be rendered to an image. 

13 All renderable objects must implement the render method. 

14 """ 

15 

16 def render(self): 

17 """ 

18 Render the object to an image. 

19 

20 Returns: 

21 PIL.Image: The rendered image 

22 """ 

23 

24 @property 

25 def origin(self): 

26 return self._origin 

27 

28 

29class Interactable(ABC): 

30 """ 

31 Abstract base class for any object that can be interacted with. 

32 Interactable objects must have a callback that is executed when interacted with. 

33 """ 

34 

35 def __init__(self, callback=None): 

36 """ 

37 Initialize an interactable object. 

38 

39 Args: 

40 callback: The function to call when this object is interacted with 

41 """ 

42 self._callback = callback 

43 

44 def interact(self, point: np.generic): 

45 """ 

46 Handle interaction at the given point. 

47 

48 Args: 

49 point: The coordinates of the interaction 

50 

51 Returns: 

52 The result of calling the callback function with the point 

53 """ 

54 if self._callback is None: 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true

55 return None 

56 return self._callback(point) 

57 

58 

59class Layoutable(ABC): 

60 """ 

61 Abstract base class for any object that can be laid out. 

62 Layoutable objects must implement the layout method which arranges their contents. 

63 """ 

64 

65 def layout(self): 

66 """ 

67 Layout the object's contents. 

68 This method should be called before rendering to properly arrange the object's contents. 

69 """ 

70 

71 

72class Queriable(ABC): 

73 

74 def in_object(self, point: np.generic): 

75 """ 

76 check if a point is in the object 

77 """ 

78 point_array = np.array(point) 

79 relative_point = point_array - self._origin 

80 return np.all((0 <= relative_point) & (relative_point < self.size)) 

81 

82 

83# ============================================================================== 

84# Mixins - Reusable components for common patterns 

85# ============================================================================== 

86 

87 

88class Hierarchical: 

89 """ 

90 Mixin providing parent-child relationship management. 

91 

92 Classes using this mixin can track their parent in a document hierarchy. 

93 """ 

94 

95 def __init__(self, *args, **kwargs): 

96 super().__init__(*args, **kwargs) 

97 self._parent: Optional[Any] = None 

98 

99 @property 

100 def parent(self) -> Optional[Any]: 

101 """Get the parent object containing this object, if any""" 

102 return self._parent 

103 

104 @parent.setter 

105 def parent(self, parent: Any): 

106 """Set the parent object""" 

107 self._parent = parent 

108 

109 

110class Geometric: 

111 """ 

112 Mixin providing origin and size properties for positioned elements. 

113 

114 Provides standard geometric properties for elements that have a position 

115 and size in 2D space. Uses numpy arrays for efficient calculations. 

116 """ 

117 

118 def __init__(self, *args, origin=None, size=None, **kwargs): 

119 super().__init__(*args, **kwargs) 

120 self._origin = np.array(origin) if origin is not None else np.array([0, 0]) 

121 self._size = np.array(size) if size is not None else np.array([0, 0]) 

122 

123 @property 

124 def origin(self) -> np.ndarray: 

125 """Get the origin (top-left corner) of the element""" 

126 return self._origin 

127 

128 @origin.setter 

129 def origin(self, origin: np.ndarray): 

130 """Set the origin of the element""" 

131 self._origin = np.array(origin) 

132 

133 @property 

134 def size(self) -> np.ndarray: 

135 """Get the size (width, height) of the element""" 

136 return self._size 

137 

138 @size.setter 

139 def size(self, size: np.ndarray): 

140 """Set the size of the element""" 

141 self._size = np.array(size) 

142 

143 def set_origin(self, origin: np.ndarray): 

144 """Set the origin of this element (alternative setter method)""" 

145 self._origin = np.array(origin) 

146 

147 

148class Styleable: 

149 """ 

150 Mixin providing style property management. 

151 

152 Classes using this mixin can have a style property that can be 

153 inherited from parents or set explicitly. 

154 """ 

155 

156 def __init__(self, *args, style=None, **kwargs): 

157 super().__init__(*args, **kwargs) 

158 self._style = style 

159 

160 @property 

161 def style(self) -> Optional[Any]: 

162 """Get the style for this element""" 

163 return self._style 

164 

165 @style.setter 

166 def style(self, style: Any): 

167 """Set the style for this element""" 

168 self._style = style 

169 

170 

171class FontRegistry: 

172 """ 

173 Mixin providing font caching and creation with parent delegation. 

174 

175 This mixin allows classes to maintain a local font registry and create/reuse 

176 Font objects efficiently. It supports parent delegation, where font requests 

177 can cascade up to a parent container if one exists. 

178 

179 Classes using this mixin should also use Hierarchical to support parent delegation. 

180 """ 

181 

182 def __init__(self, *args, **kwargs): 

183 super().__init__(*args, **kwargs) 

184 self._fonts: Dict[str, 'Font'] = {} 

185 

186 def get_or_create_font(self, 

187 font_path: Optional[str] = None, 

188 font_size: int = 16, 

189 colour: Tuple[int, int, int] = (0, 0, 0), 

190 weight: 'FontWeight' = None, 

191 style: 'FontStyle' = None, 

192 decoration: 'TextDecoration' = None, 

193 background: Optional[Tuple[int, int, int, int]] = None, 

194 language: str = "en_EN", 

195 min_hyphenation_width: Optional[int] = None) -> 'Font': 

196 """ 

197 Get or create a font with the specified properties. 

198 

199 This method will first check if a parent object has a get_or_create_font 

200 method and delegate to it. Otherwise, it will manage fonts locally. 

201 

202 Args: 

203 font_path: Path to the font file (.ttf, .otf). If None, uses default font. 

204 font_size: Size of the font in points. 

205 colour: RGB color tuple for the text. 

206 weight: Font weight (normal or bold). 

207 style: Font style (normal or italic). 

208 decoration: Text decoration (none, underline, or strikethrough). 

209 background: RGBA background color for the text. If None, transparent background. 

210 language: Language code for hyphenation and text processing. 

211 min_hyphenation_width: Minimum width in pixels required for hyphenation. 

212 

213 Returns: 

214 Font object (either existing or newly created) 

215 """ 

216 # Import here to avoid circular imports 

217 from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration 

218 

219 # Set defaults for enum types 

220 if weight is None: 

221 weight = FontWeight.NORMAL 

222 if style is None: 

223 style = FontStyle.NORMAL 

224 if decoration is None: 

225 decoration = TextDecoration.NONE 

226 

227 # If we have a parent with font management, delegate to parent 

228 if hasattr( 

229 self, 

230 '_parent') and self._parent and hasattr( 

231 self._parent, 

232 'get_or_create_font'): 

233 return self._parent.get_or_create_font( 

234 font_path=font_path, 

235 font_size=font_size, 

236 colour=colour, 

237 weight=weight, 

238 style=style, 

239 decoration=decoration, 

240 background=background, 

241 language=language, 

242 min_hyphenation_width=min_hyphenation_width 

243 ) 

244 

245 # Otherwise manage our own fonts 

246 # Create a unique key for this font configuration 

247 bg_tuple = background if background else (255, 255, 255, 0) 

248 min_hyph_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4 

249 

250 font_key = ( 

251 font_path, 

252 font_size, 

253 colour, 

254 weight.value if hasattr(weight, 'value') else weight, 

255 style.value if hasattr(style, 'value') else style, 

256 decoration.value if hasattr(decoration, 'value') else decoration, 

257 bg_tuple, 

258 language, 

259 min_hyph_width 

260 ) 

261 

262 # Convert tuple to string for dictionary key 

263 key_str = str(font_key) 

264 

265 # Check if we already have this font 

266 if key_str in self._fonts: 

267 return self._fonts[key_str] 

268 

269 # Create new font and store it 

270 new_font = Font( 

271 font_path=font_path, 

272 font_size=font_size, 

273 colour=colour, 

274 weight=weight, 

275 style=style, 

276 decoration=decoration, 

277 background=background, 

278 language=language, 

279 min_hyphenation_width=min_hyphenation_width 

280 ) 

281 

282 self._fonts[key_str] = new_font 

283 return new_font 

284 

285 

286class MetadataContainer: 

287 """ 

288 Mixin providing metadata dictionary management. 

289 

290 Allows classes to store and retrieve arbitrary metadata as key-value pairs. 

291 """ 

292 

293 def __init__(self, *args, **kwargs): 

294 super().__init__(*args, **kwargs) 

295 self._metadata: Dict[Any, Any] = {} 

296 

297 def set_metadata(self, key: Any, value: Any): 

298 """ 

299 Set a metadata value. 

300 

301 Args: 

302 key: The metadata key 

303 value: The metadata value 

304 """ 

305 self._metadata[key] = value 

306 

307 def get_metadata(self, key: Any) -> Optional[Any]: 

308 """ 

309 Get a metadata value. 

310 

311 Args: 

312 key: The metadata key 

313 

314 Returns: 

315 The metadata value, or None if not set 

316 """ 

317 return self._metadata.get(key) 

318 

319 

320class BlockContainer: 

321 """ 

322 Mixin providing block management methods. 

323 

324 Provides standard methods for managing block-level children including 

325 adding blocks and creating common block types. 

326 

327 Classes using this mixin should also use Styleable to support style inheritance. 

328 """ 

329 

330 def __init__(self, *args, **kwargs): 

331 super().__init__(*args, **kwargs) 

332 self._blocks = [] 

333 

334 def blocks(self): 

335 """ 

336 Get an iterator over the blocks in this container. 

337 

338 Can be used as blocks() for iteration or accessing the _blocks list directly. 

339 

340 Returns: 

341 Iterator over blocks 

342 """ 

343 return iter(self._blocks) 

344 

345 def add_block(self, block): 

346 """ 

347 Add a block to this container. 

348 

349 Args: 

350 block: The block to add 

351 """ 

352 self._blocks.append(block) 

353 if hasattr(block, 'parent'): 353 ↛ exitline 353 didn't return from function 'add_block' because the condition on line 353 was always true

354 block.parent = self 

355 

356 def create_paragraph(self, style=None): 

357 """ 

358 Create a new paragraph and add it to this container. 

359 

360 Args: 

361 style: Optional style override. If None, inherits from container 

362 

363 Returns: 

364 The newly created Paragraph object 

365 """ 

366 from pyWebLayout.abstract.block import Paragraph 

367 

368 if style is None and hasattr(self, '_style'): 

369 style = self._style 

370 

371 paragraph = Paragraph(style) 

372 self.add_block(paragraph) 

373 return paragraph 

374 

375 def create_heading(self, level=None, style=None): 

376 """ 

377 Create a new heading and add it to this container. 

378 

379 Args: 

380 level: The heading level (h1-h6) 

381 style: Optional style override. If None, inherits from container 

382 

383 Returns: 

384 The newly created Heading object 

385 """ 

386 from pyWebLayout.abstract.block import Heading, HeadingLevel 

387 

388 if level is None: 

389 level = HeadingLevel.H1 

390 

391 if style is None and hasattr(self, '_style'): 

392 style = self._style 

393 

394 heading = Heading(level, style) 

395 self.add_block(heading) 

396 return heading 

397 

398 

399class ContainerAware: 

400 """ 

401 Mixin providing support for the create_and_add_to factory pattern. 

402 

403 This is a base that can be extended to provide the create_and_add_to 

404 class method pattern used throughout the abstract module. 

405 

406 Note: This is a framework for future refactoring. Currently, each class 

407 has its own create_and_add_to implementation due to varying constructor 

408 signatures. This mixin provides a foundation for standardizing that pattern. 

409 """ 

410 

411 @classmethod 

412 def _validate_container(cls, container, required_method='add_block'): 

413 """ 

414 Validate that a container has the required method. 

415 

416 Args: 

417 container: The container to validate 

418 required_method: The method name to check for 

419 

420 Raises: 

421 AttributeError: If the container doesn't have the required method 

422 """ 

423 if not hasattr(container, required_method): 

424 raise AttributeError( 

425 f"Container {type(container).__name__} must have a '{required_method}' method" 

426 ) 

427 

428 @classmethod 

429 def _inherit_style(cls, container, style=None): 

430 """ 

431 Inherit style from container if not explicitly provided. 

432 

433 Args: 

434 container: The container to inherit from 

435 style: Optional explicit style 

436 

437 Returns: 

438 The style to use (explicit or inherited) 

439 """ 

440 if style is not None: 

441 return style 

442 

443 if hasattr(container, 'style'): 

444 return container.style 

445 elif hasattr(container, 'default_style'): 

446 return container.default_style 

447 

448 return None