Tables now use ""dynamic page" allowing the contents to be anything that can be rendered ion a page.
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:
2025-11-11 18:10:47 +01:00
parent 889f27e1a3
commit 3bcd1bffb5
13 changed files with 2399 additions and 14 deletions
+396
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