Coverage for pyWebLayout/concrete/page.py: 66%
204 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 typing import List, Tuple, Optional
2import numpy as np
3from PIL import Image, ImageDraw
5from pyWebLayout.core.base import Renderable, Queriable
6from pyWebLayout.core.query import QueryResult, SelectionRange
7from pyWebLayout.core.callback_registry import CallbackRegistry
8from pyWebLayout.style.page_style import PageStyle
11class Page(Renderable, Queriable):
12 """
13 A page represents a canvas that can hold and render child renderable objects.
14 It handles layout, rendering, and provides query capabilities to find which child
15 contains a given point.
16 """
18 def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None):
19 """
20 Initialize a new page.
22 Args:
23 size: The total size of the page (width, height) including borders
24 style: The PageStyle defining borders, spacing, and appearance
25 """
26 self._size = size
27 self._style = style if style is not None else PageStyle()
28 self._children: List[Renderable] = []
29 self._canvas: Optional[Image.Image] = None
30 self._draw: Optional[ImageDraw.Draw] = None
31 # Initialize y_offset to start of content area
32 # Position the first line so its baseline is close to the top boundary
33 # For subsequent lines, baseline-to-baseline spacing is used
34 self._current_y_offset = self._style.border_width + self._style.padding_top
35 self._is_first_line = True # Track if we're placing the first line
36 # Callback registry for managing interactable elements
37 self._callbacks = CallbackRegistry()
38 # Dirty flag to track if page needs re-rendering due to state changes
39 self._dirty = True
41 def free_space(self) -> Tuple[int, int]:
42 """Get the remaining space on the page"""
43 return (self._size[0], self._size[1] - self._current_y_offset)
45 def can_fit_line(
46 self,
47 baseline_spacing: int,
48 ascent: int = 0,
49 descent: int = 0) -> bool:
50 """
51 Check if a line with the given metrics can fit on the page.
53 Args:
54 baseline_spacing: Distance from current position to next baseline
55 ascent: Font ascent (height above baseline), defaults to 0 for backward compat
56 descent: Font descent (height below baseline), defaults to 0 for backward compat
58 Returns:
59 True if the line fits within page boundaries
60 """
61 # Calculate the maximum Y position allowed (bottom boundary)
62 max_y = self._size[1] - self._style.border_width - self._style.padding_bottom
64 # If ascent/descent not provided, use simple check (backward compatibility)
65 if ascent == 0 and descent == 0:
66 return (self._current_y_offset + baseline_spacing) <= max_y
68 # Calculate where the bottom of the text would be
69 # Text bottom = current_y_offset + ascent + descent
70 text_bottom = self._current_y_offset + ascent + descent
72 # Check if text bottom would exceed the boundary
73 return text_bottom <= max_y
75 @property
76 def size(self) -> Tuple[int, int]:
77 """Get the total page size including borders"""
78 return self._size
80 @property
81 def canvas_size(self) -> Tuple[int, int]:
82 """Get the canvas size (page size minus borders)"""
83 border_reduction = self._style.total_border_width
84 return (
85 self._size[0] - border_reduction,
86 self._size[1] - border_reduction
87 )
89 @property
90 def content_size(self) -> Tuple[int, int]:
91 """Get the content area size (canvas minus padding)"""
92 canvas_w, canvas_h = self.canvas_size
93 return (
94 canvas_w - self._style.total_horizontal_padding,
95 canvas_h - self._style.total_vertical_padding
96 )
98 @property
99 def border_size(self) -> int:
100 """Get the border width"""
101 return self._style.border_width
103 @property
104 def available_width(self) -> int:
105 """Get the available width for content (content area width)"""
106 return self.content_size[0]
108 @property
109 def style(self) -> PageStyle:
110 """Get the page style"""
111 return self._style
113 @property
114 def callbacks(self) -> CallbackRegistry:
115 """Get the callback registry for managing interactable elements"""
116 return self._callbacks
118 @property
119 def is_dirty(self) -> bool:
120 """Check if the page needs re-rendering due to state changes"""
121 return self._dirty
123 def mark_dirty(self):
124 """Mark the page as needing re-rendering"""
125 self._dirty = True
127 def mark_clean(self):
128 """Mark the page as clean (up-to-date render)"""
129 self._dirty = False
131 @property
132 def draw(self) -> Optional[ImageDraw.Draw]:
133 """Get the ImageDraw object for drawing on this page's canvas"""
134 if self._draw is None:
135 # Initialize canvas and draw context if not already done
136 self._canvas = self._create_canvas()
137 self._draw = ImageDraw.Draw(self._canvas)
138 return self._draw
140 def add_child(self, child: Renderable) -> 'Page':
141 """
142 Add a child renderable object to this page.
144 Args:
145 child: The renderable object to add
147 Returns:
148 Self for method chaining
149 """
150 self._children.append(child)
151 self._current_y_offset = child.origin[1] + child.size[1]
152 # Invalidate the canvas when children change
153 self._canvas = None
154 return self
156 def remove_child(self, child: Renderable) -> bool:
157 """
158 Remove a child from the page.
160 Args:
161 child: The child to remove
163 Returns:
164 True if the child was found and removed, False otherwise
165 """
166 try:
167 self._children.remove(child)
168 self._canvas = None
169 return True
170 except ValueError:
171 return False
173 def clear_children(self) -> 'Page':
174 """
175 Remove all children from the page.
177 Returns:
178 Self for method chaining
179 """
180 self._children.clear()
181 self._canvas = None
182 # Clear callback registry when clearing children
183 self._callbacks.clear()
184 # Reset y_offset to start of content area (after border and padding)
185 self._current_y_offset = self._style.border_width + self._style.padding_top
186 return self
188 @property
189 def children(self) -> List[Renderable]:
190 """Get a copy of the children list"""
191 return self._children.copy()
193 def _get_child_property(self, child: Renderable, private_attr: str,
194 public_attr: str, index: Optional[int] = None,
195 default: Optional[int] = None) -> Optional[int]:
196 """
197 Generic helper to extract properties from child objects with multiple fallback strategies.
199 Args:
200 child: The child object
201 private_attr: Name of the private attribute (e.g., '_size')
202 public_attr: Name of the public property (e.g., 'size')
203 index: Optional index for array-like properties (0 for width, 1 for height)
204 default: Default value if property cannot be determined
206 Returns:
207 Property value or default
208 """
209 # Try private attribute first
210 if hasattr(child, private_attr):
211 value = getattr(child, private_attr)
212 if value is not None:
213 if isinstance(value, (list, tuple, np.ndarray)):
214 if index is not None and len(value) > index:
215 return int(value[index])
216 elif index is None:
217 return value
219 # Try public property
220 if hasattr(child, public_attr):
221 value = getattr(child, public_attr)
222 if value is not None:
223 if isinstance(value, (list, tuple, np.ndarray)):
224 if index is not None and len(value) > index:
225 return int(value[index])
226 elif index is None:
227 return value
228 else:
229 return int(value)
231 return default
233 def _get_child_height(self, child: Renderable) -> int:
234 """
235 Get the height of a child object.
237 Args:
238 child: The child to measure
240 Returns:
241 Height in pixels
242 """
243 # Try to get height from size property (index 1)
244 height = self._get_child_property(child, '_size', 'size', index=1)
245 if height is not None:
246 return height
248 # Try direct height attribute
249 height = self._get_child_property(child, '_height', 'height')
250 if height is not None:
251 return height
253 # Default fallback height
254 return 20
256 def render_children(self):
257 """
258 Call render on all children in the list.
259 Children draw directly onto the page's canvas via the shared ImageDraw object.
260 """
261 for child in self._children:
262 # Synchronize draw context for Line objects before rendering
263 if hasattr(child, '_draw'):
264 child._draw = self._draw
265 # Synchronize canvas for Image objects before rendering
266 if hasattr(child, '_canvas'): 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 child._canvas = self._canvas
268 if hasattr(child, 'render'): 268 ↛ 261line 268 didn't jump to line 261 because the condition on line 268 was always true
269 child.render()
271 def render(self) -> Image.Image:
272 """
273 Render the page with all its children.
275 Returns:
276 PIL Image containing the rendered page
277 """
278 # Create the base canvas and draw object
279 self._canvas = self._create_canvas()
280 self._draw = ImageDraw.Draw(self._canvas)
282 # Render all children - they draw directly onto the canvas
283 self.render_children()
285 # Mark as clean after rendering
286 self._dirty = False
288 return self._canvas
290 def _create_canvas(self) -> Image.Image:
291 """
292 Create the base canvas with background and borders.
294 Returns:
295 PIL Image with background and borders applied
296 """
297 # Create base image
298 canvas = Image.new('RGBA', self._size, (*self._style.background_color, 255))
300 # Draw borders if needed
301 if self._style.border_width > 0:
302 draw = ImageDraw.Draw(canvas)
303 border_color = (*self._style.border_color, 255)
305 # Draw border rectangle inside the content area
306 border_offset = self._style.border_width
307 draw.rectangle([
308 (border_offset, border_offset),
309 (self._size[0] - border_offset - 1, self._size[1] - border_offset - 1)
310 ], outline=border_color)
312 return canvas
314 def _get_child_position(self, child: Renderable) -> Tuple[int, int]:
315 """
316 Get the position where a child should be rendered.
318 Args:
319 child: The child object
321 Returns:
322 Tuple of (x, y) coordinates
323 """
324 # Try to get x coordinate
325 x = self._get_child_property(child, '_origin', 'position', index=0, default=0)
326 # Try to get y coordinate
327 y = self._get_child_property(child, '_origin', 'position', index=1, default=0)
329 return (x, y)
331 def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]:
332 """
333 Query a point to find the deepest object at that location.
334 Traverses children and uses Queriable.in_object() for hit-testing.
336 Args:
337 point: The (x, y) coordinates to query
339 Returns:
340 QueryResult with metadata about what was found, or None if nothing hit
341 """
342 point_array = np.array(point)
344 # Check each child (in reverse order so topmost child is found first)
345 for child in reversed(self._children):
346 # Use Queriable mixin's in_object() for hit-testing
347 if isinstance(child, Queriable) and child.in_object(point_array):
348 # If child can also query (has children of its own), recurse
349 if hasattr(child, 'query_point'):
350 result = child.query_point(point)
351 if result: 351 ↛ 355line 351 didn't jump to line 355 because the condition on line 351 was always true
352 result.parent_page = self
353 return result
354 # If child's query returned None, continue to next child
355 continue
357 # Otherwise, package this child as the result
358 return self._make_query_result(child, point)
360 # Nothing hit - return empty result
361 return QueryResult(
362 object=self,
363 object_type="empty",
364 bounds=(int(point[0]), int(point[1]), 0, 0)
365 )
367 def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
368 """
369 Check if a point is within a child's bounds.
371 Args:
372 point: The point to check
373 child: The child to check against
375 Returns:
376 True if the point is within the child's bounds
377 """
378 # If child implements Queriable interface, use it
379 if isinstance(child, Queriable) and hasattr(child, 'in_object'):
380 try:
381 return child.in_object(point)
382 except BaseException:
383 pass # Fall back to bounds checking
385 # Get child position and size for bounds checking
386 child_pos = self._get_child_position(child)
387 child_size = self._get_child_size(child)
389 if child_size is None:
390 return False
392 # Check if point is within child bounds
393 return (
394 child_pos[0] <= point[0] < child_pos[0] + child_size[0] and
395 child_pos[1] <= point[1] < child_pos[1] + child_size[1]
396 )
398 def _get_child_size(self, child: Renderable) -> Optional[Tuple[int, int]]:
399 """
400 Get the size of a child object.
402 Args:
403 child: The child to measure
405 Returns:
406 Tuple of (width, height) or None if size cannot be determined
407 """
408 # Try to get width and height from size property
409 width = self._get_child_property(child, '_size', 'size', index=0)
410 height = self._get_child_property(child, '_size', 'size', index=1)
412 # If size property worked, return it
413 if width is not None and height is not None:
414 return (width, height)
416 # Try direct width/height attributes
417 width = self._get_child_property(child, '_width', 'width')
418 height = self._get_child_property(child, '_height', 'height')
420 if width is not None and height is not None:
421 return (width, height)
423 return None
425 def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult:
426 """
427 Package an object into a QueryResult with metadata.
429 Args:
430 obj: The object to package
431 point: The query point
433 Returns:
434 QueryResult with extracted metadata
435 """
436 from .text import Text
437 from .functional import LinkText, ButtonText
439 # Extract bounds
440 origin = getattr(obj, '_origin', np.array([0, 0]))
441 size = getattr(obj, 'size', np.array([0, 0]))
442 bounds = (
443 int(origin[0]),
444 int(origin[1]),
445 int(size[0]) if hasattr(size, '__getitem__') else 0,
446 int(size[1]) if hasattr(size, '__getitem__') else 0
447 )
449 # Determine type and extract metadata
450 if isinstance(obj, LinkText):
451 return QueryResult(
452 object=obj,
453 object_type="link",
454 bounds=bounds,
455 text=obj._text,
456 is_interactive=True,
457 link_target=obj._link.location if hasattr(obj, '_link') else None
458 )
459 elif isinstance(obj, ButtonText): 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true
460 return QueryResult(
461 object=obj,
462 object_type="button",
463 bounds=bounds,
464 text=obj._text,
465 is_interactive=True,
466 callback=obj._callback if hasattr(obj, '_callback') else None
467 )
468 elif isinstance(obj, Text):
469 return QueryResult(
470 object=obj,
471 object_type="text",
472 bounds=bounds,
473 text=obj._text if hasattr(obj, '_text') else None
474 )
475 else:
476 return QueryResult(
477 object=obj,
478 object_type="unknown",
479 bounds=bounds
480 )
482 def query_range(self, start: Tuple[int, int],
483 end: Tuple[int, int]) -> SelectionRange:
484 """
485 Query all text objects between two points (for text selection).
486 Uses Queriable.in_object() to determine which objects are in range.
488 Args:
489 start: Starting (x, y) point
490 end: Ending (x, y) point
492 Returns:
493 SelectionRange with all text objects between the points
494 """
495 results = []
496 in_selection = False
498 start_result = self.query_point(start)
499 end_result = self.query_point(end)
501 if not start_result or not end_result: 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true
502 return SelectionRange(start, end, [])
504 # Walk through all children (Lines) and their text objects
505 from .text import Line, Text
507 for child in self._children:
508 if isinstance(child, Line) and hasattr(child, '_text_objects'): 508 ↛ 507line 508 didn't jump to line 507 because the condition on line 508 was always true
509 for text_obj in child._text_objects: 509 ↛ 507line 509 didn't jump to line 507 because the loop on line 509 didn't complete
510 # Check if this text is the start or is between start and end
511 if text_obj == start_result.object:
512 in_selection = True
514 if in_selection and isinstance(text_obj, Text): 514 ↛ 518line 514 didn't jump to line 518 because the condition on line 514 was always true
515 result = self._make_query_result(text_obj, start)
516 results.append(result)
518 if text_obj == end_result.object:
519 in_selection = False
520 break
522 return SelectionRange(start, end, results)
524 def in_object(self, point: Tuple[int, int]) -> bool:
525 """
526 Check if a point is within this page's bounds.
528 Args:
529 point: The (x, y) coordinates to check
531 Returns:
532 True if the point is within the page bounds
533 """
534 return (
535 0 <= point[0] < self._size[0] and
536 0 <= point[1] < self._size[1]
537 )