diff --git a/docs/images/example_06_html_table_with_images.png b/docs/images/example_06_html_table_with_images.png new file mode 100644 index 0000000..3384603 Binary files /dev/null and b/docs/images/example_06_html_table_with_images.png differ diff --git a/examples/05_table_with_images_html.py b/examples/05_table_with_images_html.py new file mode 100644 index 0000000..419742d --- /dev/null +++ b/examples/05_table_with_images_html.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +""" +Table with Images Example - HTML Output + +This example demonstrates creating HTML tables with images: +- Creating HTML tables programmatically +- Embedding images in table cells +- Book catalog / product showcase tables +- Styled tables with CSS +- Mixed content (images and text) in cells + +Generates standalone HTML files with embedded images (base64). +""" + +import sys +from pathlib import Path +import base64 +from typing import Dict, List, Tuple + +# Add pyWebLayout to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +def image_to_base64(image_path: Path) -> str: + """Convert image file to base64 string for HTML embedding.""" + with open(image_path, 'rb') as img_file: + img_data = img_file.read() + return base64.b64encode(img_data).decode('utf-8') + + +def create_html_header(title: str) -> str: + """Create HTML document header with CSS styles.""" + return f""" + + + + + {title} + + + +
+

{title}

+""" + + +def create_html_footer() -> str: + """Create HTML document footer.""" + return """ + +
+ + +""" + + +def create_book_catalog_html(cover_images: Dict[str, str]) -> str: + """Create HTML for book catalog table with cover images.""" + 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"), + ] + + html = '
\n' + html += '
Book Catalog
\n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + + for cover_file, title, author, price in books: + html += ' \n' + + # Cover cell + html += ' \n' + + # Title cell + html += f' \n' + + # Author cell + html += f' \n' + + # Price cell + html += f' \n' + + html += ' \n' + + html += ' \n' + html += '
CoverTitleAuthorPrice
\n' + if cover_file in cover_images: + html += f' {title}\n' + html += ' {title}{author}{price}
\n' + html += '
\n' + + return html + + +def create_product_showcase_html(cover_images: Dict[str, str]) -> str: + """Create HTML for product showcase table.""" + products = [ + ("cover 1.png", "Premium Edition - Hardcover with gold embossing"), + ("cover 2.png", "Collector's Item - Limited print run"), + ] + + html = '
\n' + html += '
Product Showcase
\n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + html += ' \n' + + for cover_file, description in products: + html += ' \n' + + # Product cell with image + html += ' \n' + + # Description cell + html += f' \n' + + html += ' \n' + + html += ' \n' + html += '
ProductDescription
\n' + if cover_file in cover_images: + html += f' Product cover\n' + html += ' {description}
\n' + html += '
\n' + + return html + + +def main(): + """Generate HTML tables with images.""" + print("Table with Images Example - HTML Version") + print("=" * 50) + + # Load cover images and convert to base64 + print("\n Loading and encoding 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(): + try: + cover_images[f"cover {i}.png"] = image_to_base64(cover_path) + print(f" ✓ Loaded and encoded cover {i}.png") + except Exception as e: + print(f" ✗ Failed to load cover {i}.png: {e}") + + if not cover_images: + print(" ✗ No cover images found!") + return + + # Generate HTML content + print("\n Generating HTML tables...") + html_content = create_html_header("Table with Images - HTML Example") + + print(" - Creating book catalog table...") + html_content += create_book_catalog_html(cover_images) + + print(" - Creating product showcase table...") + html_content += create_product_showcase_html(cover_images) + + html_content += create_html_footer() + + # Save HTML output + output_dir = Path("docs/html") + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "example_05_table_with_images.html" + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(html_content) + + print(f"\n✓ Example completed!") + print(f" Output saved to: {output_path}") + print(f" Used {len(cover_images)} cover images (embedded as base64)") + print(f" Open the file in a web browser to view the tables") + + return output_path + + +if __name__ == "__main__": + main() diff --git a/examples/06_html_table_with_images.py b/examples/06_html_table_with_images.py new file mode 100644 index 0000000..f8af442 --- /dev/null +++ b/examples/06_html_table_with_images.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +""" +HTML Table with Images Example - End-to-End Rendering + +This example demonstrates the complete pipeline: +1. HTML table source with tags in cells +2. parse_html_string() converts HTML → Abstract document structure +3. DocumentLayouter handles all layout and rendering + +No custom rendering code needed - DocumentLayouter handles everything! +""" + +import sys +from pathlib import Path +from PIL import Image + +# Add pyWebLayout to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pyWebLayout.io.readers.html_extraction import parse_html_string +from pyWebLayout.concrete.page import Page +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.layout.document_layouter import DocumentLayouter +from pyWebLayout.concrete.table import TableStyle +from pyWebLayout.style import Font + + +def create_book_catalog_html(): + """Create HTML for a book catalog table with actual tags.""" + # Get base path for images - use absolute paths for the img src + data_path = Path(__file__).parent.parent / "tests" / "data" + + html = f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CoverTitleAuthorPrice
The Great AdventureThe Great AdventureJohn Smith$19.99
Mystery of the AgesMystery of the AgesJane Doe$24.99
Science TodayScience TodayDr. Brown$29.99
Art & DesignArt & DesignM. Artist$34.99
+ + + """ + return html + + +def create_product_showcase_html(): + """Create HTML for a product showcase table with images.""" + data_path = Path(__file__).parent.parent / "tests" / "data" + + html = f""" + + + + + + + + + + + + + + + + + + + +
ProductDescription
Premium EditionPremium Edition - Hardcover with gold embossing
Collector's ItemCollector's Item - Limited print run
+ + + """ + return html + + +def render_html_with_layouter(html_string: str, title: str, + table_style: TableStyle, + page_size=(600, 500)): + """ + Render HTML using DocumentLayouter - the proper way! + + This function demonstrates the correct usage: + 1. Parse HTML → Abstract blocks + 2. Create Page + 3. Create DocumentLayouter + 4. Layout all blocks using layouter + + Args: + html_string: HTML source containing table with tags + title: Title for the output (for logging) + table_style: Table styling configuration + page_size: Page dimensions + + Returns: + PIL Image with rendered content + """ + print(f"\n Processing '{title}'...") + + # Step 1: Parse HTML to abstract blocks + print(" 1. Parsing HTML → Abstract blocks...") + base_font = Font(font_size=11) + blocks = parse_html_string(html_string, base_font=base_font) + print(f" → Parsed {len(blocks)} blocks") + + # Step 2: Create page + print(" 2. Creating 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) + + # Step 3: Create DocumentLayouter + print(" 3. Creating DocumentLayouter...") + layouter = DocumentLayouter(page) + + # Step 4: Layout all blocks using the layouter + print(" 4. Laying out all blocks...") + for block in blocks: + # For tables, we can pass a custom style + from pyWebLayout.abstract.block import Table + if isinstance(block, Table): + success = layouter.layout_table(block, style=table_style) + else: + # For other blocks (paragraphs, headings, images), use layout_document + success = layouter.layout_document([block]) + + if not success: + print(f" ⚠ Warning: Block {type(block).__name__} didn't fit on page") + + print(f" ✓ Layout complete!") + + # Step 5: Get the rendered canvas + # Note: Tables render directly onto page._canvas + # We access page.draw to ensure canvas is initialized + print(" 5. Getting rendered canvas...") + _ = page.draw # Ensure canvas exists + return page._canvas + + +def main(): + """Demonstrate end-to-end HTML table with images rendering using DocumentLayouter.""" + print("HTML Table with Images Example - DocumentLayouter") + print("=" * 60) + print("\nThis example demonstrates:") + print(" 1. HTML with tags inside cells") + print(" 2. parse_html_string() automatically handles images") + print(" 3. DocumentLayouter handles all layout and rendering") + print(" 4. NO manual TableRenderer or custom rendering code!") + + # Verify images exist + print("\n Checking for cover images...") + data_path = Path(__file__).parent.parent / "tests" / "data" + cover_count = 0 + for i in range(1, 5): + cover_path = data_path / f"cover {i}.png" + if cover_path.exists(): + cover_count += 1 + print(f" ✓ Found cover {i}.png") + + if cover_count == 0: + print(" ✗ No cover images found! This example requires cover images.") + return + + # Create HTML sources with tags + print("\n Creating HTML sources with tags...") + print(" - Book catalog HTML") + book_html = create_book_catalog_html() + + print(" - Product showcase HTML") + product_html = create_product_showcase_html() + + # Define table styles + book_style = 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) + ) + + product_style = 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) + ) + + # Render using DocumentLayouter - the proper way! + print("\n Rendering with DocumentLayouter (HTML → Abstract → Layout → PNG)...") + + book_image = render_html_with_layouter( + book_html, + "Book Catalog", + book_style, + page_size=(700, 600) + ) + + product_image = render_html_with_layouter( + product_html, + "Product Showcase", + product_style, + page_size=(600, 350) + ) + + # Combine images side by side + print("\n Combining output 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_06_html_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"\nThe complete pipeline:") + print(f" 1. HTML with tags → parse_html_string() → Abstract blocks") + print(f" 2. Abstract blocks → DocumentLayouter → Concrete objects") + print(f" 3. Page.render() → PNG output") + print(f"\n ✓ Using DocumentLayouter - NO custom rendering code!") + + return combined + + +if __name__ == "__main__": + main() diff --git a/pyWebLayout/layout/document_layouter.py b/pyWebLayout/layout/document_layouter.py index a72b614..a35446b 100644 --- a/pyWebLayout/layout/document_layouter.py +++ b/pyWebLayout/layout/document_layouter.py @@ -5,8 +5,9 @@ from typing import List, Tuple, Optional, Union from pyWebLayout.concrete import Page, Line, Text from pyWebLayout.concrete.image import RenderableImage from pyWebLayout.concrete.functional import LinkText +from pyWebLayout.concrete.table import TableRenderer, TableStyle from pyWebLayout.abstract import Paragraph, Word, Link -from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak +from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table from pyWebLayout.abstract.inline import LinkedWord from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver from pyWebLayout.style import Font, Alignment @@ -288,14 +289,65 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = return True +def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None) -> bool: + """ + Layout a table within a given page. + + This function uses the TableRenderer to render the table at the current + page position, advancing the page's y-offset after successful rendering. + + Args: + table: The abstract Table object to layout + page: The page to layout the table on + style: Optional table styling configuration + + Returns: + bool: True if table was successfully laid out, False if page ran out of space + """ + # Calculate available space + available_width = page.available_width + x_offset = page.border_size + y_offset = page._current_y_offset + + # Access page.draw to ensure canvas is initialized + draw = page.draw + canvas = page._canvas + + # Create table renderer + origin = (x_offset, y_offset) + renderer = TableRenderer( + table=table, + origin=origin, + available_width=available_width, + draw=draw, + style=style, + canvas=canvas + ) + + # Check if table fits on current page + table_height = renderer.size[1] + available_height = page.size[1] - y_offset - page.border_size + + if table_height > available_height: + return False + + # Render the table + renderer.render() + + # Update page y-offset + page._current_y_offset = y_offset + table_height + + return True + + class DocumentLayouter: """ Document layouter that orchestrates layout of various abstract elements. Delegates to specialized layouters for different content types: - paragraph_layouter for text paragraphs - - image_layouter for images (future) - - table_layouter for tables (future) + - image_layouter for images + - table_layouter for tables This class acts as a coordinator, managing the overall document flow and page context while delegating specific layout tasks to specialized @@ -305,12 +357,20 @@ class DocumentLayouter: def __init__(self, page: Page): """ Initialize the document layouter with a page. - + Args: page: The page to layout content on """ self.page = page - self.style_registry = ConcreteStyleRegistry(page.style_resolver) + # Create a style resolver if page doesn't have one + if hasattr(page, 'style_resolver'): + style_resolver = page.style_resolver + else: + # Create a default rendering context and style resolver + from pyWebLayout.style.concrete_style import RenderingContext + context = RenderingContext() + style_resolver = StyleResolver(context) + self.style_registry = ConcreteStyleRegistry(style_resolver) def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0, pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]: @@ -327,33 +387,46 @@ class DocumentLayouter: """ return paragraph_layouter(paragraph, self.page, start_word, pretext) - def layout_image(self, image: AbstractImage, max_width: Optional[int] = None, + def layout_image(self, image: AbstractImage, max_width: Optional[int] = None, max_height: Optional[int] = None) -> bool: """ Layout an image using the image_layouter. - + Args: image: The abstract Image object to layout max_width: Maximum width constraint (defaults to page available width) max_height: Maximum height constraint (defaults to remaining page height) - + Returns: bool: True if image was successfully laid out, False if page ran out of space """ return image_layouter(image, self.page, max_width, max_height) - - def layout_document(self, elements: List[Union[Paragraph, AbstractImage]]) -> bool: + + def layout_table(self, table: Table, style: Optional[TableStyle] = None) -> bool: """ - Layout a list of abstract elements (paragraphs and images). - + Layout a table using the table_layouter. + + Args: + table: The abstract Table object to layout + style: Optional table styling configuration + + Returns: + bool: True if table was successfully laid out, False if page ran out of space + """ + return table_layouter(table, self.page, style) + + def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table]]) -> bool: + """ + Layout a list of abstract elements (paragraphs, images, and tables). + This method delegates to specialized layouters based on element type: - Paragraphs are handled by layout_paragraph - Images are handled by layout_image - - Tables and other elements can be added in the future - + - Tables are handled by layout_table + Args: elements: List of abstract elements to layout - + Returns: True if all elements were successfully laid out, False otherwise """ @@ -366,6 +439,9 @@ class DocumentLayouter: success = self.layout_image(element) if not success: return False - # Future: elif isinstance(element, Table): use table_layouter + elif isinstance(element, Table): + success = self.layout_table(element) + if not success: + return False # Future: elif isinstance(element, CodeBlock): use code_layouter return True diff --git a/tests/layouter/test_document_layouter.py b/tests/layouter/test_document_layouter.py index deacefd..2182653 100644 --- a/tests/layouter/test_document_layouter.py +++ b/tests/layouter/test_document_layouter.py @@ -9,9 +9,11 @@ import pytest from unittest.mock import Mock, MagicMock, patch from typing import List, Optional -from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter +from pyWebLayout.layout.document_layouter import paragraph_layouter, table_layouter, DocumentLayouter from pyWebLayout.style.abstract_style import AbstractStyle from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext +from pyWebLayout.abstract.block import Table, TableRow, TableCell +from pyWebLayout.concrete.table import TableStyle class TestDocumentLayouter: @@ -577,6 +579,179 @@ class TestMultiPageLayout: print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px") +class TestTableLayouter: + """Test cases for table layouter functionality.""" + + def setup_method(self): + """Set up test fixtures before each test method.""" + # Create mock page + self.mock_page = Mock() + self.mock_page.border_size = 20 + self.mock_page._current_y_offset = 50 + self.mock_page.available_width = 600 + self.mock_page.size = (800, 1000) + + # Create mock draw and canvas + self.mock_draw = Mock() + self.mock_canvas = Mock() + self.mock_page.draw = self.mock_draw + self.mock_page._canvas = self.mock_canvas + + # Create mock table + self.mock_table = Mock(spec=Table) + + # Create mock style resolver + self.mock_style_resolver = Mock() + self.mock_page.style_resolver = self.mock_style_resolver + + @patch('pyWebLayout.layout.document_layouter.TableRenderer') + def test_table_layouter_success(self, mock_table_renderer_class): + """Test table_layouter with successful table rendering.""" + # Setup mock renderer + mock_renderer = Mock() + mock_table_renderer_class.return_value = mock_renderer + mock_renderer.size = (500, 200) # Table fits on page + + # Call function + result = table_layouter(self.mock_table, self.mock_page) + + # Verify results + assert result is True + + # Verify TableRenderer was created with correct parameters + mock_table_renderer_class.assert_called_once_with( + table=self.mock_table, + origin=(20, 50), # (border_size, current_y_offset) + available_width=600, + draw=self.mock_draw, + style=None, + canvas=self.mock_canvas + ) + + # Verify render was called + mock_renderer.render.assert_called_once() + + # Verify y_offset was updated + assert self.mock_page._current_y_offset == 250 # 50 + 200 + + @patch('pyWebLayout.layout.document_layouter.TableRenderer') + def test_table_layouter_with_custom_style(self, mock_table_renderer_class): + """Test table_layouter with custom TableStyle.""" + # Create custom style + custom_style = TableStyle( + border_width=2, + border_color=(100, 100, 100), + cell_padding=(10, 10, 10, 10) + ) + + # Setup mock renderer + mock_renderer = Mock() + mock_table_renderer_class.return_value = mock_renderer + mock_renderer.size = (500, 150) + + # Call function with style + result = table_layouter(self.mock_table, self.mock_page, style=custom_style) + + # Verify TableRenderer was created with custom style + assert result is True + call_args = mock_table_renderer_class.call_args + assert call_args[1]['style'] == custom_style + + @patch('pyWebLayout.layout.document_layouter.TableRenderer') + def test_table_layouter_table_too_large(self, mock_table_renderer_class): + """Test table_layouter when table doesn't fit on page.""" + # Setup mock renderer with table larger than available space + mock_renderer = Mock() + mock_table_renderer_class.return_value = mock_renderer + mock_renderer.size = (500, 1000) # Table height exceeds available space + + # Available height = page_size[1] - y_offset - border_size + # = 1000 - 50 - 20 = 930, but table is 1000 pixels tall + + # Call function + result = table_layouter(self.mock_table, self.mock_page) + + # Verify failure + assert result is False + + # Verify render was NOT called + mock_renderer.render.assert_not_called() + + # Verify y_offset was NOT updated + assert self.mock_page._current_y_offset == 50 + + @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') + def test_document_layouter_layout_table(self, mock_style_registry_class): + """Test DocumentLayouter.layout_table method.""" + # Setup mocks + mock_style_registry = Mock() + mock_style_registry_class.return_value = mock_style_registry + + layouter = DocumentLayouter(self.mock_page) + + # Mock the table_layouter function + with patch('pyWebLayout.layout.document_layouter.table_layouter') as mock_table_layouter: + mock_table_layouter.return_value = True + + custom_style = TableStyle(border_width=1) + result = layouter.layout_table(self.mock_table, style=custom_style) + + # Verify the function was called correctly + mock_table_layouter.assert_called_once_with( + self.mock_table, self.mock_page, custom_style + ) + assert result is True + + def test_document_layouter_layout_document_with_table(self): + """Test DocumentLayouter.layout_document with Table elements.""" + from pyWebLayout.abstract import Paragraph + from pyWebLayout.abstract.block import Image as AbstractImage + + # Create mixed elements + elements = [ + Mock(spec=Paragraph), + Mock(spec=Table), + Mock(spec=AbstractImage), + Mock(spec=Table) + ] + + with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): + layouter = DocumentLayouter(self.mock_page) + + # Mock the layout methods + layouter.layout_paragraph = Mock(return_value=(True, None, None)) + layouter.layout_table = Mock(return_value=True) + layouter.layout_image = Mock(return_value=True) + + result = layouter.layout_document(elements) + + # Verify all elements were laid out + assert result is True + assert layouter.layout_paragraph.call_count == 1 + assert layouter.layout_table.call_count == 2 + assert layouter.layout_image.call_count == 1 + + def test_document_layouter_layout_document_table_failure(self): + """Test DocumentLayouter.layout_document when table layout fails.""" + # Create elements with table that will fail + elements = [ + Mock(spec=Table), + Mock(spec=Table) + ] + + with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): + layouter = DocumentLayouter(self.mock_page) + + # Mock layout_table: first succeeds, second fails + layouter.layout_table = Mock(side_effect=[True, False]) + + result = layouter.layout_document(elements) + + # Verify it stopped after failure + assert result is False + assert layouter.layout_table.call_count == 2 + + if __name__ == "__main__": # Run specific tests for debugging test = TestDocumentLayouter()