Files
pyWebLayout/pyWebLayout/concrete/table.py
T
dtourolle 3bcd1bffb5
Python CI / test (3.10) (push) Successful in 2m22s
Python CI / test (3.12) (push) Successful in 2m13s
Python CI / test (3.13) (push) Successful in 2m9s
Tables now use ""dynamic page" allowing the contents to be anything that can be rendered ion a page.
2025-11-11 18:10:47 +01:00

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])