Tables now use ""dynamic page" allowing the contents to be anything that can be rendered ion a page.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 118 KiB |
BIN
docs/images/example_12_optimized_table_layout.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
docs/images/example_13_table_pagination.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
docs/images/example_14_interactive_table.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
254
examples/12_optimized_table_layout_demo.py
Normal file
@ -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()
|
||||
291
examples/13_table_pagination_demo.py
Normal file
@ -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()
|
||||
312
examples/14_interactive_table.py
Normal file
@ -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()
|
||||
418
pyWebLayout/concrete/dynamic_page.py
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
396
pyWebLayout/layout/table_optimizer.py
Normal file
@ -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:
|
||||
- <col width="100px"> elements
|
||||
- <td width="100px"> in first row
|
||||
- <th width="100px"> in header
|
||||
|
||||
Args:
|
||||
table: The table to check
|
||||
|
||||
Returns:
|
||||
List of widths (None for auto-layout columns)
|
||||
"""
|
||||
n_cols = get_column_count(table)
|
||||
widths = [None] * n_cols
|
||||
|
||||
# Check for <col> elements with width
|
||||
if hasattr(table, 'col_widths'):
|
||||
for i, width in enumerate(table.col_widths):
|
||||
if width is not None:
|
||||
widths[i] = parse_html_width(width)
|
||||
|
||||
# Check first row cells for width attributes
|
||||
all_rows = list(table.all_rows())
|
||||
if all_rows:
|
||||
first_row = all_rows[0][1]
|
||||
cells = list(first_row.cells())
|
||||
for i, cell in enumerate(cells):
|
||||
if i < len(widths) and hasattr(cell, 'width') and cell.width is not None:
|
||||
widths[i] = parse_html_width(cell.width)
|
||||
|
||||
return widths
|
||||
|
||||
|
||||
def parse_html_width(width_value) -> Optional[int]:
|
||||
"""
|
||||
Parse HTML width value (e.g., "100px", "20%", "100").
|
||||
|
||||
Args:
|
||||
width_value: HTML width attribute value
|
||||
|
||||
Returns:
|
||||
Width in pixels, or None if percentage/invalid
|
||||
"""
|
||||
if isinstance(width_value, int):
|
||||
return width_value
|
||||
|
||||
if isinstance(width_value, str):
|
||||
# Remove whitespace
|
||||
width_value = width_value.strip()
|
||||
|
||||
# Percentage widths not supported yet
|
||||
if '%' in width_value:
|
||||
return None
|
||||
|
||||
# Parse pixel values
|
||||
if width_value.endswith('px'):
|
||||
try:
|
||||
return int(width_value[:-2])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Plain number
|
||||
try:
|
||||
return int(width_value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def distribute_column_widths(min_widths: List[int],
|
||||
pref_widths: List[int],
|
||||
available_width: int,
|
||||
fixed_columns: Dict[int, int]) -> List[int]:
|
||||
"""
|
||||
Distribute width among columns, respecting fixed column widths.
|
||||
|
||||
Args:
|
||||
min_widths: Minimum width for each column
|
||||
pref_widths: Preferred width for each column
|
||||
available_width: Total width available
|
||||
fixed_columns: Dict mapping column index to fixed width
|
||||
|
||||
Returns:
|
||||
List of final column widths
|
||||
"""
|
||||
n_cols = len(min_widths)
|
||||
if n_cols == 0:
|
||||
return []
|
||||
|
||||
# Calculate available space for flexible columns
|
||||
fixed_total = sum(fixed_columns.values())
|
||||
flexible_available = available_width - fixed_total
|
||||
|
||||
# Get indices of flexible columns
|
||||
flexible_cols = [i for i in range(n_cols) if i not in fixed_columns]
|
||||
|
||||
if not flexible_cols:
|
||||
# All columns fixed - return as-is
|
||||
return [fixed_columns.get(i, min_widths[i]) for i in range(n_cols)]
|
||||
|
||||
# Calculate totals for flexible columns only
|
||||
flex_min_total = sum(min_widths[i] for i in flexible_cols)
|
||||
flex_pref_total = sum(pref_widths[i] for i in flexible_cols)
|
||||
|
||||
# Distribute space among flexible columns
|
||||
widths = [0] * n_cols
|
||||
|
||||
# Set fixed columns
|
||||
for i, width in fixed_columns.items():
|
||||
widths[i] = width
|
||||
|
||||
# Distribute to flexible columns
|
||||
if flex_pref_total <= flexible_available:
|
||||
# Preferred widths fit - distribute remaining space proportionally
|
||||
extra_space = flexible_available - flex_pref_total
|
||||
|
||||
if extra_space > 0 and flex_pref_total > 0:
|
||||
# Distribute extra space proportionally based on preferred widths
|
||||
for i in flexible_cols:
|
||||
proportion = pref_widths[i] / flex_pref_total
|
||||
widths[i] = int(pref_widths[i] + (extra_space * proportion))
|
||||
else:
|
||||
# No extra space, just use preferred widths
|
||||
for i in flexible_cols:
|
||||
widths[i] = pref_widths[i]
|
||||
elif flex_min_total > flexible_available:
|
||||
# Can't satisfy minimum - force it anyway (graceful degradation)
|
||||
for i in flexible_cols:
|
||||
widths[i] = min_widths[i]
|
||||
else:
|
||||
# Proportional distribution between min and pref
|
||||
extra_space = flexible_available - flex_min_total
|
||||
flex_pref_over_min = flex_pref_total - flex_min_total
|
||||
|
||||
for i in flexible_cols:
|
||||
if flex_pref_over_min > 0:
|
||||
pref_over_min = pref_widths[i] - min_widths[i]
|
||||
proportion = pref_over_min / flex_pref_over_min
|
||||
extra = extra_space * proportion
|
||||
widths[i] = int(min_widths[i] + extra)
|
||||
else:
|
||||
widths[i] = int(min_widths[i])
|
||||
|
||||
return widths
|
||||
|
||||
|
||||
def calculate_table_overhead(n_cols: int, style) -> int:
|
||||
"""
|
||||
Calculate the pixel overhead for table borders and spacing.
|
||||
|
||||
Args:
|
||||
n_cols: Number of columns
|
||||
style: TableStyle object
|
||||
|
||||
Returns:
|
||||
Total pixel overhead
|
||||
"""
|
||||
# Border on each side of each column + outer borders
|
||||
border_overhead = style.border_width * (n_cols + 1)
|
||||
|
||||
# Cell spacing if any
|
||||
spacing_overhead = style.cell_spacing * (n_cols - 1) if n_cols > 1 else 0
|
||||
|
||||
return border_overhead + spacing_overhead
|
||||
313
tests/concrete/test_dynamic_page.py
Normal file
@ -0,0 +1,313 @@
|
||||
"""
|
||||
Unit tests for DynamicPage class.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from pyWebLayout.concrete.dynamic_page import DynamicPage, SizeConstraints
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
class TestSizeConstraints:
|
||||
"""Test SizeConstraints dataclass."""
|
||||
|
||||
def test_default_constraints(self):
|
||||
"""Test default constraint values."""
|
||||
constraints = SizeConstraints()
|
||||
assert constraints.min_width is None
|
||||
assert constraints.max_width is None
|
||||
assert constraints.min_height is None
|
||||
assert constraints.max_height is None
|
||||
|
||||
def test_custom_constraints(self):
|
||||
"""Test custom constraint values."""
|
||||
constraints = SizeConstraints(
|
||||
min_width=100,
|
||||
max_width=500,
|
||||
min_height=50,
|
||||
max_height=1000
|
||||
)
|
||||
assert constraints.min_width == 100
|
||||
assert constraints.max_width == 500
|
||||
assert constraints.min_height == 50
|
||||
assert constraints.max_height == 1000
|
||||
|
||||
|
||||
class TestDynamicPage:
|
||||
"""Test DynamicPage class."""
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test DynamicPage initialization."""
|
||||
page = DynamicPage()
|
||||
|
||||
assert page.size == (0, 0) # Starts with zero size
|
||||
assert not page._is_measured
|
||||
assert not page._is_laid_out
|
||||
assert page._render_offset == 0
|
||||
assert page.constraints is not None
|
||||
|
||||
def test_initialization_with_constraints(self):
|
||||
"""Test initialization with custom constraints."""
|
||||
constraints = SizeConstraints(min_width=200, max_width=800)
|
||||
page = DynamicPage(constraints=constraints)
|
||||
|
||||
assert page.constraints.min_width == 200
|
||||
assert page.constraints.max_width == 800
|
||||
|
||||
def test_initialization_with_style(self):
|
||||
"""Test initialization with custom style."""
|
||||
style = PageStyle(border_width=2, padding=(10, 20, 10, 20))
|
||||
page = DynamicPage(style=style)
|
||||
|
||||
assert page.style.border_width == 2
|
||||
assert page.style.padding_top == 10
|
||||
|
||||
def test_measure_empty_page(self):
|
||||
"""Test measuring an empty page."""
|
||||
page = DynamicPage()
|
||||
width, height = page.measure()
|
||||
|
||||
# Empty page should have minimal size (just padding/borders)
|
||||
assert width > 0 # At least padding/borders
|
||||
assert height > 0
|
||||
assert page._is_measured
|
||||
|
||||
def test_measure_with_constraints(self):
|
||||
"""Test measuring respects constraints."""
|
||||
constraints = SizeConstraints(min_width=300, min_height=200)
|
||||
page = DynamicPage(constraints=constraints)
|
||||
|
||||
width, height = page.measure()
|
||||
|
||||
assert width >= 300
|
||||
assert height >= 200
|
||||
|
||||
def test_measure_caching(self):
|
||||
"""Test that measurement is cached."""
|
||||
page = DynamicPage()
|
||||
|
||||
# First measurement
|
||||
size1 = page.measure()
|
||||
|
||||
# Second measurement should return cached value
|
||||
size2 = page.measure()
|
||||
|
||||
assert size1 == size2
|
||||
assert page._is_measured
|
||||
|
||||
def test_get_min_width(self):
|
||||
"""Test get_min_width."""
|
||||
page = DynamicPage()
|
||||
min_width = page.get_min_width()
|
||||
|
||||
assert min_width > 0
|
||||
assert isinstance(min_width, int)
|
||||
|
||||
def test_get_preferred_width(self):
|
||||
"""Test get_preferred_width."""
|
||||
page = DynamicPage()
|
||||
pref_width = page.get_preferred_width()
|
||||
|
||||
assert pref_width > 0
|
||||
assert isinstance(pref_width, int)
|
||||
|
||||
def test_measure_content_height(self):
|
||||
"""Test measure_content_height."""
|
||||
page = DynamicPage()
|
||||
content_height = page.measure_content_height()
|
||||
|
||||
assert content_height > 0
|
||||
assert isinstance(content_height, int)
|
||||
|
||||
def test_layout(self):
|
||||
"""Test layout method."""
|
||||
page = DynamicPage()
|
||||
target_size = (400, 600)
|
||||
|
||||
page.layout(target_size)
|
||||
|
||||
assert page.size == target_size
|
||||
assert page._is_laid_out
|
||||
assert page._dirty # Should be marked for re-render
|
||||
|
||||
def test_render_without_layout(self):
|
||||
"""Test rendering without explicit layout (auto-sizing)."""
|
||||
page = DynamicPage()
|
||||
image = page.render()
|
||||
|
||||
assert isinstance(image, Image.Image)
|
||||
assert image.size[0] > 0
|
||||
assert image.size[1] > 0
|
||||
|
||||
def test_render_with_layout(self):
|
||||
"""Test rendering after explicit layout."""
|
||||
page = DynamicPage()
|
||||
page.layout((500, 700))
|
||||
|
||||
image = page.render()
|
||||
|
||||
assert isinstance(image, Image.Image)
|
||||
assert image.size == (500, 700)
|
||||
|
||||
def test_add_child_invalidates_cache(self):
|
||||
"""Test that adding a child invalidates measurement caches."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Measure to populate cache
|
||||
page.measure()
|
||||
assert page._is_measured
|
||||
|
||||
# Add a child (mock renderable)
|
||||
class MockRenderable:
|
||||
def __init__(self):
|
||||
self.size = (100, 50)
|
||||
self._origin = (0, 0)
|
||||
|
||||
@property
|
||||
def origin(self):
|
||||
return self._origin
|
||||
|
||||
def render(self):
|
||||
pass
|
||||
|
||||
page.add_child(MockRenderable())
|
||||
|
||||
# Caches should be invalidated
|
||||
assert not page._is_measured
|
||||
assert page._intrinsic_size is None
|
||||
|
||||
def test_clear_children_invalidates_cache(self):
|
||||
"""Test that clearing children invalidates caches."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Measure to populate cache
|
||||
page.measure()
|
||||
assert page._is_measured
|
||||
|
||||
# Clear children
|
||||
page.clear_children()
|
||||
|
||||
# Caches should be invalidated
|
||||
assert not page._is_measured
|
||||
|
||||
def test_pagination_reset(self):
|
||||
"""Test pagination reset."""
|
||||
page = DynamicPage()
|
||||
page._render_offset = 100
|
||||
|
||||
page.reset_pagination()
|
||||
|
||||
assert page._render_offset == 0
|
||||
|
||||
def test_has_more_content_false(self):
|
||||
"""Test has_more_content when all content is rendered."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Set render offset to total height
|
||||
total_height = page.measure_content_height()
|
||||
page._render_offset = total_height
|
||||
|
||||
assert not page.has_more_content()
|
||||
|
||||
def test_has_more_content_true(self):
|
||||
"""Test has_more_content when content remains."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Offset is less than total
|
||||
page._render_offset = 0
|
||||
|
||||
assert page.has_more_content()
|
||||
|
||||
def test_min_width_measurement(self):
|
||||
"""Test min width measures longest word."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Min width should be at least padding/borders
|
||||
min_width = page.get_min_width()
|
||||
assert min_width > 0
|
||||
|
||||
def test_invalidate_caches(self):
|
||||
"""Test cache invalidation."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Populate caches
|
||||
page.measure()
|
||||
page.get_min_width()
|
||||
page.get_preferred_width()
|
||||
page.measure_content_height()
|
||||
|
||||
assert page._is_measured
|
||||
assert page._intrinsic_size is not None
|
||||
assert page._min_width_cache is not None
|
||||
assert page._preferred_width_cache is not None
|
||||
assert page._content_height_cache is not None
|
||||
|
||||
# Invalidate
|
||||
page.invalidate_caches()
|
||||
|
||||
assert not page._is_measured
|
||||
assert page._intrinsic_size is None
|
||||
assert page._min_width_cache is None
|
||||
assert page._preferred_width_cache is None
|
||||
assert page._content_height_cache is None
|
||||
assert not page._is_laid_out
|
||||
|
||||
def test_measure_with_available_width(self):
|
||||
"""Test measurement with available_width constraint."""
|
||||
page = DynamicPage()
|
||||
|
||||
width, height = page.measure(available_width=300)
|
||||
|
||||
# Width should respect available_width
|
||||
assert width <= 300
|
||||
|
||||
def test_constraints_override_available_width(self):
|
||||
"""Test that constraints override available_width."""
|
||||
constraints = SizeConstraints(min_width=400)
|
||||
page = DynamicPage(constraints=constraints)
|
||||
|
||||
width, height = page.measure(available_width=300)
|
||||
|
||||
# Should use min_width constraint, not available_width
|
||||
assert width >= 400
|
||||
|
||||
def test_render_partial_empty_page(self):
|
||||
"""Test partial rendering on empty page."""
|
||||
page = DynamicPage()
|
||||
|
||||
rendered = page.render_partial(available_height=100)
|
||||
|
||||
assert rendered >= 0
|
||||
assert isinstance(rendered, int)
|
||||
|
||||
def test_method_chaining_add_child(self):
|
||||
"""Test that add_child returns self for chaining."""
|
||||
page = DynamicPage()
|
||||
|
||||
class MockRenderable:
|
||||
def __init__(self):
|
||||
self.size = (50, 50)
|
||||
self._origin = (0, 0)
|
||||
|
||||
@property
|
||||
def origin(self):
|
||||
return self._origin
|
||||
|
||||
result = page.add_child(MockRenderable())
|
||||
|
||||
assert result is page
|
||||
|
||||
def test_method_chaining_clear_children(self):
|
||||
"""Test that clear_children returns self for chaining."""
|
||||
page = DynamicPage()
|
||||
|
||||
result = page.clear_children()
|
||||
|
||||
assert result is page
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
397
tests/layout/test_table_optimizer.py
Normal file
@ -0,0 +1,397 @@
|
||||
"""
|
||||
Unit tests for table column width optimization.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pyWebLayout.layout.table_optimizer import (
|
||||
optimize_table_layout,
|
||||
sample_table_rows,
|
||||
extract_html_column_widths,
|
||||
parse_html_width,
|
||||
distribute_column_widths,
|
||||
get_column_count,
|
||||
calculate_table_overhead
|
||||
)
|
||||
from pyWebLayout.abstract.block import Table
|
||||
from pyWebLayout.concrete.table import TableStyle
|
||||
|
||||
|
||||
class TestParseHtmlWidth:
|
||||
"""Test HTML width parsing."""
|
||||
|
||||
def test_parse_int(self):
|
||||
assert parse_html_width(100) == 100
|
||||
|
||||
def test_parse_px_string(self):
|
||||
assert parse_html_width("150px") == 150
|
||||
|
||||
def test_parse_plain_number_string(self):
|
||||
assert parse_html_width("200") == 200
|
||||
|
||||
def test_parse_percentage_returns_none(self):
|
||||
assert parse_html_width("50%") is None
|
||||
|
||||
def test_parse_invalid_string(self):
|
||||
assert parse_html_width("invalid") is None
|
||||
|
||||
def test_parse_with_whitespace(self):
|
||||
assert parse_html_width(" 120px ") == 120
|
||||
|
||||
|
||||
class TestDistributeColumnWidths:
|
||||
"""Test column width distribution."""
|
||||
|
||||
def test_distribute_with_no_fixed_columns(self):
|
||||
min_widths = [50, 60, 70]
|
||||
pref_widths = [100, 120, 140]
|
||||
available = 360
|
||||
fixed = {}
|
||||
|
||||
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||
|
||||
# Should use preferred widths (they fit)
|
||||
assert result == [100, 120, 140]
|
||||
|
||||
def test_distribute_when_preferred_fits(self):
|
||||
min_widths = [50, 50]
|
||||
pref_widths = [100, 100]
|
||||
available = 250
|
||||
fixed = {}
|
||||
|
||||
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||
|
||||
# Preferred widths fit, extra 50px distributed proportionally (25px each)
|
||||
assert result == [125, 125]
|
||||
|
||||
def test_distribute_when_must_use_minimum(self):
|
||||
min_widths = [100, 100]
|
||||
pref_widths = [200, 200]
|
||||
available = 150
|
||||
fixed = {}
|
||||
|
||||
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||
|
||||
# Can't even fit minimum, but force it anyway
|
||||
assert result == [100, 100]
|
||||
|
||||
def test_distribute_proportional(self):
|
||||
min_widths = [50, 50]
|
||||
pref_widths = [200, 100]
|
||||
available = 200 # Between min and pref totals
|
||||
fixed = {}
|
||||
|
||||
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||
|
||||
# Should distribute proportionally
|
||||
assert len(result) == 2
|
||||
assert result[0] + result[1] == 200
|
||||
# First column should get more (higher pref)
|
||||
assert result[0] > result[1]
|
||||
|
||||
def test_distribute_with_fixed_columns(self):
|
||||
min_widths = [50, 50, 50]
|
||||
pref_widths = [100, 100, 100]
|
||||
available = 300
|
||||
fixed = {1: 80} # Second column fixed at 80
|
||||
|
||||
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||
|
||||
# Second column should be 80
|
||||
assert result[1] == 80
|
||||
# Other columns share remaining space
|
||||
assert result[0] + result[2] == 220
|
||||
|
||||
def test_distribute_all_fixed(self):
|
||||
min_widths = [50, 50]
|
||||
pref_widths = [100, 100]
|
||||
available = 300
|
||||
fixed = {0: 120, 1: 150}
|
||||
|
||||
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||
|
||||
assert result == [120, 150]
|
||||
|
||||
def test_distribute_empty(self):
|
||||
result = distribute_column_widths([], [], 100, {})
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestGetColumnCount:
|
||||
"""Test column counting."""
|
||||
|
||||
def test_empty_table(self):
|
||||
table = Table()
|
||||
assert get_column_count(table) == 0
|
||||
|
||||
def test_table_with_header(self):
|
||||
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph, Word
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
table = Table()
|
||||
font = Font(font_size=12)
|
||||
|
||||
row = TableRow()
|
||||
for text in ["A", "B", "C"]:
|
||||
cell = TableCell(is_header=True)
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(text, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
table.add_row(row, section="header")
|
||||
|
||||
assert get_column_count(table) == 3
|
||||
|
||||
def test_table_with_body(self):
|
||||
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph, Word
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
table = Table()
|
||||
font = Font(font_size=12)
|
||||
|
||||
row = TableRow()
|
||||
for text in ["1", "2"]:
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(text, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
table.add_row(row, section="body")
|
||||
|
||||
assert get_column_count(table) == 2
|
||||
|
||||
|
||||
class TestSampleTableRows:
|
||||
"""Test row sampling."""
|
||||
|
||||
def test_sample_small_table(self):
|
||||
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
table = Table()
|
||||
font = Font(font_size=12)
|
||||
|
||||
for text in ["1", "2"]:
|
||||
row = TableRow()
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(text, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
table.add_row(row, section="body")
|
||||
|
||||
sampled = sample_table_rows(table, sample_size=5)
|
||||
|
||||
# Should get all rows (only 2)
|
||||
assert len(sampled) == 2
|
||||
|
||||
def test_sample_large_table(self):
|
||||
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
table = Table()
|
||||
font = Font(font_size=12)
|
||||
|
||||
for i in range(20):
|
||||
row = TableRow()
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(str(i), font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
table.add_row(row, section="body")
|
||||
|
||||
sampled = sample_table_rows(table, sample_size=5)
|
||||
|
||||
# Should get only 5 body rows
|
||||
assert len(sampled) == 5
|
||||
|
||||
def test_sample_with_header_body_footer(self):
|
||||
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
table = Table()
|
||||
font = Font(font_size=12)
|
||||
|
||||
# 3 header rows
|
||||
for i in range(3):
|
||||
row = TableRow()
|
||||
cell = TableCell(is_header=True)
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(f"H{i}", font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
table.add_row(row, section="header")
|
||||
|
||||
# 10 body rows
|
||||
for i in range(10):
|
||||
row = TableRow()
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(f"B{i}", font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
table.add_row(row, section="body")
|
||||
|
||||
# 2 footer rows
|
||||
for i in range(2):
|
||||
row = TableRow()
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(f"F{i}", font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
table.add_row(row, section="footer")
|
||||
|
||||
sampled = sample_table_rows(table, sample_size=2)
|
||||
|
||||
# Should get 2 from each section = 6 total
|
||||
assert len(sampled) == 6
|
||||
|
||||
|
||||
class TestExtractHtmlColumnWidths:
|
||||
"""Test HTML width extraction."""
|
||||
|
||||
def test_no_widths(self):
|
||||
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
table = Table()
|
||||
font = Font(font_size=12)
|
||||
|
||||
row = TableRow()
|
||||
for text in ["A", "B"]:
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(text, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
table.add_row(row, section="body")
|
||||
|
||||
widths = extract_html_column_widths(table)
|
||||
|
||||
assert widths == [None, None]
|
||||
|
||||
def test_cell_width_attributes(self):
|
||||
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
table = Table()
|
||||
font = Font(font_size=12)
|
||||
|
||||
row = TableRow()
|
||||
|
||||
cell1 = TableCell()
|
||||
cell1.width = "100px"
|
||||
para1 = Paragraph(font)
|
||||
para1.add_word(Word("A", font))
|
||||
cell1.add_block(para1)
|
||||
row.add_cell(cell1)
|
||||
|
||||
cell2 = TableCell()
|
||||
cell2.width = "150"
|
||||
para2 = Paragraph(font)
|
||||
para2.add_word(Word("B", font))
|
||||
cell2.add_block(para2)
|
||||
row.add_cell(cell2)
|
||||
|
||||
table.add_row(row, section="body")
|
||||
|
||||
widths = extract_html_column_widths(table)
|
||||
|
||||
assert widths == [100, 150]
|
||||
|
||||
|
||||
class TestCalculateTableOverhead:
|
||||
"""Test table overhead calculation."""
|
||||
|
||||
def test_basic_overhead(self):
|
||||
style = TableStyle(border_width=1, cell_spacing=0)
|
||||
overhead = calculate_table_overhead(3, style)
|
||||
|
||||
# 3 columns = 4 borders (n+1)
|
||||
assert overhead == 4
|
||||
|
||||
def test_with_cell_spacing(self):
|
||||
style = TableStyle(border_width=1, cell_spacing=5)
|
||||
overhead = calculate_table_overhead(3, style)
|
||||
|
||||
# 4 borders + 2 spacings (n-1)
|
||||
assert overhead == 4 + 10
|
||||
|
||||
def test_thicker_borders(self):
|
||||
style = TableStyle(border_width=3, cell_spacing=0)
|
||||
overhead = calculate_table_overhead(2, style)
|
||||
|
||||
# 2 columns = 3 borders * 3px
|
||||
assert overhead == 9
|
||||
|
||||
|
||||
class TestOptimizeTableLayout:
|
||||
"""Test full table optimization."""
|
||||
|
||||
def test_optimize_simple_table(self):
|
||||
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
table = Table()
|
||||
font = Font(font_size=12)
|
||||
|
||||
row = TableRow()
|
||||
for text in ["Short", "A bit longer text"]:
|
||||
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")
|
||||
|
||||
style = TableStyle()
|
||||
widths = optimize_table_layout(table, available_width=400, style=style)
|
||||
|
||||
# Should return 2 column widths
|
||||
assert len(widths) == 2
|
||||
# Second column should be wider
|
||||
assert widths[1] > widths[0]
|
||||
|
||||
def test_optimize_empty_table(self):
|
||||
table = Table()
|
||||
|
||||
widths = optimize_table_layout(table, available_width=400)
|
||||
|
||||
assert widths == []
|
||||
|
||||
def test_optimize_respects_sample_size(self):
|
||||
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
table = Table()
|
||||
font = Font(font_size=12)
|
||||
|
||||
# Create 20 rows but only first 5 should be sampled
|
||||
for i in range(20):
|
||||
row = TableRow()
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(f"Data {i}", font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
table.add_row(row, section="body")
|
||||
|
||||
widths = optimize_table_layout(table, available_width=400, sample_size=5)
|
||||
|
||||
# Should return width for 1 column
|
||||
assert len(widths) == 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||