From 8d892bfe2802a53026b550c873aa27952db23b1d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 8 Jun 2025 14:08:29 +0200 Subject: [PATCH] New alignement handlers --- ARCHITECTURE.md | 233 +++++++++++++++++++++++++ pyWebLayout/concrete/text.py | 317 +++++++++++++++++++++++++++-------- simple_test.py | 38 +++++ test_alignment_handlers.py | 118 +++++++++++++ tests/test_long_word_fix.py | 162 ------------------ 5 files changed, 636 insertions(+), 232 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 simple_test.py create mode 100644 test_alignment_handlers.py delete mode 100644 tests/test_long_word_fix.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..09f89f0 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,233 @@ +# pyWebLayout Architecture: Abstract vs Concrete + +This document explains the fundamental architectural separation between **Abstract** and **Concrete** layers in the pyWebLayout library. + +## Overview + +The pyWebLayout library follows a clear separation between two distinct layers: + +- **Abstract Layer**: Represents the logical structure and content of documents (HTML/EPUB text) +- **Concrete Layer**: Handles the spatial rendering and visual representation of content + +This separation provides flexibility, testability, and clean separation of concerns. + +## Abstract Layer (`pyWebLayout/abstract/`) + +The Abstract layer deals with the **logical structure** of documents without concerning itself with how content will be visually rendered. + +### Key Components + +#### `abstract/block.py` +- `Block`: Base class for all block-level content +- `Paragraph`: Represents a logical paragraph containing words +- `Heading`: Represents headings with semantic levels (H1-H6) +- `HList`: Represents ordered/unordered lists +- `Image`: Represents image references + +#### `abstract/inline.py` +- `Word`: Represents individual words with text content and styling information +- Contains methods for hyphenation and text manipulation +- Does **not** handle rendering or spatial layout + +#### `abstract/document.py` +- `Document`: Container for the overall document structure +- `Chapter`: Logical grouping of blocks (for books/long documents) + +### Characteristics of Abstract Classes + +1. **Content-focused**: Store text, structure, and semantic meaning +2. **Layout-agnostic**: No knowledge of fonts, pixels, or rendering +3. **Reusable**: Same content can be rendered in different formats/sizes +4. **Serializable**: Can be saved/loaded without rendering context + +### Example: Abstract Word + +```python +# An Abstract Word knows its text content and semantic properties +word = Word("supercalifragilisticexpialidocious", font_style) +word.hyphenate() # Logical operation - finds break points +parts = word.get_hyphenated_parts() # Returns ["super-", "cali-", "fragi-", ...] +``` + +## Concrete Layer (`pyWebLayout/concrete/`) + +The Concrete layer handles the **spatial representation** and actual rendering of content. + +### Key Components + +#### `concrete/text.py` +- `Text`: Renders a specific text fragment with precise positioning +- `Line`: Manages a line of `Text` objects with spacing and alignment +- Handles actual pixel measurements, font rendering, and positioning + +#### `concrete/page.py` +- `Page`: Top-level container for rendered content +- `Container`: Layout manager for organizing renderable objects +- Handles spatial layout, pagination, and visual composition + +#### `concrete/box.py` +- `Box`: Base class for all spatially-aware renderable objects +- Provides positioning, sizing, and rendering capabilities + +### Characteristics of Concrete Classes + +1. **Rendering-focused**: Handle pixels, fonts, images, and visual output +2. **Spatially-aware**: Know exact positions, sizes, and layout constraints +3. **Implementation-specific**: Tied to specific rendering technologies (PIL, etc.) +4. **Non-portable**: Rendering results are tied to specific display contexts + +### Example: Concrete Text + +```python +# A Concrete Text object handles actual rendering +text = Text("super-", font) # Specific text fragment +text._calculate_dimensions() # Computes exact pixel size +image = text.render() # Produces actual visual output +``` + +## The Transformation Process + +The architecture involves a clear transformation from Abstract to Concrete: + +``` +Abstract Document + ↓ + [Parser Layer] + ↓ +Abstract Blocks (Paragraph, Heading, etc.) + ↓ + [Layout Engine] + ↓ +Concrete Objects (Text, Line, Page) + ↓ + [Rendering Engine] + ↓ +Visual Output (Images, PDF, etc.) +``` + +### Example Transformation + +```python +# 1. Abstract content +paragraph = Paragraph() +paragraph.add_word(Word("This", font)) +paragraph.add_word(Word("is", font)) +paragraph.add_word(Word("a", font)) +paragraph.add_word(Word("test", font)) + +# 2. Layout transformation +layout = ParagraphLayout(line_width=200, line_height=20) +lines = layout.layout_paragraph(paragraph) # Returns List[Line] + +# 3. Each Line contains concrete Text objects +for line in lines: + for text_obj in line.text_objects: # List[Text] + print(f"Text: '{text_obj.text}' at position {text_obj._origin}") +``` + +## Key Architectural Principles + +### 1. **Single Responsibility** +- Abstract classes: Handle content and structure +- Concrete classes: Handle rendering and layout + +### 2. **Separation of Concerns** +- Text parsing/processing ≠ Text rendering +- Document structure ≠ Page layout +- Content semantics ≠ Visual presentation + +### 3. **Immutable Abstract Content** +- Abstract content remains unchanged during rendering +- Multiple concrete representations can be generated from same abstract content +- Enables pagination, different formats, responsive layouts + +### 4. **One-to-Many Relationships** +- One Abstract Word → Multiple Concrete Text objects (hyphenation) +- One Abstract Paragraph → Multiple Concrete Lines +- One Abstract Document → Multiple Concrete Pages + +## Common Anti-Patterns to Avoid + +### ❌ **Mixing Concerns** +```python +# WRONG: Abstract class knowing about pixels +class Word: + def __init__(self, text): + self.text = text + self.rendered_width = None # ❌ Concrete concern in abstract class +``` + +### ❌ **renderable_words Concept** +```python +# WRONG: Confusing abstract and concrete +line.renderable_words # ❌ This suggests Words are renderable + # Words are abstract - only Text objects render +``` + +### ✅ **Correct Separation** +```python +# CORRECT: Clear separation +abstract_word = Word("test") # Abstract content +concrete_text = Text("test", font) # Concrete rendering +line.text_objects.append(concrete_text) # Concrete objects in concrete container +``` + +## Benefits of This Architecture + +### 1. **Flexibility** +- Same content can be rendered at different sizes +- Multiple output formats from single source +- Easy to implement responsive design + +### 2. **Testability** +- Abstract logic can be tested without rendering +- Layout algorithms can be tested independently +- Visual rendering can be mocked + +### 3. **Performance** +- Abstract content can be cached and reused +- Layout can be computed once for multiple renderings +- Incremental updates possible + +### 4. **Maintainability** +- Clear boundaries between text processing and rendering +- Changes to rendering don't affect content parsing +- Easy to swap rendering backends + +## File Organization + +``` +pyWebLayout/ +├── abstract/ # Content and structure +│ ├── block.py # Document blocks (Paragraph, Heading, etc.) +│ ├── inline.py # Inline content (Word, etc.) +│ ├── document.py # Document structure +│ └── functional.py # Links, buttons, etc. +│ +├── concrete/ # Rendering and layout +│ ├── text.py # Text and Line rendering +│ ├── page.py # Page layout and containers +│ ├── box.py # Base rendering classes +│ ├── image.py # Image rendering +│ └── functional.py # Interactive elements +│ +├── typesetting/ # Layout algorithms +│ ├── paragraph_layout.py # Abstract → Concrete transformation +│ ├── flow.py # Text flow management +│ └── pagination.py # Page breaking logic +│ +└── style/ # Styling and formatting + ├── fonts.py # Font management + ├── layout.py # Layout constants + └── alignment.py # Alignment enums +``` + +## Conclusion + +The Abstract/Concrete separation is fundamental to pyWebLayout's design. It ensures clean separation between content processing and visual rendering, enabling flexible, maintainable, and testable document processing pipelines. + +**Remember**: +- **Abstract** = What to display (content, structure, semantics) +- **Concrete** = How to display it (pixels, fonts, positioning, rendering) + +This architecture enables the library to handle complex document layouts while maintaining clear, understandable code organization. diff --git a/pyWebLayout/concrete/text.py b/pyWebLayout/concrete/text.py index 7c75f46..4a979da 100644 --- a/pyWebLayout/concrete/text.py +++ b/pyWebLayout/concrete/text.py @@ -5,8 +5,146 @@ from pyWebLayout.style.layout import Alignment from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration from pyWebLayout.abstract.inline import Word from PIL import Image, ImageDraw, ImageFont -from typing import Tuple, Union, List, Optional +from typing import Tuple, Union, List, Optional, Protocol import numpy as np +from abc import ABC, abstractmethod + + +class AlignmentHandler(ABC): + """ + Abstract base class for text alignment handlers. + Each handler implements a specific alignment strategy. + """ + + @abstractmethod + def calculate_spacing_and_position(self, text_objects: List['Text'], + available_width: int, min_spacing: int, + max_spacing: int) -> Tuple[int, int]: + """ + Calculate the spacing between words and starting position for the line. + + Args: + text_objects: List of Text objects in the line + available_width: Total width available for the line + min_spacing: Minimum spacing between words + max_spacing: Maximum spacing between words + + Returns: + Tuple of (spacing_between_words, starting_x_position) + """ + pass + + @abstractmethod + def should_try_hyphenation(self, text_objects: List['Text'], word_width: int, + available_width: int, spacing: int) -> bool: + """ + Determine if hyphenation should be attempted for better spacing. + + Args: + text_objects: Current text objects in the line + word_width: Width of the word trying to be added + available_width: Available width remaining + spacing: Current minimum spacing being used + + Returns: + True if hyphenation should be attempted + """ + pass + + +class LeftAlignmentHandler(AlignmentHandler): + """Handler for left-aligned text.""" + + def calculate_spacing_and_position(self, text_objects: List['Text'], + available_width: int, min_spacing: int, + max_spacing: int) -> Tuple[int, int]: + """Left alignment uses minimum spacing and starts at position 0.""" + return min_spacing, 0 + + def should_try_hyphenation(self, text_objects: List['Text'], word_width: int, + available_width: int, spacing: int) -> bool: + """For left alignment, hyphenate only if the word doesn't fit.""" + return word_width > available_width + + +class CenterRightAlignmentHandler(AlignmentHandler): + """Handler for center and right-aligned text.""" + + def __init__(self, alignment: Alignment): + self._alignment = alignment + + def calculate_spacing_and_position(self, text_objects: List['Text'], + available_width: int, min_spacing: int, + max_spacing: int) -> Tuple[int, int]: + """Center/right alignment uses minimum spacing with calculated start position.""" + if not text_objects: + return min_spacing, 0 + + total_text_width = sum(text_obj.width for text_obj in text_objects) + num_spaces = len(text_objects) - 1 + spacing = min_spacing + + if self._alignment == Alignment.RIGHT: + x_pos = available_width - (total_text_width + spacing * num_spaces) + else: # CENTER + x_pos = (available_width - (total_text_width + spacing * num_spaces)) // 2 + + return spacing, max(0, x_pos) + + def should_try_hyphenation(self, text_objects: List['Text'], word_width: int, + available_width: int, spacing: int) -> bool: + """For center/right alignment, hyphenate only if the word doesn't fit.""" + return word_width > available_width + + +class JustifyAlignmentHandler(AlignmentHandler): + """Handler for justified text with optimal spacing.""" + + def calculate_spacing_and_position(self, text_objects: List['Text'], + available_width: int, min_spacing: int, + max_spacing: int) -> Tuple[int, int]: + """Justified alignment distributes space evenly between words.""" + if not text_objects or len(text_objects) == 1: + # Single word or empty line - use left alignment + return min_spacing, 0 + + total_text_width = sum(text_obj.width for text_obj in text_objects) + num_spaces = len(text_objects) - 1 + available_space = available_width - total_text_width + + if num_spaces > 0: + spacing = available_space // num_spaces + # Ensure spacing is within acceptable bounds + spacing = max(min_spacing, min(max_spacing, spacing)) + else: + spacing = min_spacing + + return spacing, 0 + + def should_try_hyphenation(self, text_objects: List['Text'], word_width: int, + available_width: int, spacing: int) -> bool: + """ + For justified text, consider hyphenation if it would improve spacing quality. + This includes cases where the word fits but would create poor spacing. + """ + if word_width > available_width: + return True + + # Calculate what the spacing would be with this word added + if not text_objects: + return False + + total_text_width = sum(text_obj.width for text_obj in text_objects) + word_width + num_spaces = len(text_objects) # Will be len(text_objects) after adding the word + available_space = available_width - total_text_width + + if num_spaces > 0: + projected_spacing = available_space // num_spaces + # If spacing would be too large, consider hyphenation for better distribution + max_acceptable_spacing = spacing * 2 # Allow up to 2x normal spacing + return projected_spacing > max_acceptable_spacing + + return False class Text(Renderable, Queriable): @@ -227,6 +365,26 @@ class Line(Box): self._previous = previous self._next = None + + # Create the appropriate alignment handler + self._alignment_handler = self._create_alignment_handler(halign) + + def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler: + """ + Create the appropriate alignment handler based on the alignment type. + + Args: + alignment: The alignment type + + Returns: + The appropriate alignment handler instance + """ + if alignment == Alignment.LEFT: + return LeftAlignmentHandler() + elif alignment == Alignment.JUSTIFY: + return JustifyAlignmentHandler() + else: # CENTER or RIGHT + return CenterRightAlignmentHandler(alignment) @property def text_objects(self) -> List[Text]: @@ -286,7 +444,7 @@ class Line(Box): def add_word(self, text: str, font: Optional[Font] = None) -> Union[None, str]: """ - Add a word to this line as a Text object. + Add a word to this line as a Text object using intelligent hyphenation decisions. Args: text: The text content of the word @@ -307,65 +465,105 @@ class Line(Box): spacing_needed = min_spacing if self._text_objects else 0 # Add a small margin to prevent edge cases where words appear to fit but get cropped - # This addresses the issue of lines appearing too short safety_margin = max(1, int(font.font_size * 0.05)) # 5% of font size as safety margin # Check if word fits in the line with safety margin available_width = self._size[0] - self._current_width - spacing_needed - safety_margin - if word_width <= available_width: - # Word fits - add it to the line + + # Use the alignment handler to decide if hyphenation should be attempted + should_hyphenate = self._alignment_handler.should_try_hyphenation( + self._text_objects, word_width, available_width, min_spacing) + + if word_width <= available_width and not should_hyphenate: + # Word fits and alignment handler doesn't suggest hyphenation - add it to the line text_obj.add_to_line(self) self._text_objects.append(text_obj) self._current_width += spacing_needed + word_width return None + elif should_hyphenate: + # Try hyphenation for better spacing + return self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin) else: - # Word doesn't fit - try to hyphenate - abstract_word = Word(text, font) - if abstract_word.hyphenate(): - # Get the first hyphenated part - first_part_text = abstract_word.get_hyphenated_part(0) - first_part_obj = Text(first_part_text, font) - - # Check if first part fits (with safety margin) - if first_part_obj.width <= available_width: - # First part fits - add it to the line - first_part_obj.add_to_line(self) - self._text_objects.append(first_part_obj) - self._current_width += spacing_needed + first_part_obj.width - - # Return the remaining part(s) - if abstract_word.get_hyphenated_part_count() > 1: - return abstract_word.get_hyphenated_part(1) - else: - return None - else: - # Even the first hyphenated part doesn't fit - if self._text_objects: - # Line already has words, can't fit this one at all - return text - else: - # Empty line - must fit something or infinite loop - first_part_text = abstract_word.get_hyphenated_part(0) - # If the first part is nearly as long as the original word, this is likely a test - if len(first_part_text.rstrip('-')) >= len(text) * 0.8: # 80% of original length - # This is likely a mocked test scenario - return original word unchanged - return text - else: - # Real scenario with proper hyphenation - try force fitting - return self._force_fit_long_word(text, font, available_width + safety_margin) + # Word doesn't fit and no hyphenation recommended + if self._text_objects: + # Line already has words, can't fit this one at all + return text else: - # Word cannot be hyphenated - if self._text_objects: - # Line already has words, can't fit this unhyphenatable word - return text + # Empty line with word that's too long - force fit + return self._force_fit_long_word(text, font, available_width + safety_margin) + + def _try_hyphenation_or_fit(self, text: str, font: Font, available_width: int, + spacing_needed: int, safety_margin: int) -> Union[None, str]: + """ + Try different hyphenation options and choose the best one for spacing. + + Args: + text: The text to hyphenate + font: The font to use + available_width: Available width for the word + spacing_needed: Spacing needed before the word + safety_margin: Safety margin for fitting + + Returns: + None if the word fits, or remaining text if it doesn't fit + """ + abstract_word = Word(text, font) + + if abstract_word.hyphenate(): + # Try different hyphenation breakpoints to find the best spacing + best_option = None + best_spacing_quality = float('inf') # Lower is better + + for i in range(abstract_word.get_hyphenated_part_count()): + part_text = abstract_word.get_hyphenated_part(i) + part_obj = Text(part_text, font) + + if part_obj.width <= available_width: + # Calculate spacing quality with this hyphenation + temp_text_objects = self._text_objects + [part_obj] + spacing, _ = self._alignment_handler.calculate_spacing_and_position( + temp_text_objects, self._size[0], self._spacing[0], self._spacing[1]) + + # Quality metric: prefer spacing closer to minimum, avoid extremes + spacing_quality = abs(spacing - self._spacing[0]) + + if spacing_quality < best_spacing_quality: + best_spacing_quality = spacing_quality + best_option = (i, part_obj, part_text) else: - # Empty line with unhyphenatable word that's too long - # Force-fit as many characters as possible + # Can't fit this part, no point trying longer parts + break + + if best_option: + # Use the best hyphenation option + i, part_obj, part_text = best_option + part_obj.add_to_line(self) + self._text_objects.append(part_obj) + self._current_width += spacing_needed + part_obj.width + + # Return remaining part(s) if any + if i + 1 < abstract_word.get_hyphenated_part_count(): + return abstract_word.get_hyphenated_part(i + 1) + else: + return None + else: + # No hyphenation part fits + if self._text_objects: + return text # Line already has words, can't fit this one + else: + # Empty line - must fit something return self._force_fit_long_word(text, font, available_width + safety_margin) + else: + # Word cannot be hyphenated + if self._text_objects: + return text # Line already has words, can't fit this unhyphenatable word + else: + # Empty line with unhyphenatable word that's too long + return self._force_fit_long_word(text, font, available_width + safety_margin) def render(self) -> Image.Image: """ - Render the line with all its text objects. + Render the line with all its text objects using the alignment handler system. Returns: A PIL Image containing the rendered line @@ -377,30 +575,9 @@ class Line(Box): if not self._text_objects: return canvas - # Calculate total width of text objects - total_text_width = sum(text_obj.width for text_obj in self._text_objects) - - # Calculate spacing based on alignment and available space - available_space = self._size[0] - total_text_width - num_spaces = len(self._text_objects) - 1 - - if num_spaces > 0: - if self._halign == Alignment.JUSTIFY: - # For justified text, distribute space evenly between words - spacing = available_space // num_spaces - else: - # Use minimum spacing for other alignments - spacing = self._spacing[0] - else: - spacing = 0 - - # Calculate starting x position based on alignment - if self._halign == Alignment.LEFT: - x_pos = 0 - elif self._halign == Alignment.RIGHT: - x_pos = self._size[0] - (total_text_width + spacing * num_spaces) - else: # CENTER - x_pos = (self._size[0] - (total_text_width + spacing * num_spaces)) // 2 + # Use the alignment handler to calculate spacing and position + spacing, x_pos = self._alignment_handler.calculate_spacing_and_position( + self._text_objects, self._size[0], self._spacing[0], self._spacing[1]) # Vertical alignment - center text vertically in the line y_pos = (self._size[1] - max(text_obj.height for text_obj in self._text_objects)) // 2 diff --git a/simple_test.py b/simple_test.py new file mode 100644 index 0000000..e7f7f32 --- /dev/null +++ b/simple_test.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +""" +Simple test to check if the refactored text alignment system works. +""" + +try: + from pyWebLayout.concrete.text import Line, Text, AlignmentHandler, LeftAlignmentHandler + from pyWebLayout.style.layout import Alignment + from pyWebLayout.style import Font + print("✓ All imports successful") + + # Create a simple font + font = Font() + print("✓ Font created") + + # Create a line with left alignment + line = Line((5, 20), (0, 0), (200, 30), font, halign=Alignment.LEFT) + print("✓ Line created with left alignment") + print(f" Handler type: {type(line._alignment_handler).__name__}") + + # Try adding a word + result = line.add_word("Hello") + print(f"✓ Added word 'Hello', result: {result}") + print(f" Line now has {len(line.text_objects)} text objects") + + # Test different alignments + alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY] + for align in alignments: + test_line = Line((5, 20), (0, 0), (200, 30), font, halign=align) + handler_name = type(test_line._alignment_handler).__name__ + print(f"✓ {align.name} alignment uses {handler_name}") + + print("✓ All tests passed!") + +except Exception as e: + print(f"✗ Error: {e}") + import traceback + traceback.print_exc() diff --git a/test_alignment_handlers.py b/test_alignment_handlers.py new file mode 100644 index 0000000..7753291 --- /dev/null +++ b/test_alignment_handlers.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Test script to verify the alignment handler system is working correctly. +""" + +from pyWebLayout.concrete.text import Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler +from pyWebLayout.style.layout import Alignment +from pyWebLayout.style import Font +import numpy as np + +def test_alignment_handlers(): + """Test all three alignment handlers.""" + print("Testing Alignment Handler System") + print("=" * 40) + + # Create a basic font for testing + font = Font() + + # Test data + test_words = ["This", "is", "a", "test", "sentence"] + line_width = 300 + line_height = 30 + spacing = (5, 20) # min_spacing, max_spacing + + # Test Left Alignment + print("\n1. Testing Left Alignment:") + left_line = Line(spacing, (0, 0), (line_width, line_height), font, halign=Alignment.LEFT) + + for word in test_words: + result = left_line.add_word(word) + if result: + print(f" Word '{word}' didn't fit, remainder: '{result}'") + break + else: + print(f" Added word: '{word}'") + + print(f" Final line has {len(left_line.text_objects)} words") + print(f" Handler type: {type(left_line._alignment_handler).__name__}") + + # Test Center Alignment + print("\n2. Testing Center Alignment:") + center_line = Line(spacing, (0, 0), (line_width, line_height), font, halign=Alignment.CENTER) + + for word in test_words: + result = center_line.add_word(word) + if result: + print(f" Word '{word}' didn't fit, remainder: '{result}'") + break + else: + print(f" Added word: '{word}'") + + print(f" Final line has {len(center_line.text_objects)} words") + print(f" Handler type: {type(center_line._alignment_handler).__name__}") + + # Test Right Alignment + print("\n3. Testing Right Alignment:") + right_line = Line(spacing, (0, 0), (line_width, line_height), font, halign=Alignment.RIGHT) + + for word in test_words: + result = right_line.add_word(word) + if result: + print(f" Word '{word}' didn't fit, remainder: '{result}'") + break + else: + print(f" Added word: '{word}'") + + print(f" Final line has {len(right_line.text_objects)} words") + print(f" Handler type: {type(right_line._alignment_handler).__name__}") + + # Test Justify Alignment + print("\n4. Testing Justify Alignment:") + justify_line = Line(spacing, (0, 0), (line_width, line_height), font, halign=Alignment.JUSTIFY) + + for word in test_words: + result = justify_line.add_word(word) + if result: + print(f" Word '{word}' didn't fit, remainder: '{result}'") + break + else: + print(f" Added word: '{word}'") + + print(f" Final line has {len(justify_line.text_objects)} words") + print(f" Handler type: {type(justify_line._alignment_handler).__name__}") + + # Test spacing and position calculations + print("\n5. Testing Handler Calculations:") + + # Create sample text objects + text_objects = [Text(word, font) for word in ["Hello", "World"]] + + handlers = [ + ("Left", LeftAlignmentHandler()), + ("Center", CenterRightAlignmentHandler(Alignment.CENTER)), + ("Right", CenterRightAlignmentHandler(Alignment.RIGHT)), + ("Justify", JustifyAlignmentHandler()) + ] + + for name, handler in handlers: + spacing_calc, position = handler.calculate_spacing_and_position( + text_objects, line_width, spacing[0], spacing[1]) + print(f" {name}: spacing={spacing_calc}, position={position}") + + print("\n6. Testing Hyphenation Decisions:") + + # Test hyphenation decisions for different alignments + test_word_width = 50 + available_width = 40 # Word doesn't fit + + for name, handler in handlers: + should_hyphenate = handler.should_try_hyphenation( + text_objects, test_word_width, available_width, spacing[0]) + print(f" {name}: should_hyphenate={should_hyphenate}") + + print("\nAlignment Handler Test Completed Successfully!") + return True + +if __name__ == "__main__": + test_alignment_handlers() diff --git a/tests/test_long_word_fix.py b/tests/test_long_word_fix.py deleted file mode 100644 index d0112c0..0000000 --- a/tests/test_long_word_fix.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script specifically for verifying the long word fix. -""" - -from PIL import Image, ImageDraw -from pyWebLayout.concrete.text import Text, Line -from pyWebLayout.style import Font, FontStyle, FontWeight -from pyWebLayout.style.layout import Alignment - -def test_supercalifragilisticexpialidocious(): - """Test the specific long word that was causing issues""" - - print("Testing long word handling...") - - font_style = Font( - font_path=None, - font_size=12, - colour=(0, 0, 0, 255) - ) - - # The problematic sentence - sentence = "This sentence has some really long words like supercalifragilisticexpialidocious that might need hyphenation." - - # Test with the same constraints that were failing - line_width = 150 - line_height = 25 - - words = sentence.split() - - # Create lines and track all the text - lines = [] - words_remaining = words.copy() - all_rendered_text = [] - - print(f"Original sentence: {sentence}") - print(f"Line width: {line_width}px") - print() - - line_number = 1 - while words_remaining: - print(f"Creating line {line_number}...") - - # Create a new line - current_line = Line( - spacing=(3, 8), - origin=(0, (line_number-1) * line_height), - size=(line_width, line_height), - font=font_style, - halign=Alignment.LEFT - ) - - lines.append(current_line) - - # Add words to current line until it's full - words_added_to_line = [] - while words_remaining: - word = words_remaining[0] - print(f" Trying to add word: '{word}'") - - result = current_line.add_word(word) - - if result is None: - # Word fit in the line - words_added_to_line.append(word) - words_remaining.pop(0) - print(f" ✓ Added '{word}' to line {line_number}") - else: - # Word didn't fit, or only part of it fit - if result == word: - # Whole word didn't fit - print(f" ✗ Word '{word}' didn't fit, moving to next line") - break - else: - # Part of word fit, remainder is in result - words_added_to_line.append(word) # The original word - words_remaining[0] = result # Replace with remainder - print(f" ⚡ Part of '{word}' fit, remainder: '{result}'") - break - - # Show what's on this line - line_words = [word.text for word in current_line.text_objects] - line_text = ' '.join(line_words) - all_rendered_text.extend(line_words) - print(f" Line {line_number} contains: \"{line_text}\"") - print(f" Line {line_number} width usage: {current_line._current_width}/{line_width}px") - print() - - # If no words were added to this line, we have a problem - if not line_words: - print(f"ERROR: No words could be added to line {line_number}") - break - - line_number += 1 - - # Safety check to prevent infinite loops - if line_number > 10: - print("Safety break: too many lines") - break - - # Check if all words were rendered - original_words = sentence.split() - rendered_text_combined = ' '.join(all_rendered_text) - - print("="*60) - print("VERIFICATION") - print("="*60) - print(f"Original text: {sentence}") - print(f"Rendered text: {rendered_text_combined}") - print() - - # Check for the problematic word - long_word = "supercalifragilisticexpialidocious" - if long_word in rendered_text_combined: - print(f"✓ SUCCESS: Long word '{long_word}' was rendered!") - elif "supercalifragilisticexpialidocious" in rendered_text_combined: - print(f"✓ SUCCESS: Long word was rendered (possibly hyphenated)!") - else: - # Check if parts of the word are there - found_parts = [] - for rendered_word in all_rendered_text: - if long_word.startswith(rendered_word.replace('-', '')): - found_parts.append(rendered_word) - elif rendered_word.replace('-', '') in long_word: - found_parts.append(rendered_word) - - if found_parts: - print(f"✓ PARTIAL SUCCESS: Found parts of long word: {found_parts}") - else: - print(f"✗ FAILURE: Long word '{long_word}' was not rendered at all!") - - print(f"Total lines used: {len(lines)}") - - # Create combined image showing all lines - total_height = len(lines) * line_height - combined_image = Image.new('RGBA', (line_width, total_height), (255, 255, 255, 255)) - - for i, line in enumerate(lines): - line_img = line.render() - y_pos = i * line_height - combined_image.paste(line_img, (0, y_pos), line_img) - - # Add a border for visualization - draw = ImageDraw.Draw(combined_image) - draw.rectangle([(0, y_pos), (line_width-1, y_pos + line_height-1)], outline=(200, 200, 200), width=1) - - # Save the result - output_filename = "test_long_word_fix.png" - combined_image.save(output_filename) - print(f"Result saved as: {output_filename}") - - return len(lines), all_rendered_text - -if __name__ == "__main__": - print("Testing long word fix for 'supercalifragilisticexpialidocious'...\n") - - lines_used, rendered_words = test_supercalifragilisticexpialidocious() - - print(f"\nTest completed!") - print(f"- Lines used: {lines_used}") - print(f"- Total words rendered: {len(rendered_words)}") - print(f"- Check test_long_word_fix.png for visual verification")