490 lines
18 KiB
Python
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])
|