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

176 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-08 20:34 +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 # Mode of the render canvas. The measurement context matches it so that text 

19 # width caching keys stay consistent between layout and rendering. 

20 _CANVAS_MODE = 'RGBA' 

21 

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

23 origin: Tuple[int, int] = (0, 0)): 

24 """ 

25 Initialize a new page. 

26 

27 Args: 

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

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

30 origin: Absolute position of the page's top-left corner. Non-zero for 

31 a page nested inside another surface, such as a table cell. 

32 """ 

33 self._size = size 

34 self._origin = origin 

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

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

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

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

39 self._measurement_draw: Optional[ImageDraw.ImageDraw] = None 

40 # Initialize y_offset to start of content area 

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

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

43 self._current_y_offset = (self._origin[1] + self._style.border_width 

44 + self._style.padding_top) 

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

46 # Callback registry for managing interactable elements 

47 self._callbacks = CallbackRegistry() 

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

49 self._dirty = True 

50 

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

52 """ 

53 Get the remaining space in the content area. 

54 

55 Deprecated: use content_rect and remaining_height, which this delegates to. 

56 """ 

57 return (self.content_rect[2], self.remaining_height) 

58 

59 def can_fit_line( 

60 self, 

61 baseline_spacing: int, 

62 ascent: int = 0, 

63 descent: int = 0) -> bool: 

64 """ 

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

66 

67 Args: 

68 baseline_spacing: Distance from current position to next baseline 

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

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

71 

72 Returns: 

73 True if the line fits within page boundaries 

74 """ 

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

76 content_y, content_h = self.content_rect[1], self.content_rect[3] 

77 max_y = content_y + content_h 

78 

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

80 if ascent == 0 and descent == 0: 

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

82 

83 # Calculate where the bottom of the text would be 

84 # Text bottom = current_y_offset + ascent + descent 

85 text_bottom = self._current_y_offset + ascent + descent 

86 

87 # Check if text bottom would exceed the boundary 

88 return text_bottom <= max_y 

89 

90 @property 

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

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

93 return self._size 

94 

95 @property 

96 def origin(self) -> Tuple[int, int]: 

97 """Absolute position of the page's top-left corner""" 

98 return self._origin 

99 

100 @property 

101 def content_origin(self) -> Tuple[int, int]: 

102 """ 

103 Absolute top-left of the content box: the page origin plus its border and 

104 top/left padding. Layout starts here. 

105 """ 

106 return ( 

107 self._origin[0] + self._style.border_width + self._style.padding_left, 

108 self._origin[1] + self._style.border_width + self._style.padding_top, 

109 ) 

110 

111 @property 

112 def content_rect(self) -> Tuple[int, int, int, int]: 

113 """(x, y, width, height) of the content box, in absolute coordinates""" 

114 x, y = self.content_origin 

115 return (x, y, self.content_size[0], self.content_size[1]) 

116 

117 @property 

118 def remaining_height(self) -> int: 

119 """Content-box height still available below the current layout cursor""" 

120 _, y, _, h = self.content_rect 

121 return max(0, y + h - self._current_y_offset) 

122 

123 @property 

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

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

126 border_reduction = self._style.total_border_width 

127 return ( 

128 self._size[0] - border_reduction, 

129 self._size[1] - border_reduction 

130 ) 

131 

132 @property 

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

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

135 canvas_w, canvas_h = self.canvas_size 

136 return ( 

137 canvas_w - self._style.total_horizontal_padding, 

138 canvas_h - self._style.total_vertical_padding 

139 ) 

140 

141 @property 

142 def border_size(self) -> int: 

143 """Get the border width""" 

144 return self._style.border_width 

145 

146 @property 

147 def available_width(self) -> int: 

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

149 return self.content_size[0] 

150 

151 @property 

152 def style(self) -> PageStyle: 

153 """Get the page style""" 

154 return self._style 

155 

156 @property 

157 def callbacks(self) -> CallbackRegistry: 

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

159 return self._callbacks 

160 

161 @property 

162 def is_dirty(self) -> bool: 

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

164 return self._dirty 

165 

166 def mark_dirty(self): 

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

168 self._dirty = True 

169 

170 def mark_clean(self): 

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

172 self._dirty = False 

173 

174 @property 

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

176 """ 

177 Get the ImageDraw object bound to this page's render canvas. 

178 

179 Rebuilt whenever the canvas has been invalidated: a draw context 

180 outlives the image it was created from, so checking only _draw would 

181 hand back a context pointing at a discarded canvas. 

182 """ 

183 if self._draw is None or self._canvas is None: 

184 # Initialize canvas and draw context if not already done 

185 self._canvas = self._create_canvas() 

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

187 return self._draw 

188 

189 @property 

190 def measurement_draw(self) -> ImageDraw.ImageDraw: 

191 """ 

192 A scratch draw context for text metrics during layout. 

193 

194 Layout asks for text widths constantly, but has no reason to touch the 

195 render canvas - and the canvas is invalidated on every add_child, so 

196 measuring through `draw` would allocate a full-page image per line. 

197 This context is 1x1 and never invalidated. 

198 

199 Its mode matches the render canvas because Text keys its width cache on 

200 the draw mode; a mismatch would double every cache entry. Children built 

201 against it are re-bound to the real canvas by render_children. 

202 """ 

203 if self._measurement_draw is None: 

204 scratch = Image.new(self._CANVAS_MODE, (1, 1)) 

205 self._measurement_draw = ImageDraw.Draw(scratch) 

206 return self._measurement_draw 

207 

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

209 """ 

210 Add a child renderable object to this page. 

211 

212 Args: 

213 child: The renderable object to add 

214 

215 Returns: 

216 Self for method chaining 

217 """ 

218 self._children.append(child) 

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

220 # Invalidate the canvas when children change 

221 self._canvas = None 

222 return self 

223 

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

225 """ 

226 Remove a child from the page. 

227 

228 Args: 

229 child: The child to remove 

230 

231 Returns: 

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

233 """ 

234 try: 

235 self._children.remove(child) 

236 self._canvas = None 

237 return True 

238 except ValueError: 

239 return False 

240 

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

242 """ 

243 Remove all children from the page. 

244 

245 Returns: 

246 Self for method chaining 

247 """ 

248 self._children.clear() 

249 self._canvas = None 

250 # Clear callback registry when clearing children 

251 self._callbacks.clear() 

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

253 self._current_y_offset = self.content_origin[1] 

254 return self 

255 

256 @property 

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

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

259 return self._children.copy() 

260 

261 def render_children(self): 

262 """ 

263 Call render on all children in the list. 

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

265 """ 

266 for child in self._children: 

267 # Synchronize draw context for Line objects before rendering 

268 if hasattr(child, '_draw'): 

269 child._draw = self._draw 

270 # Synchronize canvas for Image objects before rendering 

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

272 child._canvas = self._canvas 

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

274 child.render() 

275 

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

277 """ 

278 Render the page with all its children. 

279 

280 Returns: 

281 PIL Image containing the rendered page 

282 """ 

283 # Create the base canvas and draw object 

284 self._canvas = self._create_canvas() 

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

286 

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

288 self.render_children() 

289 

290 # Mark as clean after rendering 

291 self._dirty = False 

292 

293 return self._canvas 

294 

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

296 """ 

297 Create the base canvas with background and borders. 

298 

299 Returns: 

300 PIL Image with background and borders applied 

301 """ 

302 # Create base image 

303 canvas = Image.new(self._CANVAS_MODE, self._size, (*self._style.background_color, 255)) 

304 

305 # Draw borders if needed 

306 if self._style.border_width > 0: 

307 draw = ImageDraw.Draw(canvas) 

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

309 

310 # Draw border rectangle inside the content area 

311 border_offset = self._style.border_width 

312 draw.rectangle([ 

313 (border_offset, border_offset), 

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

315 ], outline=border_color) 

316 

317 return canvas 

318 

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

320 """ 

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

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

323 

324 Args: 

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

326 

327 Returns: 

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

329 """ 

330 point_array = np.array(point) 

331 

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

333 for child in reversed(self._children): 

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

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

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

337 if hasattr(child, 'query_point'): 

338 result = child.query_point(point) 

339 if result: 

340 result.parent_page = self 

341 return result 

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

343 continue 

344 

345 # Otherwise, package this child as the result 

346 return self._make_query_result(child, point) 

347 

348 # Nothing hit - return empty result 

349 return QueryResult( 

350 object=self, 

351 object_type="empty", 

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

353 ) 

354 

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

356 """ 

357 Package an object into a QueryResult with metadata. 

358 

359 Args: 

360 obj: The object to package 

361 point: The query point 

362 

363 Returns: 

364 QueryResult with extracted metadata 

365 """ 

366 from .text import Text 

367 from .functional import LinkText, ButtonText 

368 

369 # Extract bounds 

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

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

372 bounds = ( 

373 int(origin[0]), 

374 int(origin[1]), 

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

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

377 ) 

378 

379 # Determine type and extract metadata 

380 if isinstance(obj, LinkText): 

381 return QueryResult( 

382 object=obj, 

383 object_type="link", 

384 bounds=bounds, 

385 text=obj._text, 

386 is_interactive=True, 

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

388 ) 

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

390 return QueryResult( 

391 object=obj, 

392 object_type="button", 

393 bounds=bounds, 

394 text=obj._text, 

395 is_interactive=True, 

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

397 ) 

398 elif isinstance(obj, Text): 

399 return QueryResult( 

400 object=obj, 

401 object_type="text", 

402 bounds=bounds, 

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

404 ) 

405 else: 

406 return QueryResult( 

407 object=obj, 

408 object_type="unknown", 

409 bounds=bounds 

410 ) 

411 

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

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

414 """ 

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

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

417 

418 Args: 

419 start: Starting (x, y) point 

420 end: Ending (x, y) point 

421 

422 Returns: 

423 SelectionRange with all text objects between the points 

424 """ 

425 results = [] 

426 in_selection = False 

427 

428 start_result = self.query_point(start) 

429 end_result = self.query_point(end) 

430 

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

432 return SelectionRange(start, end, []) 

433 

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

435 from .text import Line, Text 

436 

437 for child in self._children: 

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

439 for text_obj in child._text_objects: 

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

441 if text_obj == start_result.object: 

442 in_selection = True 

443 

444 if in_selection and isinstance(text_obj, Text): 

445 result = self._make_query_result(text_obj, start) 

446 results.append(result) 

447 

448 if text_obj == end_result.object: 

449 in_selection = False 

450 break 

451 

452 return SelectionRange(start, end, results) 

453 

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

455 """ 

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

457 

458 Args: 

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

460 

461 Returns: 

462 True if the point is within the page bounds 

463 """ 

464 return ( 

465 self._origin[0] <= point[0] < self._origin[0] + self._size[0] and 

466 self._origin[1] <= point[1] < self._origin[1] + self._size[1] 

467 )