470 lines
16 KiB
Python
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]
|
|
)
|