Coverage for pyWebLayout/concrete/dynamic_page.py: 68%
178 statements
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
1"""
2DynamicPage implementation for pyWebLayout.
4A DynamicPage is a page that dynamically sizes itself based on content and constraints.
5Unlike a regular Page with fixed size, a DynamicPage measures its content first and
6then layouts within the allocated space.
8Use cases:
9- Table cells that need to fit content
10- Containers that should grow with content
11- Responsive layouts that adapt to constraints
12"""
14from typing import Tuple, Optional, List
15from dataclasses import dataclass
16import numpy as np
17from PIL import Image
19from pyWebLayout.concrete.page import Page
20from pyWebLayout.style.page_style import PageStyle
21from pyWebLayout.core.base import Renderable
24@dataclass
25class SizeConstraints:
26 """Size constraints for dynamic layout."""
27 min_width: Optional[int] = None
28 max_width: Optional[int] = None
29 min_height: Optional[int] = None
30 max_height: Optional[int] = None
31 # Note: Hyphenation threshold is controlled by Font.min_hyphenation_width
32 # Don't duplicate that logic here
35class DynamicPage(Page):
36 """
37 A page that dynamically sizes itself based on content and constraints.
39 The layout process has two phases:
40 1. Measurement: Calculate intrinsic size needed for content
41 2. Layout: Position content within allocated size
43 This allows containers (like tables) to optimize space allocation before rendering.
44 """
46 def __init__(self,
47 constraints: Optional[SizeConstraints] = None,
48 style: Optional[PageStyle] = None):
49 """
50 Initialize a dynamic page.
52 Args:
53 constraints: Optional size constraints (min/max width/height)
54 style: The PageStyle defining borders, spacing, and appearance
55 """
56 # Start with zero size - will be determined during measurement/layout
57 super().__init__(size=(0, 0), style=style)
58 self._constraints = constraints if constraints is not None else SizeConstraints()
60 # Measurement state
61 self._is_measured = False
62 self._intrinsic_size: Optional[Tuple[int, int]] = None
63 self._min_width_cache: Optional[int] = None
64 self._preferred_width_cache: Optional[int] = None
65 self._content_height_cache: Optional[int] = None
67 # Pagination state
68 self._render_offset = 0 # For partial rendering (pagination)
69 self._is_laid_out = False
71 @property
72 def constraints(self) -> SizeConstraints:
73 """Get the size constraints for this page."""
74 return self._constraints
76 def measure(self, available_width: Optional[int] = None) -> Tuple[int, int]:
77 """
78 Measure the intrinsic size needed for content.
80 This walks through all children and calculates how much space they need.
81 The measurement respects constraints (min/max width/height).
83 Args:
84 available_width: Optional width constraint for wrapping content
86 Returns:
87 Tuple of (width, height) needed
88 """
89 if self._is_measured and self._intrinsic_size is not None:
90 return self._intrinsic_size
92 # Apply constraints to available width
93 if available_width is not None:
94 if self._constraints.max_width is not None: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 available_width = min(available_width, self._constraints.max_width)
96 if self._constraints.min_width is not None:
97 available_width = max(available_width, self._constraints.min_width)
99 # Measure content
100 # For now, walk through children and sum their sizes
101 total_width = 0
102 total_height = 0
104 for child in self._children: 104 ↛ 105line 104 didn't jump to line 105 because the loop on line 104 never started
105 if hasattr(child, 'measure'):
106 # Child is also dynamic - ask it to measure
107 child_size = child.measure(available_width)
108 child_width, child_height = child_size
109 else:
110 # Child has fixed size
111 child_width = child.size[0] if hasattr(child, 'size') else 0
112 child_height = child.size[1] if hasattr(child, 'size') else 0
114 total_width = max(total_width, child_width)
115 total_height += child_height
117 # Add page padding/borders
118 total_width += self._style.total_horizontal_padding + self._style.total_border_width
119 total_height += self._style.total_vertical_padding + self._style.total_border_width
121 # Apply constraints
122 if self._constraints.min_width is not None:
123 total_width = max(total_width, self._constraints.min_width)
124 if self._constraints.max_width is not None: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 total_width = min(total_width, self._constraints.max_width)
126 if self._constraints.min_height is not None:
127 total_height = max(total_height, self._constraints.min_height)
128 if self._constraints.max_height is not None: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 total_height = min(total_height, self._constraints.max_height)
131 self._intrinsic_size = (total_width, total_height)
132 self._is_measured = True
134 return self._intrinsic_size
136 def get_min_width(self) -> int:
137 """
138 Get minimum width needed to render content.
140 This finds the widest word/element that cannot be broken,
141 using Font.min_hyphenation_width for hyphenation control.
143 Returns:
144 Minimum width in pixels
145 """
146 # Check cache
147 if self._min_width_cache is not None: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true
148 return self._min_width_cache
150 # Calculate minimum width based on content
151 from pyWebLayout.concrete.text import Line, Text
153 min_width = 0
155 # Walk through children and find longest unbreakable segment
156 for child in self._children:
157 if isinstance(child, Line): 157 ↛ 170line 157 didn't jump to line 170 because the condition on line 157 was always true
158 # Check all words in the line
159 # Font's min_hyphenation_width already controls breaking
160 for text_obj in getattr(child, '_text_objects', []):
161 if isinstance(text_obj, Text) and hasattr(text_obj, '_text'): 161 ↛ 160line 161 didn't jump to line 160 because the condition on line 161 was always true
162 word_text = text_obj._text
163 # Text stores font in _style, not _font
164 font = getattr(text_obj, '_style', None)
166 if font: 166 ↛ 160line 166 didn't jump to line 160 because the condition on line 166 was always true
167 # Just measure the word - Font handles hyphenation rules
168 word_width = int(font.font.getlength(word_text))
169 min_width = max(min_width, word_width)
170 elif hasattr(child, 'get_min_width'):
171 # Child supports min width calculation
172 child_min = child.get_min_width()
173 min_width = max(min_width, child_min)
174 elif hasattr(child, 'size'):
175 # Use actual width
176 min_width = max(min_width, child.size[0])
178 # Add padding/borders
179 min_width += self._style.total_horizontal_padding + self._style.total_border_width
181 # Apply minimum constraint
182 if self._constraints.min_width is not None: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 min_width = max(min_width, self._constraints.min_width)
185 self._min_width_cache = min_width
186 return min_width
188 def get_preferred_width(self) -> int:
189 """
190 Get preferred width (no wrapping).
192 This returns the width needed to render all content without any
193 line wrapping.
195 Returns:
196 Preferred width in pixels
197 """
198 # Check cache
199 if self._preferred_width_cache is not None: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 return self._preferred_width_cache
202 # Calculate preferred width (no wrapping)
203 from pyWebLayout.concrete.text import Line
205 pref_width = 0
207 for child in self._children:
208 if isinstance(child, Line): 208 ↛ 227line 208 didn't jump to line 227 because the condition on line 208 was always true
209 # Get line width without wrapping (including spacing between words)
210 text_objects = getattr(child, '_text_objects', [])
211 if text_objects: 211 ↛ 207line 211 didn't jump to line 207 because the condition on line 211 was always true
212 line_width = 0
213 for i, text_obj in enumerate(text_objects):
214 if hasattr(text_obj, '_text') and hasattr(text_obj, '_style'): 214 ↛ 213line 214 didn't jump to line 213 because the condition on line 214 was always true
215 # Text stores font in _style, not _font
216 word_width = text_obj._style.font.getlength(text_obj._text)
217 line_width += word_width
219 # Add spacing after word (except last word)
220 if i < len(text_objects) - 1:
221 # Get spacing from Line if available, otherwise use default
222 spacing = getattr(child, '_spacing', (3, 6))
223 # Use minimum spacing for preferred width calculation
224 line_width += spacing[0] if isinstance(spacing, tuple) else 3
226 pref_width = max(pref_width, line_width)
227 elif hasattr(child, 'get_preferred_width'):
228 child_pref = child.get_preferred_width()
229 pref_width = max(pref_width, child_pref)
230 elif hasattr(child, 'size'):
231 # Use actual size
232 pref_width = max(pref_width, child.size[0])
234 # Add padding/borders
235 pref_width += self._style.total_horizontal_padding + self._style.total_border_width
237 # Apply constraints
238 if self._constraints.max_width is not None: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true
239 pref_width = min(pref_width, self._constraints.max_width)
240 if self._constraints.min_width is not None: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 pref_width = max(pref_width, self._constraints.min_width)
243 self._preferred_width_cache = pref_width
244 return pref_width
246 def measure_content_height(self) -> int:
247 """
248 Measure total height needed to render all content.
250 This is used for pagination to know how much content remains.
252 Returns:
253 Total height in pixels
254 """
255 # Check cache
256 if self._content_height_cache is not None:
257 return self._content_height_cache
259 total_height = 0
261 for child in self._children: 261 ↛ 262line 261 didn't jump to line 262 because the loop on line 261 never started
262 if hasattr(child, 'measure_content_height'):
263 child_height = child.measure_content_height()
264 elif hasattr(child, 'size'):
265 child_height = child.size[1]
266 else:
267 child_height = 0
269 total_height += child_height
271 # Add padding/borders
272 total_height += self._style.total_vertical_padding + self._style.total_border_width
274 self._content_height_cache = total_height
275 return total_height
277 def layout(self, size: Tuple[int, int]):
278 """
279 Layout content within the given size.
281 This is called after measurement to position children within
282 the allocated space.
284 Args:
285 size: The final size allocated to this page (width, height)
286 """
287 # Set the page size
288 self._size = size
290 # Position children sequentially
291 # Use the same logic as Page but now we know our final size
292 content_x = self._style.border_width + self._style.padding_left
293 content_y = self._style.border_width + self._style.padding_top
295 self._current_y_offset = content_y
296 self._is_first_line = True
298 # Children position themselves, we just track y_offset
299 # The actual positioning happens when children render
301 self._is_laid_out = True
302 self._dirty = True # Mark for re-render
304 def render(self) -> Image.Image:
305 """
306 Render the page with all its children.
308 If not yet measured/laid out, use intrinsic sizing.
310 Returns:
311 PIL Image containing the rendered page
312 """
313 # Ensure we have a valid size
314 if self._size[0] == 0 or self._size[1] == 0:
315 if not self._is_measured: 315 ↛ 319line 315 didn't jump to line 319 because the condition on line 315 was always true
316 # Auto-measure with no constraints
317 self.measure()
319 if self._intrinsic_size: 319 ↛ 323line 319 didn't jump to line 323 because the condition on line 319 was always true
320 self._size = self._intrinsic_size
321 else:
322 # Fallback to minimum size
323 self._size = (100, 100)
325 # Use parent's render implementation
326 return super().render()
328 # Pagination Support
329 # ------------------
331 def render_partial(self, available_height: int) -> int:
332 """
333 Render as much content as fits in available_height.
335 This is used for pagination when a page needs to be split across
336 multiple output pages.
338 Args:
339 available_height: Height available on current page
341 Returns:
342 Amount of content rendered (in pixels)
343 """
344 # Calculate how many children fit in available height
345 rendered_height = 0
346 content_start_y = self._style.border_width + self._style.padding_top
348 for i, child in enumerate(self._children): 348 ↛ 350line 348 didn't jump to line 350 because the loop on line 348 never started
349 # Skip already rendered children
350 if rendered_height < self._render_offset:
351 if hasattr(child, 'size'):
352 rendered_height += child.size[1]
353 continue
355 # Check if this child fits
356 child_height = child.size[1] if hasattr(child, 'size') else 0
358 if rendered_height + child_height <= available_height:
359 # Child fits - render it
360 if hasattr(child, 'render'):
361 child.render()
362 rendered_height += child_height
363 else:
364 # No more space
365 break
367 # Update render offset for next call
368 self._render_offset = rendered_height
370 return rendered_height
372 def has_more_content(self) -> bool:
373 """
374 Check if there's unrendered content remaining.
376 Returns:
377 True if more content needs to be rendered
378 """
379 total_height = self.measure_content_height()
380 return self._render_offset < total_height
382 def reset_pagination(self):
383 """Reset pagination to render from beginning."""
384 self._render_offset = 0
386 def invalidate_caches(self):
387 """Invalidate all measurement caches (call when children change)."""
388 self._is_measured = False
389 self._intrinsic_size = None
390 self._min_width_cache = None
391 self._preferred_width_cache = None
392 self._content_height_cache = None
393 self._is_laid_out = False
395 def add_child(self, child: Renderable) -> 'DynamicPage':
396 """
397 Add a child and invalidate caches.
399 Args:
400 child: The renderable object to add
402 Returns:
403 Self for method chaining
404 """
405 super().add_child(child)
406 self.invalidate_caches()
407 return self
409 def clear_children(self) -> 'DynamicPage':
410 """
411 Remove all children and invalidate caches.
413 Returns:
414 Self for method chaining
415 """
416 super().clear_children()
417 self.invalidate_caches()
418 return self