419 lines
15 KiB
Python
419 lines
15 KiB
Python
"""
|
|
DynamicPage implementation for pyWebLayout.
|
|
|
|
A DynamicPage is a page that dynamically sizes itself based on content and constraints.
|
|
Unlike a regular Page with fixed size, a DynamicPage measures its content first and
|
|
then layouts within the allocated space.
|
|
|
|
Use cases:
|
|
- Table cells that need to fit content
|
|
- Containers that should grow with content
|
|
- Responsive layouts that adapt to constraints
|
|
"""
|
|
|
|
from typing import Tuple, Optional, List
|
|
from dataclasses import dataclass
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
from pyWebLayout.concrete.page import Page
|
|
from pyWebLayout.style.page_style import PageStyle
|
|
from pyWebLayout.core.base import Renderable
|
|
|
|
|
|
@dataclass
|
|
class SizeConstraints:
|
|
"""Size constraints for dynamic layout."""
|
|
min_width: Optional[int] = None
|
|
max_width: Optional[int] = None
|
|
min_height: Optional[int] = None
|
|
max_height: Optional[int] = None
|
|
# Note: Hyphenation threshold is controlled by Font.min_hyphenation_width
|
|
# Don't duplicate that logic here
|
|
|
|
|
|
class DynamicPage(Page):
|
|
"""
|
|
A page that dynamically sizes itself based on content and constraints.
|
|
|
|
The layout process has two phases:
|
|
1. Measurement: Calculate intrinsic size needed for content
|
|
2. Layout: Position content within allocated size
|
|
|
|
This allows containers (like tables) to optimize space allocation before rendering.
|
|
"""
|
|
|
|
def __init__(self,
|
|
constraints: Optional[SizeConstraints] = None,
|
|
style: Optional[PageStyle] = None):
|
|
"""
|
|
Initialize a dynamic page.
|
|
|
|
Args:
|
|
constraints: Optional size constraints (min/max width/height)
|
|
style: The PageStyle defining borders, spacing, and appearance
|
|
"""
|
|
# Start with zero size - will be determined during measurement/layout
|
|
super().__init__(size=(0, 0), style=style)
|
|
self._constraints = constraints if constraints is not None else SizeConstraints()
|
|
|
|
# Measurement state
|
|
self._is_measured = False
|
|
self._intrinsic_size: Optional[Tuple[int, int]] = None
|
|
self._min_width_cache: Optional[int] = None
|
|
self._preferred_width_cache: Optional[int] = None
|
|
self._content_height_cache: Optional[int] = None
|
|
|
|
# Pagination state
|
|
self._render_offset = 0 # For partial rendering (pagination)
|
|
self._is_laid_out = False
|
|
|
|
@property
|
|
def constraints(self) -> SizeConstraints:
|
|
"""Get the size constraints for this page."""
|
|
return self._constraints
|
|
|
|
def measure(self, available_width: Optional[int] = None) -> Tuple[int, int]:
|
|
"""
|
|
Measure the intrinsic size needed for content.
|
|
|
|
This walks through all children and calculates how much space they need.
|
|
The measurement respects constraints (min/max width/height).
|
|
|
|
Args:
|
|
available_width: Optional width constraint for wrapping content
|
|
|
|
Returns:
|
|
Tuple of (width, height) needed
|
|
"""
|
|
if self._is_measured and self._intrinsic_size is not None:
|
|
return self._intrinsic_size
|
|
|
|
# Apply constraints to available width
|
|
if available_width is not None:
|
|
if self._constraints.max_width is not None:
|
|
available_width = min(available_width, self._constraints.max_width)
|
|
if self._constraints.min_width is not None:
|
|
available_width = max(available_width, self._constraints.min_width)
|
|
|
|
# Measure content
|
|
# For now, walk through children and sum their sizes
|
|
total_width = 0
|
|
total_height = 0
|
|
|
|
for child in self._children:
|
|
if hasattr(child, 'measure'):
|
|
# Child is also dynamic - ask it to measure
|
|
child_size = child.measure(available_width)
|
|
child_width, child_height = child_size
|
|
else:
|
|
# Child has fixed size
|
|
child_width = child.size[0] if hasattr(child, 'size') else 0
|
|
child_height = child.size[1] if hasattr(child, 'size') else 0
|
|
|
|
total_width = max(total_width, child_width)
|
|
total_height += child_height
|
|
|
|
# Add page padding/borders
|
|
total_width += self._style.total_horizontal_padding + self._style.total_border_width
|
|
total_height += self._style.total_vertical_padding + self._style.total_border_width
|
|
|
|
# Apply constraints
|
|
if self._constraints.min_width is not None:
|
|
total_width = max(total_width, self._constraints.min_width)
|
|
if self._constraints.max_width is not None:
|
|
total_width = min(total_width, self._constraints.max_width)
|
|
if self._constraints.min_height is not None:
|
|
total_height = max(total_height, self._constraints.min_height)
|
|
if self._constraints.max_height is not None:
|
|
total_height = min(total_height, self._constraints.max_height)
|
|
|
|
self._intrinsic_size = (total_width, total_height)
|
|
self._is_measured = True
|
|
|
|
return self._intrinsic_size
|
|
|
|
def get_min_width(self) -> int:
|
|
"""
|
|
Get minimum width needed to render content.
|
|
|
|
This finds the widest word/element that cannot be broken,
|
|
using Font.min_hyphenation_width for hyphenation control.
|
|
|
|
Returns:
|
|
Minimum width in pixels
|
|
"""
|
|
# Check cache
|
|
if self._min_width_cache is not None:
|
|
return self._min_width_cache
|
|
|
|
# Calculate minimum width based on content
|
|
from pyWebLayout.concrete.text import Line, Text
|
|
|
|
min_width = 0
|
|
|
|
# Walk through children and find longest unbreakable segment
|
|
for child in self._children:
|
|
if isinstance(child, Line):
|
|
# Check all words in the line
|
|
# Font's min_hyphenation_width already controls breaking
|
|
for text_obj in getattr(child, '_text_objects', []):
|
|
if isinstance(text_obj, Text) and hasattr(text_obj, '_text'):
|
|
word_text = text_obj._text
|
|
# Text stores font in _style, not _font
|
|
font = getattr(text_obj, '_style', None)
|
|
|
|
if font:
|
|
# Just measure the word - Font handles hyphenation rules
|
|
word_width = int(font.font.getlength(word_text))
|
|
min_width = max(min_width, word_width)
|
|
elif hasattr(child, 'get_min_width'):
|
|
# Child supports min width calculation
|
|
child_min = child.get_min_width()
|
|
min_width = max(min_width, child_min)
|
|
elif hasattr(child, 'size'):
|
|
# Use actual width
|
|
min_width = max(min_width, child.size[0])
|
|
|
|
# Add padding/borders
|
|
min_width += self._style.total_horizontal_padding + self._style.total_border_width
|
|
|
|
# Apply minimum constraint
|
|
if self._constraints.min_width is not None:
|
|
min_width = max(min_width, self._constraints.min_width)
|
|
|
|
self._min_width_cache = min_width
|
|
return min_width
|
|
|
|
def get_preferred_width(self) -> int:
|
|
"""
|
|
Get preferred width (no wrapping).
|
|
|
|
This returns the width needed to render all content without any
|
|
line wrapping.
|
|
|
|
Returns:
|
|
Preferred width in pixels
|
|
"""
|
|
# Check cache
|
|
if self._preferred_width_cache is not None:
|
|
return self._preferred_width_cache
|
|
|
|
# Calculate preferred width (no wrapping)
|
|
from pyWebLayout.concrete.text import Line
|
|
|
|
pref_width = 0
|
|
|
|
for child in self._children:
|
|
if isinstance(child, Line):
|
|
# Get line width without wrapping (including spacing between words)
|
|
text_objects = getattr(child, '_text_objects', [])
|
|
if text_objects:
|
|
line_width = 0
|
|
for i, text_obj in enumerate(text_objects):
|
|
if hasattr(text_obj, '_text') and hasattr(text_obj, '_style'):
|
|
# Text stores font in _style, not _font
|
|
word_width = text_obj._style.font.getlength(text_obj._text)
|
|
line_width += word_width
|
|
|
|
# Add spacing after word (except last word)
|
|
if i < len(text_objects) - 1:
|
|
# Get spacing from Line if available, otherwise use default
|
|
spacing = getattr(child, '_spacing', (3, 6))
|
|
# Use minimum spacing for preferred width calculation
|
|
line_width += spacing[0] if isinstance(spacing, tuple) else 3
|
|
|
|
pref_width = max(pref_width, line_width)
|
|
elif hasattr(child, 'get_preferred_width'):
|
|
child_pref = child.get_preferred_width()
|
|
pref_width = max(pref_width, child_pref)
|
|
elif hasattr(child, 'size'):
|
|
# Use actual size
|
|
pref_width = max(pref_width, child.size[0])
|
|
|
|
# Add padding/borders
|
|
pref_width += self._style.total_horizontal_padding + self._style.total_border_width
|
|
|
|
# Apply constraints
|
|
if self._constraints.max_width is not None:
|
|
pref_width = min(pref_width, self._constraints.max_width)
|
|
if self._constraints.min_width is not None:
|
|
pref_width = max(pref_width, self._constraints.min_width)
|
|
|
|
self._preferred_width_cache = pref_width
|
|
return pref_width
|
|
|
|
def measure_content_height(self) -> int:
|
|
"""
|
|
Measure total height needed to render all content.
|
|
|
|
This is used for pagination to know how much content remains.
|
|
|
|
Returns:
|
|
Total height in pixels
|
|
"""
|
|
# Check cache
|
|
if self._content_height_cache is not None:
|
|
return self._content_height_cache
|
|
|
|
total_height = 0
|
|
|
|
for child in self._children:
|
|
if hasattr(child, 'measure_content_height'):
|
|
child_height = child.measure_content_height()
|
|
elif hasattr(child, 'size'):
|
|
child_height = child.size[1]
|
|
else:
|
|
child_height = 0
|
|
|
|
total_height += child_height
|
|
|
|
# Add padding/borders
|
|
total_height += self._style.total_vertical_padding + self._style.total_border_width
|
|
|
|
self._content_height_cache = total_height
|
|
return total_height
|
|
|
|
def layout(self, size: Tuple[int, int]):
|
|
"""
|
|
Layout content within the given size.
|
|
|
|
This is called after measurement to position children within
|
|
the allocated space.
|
|
|
|
Args:
|
|
size: The final size allocated to this page (width, height)
|
|
"""
|
|
# Set the page size
|
|
self._size = size
|
|
|
|
# Position children sequentially
|
|
# Use the same logic as Page but now we know our final size
|
|
content_x = self._style.border_width + self._style.padding_left
|
|
content_y = self._style.border_width + self._style.padding_top
|
|
|
|
self._current_y_offset = content_y
|
|
self._is_first_line = True
|
|
|
|
# Children position themselves, we just track y_offset
|
|
# The actual positioning happens when children render
|
|
|
|
self._is_laid_out = True
|
|
self._dirty = True # Mark for re-render
|
|
|
|
def render(self) -> Image.Image:
|
|
"""
|
|
Render the page with all its children.
|
|
|
|
If not yet measured/laid out, use intrinsic sizing.
|
|
|
|
Returns:
|
|
PIL Image containing the rendered page
|
|
"""
|
|
# Ensure we have a valid size
|
|
if self._size[0] == 0 or self._size[1] == 0:
|
|
if not self._is_measured:
|
|
# Auto-measure with no constraints
|
|
self.measure()
|
|
|
|
if self._intrinsic_size:
|
|
self._size = self._intrinsic_size
|
|
else:
|
|
# Fallback to minimum size
|
|
self._size = (100, 100)
|
|
|
|
# Use parent's render implementation
|
|
return super().render()
|
|
|
|
# Pagination Support
|
|
# ------------------
|
|
|
|
def render_partial(self, available_height: int) -> int:
|
|
"""
|
|
Render as much content as fits in available_height.
|
|
|
|
This is used for pagination when a page needs to be split across
|
|
multiple output pages.
|
|
|
|
Args:
|
|
available_height: Height available on current page
|
|
|
|
Returns:
|
|
Amount of content rendered (in pixels)
|
|
"""
|
|
# Calculate how many children fit in available height
|
|
rendered_height = 0
|
|
content_start_y = self._style.border_width + self._style.padding_top
|
|
|
|
for i, child in enumerate(self._children):
|
|
# Skip already rendered children
|
|
if rendered_height < self._render_offset:
|
|
if hasattr(child, 'size'):
|
|
rendered_height += child.size[1]
|
|
continue
|
|
|
|
# Check if this child fits
|
|
child_height = child.size[1] if hasattr(child, 'size') else 0
|
|
|
|
if rendered_height + child_height <= available_height:
|
|
# Child fits - render it
|
|
if hasattr(child, 'render'):
|
|
child.render()
|
|
rendered_height += child_height
|
|
else:
|
|
# No more space
|
|
break
|
|
|
|
# Update render offset for next call
|
|
self._render_offset = rendered_height
|
|
|
|
return rendered_height
|
|
|
|
def has_more_content(self) -> bool:
|
|
"""
|
|
Check if there's unrendered content remaining.
|
|
|
|
Returns:
|
|
True if more content needs to be rendered
|
|
"""
|
|
total_height = self.measure_content_height()
|
|
return self._render_offset < total_height
|
|
|
|
def reset_pagination(self):
|
|
"""Reset pagination to render from beginning."""
|
|
self._render_offset = 0
|
|
|
|
def invalidate_caches(self):
|
|
"""Invalidate all measurement caches (call when children change)."""
|
|
self._is_measured = False
|
|
self._intrinsic_size = None
|
|
self._min_width_cache = None
|
|
self._preferred_width_cache = None
|
|
self._content_height_cache = None
|
|
self._is_laid_out = False
|
|
|
|
def add_child(self, child: Renderable) -> 'DynamicPage':
|
|
"""
|
|
Add a child and invalidate caches.
|
|
|
|
Args:
|
|
child: The renderable object to add
|
|
|
|
Returns:
|
|
Self for method chaining
|
|
"""
|
|
super().add_child(child)
|
|
self.invalidate_caches()
|
|
return self
|
|
|
|
def clear_children(self) -> 'DynamicPage':
|
|
"""
|
|
Remove all children and invalidate caches.
|
|
|
|
Returns:
|
|
Self for method chaining
|
|
"""
|
|
super().clear_children()
|
|
self.invalidate_caches()
|
|
return self
|