diff --git a/README.md b/README.md index 81c45c9..5f74296 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ PyWebLayout is a Python library for HTML-like layout and rendering to paginated - šŸ”¤ **Font Support** - Multiple font sizes, weights, and styles - ā†”ļø **Text Alignment** - Left, center, right, and justified text - šŸ“– **Rich Content** - Headings, paragraphs, bold, italic, and more +- šŸ“Š **Table Rendering** - Full HTML table support with headers, borders, and styling ### Architecture - **Abstract/Concrete Separation** - Clean separation between content structure and rendering @@ -87,21 +88,28 @@ The library supports various page layouts and configurations: - - - + + +
+ Page Styles
- Page Rendering
+ Page Rendering
Different borders, padding, and backgrounds
+ HTML Content
- Text Layout
+ Text Layout
Parsed HTML with various text styles
+
Page Layouts
- Page Layouts
+ Page Layouts
Portrait, landscape, and square formats
+ Table Rendering
+ Table Rendering
+ HTML tables with headers and styling +
@@ -113,6 +121,8 @@ The `examples/` directory contains working demonstrations: - **[01_simple_page_rendering.py](examples/01_simple_page_rendering.py)** - Introduction to the Page system - **[02_text_and_layout.py](examples/02_text_and_layout.py)** - HTML parsing and text rendering - **[03_page_layouts.py](examples/03_page_layouts.py)** - Different page configurations +- **[04_table_rendering.py](examples/04_table_rendering.py)** - HTML table rendering with styling +- **[05_table_with_images.py](examples/05_table_with_images.py)** - Tables with embedded images ### Advanced Examples - **[html_multipage_simple.py](examples/html_multipage_simple.py)** - Multi-page HTML rendering diff --git a/docs/images/example_04_table_rendering.png b/docs/images/example_04_table_rendering.png new file mode 100644 index 0000000..97d4aed Binary files /dev/null and b/docs/images/example_04_table_rendering.png differ diff --git a/docs/images/example_05_table_with_images.png b/docs/images/example_05_table_with_images.png new file mode 100644 index 0000000..2fd543e Binary files /dev/null and b/docs/images/example_05_table_with_images.png differ diff --git a/examples/04_table_rendering.py b/examples/04_table_rendering.py new file mode 100644 index 0000000..09cfa2c --- /dev/null +++ b/examples/04_table_rendering.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Table Rendering Example + +This example demonstrates rendering HTML tables: +- Simple tables with headers +- Tables with multiple rows and columns +- Tables with colspan and borders +- Tables with formatted content +- Tables parsed from HTML + +Shows the TableRenderer system in action. +""" + +import sys +from pathlib import Path +from PIL import Image, ImageDraw + +# Add pyWebLayout to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pyWebLayout.concrete.page import Page +from pyWebLayout.concrete.table import TableRenderer, TableStyle +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.io.readers.html_extraction import parse_html_string +from pyWebLayout.style import Font +from pyWebLayout.abstract.block import Table + + +def create_simple_table_example(): + """Create a simple table from HTML.""" + print(" - Simple data table") + + html = """ + + + + + + + + + + + + + + + + + + + + + + + + + +
NameAgeCity
Alice28Paris
Bob34London
Charlie25Tokyo
+ """ + + return html, "Simple Table" + + +def create_styled_table_example(): + """Create a table with custom styling.""" + print(" - Styled table") + + html = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Monthly Sales Report
MonthRevenueExpensesProfit
January$50,000$30,000$20,000
February$55,000$32,000$23,000
March$60,000$35,000$25,000
+ """ + + return html, "Styled Table" + + +def create_complex_table_example(): + """Create a table with colspan.""" + print(" - Complex table with colspan") + + html = """ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Product Specifications
ProductFeaturesPrice
Laptop16GB RAM, 512GB SSD$1,299
Monitor27 inch, 4K Resolution$599
KeyboardMechanical, RGB$129
+ """ + + return html, "Complex Table" + + +def create_data_table_example(): + """Create a table with numerical data.""" + print(" - Data table") + + html = """ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Test Results
TestScoreStatus
Unit Tests98%Pass
Integration95%Pass
Performance87%Pass
+ """ + + return html, "Data Table" + + +def render_table_example(html: str, title: str, style_variant: int = 0, page_size=(500, 400)): + """Render a table from HTML to an image.""" + # Create page with varying backgrounds + bg_colors = [ + (255, 255, 255), # White + (250, 255, 250), # Light green tint + (255, 250, 245), # Light orange tint + (245, 250, 255), # Light blue tint + ] + + page_style = PageStyle( + border_width=2, + border_color=(200, 200, 200), + padding=(20, 20, 20, 20), + background_color=bg_colors[style_variant % len(bg_colors)] + ) + + page = Page(size=page_size, style=page_style) + image = page.render() + draw = ImageDraw.Draw(image) + + # Add title + from PIL import ImageFont + try: + title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14) + except: + title_font = ImageFont.load_default() + + draw.text((page.border_size + 10, page.border_size + 10), title, fill=(50, 50, 150), font=title_font) + + # Parse HTML to get table + base_font = Font(font_size=12) + blocks = parse_html_string(html, base_font=base_font) + + # Find the table block + table = None + for block in blocks: + if isinstance(block, Table): + table = block + break + + if table: + # Create table renderer with different styles + table_styles = [ + # Style 0: Classic blue header + TableStyle( + border_width=1, + border_color=(80, 80, 80), + cell_padding=(8, 10, 8, 10), + header_bg_color=(70, 130, 180), # Steel blue + cell_bg_color=(255, 255, 255), + alternate_row_color=(240, 248, 255) # Alice blue + ), + # Style 1: Green theme + TableStyle( + border_width=2, + border_color=(34, 139, 34), # Forest green + cell_padding=(10, 12, 10, 12), + header_bg_color=(144, 238, 144), # Light green + cell_bg_color=(255, 255, 255), + alternate_row_color=(240, 255, 240) # Honeydew + ), + # Style 2: Minimal style + TableStyle( + border_width=0, + border_color=(200, 200, 200), + cell_padding=(6, 8, 6, 8), + header_bg_color=(245, 245, 245), + cell_bg_color=(255, 255, 255), + alternate_row_color=None # No alternating + ), + # Style 3: Bold borders + TableStyle( + border_width=3, + border_color=(0, 0, 0), + cell_padding=(10, 10, 10, 10), + header_bg_color=(255, 215, 0), # Gold + cell_bg_color=(255, 255, 255), + alternate_row_color=(255, 250, 205) # Lemon chiffon + ), + ] + + table_style = table_styles[style_variant % len(table_styles)] + + # Position table below title + table_origin = (page.border_size + 10, page.border_size + 40) + table_width = page.content_size[0] - 20 + + renderer = TableRenderer( + table, + table_origin, + table_width, + draw, + table_style + ) + + renderer.render() + else: + # Draw "No table found" message + draw.text((page.border_size + 10, page.border_size + 50), + "No table found in HTML", + fill=(200, 0, 0), font=title_font) + + return image + + +def combine_examples(examples): + """Combine multiple table examples into a grid.""" + print("\n Rendering table examples...") + + images = [] + for i, (html, title) in enumerate(examples): + img = render_table_example(html, title, style_variant=i) + images.append(img) + + # Create grid (2x2) + padding = 15 + cols = 2 + rows = 2 + + img_width = images[0].size[0] + img_height = images[0].size[1] + + total_width = cols * img_width + (cols + 1) * padding + total_height = rows * img_height + (rows + 1) * padding + 50 # Extra for main title + + combined = Image.new('RGB', (total_width, total_height), (240, 240, 240)) + draw = ImageDraw.Draw(combined) + + # Add main title + from PIL import ImageFont + try: + main_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) + except: + main_font = ImageFont.load_default() + + title_text = "Table Rendering Examples" + bbox = draw.textbbox((0, 0), title_text, font=main_font) + text_width = bbox[2] - bbox[0] + title_x = (total_width - text_width) // 2 + draw.text((title_x, 15), title_text, fill=(50, 50, 50), font=main_font) + + # Place images + y_offset = 50 + padding + for row in range(rows): + x_offset = padding + for col in range(cols): + idx = row * cols + col + if idx < len(images): + combined.paste(images[idx], (x_offset, y_offset)) + x_offset += img_width + padding + y_offset += img_height + padding + + return combined + + +def main(): + """Demonstrate table rendering.""" + print("Table Rendering Example") + print("=" * 50) + + # Create table examples + print("\n Creating table examples...") + examples = [ + create_simple_table_example(), + create_styled_table_example(), + create_complex_table_example(), + create_data_table_example() + ] + + # Render and combine + combined_image = combine_examples(examples) + + # Save output + output_dir = Path("docs/images") + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "example_04_table_rendering.png" + combined_image.save(output_path) + + print(f"\nāœ“ Example completed!") + print(f" Output saved to: {output_path}") + print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels") + print(f" Created {len(examples)} table examples") + + return combined_image + + +if __name__ == "__main__": + main() diff --git a/examples/05_table_with_images.py b/examples/05_table_with_images.py new file mode 100644 index 0000000..eba263c --- /dev/null +++ b/examples/05_table_with_images.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +Table with Images Example + +This example demonstrates rendering tables with images: +- Creating tables programmatically +- Adding images to table cells +- Book catalog / product showcase tables +- Mixed content (images and text) in cells + +Uses the cover images from tests/data directory. +""" + +import sys +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +# Add pyWebLayout to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pyWebLayout.concrete.page import Page +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.concrete.table import TableRenderer, TableStyle +from pyWebLayout.abstract.block import Table, Image as AbstractImage +from pyWebLayout.abstract.inline import Word +from pyWebLayout.style import Font + + +def create_book_catalog_table(cover_images: dict): + """Create a book catalog table with cover images and details.""" + print(" - Creating book catalog table...") + + # Get base path for images + data_path = Path(__file__).parent.parent / "tests" / "data" + + # Create table + table = Table(caption="Book Catalog", style=Font(font_size=14)) + + # Header row + header = table.create_row("header") + header.create_cell(is_header=True).create_paragraph().add_word(Word("Cover", Font(font_size=12))) + header.create_cell(is_header=True).create_paragraph().add_word(Word("Title", Font(font_size=12))) + header.create_cell(is_header=True).create_paragraph().add_word(Word("Author", Font(font_size=12))) + header.create_cell(is_header=True).create_paragraph().add_word(Word("Price", Font(font_size=12))) + + # Book entries + books = [ + ("cover 1.png", "The Great Adventure", "John Smith", "$19.99"), + ("cover 2.png", "Mystery of the Ages", "Jane Doe", "$24.99"), + ("cover 3.png", "Science Today", "Dr. Brown", "$29.99"), + ("cover 4.png", "Art & Design", "M. Artist", "$34.99"), + ] + + for cover_file, title, author, price in books: + row = table.create_row("body") + + # Cover cell with actual Image block + cover_cell = row.create_cell() + if cover_file in cover_images: + cover_path = str(data_path / cover_file) + AbstractImage.create_and_add_to(cover_cell, source=cover_path, alt_text=title) + + # Title cell + title_cell = row.create_cell() + title_para = title_cell.create_paragraph() + for word in title.split(): + title_para.add_word(Word(word, Font(font_size=11))) + + # Author cell + author_cell = row.create_cell() + author_para = author_cell.create_paragraph() + for word in author.split(): + author_para.add_word(Word(word, Font(font_size=11))) + + # Price cell + price_cell = row.create_cell() + price_para = price_cell.create_paragraph() + price_para.add_word(Word(price, Font(font_size=11, weight="bold"))) + + return table + + +def create_product_showcase_table(cover_images: dict): + """Create a product showcase table.""" + print(" - Creating product showcase table...") + + # Get base path for images + data_path = Path(__file__).parent.parent / "tests" / "data" + + table = Table(caption="Product Showcase", style=Font(font_size=14)) + + # Header row + header = table.create_row("header") + header.create_cell(is_header=True).create_paragraph().add_word(Word("Product", Font(font_size=12))) + header.create_cell(is_header=True).create_paragraph().add_word(Word("Description", Font(font_size=12))) + + # Products with covers + products = [ + ("cover 1.png", "Premium Edition - Hardcover with gold embossing"), + ("cover 2.png", "Collector's Item - Limited print run"), + ] + + for cover_file, description in products: + row = table.create_row("body") + + # Product cell with actual Image block + product_cell = row.create_cell() + if cover_file in cover_images: + cover_path = str(data_path / cover_file) + AbstractImage.create_and_add_to(product_cell, source=cover_path, alt_text="Product cover") + + # Description cell + desc_cell = row.create_cell() + desc_para = desc_cell.create_paragraph() + for word in description.split(): + desc_para.add_word(Word(word, Font(font_size=10))) + + return table + + +def render_table_with_images(table: Table, title: str, style_variant: int = 0, + page_size=(600, 500)): + """Render a table with images using the library API.""" + # Create page + page_style = PageStyle( + border_width=2, + border_color=(180, 180, 180), + padding=(20, 20, 20, 20), + background_color=(255, 255, 255) + ) + + page = Page(size=page_size, style=page_style) + canvas = page.render() + draw = ImageDraw.Draw(canvas) + + # Add title + try: + title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16) + except: + title_font = ImageFont.load_default() + + draw.text((page.border_size + 10, page.border_size + 10), title, fill=(50, 50, 150), font=title_font) + + # Table styles + table_styles = [ + TableStyle( + border_width=1, + border_color=(100, 100, 100), + cell_padding=(8, 10, 8, 10), + header_bg_color=(70, 130, 180), + cell_bg_color=(255, 255, 255), + alternate_row_color=(245, 248, 250) + ), + TableStyle( + border_width=2, + border_color=(60, 120, 60), + cell_padding=(10, 12, 10, 12), + header_bg_color=(144, 238, 144), + cell_bg_color=(255, 255, 255), + alternate_row_color=(240, 255, 240) + ), + ] + + table_style = table_styles[style_variant % len(table_styles)] + + # Position table + table_origin = (page.border_size + 10, page.border_size + 45) + table_width = page.content_size[0] - 20 + + # Render table with canvas support for images + renderer = TableRenderer( + table, + table_origin, + table_width, + draw, + table_style, + canvas # Pass canvas to enable image rendering + ) + renderer.render() + + return canvas + + +def main(): + """Demonstrate tables with images.""" + print("Table with Images Example") + print("=" * 50) + + # Load cover images + print("\n Loading cover images...") + cover_images = {} + data_path = Path(__file__).parent.parent / "tests" / "data" + + for i in range(1, 5): + cover_path = data_path / f"cover {i}.png" + if cover_path.exists(): + cover_images[f"cover {i}.png"] = Image.open(cover_path) + print(f" āœ“ Loaded cover {i}.png") + + if not cover_images: + print(" āœ— No cover images found!") + return + + # Create tables + print("\n Creating tables...") + book_table = create_book_catalog_table(cover_images) + product_table = create_product_showcase_table(cover_images) + + # Render tables + print("\n Rendering tables with images...") + print(" - Rendering book catalog...") + book_image = render_table_with_images( + book_table, + "Book Catalog with Covers", + style_variant=0, + page_size=(700, 600) + ) + + print(" - Rendering product showcase...") + product_image = render_table_with_images( + product_table, + "Product Showcase", + style_variant=1, + page_size=(600, 350) + ) + + # Combine images side by side + print("\n Combining images...") + padding = 20 + total_width = book_image.size[0] + product_image.size[0] + padding * 3 + total_height = max(book_image.size[1], product_image.size[1]) + padding * 2 + + combined = Image.new('RGB', (total_width, total_height), (240, 240, 240)) + combined.paste(book_image, (padding, padding)) + combined.paste(product_image, (book_image.size[0] + padding * 2, padding)) + + # Save output + output_dir = Path("docs/images") + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "example_05_table_with_images.png" + combined.save(output_path) + + print(f"\nāœ“ Example completed!") + print(f" Output saved to: {output_path}") + print(f" Image size: {combined.size[0]}x{combined.size[1]} pixels") + print(f" Used {len(cover_images)} cover images") + + return combined + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md index 81fc46d..9384445 100644 --- a/examples/README.md +++ b/examples/README.md @@ -51,6 +51,38 @@ Demonstrates: ![Page Layouts Example](../docs/images/example_03_page_layouts.png) +### 04. Table Rendering +**`04_table_rendering.py`** - HTML table rendering with styling + +```bash +python 04_table_rendering.py +``` + +Demonstrates: +- Rendering HTML tables +- Table headers and body rows +- Cell borders and padding +- Caption support +- Custom table styling + +![Table Rendering Example](../docs/images/example_04_table_rendering.png) + +### 05. Tables with Images +**`05_table_with_images.py`** - Tables containing images and mixed content + +```bash +python 05_table_with_images.py +``` + +Demonstrates: +- Creating tables programmatically +- Adding images to table cells +- Book catalog and product showcase tables +- Mixed content (images and text) in cells +- Using cover images from test data + +![Table with Images Example](../docs/images/example_05_table_with_images.png) + ## Advanced Examples ### HTML Rendering @@ -72,6 +104,8 @@ cd examples python 01_simple_page_rendering.py python 02_text_and_layout.py python 03_page_layouts.py +python 04_table_rendering.py +python 05_table_with_images.py ``` Output images are saved to the `docs/images/` directory. diff --git a/pyWebLayout/concrete/__init__.py b/pyWebLayout/concrete/__init__.py index 4e2f210..3501b05 100644 --- a/pyWebLayout/concrete/__init__.py +++ b/pyWebLayout/concrete/__init__.py @@ -3,3 +3,4 @@ from .page import Page from .text import Text, Line from .functional import LinkText, ButtonText, FormFieldText, create_link_text, create_button_text, create_form_field_text from .image import RenderableImage +from .table import TableRenderer, TableRowRenderer, TableCellRenderer, TableStyle diff --git a/pyWebLayout/concrete/table.py b/pyWebLayout/concrete/table.py new file mode 100644 index 0000000..c47739d --- /dev/null +++ b/pyWebLayout/concrete/table.py @@ -0,0 +1,444 @@ +""" +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 +import numpy as np +from PIL import Image, ImageDraw +from dataclasses import dataclass + +from pyWebLayout.core.base import Renderable, Queriable +from pyWebLayout.concrete.box import Box +from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage +from pyWebLayout.style import Font, Alignment + + +@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: + 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: + 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) + + return y + new_height + 5 # Add some spacing after image + + except Exception as e: + # 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: + 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])