cell height now dynamic in tables
All checks were successful
Python CI / test (3.10) (push) Successful in 2m16s
Python CI / test (3.12) (push) Successful in 2m8s
Python CI / test (3.13) (push) Successful in 2m2s

This commit is contained in:
Duncan Tourolle 2025-11-10 22:06:05 +01:00
parent 41dc904755
commit 9de67d958e
11 changed files with 322 additions and 62 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -371,6 +371,59 @@ def demo_performance_optimization():
print()
def create_animated_gif():
"""
Create an animated GIF showing the button press sequence.
"""
from PIL import Image
import os
print("=" * 70)
print("Creating Animated GIF")
print("=" * 70)
print()
# Check if the PNG files exist
png_files = [
"demo_07_initial.png",
"demo_07_pressed.png",
"demo_07_released.png"
]
if not all(os.path.exists(f) for f in png_files):
print(" ⚠ PNG files not found, skipping GIF creation")
return
# Load the images
initial = Image.open('demo_07_initial.png')
pressed = Image.open('demo_07_pressed.png')
released = Image.open('demo_07_released.png')
# Create animated GIF showing the button interaction sequence
# Sequence: initial (1000ms) -> pressed (200ms) -> released (500ms) -> loop
frames = [initial, pressed, released]
durations = [1000, 200, 500] # milliseconds per frame
output_path = "docs/images/example_07_button_animation.gif"
# Create docs/images directory if it doesn't exist
os.makedirs("docs/images", exist_ok=True)
# Save as animated GIF
initial.save(
output_path,
save_all=True,
append_images=[pressed, released],
duration=durations,
loop=0 # 0 means loop forever
)
print(f" ✓ Created: {output_path}")
print(f" ✓ Frames: {len(frames)}")
print(f" ✓ Sequence: initial (1000ms) → pressed (200ms) → released (500ms)")
print()
if __name__ == "__main__":
print("\n")
print("" + "" * 68 + "")
@ -391,6 +444,9 @@ if __name__ == "__main__":
demo_performance_optimization()
print("\n")
# Create animated GIF
create_animated_gif()
print("=" * 70)
print("All demos complete! Check the generated PNG files.")
print("All demos complete! Check the generated PNG files and animated GIF.")
print("=" * 70)

View File

@ -68,10 +68,10 @@ Demonstrates:
![Table Rendering Example](../docs/images/example_04_table_rendering.png)
### 05. Tables with Images
**`05_table_with_images.py`** - Tables containing images and mixed content
**`05_html_table_with_images.py`** - Tables containing images and mixed content
```bash
python 05_table_with_images.py
python 05_html_table_with_images.py
```
Demonstrates:
@ -80,8 +80,9 @@ Demonstrates:
- Book catalog and product showcase tables
- Mixed content (images and text) in cells
- Using cover images from test data
- HTML table parsing with `<img>` tags
![Table with Images Example](../docs/images/example_05_table_with_images.png)
![Table with Images Example](../docs/images/example_05_html_table_with_images.png)
### 06. Functional Elements (Interactive)
**`06_functional_elements_demo.py`** - Interactive buttons and forms with callbacks
@ -101,13 +102,47 @@ Demonstrates:
![Functional Elements Example](../docs/images/example_06_functional_elements.png)
### 07. Button Pressed States (Interactive)
**`07_pressed_state_demo.py`** - Visual feedback for button interactions
```bash
python 07_pressed_state_demo.py
```
Demonstrates:
- Button pressed/released state management
- Visual feedback timing (150ms press duration)
- Automatic interaction handling with `InteractionHandler`
- Manual state management for custom event loops
- Dirty flag system for optimized re-rendering
- State tracking with `InteractionStateManager`
![Button Pressed State Animation](../docs/images/example_07_button_animation.gif)
*Animated GIF showing button press sequence: initial → pressed → released*
---
## 🆕 New Examples (2024-11)
These examples address critical coverage gaps and demonstrate advanced features:
### 08. Pagination with PageBreak (NEW) ✅
### 08. Bundled Fonts Showcase
**`08_bundled_fonts_demo.py`** - Demonstration of all bundled fonts
```bash
python 08_bundled_fonts_demo.py
```
Demonstrates:
- DejaVu Sans (Sans-serif)
- DejaVu Serif (Serif)
- DejaVu Sans Mono (Monospace)
- All font variants: Regular, Bold, Italic, Bold Italic
![Bundled Fonts Example](../docs/images/demo_08_bundled_fonts.png)
### 08. Pagination with PageBreak ✅
**`08_pagination_demo.py`** - Multi-page documents with explicit and automatic pagination
```bash
@ -201,18 +236,6 @@ Demonstrates:
---
## Advanced Examples
### HTML Rendering
These examples demonstrate rendering HTML content to multi-page layouts:
**`html_line_breaking_demo.py`** - Basic HTML line breaking demonstration
**`html_multipage_simple.py`** - Simple single-page HTML rendering
**`html_multipage_demo_final.py`** - Complete multi-page HTML rendering with headers/footers
For detailed information about HTML rendering, see `README_HTML_MULTIPAGE.md`.
## Running the Examples
All examples can be run directly from the examples directory:
@ -220,24 +243,45 @@ All examples can be run directly from the examples directory:
```bash
cd examples
# Getting Started
python 01_simple_page_rendering.py
python 02_text_and_layout.py
python 03_page_layouts.py
python 04_table_rendering.py
python 05_table_with_images.py
python 06_functional_elements_demo.py
# Getting Started (01-07)
python 01_simple_page_rendering.py # Page layouts
python 02_text_and_layout.py # Text alignment with justified text
python 03_page_layouts.py # Various page sizes
python 04_table_rendering.py # Table styles
python 05_html_table_with_images.py # HTML tables with images
python 06_functional_elements_demo.py # Interactive buttons and forms
python 07_pressed_state_demo.py # Button pressed states (generates GIF)
# NEW: Advanced Features
# Advanced Features (08-11)
python 08_bundled_fonts_demo.py # Bundled font showcase
python 08_pagination_demo.py # Multi-page documents
python 09_link_navigation_demo.py # All link types
python 10_forms_demo.py # All form field types
python 11_table_text_wrapping_demo.py # Table cell text wrapping
python 11_table_text_wrapping_demo.py # Table text wrapping
python 11b_simple_table_wrapping.py # Simple wrapping demo
```
Output images are saved to the `docs/images/` directory.
## Recent Improvements
### ✅ Justified Text Fix (2024-11-10)
Lines using justified alignment now properly fill the entire width by:
- Calculating base spacing and remainder pixels
- Distributing remainder across word gaps to eliminate short lines
- Removing max_spacing constraint for true justification
**Affected examples:** 02, 11, 11b - All text now perfectly justified!
### ✅ Animated Button States (2024-11-10)
Example 07 now automatically generates an animated GIF showing button interactions:
- Initial state (1000ms)
- Pressed state (200ms)
- Released state (500ms)
- Loops continuously
**Output:** `docs/images/example_07_button_animation.gif`
### Running Tests
All new examples (08, 09, 10) include comprehensive test coverage:

View File

@ -472,28 +472,17 @@ class TableRenderer(Box):
column_width = max(50, available_for_columns // num_columns)
column_widths = [column_width] * num_columns
# Calculate row heights
# Minimum height needs to account for:
# - Font size (12px) + line height (4px) = 16px per line
# - Cell padding (varies, but typically 10-20px top+bottom)
# - At least 2 lines of text for wrapping
# So minimum should be ~60px to accommodate padding + 2 lines
header_height = 60 if any(1 for section,
_ in all_rows if section == "header") else 0
# Calculate row heights dynamically based on content
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
# Check if any body rows contain images - if so, use larger height
body_height = 60 # Increased from 30 to allow for text wrapping
for section, row in all_rows:
if section == "body":
for cell in row.cells():
for block in cell.blocks():
if isinstance(block, AbstractImage):
# Use larger height for rows with images
body_height = max(body_height, 120)
break
body_height = self._calculate_row_height_for_section(
all_rows, "body", column_widths)
footer_height = 60 if any(1 for section,
_ in all_rows if section == "footer") else 0
footer_height = self._calculate_row_height_for_section(
all_rows, "footer", column_widths) if any(
1 for section, _ in all_rows if section == "footer") else 0
row_heights = {
"header": header_height,
@ -503,6 +492,148 @@ class TableRenderer(Box):
return (column_widths, row_heights)
def _calculate_row_height_for_section(
self,
all_rows: List,
section: str,
column_widths: List[int]) -> int:
"""
Calculate the maximum required height for rows in a specific section.
Args:
all_rows: List of all rows in the table
section: Section name ('header', 'body', or 'footer')
column_widths: List of column widths
Returns:
Maximum height needed for rows in this section
"""
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
from pyWebLayout.abstract.inline import Word as AbstractWord
# Font configuration
font_size = 12
line_height = font_size + 4
padding = self._style.cell_padding
vertical_padding = padding[0] + padding[2] # top + bottom
horizontal_padding = padding[1] + padding[3] # left + right
max_height = 40 # Minimum height
for row_section, row in all_rows:
if row_section != section:
continue
row_max_height = 40 # Minimum for this row
for cell_idx, cell in enumerate(row.cells()):
if cell_idx >= len(column_widths):
continue
# Get cell width (accounting for colspan)
cell_width = column_widths[cell_idx]
if cell.colspan > 1 and cell_idx + \
cell.colspan <= len(column_widths):
cell_width = sum(
column_widths[cell_idx:cell_idx + cell.colspan])
cell_width += self._style.border_width * (cell.colspan - 1)
# Calculate content width (minus padding)
content_width = cell_width - horizontal_padding - 4 # Extra margin
cell_height = vertical_padding + 4 # Base height with padding
# Analyze each block in the cell
for block in cell.blocks():
if isinstance(block, AbstractImage):
# Images need more space
cell_height = max(cell_height, 120)
elif isinstance(block, (Paragraph, Heading)):
# Calculate text wrapping height
word_items = block.words() if callable(
block.words) else block.words
words = list(word_items)
if not words:
continue
# Simulate text wrapping to count lines
lines_needed = self._estimate_wrapped_lines(
words, content_width, font_size)
text_height = lines_needed * line_height
cell_height = max(
cell_height, text_height + vertical_padding + 4)
row_max_height = max(row_max_height, cell_height)
max_height = max(max_height, row_max_height)
return max_height
def _estimate_wrapped_lines(
self,
words: List,
available_width: int,
font_size: int) -> int:
"""
Estimate how many lines are needed to render the given words.
Args:
words: List of word objects
available_width: Available width for text
font_size: Font size in pixels
Returns:
Number of lines needed
"""
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
# Create a temporary font for measurement
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
font = Font(font_path=font_path, font_size=font_size)
# Word spacing (approximate)
word_spacing = int(font_size * 0.25)
lines = 1
current_line_width = 0
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)
# Measure word width
word_width = font.font.getlength(word_text)
# Check if word fits on current line
if current_line_width > 0: # Not first word on line
needed_width = current_line_width + word_spacing + word_width
if needed_width > available_width:
# Need new line
lines += 1
current_line_width = word_width
else:
current_line_width = needed_width
else:
# First word on line
if word_width > available_width:
# Word needs to be hyphenated, assume it takes 1 line
lines += 1
current_line_width = 0
else:
current_line_width = word_width
return lines
def render(self) -> Image.Image:
"""Render the complete table."""
x, y = self._origin

View File

@ -131,24 +131,45 @@ class CenterRightAlignmentHandler(AlignmentHandler):
class JustifyAlignmentHandler(AlignmentHandler):
"""Handler for justified text with full justification."""
def __init__(self):
# Store variable spacing for each gap to distribute remainder pixels
self._gap_spacings: List[int] = []
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""Justified alignment distributes space to fill the entire line width."""
"""
Justified alignment distributes space to fill the entire line width.
For justified text, we ALWAYS try to fill the entire width by distributing
space between words, regardless of max_spacing constraints. The only limit
is min_spacing to ensure readability.
"""
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
num_gaps = max(1, len(text_objects) - 1)
actual_spacing = residual_space // num_gaps
ideal_space = (min_spacing + max_spacing) // 2
# can we touch the end?
if actual_spacing < max_spacing:
if actual_spacing < min_spacing:
# Ensure we never return spacing less than min_spacing
# For justified text, calculate the actual spacing needed to fill the line
base_spacing = int(residual_space // num_gaps)
remainder = int(residual_space % num_gaps) # The extra pixels to distribute
# Check if we have enough space for minimum spacing
if base_spacing < min_spacing:
# Not enough space - this is overflow
self._gap_spacings = [min_spacing] * num_gaps
return min_spacing, 0, True
return max(min_spacing, actual_spacing), 0, False
return ideal_space, 0, False
# Distribute remainder pixels across the first 'remainder' gaps
# This ensures the line fills the entire width exactly
self._gap_spacings = []
for i in range(num_gaps):
if i < remainder:
self._gap_spacings.append(base_spacing + 1)
else:
self._gap_spacings.append(base_spacing)
return base_spacing, 0, False
class Text(Renderable, Queriable):
@ -642,12 +663,20 @@ class Line(Box):
next_text = self._text_objects[i + 1] if i + \
1 < len(self._text_objects) else None
# Get the spacing for this specific gap (variable for justified text)
if isinstance(self._alignment_handler, JustifyAlignmentHandler) and \
hasattr(self._alignment_handler, '_gap_spacings') and \
i < len(self._alignment_handler._gap_spacings):
current_spacing = self._alignment_handler._gap_spacings[i]
else:
current_spacing = self._spacing_render
# Render with next text information for continuous underline/strikethrough
text.render(next_text, self._spacing_render)
text.render(next_text, current_spacing)
# Add text width, then spacing only if there are more words
x_cursor += text.width
if i < len(self._text_objects) - 1:
x_cursor += self._spacing_render
x_cursor += current_spacing
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
"""