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.
|
Calculate column widths and row heights for the table.
|
||||||
|
|
||||||
|
Uses the table optimizer for intelligent column width distribution.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (column_widths, row_heights_dict)
|
Tuple of (column_widths, row_heights_dict)
|
||||||
"""
|
"""
|
||||||
# Determine number of columns (from first row)
|
from pyWebLayout.layout.table_optimizer import optimize_table_layout
|
||||||
num_columns = 0
|
|
||||||
all_rows = list(self._table.all_rows())
|
|
||||||
if all_rows:
|
|
||||||
first_row = all_rows[0][1]
|
|
||||||
num_columns = first_row.cell_count
|
|
||||||
|
|
||||||
if num_columns == 0:
|
all_rows = list(self._table.all_rows())
|
||||||
|
|
||||||
|
if not all_rows:
|
||||||
return ([100], {"header": 30, "body": 30, "footer": 30})
|
return ([100], {"header": 30, "body": 30, "footer": 30})
|
||||||
|
|
||||||
# Calculate column widths (equal distribution for now)
|
# Use optimizer for column widths!
|
||||||
# Account for borders between columns
|
column_widths = optimize_table_layout(
|
||||||
total_border_width = self._style.border_width * (num_columns + 1)
|
self._table,
|
||||||
available_for_columns = self._available_width - total_border_width
|
self._available_width,
|
||||||
column_width = max(50, available_for_columns // num_columns)
|
sample_size=5,
|
||||||
column_widths = [column_width] * num_columns
|
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(
|
header_height = self._calculate_row_height_for_section(
|
||||||
all_rows, "header", column_widths) if any(
|
all_rows, "header", column_widths) if any(
|
||||||
1 for section, _ in all_rows if section == "header") else 0
|
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'])
|
||||||