397 lines
13 KiB
Python
397 lines
13 KiB
Python
"""
|
|
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
|