paragraph_layouter placed lines at page.border_size while sizing them to available_width, which subtracts both paddings. Text therefore started flush against the left border and the entire padding budget accumulated on the right, so lines broke well short of the right border. Page now describes its content box directly - content_origin, content_rect and remaining_height - and the layouters use it instead of each recomputing the geometry from border_size. The four block layouters had all been computing remaining space as size[1] - y_offset - border_size, subtracting the border but not the bottom padding, so every block type could be placed into the bottom padding; remaining_height fixes that too. Page also gains an origin, defaulting to (0, 0). That is inert for a top-level page but lets a page be positioned inside another surface, which table cells need in order to be laid out by the normal engine. Golden images regenerated: content now sits inside the padding on all sides.
576 lines
20 KiB
Python
576 lines
20 KiB
Python
from typing import List, Tuple, Optional
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw
|
|
|
|
from pyWebLayout.core.base import Renderable, Queriable
|
|
from pyWebLayout.core.query import QueryResult, SelectionRange
|
|
from pyWebLayout.core.callback_registry import CallbackRegistry
|
|
from pyWebLayout.style.page_style import PageStyle
|
|
|
|
|
|
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,
|
|
origin: Tuple[int, int] = (0, 0)):
|
|
"""
|
|
Initialize a new page.
|
|
|
|
Args:
|
|
size: The total size of the page (width, height) including borders
|
|
style: The PageStyle defining borders, spacing, and appearance
|
|
origin: Absolute position of the page's top-left corner. Non-zero for
|
|
a page nested inside another surface, such as a table cell.
|
|
"""
|
|
self._size = size
|
|
self._origin = origin
|
|
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._origin[1] + self._style.border_width
|
|
+ self._style.padding_top)
|
|
self._is_first_line = True # Track if we're placing the first line
|
|
# Callback registry for managing interactable elements
|
|
self._callbacks = CallbackRegistry()
|
|
# Dirty flag to track if page needs re-rendering due to state changes
|
|
self._dirty = True
|
|
|
|
def free_space(self) -> Tuple[int, int]:
|
|
"""
|
|
Get the remaining space in the content area.
|
|
|
|
Deprecated: use content_rect and remaining_height, which this delegates to.
|
|
"""
|
|
return (self.content_rect[2], self.remaining_height)
|
|
|
|
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)
|
|
content_y, content_h = self.content_rect[1], self.content_rect[3]
|
|
max_y = content_y + content_h
|
|
|
|
# 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 origin(self) -> Tuple[int, int]:
|
|
"""Absolute position of the page's top-left corner"""
|
|
return self._origin
|
|
|
|
@property
|
|
def content_origin(self) -> Tuple[int, int]:
|
|
"""
|
|
Absolute top-left of the content box: the page origin plus its border and
|
|
top/left padding. Layout starts here.
|
|
"""
|
|
return (
|
|
self._origin[0] + self._style.border_width + self._style.padding_left,
|
|
self._origin[1] + self._style.border_width + self._style.padding_top,
|
|
)
|
|
|
|
@property
|
|
def content_rect(self) -> Tuple[int, int, int, int]:
|
|
"""(x, y, width, height) of the content box, in absolute coordinates"""
|
|
x, y = self.content_origin
|
|
return (x, y, self.content_size[0], self.content_size[1])
|
|
|
|
@property
|
|
def remaining_height(self) -> int:
|
|
"""Content-box height still available below the current layout cursor"""
|
|
_, y, _, h = self.content_rect
|
|
return max(0, y + h - self._current_y_offset)
|
|
|
|
@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 callbacks(self) -> CallbackRegistry:
|
|
"""Get the callback registry for managing interactable elements"""
|
|
return self._callbacks
|
|
|
|
@property
|
|
def is_dirty(self) -> bool:
|
|
"""Check if the page needs re-rendering due to state changes"""
|
|
return self._dirty
|
|
|
|
def mark_dirty(self):
|
|
"""Mark the page as needing re-rendering"""
|
|
self._dirty = True
|
|
|
|
def mark_clean(self):
|
|
"""Mark the page as clean (up-to-date render)"""
|
|
self._dirty = False
|
|
|
|
@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
|
|
# Clear callback registry when clearing children
|
|
self._callbacks.clear()
|
|
# Reset y_offset to start of content area (after border and padding)
|
|
self._current_y_offset = self.content_origin[1]
|
|
return self
|
|
|
|
@property
|
|
def children(self) -> List[Renderable]:
|
|
"""Get a copy of the children list"""
|
|
return self._children.copy()
|
|
|
|
def _get_child_property(self, child: Renderable, private_attr: str,
|
|
public_attr: str, index: Optional[int] = None,
|
|
default: Optional[int] = None) -> Optional[int]:
|
|
"""
|
|
Generic helper to extract properties from child objects with multiple fallback strategies.
|
|
|
|
Args:
|
|
child: The child object
|
|
private_attr: Name of the private attribute (e.g., '_size')
|
|
public_attr: Name of the public property (e.g., 'size')
|
|
index: Optional index for array-like properties (0 for width, 1 for height)
|
|
default: Default value if property cannot be determined
|
|
|
|
Returns:
|
|
Property value or default
|
|
"""
|
|
# Try private attribute first
|
|
if hasattr(child, private_attr):
|
|
value = getattr(child, private_attr)
|
|
if value is not None:
|
|
if isinstance(value, (list, tuple, np.ndarray)):
|
|
if index is not None and len(value) > index:
|
|
return int(value[index])
|
|
elif index is None:
|
|
return value
|
|
|
|
# Try public property
|
|
if hasattr(child, public_attr):
|
|
value = getattr(child, public_attr)
|
|
if value is not None:
|
|
if isinstance(value, (list, tuple, np.ndarray)):
|
|
if index is not None and len(value) > index:
|
|
return int(value[index])
|
|
elif index is None:
|
|
return value
|
|
else:
|
|
return int(value)
|
|
|
|
return default
|
|
|
|
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
|
|
"""
|
|
# Try to get height from size property (index 1)
|
|
height = self._get_child_property(child, '_size', 'size', index=1)
|
|
if height is not None:
|
|
return height
|
|
|
|
# Try direct height attribute
|
|
height = self._get_child_property(child, '_height', 'height')
|
|
if height is not None:
|
|
return 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()
|
|
|
|
# Mark as clean after rendering
|
|
self._dirty = False
|
|
|
|
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
|
|
"""
|
|
# Try to get x coordinate
|
|
x = self._get_child_property(child, '_origin', 'position', index=0, default=0)
|
|
# Try to get y coordinate
|
|
y = self._get_child_property(child, '_origin', 'position', index=1, default=0)
|
|
|
|
return (x, y)
|
|
|
|
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 BaseException:
|
|
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
|
|
"""
|
|
# Try to get width and height from size property
|
|
width = self._get_child_property(child, '_size', 'size', index=0)
|
|
height = self._get_child_property(child, '_size', 'size', index=1)
|
|
|
|
# If size property worked, return it
|
|
if width is not None and height is not None:
|
|
return (width, height)
|
|
|
|
# Try direct width/height attributes
|
|
width = self._get_child_property(child, '_width', 'width')
|
|
height = self._get_child_property(child, '_height', 'height')
|
|
|
|
if width is not None and height is not None:
|
|
return (width, 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 (
|
|
self._origin[0] <= point[0] < self._origin[0] + self._size[0] and
|
|
self._origin[1] <= point[1] < self._origin[1] + self._size[1]
|
|
)
|