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

1""" 

2DynamicPage implementation for pyWebLayout. 

3 

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. 

7 

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""" 

13 

14from typing import Tuple, Optional, List 

15from dataclasses import dataclass 

16import numpy as np 

17from PIL import Image 

18 

19from pyWebLayout.concrete.page import Page 

20from pyWebLayout.style.page_style import PageStyle 

21from pyWebLayout.core.base import Renderable 

22 

23 

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 

33 

34 

35class DynamicPage(Page): 

36 """ 

37 A page that dynamically sizes itself based on content and constraints. 

38 

39 The layout process has two phases: 

40 1. Measurement: Calculate intrinsic size needed for content 

41 2. Layout: Position content within allocated size 

42 

43 This allows containers (like tables) to optimize space allocation before rendering. 

44 """ 

45 

46 def __init__(self, 

47 constraints: Optional[SizeConstraints] = None, 

48 style: Optional[PageStyle] = None): 

49 """ 

50 Initialize a dynamic page. 

51 

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

59 

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 

66 

67 # Pagination state 

68 self._render_offset = 0 # For partial rendering (pagination) 

69 self._is_laid_out = False 

70 

71 @property 

72 def constraints(self) -> SizeConstraints: 

73 """Get the size constraints for this page.""" 

74 return self._constraints 

75 

76 def measure(self, available_width: Optional[int] = None) -> Tuple[int, int]: 

77 """ 

78 Measure the intrinsic size needed for content. 

79 

80 This walks through all children and calculates how much space they need. 

81 The measurement respects constraints (min/max width/height). 

82 

83 Args: 

84 available_width: Optional width constraint for wrapping content 

85 

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 

91 

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) 

98 

99 # Measure content 

100 # For now, walk through children and sum their sizes 

101 total_width = 0 

102 total_height = 0 

103 

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 

113 

114 total_width = max(total_width, child_width) 

115 total_height += child_height 

116 

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 

120 

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) 

130 

131 self._intrinsic_size = (total_width, total_height) 

132 self._is_measured = True 

133 

134 return self._intrinsic_size 

135 

136 def get_min_width(self) -> int: 

137 """ 

138 Get minimum width needed to render content. 

139 

140 This finds the widest word/element that cannot be broken, 

141 using Font.min_hyphenation_width for hyphenation control. 

142 

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 

149 

150 # Calculate minimum width based on content 

151 from pyWebLayout.concrete.text import Line, Text 

152 

153 min_width = 0 

154 

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) 

165 

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

177 

178 # Add padding/borders 

179 min_width += self._style.total_horizontal_padding + self._style.total_border_width 

180 

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) 

184 

185 self._min_width_cache = min_width 

186 return min_width 

187 

188 def get_preferred_width(self) -> int: 

189 """ 

190 Get preferred width (no wrapping). 

191 

192 This returns the width needed to render all content without any 

193 line wrapping. 

194 

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 

201 

202 # Calculate preferred width (no wrapping) 

203 from pyWebLayout.concrete.text import Line 

204 

205 pref_width = 0 

206 

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 

218 

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 

225 

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

233 

234 # Add padding/borders 

235 pref_width += self._style.total_horizontal_padding + self._style.total_border_width 

236 

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) 

242 

243 self._preferred_width_cache = pref_width 

244 return pref_width 

245 

246 def measure_content_height(self) -> int: 

247 """ 

248 Measure total height needed to render all content. 

249 

250 This is used for pagination to know how much content remains. 

251 

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 

258 

259 total_height = 0 

260 

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 

268 

269 total_height += child_height 

270 

271 # Add padding/borders 

272 total_height += self._style.total_vertical_padding + self._style.total_border_width 

273 

274 self._content_height_cache = total_height 

275 return total_height 

276 

277 def layout(self, size: Tuple[int, int]): 

278 """ 

279 Layout content within the given size. 

280 

281 This is called after measurement to position children within 

282 the allocated space. 

283 

284 Args: 

285 size: The final size allocated to this page (width, height) 

286 """ 

287 # Set the page size 

288 self._size = size 

289 

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 

294 

295 self._current_y_offset = content_y 

296 self._is_first_line = True 

297 

298 # Children position themselves, we just track y_offset 

299 # The actual positioning happens when children render 

300 

301 self._is_laid_out = True 

302 self._dirty = True # Mark for re-render 

303 

304 def render(self) -> Image.Image: 

305 """ 

306 Render the page with all its children. 

307 

308 If not yet measured/laid out, use intrinsic sizing. 

309 

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

318 

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) 

324 

325 # Use parent's render implementation 

326 return super().render() 

327 

328 # Pagination Support 

329 # ------------------ 

330 

331 def render_partial(self, available_height: int) -> int: 

332 """ 

333 Render as much content as fits in available_height. 

334 

335 This is used for pagination when a page needs to be split across 

336 multiple output pages. 

337 

338 Args: 

339 available_height: Height available on current page 

340 

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 

347 

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 

354 

355 # Check if this child fits 

356 child_height = child.size[1] if hasattr(child, 'size') else 0 

357 

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 

366 

367 # Update render offset for next call 

368 self._render_offset = rendered_height 

369 

370 return rendered_height 

371 

372 def has_more_content(self) -> bool: 

373 """ 

374 Check if there's unrendered content remaining. 

375 

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 

381 

382 def reset_pagination(self): 

383 """Reset pagination to render from beginning.""" 

384 self._render_offset = 0 

385 

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 

394 

395 def add_child(self, child: Renderable) -> 'DynamicPage': 

396 """ 

397 Add a child and invalidate caches. 

398 

399 Args: 

400 child: The renderable object to add 

401 

402 Returns: 

403 Self for method chaining 

404 """ 

405 super().add_child(child) 

406 self.invalidate_caches() 

407 return self 

408 

409 def clear_children(self) -> 'DynamicPage': 

410 """ 

411 Remove all children and invalidate caches. 

412 

413 Returns: 

414 Self for method chaining 

415 """ 

416 super().clear_children() 

417 self.invalidate_caches() 

418 return self