Duncan Tourolle 33e2cbc363
Some checks failed
Python CI / test (push) Failing after 6m29s
remove application from library
2025-11-07 18:48:36 +01:00

470 lines
16 KiB
Python

from typing import List, Tuple, Optional
import numpy as np
from PIL import Image, ImageDraw
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Alignment
from .box import Box
class Page(Renderable, Queriable):
"""
A page represents a canvas that can hold and render child renderable objects.
It handles layout, rendering, and provides query capabilities to find which child
contains a given point.
"""
def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None):
"""
Initialize a new page.
Args:
size: The total size of the page (width, height) including borders
style: The PageStyle defining borders, spacing, and appearance
"""
self._size = size
self._style = style if style is not None else PageStyle()
self._children: List[Renderable] = []
self._canvas: Optional[Image.Image] = None
self._draw: Optional[ImageDraw.Draw] = None
# Initialize y_offset to start of content area
# Position the first line so its baseline is close to the top boundary
# For subsequent lines, baseline-to-baseline spacing is used
self._current_y_offset = self._style.border_width + self._style.padding_top
self._is_first_line = True # Track if we're placing the first line
def free_space(self) -> Tuple[int, int]:
"""Get the remaining space on the page"""
return (self._size[0], self._size[1] - self._current_y_offset)
def can_fit_line(self, baseline_spacing: int, ascent: int = 0, descent: int = 0) -> bool:
"""
Check if a line with the given metrics can fit on the page.
Args:
baseline_spacing: Distance from current position to next baseline
ascent: Font ascent (height above baseline), defaults to 0 for backward compat
descent: Font descent (height below baseline), defaults to 0 for backward compat
Returns:
True if the line fits within page boundaries
"""
# Calculate the maximum Y position allowed (bottom boundary)
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom
# If ascent/descent not provided, use simple check (backward compatibility)
if ascent == 0 and descent == 0:
return (self._current_y_offset + baseline_spacing) <= max_y
# Calculate where the bottom of the text would be
# Text bottom = current_y_offset + ascent + descent
text_bottom = self._current_y_offset + ascent + descent
# Check if text bottom would exceed the boundary
return text_bottom <= max_y
@property
def size(self) -> Tuple[int, int]:
"""Get the total page size including borders"""
return self._size
@property
def canvas_size(self) -> Tuple[int, int]:
"""Get the canvas size (page size minus borders)"""
border_reduction = self._style.total_border_width
return (
self._size[0] - border_reduction,
self._size[1] - border_reduction
)
@property
def content_size(self) -> Tuple[int, int]:
"""Get the content area size (canvas minus padding)"""
canvas_w, canvas_h = self.canvas_size
return (
canvas_w - self._style.total_horizontal_padding,
canvas_h - self._style.total_vertical_padding
)
@property
def border_size(self) -> int:
"""Get the border width"""
return self._style.border_width
@property
def available_width(self) -> int:
"""Get the available width for content (content area width)"""
return self.content_size[0]
@property
def style(self) -> PageStyle:
"""Get the page style"""
return self._style
@property
def draw(self) -> Optional[ImageDraw.Draw]:
"""Get the ImageDraw object for drawing on this page's canvas"""
if self._draw is None:
# Initialize canvas and draw context if not already done
self._canvas = self._create_canvas()
self._draw = ImageDraw.Draw(self._canvas)
return self._draw
def add_child(self, child: Renderable) -> 'Page':
"""
Add a child renderable object to this page.
Args:
child: The renderable object to add
Returns:
Self for method chaining
"""
self._children.append(child)
self._current_y_offset = child.origin[1] + child.size[1]
# Invalidate the canvas when children change
self._canvas = None
return self
def remove_child(self, child: Renderable) -> bool:
"""
Remove a child from the page.
Args:
child: The child to remove
Returns:
True if the child was found and removed, False otherwise
"""
try:
self._children.remove(child)
self._canvas = None
return True
except ValueError:
return False
def clear_children(self) -> 'Page':
"""
Remove all children from the page.
Returns:
Self for method chaining
"""
self._children.clear()
self._canvas = None
# Reset y_offset to start of content area (after border and padding)
self._current_y_offset = self._style.border_width + self._style.padding_top
return self
@property
def children(self) -> List[Renderable]:
"""Get a copy of the children list"""
return self._children.copy()
def _get_child_height(self, child: Renderable) -> int:
"""
Get the height of a child object.
Args:
child: The child to measure
Returns:
Height in pixels
"""
if hasattr(child, '_size') and child._size is not None:
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
return int(child._size[1])
if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
return int(child.size[1])
if hasattr(child, 'height'):
return int(child.height)
# Default fallback height
return 20
def render_children(self):
"""
Call render on all children in the list.
Children draw directly onto the page's canvas via the shared ImageDraw object.
"""
for child in self._children:
# Synchronize draw context for Line objects before rendering
if hasattr(child, '_draw'):
child._draw = self._draw
# Synchronize canvas for Image objects before rendering
if hasattr(child, '_canvas'):
child._canvas = self._canvas
if hasattr(child, 'render'):
child.render()
def render(self) -> Image.Image:
"""
Render the page with all its children.
Returns:
PIL Image containing the rendered page
"""
# Create the base canvas and draw object
self._canvas = self._create_canvas()
self._draw = ImageDraw.Draw(self._canvas)
# Render all children - they draw directly onto the canvas
self.render_children()
return self._canvas
def _create_canvas(self) -> Image.Image:
"""
Create the base canvas with background and borders.
Returns:
PIL Image with background and borders applied
"""
# Create base image
canvas = Image.new('RGBA', self._size, (*self._style.background_color, 255))
# Draw borders if needed
if self._style.border_width > 0:
draw = ImageDraw.Draw(canvas)
border_color = (*self._style.border_color, 255)
# Draw border rectangle inside the content area
border_offset = self._style.border_width
draw.rectangle([
(border_offset, border_offset),
(self._size[0] - border_offset - 1, self._size[1] - border_offset - 1)
], outline=border_color)
return canvas
def _get_child_position(self, child: Renderable) -> Tuple[int, int]:
"""
Get the position where a child should be rendered.
Args:
child: The child object
Returns:
Tuple of (x, y) coordinates
"""
if hasattr(child, '_origin') and child._origin is not None:
if isinstance(child._origin, np.ndarray):
return (int(child._origin[0]), int(child._origin[1]))
elif isinstance(child._origin, (list, tuple)) and len(child._origin) >= 2:
return (int(child._origin[0]), int(child._origin[1]))
if hasattr(child, 'position'):
pos = child.position
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
return (int(pos[0]), int(pos[1]))
# Default to origin
return (0, 0)
def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]:
"""
Query a point to find the deepest object at that location.
Traverses children and uses Queriable.in_object() for hit-testing.
Args:
point: The (x, y) coordinates to query
Returns:
QueryResult with metadata about what was found, or None if nothing hit
"""
point_array = np.array(point)
# Check each child (in reverse order so topmost child is found first)
for child in reversed(self._children):
# Use Queriable mixin's in_object() for hit-testing
if isinstance(child, Queriable) and child.in_object(point_array):
# If child can also query (has children of its own), recurse
if hasattr(child, 'query_point'):
result = child.query_point(point)
if result:
result.parent_page = self
return result
# If child's query returned None, continue to next child
continue
# Otherwise, package this child as the result
return self._make_query_result(child, point)
# Nothing hit - return empty result
return QueryResult(
object=self,
object_type="empty",
bounds=(int(point[0]), int(point[1]), 0, 0)
)
def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
"""
Check if a point is within a child's bounds.
Args:
point: The point to check
child: The child to check against
Returns:
True if the point is within the child's bounds
"""
# If child implements Queriable interface, use it
if isinstance(child, Queriable) and hasattr(child, 'in_object'):
try:
return child.in_object(point)
except:
pass # Fall back to bounds checking
# Get child position and size for bounds checking
child_pos = self._get_child_position(child)
child_size = self._get_child_size(child)
if child_size is None:
return False
# Check if point is within child bounds
return (
child_pos[0] <= point[0] < child_pos[0] + child_size[0] and
child_pos[1] <= point[1] < child_pos[1] + child_size[1]
)
def _get_child_size(self, child: Renderable) -> Optional[Tuple[int, int]]:
"""
Get the size of a child object.
Args:
child: The child to measure
Returns:
Tuple of (width, height) or None if size cannot be determined
"""
if hasattr(child, '_size') and child._size is not None:
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
return (int(child._size[0]), int(child._size[1]))
if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
return (int(child.size[0]), int(child.size[1]))
if hasattr(child, 'width') and hasattr(child, 'height'):
return (int(child.width), int(child.height))
return None
def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult:
"""
Package an object into a QueryResult with metadata.
Args:
obj: The object to package
point: The query point
Returns:
QueryResult with extracted metadata
"""
from .text import Text
from .functional import LinkText, ButtonText
# Extract bounds
origin = getattr(obj, '_origin', np.array([0, 0]))
size = getattr(obj, 'size', np.array([0, 0]))
bounds = (
int(origin[0]),
int(origin[1]),
int(size[0]) if hasattr(size, '__getitem__') else 0,
int(size[1]) if hasattr(size, '__getitem__') else 0
)
# Determine type and extract metadata
if isinstance(obj, LinkText):
return QueryResult(
object=obj,
object_type="link",
bounds=bounds,
text=obj._text,
is_interactive=True,
link_target=obj._link.location if hasattr(obj, '_link') else None
)
elif isinstance(obj, ButtonText):
return QueryResult(
object=obj,
object_type="button",
bounds=bounds,
text=obj._text,
is_interactive=True,
callback=obj._callback if hasattr(obj, '_callback') else None
)
elif isinstance(obj, Text):
return QueryResult(
object=obj,
object_type="text",
bounds=bounds,
text=obj._text if hasattr(obj, '_text') else None
)
else:
return QueryResult(
object=obj,
object_type="unknown",
bounds=bounds
)
def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange:
"""
Query all text objects between two points (for text selection).
Uses Queriable.in_object() to determine which objects are in range.
Args:
start: Starting (x, y) point
end: Ending (x, y) point
Returns:
SelectionRange with all text objects between the points
"""
results = []
in_selection = False
start_result = self.query_point(start)
end_result = self.query_point(end)
if not start_result or not end_result:
return SelectionRange(start, end, [])
# Walk through all children (Lines) and their text objects
from .text import Line, Text
for child in self._children:
if isinstance(child, Line) and hasattr(child, '_text_objects'):
for text_obj in child._text_objects:
# Check if this text is the start or is between start and end
if text_obj == start_result.object:
in_selection = True
if in_selection and isinstance(text_obj, Text):
result = self._make_query_result(text_obj, start)
results.append(result)
if text_obj == end_result.object:
in_selection = False
break
return SelectionRange(start, end, results)
def in_object(self, point: Tuple[int, int]) -> bool:
"""
Check if a point is within this page's bounds.
Args:
point: The (x, y) coordinates to check
Returns:
True if the point is within the page bounds
"""
return (
0 <= point[0] < self._size[0] and
0 <= point[1] < self._size[1]
)