490 lines
18 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)."""
from PIL import ImageFont
current_y = y + 2
# Get font
try:
if self._is_header_section and self._style.header_text_bold:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
else:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
except BaseException:
font = ImageFont.load_default()
# 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)):
# Extract and render text
words = []
word_items = block.words() if callable(block.words) else block.words
for word in word_items:
if hasattr(word, 'text'):
words.append(word.text)
elif isinstance(word, tuple) and len(word) >= 2:
word_obj = word[1]
if hasattr(word_obj, 'text'):
words.append(word_obj.text)
if words:
text = " ".join(words)
if current_y <= y + height - 15:
self._draw.text((x + 2, current_y), text,
fill=(0, 0, 0), font=font)
current_y += 16
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'):
self._draw.text(
(x + 2,
current_y),
self._cell._text_content,
fill=(
0,
0,
0),
font=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.
Returns:
Tuple of (column_widths, row_heights_dict)
"""
# Determine number of columns (from first row)
num_columns = 0
all_rows = list(self._table.all_rows())
if all_rows:
first_row = all_rows[0][1]
num_columns = first_row.cell_count
if num_columns == 0:
return ([100], {"header": 30, "body": 30, "footer": 30})
# Calculate column widths (equal distribution for now)
# Account for borders between columns
total_border_width = self._style.border_width * (num_columns + 1)
available_for_columns = self._available_width - total_border_width
column_width = max(50, available_for_columns // num_columns)
column_widths = [column_width] * num_columns
# Calculate row heights
header_height = 35 if any(1 for section,
_ in all_rows if section == "header") else 0
# Check if any body rows contain images - if so, use larger height
body_height = 30
for section, row in all_rows:
if section == "body":
for cell in row.cells():
for block in cell.blocks():
if isinstance(block, AbstractImage):
# Use larger height for rows with images
body_height = max(body_height, 120)
break
footer_height = 30 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 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])