page.py carried a closed cluster of five methods with no callers outside
itself:
_get_child_property called only by the four below
_get_child_height called by nothing
_get_child_position called only by _point_in_child
_point_in_child called by nothing
_get_child_size called only by _point_in_child
138 lines, verified unreferenced across pyWebLayout/, tests/, examples/
and scripts/.
They existed because Renderable declares no size, so the code probed
_size, size, _height, height, _origin and position in turn with hasattr,
guessing at each child's shape. query_point already does the right thing
instead: it hit-tests through the Queriable interface.
Hardening the Renderable contract so this cannot grow back - Renderable
has origin but no size - belongs with S10.1, which is already going to
revisit the render contract in core/base.py. Left alone here rather than
half-done.
894 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
468 lines
16 KiB
Python
468 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, 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.
|
|
"""
|
|
|
|
# Mode of the render canvas. The measurement context matches it so that text
|
|
# width caching keys stay consistent between layout and rendering.
|
|
_CANVAS_MODE = 'RGBA'
|
|
|
|
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
|
|
self._measurement_draw: Optional[ImageDraw.ImageDraw] = 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 bound to this page's render canvas.
|
|
|
|
Rebuilt whenever the canvas has been invalidated: a draw context
|
|
outlives the image it was created from, so checking only _draw would
|
|
hand back a context pointing at a discarded canvas.
|
|
"""
|
|
if self._draw is None or self._canvas 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
|
|
|
|
@property
|
|
def measurement_draw(self) -> ImageDraw.ImageDraw:
|
|
"""
|
|
A scratch draw context for text metrics during layout.
|
|
|
|
Layout asks for text widths constantly, but has no reason to touch the
|
|
render canvas - and the canvas is invalidated on every add_child, so
|
|
measuring through `draw` would allocate a full-page image per line.
|
|
This context is 1x1 and never invalidated.
|
|
|
|
Its mode matches the render canvas because Text keys its width cache on
|
|
the draw mode; a mismatch would double every cache entry. Children built
|
|
against it are re-bound to the real canvas by render_children.
|
|
"""
|
|
if self._measurement_draw is None:
|
|
scratch = Image.new(self._CANVAS_MODE, (1, 1))
|
|
self._measurement_draw = ImageDraw.Draw(scratch)
|
|
return self._measurement_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 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(self._CANVAS_MODE, 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 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 _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]
|
|
)
|