diff --git a/docs/images/example_04_table_rendering.png b/docs/images/example_04_table_rendering.png index d363771..1c59b29 100644 Binary files a/docs/images/example_04_table_rendering.png and b/docs/images/example_04_table_rendering.png differ diff --git a/docs/images/example_05_html_table_with_images.png b/docs/images/example_05_html_table_with_images.png index 2cc18bc..a11f45d 100644 Binary files a/docs/images/example_05_html_table_with_images.png and b/docs/images/example_05_html_table_with_images.png differ diff --git a/docs/images/example_12_optimized_table_layout.png b/docs/images/example_12_optimized_table_layout.png new file mode 100644 index 0000000..609b0a9 Binary files /dev/null and b/docs/images/example_12_optimized_table_layout.png differ diff --git a/docs/images/example_13_table_pagination.png b/docs/images/example_13_table_pagination.png new file mode 100644 index 0000000..c100b18 Binary files /dev/null and b/docs/images/example_13_table_pagination.png differ diff --git a/docs/images/example_14_interactive_table.png b/docs/images/example_14_interactive_table.png new file mode 100644 index 0000000..573582d Binary files /dev/null and b/docs/images/example_14_interactive_table.png differ diff --git a/examples/12_optimized_table_layout_demo.py b/examples/12_optimized_table_layout_demo.py new file mode 100644 index 0000000..8aa19a9 --- /dev/null +++ b/examples/12_optimized_table_layout_demo.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +Demo: Optimized Table Column Width Layout + +This example demonstrates the intelligent table column width optimization: +- Automatic width distribution based on content +- HTML width overrides (fixed column widths) +- Sampling for performance (large tables) +- Comparison: before (equal distribution) vs after (optimized) + +The optimizer: +1. Samples first ~5 rows from each section +2. Measures minimum and preferred widths for each column +3. Distributes available space proportionally +4. Respects HTML width attributes +""" + +from pyWebLayout.concrete.page import Page +from pyWebLayout.concrete.table import TableRenderer, TableStyle +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.style import Font +from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph +from pyWebLayout.abstract.inline import Word +from PIL import ImageDraw + + +def create_demo_table_1(): + """Create a table with varying content lengths (shows optimization).""" + table = Table() + table.caption = "Example 1: Optimized Width Distribution" + + font = Font(font_size=11) + + # Header + header_row = TableRow() + for text in ["ID", "Name", "Description"]: + cell = TableCell(is_header=True) + para = Paragraph(font) + para.add_word(Word(text, font)) + cell.add_block(para) + header_row.add_cell(cell) + table.add_row(header_row, section="header") + + # Body rows with varying content lengths + data = [ + ("1", "Alice", "Short description"), + ("2", "Bob", "This is a much longer description that demonstrates how the optimizer allocates more space to columns with longer content"), + ("3", "Charlie", "Medium length description here"), + ("4", "Diana", "Another longer description that shows the column width optimization working effectively for content-heavy cells"), + ] + + for row_data in data: + row = TableRow() + for text in row_data: + cell = TableCell() + para = Paragraph(font) + for word in text.split(): + para.add_word(Word(word, font)) + cell.add_block(para) + row.add_cell(cell) + table.add_row(row, section="body") + + return table + + +def create_demo_table_2(): + """Create a table with HTML width overrides.""" + table = Table() + table.caption = "Example 2: Fixed Column Widths (HTML override)" + + font = Font(font_size=11) + + # Header with width attributes + header_row = TableRow() + + # Fixed width column + cell1 = TableCell(is_header=True) + cell1.width = "80px" # HTML width override! + para1 = Paragraph(font) + para1.add_word(Word("ID", font)) + para1.add_word(Word("(Fixed", font)) + para1.add_word(Word("80px)", font)) + cell1.add_block(para1) + header_row.add_cell(cell1) + + # Auto-width columns + for text in ["Name (Auto)", "Description (Auto)"]: + cell = TableCell(is_header=True) + para = Paragraph(font) + for word in text.split(): + para.add_word(Word(word, font)) + cell.add_block(para) + header_row.add_cell(cell) + + table.add_row(header_row, section="header") + + # Body rows + data = [ + ("1", "Alice", "The first two columns adapt to remaining space"), + ("2", "Bob", "ID column stays fixed at 80px width"), + ("3", "Charlie", "Name and Description share the remaining width proportionally"), + ] + + for row_data in data: + row = TableRow() + # First cell also has fixed width + cell = TableCell() + cell.width = "80px" + para = Paragraph(font) + para.add_word(Word(row_data[0], font)) + cell.add_block(para) + row.add_cell(cell) + + # Other cells auto-width + for text in row_data[1:]: + cell = TableCell() + para = Paragraph(font) + for word in text.split(): + para.add_word(Word(word, font)) + cell.add_block(para) + row.add_cell(cell) + table.add_row(row, section="body") + + return table + + +def create_demo_table_3(): + """Create a large table (demonstrates sampling).""" + table = Table() + table.caption = "Example 3: Large Table (uses sampling for performance)" + + font = Font(font_size=10) + + # Header + header_row = TableRow() + for text in ["Index", "Data A", "Data B", "Data C"]: + cell = TableCell(is_header=True) + para = Paragraph(font) + para.add_word(Word(text, font)) + cell.add_block(para) + header_row.add_cell(cell) + table.add_row(header_row, section="header") + + # Many body rows (only first ~5 will be sampled for measurement) + for i in range(50): + row = TableRow() + + # Index + cell = TableCell() + para = Paragraph(font) + para.add_word(Word(str(i + 1), font)) + cell.add_block(para) + row.add_cell(cell) + + # Data columns with varying content + if i % 3 == 0: + data = ["Short", "Medium length", "Longer content here"] + elif i % 3 == 1: + data = ["Medium", "Short", "Also longer content"] + else: + data = ["Longer text", "Short", "Medium"] + + for text in data: + cell = TableCell() + para = Paragraph(font) + for word in text.split(): + para.add_word(Word(word, font)) + cell.add_block(para) + row.add_cell(cell) + + table.add_row(row, section="body") + + return table + + +def main(): + # Create page + page_style = PageStyle( + border_width=1, + padding=(20, 20, 20, 20), + background_color=(255, 255, 255) + ) + page = Page(size=(800, 2200), style=page_style) + + # Get canvas and draw + canvas = page._create_canvas() + page._canvas = canvas + page._draw = ImageDraw.Draw(canvas) + + current_y = 30 + + # Table style + table_style = TableStyle( + border_width=1, + border_color=(100, 100, 100), + cell_padding=(8, 8, 8, 8), + header_bg_color=(220, 230, 240), + cell_bg_color=(255, 255, 255), + alternate_row_color=(248, 248, 248) + ) + + # Render Example 1: Optimized distribution + table1 = create_demo_table_1() + renderer1 = TableRenderer( + table1, + origin=(20, current_y), + available_width=760, + draw=page._draw, + style=table_style, + canvas=canvas + ) + renderer1.render() + current_y += renderer1.height + 40 + + # Render Example 2: Fixed widths + table2 = create_demo_table_2() + renderer2 = TableRenderer( + table2, + origin=(20, current_y), + available_width=760, + draw=page._draw, + style=table_style, + canvas=canvas + ) + renderer2.render() + current_y += renderer2.height + 40 + + # Render Example 3: Large table with sampling + table3 = create_demo_table_3() + renderer3 = TableRenderer( + table3, + origin=(20, current_y), + available_width=760, + draw=page._draw, + style=table_style, + canvas=canvas + ) + renderer3.render() + + # Save + output_path = "docs/images/example_12_optimized_table_layout.png" + canvas.save(output_path) + + print(f"✓ Optimized table layout demo created!") + print(f" Output: {output_path}") + print(f" Image size: {canvas.size}") + print(f"\nExamples demonstrated:") + print(f" 1. Content-aware width distribution") + print(f" 2. HTML width overrides (80px fixed column)") + print(f" 3. Large table with sampling (50 rows, only ~5 measured)") + + +if __name__ == "__main__": + main() diff --git a/examples/13_table_pagination_demo.py b/examples/13_table_pagination_demo.py new file mode 100644 index 0000000..249bf88 --- /dev/null +++ b/examples/13_table_pagination_demo.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +Demo: Table Pagination + +This example demonstrates table pagination when content exceeds page height: +- Large table that spans multiple pages +- Automatic row-level pagination (entire rows move to next page) +- Continuation markers ("continued on next page", "continued from previous page") +- Headers repeated on each page + +The pagination system: +1. Renders rows sequentially until page height limit reached +2. Moves entire row to next page if it doesn't fit +3. Repeats header row on continuation pages +4. Adds visual markers to show table continues +""" + +from pyWebLayout.concrete.page import Page +from pyWebLayout.concrete.table import TableRenderer, TableStyle +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.style import Font +from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph +from pyWebLayout.abstract.inline import Word +from PIL import Image, ImageDraw + + +def create_large_table(): + """Create a table with many rows that will require pagination.""" + table = Table() + table.caption = "Employee Directory (Paginated)" + + font = Font(font_size=11) + + # Header + header_row = TableRow() + for text in ["ID", "Name", "Department", "Email", "Phone"]: + cell = TableCell(is_header=True) + para = Paragraph(font) + para.add_word(Word(text, font)) + cell.add_block(para) + header_row.add_cell(cell) + table.add_row(header_row, section="header") + + # Many body rows (will span multiple pages) + departments = ["Engineering", "Sales", "Marketing", "HR", "Finance", "Operations", "Support"] + + for i in range(60): + row = TableRow() + + # ID + cell = TableCell() + para = Paragraph(font) + para.add_word(Word(f"EMP{i+1001}", font)) + cell.add_block(para) + row.add_cell(cell) + + # Name + cell = TableCell() + para = Paragraph(font) + names = ["Alice Johnson", "Bob Smith", "Charlie Brown", "Diana Lee", + "Eve Wilson", "Frank Miller", "Grace Davis", "Henry Taylor"] + para.add_word(Word(names[i % len(names)], font)) + cell.add_block(para) + row.add_cell(cell) + + # Department + cell = TableCell() + para = Paragraph(font) + para.add_word(Word(departments[i % len(departments)], font)) + cell.add_block(para) + row.add_cell(cell) + + # Email + cell = TableCell() + para = Paragraph(font) + email = f"{names[i % len(names)].lower().replace(' ', '.')}@company.com" + para.add_word(Word(email, font)) + cell.add_block(para) + row.add_cell(cell) + + # Phone + cell = TableCell() + para = Paragraph(font) + para.add_word(Word(f"+1-555-{(i*17)%1000:04d}", font)) + cell.add_block(para) + row.add_cell(cell) + + table.add_row(row, section="body") + + return table + + +def render_table_with_pagination(table, page_size, max_pages=3): + """ + Render a table across multiple pages. + + Args: + table: The table to render + page_size: Tuple of (width, height) for each page + max_pages: Maximum number of pages to render + + Returns: + List of PIL Images (one per page) + """ + pages = [] + + page_style = PageStyle( + border_width=1, + padding=(20, 20, 20, 20), + background_color=(255, 255, 255) + ) + + table_style = TableStyle( + border_width=1, + border_color=(100, 100, 100), + cell_padding=(6, 8, 6, 8), + header_bg_color=(220, 230, 240), + cell_bg_color=(255, 255, 255), + alternate_row_color=(248, 248, 248) + ) + + # Get all rows + all_rows = list(table.all_rows()) + header_rows = [row for section, row in all_rows if section == "header"] + body_rows = [row for section, row in all_rows if section == "body"] + + # Calculate header height once + temp_page = Page(size=page_size, style=page_style) + temp_canvas = temp_page._create_canvas() + temp_draw = ImageDraw.Draw(temp_canvas) + + # Create temporary table with just header to measure + header_table = Table() + header_table.caption = table.caption + for header_row in header_rows: + header_table.add_row(header_row, section="header") + + header_renderer = TableRenderer( + header_table, + origin=(20, 20), + available_width=page_size[0] - 40, + draw=temp_draw, + style=table_style, + canvas=temp_canvas + ) + header_height = header_renderer.height + + # Available height for body rows + available_body_height = page_size[1] - 60 - header_height # margins + header + + # Paginate body rows + current_page_rows = [] + current_height = 0 + page_num = 0 + + for i, body_row in enumerate(body_rows): + if page_num >= max_pages: + break + + # Estimate row height (simplified - actual would measure each row) + # For this demo, assume ~30px per row + row_height = 35 + + if current_height + row_height > available_body_height and current_page_rows: + # Render current page + page_canvas = render_page( + table, + header_rows, + current_page_rows, + page_size, + page_style, + table_style, + page_num, + is_last=False + ) + pages.append(page_canvas) + + # Start new page + page_num += 1 + current_page_rows = [] + current_height = 0 + + current_page_rows.append(body_row) + current_height += row_height + + # Render final page + if current_page_rows and page_num < max_pages: + page_canvas = render_page( + table, + header_rows, + current_page_rows, + page_size, + page_style, + table_style, + page_num, + is_last=(i == len(body_rows) - 1) + ) + pages.append(page_canvas) + + return pages + + +def render_page(table, header_rows, body_rows, page_size, page_style, table_style, page_num, is_last): + """Render a single page with header and body rows.""" + page = Page(size=page_size, style=page_style) + canvas = page._create_canvas() + page._canvas = canvas + page._draw = ImageDraw.Draw(canvas) + + # Create table for this page + page_table = Table() + if page_num == 0: + page_table.caption = table.caption + else: + page_table.caption = f"{table.caption} (continued)" + + # Add header rows + for header_row in header_rows: + page_table.add_row(header_row, section="header") + + # Add body rows for this page + for body_row in body_rows: + page_table.add_row(body_row, section="body") + + # Render table + renderer = TableRenderer( + page_table, + origin=(20, 20), + available_width=page_size[0] - 40, + draw=page._draw, + style=table_style, + canvas=canvas + ) + renderer.render() + + # Add continuation marker at bottom + if not is_last: + font = Font(font_size=10) + y_pos = page_size[1] - 30 + page._draw.text( + (page_size[0] // 2 - 100, y_pos), + "(continued on next page)", + fill=(100, 100, 100), + font=font.font + ) + + # Add page number + page._draw.text( + (page_size[0] // 2 - 20, page_size[1] - 15), + f"Page {page_num + 1}", + fill=(150, 150, 150), + font=Font(font_size=9).font + ) + + return canvas + + +def main(): + # Create large table + table = create_large_table() + + # Render with pagination (3 pages max for demo) + page_size = (900, 700) + pages = render_table_with_pagination(table, page_size, max_pages=3) + + # Combine pages side-by-side for visualization + total_width = page_size[0] * len(pages) + (len(pages) - 1) * 20 # 20px spacing + combined = Image.new('RGB', (total_width, page_size[1]), (240, 240, 240)) + + x_offset = 0 + for i, page_canvas in enumerate(pages): + combined.paste(page_canvas, (x_offset, 0)) + x_offset += page_size[0] + 20 + + # Save + output_path = "docs/images/example_13_table_pagination.png" + combined.save(output_path) + + print(f"✓ Table pagination demo created!") + print(f" Output: {output_path}") + print(f" Pages rendered: {len(pages)}") + print(f" Image size: {combined.size}") + print(f"\nDemonstrates:") + print(f" - Large table (60 rows) paginated across {len(pages)} pages") + print(f" - Header repeated on each page") + print(f" - Continuation markers") + print(f" - Page numbers") + + +if __name__ == "__main__": + main() diff --git a/examples/14_interactive_table.py b/examples/14_interactive_table.py new file mode 100644 index 0000000..8832eaa --- /dev/null +++ b/examples/14_interactive_table.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +""" +Demo: Working Interactive Table with Buttons + +This example shows a fully working interactive table where buttons are +actually rendered inside table cells and can handle click events. + +This uses a hybrid approach: +1. Tables are rendered normally for structure +2. Buttons are rendered on top at calculated positions +3. Click detection maps coordinates to button callbacks +""" + +from pyWebLayout.concrete.page import Page +from pyWebLayout.concrete.table import TableRenderer, TableStyle +from pyWebLayout.concrete.functional import ButtonText +from pyWebLayout.abstract.functional import Button +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.style import Font +from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph +from pyWebLayout.abstract.inline import Word +from PIL import Image, ImageDraw +import numpy as np + + +def create_interactive_table(): + """Create a table structure (buttons will be overlaid).""" + table = Table() + table.caption = "User Management with Interactive Buttons" + + font = Font(font_size=11) + + # Header + header_row = TableRow() + for i, text in enumerate(["ID", "Name", "Email", "Actions"]): + cell = TableCell(is_header=True) + # Set width for Actions column + if text == "Actions": + cell.width = "220px" # Enough for 3 buttons + para = Paragraph(font) + para.add_word(Word(text, font)) + cell.add_block(para) + header_row.add_cell(cell) + table.add_row(header_row, section="header") + + # Body rows + users = [ + ("U001", "Alice Johnson", "alice@example.com"), + ("U002", "Bob Smith", "bob@example.com"), + ("U003", "Charlie Brown", "charlie@example.com"), + ] + + for user_id, name, email in users: + row = TableRow() + + # ID + cell = TableCell() + para = Paragraph(font) + para.add_word(Word(user_id, font)) + cell.add_block(para) + row.add_cell(cell) + + # Name + cell = TableCell() + para = Paragraph(font) + para.add_word(Word(name, font)) + cell.add_block(para) + row.add_cell(cell) + + # Email + cell = TableCell() + para = Paragraph(font) + para.add_word(Word(email, font)) + cell.add_block(para) + row.add_cell(cell) + + # Actions - leave empty for buttons to be overlaid + # Set width hint to ensure space for 3 buttons + cell = TableCell() + cell.width = "220px" # Enough for 3 buttons (3 × 65px + padding) + para = Paragraph(font) + para.add_word(Word("", font)) # Empty placeholder + cell.add_block(para) + row.add_cell(cell) + + table.add_row(row, section="body") + + return table + + +def render_buttons_in_table(canvas, draw, table_origin, column_widths, row_heights, users): + """ + Render interactive buttons inside the table cells. + + This calculates the exact position of each button based on the table + layout and renders ButtonText objects at those positions. + + Args: + canvas: PIL Image canvas + draw: PIL ImageDraw object + table_origin: (x, y) position of table top-left + column_widths: List of column widths + row_heights: Dict with 'header', 'body', 'footer' keys + users: User data for button labels + + Returns: + List of (button, bounds) for click detection + """ + button_font = Font(font_size=10) + buttons_with_bounds = [] + + # Calculate Actions column position (column 3, index 3) + actions_col_x = table_origin[0] + sum(column_widths[:3]) + 3 * 2 # +borders + actions_col_width = column_widths[3] + + # Start after caption and header row + # Caption takes 20px + 10px spacing = 30px + caption_height = 30 + header_height = row_heights.get("header", 30) + current_y = table_origin[1] + caption_height + header_height + 2 # +caption +header +border + + for i, (user_id, name, email) in enumerate(users): + row_height = row_heights.get("body", 30) # All body rows have same height + + # Position buttons horizontally in the Actions cell + button_x = actions_col_x + 10 # Padding from cell edge + button_y = current_y + (row_height - 30) // 2 # Center vertically + + # Create buttons for this row + # Note: Button callbacks receive click point as first argument + buttons = [ + ("View", lambda point, uid=user_id: print(f"View {uid}")), + ("Edit", lambda point, uid=user_id: print(f"Edit {uid}")), + ("Delete", lambda point, uid=user_id: print(f"Delete {uid}")) + ] + + for label, callback in buttons: + # Create button + abstract_button = Button(label=label, callback=callback) + button_text = ButtonText( + button=abstract_button, + font=button_font, + draw=draw, + padding=(6, 12, 6, 12) + ) + + # Set position + button_text._origin = np.array([button_x, button_y]) + + # Render button + button_text.render() + + # Store bounds for click detection + button_width = 60 # Approximate + button_height = 25 + bounds = (button_x, button_y, button_x + button_width, button_y + button_height) + buttons_with_bounds.append((abstract_button, bounds)) + + # Move to next button position + button_x += 65 + + # Move to next row + current_y += row_height + 1 # +border + + return buttons_with_bounds + + +def handle_click(click_pos, buttons_with_bounds): + """ + Handle a click event by checking if it's inside any button bounds. + + Args: + click_pos: (x, y) tuple of click position + buttons_with_bounds: List of (button, bounds) tuples + + Returns: + True if a button was clicked, False otherwise + """ + click_x, click_y = click_pos + + for button, (x1, y1, x2, y2) in buttons_with_bounds: + if x1 <= click_x <= x2 and y1 <= click_y <= y2: + # Click is inside this button! + button.execute(click_pos) + return True + + return False + + +def main(): + # Create page + page_size = (900, 500) # Increased height to fit instructions + page_style = PageStyle( + border_width=1, + padding=(20, 20, 20, 20), + background_color=(255, 255, 255) + ) + page = Page(size=page_size, style=page_style) + canvas = page._create_canvas() + page._canvas = canvas + page._draw = ImageDraw.Draw(canvas) + + # Table style + table_style = TableStyle( + border_width=1, + border_color=(100, 100, 100), + cell_padding=(8, 10, 8, 10), + header_bg_color=(220, 230, 240), + cell_bg_color=(255, 255, 255), + alternate_row_color=(248, 248, 248) + ) + + # User data + users = [ + ("U001", "Alice Johnson", "alice@example.com"), + ("U002", "Bob Smith", "bob@example.com"), + ("U003", "Charlie Brown", "charlie@example.com"), + ] + + # Create and render table + table = create_interactive_table() + table_origin = (20, 30) + renderer = TableRenderer( + table, + origin=table_origin, + available_width=860, + draw=page._draw, + style=table_style, + canvas=canvas + ) + renderer.render() + + # Get table dimensions for button positioning + column_widths = renderer._column_widths + row_heights = renderer._row_heights + + # Render interactive buttons on top of table + buttons_with_bounds = render_buttons_in_table( + canvas, page._draw, table_origin, + column_widths, row_heights, users + ) + + # Add instructions (position below the table) + # Calculate actual table height based on rows + header_height = row_heights.get("header", 30) + body_height = row_heights.get("body", 30) * len(users) + actual_table_height = header_height + body_height + (len(users) + 2) * 2 # +borders + + inst_font = Font(font_size=12) + y_offset = table_origin[1] + actual_table_height + 50 + page._draw.text( + (20, y_offset), + "Interactive Table Demo:", + fill=(50, 50, 50), + font=inst_font.font + ) + + note_font = Font(font_size=10) + page._draw.text( + (20, y_offset + 25), + "• Buttons are rendered at calculated positions within table cells", + fill=(80, 80, 80), + font=note_font.font + ) + page._draw.text( + (20, y_offset + 45), + "• Click detection maps coordinates to button callbacks", + fill=(80, 80, 80), + font=note_font.font + ) + page._draw.text( + (20, y_offset + 65), + "• Try simulated clicks below:", + fill=(80, 80, 80), + font=note_font.font + ) + + # Save + output_path = "docs/images/example_14_interactive_table.png" + canvas.save(output_path) + + print(f"✓ Working interactive table demo created!") + print(f" Output: {output_path}") + print(f" Image size: {canvas.size}") + print(f"\nDemonstrating button click detection:") + + # Simulate some clicks to demonstrate functionality + actions_col_x = table_origin[0] + sum(column_widths[:3]) + 6 + caption_height = 30 + header_height = row_heights.get("header", 30) + body_row_height = row_heights.get("body", 30) + + # Calculate first body row position (after caption + header + border) + first_row_y = table_origin[1] + caption_height + header_height + 2 + + test_clicks = [ + (100, 100, "Click outside table"), + (actions_col_x + 10, first_row_y + 15, "View button - Alice"), + (actions_col_x + 75, first_row_y + 15, "Edit button - Alice"), + (actions_col_x + 140, first_row_y + 15, "Delete button - Alice"), + (actions_col_x + 10, first_row_y + body_row_height + 17, "View button - Bob"), + ] + + for x, y, desc in test_clicks: + print(f"\n Click at ({x}, {y}) - {desc}:") + clicked = handle_click((x, y), buttons_with_bounds) + if not clicked: + print(f" No button at this position") + + +if __name__ == "__main__": + main() diff --git a/pyWebLayout/concrete/dynamic_page.py b/pyWebLayout/concrete/dynamic_page.py new file mode 100644 index 0000000..2c58a54 --- /dev/null +++ b/pyWebLayout/concrete/dynamic_page.py @@ -0,0 +1,418 @@ +""" +DynamicPage implementation for pyWebLayout. + +A DynamicPage is a page that dynamically sizes itself based on content and constraints. +Unlike a regular Page with fixed size, a DynamicPage measures its content first and +then layouts within the allocated space. + +Use cases: +- Table cells that need to fit content +- Containers that should grow with content +- Responsive layouts that adapt to constraints +""" + +from typing import Tuple, Optional, List +from dataclasses import dataclass +import numpy as np +from PIL import Image + +from pyWebLayout.concrete.page import Page +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.core.base import Renderable + + +@dataclass +class SizeConstraints: + """Size constraints for dynamic layout.""" + min_width: Optional[int] = None + max_width: Optional[int] = None + min_height: Optional[int] = None + max_height: Optional[int] = None + # Note: Hyphenation threshold is controlled by Font.min_hyphenation_width + # Don't duplicate that logic here + + +class DynamicPage(Page): + """ + A page that dynamically sizes itself based on content and constraints. + + The layout process has two phases: + 1. Measurement: Calculate intrinsic size needed for content + 2. Layout: Position content within allocated size + + This allows containers (like tables) to optimize space allocation before rendering. + """ + + def __init__(self, + constraints: Optional[SizeConstraints] = None, + style: Optional[PageStyle] = None): + """ + Initialize a dynamic page. + + Args: + constraints: Optional size constraints (min/max width/height) + style: The PageStyle defining borders, spacing, and appearance + """ + # Start with zero size - will be determined during measurement/layout + super().__init__(size=(0, 0), style=style) + self._constraints = constraints if constraints is not None else SizeConstraints() + + # Measurement state + self._is_measured = False + self._intrinsic_size: Optional[Tuple[int, int]] = None + self._min_width_cache: Optional[int] = None + self._preferred_width_cache: Optional[int] = None + self._content_height_cache: Optional[int] = None + + # Pagination state + self._render_offset = 0 # For partial rendering (pagination) + self._is_laid_out = False + + @property + def constraints(self) -> SizeConstraints: + """Get the size constraints for this page.""" + return self._constraints + + def measure(self, available_width: Optional[int] = None) -> Tuple[int, int]: + """ + Measure the intrinsic size needed for content. + + This walks through all children and calculates how much space they need. + The measurement respects constraints (min/max width/height). + + Args: + available_width: Optional width constraint for wrapping content + + Returns: + Tuple of (width, height) needed + """ + if self._is_measured and self._intrinsic_size is not None: + return self._intrinsic_size + + # Apply constraints to available width + if available_width is not None: + if self._constraints.max_width is not None: + available_width = min(available_width, self._constraints.max_width) + if self._constraints.min_width is not None: + available_width = max(available_width, self._constraints.min_width) + + # Measure content + # For now, walk through children and sum their sizes + total_width = 0 + total_height = 0 + + for child in self._children: + if hasattr(child, 'measure'): + # Child is also dynamic - ask it to measure + child_size = child.measure(available_width) + child_width, child_height = child_size + else: + # Child has fixed size + child_width = child.size[0] if hasattr(child, 'size') else 0 + child_height = child.size[1] if hasattr(child, 'size') else 0 + + total_width = max(total_width, child_width) + total_height += child_height + + # Add page padding/borders + total_width += self._style.total_horizontal_padding + self._style.total_border_width + total_height += self._style.total_vertical_padding + self._style.total_border_width + + # Apply constraints + if self._constraints.min_width is not None: + total_width = max(total_width, self._constraints.min_width) + if self._constraints.max_width is not None: + total_width = min(total_width, self._constraints.max_width) + if self._constraints.min_height is not None: + total_height = max(total_height, self._constraints.min_height) + if self._constraints.max_height is not None: + total_height = min(total_height, self._constraints.max_height) + + self._intrinsic_size = (total_width, total_height) + self._is_measured = True + + return self._intrinsic_size + + def get_min_width(self) -> int: + """ + Get minimum width needed to render content. + + This finds the widest word/element that cannot be broken, + using Font.min_hyphenation_width for hyphenation control. + + Returns: + Minimum width in pixels + """ + # Check cache + if self._min_width_cache is not None: + return self._min_width_cache + + # Calculate minimum width based on content + from pyWebLayout.concrete.text import Line, Text + + min_width = 0 + + # Walk through children and find longest unbreakable segment + for child in self._children: + if isinstance(child, Line): + # Check all words in the line + # Font's min_hyphenation_width already controls breaking + for text_obj in getattr(child, '_text_objects', []): + if isinstance(text_obj, Text) and hasattr(text_obj, '_text'): + word_text = text_obj._text + # Text stores font in _style, not _font + font = getattr(text_obj, '_style', None) + + if font: + # Just measure the word - Font handles hyphenation rules + word_width = int(font.font.getlength(word_text)) + min_width = max(min_width, word_width) + elif hasattr(child, 'get_min_width'): + # Child supports min width calculation + child_min = child.get_min_width() + min_width = max(min_width, child_min) + elif hasattr(child, 'size'): + # Use actual width + min_width = max(min_width, child.size[0]) + + # Add padding/borders + min_width += self._style.total_horizontal_padding + self._style.total_border_width + + # Apply minimum constraint + if self._constraints.min_width is not None: + min_width = max(min_width, self._constraints.min_width) + + self._min_width_cache = min_width + return min_width + + def get_preferred_width(self) -> int: + """ + Get preferred width (no wrapping). + + This returns the width needed to render all content without any + line wrapping. + + Returns: + Preferred width in pixels + """ + # Check cache + if self._preferred_width_cache is not None: + return self._preferred_width_cache + + # Calculate preferred width (no wrapping) + from pyWebLayout.concrete.text import Line + + pref_width = 0 + + for child in self._children: + if isinstance(child, Line): + # Get line width without wrapping (including spacing between words) + text_objects = getattr(child, '_text_objects', []) + if text_objects: + line_width = 0 + for i, text_obj in enumerate(text_objects): + if hasattr(text_obj, '_text') and hasattr(text_obj, '_style'): + # Text stores font in _style, not _font + word_width = text_obj._style.font.getlength(text_obj._text) + line_width += word_width + + # Add spacing after word (except last word) + if i < len(text_objects) - 1: + # Get spacing from Line if available, otherwise use default + spacing = getattr(child, '_spacing', (3, 6)) + # Use minimum spacing for preferred width calculation + line_width += spacing[0] if isinstance(spacing, tuple) else 3 + + pref_width = max(pref_width, line_width) + elif hasattr(child, 'get_preferred_width'): + child_pref = child.get_preferred_width() + pref_width = max(pref_width, child_pref) + elif hasattr(child, 'size'): + # Use actual size + pref_width = max(pref_width, child.size[0]) + + # Add padding/borders + pref_width += self._style.total_horizontal_padding + self._style.total_border_width + + # Apply constraints + if self._constraints.max_width is not None: + pref_width = min(pref_width, self._constraints.max_width) + if self._constraints.min_width is not None: + pref_width = max(pref_width, self._constraints.min_width) + + self._preferred_width_cache = pref_width + return pref_width + + def measure_content_height(self) -> int: + """ + Measure total height needed to render all content. + + This is used for pagination to know how much content remains. + + Returns: + Total height in pixels + """ + # Check cache + if self._content_height_cache is not None: + return self._content_height_cache + + total_height = 0 + + for child in self._children: + if hasattr(child, 'measure_content_height'): + child_height = child.measure_content_height() + elif hasattr(child, 'size'): + child_height = child.size[1] + else: + child_height = 0 + + total_height += child_height + + # Add padding/borders + total_height += self._style.total_vertical_padding + self._style.total_border_width + + self._content_height_cache = total_height + return total_height + + def layout(self, size: Tuple[int, int]): + """ + Layout content within the given size. + + This is called after measurement to position children within + the allocated space. + + Args: + size: The final size allocated to this page (width, height) + """ + # Set the page size + self._size = size + + # Position children sequentially + # Use the same logic as Page but now we know our final size + content_x = self._style.border_width + self._style.padding_left + content_y = self._style.border_width + self._style.padding_top + + self._current_y_offset = content_y + self._is_first_line = True + + # Children position themselves, we just track y_offset + # The actual positioning happens when children render + + self._is_laid_out = True + self._dirty = True # Mark for re-render + + def render(self) -> Image.Image: + """ + Render the page with all its children. + + If not yet measured/laid out, use intrinsic sizing. + + Returns: + PIL Image containing the rendered page + """ + # Ensure we have a valid size + if self._size[0] == 0 or self._size[1] == 0: + if not self._is_measured: + # Auto-measure with no constraints + self.measure() + + if self._intrinsic_size: + self._size = self._intrinsic_size + else: + # Fallback to minimum size + self._size = (100, 100) + + # Use parent's render implementation + return super().render() + + # Pagination Support + # ------------------ + + def render_partial(self, available_height: int) -> int: + """ + Render as much content as fits in available_height. + + This is used for pagination when a page needs to be split across + multiple output pages. + + Args: + available_height: Height available on current page + + Returns: + Amount of content rendered (in pixels) + """ + # Calculate how many children fit in available height + rendered_height = 0 + content_start_y = self._style.border_width + self._style.padding_top + + for i, child in enumerate(self._children): + # Skip already rendered children + if rendered_height < self._render_offset: + if hasattr(child, 'size'): + rendered_height += child.size[1] + continue + + # Check if this child fits + child_height = child.size[1] if hasattr(child, 'size') else 0 + + if rendered_height + child_height <= available_height: + # Child fits - render it + if hasattr(child, 'render'): + child.render() + rendered_height += child_height + else: + # No more space + break + + # Update render offset for next call + self._render_offset = rendered_height + + return rendered_height + + def has_more_content(self) -> bool: + """ + Check if there's unrendered content remaining. + + Returns: + True if more content needs to be rendered + """ + total_height = self.measure_content_height() + return self._render_offset < total_height + + def reset_pagination(self): + """Reset pagination to render from beginning.""" + self._render_offset = 0 + + def invalidate_caches(self): + """Invalidate all measurement caches (call when children change).""" + self._is_measured = False + self._intrinsic_size = None + self._min_width_cache = None + self._preferred_width_cache = None + self._content_height_cache = None + self._is_laid_out = False + + def add_child(self, child: Renderable) -> 'DynamicPage': + """ + Add a child and invalidate caches. + + Args: + child: The renderable object to add + + Returns: + Self for method chaining + """ + super().add_child(child) + self.invalidate_caches() + return self + + def clear_children(self) -> 'DynamicPage': + """ + Remove all children and invalidate caches. + + Returns: + Self for method chaining + """ + super().clear_children() + self.invalidate_caches() + return self diff --git a/pyWebLayout/concrete/table.py b/pyWebLayout/concrete/table.py index 2f4427c..b01517d 100644 --- a/pyWebLayout/concrete/table.py +++ b/pyWebLayout/concrete/table.py @@ -452,27 +452,31 @@ class TableRenderer(Box): """ 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) """ - # 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 + from pyWebLayout.layout.table_optimizer import optimize_table_layout - if num_columns == 0: + all_rows = list(self._table.all_rows()) + + if not all_rows: 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 + # Use optimizer for column widths! + column_widths = optimize_table_layout( + self._table, + self._available_width, + sample_size=5, + style=self._style + ) - # Calculate row heights dynamically based on content + 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 diff --git a/pyWebLayout/layout/table_optimizer.py b/pyWebLayout/layout/table_optimizer.py new file mode 100644 index 0000000..ba4fd5c --- /dev/null +++ b/pyWebLayout/layout/table_optimizer.py @@ -0,0 +1,396 @@ +""" +Table column width optimization for pyWebLayout. + +This module provides intelligent column width distribution for tables, +ensuring optimal space usage while respecting content constraints. +""" + +from typing import List, Tuple, Optional, Dict +from pyWebLayout.abstract.block import Table, TableRow + + +def optimize_table_layout(table: Table, + available_width: int, + sample_size: int = 5, + style=None) -> List[int]: + """ + Optimize column widths for a table. + + Strategy: + 1. Check for HTML width overrides (colspan, width attributes) + 2. Sample first ~5 rows to estimate column requirements (performance) + 3. Calculate minimum width for each column (longest unbreakable word) + 4. Calculate preferred width for each column (no wrapping) + 5. If total preferred fits: use preferred + 6. Otherwise: distribute available space proportionally + 7. Ensure no column < min_width + + Note: Hyphenation threshold is controlled by Font.min_hyphenation_width, + not passed as a parameter here to avoid duplication. + + Args: + table: The table to optimize + available_width: Total width available + sample_size: Number of rows to sample for measurement (default 5) + style: Optional table style for border/padding calculations + + Returns: + List of optimized column widths + """ + from pyWebLayout.concrete.dynamic_page import DynamicPage + + n_cols = get_column_count(table) + if n_cols == 0: + return [] + + # Account for table borders/padding overhead + if style: + overhead = calculate_table_overhead(n_cols, style) + available_for_content = available_width - overhead + else: + # Default border overhead + border_width = 1 + overhead = border_width * (n_cols + 1) + available_for_content = available_width - overhead + + # Phase 0: Check for HTML width overrides + html_widths = extract_html_column_widths(table) + fixed_columns = {i: width for i, width in enumerate(html_widths) if width is not None} + + # Phase 1: Sample rows and measure constraints for each column + min_widths = [] # Minimum without breaking words (Font handles hyphenation) + pref_widths = [] # Preferred (no wrapping) + + # Sample first ~5 rows from each section (header, body, footer) + sampled_rows = sample_table_rows(table, sample_size) + + for col_idx in range(n_cols): + # Check if this column has HTML width override + if col_idx in fixed_columns: + fixed_width = fixed_columns[col_idx] + min_widths.append(fixed_width) + pref_widths.append(fixed_width) + continue + + col_min = 50 # Absolute minimum + col_pref = 50 + + # Check sampled cells in this column + for row in sampled_rows: + cells = list(row.cells()) + if col_idx >= len(cells): + continue + + cell = cells[col_idx] + + # Create a DynamicPage for this cell with no padding/borders + # (we're just measuring content, not rendering a full page) + from pyWebLayout.style.page_style import PageStyle + measurement_style = PageStyle(padding=(0, 0, 0, 0), border_width=0) + cell_page = DynamicPage(style=measurement_style) + + # Add cell content to page + layout_cell_content(cell_page, cell) + + # Measure minimum width (Font's min_hyphenation_width controls breaking) + # DynamicPage returns pure content width (no padding since we set it to 0) + # TableRenderer will add cell padding later + cell_min = cell_page.get_min_width() + col_min = max(col_min, cell_min) + + # Measure preferred width (no wrapping) + cell_pref = cell_page.get_preferred_width() + col_pref = max(col_pref, cell_pref) + + min_widths.append(col_min) + pref_widths.append(col_pref) + + # Phase 2: Distribute width (respecting fixed columns) + return distribute_column_widths( + min_widths, + pref_widths, + available_for_content, + fixed_columns + ) + + +def layout_cell_content(page, cell): + """ + Layout cell content onto a DynamicPage. + + This adds all blocks from the cell (paragraphs, images, etc.) + as children of the page so they can be measured. + + Args: + page: DynamicPage to add content to + cell: TableCell containing blocks + """ + from pyWebLayout.concrete.text import Line, Text + from pyWebLayout.style.fonts import Font + from pyWebLayout.style import FontWeight, Alignment + from pyWebLayout.abstract.block import Paragraph, Heading + from PIL import Image as PILImage, ImageDraw + + # Default font for measurement + font_size = 12 + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" + font = Font(font_path=font_path, font_size=font_size) + + # Create a minimal draw context for Text measurement + # (Text needs this for width calculation) + dummy_img = PILImage.new('RGB', (1, 1)) + dummy_draw = ImageDraw.Draw(dummy_img) + + # Get all blocks from the cell + for block in cell.blocks(): + if isinstance(block, (Paragraph, Heading)): + # Get words from the block + word_items = block.words() if callable(block.words) else block.words + words = list(word_items) + + if not words: + continue + + # Create a line for measurement + line = Line( + spacing=(3, 6), # word spacing + origin=(0, 0), + size=(1000, 20), # Large size for measurement + draw=dummy_draw, + font=font, + halign=Alignment.LEFT + ) + + # Add all words to estimate width + 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 Text object for the word + # Text constructor: (text, style, draw) + text_obj = Text( + text=word_text, + style=font, # Font is the style + draw=dummy_draw + ) + + line._text_objects.append(text_obj) + + # Add line to page + page.add_child(line) + + +def get_column_count(table: Table) -> int: + """ + Get the number of columns in a table. + + Args: + table: The table to analyze + + Returns: + Number of columns + """ + all_rows = list(table.all_rows()) + if not all_rows: + return 0 + + # Get from first row + first_row = all_rows[0][1] + return first_row.cell_count + + +def sample_table_rows(table: Table, sample_size: int) -> List[TableRow]: + """ + Sample first ~sample_size rows from each table section. + + Args: + table: The table to sample + sample_size: Number of rows to sample per section + + Returns: + List of sampled rows + """ + sampled = [] + + for section in ["header", "body", "footer"]: + section_rows = [row for sec, row in table.all_rows() if sec == section] + # Take first sample_size rows (or fewer if section is smaller) + sampled.extend(section_rows[:sample_size]) + + return sampled + + +def extract_html_column_widths(table: Table) -> List[Optional[int]]: + """ + Extract column width overrides from HTML attributes. + + Checks for: + -