Coverage for pyWebLayout/concrete/table.py: 70%

303 statements  

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

1""" 

2Concrete table rendering implementation for pyWebLayout. 

3 

4This module provides the concrete rendering classes for tables, including: 

5- TableRenderer: Main table rendering with borders and spacing 

6- TableRowRenderer: Individual row rendering 

7- TableCellRenderer: Cell rendering with support for nested content (text, images, links) 

8""" 

9 

10from __future__ import annotations 

11from typing import Tuple, List, Optional, Dict 

12from PIL import Image, ImageDraw 

13from dataclasses import dataclass 

14 

15from pyWebLayout.core.base import Renderable 

16from pyWebLayout.concrete.box import Box 

17from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage 

18from pyWebLayout.abstract.interactive_image import InteractiveImage 

19 

20 

21@dataclass 

22class TableStyle: 

23 """Styling configuration for table rendering.""" 

24 

25 # Border configuration 

26 border_width: int = 1 

27 border_color: Tuple[int, int, int] = (0, 0, 0) 

28 

29 # Cell padding 

30 cell_padding: Tuple[int, int, int, int] = (5, 5, 5, 5) # top, right, bottom, left 

31 

32 # Header styling 

33 header_bg_color: Tuple[int, int, int] = (240, 240, 240) 

34 header_text_bold: bool = True 

35 

36 # Cell background 

37 cell_bg_color: Tuple[int, int, int] = (255, 255, 255) 

38 alternate_row_color: Optional[Tuple[int, int, int]] = (250, 250, 250) 

39 

40 # Spacing 

41 cell_spacing: int = 0 # Space between cells (for separated borders model) 

42 

43 

44class TableCellRenderer(Box): 

45 """ 

46 Renders a single table cell with its content. 

47 Supports paragraphs, headings, images, and links within cells. 

48 """ 

49 

50 def __init__(self, 

51 cell: TableCell, 

52 origin: Tuple[int, 

53 int], 

54 size: Tuple[int, 

55 int], 

56 draw: ImageDraw.Draw, 

57 style: TableStyle, 

58 is_header_section: bool = False, 

59 canvas: Optional[Image.Image] = None): 

60 """ 

61 Initialize a table cell renderer. 

62 

63 Args: 

64 cell: The abstract TableCell to render 

65 origin: Top-left position of the cell 

66 size: Width and height of the cell 

67 draw: PIL ImageDraw object for rendering 

68 style: Table styling configuration 

69 is_header_section: Whether this cell is in the header section 

70 canvas: Optional PIL Image for pasting images (required for image rendering) 

71 """ 

72 super().__init__(origin, size) 

73 self._cell = cell 

74 self._draw = draw 

75 self._style = style 

76 self._is_header_section = is_header_section or cell.is_header 

77 self._canvas = canvas 

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

79 

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

81 """Render the table cell.""" 

82 # Determine background color 

83 if self._is_header_section: 

84 bg_color = self._style.header_bg_color 

85 else: 

86 bg_color = self._style.cell_bg_color 

87 

88 # Draw cell background 

89 x, y = self._origin 

90 w, h = self._size 

91 self._draw.rectangle( 

92 [x, y, x + w, y + h], 

93 fill=bg_color, 

94 outline=self._style.border_color, 

95 width=self._style.border_width 

96 ) 

97 

98 # Calculate content area (inside padding) 

99 padding = self._style.cell_padding 

100 content_x = x + padding[3] # left padding 

101 content_y = y + padding[0] # top padding 

102 content_width = w - (padding[1] + padding[3]) # minus left and right padding 

103 content_height = h - (padding[0] + padding[2]) # minus top and bottom padding 

104 

105 # Render cell content (text) 

106 self._render_cell_content(content_x, content_y, content_width, content_height) 

107 

108 return None # Cell rendering is done directly on the page 

109 

110 def _render_cell_content(self, x: int, y: int, width: int, height: int): 

111 """Render the content inside the cell (text and images) with line wrapping.""" 

112 from pyWebLayout.concrete.text import Line, Text 

113 from pyWebLayout.style.fonts import Font 

114 from pyWebLayout.style import FontWeight, Alignment 

115 

116 current_y = y + 2 

117 available_height = height - 4 # Account for top/bottom padding 

118 

119 # Create font for the cell 

120 font_size = 12 

121 font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" 

122 if self._is_header_section and self._style.header_text_bold: 

123 font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" 

124 

125 font = Font( 

126 font_path=font_path, 

127 font_size=font_size, 

128 weight=FontWeight.BOLD if self._is_header_section and self._style.header_text_bold else FontWeight.NORMAL 

129 ) 

130 

131 # Word spacing constraints (min, max) 

132 min_spacing = int(font_size * 0.25) 

133 max_spacing = int(font_size * 0.5) 

134 word_spacing = (min_spacing, max_spacing) 

135 

136 # Line height (baseline spacing) 

137 line_height = font_size + 4 

138 ascent, descent = font.font.getmetrics() 

139 

140 # Render each block in the cell 

141 for block in self._cell.blocks(): 

142 if isinstance(block, AbstractImage): 142 ↛ 144line 142 didn't jump to line 144 because the condition on line 142 was never true

143 # Render image 

144 current_y = self._render_image_in_cell( 

145 block, x, current_y, width, height - (current_y - y)) 

146 elif isinstance(block, (Paragraph, Heading)): 146 ↛ 225line 146 didn't jump to line 225 because the condition on line 146 was always true

147 # Get words from the block 

148 from pyWebLayout.abstract.inline import Word as AbstractWord 

149 

150 word_items = block.words() if callable(block.words) else block.words 

151 words = list(word_items) 

152 

153 if not words: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true

154 continue 

155 

156 # Create new Word objects with the table cell's font 

157 # The words from the paragraph may have AbstractStyle, but we need Font objects 

158 wrapped_words = [] 

159 for word_item in words: 

160 # Handle word tuples (index, word_obj) 

161 if isinstance(word_item, tuple) and len(word_item) >= 2: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 word_obj = word_item[1] 

163 else: 

164 word_obj = word_item 

165 

166 # Extract text from the word 

167 word_text = word_obj.text if hasattr(word_obj, 'text') else str(word_obj) 

168 

169 # Create a new Word with the cell's Font 

170 new_word = AbstractWord(word_text, font) 

171 wrapped_words.append(new_word) 

172 

173 # Layout words using Line objects with wrapping 

174 word_index = 0 

175 pretext = None 

176 

177 while word_index < len(wrapped_words): 

178 # Check if we have space for another line 

179 if current_y + ascent + descent > y + available_height: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 break # No more space in cell 

181 

182 # Create a new line 

183 line = Line( 

184 spacing=word_spacing, 

185 origin=(x + 2, current_y), 

186 size=(width - 4, line_height), 

187 draw=self._draw, 

188 font=font, 

189 halign=Alignment.LEFT 

190 ) 

191 

192 # Add words to this line until it's full 

193 line_has_content = False 

194 while word_index < len(wrapped_words): 

195 word = wrapped_words[word_index] 

196 

197 # Try to add word to line 

198 success, overflow = line.add_word(word, pretext) 

199 pretext = None # Clear pretext after use 

200 

201 if success: 201 ↛ 214line 201 didn't jump to line 214 because the condition on line 201 was always true

202 line_has_content = True 

203 if overflow: 203 ↛ 207line 203 didn't jump to line 207 because the condition on line 203 was never true

204 # Word was hyphenated, carry over to next line 

205 # DON'T increment word_index - we need to add the overflow 

206 # to the next line with the same word 

207 pretext = overflow 

208 break # Move to next line 

209 else: 

210 # Word fit completely, move to next word 

211 word_index += 1 

212 else: 

213 # Word doesn't fit on this line 

214 if not line_has_content: 

215 # Even first word doesn't fit, force it anyway and advance 

216 # This prevents infinite loops with words that truly can't fit 

217 word_index += 1 

218 break 

219 

220 # Render the line if it has content 

221 if line_has_content or len(line.text_objects) > 0: 221 ↛ 177line 221 didn't jump to line 177 because the condition on line 221 was always true

222 line.render() 

223 current_y += line_height 

224 

225 if current_y > y + height - 10: # Don't overflow cell 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true

226 break 

227 

228 # If no structured content, try to get any text representation 

229 if current_y == y + 2 and hasattr(self._cell, '_text_content'): 229 ↛ 231line 229 didn't jump to line 231 because the condition on line 229 was never true

230 # Use simple text rendering for fallback case 

231 from PIL import ImageFont 

232 try: 

233 pil_font = ImageFont.truetype(font_path, font_size) 

234 except BaseException: 

235 pil_font = ImageFont.load_default() 

236 

237 self._draw.text( 

238 (x + 2, current_y), 

239 self._cell._text_content, 

240 fill=(0, 0, 0), 

241 font=pil_font 

242 ) 

243 

244 def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int, 

245 max_width: int, max_height: int) -> int: 

246 """ 

247 Render an image block inside a table cell. 

248 

249 Returns: 

250 The new Y position after the image 

251 """ 

252 try: 

253 # Get the image path from the block 

254 image_path = None 

255 if hasattr(image_block, 'source'): 

256 image_path = image_block.source 

257 elif hasattr(image_block, '_source'): 

258 image_path = image_block._source 

259 elif hasattr(image_block, 'path'): 

260 image_path = image_block.path 

261 elif hasattr(image_block, 'src'): 

262 image_path = image_block.src 

263 elif hasattr(image_block, '_path'): 

264 image_path = image_block._path 

265 elif hasattr(image_block, '_src'): 

266 image_path = image_block._src 

267 

268 if not image_path: 

269 return y + 20 # Skip if no image path 

270 

271 # Load and resize image to fit in cell 

272 img = Image.open(image_path) 

273 

274 # Calculate scaling to fit within max dimensions 

275 # Use more of the cell space for images 

276 img_width, img_height = img.size 

277 scale_w = max_width / img_width if img_width > max_width else 1 

278 scale_h = (max_height - 10) / \ 

279 img_height if img_height > (max_height - 10) else 1 

280 scale = min(scale_w, scale_h, 1.0) # Don't upscale 

281 

282 new_width = int(img_width * scale) 

283 new_height = int(img_height * scale) 

284 

285 if scale < 1.0: 

286 img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) 

287 

288 # Center image horizontally in cell 

289 img_x = x + (max_width - new_width) // 2 

290 

291 # Paste the image onto the canvas if available 

292 if self._canvas is not None: 

293 if img.mode == 'RGBA': 

294 self._canvas.paste(img, (img_x, y), img) 

295 else: 

296 self._canvas.paste(img, (img_x, y)) 

297 else: 

298 # Fallback: draw a placeholder if no canvas provided 

299 self._draw.rectangle( 

300 [img_x, y, img_x + new_width, y + new_height], 

301 fill=(200, 200, 200), 

302 outline=(150, 150, 150) 

303 ) 

304 

305 # Draw image indicator text 

306 from PIL import ImageFont 

307 try: 

308 small_font = ImageFont.truetype( 

309 "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9) 

310 except BaseException: 

311 small_font = ImageFont.load_default() 

312 

313 text = f"[Image: {new_width}x{new_height}]" 

314 bbox = self._draw.textbbox((0, 0), text, font=small_font) 

315 text_width = bbox[2] - bbox[0] 

316 text_x = img_x + (new_width - text_width) // 2 

317 text_y = y + (new_height - 12) // 2 

318 self._draw.text( 

319 (text_x, text_y), text, fill=( 

320 100, 100, 100), font=small_font) 

321 

322 # Set bounds on InteractiveImage objects for tap detection 

323 if isinstance(image_block, InteractiveImage): 

324 image_block.set_rendered_bounds( 

325 origin=(img_x, y), 

326 size=(new_width, new_height) 

327 ) 

328 

329 return y + new_height + 5 # Add some spacing after image 

330 

331 except Exception: 

332 # If image loading fails, just return current position 

333 return y + 20 

334 

335 

336class TableRowRenderer(Box): 

337 """ 

338 Renders a single table row containing multiple cells. 

339 """ 

340 

341 def __init__(self, 

342 row: TableRow, 

343 origin: Tuple[int, 

344 int], 

345 column_widths: List[int], 

346 row_height: int, 

347 draw: ImageDraw.Draw, 

348 style: TableStyle, 

349 is_header_section: bool = False, 

350 canvas: Optional[Image.Image] = None): 

351 """ 

352 Initialize a table row renderer. 

353 

354 Args: 

355 row: The abstract TableRow to render 

356 origin: Top-left position of the row 

357 column_widths: List of widths for each column 

358 row_height: Height of this row 

359 draw: PIL ImageDraw object for rendering 

360 style: Table styling configuration 

361 is_header_section: Whether this row is in the header section 

362 canvas: Optional PIL Image for pasting images 

363 """ 

364 width = sum(column_widths) + style.border_width * (len(column_widths) + 1) 

365 super().__init__(origin, (width, row_height)) 

366 self._row = row 

367 self._column_widths = column_widths 

368 self._row_height = row_height 

369 self._draw = draw 

370 self._style = style 

371 self._is_header_section = is_header_section 

372 self._canvas = canvas 

373 self._cell_renderers: List[TableCellRenderer] = [] 

374 

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

376 """Render the table row by rendering each cell.""" 

377 x, y = self._origin 

378 current_x = x 

379 

380 # Render each cell 

381 cells = list(self._row.cells()) 

382 for i, cell in enumerate(cells): 

383 if i < len(self._column_widths): 383 ↛ 382line 383 didn't jump to line 382 because the condition on line 383 was always true

384 cell_width = self._column_widths[i] 

385 

386 # Handle colspan 

387 if cell.colspan > 1 and i + cell.colspan <= len(self._column_widths): 

388 # Sum up widths for spanned columns 

389 cell_width = sum(self._column_widths[i:i + cell.colspan]) 

390 cell_width += self._style.border_width * (cell.colspan - 1) 

391 

392 # Create and render cell 

393 cell_renderer = TableCellRenderer( 

394 cell, 

395 (current_x, y), 

396 (cell_width, self._row_height), 

397 self._draw, 

398 self._style, 

399 self._is_header_section, 

400 self._canvas 

401 ) 

402 cell_renderer.render() 

403 self._cell_renderers.append(cell_renderer) 

404 

405 current_x += cell_width + self._style.border_width 

406 

407 return None # Row rendering is done directly on the page 

408 

409 

410class TableRenderer(Box): 

411 """ 

412 Main table renderer that orchestrates the rendering of an entire table. 

413 Handles layout calculation, row/cell placement, and overall table structure. 

414 """ 

415 

416 def __init__(self, 

417 table: Table, 

418 origin: Tuple[int, 

419 int], 

420 available_width: int, 

421 draw: ImageDraw.Draw, 

422 style: Optional[TableStyle] = None, 

423 canvas: Optional[Image.Image] = None): 

424 """ 

425 Initialize a table renderer. 

426 

427 Args: 

428 table: The abstract Table to render 

429 origin: Top-left position where the table should be rendered 

430 available_width: Maximum width available for the table 

431 draw: PIL ImageDraw object for rendering 

432 style: Optional table styling configuration 

433 canvas: Optional PIL Image for pasting images 

434 """ 

435 self._table = table 

436 self._draw = draw 

437 self._style = style or TableStyle() 

438 self._available_width = available_width 

439 self._canvas = canvas 

440 

441 # Calculate table dimensions 

442 self._column_widths, self._row_heights = self._calculate_dimensions() 

443 total_width = sum(self._column_widths) + \ 

444 self._style.border_width * (len(self._column_widths) + 1) 

445 total_height = sum(self._row_heights.values()) + \ 

446 self._style.border_width * (len(self._row_heights) + 1) 

447 

448 super().__init__(origin, (total_width, total_height)) 

449 self._row_renderers: List[TableRowRenderer] = [] 

450 

451 def _calculate_dimensions(self) -> Tuple[List[int], Dict[str, int]]: 

452 """ 

453 Calculate column widths and row heights for the table. 

454 

455 Uses the table optimizer for intelligent column width distribution. 

456 

457 Returns: 

458 Tuple of (column_widths, row_heights_dict) 

459 """ 

460 from pyWebLayout.layout.table_optimizer import optimize_table_layout 

461 

462 all_rows = list(self._table.all_rows()) 

463 

464 if not all_rows: 

465 return ([100], {"header": 30, "body": 30, "footer": 30}) 

466 

467 # Use optimizer for column widths! 

468 column_widths = optimize_table_layout( 

469 self._table, 

470 self._available_width, 

471 sample_size=5, 

472 style=self._style 

473 ) 

474 

475 if not column_widths: 475 ↛ 477line 475 didn't jump to line 477 because the condition on line 475 was never true

476 # Fallback if table is empty 

477 column_widths = [100] 

478 

479 # Calculate row heights dynamically based on optimized column widths 

480 header_height = self._calculate_row_height_for_section( 

481 all_rows, "header", column_widths) if any( 

482 1 for section, _ in all_rows if section == "header") else 0 

483 

484 body_height = self._calculate_row_height_for_section( 

485 all_rows, "body", column_widths) 

486 

487 footer_height = self._calculate_row_height_for_section( 

488 all_rows, "footer", column_widths) if any( 

489 1 for section, _ in all_rows if section == "footer") else 0 

490 

491 row_heights = { 

492 "header": header_height, 

493 "body": body_height, 

494 "footer": footer_height 

495 } 

496 

497 return (column_widths, row_heights) 

498 

499 def _calculate_row_height_for_section( 

500 self, 

501 all_rows: List, 

502 section: str, 

503 column_widths: List[int]) -> int: 

504 """ 

505 Calculate the maximum required height for rows in a specific section. 

506 

507 Args: 

508 all_rows: List of all rows in the table 

509 section: Section name ('header', 'body', or 'footer') 

510 column_widths: List of column widths 

511 

512 Returns: 

513 Maximum height needed for rows in this section 

514 """ 

515 from pyWebLayout.concrete.text import Text 

516 from pyWebLayout.style.fonts import Font 

517 from pyWebLayout.abstract.inline import Word as AbstractWord 

518 

519 # Font configuration 

520 font_size = 12 

521 line_height = font_size + 4 

522 padding = self._style.cell_padding 

523 vertical_padding = padding[0] + padding[2] # top + bottom 

524 horizontal_padding = padding[1] + padding[3] # left + right 

525 

526 max_height = 40 # Minimum height 

527 

528 for row_section, row in all_rows: 

529 if row_section != section: 

530 continue 

531 

532 row_max_height = 40 # Minimum for this row 

533 

534 for cell_idx, cell in enumerate(row.cells()): 

535 if cell_idx >= len(column_widths): 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true

536 continue 

537 

538 # Get cell width (accounting for colspan) 

539 cell_width = column_widths[cell_idx] 

540 if cell.colspan > 1 and cell_idx + \ 540 ↛ 542line 540 didn't jump to line 542 because the condition on line 540 was never true

541 cell.colspan <= len(column_widths): 

542 cell_width = sum( 

543 column_widths[cell_idx:cell_idx + cell.colspan]) 

544 cell_width += self._style.border_width * (cell.colspan - 1) 

545 

546 # Calculate content width (minus padding) 

547 content_width = cell_width - horizontal_padding - 4 # Extra margin 

548 

549 cell_height = vertical_padding + 4 # Base height with padding 

550 

551 # Analyze each block in the cell 

552 for block in cell.blocks(): 

553 if isinstance(block, AbstractImage): 553 ↛ 555line 553 didn't jump to line 555 because the condition on line 553 was never true

554 # Images need more space 

555 cell_height = max(cell_height, 120) 

556 elif isinstance(block, (Paragraph, Heading)): 556 ↛ 552line 556 didn't jump to line 552 because the condition on line 556 was always true

557 # Calculate text wrapping height 

558 word_items = block.words() if callable( 

559 block.words) else block.words 

560 words = list(word_items) 

561 

562 if not words: 562 ↛ 563line 562 didn't jump to line 563 because the condition on line 562 was never true

563 continue 

564 

565 # Simulate text wrapping to count lines 

566 lines_needed = self._estimate_wrapped_lines( 

567 words, content_width, font_size) 

568 text_height = lines_needed * line_height 

569 cell_height = max( 

570 cell_height, text_height + vertical_padding + 4) 

571 

572 row_max_height = max(row_max_height, cell_height) 

573 

574 max_height = max(max_height, row_max_height) 

575 

576 return max_height 

577 

578 def _estimate_wrapped_lines( 

579 self, 

580 words: List, 

581 available_width: int, 

582 font_size: int) -> int: 

583 """ 

584 Estimate how many lines are needed to render the given words. 

585 

586 Args: 

587 words: List of word objects 

588 available_width: Available width for text 

589 font_size: Font size in pixels 

590 

591 Returns: 

592 Number of lines needed 

593 """ 

594 from pyWebLayout.concrete.text import Text 

595 from pyWebLayout.style.fonts import Font 

596 

597 # Create a temporary font for measurement 

598 font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" 

599 font = Font(font_path=font_path, font_size=font_size) 

600 

601 # Word spacing (approximate) 

602 word_spacing = int(font_size * 0.25) 

603 

604 lines = 1 

605 current_line_width = 0 

606 

607 for word_item in words: 

608 # Handle word tuples (index, word_obj) 

609 if isinstance(word_item, tuple) and len(word_item) >= 2: 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true

610 word_obj = word_item[1] 

611 else: 

612 word_obj = word_item 

613 

614 # Extract text from the word 

615 word_text = word_obj.text if hasattr( 

616 word_obj, 'text') else str(word_obj) 

617 

618 # Measure word width 

619 word_width = font.font.getlength(word_text) 

620 

621 # Check if word fits on current line 

622 if current_line_width > 0: # Not first word on line 

623 needed_width = current_line_width + word_spacing + word_width 

624 if needed_width > available_width: 624 ↛ 626line 624 didn't jump to line 626 because the condition on line 624 was never true

625 # Need new line 

626 lines += 1 

627 current_line_width = word_width 

628 else: 

629 current_line_width = needed_width 

630 else: 

631 # First word on line 

632 if word_width > available_width: 632 ↛ 634line 632 didn't jump to line 634 because the condition on line 632 was never true

633 # Word needs to be hyphenated, assume it takes 1 line 

634 lines += 1 

635 current_line_width = 0 

636 else: 

637 current_line_width = word_width 

638 

639 return lines 

640 

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

642 """Render the complete table.""" 

643 x, y = self._origin 

644 current_y = y 

645 

646 # Render caption if present 

647 if self._table.caption: 

648 current_y = self._render_caption(x, current_y) 

649 current_y += 10 # Space after caption 

650 

651 # Render header rows 

652 for section, row in self._table.all_rows(): 

653 if section == "header": 

654 row_height = self._row_heights["header"] 

655 elif section == "footer": 

656 row_height = self._row_heights["footer"] 

657 else: 

658 row_height = self._row_heights["body"] 

659 

660 is_header = (section == "header") 

661 

662 row_renderer = TableRowRenderer( 

663 row, 

664 (x, current_y), 

665 self._column_widths, 

666 row_height, 

667 self._draw, 

668 self._style, 

669 is_header, 

670 self._canvas 

671 ) 

672 row_renderer.render() 

673 self._row_renderers.append(row_renderer) 

674 

675 current_y += row_height + self._style.border_width 

676 

677 return None # Table rendering is done directly on the page 

678 

679 def _render_caption(self, x: int, y: int) -> int: 

680 """Render the table caption and return the new Y position.""" 

681 from PIL import ImageFont 

682 

683 try: 

684 font = ImageFont.truetype( 

685 "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13) 

686 except BaseException: 

687 font = ImageFont.load_default() 

688 

689 # Center the caption 

690 bbox = self._draw.textbbox((0, 0), self._table.caption, font=font) 

691 text_width = bbox[2] - bbox[0] 

692 caption_x = x + (self._size[0] - text_width) // 2 

693 

694 self._draw.text((caption_x, y), self._table.caption, fill=(0, 0, 0), font=font) 

695 

696 return y + 20 # Caption height 

697 

698 @property 

699 def height(self) -> int: 

700 """Get the total height of the rendered table.""" 

701 return int(self._size[1]) 

702 

703 @property 

704 def width(self) -> int: 

705 """Get the total width of the rendered table.""" 

706 return int(self._size[0])