Files
dtourolleandClaude Opus 5 e81ba48f6d refactor(page): delete the dead child-measurement helpers (R6)
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>
2026-08-08 12:56:05 +02:00

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]
)