Files
pyWebLayout/pyWebLayout/concrete/page.py
T
dtourolle f18cec2da8 fix(layout): honour horizontal padding and page origin (S2)
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.
2026-08-06 21:11:28 +02:00

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