Coverage for pyWebLayout/concrete/page.py: 95%
176 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +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 # Mode of the render canvas. The measurement context matches it so that text
19 # width caching keys stay consistent between layout and rendering.
20 _CANVAS_MODE = 'RGBA'
22 def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None,
23 origin: Tuple[int, int] = (0, 0)):
24 """
25 Initialize a new page.
27 Args:
28 size: The total size of the page (width, height) including borders
29 style: The PageStyle defining borders, spacing, and appearance
30 origin: Absolute position of the page's top-left corner. Non-zero for
31 a page nested inside another surface, such as a table cell.
32 """
33 self._size = size
34 self._origin = origin
35 self._style = style if style is not None else PageStyle()
36 self._children: List[Renderable] = []
37 self._canvas: Optional[Image.Image] = None
38 self._draw: Optional[ImageDraw.Draw] = None
39 self._measurement_draw: Optional[ImageDraw.ImageDraw] = None
40 # Initialize y_offset to start of content area
41 # Position the first line so its baseline is close to the top boundary
42 # For subsequent lines, baseline-to-baseline spacing is used
43 self._current_y_offset = (self._origin[1] + self._style.border_width
44 + self._style.padding_top)
45 self._is_first_line = True # Track if we're placing the first line
46 # Callback registry for managing interactable elements
47 self._callbacks = CallbackRegistry()
48 # Dirty flag to track if page needs re-rendering due to state changes
49 self._dirty = True
51 def free_space(self) -> Tuple[int, int]:
52 """
53 Get the remaining space in the content area.
55 Deprecated: use content_rect and remaining_height, which this delegates to.
56 """
57 return (self.content_rect[2], self.remaining_height)
59 def can_fit_line(
60 self,
61 baseline_spacing: int,
62 ascent: int = 0,
63 descent: int = 0) -> bool:
64 """
65 Check if a line with the given metrics can fit on the page.
67 Args:
68 baseline_spacing: Distance from current position to next baseline
69 ascent: Font ascent (height above baseline), defaults to 0 for backward compat
70 descent: Font descent (height below baseline), defaults to 0 for backward compat
72 Returns:
73 True if the line fits within page boundaries
74 """
75 # Calculate the maximum Y position allowed (bottom boundary)
76 content_y, content_h = self.content_rect[1], self.content_rect[3]
77 max_y = content_y + content_h
79 # If ascent/descent not provided, use simple check (backward compatibility)
80 if ascent == 0 and descent == 0:
81 return (self._current_y_offset + baseline_spacing) <= max_y
83 # Calculate where the bottom of the text would be
84 # Text bottom = current_y_offset + ascent + descent
85 text_bottom = self._current_y_offset + ascent + descent
87 # Check if text bottom would exceed the boundary
88 return text_bottom <= max_y
90 @property
91 def size(self) -> Tuple[int, int]:
92 """Get the total page size including borders"""
93 return self._size
95 @property
96 def origin(self) -> Tuple[int, int]:
97 """Absolute position of the page's top-left corner"""
98 return self._origin
100 @property
101 def content_origin(self) -> Tuple[int, int]:
102 """
103 Absolute top-left of the content box: the page origin plus its border and
104 top/left padding. Layout starts here.
105 """
106 return (
107 self._origin[0] + self._style.border_width + self._style.padding_left,
108 self._origin[1] + self._style.border_width + self._style.padding_top,
109 )
111 @property
112 def content_rect(self) -> Tuple[int, int, int, int]:
113 """(x, y, width, height) of the content box, in absolute coordinates"""
114 x, y = self.content_origin
115 return (x, y, self.content_size[0], self.content_size[1])
117 @property
118 def remaining_height(self) -> int:
119 """Content-box height still available below the current layout cursor"""
120 _, y, _, h = self.content_rect
121 return max(0, y + h - self._current_y_offset)
123 @property
124 def canvas_size(self) -> Tuple[int, int]:
125 """Get the canvas size (page size minus borders)"""
126 border_reduction = self._style.total_border_width
127 return (
128 self._size[0] - border_reduction,
129 self._size[1] - border_reduction
130 )
132 @property
133 def content_size(self) -> Tuple[int, int]:
134 """Get the content area size (canvas minus padding)"""
135 canvas_w, canvas_h = self.canvas_size
136 return (
137 canvas_w - self._style.total_horizontal_padding,
138 canvas_h - self._style.total_vertical_padding
139 )
141 @property
142 def border_size(self) -> int:
143 """Get the border width"""
144 return self._style.border_width
146 @property
147 def available_width(self) -> int:
148 """Get the available width for content (content area width)"""
149 return self.content_size[0]
151 @property
152 def style(self) -> PageStyle:
153 """Get the page style"""
154 return self._style
156 @property
157 def callbacks(self) -> CallbackRegistry:
158 """Get the callback registry for managing interactable elements"""
159 return self._callbacks
161 @property
162 def is_dirty(self) -> bool:
163 """Check if the page needs re-rendering due to state changes"""
164 return self._dirty
166 def mark_dirty(self):
167 """Mark the page as needing re-rendering"""
168 self._dirty = True
170 def mark_clean(self):
171 """Mark the page as clean (up-to-date render)"""
172 self._dirty = False
174 @property
175 def draw(self) -> Optional[ImageDraw.Draw]:
176 """
177 Get the ImageDraw object bound to this page's render canvas.
179 Rebuilt whenever the canvas has been invalidated: a draw context
180 outlives the image it was created from, so checking only _draw would
181 hand back a context pointing at a discarded canvas.
182 """
183 if self._draw is None or self._canvas is None:
184 # Initialize canvas and draw context if not already done
185 self._canvas = self._create_canvas()
186 self._draw = ImageDraw.Draw(self._canvas)
187 return self._draw
189 @property
190 def measurement_draw(self) -> ImageDraw.ImageDraw:
191 """
192 A scratch draw context for text metrics during layout.
194 Layout asks for text widths constantly, but has no reason to touch the
195 render canvas - and the canvas is invalidated on every add_child, so
196 measuring through `draw` would allocate a full-page image per line.
197 This context is 1x1 and never invalidated.
199 Its mode matches the render canvas because Text keys its width cache on
200 the draw mode; a mismatch would double every cache entry. Children built
201 against it are re-bound to the real canvas by render_children.
202 """
203 if self._measurement_draw is None:
204 scratch = Image.new(self._CANVAS_MODE, (1, 1))
205 self._measurement_draw = ImageDraw.Draw(scratch)
206 return self._measurement_draw
208 def add_child(self, child: Renderable) -> 'Page':
209 """
210 Add a child renderable object to this page.
212 Args:
213 child: The renderable object to add
215 Returns:
216 Self for method chaining
217 """
218 self._children.append(child)
219 self._current_y_offset = child.origin[1] + child.size[1]
220 # Invalidate the canvas when children change
221 self._canvas = None
222 return self
224 def remove_child(self, child: Renderable) -> bool:
225 """
226 Remove a child from the page.
228 Args:
229 child: The child to remove
231 Returns:
232 True if the child was found and removed, False otherwise
233 """
234 try:
235 self._children.remove(child)
236 self._canvas = None
237 return True
238 except ValueError:
239 return False
241 def clear_children(self) -> 'Page':
242 """
243 Remove all children from the page.
245 Returns:
246 Self for method chaining
247 """
248 self._children.clear()
249 self._canvas = None
250 # Clear callback registry when clearing children
251 self._callbacks.clear()
252 # Reset y_offset to start of content area (after border and padding)
253 self._current_y_offset = self.content_origin[1]
254 return self
256 @property
257 def children(self) -> List[Renderable]:
258 """Get a copy of the children list"""
259 return self._children.copy()
261 def render_children(self):
262 """
263 Call render on all children in the list.
264 Children draw directly onto the page's canvas via the shared ImageDraw object.
265 """
266 for child in self._children:
267 # Synchronize draw context for Line objects before rendering
268 if hasattr(child, '_draw'):
269 child._draw = self._draw
270 # Synchronize canvas for Image objects before rendering
271 if hasattr(child, '_canvas'): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 child._canvas = self._canvas
273 if hasattr(child, 'render'): 273 ↛ 266line 273 didn't jump to line 266 because the condition on line 273 was always true
274 child.render()
276 def render(self) -> Image.Image:
277 """
278 Render the page with all its children.
280 Returns:
281 PIL Image containing the rendered page
282 """
283 # Create the base canvas and draw object
284 self._canvas = self._create_canvas()
285 self._draw = ImageDraw.Draw(self._canvas)
287 # Render all children - they draw directly onto the canvas
288 self.render_children()
290 # Mark as clean after rendering
291 self._dirty = False
293 return self._canvas
295 def _create_canvas(self) -> Image.Image:
296 """
297 Create the base canvas with background and borders.
299 Returns:
300 PIL Image with background and borders applied
301 """
302 # Create base image
303 canvas = Image.new(self._CANVAS_MODE, self._size, (*self._style.background_color, 255))
305 # Draw borders if needed
306 if self._style.border_width > 0:
307 draw = ImageDraw.Draw(canvas)
308 border_color = (*self._style.border_color, 255)
310 # Draw border rectangle inside the content area
311 border_offset = self._style.border_width
312 draw.rectangle([
313 (border_offset, border_offset),
314 (self._size[0] - border_offset - 1, self._size[1] - border_offset - 1)
315 ], outline=border_color)
317 return canvas
319 def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]:
320 """
321 Query a point to find the deepest object at that location.
322 Traverses children and uses Queriable.in_object() for hit-testing.
324 Args:
325 point: The (x, y) coordinates to query
327 Returns:
328 QueryResult with metadata about what was found, or None if nothing hit
329 """
330 point_array = np.array(point)
332 # Check each child (in reverse order so topmost child is found first)
333 for child in reversed(self._children):
334 # Use Queriable mixin's in_object() for hit-testing
335 if isinstance(child, Queriable) and child.in_object(point_array):
336 # If child can also query (has children of its own), recurse
337 if hasattr(child, 'query_point'):
338 result = child.query_point(point)
339 if result:
340 result.parent_page = self
341 return result
342 # If child's query returned None, continue to next child
343 continue
345 # Otherwise, package this child as the result
346 return self._make_query_result(child, point)
348 # Nothing hit - return empty result
349 return QueryResult(
350 object=self,
351 object_type="empty",
352 bounds=(int(point[0]), int(point[1]), 0, 0)
353 )
355 def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult:
356 """
357 Package an object into a QueryResult with metadata.
359 Args:
360 obj: The object to package
361 point: The query point
363 Returns:
364 QueryResult with extracted metadata
365 """
366 from .text import Text
367 from .functional import LinkText, ButtonText
369 # Extract bounds
370 origin = getattr(obj, '_origin', np.array([0, 0]))
371 size = getattr(obj, 'size', np.array([0, 0]))
372 bounds = (
373 int(origin[0]),
374 int(origin[1]),
375 int(size[0]) if hasattr(size, '__getitem__') else 0,
376 int(size[1]) if hasattr(size, '__getitem__') else 0
377 )
379 # Determine type and extract metadata
380 if isinstance(obj, LinkText):
381 return QueryResult(
382 object=obj,
383 object_type="link",
384 bounds=bounds,
385 text=obj._text,
386 is_interactive=True,
387 link_target=obj._link.location if hasattr(obj, '_link') else None
388 )
389 elif isinstance(obj, ButtonText): 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true
390 return QueryResult(
391 object=obj,
392 object_type="button",
393 bounds=bounds,
394 text=obj._text,
395 is_interactive=True,
396 callback=obj._callback if hasattr(obj, '_callback') else None
397 )
398 elif isinstance(obj, Text):
399 return QueryResult(
400 object=obj,
401 object_type="text",
402 bounds=bounds,
403 text=obj._text if hasattr(obj, '_text') else None
404 )
405 else:
406 return QueryResult(
407 object=obj,
408 object_type="unknown",
409 bounds=bounds
410 )
412 def query_range(self, start: Tuple[int, int],
413 end: Tuple[int, int]) -> SelectionRange:
414 """
415 Query all text objects between two points (for text selection).
416 Uses Queriable.in_object() to determine which objects are in range.
418 Args:
419 start: Starting (x, y) point
420 end: Ending (x, y) point
422 Returns:
423 SelectionRange with all text objects between the points
424 """
425 results = []
426 in_selection = False
428 start_result = self.query_point(start)
429 end_result = self.query_point(end)
431 if not start_result or not end_result: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 return SelectionRange(start, end, [])
434 # Walk through all children (Lines) and their text objects
435 from .text import Line, Text
437 for child in self._children:
438 if isinstance(child, Line) and hasattr(child, '_text_objects'): 438 ↛ 437line 438 didn't jump to line 437 because the condition on line 438 was always true
439 for text_obj in child._text_objects:
440 # Check if this text is the start or is between start and end
441 if text_obj == start_result.object:
442 in_selection = True
444 if in_selection and isinstance(text_obj, Text):
445 result = self._make_query_result(text_obj, start)
446 results.append(result)
448 if text_obj == end_result.object:
449 in_selection = False
450 break
452 return SelectionRange(start, end, results)
454 def in_object(self, point: Tuple[int, int]) -> bool:
455 """
456 Check if a point is within this page's bounds.
458 Args:
459 point: The (x, y) coordinates to check
461 Returns:
462 True if the point is within the page bounds
463 """
464 return (
465 self._origin[0] <= point[0] < self._origin[0] + self._size[0] and
466 self._origin[1] <= point[1] < self._origin[1] + self._size[1]
467 )