Tables now use ""dynamic page" allowing the contents to be anything that can be rendered ion a page.
All checks were successful
Python CI / test (3.10) (push) Successful in 2m22s
Python CI / test (3.12) (push) Successful in 2m13s
Python CI / test (3.13) (push) Successful in 2m9s

This commit is contained in:
Duncan Tourolle 2025-11-11 18:10:47 +01:00
parent 889f27e1a3
commit 3bcd1bffb5
13 changed files with 2399 additions and 14 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View 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()

View 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()

View 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()

View 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

View File

@ -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

View 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

View 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'])

View 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'])