707 lines
26 KiB
Python
707 lines
26 KiB
Python
"""
|
|
Concrete table rendering implementation for pyWebLayout.
|
|
|
|
This module provides the concrete rendering classes for tables, including:
|
|
- TableRenderer: Main table rendering with borders and spacing
|
|
- TableRowRenderer: Individual row rendering
|
|
- TableCellRenderer: Cell rendering with support for nested content (text, images, links)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
from typing import Tuple, List, Optional, Dict
|
|
from PIL import Image, ImageDraw
|
|
from dataclasses import dataclass
|
|
|
|
from pyWebLayout.core.base import Renderable
|
|
from pyWebLayout.concrete.box import Box
|
|
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage
|
|
from pyWebLayout.abstract.interactive_image import InteractiveImage
|
|
|
|
|
|
@dataclass
|
|
class TableStyle:
|
|
"""Styling configuration for table rendering."""
|
|
|
|
# Border configuration
|
|
border_width: int = 1
|
|
border_color: Tuple[int, int, int] = (0, 0, 0)
|
|
|
|
# Cell padding
|
|
cell_padding: Tuple[int, int, int, int] = (5, 5, 5, 5) # top, right, bottom, left
|
|
|
|
# Header styling
|
|
header_bg_color: Tuple[int, int, int] = (240, 240, 240)
|
|
header_text_bold: bool = True
|
|
|
|
# Cell background
|
|
cell_bg_color: Tuple[int, int, int] = (255, 255, 255)
|
|
alternate_row_color: Optional[Tuple[int, int, int]] = (250, 250, 250)
|
|
|
|
# Spacing
|
|
cell_spacing: int = 0 # Space between cells (for separated borders model)
|
|
|
|
|
|
class TableCellRenderer(Box):
|
|
"""
|
|
Renders a single table cell with its content.
|
|
Supports paragraphs, headings, images, and links within cells.
|
|
"""
|
|
|
|
def __init__(self,
|
|
cell: TableCell,
|
|
origin: Tuple[int,
|
|
int],
|
|
size: Tuple[int,
|
|
int],
|
|
draw: ImageDraw.Draw,
|
|
style: TableStyle,
|
|
is_header_section: bool = False,
|
|
canvas: Optional[Image.Image] = None):
|
|
"""
|
|
Initialize a table cell renderer.
|
|
|
|
Args:
|
|
cell: The abstract TableCell to render
|
|
origin: Top-left position of the cell
|
|
size: Width and height of the cell
|
|
draw: PIL ImageDraw object for rendering
|
|
style: Table styling configuration
|
|
is_header_section: Whether this cell is in the header section
|
|
canvas: Optional PIL Image for pasting images (required for image rendering)
|
|
"""
|
|
super().__init__(origin, size)
|
|
self._cell = cell
|
|
self._draw = draw
|
|
self._style = style
|
|
self._is_header_section = is_header_section or cell.is_header
|
|
self._canvas = canvas
|
|
self._children: List[Renderable] = []
|
|
|
|
def render(self) -> Image.Image:
|
|
"""Render the table cell."""
|
|
# Determine background color
|
|
if self._is_header_section:
|
|
bg_color = self._style.header_bg_color
|
|
else:
|
|
bg_color = self._style.cell_bg_color
|
|
|
|
# Draw cell background
|
|
x, y = self._origin
|
|
w, h = self._size
|
|
self._draw.rectangle(
|
|
[x, y, x + w, y + h],
|
|
fill=bg_color,
|
|
outline=self._style.border_color,
|
|
width=self._style.border_width
|
|
)
|
|
|
|
# Calculate content area (inside padding)
|
|
padding = self._style.cell_padding
|
|
content_x = x + padding[3] # left padding
|
|
content_y = y + padding[0] # top padding
|
|
content_width = w - (padding[1] + padding[3]) # minus left and right padding
|
|
content_height = h - (padding[0] + padding[2]) # minus top and bottom padding
|
|
|
|
# Render cell content (text)
|
|
self._render_cell_content(content_x, content_y, content_width, content_height)
|
|
|
|
return None # Cell rendering is done directly on the page
|
|
|
|
def _render_cell_content(self, x: int, y: int, width: int, height: int):
|
|
"""Render the content inside the cell (text and images) with line wrapping."""
|
|
from pyWebLayout.concrete.text import Line, Text
|
|
from pyWebLayout.style.fonts import Font
|
|
from pyWebLayout.style import FontWeight, Alignment
|
|
|
|
current_y = y + 2
|
|
available_height = height - 4 # Account for top/bottom padding
|
|
|
|
# Create font for the cell
|
|
font_size = 12
|
|
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
|
if self._is_header_section and self._style.header_text_bold:
|
|
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
|
|
|
font = Font(
|
|
font_path=font_path,
|
|
font_size=font_size,
|
|
weight=FontWeight.BOLD if self._is_header_section and self._style.header_text_bold else FontWeight.NORMAL
|
|
)
|
|
|
|
# Word spacing constraints (min, max)
|
|
min_spacing = int(font_size * 0.25)
|
|
max_spacing = int(font_size * 0.5)
|
|
word_spacing = (min_spacing, max_spacing)
|
|
|
|
# Line height (baseline spacing)
|
|
line_height = font_size + 4
|
|
ascent, descent = font.font.getmetrics()
|
|
|
|
# Render each block in the cell
|
|
for block in self._cell.blocks():
|
|
if isinstance(block, AbstractImage):
|
|
# Render image
|
|
current_y = self._render_image_in_cell(
|
|
block, x, current_y, width, height - (current_y - y))
|
|
elif isinstance(block, (Paragraph, Heading)):
|
|
# Get words from the block
|
|
from pyWebLayout.abstract.inline import Word as AbstractWord
|
|
|
|
word_items = block.words() if callable(block.words) else block.words
|
|
words = list(word_items)
|
|
|
|
if not words:
|
|
continue
|
|
|
|
# Create new Word objects with the table cell's font
|
|
# The words from the paragraph may have AbstractStyle, but we need Font objects
|
|
wrapped_words = []
|
|
for word_item in words:
|
|
# Handle word tuples (index, word_obj)
|
|
if isinstance(word_item, tuple) and len(word_item) >= 2:
|
|
word_obj = word_item[1]
|
|
else:
|
|
word_obj = word_item
|
|
|
|
# Extract text from the word
|
|
word_text = word_obj.text if hasattr(word_obj, 'text') else str(word_obj)
|
|
|
|
# Create a new Word with the cell's Font
|
|
new_word = AbstractWord(word_text, font)
|
|
wrapped_words.append(new_word)
|
|
|
|
# Layout words using Line objects with wrapping
|
|
word_index = 0
|
|
pretext = None
|
|
|
|
while word_index < len(wrapped_words):
|
|
# Check if we have space for another line
|
|
if current_y + ascent + descent > y + available_height:
|
|
break # No more space in cell
|
|
|
|
# Create a new line
|
|
line = Line(
|
|
spacing=word_spacing,
|
|
origin=(x + 2, current_y),
|
|
size=(width - 4, line_height),
|
|
draw=self._draw,
|
|
font=font,
|
|
halign=Alignment.LEFT
|
|
)
|
|
|
|
# Add words to this line until it's full
|
|
line_has_content = False
|
|
while word_index < len(wrapped_words):
|
|
word = wrapped_words[word_index]
|
|
|
|
# Try to add word to line
|
|
success, overflow = line.add_word(word, pretext)
|
|
pretext = None # Clear pretext after use
|
|
|
|
if success:
|
|
line_has_content = True
|
|
if overflow:
|
|
# Word was hyphenated, carry over to next line
|
|
# DON'T increment word_index - we need to add the overflow
|
|
# to the next line with the same word
|
|
pretext = overflow
|
|
break # Move to next line
|
|
else:
|
|
# Word fit completely, move to next word
|
|
word_index += 1
|
|
else:
|
|
# Word doesn't fit on this line
|
|
if not line_has_content:
|
|
# Even first word doesn't fit, force it anyway and advance
|
|
# This prevents infinite loops with words that truly can't fit
|
|
word_index += 1
|
|
break
|
|
|
|
# Render the line if it has content
|
|
if line_has_content or len(line.text_objects) > 0:
|
|
line.render()
|
|
current_y += line_height
|
|
|
|
if current_y > y + height - 10: # Don't overflow cell
|
|
break
|
|
|
|
# If no structured content, try to get any text representation
|
|
if current_y == y + 2 and hasattr(self._cell, '_text_content'):
|
|
# Use simple text rendering for fallback case
|
|
from PIL import ImageFont
|
|
try:
|
|
pil_font = ImageFont.truetype(font_path, font_size)
|
|
except BaseException:
|
|
pil_font = ImageFont.load_default()
|
|
|
|
self._draw.text(
|
|
(x + 2, current_y),
|
|
self._cell._text_content,
|
|
fill=(0, 0, 0),
|
|
font=pil_font
|
|
)
|
|
|
|
def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
|
|
max_width: int, max_height: int) -> int:
|
|
"""
|
|
Render an image block inside a table cell.
|
|
|
|
Returns:
|
|
The new Y position after the image
|
|
"""
|
|
try:
|
|
# Get the image path from the block
|
|
image_path = None
|
|
if hasattr(image_block, 'source'):
|
|
image_path = image_block.source
|
|
elif hasattr(image_block, '_source'):
|
|
image_path = image_block._source
|
|
elif hasattr(image_block, 'path'):
|
|
image_path = image_block.path
|
|
elif hasattr(image_block, 'src'):
|
|
image_path = image_block.src
|
|
elif hasattr(image_block, '_path'):
|
|
image_path = image_block._path
|
|
elif hasattr(image_block, '_src'):
|
|
image_path = image_block._src
|
|
|
|
if not image_path:
|
|
return y + 20 # Skip if no image path
|
|
|
|
# Load and resize image to fit in cell
|
|
img = Image.open(image_path)
|
|
|
|
# Calculate scaling to fit within max dimensions
|
|
# Use more of the cell space for images
|
|
img_width, img_height = img.size
|
|
scale_w = max_width / img_width if img_width > max_width else 1
|
|
scale_h = (max_height - 10) / \
|
|
img_height if img_height > (max_height - 10) else 1
|
|
scale = min(scale_w, scale_h, 1.0) # Don't upscale
|
|
|
|
new_width = int(img_width * scale)
|
|
new_height = int(img_height * scale)
|
|
|
|
if scale < 1.0:
|
|
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
# Center image horizontally in cell
|
|
img_x = x + (max_width - new_width) // 2
|
|
|
|
# Paste the image onto the canvas if available
|
|
if self._canvas is not None:
|
|
if img.mode == 'RGBA':
|
|
self._canvas.paste(img, (img_x, y), img)
|
|
else:
|
|
self._canvas.paste(img, (img_x, y))
|
|
else:
|
|
# Fallback: draw a placeholder if no canvas provided
|
|
self._draw.rectangle(
|
|
[img_x, y, img_x + new_width, y + new_height],
|
|
fill=(200, 200, 200),
|
|
outline=(150, 150, 150)
|
|
)
|
|
|
|
# Draw image indicator text
|
|
from PIL import ImageFont
|
|
try:
|
|
small_font = ImageFont.truetype(
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
|
|
except BaseException:
|
|
small_font = ImageFont.load_default()
|
|
|
|
text = f"[Image: {new_width}x{new_height}]"
|
|
bbox = self._draw.textbbox((0, 0), text, font=small_font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_x = img_x + (new_width - text_width) // 2
|
|
text_y = y + (new_height - 12) // 2
|
|
self._draw.text(
|
|
(text_x, text_y), text, fill=(
|
|
100, 100, 100), font=small_font)
|
|
|
|
# Set bounds on InteractiveImage objects for tap detection
|
|
if isinstance(image_block, InteractiveImage):
|
|
image_block.set_rendered_bounds(
|
|
origin=(img_x, y),
|
|
size=(new_width, new_height)
|
|
)
|
|
|
|
return y + new_height + 5 # Add some spacing after image
|
|
|
|
except Exception:
|
|
# If image loading fails, just return current position
|
|
return y + 20
|
|
|
|
|
|
class TableRowRenderer(Box):
|
|
"""
|
|
Renders a single table row containing multiple cells.
|
|
"""
|
|
|
|
def __init__(self,
|
|
row: TableRow,
|
|
origin: Tuple[int,
|
|
int],
|
|
column_widths: List[int],
|
|
row_height: int,
|
|
draw: ImageDraw.Draw,
|
|
style: TableStyle,
|
|
is_header_section: bool = False,
|
|
canvas: Optional[Image.Image] = None):
|
|
"""
|
|
Initialize a table row renderer.
|
|
|
|
Args:
|
|
row: The abstract TableRow to render
|
|
origin: Top-left position of the row
|
|
column_widths: List of widths for each column
|
|
row_height: Height of this row
|
|
draw: PIL ImageDraw object for rendering
|
|
style: Table styling configuration
|
|
is_header_section: Whether this row is in the header section
|
|
canvas: Optional PIL Image for pasting images
|
|
"""
|
|
width = sum(column_widths) + style.border_width * (len(column_widths) + 1)
|
|
super().__init__(origin, (width, row_height))
|
|
self._row = row
|
|
self._column_widths = column_widths
|
|
self._row_height = row_height
|
|
self._draw = draw
|
|
self._style = style
|
|
self._is_header_section = is_header_section
|
|
self._canvas = canvas
|
|
self._cell_renderers: List[TableCellRenderer] = []
|
|
|
|
def render(self) -> Image.Image:
|
|
"""Render the table row by rendering each cell."""
|
|
x, y = self._origin
|
|
current_x = x
|
|
|
|
# Render each cell
|
|
cells = list(self._row.cells())
|
|
for i, cell in enumerate(cells):
|
|
if i < len(self._column_widths):
|
|
cell_width = self._column_widths[i]
|
|
|
|
# Handle colspan
|
|
if cell.colspan > 1 and i + cell.colspan <= len(self._column_widths):
|
|
# Sum up widths for spanned columns
|
|
cell_width = sum(self._column_widths[i:i + cell.colspan])
|
|
cell_width += self._style.border_width * (cell.colspan - 1)
|
|
|
|
# Create and render cell
|
|
cell_renderer = TableCellRenderer(
|
|
cell,
|
|
(current_x, y),
|
|
(cell_width, self._row_height),
|
|
self._draw,
|
|
self._style,
|
|
self._is_header_section,
|
|
self._canvas
|
|
)
|
|
cell_renderer.render()
|
|
self._cell_renderers.append(cell_renderer)
|
|
|
|
current_x += cell_width + self._style.border_width
|
|
|
|
return None # Row rendering is done directly on the page
|
|
|
|
|
|
class TableRenderer(Box):
|
|
"""
|
|
Main table renderer that orchestrates the rendering of an entire table.
|
|
Handles layout calculation, row/cell placement, and overall table structure.
|
|
"""
|
|
|
|
def __init__(self,
|
|
table: Table,
|
|
origin: Tuple[int,
|
|
int],
|
|
available_width: int,
|
|
draw: ImageDraw.Draw,
|
|
style: Optional[TableStyle] = None,
|
|
canvas: Optional[Image.Image] = None):
|
|
"""
|
|
Initialize a table renderer.
|
|
|
|
Args:
|
|
table: The abstract Table to render
|
|
origin: Top-left position where the table should be rendered
|
|
available_width: Maximum width available for the table
|
|
draw: PIL ImageDraw object for rendering
|
|
style: Optional table styling configuration
|
|
canvas: Optional PIL Image for pasting images
|
|
"""
|
|
self._table = table
|
|
self._draw = draw
|
|
self._style = style or TableStyle()
|
|
self._available_width = available_width
|
|
self._canvas = canvas
|
|
|
|
# Calculate table dimensions
|
|
self._column_widths, self._row_heights = self._calculate_dimensions()
|
|
total_width = sum(self._column_widths) + \
|
|
self._style.border_width * (len(self._column_widths) + 1)
|
|
total_height = sum(self._row_heights.values()) + \
|
|
self._style.border_width * (len(self._row_heights) + 1)
|
|
|
|
super().__init__(origin, (total_width, total_height))
|
|
self._row_renderers: List[TableRowRenderer] = []
|
|
|
|
def _calculate_dimensions(self) -> Tuple[List[int], Dict[str, int]]:
|
|
"""
|
|
Calculate column widths and row heights for the table.
|
|
|
|
Uses the table optimizer for intelligent column width distribution.
|
|
|
|
Returns:
|
|
Tuple of (column_widths, row_heights_dict)
|
|
"""
|
|
from pyWebLayout.layout.table_optimizer import optimize_table_layout
|
|
|
|
all_rows = list(self._table.all_rows())
|
|
|
|
if not all_rows:
|
|
return ([100], {"header": 30, "body": 30, "footer": 30})
|
|
|
|
# Use optimizer for column widths!
|
|
column_widths = optimize_table_layout(
|
|
self._table,
|
|
self._available_width,
|
|
sample_size=5,
|
|
style=self._style
|
|
)
|
|
|
|
if not column_widths:
|
|
# Fallback if table is empty
|
|
column_widths = [100]
|
|
|
|
# Calculate row heights dynamically based on optimized column widths
|
|
header_height = self._calculate_row_height_for_section(
|
|
all_rows, "header", column_widths) if any(
|
|
1 for section, _ in all_rows if section == "header") else 0
|
|
|
|
body_height = self._calculate_row_height_for_section(
|
|
all_rows, "body", column_widths)
|
|
|
|
footer_height = self._calculate_row_height_for_section(
|
|
all_rows, "footer", column_widths) if any(
|
|
1 for section, _ in all_rows if section == "footer") else 0
|
|
|
|
row_heights = {
|
|
"header": header_height,
|
|
"body": body_height,
|
|
"footer": footer_height
|
|
}
|
|
|
|
return (column_widths, row_heights)
|
|
|
|
def _calculate_row_height_for_section(
|
|
self,
|
|
all_rows: List,
|
|
section: str,
|
|
column_widths: List[int]) -> int:
|
|
"""
|
|
Calculate the maximum required height for rows in a specific section.
|
|
|
|
Args:
|
|
all_rows: List of all rows in the table
|
|
section: Section name ('header', 'body', or 'footer')
|
|
column_widths: List of column widths
|
|
|
|
Returns:
|
|
Maximum height needed for rows in this section
|
|
"""
|
|
from pyWebLayout.concrete.text import Text
|
|
from pyWebLayout.style.fonts import Font
|
|
from pyWebLayout.abstract.inline import Word as AbstractWord
|
|
|
|
# Font configuration
|
|
font_size = 12
|
|
line_height = font_size + 4
|
|
padding = self._style.cell_padding
|
|
vertical_padding = padding[0] + padding[2] # top + bottom
|
|
horizontal_padding = padding[1] + padding[3] # left + right
|
|
|
|
max_height = 40 # Minimum height
|
|
|
|
for row_section, row in all_rows:
|
|
if row_section != section:
|
|
continue
|
|
|
|
row_max_height = 40 # Minimum for this row
|
|
|
|
for cell_idx, cell in enumerate(row.cells()):
|
|
if cell_idx >= len(column_widths):
|
|
continue
|
|
|
|
# Get cell width (accounting for colspan)
|
|
cell_width = column_widths[cell_idx]
|
|
if cell.colspan > 1 and cell_idx + \
|
|
cell.colspan <= len(column_widths):
|
|
cell_width = sum(
|
|
column_widths[cell_idx:cell_idx + cell.colspan])
|
|
cell_width += self._style.border_width * (cell.colspan - 1)
|
|
|
|
# Calculate content width (minus padding)
|
|
content_width = cell_width - horizontal_padding - 4 # Extra margin
|
|
|
|
cell_height = vertical_padding + 4 # Base height with padding
|
|
|
|
# Analyze each block in the cell
|
|
for block in cell.blocks():
|
|
if isinstance(block, AbstractImage):
|
|
# Images need more space
|
|
cell_height = max(cell_height, 120)
|
|
elif isinstance(block, (Paragraph, Heading)):
|
|
# Calculate text wrapping height
|
|
word_items = block.words() if callable(
|
|
block.words) else block.words
|
|
words = list(word_items)
|
|
|
|
if not words:
|
|
continue
|
|
|
|
# Simulate text wrapping to count lines
|
|
lines_needed = self._estimate_wrapped_lines(
|
|
words, content_width, font_size)
|
|
text_height = lines_needed * line_height
|
|
cell_height = max(
|
|
cell_height, text_height + vertical_padding + 4)
|
|
|
|
row_max_height = max(row_max_height, cell_height)
|
|
|
|
max_height = max(max_height, row_max_height)
|
|
|
|
return max_height
|
|
|
|
def _estimate_wrapped_lines(
|
|
self,
|
|
words: List,
|
|
available_width: int,
|
|
font_size: int) -> int:
|
|
"""
|
|
Estimate how many lines are needed to render the given words.
|
|
|
|
Args:
|
|
words: List of word objects
|
|
available_width: Available width for text
|
|
font_size: Font size in pixels
|
|
|
|
Returns:
|
|
Number of lines needed
|
|
"""
|
|
from pyWebLayout.concrete.text import Text
|
|
from pyWebLayout.style.fonts import Font
|
|
|
|
# Create a temporary font for measurement
|
|
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
|
font = Font(font_path=font_path, font_size=font_size)
|
|
|
|
# Word spacing (approximate)
|
|
word_spacing = int(font_size * 0.25)
|
|
|
|
lines = 1
|
|
current_line_width = 0
|
|
|
|
for word_item in words:
|
|
# Handle word tuples (index, word_obj)
|
|
if isinstance(word_item, tuple) and len(word_item) >= 2:
|
|
word_obj = word_item[1]
|
|
else:
|
|
word_obj = word_item
|
|
|
|
# Extract text from the word
|
|
word_text = word_obj.text if hasattr(
|
|
word_obj, 'text') else str(word_obj)
|
|
|
|
# Measure word width
|
|
word_width = font.font.getlength(word_text)
|
|
|
|
# Check if word fits on current line
|
|
if current_line_width > 0: # Not first word on line
|
|
needed_width = current_line_width + word_spacing + word_width
|
|
if needed_width > available_width:
|
|
# Need new line
|
|
lines += 1
|
|
current_line_width = word_width
|
|
else:
|
|
current_line_width = needed_width
|
|
else:
|
|
# First word on line
|
|
if word_width > available_width:
|
|
# Word needs to be hyphenated, assume it takes 1 line
|
|
lines += 1
|
|
current_line_width = 0
|
|
else:
|
|
current_line_width = word_width
|
|
|
|
return lines
|
|
|
|
def render(self) -> Image.Image:
|
|
"""Render the complete table."""
|
|
x, y = self._origin
|
|
current_y = y
|
|
|
|
# Render caption if present
|
|
if self._table.caption:
|
|
current_y = self._render_caption(x, current_y)
|
|
current_y += 10 # Space after caption
|
|
|
|
# Render header rows
|
|
for section, row in self._table.all_rows():
|
|
if section == "header":
|
|
row_height = self._row_heights["header"]
|
|
elif section == "footer":
|
|
row_height = self._row_heights["footer"]
|
|
else:
|
|
row_height = self._row_heights["body"]
|
|
|
|
is_header = (section == "header")
|
|
|
|
row_renderer = TableRowRenderer(
|
|
row,
|
|
(x, current_y),
|
|
self._column_widths,
|
|
row_height,
|
|
self._draw,
|
|
self._style,
|
|
is_header,
|
|
self._canvas
|
|
)
|
|
row_renderer.render()
|
|
self._row_renderers.append(row_renderer)
|
|
|
|
current_y += row_height + self._style.border_width
|
|
|
|
return None # Table rendering is done directly on the page
|
|
|
|
def _render_caption(self, x: int, y: int) -> int:
|
|
"""Render the table caption and return the new Y position."""
|
|
from PIL import ImageFont
|
|
|
|
try:
|
|
font = ImageFont.truetype(
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
|
|
except BaseException:
|
|
font = ImageFont.load_default()
|
|
|
|
# Center the caption
|
|
bbox = self._draw.textbbox((0, 0), self._table.caption, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
caption_x = x + (self._size[0] - text_width) // 2
|
|
|
|
self._draw.text((caption_x, y), self._table.caption, fill=(0, 0, 0), font=font)
|
|
|
|
return y + 20 # Caption height
|
|
|
|
@property
|
|
def height(self) -> int:
|
|
"""Get the total height of the rendered table."""
|
|
return int(self._size[1])
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
"""Get the total width of the rendered table."""
|
|
return int(self._size[0])
|