Coverage for pyWebLayout/concrete/page.py: 66%

204 statements  

« prev     ^ index     » next       coverage.py v7.11.2, created at 2025-11-12 12:02 +0000

1from typing import List, Tuple, Optional 

2import numpy as np 

3from PIL import Image, ImageDraw 

4 

5from pyWebLayout.core.base import Renderable, Queriable 

6from pyWebLayout.core.query import QueryResult, SelectionRange 

7from pyWebLayout.core.callback_registry import CallbackRegistry 

8from pyWebLayout.style.page_style import PageStyle 

9 

10 

11class Page(Renderable, Queriable): 

12 """ 

13 A page represents a canvas that can hold and render child renderable objects. 

14 It handles layout, rendering, and provides query capabilities to find which child 

15 contains a given point. 

16 """ 

17 

18 def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None): 

19 """ 

20 Initialize a new page. 

21 

22 Args: 

23 size: The total size of the page (width, height) including borders 

24 style: The PageStyle defining borders, spacing, and appearance 

25 """ 

26 self._size = size 

27 self._style = style if style is not None else PageStyle() 

28 self._children: List[Renderable] = [] 

29 self._canvas: Optional[Image.Image] = None 

30 self._draw: Optional[ImageDraw.Draw] = None 

31 # Initialize y_offset to start of content area 

32 # Position the first line so its baseline is close to the top boundary 

33 # For subsequent lines, baseline-to-baseline spacing is used 

34 self._current_y_offset = self._style.border_width + self._style.padding_top 

35 self._is_first_line = True # Track if we're placing the first line 

36 # Callback registry for managing interactable elements 

37 self._callbacks = CallbackRegistry() 

38 # Dirty flag to track if page needs re-rendering due to state changes 

39 self._dirty = True 

40 

41 def free_space(self) -> Tuple[int, int]: 

42 """Get the remaining space on the page""" 

43 return (self._size[0], self._size[1] - self._current_y_offset) 

44 

45 def can_fit_line( 

46 self, 

47 baseline_spacing: int, 

48 ascent: int = 0, 

49 descent: int = 0) -> bool: 

50 """ 

51 Check if a line with the given metrics can fit on the page. 

52 

53 Args: 

54 baseline_spacing: Distance from current position to next baseline 

55 ascent: Font ascent (height above baseline), defaults to 0 for backward compat 

56 descent: Font descent (height below baseline), defaults to 0 for backward compat 

57 

58 Returns: 

59 True if the line fits within page boundaries 

60 """ 

61 # Calculate the maximum Y position allowed (bottom boundary) 

62 max_y = self._size[1] - self._style.border_width - self._style.padding_bottom 

63 

64 # If ascent/descent not provided, use simple check (backward compatibility) 

65 if ascent == 0 and descent == 0: 

66 return (self._current_y_offset + baseline_spacing) <= max_y 

67 

68 # Calculate where the bottom of the text would be 

69 # Text bottom = current_y_offset + ascent + descent 

70 text_bottom = self._current_y_offset + ascent + descent 

71 

72 # Check if text bottom would exceed the boundary 

73 return text_bottom <= max_y 

74 

75 @property 

76 def size(self) -> Tuple[int, int]: 

77 """Get the total page size including borders""" 

78 return self._size 

79 

80 @property 

81 def canvas_size(self) -> Tuple[int, int]: 

82 """Get the canvas size (page size minus borders)""" 

83 border_reduction = self._style.total_border_width 

84 return ( 

85 self._size[0] - border_reduction, 

86 self._size[1] - border_reduction 

87 ) 

88 

89 @property 

90 def content_size(self) -> Tuple[int, int]: 

91 """Get the content area size (canvas minus padding)""" 

92 canvas_w, canvas_h = self.canvas_size 

93 return ( 

94 canvas_w - self._style.total_horizontal_padding, 

95 canvas_h - self._style.total_vertical_padding 

96 ) 

97 

98 @property 

99 def border_size(self) -> int: 

100 """Get the border width""" 

101 return self._style.border_width 

102 

103 @property 

104 def available_width(self) -> int: 

105 """Get the available width for content (content area width)""" 

106 return self.content_size[0] 

107 

108 @property 

109 def style(self) -> PageStyle: 

110 """Get the page style""" 

111 return self._style 

112 

113 @property 

114 def callbacks(self) -> CallbackRegistry: 

115 """Get the callback registry for managing interactable elements""" 

116 return self._callbacks 

117 

118 @property 

119 def is_dirty(self) -> bool: 

120 """Check if the page needs re-rendering due to state changes""" 

121 return self._dirty 

122 

123 def mark_dirty(self): 

124 """Mark the page as needing re-rendering""" 

125 self._dirty = True 

126 

127 def mark_clean(self): 

128 """Mark the page as clean (up-to-date render)""" 

129 self._dirty = False 

130 

131 @property 

132 def draw(self) -> Optional[ImageDraw.Draw]: 

133 """Get the ImageDraw object for drawing on this page's canvas""" 

134 if self._draw is None: 

135 # Initialize canvas and draw context if not already done 

136 self._canvas = self._create_canvas() 

137 self._draw = ImageDraw.Draw(self._canvas) 

138 return self._draw 

139 

140 def add_child(self, child: Renderable) -> 'Page': 

141 """ 

142 Add a child renderable object to this page. 

143 

144 Args: 

145 child: The renderable object to add 

146 

147 Returns: 

148 Self for method chaining 

149 """ 

150 self._children.append(child) 

151 self._current_y_offset = child.origin[1] + child.size[1] 

152 # Invalidate the canvas when children change 

153 self._canvas = None 

154 return self 

155 

156 def remove_child(self, child: Renderable) -> bool: 

157 """ 

158 Remove a child from the page. 

159 

160 Args: 

161 child: The child to remove 

162 

163 Returns: 

164 True if the child was found and removed, False otherwise 

165 """ 

166 try: 

167 self._children.remove(child) 

168 self._canvas = None 

169 return True 

170 except ValueError: 

171 return False 

172 

173 def clear_children(self) -> 'Page': 

174 """ 

175 Remove all children from the page. 

176 

177 Returns: 

178 Self for method chaining 

179 """ 

180 self._children.clear() 

181 self._canvas = None 

182 # Clear callback registry when clearing children 

183 self._callbacks.clear() 

184 # Reset y_offset to start of content area (after border and padding) 

185 self._current_y_offset = self._style.border_width + self._style.padding_top 

186 return self 

187 

188 @property 

189 def children(self) -> List[Renderable]: 

190 """Get a copy of the children list""" 

191 return self._children.copy() 

192 

193 def _get_child_property(self, child: Renderable, private_attr: str, 

194 public_attr: str, index: Optional[int] = None, 

195 default: Optional[int] = None) -> Optional[int]: 

196 """ 

197 Generic helper to extract properties from child objects with multiple fallback strategies. 

198 

199 Args: 

200 child: The child object 

201 private_attr: Name of the private attribute (e.g., '_size') 

202 public_attr: Name of the public property (e.g., 'size') 

203 index: Optional index for array-like properties (0 for width, 1 for height) 

204 default: Default value if property cannot be determined 

205 

206 Returns: 

207 Property value or default 

208 """ 

209 # Try private attribute first 

210 if hasattr(child, private_attr): 

211 value = getattr(child, private_attr) 

212 if value is not None: 

213 if isinstance(value, (list, tuple, np.ndarray)): 

214 if index is not None and len(value) > index: 

215 return int(value[index]) 

216 elif index is None: 

217 return value 

218 

219 # Try public property 

220 if hasattr(child, public_attr): 

221 value = getattr(child, public_attr) 

222 if value is not None: 

223 if isinstance(value, (list, tuple, np.ndarray)): 

224 if index is not None and len(value) > index: 

225 return int(value[index]) 

226 elif index is None: 

227 return value 

228 else: 

229 return int(value) 

230 

231 return default 

232 

233 def _get_child_height(self, child: Renderable) -> int: 

234 """ 

235 Get the height of a child object. 

236 

237 Args: 

238 child: The child to measure 

239 

240 Returns: 

241 Height in pixels 

242 """ 

243 # Try to get height from size property (index 1) 

244 height = self._get_child_property(child, '_size', 'size', index=1) 

245 if height is not None: 

246 return height 

247 

248 # Try direct height attribute 

249 height = self._get_child_property(child, '_height', 'height') 

250 if height is not None: 

251 return height 

252 

253 # Default fallback height 

254 return 20 

255 

256 def render_children(self): 

257 """ 

258 Call render on all children in the list. 

259 Children draw directly onto the page's canvas via the shared ImageDraw object. 

260 """ 

261 for child in self._children: 

262 # Synchronize draw context for Line objects before rendering 

263 if hasattr(child, '_draw'): 

264 child._draw = self._draw 

265 # Synchronize canvas for Image objects before rendering 

266 if hasattr(child, '_canvas'): 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 child._canvas = self._canvas 

268 if hasattr(child, 'render'): 268 ↛ 261line 268 didn't jump to line 261 because the condition on line 268 was always true

269 child.render() 

270 

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

272 """ 

273 Render the page with all its children. 

274 

275 Returns: 

276 PIL Image containing the rendered page 

277 """ 

278 # Create the base canvas and draw object 

279 self._canvas = self._create_canvas() 

280 self._draw = ImageDraw.Draw(self._canvas) 

281 

282 # Render all children - they draw directly onto the canvas 

283 self.render_children() 

284 

285 # Mark as clean after rendering 

286 self._dirty = False 

287 

288 return self._canvas 

289 

290 def _create_canvas(self) -> Image.Image: 

291 """ 

292 Create the base canvas with background and borders. 

293 

294 Returns: 

295 PIL Image with background and borders applied 

296 """ 

297 # Create base image 

298 canvas = Image.new('RGBA', self._size, (*self._style.background_color, 255)) 

299 

300 # Draw borders if needed 

301 if self._style.border_width > 0: 

302 draw = ImageDraw.Draw(canvas) 

303 border_color = (*self._style.border_color, 255) 

304 

305 # Draw border rectangle inside the content area 

306 border_offset = self._style.border_width 

307 draw.rectangle([ 

308 (border_offset, border_offset), 

309 (self._size[0] - border_offset - 1, self._size[1] - border_offset - 1) 

310 ], outline=border_color) 

311 

312 return canvas 

313 

314 def _get_child_position(self, child: Renderable) -> Tuple[int, int]: 

315 """ 

316 Get the position where a child should be rendered. 

317 

318 Args: 

319 child: The child object 

320 

321 Returns: 

322 Tuple of (x, y) coordinates 

323 """ 

324 # Try to get x coordinate 

325 x = self._get_child_property(child, '_origin', 'position', index=0, default=0) 

326 # Try to get y coordinate 

327 y = self._get_child_property(child, '_origin', 'position', index=1, default=0) 

328 

329 return (x, y) 

330 

331 def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]: 

332 """ 

333 Query a point to find the deepest object at that location. 

334 Traverses children and uses Queriable.in_object() for hit-testing. 

335 

336 Args: 

337 point: The (x, y) coordinates to query 

338 

339 Returns: 

340 QueryResult with metadata about what was found, or None if nothing hit 

341 """ 

342 point_array = np.array(point) 

343 

344 # Check each child (in reverse order so topmost child is found first) 

345 for child in reversed(self._children): 

346 # Use Queriable mixin's in_object() for hit-testing 

347 if isinstance(child, Queriable) and child.in_object(point_array): 

348 # If child can also query (has children of its own), recurse 

349 if hasattr(child, 'query_point'): 

350 result = child.query_point(point) 

351 if result: 351 ↛ 355line 351 didn't jump to line 355 because the condition on line 351 was always true

352 result.parent_page = self 

353 return result 

354 # If child's query returned None, continue to next child 

355 continue 

356 

357 # Otherwise, package this child as the result 

358 return self._make_query_result(child, point) 

359 

360 # Nothing hit - return empty result 

361 return QueryResult( 

362 object=self, 

363 object_type="empty", 

364 bounds=(int(point[0]), int(point[1]), 0, 0) 

365 ) 

366 

367 def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool: 

368 """ 

369 Check if a point is within a child's bounds. 

370 

371 Args: 

372 point: The point to check 

373 child: The child to check against 

374 

375 Returns: 

376 True if the point is within the child's bounds 

377 """ 

378 # If child implements Queriable interface, use it 

379 if isinstance(child, Queriable) and hasattr(child, 'in_object'): 

380 try: 

381 return child.in_object(point) 

382 except BaseException: 

383 pass # Fall back to bounds checking 

384 

385 # Get child position and size for bounds checking 

386 child_pos = self._get_child_position(child) 

387 child_size = self._get_child_size(child) 

388 

389 if child_size is None: 

390 return False 

391 

392 # Check if point is within child bounds 

393 return ( 

394 child_pos[0] <= point[0] < child_pos[0] + child_size[0] and 

395 child_pos[1] <= point[1] < child_pos[1] + child_size[1] 

396 ) 

397 

398 def _get_child_size(self, child: Renderable) -> Optional[Tuple[int, int]]: 

399 """ 

400 Get the size of a child object. 

401 

402 Args: 

403 child: The child to measure 

404 

405 Returns: 

406 Tuple of (width, height) or None if size cannot be determined 

407 """ 

408 # Try to get width and height from size property 

409 width = self._get_child_property(child, '_size', 'size', index=0) 

410 height = self._get_child_property(child, '_size', 'size', index=1) 

411 

412 # If size property worked, return it 

413 if width is not None and height is not None: 

414 return (width, height) 

415 

416 # Try direct width/height attributes 

417 width = self._get_child_property(child, '_width', 'width') 

418 height = self._get_child_property(child, '_height', 'height') 

419 

420 if width is not None and height is not None: 

421 return (width, height) 

422 

423 return None 

424 

425 def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult: 

426 """ 

427 Package an object into a QueryResult with metadata. 

428 

429 Args: 

430 obj: The object to package 

431 point: The query point 

432 

433 Returns: 

434 QueryResult with extracted metadata 

435 """ 

436 from .text import Text 

437 from .functional import LinkText, ButtonText 

438 

439 # Extract bounds 

440 origin = getattr(obj, '_origin', np.array([0, 0])) 

441 size = getattr(obj, 'size', np.array([0, 0])) 

442 bounds = ( 

443 int(origin[0]), 

444 int(origin[1]), 

445 int(size[0]) if hasattr(size, '__getitem__') else 0, 

446 int(size[1]) if hasattr(size, '__getitem__') else 0 

447 ) 

448 

449 # Determine type and extract metadata 

450 if isinstance(obj, LinkText): 

451 return QueryResult( 

452 object=obj, 

453 object_type="link", 

454 bounds=bounds, 

455 text=obj._text, 

456 is_interactive=True, 

457 link_target=obj._link.location if hasattr(obj, '_link') else None 

458 ) 

459 elif isinstance(obj, ButtonText): 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true

460 return QueryResult( 

461 object=obj, 

462 object_type="button", 

463 bounds=bounds, 

464 text=obj._text, 

465 is_interactive=True, 

466 callback=obj._callback if hasattr(obj, '_callback') else None 

467 ) 

468 elif isinstance(obj, Text): 

469 return QueryResult( 

470 object=obj, 

471 object_type="text", 

472 bounds=bounds, 

473 text=obj._text if hasattr(obj, '_text') else None 

474 ) 

475 else: 

476 return QueryResult( 

477 object=obj, 

478 object_type="unknown", 

479 bounds=bounds 

480 ) 

481 

482 def query_range(self, start: Tuple[int, int], 

483 end: Tuple[int, int]) -> SelectionRange: 

484 """ 

485 Query all text objects between two points (for text selection). 

486 Uses Queriable.in_object() to determine which objects are in range. 

487 

488 Args: 

489 start: Starting (x, y) point 

490 end: Ending (x, y) point 

491 

492 Returns: 

493 SelectionRange with all text objects between the points 

494 """ 

495 results = [] 

496 in_selection = False 

497 

498 start_result = self.query_point(start) 

499 end_result = self.query_point(end) 

500 

501 if not start_result or not end_result: 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true

502 return SelectionRange(start, end, []) 

503 

504 # Walk through all children (Lines) and their text objects 

505 from .text import Line, Text 

506 

507 for child in self._children: 

508 if isinstance(child, Line) and hasattr(child, '_text_objects'): 508 ↛ 507line 508 didn't jump to line 507 because the condition on line 508 was always true

509 for text_obj in child._text_objects: 509 ↛ 507line 509 didn't jump to line 507 because the loop on line 509 didn't complete

510 # Check if this text is the start or is between start and end 

511 if text_obj == start_result.object: 

512 in_selection = True 

513 

514 if in_selection and isinstance(text_obj, Text): 514 ↛ 518line 514 didn't jump to line 518 because the condition on line 514 was always true

515 result = self._make_query_result(text_obj, start) 

516 results.append(result) 

517 

518 if text_obj == end_result.object: 

519 in_selection = False 

520 break 

521 

522 return SelectionRange(start, end, results) 

523 

524 def in_object(self, point: Tuple[int, int]) -> bool: 

525 """ 

526 Check if a point is within this page's bounds. 

527 

528 Args: 

529 point: The (x, y) coordinates to check 

530 

531 Returns: 

532 True if the point is within the page bounds 

533 """ 

534 return ( 

535 0 <= point[0] < self._size[0] and 

536 0 <= point[1] < self._size[1] 

537 )