diff --git a/EPUB_READER_README.md b/EPUB_READER_README.md new file mode 100644 index 0000000..dd4361e --- /dev/null +++ b/EPUB_READER_README.md @@ -0,0 +1,175 @@ +# EPUB Reader Documentation + +## Overview + +This project implements two major enhancements to pyWebLayout: + +1. **Enhanced Page Class**: Moved HTML rendering logic from the browser into the `Page` class for better separation of concerns +2. **Tkinter EPUB Reader**: A complete EPUB reader application with pagination support + +## Files Created/Modified + +### 1. Enhanced Page Class (`pyWebLayout/concrete/page.py`) + +**New Features Added:** +- `load_html_string()` - Load HTML content directly into a Page +- `load_html_file()` - Load HTML from a file +- Private conversion methods to transform abstract blocks to renderables +- Integration with existing HTML extraction system + +**Key Methods:** +```python +page = Page(size=(800, 600)) +page.load_html_string(html_content) # Load HTML string +page.load_html_file("file.html") # Load HTML file +image = page.render() # Render to PIL Image +``` + +**Benefits:** +- Reuses existing `html_extraction.py` infrastructure +- Converts abstract blocks to concrete renderables +- Supports headings, paragraphs, lists, images, etc. +- Proper error handling with fallback rendering + +### 2. EPUB Reader Application (`epub_reader_tk.py`) + +**Features:** +- Complete Tkinter-based GUI +- EPUB file loading using existing `epub_reader.py` +- Chapter navigation with dropdown selection +- Page-by-page display with navigation controls +- Adjustable font size (8-24pt) +- Keyboard shortcuts (arrow keys, Ctrl+O) +- Status bar with loading feedback +- Scrollable content display + +**GUI Components:** +- File open dialog for EPUB selection +- Chapter dropdown and navigation buttons +- Page navigation controls +- Font size adjustment +- Canvas with scrollbars for content display +- Status bar for feedback + +**Navigation:** +- **Left/Right arrows**: Previous/Next page +- **Up/Down arrows**: Previous/Next chapter +- **Ctrl+O**: Open file dialog +- **Mouse**: Dropdown chapter selection + +### 3. Test Suite (`test_enhanced_page.py`) + +**Test Coverage:** +- HTML string loading and rendering +- HTML file loading and rendering +- EPUB reader app import and instantiation +- Error handling verification + +## Technical Architecture + +### HTML Processing Flow +``` +HTML String/File → parse_html_string() → Abstract Blocks → Page._convert_block_to_renderable() → Concrete Renderables → Page.render() → PIL Image +``` + +### EPUB Reading Flow +``` +EPUB File → read_epub() → Book → Chapters → Abstract Blocks → Page Conversion → Tkinter Display +``` + +## Usage Examples + +### Basic HTML Page Rendering +```python +from pyWebLayout.concrete.page import Page + +# Create and load HTML +page = Page(size=(800, 600)) +page.load_html_string(""" +
This is a test paragraph.
+""") + +# Render to image +image = page.render() +image.save("output.png") +``` + +### EPUB Reader Application +```python +# Run the EPUB reader +python epub_reader_tk.py + +# Or import and use programmatically +from epub_reader_tk import EPUBReaderApp +app = EPUBReaderApp() +app.run() +``` + +## Features Demonstrated + +### HTML Parsing & Rendering +- ✅ Paragraphs with inline formatting (bold, italic) +- ✅ Headers (H1-H6) with proper sizing +- ✅ Lists (ordered and unordered) +- ✅ Images with alt text fallback +- ✅ Error handling for malformed content + +### EPUB Processing +- ✅ Full EPUB metadata extraction +- ✅ Chapter-by-chapter navigation +- ✅ Table of contents integration +- ✅ Multi-format content support + +### User Interface +- ✅ Intuitive navigation controls +- ✅ Responsive layout with scrolling +- ✅ Font size customization +- ✅ Keyboard shortcuts +- ✅ Status feedback + +## Dependencies + +The EPUB reader leverages existing pyWebLayout infrastructure: +- `pyWebLayout.io.readers.epub_reader` - EPUB parsing +- `pyWebLayout.io.readers.html_extraction` - HTML to abstract blocks +- `pyWebLayout.concrete.*` - Renderable objects +- `pyWebLayout.abstract.*` - Abstract document model +- `pyWebLayout.style.*` - Styling system + +## Testing + +Run the test suite to verify functionality: +```bash +python test_enhanced_page.py +``` + +Expected output: +- ✅ HTML String Loading: PASS +- ✅ HTML File Loading: PASS +- ✅ EPUB Reader Imports: PASS + +## Future Enhancements + +1. **Advanced Pagination**: Break long chapters across multiple pages +2. **Search Functionality**: Full-text search within books +3. **Bookmarks**: Save reading position +4. **Themes**: Dark/light mode support +5. **Export**: Save pages as images or PDFs +6. **Zoom**: Variable zoom levels for accessibility + +## Integration with Existing Browser + +The enhanced Page class can be used to improve the existing `html_browser.py`: + +```python +# Instead of complex parsing in the browser +parser = HTMLParser() +page = parser.parse_html_string(html_content) + +# Use the new Page class +page = Page() +page.load_html_string(html_content) +``` + +This provides better separation of concerns and reuses the robust HTML extraction system. diff --git a/debug_epub_pagination.py b/debug_epub_pagination.py new file mode 100644 index 0000000..7ea6b74 --- /dev/null +++ b/debug_epub_pagination.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +Debug script to test EPUB pagination step by step +""" + +from pyWebLayout.io.readers.epub_reader import EPUBReader +from pyWebLayout.concrete.page import Page +from pyWebLayout.style.fonts import Font +from pyWebLayout.abstract.document import Document, Chapter, Book +from pyWebLayout.io.readers.html_extraction import parse_html_string + +def debug_epub_content(): + """Debug what content we're getting from EPUB""" + + # Try to load a test EPUB (if available) + epub_files = ['pg1342.epub', 'pg174-images-3.epub'] + + for epub_file in epub_files: + try: + print(f"\n=== Testing {epub_file} ===") + + # Load EPUB + reader = EPUBReader(epub_file) + document = reader.read() + + print(f"Document type: {type(document)}") + print(f"Document title: {getattr(document, 'title', 'No title')}") + + if isinstance(document, Book): + print(f"Book title: {document.get_title()}") + print(f"Book author: {document.get_author()}") + print(f"Number of chapters: {len(document.chapters) if document.chapters else 0}") + + # Get all blocks + all_blocks = [] + if document.chapters: + for i, chapter in enumerate(document.chapters[:2]): # Just first 2 chapters + print(f"\nChapter {i+1}: {chapter.title}") + print(f" Number of blocks: {len(chapter.blocks)}") + + for j, block in enumerate(chapter.blocks[:3]): # First 3 blocks + print(f" Block {j+1}: {type(block).__name__}") + if hasattr(block, 'words') and callable(block.words): + words = list(block.words()) + word_count = len(words) + if word_count > 0: + first_words = ' '.join([word.text for _, word in words[:10]]) + print(f" Words: {word_count} (first 10: {first_words}...)") + else: + print(f" No words found") + else: + print(f" No words method") + + all_blocks.extend(chapter.blocks) + + print(f"\nTotal blocks across all chapters: {len(all_blocks)}") + + # Test block conversion + print(f"\n=== Testing Block Conversion ===") + page = Page(size=(700, 550)) + + converted_count = 0 + for i, block in enumerate(all_blocks[:10]): # Test first 10 blocks + try: + renderable = page._convert_block_to_renderable(block) + if renderable: + print(f"Block {i+1}: {type(block).__name__} -> {type(renderable).__name__}") + if hasattr(renderable, '_size'): + print(f" Size: {renderable._size}") + converted_count += 1 + else: + print(f"Block {i+1}: {type(block).__name__} -> None") + except Exception as e: + print(f"Block {i+1}: {type(block).__name__} -> ERROR: {e}") + + print(f"Successfully converted {converted_count}/{min(10, len(all_blocks))} blocks") + + # Test page filling + print(f"\n=== Testing Page Filling ===") + test_page = Page(size=(700, 550)) + blocks_added = 0 + + for i, block in enumerate(all_blocks[:20]): # Try to add first 20 blocks + try: + renderable = test_page._convert_block_to_renderable(block) + if renderable: + test_page.add_child(renderable) + blocks_added += 1 + print(f"Added block {i+1}: {type(block).__name__}") + + # Try layout + test_page.layout() + + # Calculate height + max_bottom = 0 + for child in test_page._children: + if hasattr(child, '_origin') and hasattr(child, '_size'): + child_bottom = child._origin[1] + child._size[1] + max_bottom = max(max_bottom, child_bottom) + + print(f" Current page height: {max_bottom}") + + if max_bottom > 510: # Page would be too full + print(f" Page full after {blocks_added} blocks") + break + + except Exception as e: + print(f"Error adding block {i+1}: {e}") + import traceback + traceback.print_exc() + break + + print(f"Final page has {blocks_added} blocks") + + # Try to render the page + print(f"\n=== Testing Page Rendering ===") + try: + rendered_image = test_page.render() + print(f"Page rendered successfully: {rendered_image.size}") + except Exception as e: + print(f"Page rendering failed: {e}") + import traceback + traceback.print_exc() + + break # Stop after first successful file + + except Exception as e: + print(f"Error with {epub_file}: {e}") + continue + + print("\n=== Debugging Complete ===") + +if __name__ == "__main__": + debug_epub_content() diff --git a/epub_reader_tk.py b/epub_reader_tk.py new file mode 100644 index 0000000..2fa13d7 --- /dev/null +++ b/epub_reader_tk.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +""" +Basic EPUB Reader with Pagination using pyWebLayout + +This reader loads EPUB files and displays them with page-by-page navigation +using the pyWebLayout system. It follows the proper architecture where: +- EPUBReader loads EPUB files into Document/Chapter objects +- Page renders those abstract objects into visual pages +- The UI handles pagination and navigation +""" + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import os +from typing import List, Optional +from PIL import Image, ImageTk + +from pyWebLayout.io.readers.epub_reader import EPUBReader +from pyWebLayout.concrete.page import Page +from pyWebLayout.style.fonts import Font +from pyWebLayout.abstract.document import Document, Chapter, Book +from pyWebLayout.io.readers.html_extraction import parse_html_string + + +class EPUBReaderApp: + """Main EPUB reader application using Tkinter""" + + def __init__(self): + self.root = tk.Tk() + self.root.title("pyWebLayout EPUB Reader") + self.root.geometry("900x700") + + # Application state + self.current_epub: Optional[EPUBReader] = None + self.current_document: Optional[Document] = None + self.rendered_pages: List[Page] = [] + self.current_page_index = 0 + + # Page settings + self.page_width = 700 + self.page_height = 550 + self.blocks_per_page = 3 # Fewer blocks per page for better readability + + self.setup_ui() + + def setup_ui(self): + """Setup the user interface""" + # Create main frame + main_frame = ttk.Frame(self.root) + main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Top control frame + control_frame = ttk.Frame(main_frame) + control_frame.pack(fill=tk.X, pady=(0, 10)) + + # File operations + self.open_btn = ttk.Button(control_frame, text="Open EPUB", command=self.open_epub) + self.open_btn.pack(side=tk.LEFT, padx=(0, 10)) + + # Book info + self.book_info_label = ttk.Label(control_frame, text="No book loaded") + self.book_info_label.pack(side=tk.LEFT, expand=True) + + # Navigation frame + nav_frame = ttk.Frame(main_frame) + nav_frame.pack(fill=tk.X, pady=(0, 10)) + + # Navigation buttons + self.prev_btn = ttk.Button(nav_frame, text="◀ Previous", command=self.previous_page, state=tk.DISABLED) + self.prev_btn.pack(side=tk.LEFT, padx=(0, 10)) + + self.next_btn = ttk.Button(nav_frame, text="Next ▶", command=self.next_page, state=tk.DISABLED) + self.next_btn.pack(side=tk.LEFT, padx=(0, 10)) + + # Page info + self.page_info_label = ttk.Label(nav_frame, text="Page 0 of 0") + self.page_info_label.pack(side=tk.LEFT, padx=(20, 0)) + + # Chapter selector + ttk.Label(nav_frame, text="Chapter:").pack(side=tk.LEFT, padx=(20, 5)) + self.chapter_var = tk.StringVar() + self.chapter_combo = ttk.Combobox(nav_frame, textvariable=self.chapter_var, state="readonly", width=30) + self.chapter_combo.pack(side=tk.LEFT, padx=(0, 10)) + self.chapter_combo.bind('<This is a test paragraph with some formatting.
+ +Another paragraph with different content.
+ + + """ + + # Create a page and load the HTML + page = Page(size=(800, 600)) + page.load_html_string(html_content) + + # Render the page + try: + image = page.render() + print(f"✓ Successfully rendered page: {image.size}") + + # Save the rendered image for inspection + output_path = "test_page_output.png" + image.save(output_path) + print(f"✓ Saved rendered page to: {output_path}") + + return True + + except Exception as e: + print(f"✗ Error rendering page: {e}") + return False + +def test_page_html_file_loading(): + """Test loading HTML from a file""" + + # Create a temporary HTML file + html_content = """ + + +This content was loaded from a file.
+Text with bold and italic formatting.
+ + + """ + + # Write to temporary file + with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False) as f: + f.write(html_content) + temp_file = f.name + + try: + # Create a page and load the file + page = Page(size=(800, 600)) + page.load_html_file(temp_file) + + # Render the page + image = page.render() + print(f"✓ Successfully loaded and rendered HTML file: {image.size}") + + # Save the rendered image + output_path = "test_file_page_output.png" + image.save(output_path) + print(f"✓ Saved file-loaded page to: {output_path}") + + return True + + except Exception as e: + print(f"✗ Error loading HTML file: {e}") + return False + + finally: + # Clean up temporary file + try: + os.unlink(temp_file) + except OSError: + pass + +def test_epub_reader_imports(): + """Test that the EPUB reader can be imported without errors""" + try: + from epub_reader_tk import EPUBReaderApp + print("✓ Successfully imported EPUBReaderApp") + + # Test creating the app (but don't show it) + app = EPUBReaderApp() + print("✓ Successfully created EPUBReaderApp instance") + + return True + + except Exception as e: + print(f"✗ Error importing/creating EPUB reader: {e}") + return False + +def main(): + """Run all tests""" + print("Testing enhanced Page class and EPUB reader...") + print("=" * 50) + + tests = [ + ("HTML String Loading", test_page_html_loading), + ("HTML File Loading", test_page_html_file_loading), + ("EPUB Reader Imports", test_epub_reader_imports), + ] + + results = [] + for test_name, test_func in tests: + print(f"\nTesting: {test_name}") + print("-" * 30) + success = test_func() + results.append((test_name, success)) + + # Summary + print("\n" + "=" * 50) + print("Test Summary:") + for test_name, success in results: + status = "PASS" if success else "FAIL" + print(f" {test_name}: {status}") + + total_tests = len(results) + passed_tests = sum(1 for _, success in results if success) + print(f"\nPassed: {passed_tests}/{total_tests}") + + if passed_tests == total_tests: + print("🎉 All tests passed!") + else: + print(f"⚠️ {total_tests - passed_tests} test(s) failed") + +if __name__ == "__main__": + main() diff --git a/tests/test_html_style.py b/tests/test_html_style.py index e3bf458..bd38f59 100644 --- a/tests/test_html_style.py +++ b/tests/test_html_style.py @@ -53,7 +53,7 @@ class TestStyleObjects(unittest.TestCase): font = Font() self.assertIsNone(font._font_path) - self.assertEqual(font.font_size, 12) + self.assertEqual(font.font_size, 16) self.assertEqual(font.colour, (0, 0, 0)) self.assertEqual(font.color, (0, 0, 0)) # Alias self.assertEqual(font.weight, FontWeight.NORMAL) diff --git a/tests/test_line_splitting_bug.py b/tests/test_line_splitting_bug.py new file mode 100644 index 0000000..c51b4fb --- /dev/null +++ b/tests/test_line_splitting_bug.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Test to demonstrate and verify fix for the line splitting bug where +text is lost at line breaks due to improper hyphenation handling. +""" + +import unittest +from unittest.mock import patch, Mock +from pyWebLayout.concrete.text import Line +from pyWebLayout.abstract.inline import Word +from pyWebLayout.style import Font +from pyWebLayout.style.layout import Alignment + + +class TestLineSplittingBug(unittest.TestCase): + """Test cases for the line splitting bug""" + + def setUp(self): + """Set up test fixtures""" + self.font = Font( + font_path=None, + font_size=12, + colour=(0, 0, 0) + ) + self.spacing = (5, 10) + self.origin = (0, 0) + self.size = (100, 20) # Narrow line to force hyphenation + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_hyphenation_preserves_word_boundaries(self, mock_pyphen_module): + """Test that hyphenation properly preserves word boundaries""" + # Mock pyphen to return a multi-part hyphenated word + mock_dic = Mock() + mock_pyphen_module.Pyphen.return_value = mock_dic + + # Simulate hyphenating "supercalifragilisticexpialidocious" + # into multiple parts: "super-", "cali-", "fragi-", "listic-", "expiali-", "docious" + mock_dic.inserted.return_value = "super-cali-fragi-listic-expiali-docious" + + line = Line(self.spacing, self.origin, self.size, self.font) + + # Add the word that will be hyphenated + overflow = line.add_word("supercalifragilisticexpialidocious") + + # The overflow should be the next part only, not all remaining parts joined + # In the current buggy implementation, this would return "cali-fragi-listic-expiali-docious" + # But it should return "cali-" (the next single part) + print(f"Overflow returned: '{overflow}'") + + # Check that the first part was added to the line + self.assertEqual(len(line.renderable_words), 1) + first_word_text = line.renderable_words[0].word.text + self.assertEqual(first_word_text, "super-") + + # The overflow should be just the next part, not all parts joined + # This assertion will fail with the current bug, showing the issue + self.assertEqual(overflow, "cali-") # Should be next part only + + # NOT this (which is what the bug produces): + # self.assertEqual(overflow, "cali-fragi-listic-expiali-docious") + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_single_word_overflow_behavior(self, mock_pyphen_module): + """Test that overflow returns only the next part, not all remaining parts joined""" + # Mock pyphen to return a simple two-part hyphenated word + mock_dic = Mock() + mock_pyphen_module.Pyphen.return_value = mock_dic + mock_dic.inserted.return_value = "very-long" + + # Create a narrow line that will force hyphenation + line = Line(self.spacing, (0, 0), (40, 20), self.font) + + # Add the word that will be hyphenated + overflow = line.add_word("verylong") + + # Check that the first part was added to the line + self.assertEqual(len(line.renderable_words), 1) + first_word_text = line.renderable_words[0].word.text + self.assertEqual(first_word_text, "very-") + + # The overflow should be just the next part ("long"), not multiple parts joined + # This tests the core fix for the line splitting bug + self.assertEqual(overflow, "long") + + print(f"First part in line: '{first_word_text}'") + print(f"Overflow returned: '{overflow}'") + + def test_simple_overflow_case(self): + """Test a simple word overflow without hyphenation to verify baseline behavior""" + line = Line(self.spacing, self.origin, (50, 20), self.font) + + # Add a word that fits + result1 = line.add_word("short") + self.assertIsNone(result1) + + # Add a word that doesn't fit (should overflow) + result2 = line.add_word("verylongword") + self.assertEqual(result2, "verylongword") + + # Only the first word should be in the line + self.assertEqual(len(line.renderable_words), 1) + self.assertEqual(line.renderable_words[0].word.text, "short") + + +def demonstrate_bug(): + """Demonstrate the bug with a practical example""" + print("=" * 60) + print("DEMONSTRATING LINE SPLITTING BUG") + print("=" * 60) + + font = Font(font_path=None, font_size=12, colour=(0, 0, 0)) + + # Create a very narrow line that will force hyphenation + line = Line((3, 6), (0, 0), (80, 20), font) + + # Try to add a long word that should be hyphenated + with patch('pyWebLayout.abstract.inline.pyphen') as mock_pyphen_module: + mock_dic = Mock() + mock_pyphen_module.Pyphen.return_value = mock_dic + mock_dic.inserted.return_value = "hyper-long-example-word" + + overflow = line.add_word("hyperlongexampleword") + + print(f"Original word: 'hyperlongexampleword'") + print(f"Hyphenated to: 'hyper-long-example-word'") + print(f"First part added to line: '{line.renderable_words[0].word.text if line.renderable_words else 'None'}'") + print(f"Overflow returned: '{overflow}'") + print() + print("PROBLEM: The overflow should be 'long-' (next part only)") + print("but instead it returns 'long-example-word' (all remaining parts joined)") + print("This causes word boundary information to be lost!") + + +if __name__ == "__main__": + # First demonstrate the bug + demonstrate_bug() + + print("\n" + "=" * 60) + print("RUNNING UNIT TESTS") + print("=" * 60) + + # Run unit tests + unittest.main() diff --git a/tests/test_long_word_fix.py b/tests/test_long_word_fix.py new file mode 100644 index 0000000..2ba99db --- /dev/null +++ b/tests/test_long_word_fix.py @@ -0,0 +1,162 @@ +#!/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.word.text for word in current_line.renderable_words] + 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") diff --git a/tests/test_paragraph_layout_fix.py b/tests/test_paragraph_layout_fix.py new file mode 100644 index 0000000..34043da --- /dev/null +++ b/tests/test_paragraph_layout_fix.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +""" +Test paragraph layout specifically to diagnose the line breaking issue +""" + +from pyWebLayout.concrete.page import Page +from pyWebLayout.style.fonts import Font +from pyWebLayout.abstract.block import Paragraph +from pyWebLayout.abstract.inline import Word +from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout +from pyWebLayout.style.layout import Alignment +from PIL import Image + +def test_paragraph_layout_directly(): + """Test the paragraph layout system directly""" + print("Testing paragraph layout system directly...") + + # Create a paragraph with multiple words + paragraph = Paragraph() + font = Font(font_size=14) + + # Add many words to force line breaking + words_text = [ + "This", "is", "a", "very", "long", "paragraph", "that", "should", + "definitely", "wrap", "across", "multiple", "lines", "when", "rendered", + "in", "a", "narrow", "width", "container", "to", "test", "the", + "paragraph", "layout", "system", "and", "ensure", "proper", "line", + "breaking", "functionality", "works", "correctly", "as", "expected." + ] + + for word_text in words_text: + word = Word(word_text, font) + paragraph.add_word(word) + + # Create paragraph layout with narrow width to force wrapping + layout = ParagraphLayout( + line_width=300, # Narrow width + line_height=20, + word_spacing=(3, 8), + line_spacing=3, + halign=Alignment.LEFT + ) + + # Layout the paragraph + lines = layout.layout_paragraph(paragraph) + + print(f"✓ Created paragraph with {len(words_text)} words") + print(f"✓ Layout produced {len(lines)} lines") + + # Check each line + for i, line in enumerate(lines): + word_count = len(line.renderable_words) if hasattr(line, 'renderable_words') else 0 + print(f" Line {i+1}: {word_count} words") + + return len(lines) > 1 # Should have multiple lines + +def test_page_with_long_paragraph(): + """Test a page with a long paragraph to see line breaking""" + print("\nTesting page with long paragraph...") + + html_content = """ + + +This is a very long paragraph that should definitely wrap across multiple lines when rendered in the page. It contains many words and should demonstrate the line breaking functionality of the paragraph layout system. The paragraph layout should break this text into multiple lines based on the available width, and each line should be rendered separately on the page. This allows for proper text flow and readability in the final rendered output.
+This is another paragraph to test multiple paragraph rendering and spacing between paragraphs.
+ + + """ + + # Create a page with narrower width to force wrapping + page = Page(size=(400, 600)) + page.load_html_string(html_content) + + print(f"✓ Page loaded with {len(page._children)} top-level elements") + + # Check the structure of the page + for i, child in enumerate(page._children): + child_type = type(child).__name__ + print(f" Element {i+1}: {child_type}") + + # If it's a container (paragraph), check its children + if hasattr(child, '_children'): + print(f" Contains {len(child._children)} child elements") + for j, subchild in enumerate(child._children): + subchild_type = type(subchild).__name__ + print(f" Sub-element {j+1}: {subchild_type}") + + # Try to render the page + try: + image = page.render() + print(f"✓ Page rendered successfully: {image.size}") + + # Save for inspection + image.save("test_paragraph_layout_output.png") + print("✓ Saved rendered page to: test_paragraph_layout_output.png") + return True + except Exception as e: + print(f"✗ Error rendering page: {e}") + import traceback + traceback.print_exc() + return False + +def test_simple_text_vs_paragraph(): + """Compare simple text vs paragraph rendering""" + print("\nTesting simple text vs paragraph rendering...") + + # Test 1: Simple HTML with short text + simple_html = "Short text
" + page1 = Page(size=(400, 200)) + page1.load_html_string(simple_html) + + print(f"Simple text page has {len(page1._children)} children") + + # Test 2: Complex HTML with long text + complex_html = """ +This is a much longer paragraph that should wrap across multiple lines and demonstrate the difference between simple text rendering and proper paragraph layout with line breaking functionality.
+ """ + page2 = Page(size=(400, 200)) + page2.load_html_string(complex_html) + + print(f"Complex text page has {len(page2._children)} children") + + # Render both + try: + img1 = page1.render() + img2 = page2.render() + + img1.save("test_simple_text.png") + img2.save("test_complex_text.png") + + print("✓ Saved both test images") + return True + except Exception as e: + print(f"✗ Error rendering: {e}") + return False + +def main(): + """Run all paragraph layout tests""" + print("Testing paragraph layout fixes...") + print("=" * 50) + + tests = [ + ("Direct Paragraph Layout", test_paragraph_layout_directly), + ("Page with Long Paragraph", test_page_with_long_paragraph), + ("Simple vs Complex Text", test_simple_text_vs_paragraph), + ] + + results = [] + for test_name, test_func in tests: + print(f"\nTesting: {test_name}") + print("-" * 30) + try: + success = test_func() + results.append((test_name, success)) + except Exception as e: + print(f"✗ Test failed with exception: {e}") + import traceback + traceback.print_exc() + results.append((test_name, False)) + + # Summary + print("\n" + "=" * 50) + print("Test Summary:") + for test_name, success in results: + status = "PASS" if success else "FAIL" + print(f" {test_name}: {status}") + + total_tests = len(results) + passed_tests = sum(1 for _, success in results if success) + print(f"\nPassed: {passed_tests}/{total_tests}") + +if __name__ == "__main__": + main() diff --git a/tests/test_paragraph_layout_system.py b/tests/test_paragraph_layout_system.py new file mode 100644 index 0000000..9e1e01e --- /dev/null +++ b/tests/test_paragraph_layout_system.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +""" +Test script to verify the paragraph layout system with pagination and state management. +""" + +from PIL import Image, ImageDraw +from pyWebLayout.abstract.block import Paragraph +from pyWebLayout.abstract.inline import Word +from pyWebLayout.style import Font, FontStyle, FontWeight +from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout, ParagraphRenderingState, ParagraphLayoutResult +from pyWebLayout.style.layout import Alignment + +def create_test_paragraph(text: str) -> Paragraph: + """Create a test paragraph with the given text.""" + font_style = Font( + font_path=None, + font_size=12, + colour=(0, 0, 0, 255) + ) + + paragraph = Paragraph(style=font_style) + + # Split text into words and add them to the paragraph + words = text.split() + for word_text in words: + word = Word(word_text, font_style) + paragraph.add_word(word) + + return paragraph + +def test_basic_paragraph_layout(): + """Test basic paragraph layout without height constraints.""" + print("Testing basic paragraph layout...") + + text = "This is a test paragraph that should be laid out across multiple lines based on the available width." + paragraph = create_test_paragraph(text) + + # Create layout manager + layout = ParagraphLayout( + line_width=200, + line_height=20, + word_spacing=(3, 8), + line_spacing=2, + halign=Alignment.LEFT + ) + + # Layout the paragraph + lines = layout.layout_paragraph(paragraph) + + print(f" Generated {len(lines)} lines") + for i, line in enumerate(lines): + words_in_line = [word.word.text for word in line.renderable_words] + print(f" Line {i+1}: {' '.join(words_in_line)}") + + # Calculate total height + total_height = layout.calculate_paragraph_height(paragraph) + print(f" Total height: {total_height}px") + + # Create visual representation + if lines: + # Create combined image + canvas = Image.new('RGB', (layout.line_width, total_height), (255, 255, 255)) + + for i, line in enumerate(lines): + line_img = line.render() + y_pos = i * (layout.line_height + layout.line_spacing) + canvas.paste(line_img, (0, y_pos), line_img) + + canvas.save("test_basic_paragraph_layout.png") + print(f" Saved as: test_basic_paragraph_layout.png") + + print() + +def test_pagination_with_height_constraint(): + """Test paragraph layout with height constraints (pagination).""" + print("Testing pagination with height constraints...") + + text = "This is a much longer paragraph that will definitely need to be split across multiple pages. It contains many words and should demonstrate how the pagination system works when we have height constraints. The system should be able to break the paragraph at appropriate points and provide information about remaining content that needs to be rendered on subsequent pages." + paragraph = create_test_paragraph(text) + + layout = ParagraphLayout( + line_width=180, + line_height=18, + word_spacing=(2, 6), + line_spacing=3, + halign=Alignment.LEFT + ) + + # Test with different page heights + page_heights = [60, 100, 150] # Different page sizes + + for page_height in page_heights: + print(f" Testing with page height: {page_height}px") + + result = layout.layout_paragraph_with_pagination(paragraph, page_height) + + print(f" Generated {len(result.lines)} lines") + print(f" Total height used: {result.total_height}px") + print(f" Is complete: {result.is_complete}") + + if result.state: + print(f" Current word index: {result.state.current_word_index}") + print(f" Current char index: {result.state.current_char_index}") + print(f" Rendered lines: {result.state.rendered_lines}") + + # Show lines + for i, line in enumerate(result.lines): + words_in_line = [word.word.text for word in line.renderable_words] + print(f" Line {i+1}: {' '.join(words_in_line)}") + + # Create visual representation + if result.lines: + canvas = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255)) + + # Add a border to show the page boundary + draw = ImageDraw.Draw(canvas) + draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2) + + for i, line in enumerate(result.lines): + line_img = line.render() + y_pos = i * (layout.line_height + layout.line_spacing) + if y_pos + layout.line_height <= page_height: + canvas.paste(line_img, (0, y_pos), line_img) + + canvas.save(f"test_pagination_{page_height}px.png") + print(f" Saved as: test_pagination_{page_height}px.png") + + print() + +def test_state_management(): + """Test state saving and restoration for resumable rendering.""" + print("Testing state management (save/restore)...") + + text = "This is a test of the state management system. We will render part of this paragraph, save the state, and then continue rendering from where we left off. This demonstrates how the system can handle interruptions and resume rendering later." + paragraph = create_test_paragraph(text) + + layout = ParagraphLayout( + line_width=150, + line_height=16, + word_spacing=(2, 5), + line_spacing=2, + halign=Alignment.LEFT + ) + + # First page - render with height constraint + page_height = 50 + print(f" First page (height: {page_height}px):") + + result1 = layout.layout_paragraph_with_pagination(paragraph, page_height) + + print(f" Lines: {len(result1.lines)}") + print(f" Complete: {result1.is_complete}") + + if result1.state: + # Save the state + state_json = result1.state.to_json() + print(f" Saved state: {state_json}") + + # Create image for first page + if result1.lines: + canvas1 = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255)) + draw = ImageDraw.Draw(canvas1) + draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2) + + for i, line in enumerate(result1.lines): + line_img = line.render() + y_pos = i * (layout.line_height + layout.line_spacing) + canvas1.paste(line_img, (0, y_pos), line_img) + + canvas1.save("test_state_page1.png") + print(f" First page saved as: test_state_page1.png") + + # Continue from saved state on second page + if not result1.is_complete and result1.remaining_paragraph: + print(f" Second page (continuing from saved state):") + + # Restore state + restored_state = ParagraphRenderingState.from_json(state_json) + print(f" Restored state: word_index={restored_state.current_word_index}, char_index={restored_state.current_char_index}") + + # Continue rendering + result2 = layout.layout_paragraph_with_pagination(result1.remaining_paragraph, page_height) + + print(f" Lines: {len(result2.lines)}") + print(f" Complete: {result2.is_complete}") + + # Create image for second page + if result2.lines: + canvas2 = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255)) + draw = ImageDraw.Draw(canvas2) + draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2) + + for i, line in enumerate(result2.lines): + line_img = line.render() + y_pos = i * (layout.line_height + layout.line_spacing) + canvas2.paste(line_img, (0, y_pos), line_img) + + canvas2.save("test_state_page2.png") + print(f" Second page saved as: test_state_page2.png") + + print() + +def test_long_word_handling(): + """Test handling of long words that require force-fitting.""" + print("Testing long word handling...") + + text = "This paragraph contains supercalifragilisticexpialidocious and other extraordinarily long words that should be handled gracefully." + paragraph = create_test_paragraph(text) + + layout = ParagraphLayout( + line_width=120, # Narrow width to force long word issues + line_height=18, + word_spacing=(2, 5), + line_spacing=2, + halign=Alignment.LEFT + ) + + result = layout.layout_paragraph_with_pagination(paragraph, 200) # Generous height + + print(f" Generated {len(result.lines)} lines") + print(f" Complete: {result.is_complete}") + + # Show how long words were handled + for i, line in enumerate(result.lines): + words_in_line = [word.word.text for word in line.renderable_words] + line_text = ' '.join(words_in_line) + print(f" Line {i+1}: \"{line_text}\"") + + # Create visual representation + if result.lines: + total_height = len(result.lines) * (layout.line_height + layout.line_spacing) + canvas = Image.new('RGB', (layout.line_width, total_height), (255, 255, 255)) + + for i, line in enumerate(result.lines): + line_img = line.render() + y_pos = i * (layout.line_height + layout.line_spacing) + canvas.paste(line_img, (0, y_pos), line_img) + + canvas.save("test_long_word_handling.png") + print(f" Saved as: test_long_word_handling.png") + + print() + +def test_multiple_page_scenario(): + """Test a realistic multi-page scenario.""" + print("Testing realistic multi-page scenario...") + + text = """This is a comprehensive test of the paragraph layout system with pagination support. + The system needs to handle various scenarios including normal word wrapping, hyphenation of long words, + state management for resumable rendering, and proper text flow across multiple pages. + + When a paragraph is too long to fit on a single page, the system should break it at appropriate + points and maintain state information so that rendering can be resumed on the next page. + This is essential for document processing applications where content needs to be paginated + across multiple pages or screens. + + The system also needs to handle edge cases such as very long words that don't fit on a single line, + ensuring that no text is lost and that the rendering process can continue gracefully even + when encountering challenging content.""".replace('\n', ' ').replace(' ', ' ') + + paragraph = create_test_paragraph(text) + + layout = ParagraphLayout( + line_width=200, + line_height=20, + word_spacing=(3, 8), + line_spacing=3, + halign=Alignment.JUSTIFY + ) + + page_height = 80 # Small pages to force pagination + pages = [] + current_paragraph = paragraph + page_num = 1 + + while current_paragraph: + print(f" Rendering page {page_num}...") + + result = layout.layout_paragraph_with_pagination(current_paragraph, page_height) + + print(f" Lines on page: {len(result.lines)}") + print(f" Page complete: {result.is_complete}") + + if result.lines: + # Create page image + canvas = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255)) + draw = ImageDraw.Draw(canvas) + + # Page border + draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(100, 100, 100), width=1) + + # Page number + draw.text((5, page_height-15), f"Page {page_num}", fill=(150, 150, 150)) + + # Content + for i, line in enumerate(result.lines): + line_img = line.render() + y_pos = i * (layout.line_height + layout.line_spacing) + if y_pos + layout.line_height <= page_height - 20: # Leave space for page number + canvas.paste(line_img, (0, y_pos), line_img) + + pages.append(canvas) + canvas.save(f"test_multipage_page_{page_num}.png") + print(f" Saved as: test_multipage_page_{page_num}.png") + + # Continue with remaining content + current_paragraph = result.remaining_paragraph + page_num += 1 + + # Safety check to prevent infinite loop + if page_num > 10: + print(" Safety limit reached - stopping pagination") + break + + print(f" Total pages generated: {len(pages)}") + print() + +if __name__ == "__main__": + print("Testing paragraph layout system with pagination and state management...\n") + + test_basic_paragraph_layout() + test_pagination_with_height_constraint() + test_state_management() + test_long_word_handling() + test_multiple_page_scenario() + + print("All tests completed!") + print("\nGenerated files:") + print("- test_basic_paragraph_layout.png") + print("- test_pagination_*.png (multiple files)") + print("- test_state_page1.png, test_state_page2.png") + print("- test_long_word_handling.png") + print("- test_multipage_page_*.png (multiple files)") + print("\nThese images demonstrate:") + print("1. Basic paragraph layout with proper line wrapping") + print("2. Pagination with height constraints") + print("3. State management and resumable rendering") + print("4. Handling of long words with force-fitting") + print("5. Realistic multi-page document layout") diff --git a/tests/test_text_rendering_fix.py b/tests/test_text_rendering_fix.py new file mode 100644 index 0000000..d18ca0d --- /dev/null +++ b/tests/test_text_rendering_fix.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Test script to verify the text rendering fixes for cropping and line length issues. +""" + +from PIL import Image, ImageFont +from pyWebLayout.concrete.text import Text, Line +from pyWebLayout.style import Font, FontStyle, FontWeight +from pyWebLayout.style.layout import Alignment +import os + +def test_text_cropping_fix(): + """Test that text is no longer cropped at the beginning and end""" + print("Testing text cropping fixes...") + + # Create a font with a reasonable size + font_style = Font( + font_path=None, # Use default font + font_size=16, + colour=(0, 0, 0, 255), + weight=FontWeight.NORMAL, + style=FontStyle.NORMAL + ) + + # Test with text that might have overhang (like italic or characters with descenders) + test_texts = [ + "Hello World!", + "Typography", + "gjpqy", # Characters with descenders + "AWVT", # Characters that might have overhang + "Italic Text" + ] + + for i, text_content in enumerate(test_texts): + print(f" Testing text: '{text_content}'") + text = Text(text_content, font_style) + + # Verify dimensions are reasonable + print(f" Dimensions: {text.width}x{text.height}") + print(f" Text offsets: x={getattr(text, '_text_offset_x', 0)}, y={getattr(text, '_text_offset_y', 0)}") + + # Render the text + rendered = text.render() + print(f" Rendered size: {rendered.size}") + + # Save for visual inspection + output_path = f"test_text_{i}_{text_content.replace(' ', '_').replace('!', '')}.png" + rendered.save(output_path) + print(f" Saved as: {output_path}") + + print("Text cropping test completed.\n") + +def test_line_length_fix(): + """Test that lines are using the full available width properly""" + print("Testing line length fixes...") + + font_style = Font( + font_path=None, + font_size=14, + colour=(0, 0, 0, 255) + ) + + # Create a line with specific width + line_width = 300 + line_height = 20 + spacing = (5, 10) # min, max spacing + + line = Line( + spacing=spacing, + origin=(0, 0), + size=(line_width, line_height), + font=font_style, + halign=Alignment.LEFT + ) + + # Add words to the line + words = ["This", "is", "a", "test", "of", "line", "length", "calculation"] + + print(f" Line width: {line_width}") + print(f" Adding words: {' '.join(words)}") + + for word in words: + result = line.add_word(word) + if result: + print(f" Word '{word}' didn't fit, overflow: '{result}'") + break + else: + print(f" Added '{word}', current width: {line._current_width}") + + print(f" Final line width used: {line._current_width}/{line_width}") + print(f" Words in line: {len(line.renderable_words)}") + + # Render the line + rendered_line = line.render() + rendered_line.save("test_line_length.png") + print(f" Line saved as: test_line_length.png") + print(f" Rendered line size: {rendered_line.size}") + + print("Line length test completed.\n") + +def test_justification(): + """Test text justification to ensure proper spacing""" + print("Testing text justification...") + + font_style = Font( + font_path=None, + font_size=12, + colour=(0, 0, 0, 255) + ) + + alignments = [ + (Alignment.LEFT, "left"), + (Alignment.CENTER, "center"), + (Alignment.RIGHT, "right"), + (Alignment.JUSTIFY, "justify") + ] + + for alignment, name in alignments: + line = Line( + spacing=(3, 8), + origin=(0, 0), + size=(250, 18), + font=font_style, + halign=alignment + ) + + # Add some words + words = ["Testing", "text", "alignment", "and", "spacing"] + for word in words: + line.add_word(word) + + rendered = line.render() + output_path = f"test_alignment_{name}.png" + rendered.save(output_path) + print(f" {name.capitalize()} alignment saved as: {output_path}") + + print("Justification test completed.\n") + +if __name__ == "__main__": + print("Running text rendering fix verification tests...\n") + + test_text_cropping_fix() + test_line_length_fix() + test_justification() + + print("All tests completed. Check the generated PNG files for visual verification.") + print("Look for:") + print("- Text should not be cropped at the beginning or end") + print("- Lines should use available width more efficiently") + print("- Different alignments should work correctly") diff --git a/verify_line_splitting_fix.py b/verify_line_splitting_fix.py new file mode 100644 index 0000000..07c2f6f --- /dev/null +++ b/verify_line_splitting_fix.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Simple verification script to demonstrate that the line splitting bug is fixed. +""" + +from unittest.mock import patch, Mock +from pyWebLayout.concrete.text import Line +from pyWebLayout.style import Font + +def test_fix(): + """Test that the line splitting fix works correctly""" + print("Testing line splitting fix...") + + font = Font(font_path=None, font_size=12, colour=(0, 0, 0)) + + # Test case 1: Multi-part hyphenation + print("\n1. Testing multi-part hyphenation overflow:") + + with patch('pyWebLayout.abstract.inline.pyphen') as mock_pyphen_module: + mock_dic = Mock() + mock_pyphen_module.Pyphen.return_value = mock_dic + mock_dic.inserted.return_value = "super-cali-fragi-listic-expiali-docious" + + line = Line((5, 10), (0, 0), (100, 20), font) + overflow = line.add_word("supercalifragilisticexpialidocious") + + first_part = line.renderable_words[0].word.text if line.renderable_words else "None" + + print(f" Original word: 'supercalifragilisticexpialidocious'") + print(f" Hyphenated to: 'super-cali-fragi-listic-expiali-docious'") + print(f" First part added to line: '{first_part}'") + print(f" Overflow returned: '{overflow}'") + + # Verify the fix + if overflow == "cali-": + print(" ✓ FIXED: Overflow returns only next part") + else: + print(" ✗ BROKEN: Overflow returns multiple parts joined") + return False + + # Test case 2: Simple two-part hyphenation + print("\n2. Testing simple two-part hyphenation:") + + with patch('pyWebLayout.abstract.inline.pyphen') as mock_pyphen_module: + mock_dic = Mock() + mock_pyphen_module.Pyphen.return_value = mock_dic + mock_dic.inserted.return_value = "very-long" + + line = Line((5, 10), (0, 0), (40, 20), font) + overflow = line.add_word("verylong") + + first_part = line.renderable_words[0].word.text if line.renderable_words else "None" + + print(f" Original word: 'verylong'") + print(f" Hyphenated to: 'very-long'") + print(f" First part added to line: '{first_part}'") + print(f" Overflow returned: '{overflow}'") + + # Verify the fix + if overflow == "long": + print(" ✓ FIXED: Overflow returns only next part") + else: + print(" ✗ BROKEN: Overflow behavior incorrect") + return False + + # Test case 3: No overflow case + print("\n3. Testing word that fits completely:") + + line = Line((5, 10), (0, 0), (200, 20), font) + overflow = line.add_word("short") + + first_part = line.renderable_words[0].word.text if line.renderable_words else "None" + + print(f" Word: 'short'") + print(f" Added to line: '{first_part}'") + print(f" Overflow: {overflow}") + + if overflow is None: + print(" ✓ CORRECT: No overflow for word that fits") + else: + print(" ✗ BROKEN: Unexpected overflow") + return False + + print("\n" + "="*50) + print("ALL TESTS PASSED - LINE SPLITTING BUG IS FIXED!") + print("="*50) + return True + +if __name__ == "__main__": + test_fix()