Files
pyWebLayout/pyWebLayout/concrete/dynamic_page.py
T
dtourolle 3bcd1bffb5
Python CI / test (3.10) (push) Successful in 2m22s
Python CI / test (3.12) (push) Successful in 2m13s
Python CI / test (3.13) (push) Successful in 2m9s
Tables now use ""dynamic page" allowing the contents to be anything that can be rendered ion a page.
2025-11-11 18:10:47 +01:00

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