Tables now use ""dynamic page" allowing the contents to be anything that can be rendered ion a page.
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user