diff --git a/docs/images/demo_08_bundled_fonts.png b/docs/images/demo_08_bundled_fonts.png new file mode 100644 index 0000000..0397e4b Binary files /dev/null and b/docs/images/demo_08_bundled_fonts.png differ diff --git a/docs/images/example_05_table_with_images.png b/docs/images/example_05_table_with_images.png deleted file mode 100644 index a58d021..0000000 Binary files a/docs/images/example_05_table_with_images.png and /dev/null differ diff --git a/docs/images/example_06_functional_elements.png b/docs/images/example_06_functional_elements.png index 2ff8f1b..22c5392 100644 Binary files a/docs/images/example_06_functional_elements.png and b/docs/images/example_06_functional_elements.png differ diff --git a/docs/images/example_07_button_animation.gif b/docs/images/example_07_button_animation.gif new file mode 100644 index 0000000..fb3deca Binary files /dev/null and b/docs/images/example_07_button_animation.gif differ diff --git a/docs/images/example_07_pressed_state.png b/docs/images/example_07_pressed_state.png new file mode 100644 index 0000000..1539ad1 Binary files /dev/null and b/docs/images/example_07_pressed_state.png differ diff --git a/docs/images/example_11b_simple_wrapping.png b/docs/images/example_11b_simple_wrapping.png index 39467d5..4c4f655 100644 Binary files a/docs/images/example_11b_simple_wrapping.png and b/docs/images/example_11b_simple_wrapping.png differ diff --git a/docs/images/functional_elements_demo.png b/docs/images/functional_elements_demo.png new file mode 100644 index 0000000..22c5392 Binary files /dev/null and b/docs/images/functional_elements_demo.png differ diff --git a/examples/07_pressed_state_demo.py b/examples/07_pressed_state_demo.py index dccc1e7..c7c1fb9 100644 --- a/examples/07_pressed_state_demo.py +++ b/examples/07_pressed_state_demo.py @@ -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) diff --git a/examples/README.md b/examples/README.md index 08bfc5c..3927c91 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 `` 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 -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 11b_simple_table_wrapping.py # Simple wrapping demo +# 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 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: diff --git a/pyWebLayout/concrete/table.py b/pyWebLayout/concrete/table.py index ff7d303..2f4427c 100644 --- a/pyWebLayout/concrete/table.py +++ b/pyWebLayout/concrete/table.py @@ -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 diff --git a/pyWebLayout/concrete/text.py b/pyWebLayout/concrete/text.py index ab6b6d8..9ab0daf 100644 --- a/pyWebLayout/concrete/text.py +++ b/pyWebLayout/concrete/text.py @@ -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 - return min_spacing, 0, True - return max(min_spacing, actual_spacing), 0, False - return ideal_space, 0, False + # 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 + + # 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']: """