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
« 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
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
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 """
16 def render(self):
17 """
18 Render the object to an image.
20 Returns:
21 PIL.Image: The rendered image
22 """
24 @property
25 def origin(self):
26 return self._origin
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 """
35 def __init__(self, callback=None):
36 """
37 Initialize an interactable object.
39 Args:
40 callback: The function to call when this object is interacted with
41 """
42 self._callback = callback
44 def interact(self, point: np.generic):
45 """
46 Handle interaction at the given point.
48 Args:
49 point: The coordinates of the interaction
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)
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 """
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 """
72class Queriable(ABC):
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))
83# ==============================================================================
84# Mixins - Reusable components for common patterns
85# ==============================================================================
88class Hierarchical:
89 """
90 Mixin providing parent-child relationship management.
92 Classes using this mixin can track their parent in a document hierarchy.
93 """
95 def __init__(self, *args, **kwargs):
96 super().__init__(*args, **kwargs)
97 self._parent: Optional[Any] = None
99 @property
100 def parent(self) -> Optional[Any]:
101 """Get the parent object containing this object, if any"""
102 return self._parent
104 @parent.setter
105 def parent(self, parent: Any):
106 """Set the parent object"""
107 self._parent = parent
110class Geometric:
111 """
112 Mixin providing origin and size properties for positioned elements.
114 Provides standard geometric properties for elements that have a position
115 and size in 2D space. Uses numpy arrays for efficient calculations.
116 """
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])
123 @property
124 def origin(self) -> np.ndarray:
125 """Get the origin (top-left corner) of the element"""
126 return self._origin
128 @origin.setter
129 def origin(self, origin: np.ndarray):
130 """Set the origin of the element"""
131 self._origin = np.array(origin)
133 @property
134 def size(self) -> np.ndarray:
135 """Get the size (width, height) of the element"""
136 return self._size
138 @size.setter
139 def size(self, size: np.ndarray):
140 """Set the size of the element"""
141 self._size = np.array(size)
143 def set_origin(self, origin: np.ndarray):
144 """Set the origin of this element (alternative setter method)"""
145 self._origin = np.array(origin)
148class Styleable:
149 """
150 Mixin providing style property management.
152 Classes using this mixin can have a style property that can be
153 inherited from parents or set explicitly.
154 """
156 def __init__(self, *args, style=None, **kwargs):
157 super().__init__(*args, **kwargs)
158 self._style = style
160 @property
161 def style(self) -> Optional[Any]:
162 """Get the style for this element"""
163 return self._style
165 @style.setter
166 def style(self, style: Any):
167 """Set the style for this element"""
168 self._style = style
171class FontRegistry:
172 """
173 Mixin providing font caching and creation with parent delegation.
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.
179 Classes using this mixin should also use Hierarchical to support parent delegation.
180 """
182 def __init__(self, *args, **kwargs):
183 super().__init__(*args, **kwargs)
184 self._fonts: Dict[str, 'Font'] = {}
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.
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.
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.
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
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
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 )
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
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 )
262 # Convert tuple to string for dictionary key
263 key_str = str(font_key)
265 # Check if we already have this font
266 if key_str in self._fonts:
267 return self._fonts[key_str]
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 )
282 self._fonts[key_str] = new_font
283 return new_font
286class MetadataContainer:
287 """
288 Mixin providing metadata dictionary management.
290 Allows classes to store and retrieve arbitrary metadata as key-value pairs.
291 """
293 def __init__(self, *args, **kwargs):
294 super().__init__(*args, **kwargs)
295 self._metadata: Dict[Any, Any] = {}
297 def set_metadata(self, key: Any, value: Any):
298 """
299 Set a metadata value.
301 Args:
302 key: The metadata key
303 value: The metadata value
304 """
305 self._metadata[key] = value
307 def get_metadata(self, key: Any) -> Optional[Any]:
308 """
309 Get a metadata value.
311 Args:
312 key: The metadata key
314 Returns:
315 The metadata value, or None if not set
316 """
317 return self._metadata.get(key)
320class BlockContainer:
321 """
322 Mixin providing block management methods.
324 Provides standard methods for managing block-level children including
325 adding blocks and creating common block types.
327 Classes using this mixin should also use Styleable to support style inheritance.
328 """
330 def __init__(self, *args, **kwargs):
331 super().__init__(*args, **kwargs)
332 self._blocks = []
334 def blocks(self):
335 """
336 Get an iterator over the blocks in this container.
338 Can be used as blocks() for iteration or accessing the _blocks list directly.
340 Returns:
341 Iterator over blocks
342 """
343 return iter(self._blocks)
345 def add_block(self, block):
346 """
347 Add a block to this container.
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
356 def create_paragraph(self, style=None):
357 """
358 Create a new paragraph and add it to this container.
360 Args:
361 style: Optional style override. If None, inherits from container
363 Returns:
364 The newly created Paragraph object
365 """
366 from pyWebLayout.abstract.block import Paragraph
368 if style is None and hasattr(self, '_style'):
369 style = self._style
371 paragraph = Paragraph(style)
372 self.add_block(paragraph)
373 return paragraph
375 def create_heading(self, level=None, style=None):
376 """
377 Create a new heading and add it to this container.
379 Args:
380 level: The heading level (h1-h6)
381 style: Optional style override. If None, inherits from container
383 Returns:
384 The newly created Heading object
385 """
386 from pyWebLayout.abstract.block import Heading, HeadingLevel
388 if level is None:
389 level = HeadingLevel.H1
391 if style is None and hasattr(self, '_style'):
392 style = self._style
394 heading = Heading(level, style)
395 self.add_block(heading)
396 return heading
399class ContainerAware:
400 """
401 Mixin providing support for the create_and_add_to factory pattern.
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.
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 """
411 @classmethod
412 def _validate_container(cls, container, required_method='add_block'):
413 """
414 Validate that a container has the required method.
416 Args:
417 container: The container to validate
418 required_method: The method name to check for
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 )
428 @classmethod
429 def _inherit_style(cls, container, style=None):
430 """
431 Inherit style from container if not explicitly provided.
433 Args:
434 container: The container to inherit from
435 style: Optional explicit style
437 Returns:
438 The style to use (explicit or inherited)
439 """
440 if style is not None:
441 return style
443 if hasattr(container, 'style'):
444 return container.style
445 elif hasattr(container, 'default_style'):
446 return container.default_style
448 return None