From 33e2cbc363ac992776e816b404ef7ef7c0eb4b5e Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 7 Nov 2025 18:48:36 +0100 Subject: [PATCH] remove application from library --- ereader_bookmarks/test_position.json | 2 +- examples/ereader_demo.py | 329 ------- examples/generate_ereader_gifs.py | 289 ------ examples/simple_ereader_example.py | 96 -- highlights/test_highlights.json | 4 + pyWebLayout/concrete/functional.py | 10 +- pyWebLayout/concrete/page.py | 128 ++- pyWebLayout/concrete/text.py | 65 ++ pyWebLayout/core/base.py | 7 + pyWebLayout/core/highlight.py | 248 ++++++ pyWebLayout/core/query.py | 87 ++ pyWebLayout/io/gesture.py | 124 +++ pyWebLayout/layout/ereader_application.py | 632 ------------- .../concrete/test_new_page_implementation.py | 29 +- tests/core/__init__.py | 1 + tests/core/test_highlight.py | 353 ++++++++ tests/core/test_query_system.py | 424 +++++++++ tests/io_tests/io/__init__.py | 1 + tests/io_tests/io/test_gesture.py | 287 ++++++ tests/layout/test_ereader_application.py | 832 ------------------ tests/layout/test_ereader_system.py | 455 ---------- 21 files changed, 1747 insertions(+), 2656 deletions(-) delete mode 100644 examples/ereader_demo.py delete mode 100644 examples/generate_ereader_gifs.py delete mode 100644 examples/simple_ereader_example.py create mode 100644 highlights/test_highlights.json create mode 100644 pyWebLayout/core/highlight.py create mode 100644 pyWebLayout/core/query.py create mode 100644 pyWebLayout/io/gesture.py delete mode 100644 pyWebLayout/layout/ereader_application.py create mode 100644 tests/core/__init__.py create mode 100644 tests/core/test_highlight.py create mode 100644 tests/core/test_query_system.py create mode 100644 tests/io_tests/io/__init__.py create mode 100644 tests/io_tests/io/test_gesture.py delete mode 100644 tests/layout/test_ereader_application.py delete mode 100644 tests/layout/test_ereader_system.py diff --git a/ereader_bookmarks/test_position.json b/ereader_bookmarks/test_position.json index 526dc5f..c8b76f3 100644 --- a/ereader_bookmarks/test_position.json +++ b/ereader_bookmarks/test_position.json @@ -1,6 +1,6 @@ { "chapter_index": 0, - "block_index": 27, + "block_index": 54, "word_index": 0, "table_row": 0, "table_col": 0, diff --git a/examples/ereader_demo.py b/examples/ereader_demo.py deleted file mode 100644 index 99ffd60..0000000 --- a/examples/ereader_demo.py +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive demo of the EbookReader functionality. - -This script demonstrates all features of the pyWebLayout EbookReader: -- Loading EPUB files -- Page navigation (forward/backward) -- Position saving/loading -- Chapter navigation -- Font size and spacing adjustments -- Getting book and position information - -Usage: - python ereader_demo.py path/to/book.epub -""" - -import sys -import os -from pathlib import Path - -# Add parent directory to path to import pyWebLayout -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from pyWebLayout.layout.ereader_application import EbookReader - - -def print_separator(): - """Print a visual separator.""" - print("\n" + "="*70 + "\n") - - -def demo_basic_navigation(reader: EbookReader): - """Demonstrate basic page navigation.""" - print("DEMO: Basic Navigation") - print_separator() - - # Get current page - print("Getting first page...") - page = reader.get_current_page() - if page: - print(f"✓ Current page rendered: {page.size}") - reader.render_to_file("demo_page_001.png") - print(" Saved to: demo_page_001.png") - - # Navigate forward - print("\nNavigating to next page...") - page = reader.next_page() - if page: - print(f"✓ Next page rendered: {page.size}") - reader.render_to_file("demo_page_002.png") - print(" Saved to: demo_page_002.png") - - # Navigate backward - print("\nNavigating to previous page...") - page = reader.previous_page() - if page: - print(f"✓ Previous page rendered: {page.size}") - - print_separator() - - -def demo_position_management(reader: EbookReader): - """Demonstrate position save/load functionality.""" - print("DEMO: Position Management") - print_separator() - - # Navigate a few pages forward - print("Navigating forward 3 pages...") - for i in range(3): - reader.next_page() - - # Save position - print("Saving current position as 'demo_bookmark'...") - success = reader.save_position("demo_bookmark") - if success: - print("✓ Position saved successfully") - - # Get position info - pos_info = reader.get_position_info() - print(f"\nCurrent position info:") - print(f" Chapter: {pos_info.get('chapter', {}).get('title', 'N/A')}") - print(f" Block index: {pos_info['position']['block_index']}") - print(f" Word index: {pos_info['position']['word_index']}") - print(f" Progress: {pos_info['progress']*100:.1f}%") - - # Navigate away - print("\nNavigating forward 5 more pages...") - for i in range(5): - reader.next_page() - - # Load saved position - print("Loading saved position 'demo_bookmark'...") - page = reader.load_position("demo_bookmark") - if page: - print("✓ Position restored successfully") - reader.render_to_file("demo_restored_position.png") - print(" Saved to: demo_restored_position.png") - - # List all saved positions - positions = reader.list_saved_positions() - print(f"\nAll saved positions: {positions}") - - print_separator() - - -def demo_chapter_navigation(reader: EbookReader): - """Demonstrate chapter navigation.""" - print("DEMO: Chapter Navigation") - print_separator() - - # Get all chapters - chapters = reader.get_chapters() - print(f"Found {len(chapters)} chapters:") - for title, idx in chapters[:5]: # Show first 5 - print(f" [{idx}] {title}") - - if len(chapters) > 5: - print(f" ... and {len(chapters) - 5} more") - - # Jump to a chapter by index - if len(chapters) > 1: - print(f"\nJumping to chapter 1...") - page = reader.jump_to_chapter(1) - if page: - print("✓ Jumped to chapter successfully") - reader.render_to_file("demo_chapter_1.png") - print(" Saved to: demo_chapter_1.png") - - # Get current chapter info - chapter_info = reader.get_current_chapter_info() - if chapter_info: - print(f" Current chapter: {chapter_info['title']}") - - # Jump to a chapter by title (if we have chapters) - if len(chapters) > 0: - first_chapter_title = chapters[0][0] - print(f"\nJumping to chapter by title: '{first_chapter_title}'...") - page = reader.jump_to_chapter(first_chapter_title) - if page: - print("✓ Jumped to chapter by title successfully") - - print_separator() - - -def demo_font_size_adjustment(reader: EbookReader): - """Demonstrate font size adjustments.""" - print("DEMO: Font Size Adjustment") - print_separator() - - # Save current page for comparison - print("Rendering page at normal font size (1.0x)...") - page = reader.get_current_page() - if page: - reader.render_to_file("demo_font_normal.png") - print("✓ Saved to: demo_font_normal.png") - - # Increase font size - print("\nIncreasing font size...") - page = reader.increase_font_size() - if page: - print(f"✓ Font size increased to {reader.get_font_size():.1f}x") - reader.render_to_file("demo_font_larger.png") - print(" Saved to: demo_font_larger.png") - - # Increase again - print("\nIncreasing font size again...") - page = reader.increase_font_size() - if page: - print(f"✓ Font size increased to {reader.get_font_size():.1f}x") - reader.render_to_file("demo_font_largest.png") - print(" Saved to: demo_font_largest.png") - - # Decrease font size - print("\nDecreasing font size...") - page = reader.decrease_font_size() - if page: - print(f"✓ Font size decreased to {reader.get_font_size():.1f}x") - - # Set specific font size - print("\nResetting to normal font size (1.0x)...") - page = reader.set_font_size(1.0) - if page: - print("✓ Font size reset to 1.0x") - - print_separator() - - -def demo_spacing_adjustment(reader: EbookReader): - """Demonstrate line and block spacing adjustments.""" - print("DEMO: Spacing Adjustment") - print_separator() - - # Save current page - print("Rendering page with default spacing...") - page = reader.get_current_page() - if page: - reader.render_to_file("demo_spacing_default.png") - print("✓ Saved to: demo_spacing_default.png") - - # Increase line spacing - print("\nIncreasing line spacing to 10px...") - page = reader.set_line_spacing(10) - if page: - print("✓ Line spacing increased") - reader.render_to_file("demo_spacing_lines_10.png") - print(" Saved to: demo_spacing_lines_10.png") - - # Increase inter-block spacing - print("\nIncreasing inter-block spacing to 25px...") - page = reader.set_inter_block_spacing(25) - if page: - print("✓ Inter-block spacing increased") - reader.render_to_file("demo_spacing_blocks_25.png") - print(" Saved to: demo_spacing_blocks_25.png") - - # Reset to defaults - print("\nResetting spacing to defaults (line: 5px, block: 15px)...") - reader.set_line_spacing(5) - page = reader.set_inter_block_spacing(15) - if page: - print("✓ Spacing reset to defaults") - - print_separator() - - -def demo_book_information(reader: EbookReader): - """Demonstrate getting book information.""" - print("DEMO: Book Information") - print_separator() - - # Get book info - book_info = reader.get_book_info() - print("Book Information:") - print(f" Title: {book_info['title']}") - print(f" Author: {book_info['author']}") - print(f" Document ID: {book_info['document_id']}") - print(f" Total blocks: {book_info['total_blocks']}") - print(f" Total chapters: {book_info['total_chapters']}") - print(f" Page size: {book_info['page_size']}") - print(f" Font scale: {book_info['font_scale']}") - - # Get reading progress - progress = reader.get_reading_progress() - print(f"\nReading Progress: {progress*100:.1f}%") - - # Get detailed position info - pos_info = reader.get_position_info() - print("\nDetailed Position:") - print(f" Chapter index: {pos_info['position']['chapter_index']}") - print(f" Block index: {pos_info['position']['block_index']}") - print(f" Word index: {pos_info['position']['word_index']}") - - chapter = pos_info.get('chapter', {}) - if chapter.get('title'): - print(f" Current chapter: {chapter['title']}") - - print_separator() - - -def main(): - """Main function to run all demos.""" - if len(sys.argv) < 2: - print("Usage: python ereader_demo.py path/to/book.epub") - print("\nExample EPUBs to try:") - print(" - tests/data/test.epub") - print(" - tests/data/test2.epub") - sys.exit(1) - - epub_path = sys.argv[1] - - if not os.path.exists(epub_path): - print(f"Error: File not found: {epub_path}") - sys.exit(1) - - print("="*70) - print(" EbookReader Demo - pyWebLayout") - print("="*70) - print(f"\nLoading EPUB: {epub_path}") - - # Create reader with context manager - with EbookReader(page_size=(800, 1000)) as reader: - # Load the EPUB - if not reader.load_epub(epub_path): - print("Error: Failed to load EPUB file") - sys.exit(1) - - print("✓ EPUB loaded successfully") - - # Run all demos - try: - demo_basic_navigation(reader) - demo_position_management(reader) - demo_chapter_navigation(reader) - demo_font_size_adjustment(reader) - demo_spacing_adjustment(reader) - demo_book_information(reader) - - print("\n" + "="*70) - print(" Demo Complete!") - print("="*70) - print("\nGenerated demo images:") - demo_files = [ - "demo_page_001.png", - "demo_page_002.png", - "demo_restored_position.png", - "demo_chapter_1.png", - "demo_font_normal.png", - "demo_font_larger.png", - "demo_font_largest.png", - "demo_spacing_default.png", - "demo_spacing_lines_10.png", - "demo_spacing_blocks_25.png" - ] - - for filename in demo_files: - if os.path.exists(filename): - print(f" ✓ {filename}") - - print("\nAll features demonstrated successfully!") - - except Exception as e: - print(f"\nError during demo: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/examples/generate_ereader_gifs.py b/examples/generate_ereader_gifs.py deleted file mode 100644 index cf43a19..0000000 --- a/examples/generate_ereader_gifs.py +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate animated GIFs demonstrating EbookReader functionality. - -This script creates animated GIFs showcasing: -1. Page navigation (next/previous) -2. Font size adjustment -3. Chapter navigation -4. Bookmark/position management - -The GIFs are saved to the examples/ directory and can be included in documentation. - -Usage: - python generate_ereader_gifs.py path/to/book.epub [output_dir] - -Example: - python generate_ereader_gifs.py ../tests/data/test.epub ../docs/images -""" - -import sys -import os -from pathlib import Path -from typing import List - -# Add parent directory to path to import pyWebLayout -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from pyWebLayout.layout.ereader_application import EbookReader -from PIL import Image - - -def create_gif(images: List[Image.Image], output_path: str, duration: int = 800, loop: int = 0): - """ - Create an animated GIF from a list of PIL Images. - - Args: - images: List of PIL Images to animate - output_path: Path where to save the GIF - duration: Duration of each frame in milliseconds - loop: Number of loops (0 = infinite) - """ - if not images: - print(f"Warning: No images provided for {output_path}") - return False - - try: - # Save as animated GIF - images[0].save( - output_path, - save_all=True, - append_images=images[1:], - duration=duration, - loop=loop, - optimize=False - ) - print(f"✓ Created: {output_path} ({len(images)} frames)") - return True - except Exception as e: - print(f"✗ Error creating {output_path}: {e}") - return False - - -def generate_page_navigation_gif(reader: EbookReader, output_path: str): - """Generate GIF showing page navigation (forward and backward).""" - print("\n[1/4] Generating page navigation GIF...") - - frames = [] - - # Go to beginning - reader.set_font_size(1.0) - - # Capture 5 pages going forward - for i in range(5): - page = reader.get_current_page() - if page: - frames.append(page.copy()) - reader.next_page() - - # Go back to start - for _ in range(4): - reader.previous_page() - - # Capture 5 pages going forward again (smoother loop) - for i in range(5): - page = reader.get_current_page() - if page: - frames.append(page.copy()) - reader.next_page() - - create_gif(frames, output_path, duration=600) - - -def generate_font_size_gif(reader: EbookReader, output_path: str): - """Generate GIF showing font size adjustment.""" - print("\n[2/4] Generating font size adjustment GIF...") - - frames = [] - - # Reset to beginning and normal font - for _ in range(10): - reader.previous_page() - reader.set_font_size(1.0) - - # Font sizes to demonstrate - font_scales = [0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.3, 1.2, 1.1, 1.0, 0.9, 0.8] - - for scale in font_scales: - page = reader.set_font_size(scale) - if page: - frames.append(page.copy()) - - # Reset to normal - reader.set_font_size(1.0) - - create_gif(frames, output_path, duration=500) - - -def generate_chapter_navigation_gif(reader: EbookReader, output_path: str): - """Generate GIF showing chapter navigation.""" - print("\n[3/4] Generating chapter navigation GIF...") - - frames = [] - - # Reset font - reader.set_font_size(1.0) - - # Get chapters - chapters = reader.get_chapters() - - if len(chapters) == 0: - print(" Warning: No chapters found, skipping chapter navigation GIF") - return - - # Visit first few chapters (or loop through available chapters) - chapter_indices = list(range(min(5, len(chapters)))) - - # Add some chapters twice for smoother animation - for idx in chapter_indices: - page = reader.jump_to_chapter(idx) - if page: - frames.append(page.copy()) - # Add a second frame at each chapter for pause effect - frames.append(page.copy()) - - # Go back to first chapter - page = reader.jump_to_chapter(0) - if page: - frames.append(page.copy()) - - if frames: - create_gif(frames, output_path, duration=1000) - else: - print(" Warning: No frames captured for chapter navigation") - - -def generate_bookmark_gif(reader: EbookReader, output_path: str): - """Generate GIF showing bookmark save/load functionality.""" - print("\n[4/4] Generating bookmark/position GIF...") - - frames = [] - - # Reset font - reader.set_font_size(1.0) - - # Go to beginning - for _ in range(20): - reader.previous_page() - - # Capture initial position - page = reader.get_current_page() - if page: - frames.append(page.copy()) - frames.append(page.copy()) # Hold frame - - # Navigate forward a bit - for i in range(3): - reader.next_page() - page = reader.get_current_page() - if page: - frames.append(page.copy()) - - # Save this position - reader.save_position("demo_bookmark") - page = reader.get_current_page() - if page: - frames.append(page.copy()) - frames.append(page.copy()) # Hold frame to show saved position - - # Navigate away - for i in range(5): - reader.next_page() - page = reader.get_current_page() - if page: - frames.append(page.copy()) - - # Hold at distant position - page = reader.get_current_page() - if page: - frames.append(page.copy()) - frames.append(page.copy()) - - # Jump back to bookmark - page = reader.load_position("demo_bookmark") - if page: - frames.append(page.copy()) - frames.append(page.copy()) - frames.append(page.copy()) # Hold longer to show we're back - - create_gif(frames, output_path, duration=600) - - -def main(): - """Main function to generate all GIFs.""" - if len(sys.argv) < 2: - print("Usage: python generate_ereader_gifs.py path/to/book.epub [output_dir]") - print("\nExample:") - print(" python generate_ereader_gifs.py ../tests/data/test.epub ../docs/images") - sys.exit(1) - - epub_path = sys.argv[1] - output_dir = sys.argv[2] if len(sys.argv) > 2 else "." - - # Validate EPUB path - if not os.path.exists(epub_path): - print(f"Error: EPUB file not found: {epub_path}") - sys.exit(1) - - # Create output directory - os.makedirs(output_dir, exist_ok=True) - - print("="*70) - print(" EbookReader Animated GIF Generator") - print("="*70) - print(f"\nInput EPUB: {epub_path}") - print(f"Output directory: {output_dir}") - - # Create paths for output GIFs - nav_gif = os.path.join(output_dir, "ereader_page_navigation.gif") - font_gif = os.path.join(output_dir, "ereader_font_size.gif") - chapter_gif = os.path.join(output_dir, "ereader_chapter_navigation.gif") - bookmark_gif = os.path.join(output_dir, "ereader_bookmarks.gif") - - try: - # Create reader - with EbookReader(page_size=(600, 800), margin=30) as reader: - # Load EPUB - print("\nLoading EPUB...") - if not reader.load_epub(epub_path): - print("Error: Failed to load EPUB file") - sys.exit(1) - - print("✓ EPUB loaded successfully") - - # Get book info - book_info = reader.get_book_info() - print(f"\nBook: {book_info['title']}") - print(f"Author: {book_info['author']}") - print(f"Chapters: {book_info['total_chapters']}") - print(f"Blocks: {book_info['total_blocks']}") - - print("\nGenerating GIFs...") - print("-" * 70) - - # Generate all GIFs - generate_page_navigation_gif(reader, nav_gif) - generate_font_size_gif(reader, font_gif) - generate_chapter_navigation_gif(reader, chapter_gif) - generate_bookmark_gif(reader, bookmark_gif) - - print("\n" + "="*70) - print(" Generation Complete!") - print("="*70) - print("\nGenerated files:") - for gif_path in [nav_gif, font_gif, chapter_gif, bookmark_gif]: - if os.path.exists(gif_path): - size = os.path.getsize(gif_path) - print(f" ✓ {gif_path} ({size/1024:.1f} KB)") - - print("\nYou can now add these GIFs to your README.md!") - - except Exception as e: - print(f"\nError: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/examples/simple_ereader_example.py b/examples/simple_ereader_example.py deleted file mode 100644 index 8ea1a16..0000000 --- a/examples/simple_ereader_example.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple example showing the most common EbookReader usage. - -This script loads an EPUB and allows you to navigate through it, -saving each page as an image. - -Usage: - python simple_ereader_example.py book.epub -""" - -import sys -from pathlib import Path - -# Add parent directory to path to import pyWebLayout -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from pyWebLayout.layout.ereader_application import EbookReader - - -def main(): - if len(sys.argv) < 2: - print("Usage: python simple_ereader_example.py book.epub") - sys.exit(1) - - epub_path = sys.argv[1] - - # Create reader and load EPUB - print(f"Loading: {epub_path}") - reader = EbookReader(page_size=(800, 1000)) - - if not reader.load_epub(epub_path): - print("Failed to load EPUB") - sys.exit(1) - - # Get book information - info = reader.get_book_info() - print(f"\nBook: {info['title']}") - print(f"Author: {info['author']}") - print(f"Total blocks: {info['total_blocks']}") - - # Get chapters - chapters = reader.get_chapters() - print(f"Chapters: {len(chapters)}") - if chapters: - print("\nChapter list:") - for title, idx in chapters[:10]: # Show first 10 - print(f" {idx}: {title}") - if len(chapters) > 10: - print(f" ... and {len(chapters) - 10} more") - - # Navigate through first 10 pages - print("\nRendering first 10 pages...") - for i in range(10): - page = reader.get_current_page() - if page: - filename = f"page_{i+1:03d}.png" - reader.render_to_file(filename) - - # Show progress - progress = reader.get_reading_progress() - chapter_info = reader.get_current_chapter_info() - chapter_name = chapter_info['title'] if chapter_info else "N/A" - - print(f" Page {i+1}: {filename} (Progress: {progress*100:.1f}%, Chapter: {chapter_name})") - - # Move to next page - if not reader.next_page(): - print(" Reached end of book") - break - - # Save current position - reader.save_position("stopped_at_page_10") - print("\nSaved position as 'stopped_at_page_10'") - - # Example: Jump to a chapter (if available) - if len(chapters) >= 2: - print(f"\nJumping to chapter: {chapters[1][0]}") - reader.jump_to_chapter(1) - reader.render_to_file("chapter_2_start.png") - print(" Saved to: chapter_2_start.png") - - # Example: Increase font size - print("\nIncreasing font size...") - reader.increase_font_size() - reader.render_to_file("larger_font.png") - print(f" Font size now: {reader.get_font_size():.1f}x") - print(" Saved to: larger_font.png") - - # Close reader (saves current position automatically) - reader.close() - print("\nDone! Current position saved automatically.") - - -if __name__ == "__main__": - main() diff --git a/highlights/test_highlights.json b/highlights/test_highlights.json new file mode 100644 index 0000000..3fae1cd --- /dev/null +++ b/highlights/test_highlights.json @@ -0,0 +1,4 @@ +{ + "document_id": "test", + "highlights": [] +} \ No newline at end of file diff --git a/pyWebLayout/concrete/functional.py b/pyWebLayout/concrete/functional.py index 915d55f..b95677d 100644 --- a/pyWebLayout/concrete/functional.py +++ b/pyWebLayout/concrete/functional.py @@ -77,12 +77,16 @@ class LinkText(Text, Interactable, Queriable): return None return self._callback() # Don't pass the point to the callback - def render(self): + def render(self, next_text: Optional['Text'] = None, spacing: int = 0): """ Render the link text with optional hover effects. + + Args: + next_text: The next Text object in the line (if any) + spacing: The spacing to the next text object """ - # Call the parent Text render method - super().render() + # Call the parent Text render method with parameters + super().render(next_text, spacing) # Add hover effect if needed if self._hovered: diff --git a/pyWebLayout/concrete/page.py b/pyWebLayout/concrete/page.py index 6a44ff2..35000c0 100644 --- a/pyWebLayout/concrete/page.py +++ b/pyWebLayout/concrete/page.py @@ -3,6 +3,7 @@ import numpy as np from PIL import Image, ImageDraw from pyWebLayout.core.base import Renderable, Layoutable, Queriable +from pyWebLayout.core.query import QueryResult, SelectionRange from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style import Alignment from .box import Box @@ -264,24 +265,41 @@ class Page(Renderable, Queriable): # Default to origin return (0, 0) - def query_point(self, point: Tuple[int, int]) -> Optional[Renderable]: + def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]: """ - Query a point to determine which child it belongs to. + Query a point to find the deepest object at that location. + Traverses children and uses Queriable.in_object() for hit-testing. Args: point: The (x, y) coordinates to query Returns: - The child object that contains the point, or None if no child contains it + QueryResult with metadata about what was found, or None if nothing hit """ point_array = np.array(point) # Check each child (in reverse order so topmost child is found first) for child in reversed(self._children): - if self._point_in_child(point_array, child): - return child + # Use Queriable mixin's in_object() for hit-testing + if isinstance(child, Queriable) and child.in_object(point_array): + # If child can also query (has children of its own), recurse + if hasattr(child, 'query_point'): + result = child.query_point(point) + if result: + result.parent_page = self + return result + # If child's query returned None, continue to next child + continue - return None + # Otherwise, package this child as the result + return self._make_query_result(child, point) + + # Nothing hit - return empty result + return QueryResult( + object=self, + object_type="empty", + bounds=(int(point[0]), int(point[1]), 0, 0) + ) def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool: """ @@ -337,6 +355,104 @@ class Page(Renderable, Queriable): return None + def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult: + """ + Package an object into a QueryResult with metadata. + + Args: + obj: The object to package + point: The query point + + Returns: + QueryResult with extracted metadata + """ + from .text import Text + from .functional import LinkText, ButtonText + + # Extract bounds + origin = getattr(obj, '_origin', np.array([0, 0])) + size = getattr(obj, 'size', np.array([0, 0])) + bounds = ( + int(origin[0]), + int(origin[1]), + int(size[0]) if hasattr(size, '__getitem__') else 0, + int(size[1]) if hasattr(size, '__getitem__') else 0 + ) + + # Determine type and extract metadata + if isinstance(obj, LinkText): + return QueryResult( + object=obj, + object_type="link", + bounds=bounds, + text=obj._text, + is_interactive=True, + link_target=obj._link.location if hasattr(obj, '_link') else None + ) + elif isinstance(obj, ButtonText): + return QueryResult( + object=obj, + object_type="button", + bounds=bounds, + text=obj._text, + is_interactive=True, + callback=obj._callback if hasattr(obj, '_callback') else None + ) + elif isinstance(obj, Text): + return QueryResult( + object=obj, + object_type="text", + bounds=bounds, + text=obj._text if hasattr(obj, '_text') else None + ) + else: + return QueryResult( + object=obj, + object_type="unknown", + bounds=bounds + ) + + def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange: + """ + Query all text objects between two points (for text selection). + Uses Queriable.in_object() to determine which objects are in range. + + Args: + start: Starting (x, y) point + end: Ending (x, y) point + + Returns: + SelectionRange with all text objects between the points + """ + results = [] + in_selection = False + + start_result = self.query_point(start) + end_result = self.query_point(end) + + if not start_result or not end_result: + return SelectionRange(start, end, []) + + # Walk through all children (Lines) and their text objects + from .text import Line, Text + + for child in self._children: + if isinstance(child, Line) and hasattr(child, '_text_objects'): + for text_obj in child._text_objects: + # Check if this text is the start or is between start and end + if text_obj == start_result.object: + in_selection = True + + if in_selection and isinstance(text_obj, Text): + result = self._make_query_result(text_obj, start) + results.append(result) + + if text_obj == end_result.object: + in_selection = False + break + + return SelectionRange(start, end, results) + def in_object(self, point: Tuple[int, int]) -> bool: """ Check if a point is within this page's bounds. diff --git a/pyWebLayout/concrete/text.py b/pyWebLayout/concrete/text.py index 4100b61..4817075 100644 --- a/pyWebLayout/concrete/text.py +++ b/pyWebLayout/concrete/text.py @@ -560,3 +560,68 @@ class Line(Box): # Render with next text information for continuous underline/strikethrough text.render(next_text, self._spacing_render) x_cursor += self._spacing_render + text.width # x-spacing + width of text object + + def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']: + """ + Find which Text object contains the given point. + Uses Queriable.in_object() mixin for hit-testing. + + Args: + point: (x, y) coordinates to query + + Returns: + QueryResult from the text object at that point, or None + """ + from pyWebLayout.core.query import QueryResult + from .functional import LinkText, ButtonText + + point_array = np.array(point) + + # Check each text object in this line + for text_obj in self._text_objects: + # Use Queriable mixin's in_object() for hit-testing + if isinstance(text_obj, Queriable) and text_obj.in_object(point_array): + # Extract metadata based on text type + origin = text_obj._origin + size = text_obj.size + + # Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent + # Bounds should be (x, visual_top, width, height) for proper highlighting + visual_top = int(origin[1] - text_obj._ascent) + bounds = ( + int(origin[0]), + visual_top, + int(size[0]) if hasattr(size, '__getitem__') else 0, + int(size[1]) if hasattr(size, '__getitem__') else 0 + ) + + if isinstance(text_obj, LinkText): + result = QueryResult( + object=text_obj, + object_type="link", + bounds=bounds, + text=text_obj._text, + is_interactive=True, + link_target=text_obj._link.location if hasattr(text_obj, '_link') else None + ) + elif isinstance(text_obj, ButtonText): + result = QueryResult( + object=text_obj, + object_type="button", + bounds=bounds, + text=text_obj._text, + is_interactive=True, + callback=text_obj._callback if hasattr(text_obj, '_callback') else None + ) + else: + result = QueryResult( + object=text_obj, + object_type="text", + bounds=bounds, + text=text_obj._text if hasattr(text_obj, '_text') else None + ) + + result.parent_line = self + return result + + return None diff --git a/pyWebLayout/core/base.py b/pyWebLayout/core/base.py index 5b733e5..8ae3928 100644 --- a/pyWebLayout/core/base.py +++ b/pyWebLayout/core/base.py @@ -1,8 +1,12 @@ from abc import ABC +from typing import Optional, Tuple, List, TYPE_CHECKING import numpy as np from pyWebLayout.style.alignment import Alignment +if TYPE_CHECKING: + from pyWebLayout.core.query import QueryResult + class Renderable(ABC): """ @@ -17,6 +21,9 @@ class Renderable(ABC): PIL.Image: The rendered image """ pass + @property + def origin(self): + return self._origin class Interactable(ABC): """ diff --git a/pyWebLayout/core/highlight.py b/pyWebLayout/core/highlight.py new file mode 100644 index 0000000..d05dda2 --- /dev/null +++ b/pyWebLayout/core/highlight.py @@ -0,0 +1,248 @@ +""" +Text highlighting system for ebook reader. + +Provides data structures and utilities for highlighting text regions, +managing highlight collections, and rendering highlights on pages. +""" + +from __future__ import annotations +from dataclasses import dataclass +from typing import List, Tuple, Optional, Dict, Any +from enum import Enum +import json +from pathlib import Path + + +class HighlightColor(Enum): + """Predefined highlight colors with RGBA values""" + YELLOW = (255, 255, 0, 100) # Classic highlight yellow + GREEN = (100, 255, 100, 100) # Green for verified/correct + BLUE = (100, 200, 255, 100) # Blue for important + PINK = (255, 150, 200, 100) # Pink for questions + ORANGE = (255, 180, 100, 100) # Orange for warnings + PURPLE = (200, 150, 255, 100) # Purple for definitions + RED = (255, 100, 100, 100) # Red for errors/concerns + + +@dataclass +class Highlight: + """ + Represents a highlighted text region. + + Highlights are stored with both pixel bounds (for rendering) and + semantic bounds (text content, for persistence across font changes). + """ + # Identification + id: str # Unique identifier + + # Visual properties + bounds: List[Tuple[int, int, int, int]] # List of (x, y, w, h) rectangles + color: Tuple[int, int, int, int] # RGBA color + + # Semantic properties (for persistence) + text: str # The highlighted text + start_word_index: Optional[int] = None # Word index in document (if available) + end_word_index: Optional[int] = None + + # Metadata + note: Optional[str] = None # Optional annotation + tags: List[str] = None # Optional categorization tags + timestamp: Optional[float] = None # When created + + def __post_init__(self): + """Initialize default values""" + if self.tags is None: + self.tags = [] + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dictionary""" + return { + 'id': self.id, + 'bounds': self.bounds, + 'color': self.color, + 'text': self.text, + 'start_word_index': self.start_word_index, + 'end_word_index': self.end_word_index, + 'note': self.note, + 'tags': self.tags, + 'timestamp': self.timestamp + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Highlight': + """Deserialize from dictionary""" + return cls( + id=data['id'], + bounds=[tuple(b) for b in data['bounds']], + color=tuple(data['color']), + text=data['text'], + start_word_index=data.get('start_word_index'), + end_word_index=data.get('end_word_index'), + note=data.get('note'), + tags=data.get('tags', []), + timestamp=data.get('timestamp') + ) + + +class HighlightManager: + """ + Manages highlights for a document. + + Handles adding, removing, listing, and persisting highlights. + """ + + def __init__(self, document_id: str, highlights_dir: str = "highlights"): + """ + Initialize highlight manager. + + Args: + document_id: Unique identifier for the document + highlights_dir: Directory to store highlight data + """ + self.document_id = document_id + self.highlights_dir = Path(highlights_dir) + self.highlights: Dict[str, Highlight] = {} # id -> Highlight + + # Create directory if it doesn't exist + self.highlights_dir.mkdir(parents=True, exist_ok=True) + + # Load existing highlights + self._load_highlights() + + def add_highlight(self, highlight: Highlight) -> None: + """ + Add a highlight. + + Args: + highlight: Highlight to add + """ + self.highlights[highlight.id] = highlight + self._save_highlights() + + def remove_highlight(self, highlight_id: str) -> bool: + """ + Remove a highlight by ID. + + Args: + highlight_id: ID of highlight to remove + + Returns: + True if removed, False if not found + """ + if highlight_id in self.highlights: + del self.highlights[highlight_id] + self._save_highlights() + return True + return False + + def get_highlight(self, highlight_id: str) -> Optional[Highlight]: + """Get a highlight by ID""" + return self.highlights.get(highlight_id) + + def list_highlights(self) -> List[Highlight]: + """Get all highlights""" + return list(self.highlights.values()) + + def clear_all(self) -> None: + """Remove all highlights""" + self.highlights.clear() + self._save_highlights() + + def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]: + """ + Get highlights that appear on a specific page. + + Args: + page_bounds: Page bounds (x, y, width, height) + + Returns: + List of highlights on this page + """ + page_x, page_y, page_w, page_h = page_bounds + page_highlights = [] + + for highlight in self.highlights.values(): + # Check if any highlight bounds overlap with page + for hx, hy, hw, hh in highlight.bounds: + if (hx < page_x + page_w and hx + hw > page_x and + hy < page_y + page_h and hy + hh > page_y): + page_highlights.append(highlight) + break + + return page_highlights + + def _get_filepath(self) -> Path: + """Get filepath for this document's highlights""" + return self.highlights_dir / f"{self.document_id}_highlights.json" + + def _save_highlights(self) -> None: + """Persist highlights to disk""" + try: + filepath = self._get_filepath() + data = { + 'document_id': self.document_id, + 'highlights': [h.to_dict() for h in self.highlights.values()] + } + + with open(filepath, 'w') as f: + json.dump(data, f, indent=2) + except Exception as e: + print(f"Error saving highlights: {e}") + + def _load_highlights(self) -> None: + """Load highlights from disk""" + try: + filepath = self._get_filepath() + if not filepath.exists(): + return + + with open(filepath, 'r') as f: + data = json.load(f) + + self.highlights = { + h['id']: Highlight.from_dict(h) + for h in data.get('highlights', []) + } + except Exception as e: + print(f"Error loading highlights: {e}") + self.highlights = {} + + +def create_highlight_from_query_result( + result, + color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value, + note: Optional[str] = None, + tags: Optional[List[str]] = None +) -> Highlight: + """ + Create a highlight from a QueryResult. + + Args: + result: QueryResult from query_pixel or query_range + color: RGBA color tuple + note: Optional annotation + tags: Optional categorization tags + + Returns: + Highlight instance + """ + from time import time + import uuid + + # Handle single result or SelectionRange + if hasattr(result, 'results'): # SelectionRange + bounds = result.bounds_list + text = result.text + else: # Single QueryResult + bounds = [result.bounds] + text = result.text or "" + + return Highlight( + id=str(uuid.uuid4()), + bounds=bounds, + color=color, + text=text, + note=note, + tags=tags or [], + timestamp=time() + ) diff --git a/pyWebLayout/core/query.py b/pyWebLayout/core/query.py new file mode 100644 index 0000000..ccf7a4d --- /dev/null +++ b/pyWebLayout/core/query.py @@ -0,0 +1,87 @@ +""" +Query system for pixel-to-content mapping. + +This module provides data structures for querying rendered content, +enabling interactive features like link clicking, word definition lookup, +and text selection. +""" + +from __future__ import annotations +from dataclasses import dataclass +from typing import Optional, Tuple, List, Any, TYPE_CHECKING +import numpy as np + +if TYPE_CHECKING: + from pyWebLayout.core.base import Queriable + + +@dataclass +class QueryResult: + """ + Result of querying a point on a rendered page. + + This encapsulates all information about what was found at a pixel location, + including geometry, content, and interaction capabilities. + """ + # What was found + object: 'Queriable' # The object at this point + object_type: str # "link", "text", "image", "button", "word", "empty" + + # Geometry + bounds: Tuple[int, int, int, int] # (x, y, width, height) in page coordinates + + # Content (for text/words) + text: Optional[str] = None + word_index: Optional[int] = None # Index in abstract document structure + block_index: Optional[int] = None # Block index in document + + # Interaction (for links/buttons) + is_interactive: bool = False + link_target: Optional[str] = None # URL or internal reference + callback: Optional[Any] = None # Interaction callback + + # Hierarchy (for debugging/traversal) + parent_line: Optional[Any] = None + parent_page: Optional[Any] = None + + def to_dict(self) -> dict: + """Convert to dictionary for serialization""" + return { + 'object_type': self.object_type, + 'bounds': self.bounds, + 'text': self.text, + 'is_interactive': self.is_interactive, + 'link_target': self.link_target, + 'word_index': self.word_index, + 'block_index': self.block_index + } + + +@dataclass +class SelectionRange: + """ + Represents a range of selected text between two points. + """ + start_point: Tuple[int, int] + end_point: Tuple[int, int] + results: List[QueryResult] # All query results in the range + + @property + def text(self) -> str: + """Get concatenated text from all results""" + return " ".join(r.text for r in self.results if r.text) + + @property + def bounds_list(self) -> List[Tuple[int, int, int, int]]: + """Get list of all bounding boxes for highlighting""" + return [r.bounds for r in self.results] + + def to_dict(self) -> dict: + """Convert to dictionary for serialization""" + return { + 'start': self.start_point, + 'end': self.end_point, + 'text': self.text, + 'word_count': len(self.results), + 'bounds': self.bounds_list + } diff --git a/pyWebLayout/io/gesture.py b/pyWebLayout/io/gesture.py new file mode 100644 index 0000000..6371cda --- /dev/null +++ b/pyWebLayout/io/gesture.py @@ -0,0 +1,124 @@ +""" +Gesture event types for touch input. + +This module defines touch gestures that can be received from a HAL (Hardware Abstraction Layer) +or touch input system, and the response format for actions to be performed. +""" + +from __future__ import annotations +from enum import Enum +from dataclasses import dataclass +from typing import Optional, Dict, Any + + +class GestureType(Enum): + """Touch gesture types from HAL""" + TAP = "tap" # Single finger tap + LONG_PRESS = "long_press" # Hold for 500ms+ + SWIPE_LEFT = "swipe_left" # Swipe left (page forward) + SWIPE_RIGHT = "swipe_right" # Swipe right (page back) + SWIPE_UP = "swipe_up" # Swipe up (scroll down) + SWIPE_DOWN = "swipe_down" # Swipe down (scroll up) + PINCH_IN = "pinch_in" # Pinch fingers together (zoom out) + PINCH_OUT = "pinch_out" # Spread fingers apart (zoom in) + DRAG_START = "drag_start" # Start dragging/selection + DRAG_MOVE = "drag_move" # Continue dragging + DRAG_END = "drag_end" # End dragging/selection + + +@dataclass +class TouchEvent: + """ + Touch event from HAL. + + Represents a single touch gesture with its coordinates and metadata. + """ + gesture: GestureType + x: int # Primary touch point X coordinate + y: int # Primary touch point Y coordinate + x2: Optional[int] = None # Secondary point X (for pinch/drag) + y2: Optional[int] = None # Secondary point Y (for pinch/drag) + timestamp_ms: float = 0 # Timestamp in milliseconds + + @classmethod + def from_hal(cls, hal_data: dict) -> 'TouchEvent': + """ + Parse a touch event from HAL format. + + Args: + hal_data: Dictionary with gesture data from HAL + Expected keys: 'gesture', 'x', 'y', optionally 'x2', 'y2', 'timestamp' + + Returns: + TouchEvent instance + + Example: + >>> event = TouchEvent.from_hal({ + ... 'gesture': 'tap', + ... 'x': 450, + ... 'y': 320 + ... }) + """ + return cls( + gesture=GestureType(hal_data['gesture']), + x=hal_data['x'], + y=hal_data['y'], + x2=hal_data.get('x2'), + y2=hal_data.get('y2'), + timestamp_ms=hal_data.get('timestamp', 0) + ) + + def to_dict(self) -> dict: + """Convert to dictionary for serialization""" + return { + 'gesture': self.gesture.value, + 'x': self.x, + 'y': self.y, + 'x2': self.x2, + 'y2': self.y2, + 'timestamp_ms': self.timestamp_ms + } + + +@dataclass +class GestureResponse: + """ + Response from handling a gesture. + + This encapsulates the action that should be performed by the UI + in response to a gesture, keeping all business logic in the library. + """ + action: str # Action type: "navigate", "define", "select", "zoom", "page_turn", "none", etc. + data: Dict[str, Any] # Action-specific data + + def to_dict(self) -> dict: + """ + Convert to dictionary for Flask JSON response. + + Returns: + Dictionary with action and data + """ + return { + 'action': self.action, + 'data': self.data + } + + +# Action type constants for clarity +class ActionType: + """Constants for gesture response action types""" + NONE = "none" + PAGE_TURN = "page_turn" + NAVIGATE = "navigate" + DEFINE = "define" + SELECT = "select" + ZOOM = "zoom" + BOOK_LOADED = "book_loaded" + WORD_SELECTED = "word_selected" + SHOW_MENU = "show_menu" + SELECTION_START = "selection_start" + SELECTION_UPDATE = "selection_update" + SELECTION_COMPLETE = "selection_complete" + AT_START = "at_start" + AT_END = "at_end" + ERROR = "error" diff --git a/pyWebLayout/layout/ereader_application.py b/pyWebLayout/layout/ereader_application.py deleted file mode 100644 index 5f30095..0000000 --- a/pyWebLayout/layout/ereader_application.py +++ /dev/null @@ -1,632 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple ereader application interface for pyWebLayout. - -This module provides a user-friendly wrapper around the ereader infrastructure, -making it easy to build ebook reader applications with all essential features. - -Example: - from pyWebLayout.layout.ereader_application import EbookReader - - # Create reader - reader = EbookReader(page_size=(800, 1000)) - - # Load an EPUB - reader.load_epub("mybook.epub") - - # Navigate - reader.next_page() - reader.previous_page() - - # Get current page - page_image = reader.get_current_page() - - # Modify styling - reader.increase_font_size() - reader.set_line_spacing(8) - - # Chapter navigation - chapters = reader.get_chapters() - reader.jump_to_chapter("Chapter 1") - - # Position management - reader.save_position("bookmark1") - reader.load_position("bookmark1") -""" - -from __future__ import annotations -from typing import List, Tuple, Optional, Dict, Any, Union -from pathlib import Path -import os - -from PIL import Image - -from pyWebLayout.io.readers.epub_reader import read_epub -from pyWebLayout.abstract.block import Block, HeadingLevel -from pyWebLayout.layout.ereader_manager import EreaderLayoutManager -from pyWebLayout.layout.ereader_layout import RenderingPosition -from pyWebLayout.style.page_style import PageStyle -from pyWebLayout.concrete.page import Page - - -class EbookReader: - """ - Simple ereader application with all essential features. - - Features: - - Load EPUB files - - Forward/backward page navigation - - Position save/load (based on abstract document structure) - - Chapter navigation - - Font size and spacing control - - Current page retrieval as PIL Image - - The reader maintains position using abstract document structure (chapter/block/word indices), - ensuring positions remain valid across font size and styling changes. - """ - - def __init__(self, - page_size: Tuple[int, int] = (800, 1000), - margin: int = 40, - background_color: Tuple[int, int, int] = (255, 255, 255), - line_spacing: int = 5, - inter_block_spacing: int = 15, - bookmarks_dir: str = "ereader_bookmarks", - buffer_size: int = 5): - """ - Initialize the ebook reader. - - Args: - page_size: Page dimensions (width, height) in pixels - margin: Page margin in pixels - background_color: Background color as RGB tuple - line_spacing: Spacing between lines in pixels - inter_block_spacing: Spacing between blocks in pixels - bookmarks_dir: Directory to store bookmarks and positions - buffer_size: Number of pages to cache for performance - """ - self.page_size = page_size - self.bookmarks_dir = bookmarks_dir - self.buffer_size = buffer_size - - # Create page style - self.page_style = PageStyle( - background_color=background_color, - border_width=margin, - border_color=(200, 200, 200), - padding=(10, 10, 10, 10), - line_spacing=line_spacing, - inter_block_spacing=inter_block_spacing - ) - - # State - self.manager: Optional[EreaderLayoutManager] = None - self.blocks: Optional[List[Block]] = None - self.document_id: Optional[str] = None - self.book_title: Optional[str] = None - self.book_author: Optional[str] = None - - # Font scale state - self.base_font_scale = 1.0 - self.font_scale_step = 0.1 # 10% change per step - - def load_epub(self, epub_path: str) -> bool: - """ - Load an EPUB file into the reader. - - Args: - epub_path: Path to the EPUB file - - Returns: - True if loaded successfully, False otherwise - """ - try: - # Validate path - if not os.path.exists(epub_path): - raise FileNotFoundError(f"EPUB file not found: {epub_path}") - - # Load the EPUB - book = read_epub(epub_path) - - # Extract metadata - self.book_title = book.get_title() or "Unknown Title" - self.book_author = book.get_metadata('AUTHOR') or "Unknown Author" - - # Create document ID from filename - self.document_id = Path(epub_path).stem - - # Extract all blocks from chapters - self.blocks = [] - for chapter in book.chapters: - if hasattr(chapter, '_blocks'): - self.blocks.extend(chapter._blocks) - - if not self.blocks: - raise ValueError("No content blocks found in EPUB") - - # Initialize the ereader manager - self.manager = EreaderLayoutManager( - blocks=self.blocks, - page_size=self.page_size, - document_id=self.document_id, - buffer_size=self.buffer_size, - page_style=self.page_style, - bookmarks_dir=self.bookmarks_dir - ) - - return True - - except Exception as e: - print(f"Error loading EPUB: {e}") - return False - - def is_loaded(self) -> bool: - """Check if a book is currently loaded.""" - return self.manager is not None - - def get_current_page(self) -> Optional[Image.Image]: - """ - Get the current page as a PIL Image. - - Returns: - PIL Image of the current page, or None if no book is loaded - """ - if not self.manager: - return None - - try: - page = self.manager.get_current_page() - return page.render() - except Exception as e: - print(f"Error rendering page: {e}") - return None - - def next_page(self) -> Optional[Image.Image]: - """ - Navigate to the next page. - - Returns: - PIL Image of the next page, or None if at end of book - """ - if not self.manager: - return None - - try: - page = self.manager.next_page() - if page: - return page.render() - return None - except Exception as e: - print(f"Error navigating to next page: {e}") - return None - - def previous_page(self) -> Optional[Image.Image]: - """ - Navigate to the previous page. - - Returns: - PIL Image of the previous page, or None if at beginning of book - """ - if not self.manager: - return None - - try: - page = self.manager.previous_page() - if page: - return page.render() - return None - except Exception as e: - print(f"Error navigating to previous page: {e}") - return None - - def save_position(self, name: str = "current_position") -> bool: - """ - Save the current reading position with a name. - - The position is saved based on abstract document structure (chapter, block, word indices), - making it stable across font size and styling changes. - - Args: - name: Name for this saved position - - Returns: - True if saved successfully, False otherwise - """ - if not self.manager: - return False - - try: - self.manager.add_bookmark(name) - return True - except Exception as e: - print(f"Error saving position: {e}") - return False - - def load_position(self, name: str = "current_position") -> Optional[Image.Image]: - """ - Load a previously saved reading position. - - Args: - name: Name of the saved position - - Returns: - PIL Image of the page at the loaded position, or None if not found - """ - if not self.manager: - return None - - try: - page = self.manager.jump_to_bookmark(name) - if page: - return page.render() - return None - except Exception as e: - print(f"Error loading position: {e}") - return None - - def list_saved_positions(self) -> List[str]: - """ - Get a list of all saved position names. - - Returns: - List of position names - """ - if not self.manager: - return [] - - try: - bookmarks = self.manager.list_bookmarks() - return [name for name, _ in bookmarks] - except Exception as e: - print(f"Error listing positions: {e}") - return [] - - def delete_position(self, name: str) -> bool: - """ - Delete a saved position. - - Args: - name: Name of the position to delete - - Returns: - True if deleted, False otherwise - """ - if not self.manager: - return False - - return self.manager.remove_bookmark(name) - - def get_chapters(self) -> List[Tuple[str, int]]: - """ - Get a list of all chapters with their indices. - - Returns: - List of (chapter_title, chapter_index) tuples - """ - if not self.manager: - return [] - - try: - toc = self.manager.get_table_of_contents() - # Convert to simplified format (title, index) - chapters = [] - for i, (title, level, position) in enumerate(toc): - chapters.append((title, i)) - return chapters - except Exception as e: - print(f"Error getting chapters: {e}") - return [] - - def get_chapter_positions(self) -> List[Tuple[str, RenderingPosition]]: - """ - Get chapter titles with their exact rendering positions. - - Returns: - List of (title, position) tuples - """ - if not self.manager: - return [] - - try: - toc = self.manager.get_table_of_contents() - return [(title, position) for title, level, position in toc] - except Exception as e: - print(f"Error getting chapter positions: {e}") - return [] - - def jump_to_chapter(self, chapter: Union[str, int]) -> Optional[Image.Image]: - """ - Navigate to a specific chapter by title or index. - - Args: - chapter: Chapter title (string) or chapter index (integer) - - Returns: - PIL Image of the first page of the chapter, or None if not found - """ - if not self.manager: - return None - - try: - if isinstance(chapter, int): - page = self.manager.jump_to_chapter_index(chapter) - else: - page = self.manager.jump_to_chapter(chapter) - - if page: - return page.render() - return None - except Exception as e: - print(f"Error jumping to chapter: {e}") - return None - - def set_font_size(self, scale: float) -> Optional[Image.Image]: - """ - Set the font size scale and re-render current page. - - Args: - scale: Font scale factor (1.0 = normal, 2.0 = double size, 0.5 = half size) - - Returns: - PIL Image of the re-rendered page with new font size - """ - if not self.manager: - return None - - try: - self.base_font_scale = max(0.5, min(3.0, scale)) # Clamp between 0.5x and 3.0x - page = self.manager.set_font_scale(self.base_font_scale) - return page.render() - except Exception as e: - print(f"Error setting font size: {e}") - return None - - def increase_font_size(self) -> Optional[Image.Image]: - """ - Increase font size by one step and re-render. - - Returns: - PIL Image of the re-rendered page - """ - new_scale = self.base_font_scale + self.font_scale_step - return self.set_font_size(new_scale) - - def decrease_font_size(self) -> Optional[Image.Image]: - """ - Decrease font size by one step and re-render. - - Returns: - PIL Image of the re-rendered page - """ - new_scale = self.base_font_scale - self.font_scale_step - return self.set_font_size(new_scale) - - def get_font_size(self) -> float: - """ - Get the current font size scale. - - Returns: - Current font scale factor - """ - return self.base_font_scale - - def set_line_spacing(self, spacing: int) -> Optional[Image.Image]: - """ - Set line spacing and re-render current page. - - Args: - spacing: Line spacing in pixels - - Returns: - PIL Image of the re-rendered page - """ - if not self.manager: - return None - - try: - # Update page style - self.page_style.line_spacing = max(0, spacing) - - # Need to recreate the manager with new page style - current_pos = self.manager.current_position - current_font_scale = self.base_font_scale - self.manager.shutdown() - - self.manager = EreaderLayoutManager( - blocks=self.blocks, - page_size=self.page_size, - document_id=self.document_id, - buffer_size=self.buffer_size, - page_style=self.page_style, - bookmarks_dir=self.bookmarks_dir - ) - - # Restore position - self.manager.current_position = current_pos - - # Restore font scale using the method (not direct assignment) - if current_font_scale != 1.0: - self.manager.set_font_scale(current_font_scale) - - page = self.manager.get_current_page() - return page.render() - except Exception as e: - print(f"Error setting line spacing: {e}") - return None - - def set_inter_block_spacing(self, spacing: int) -> Optional[Image.Image]: - """ - Set spacing between blocks (paragraphs, headings, etc.) and re-render. - - Args: - spacing: Inter-block spacing in pixels - - Returns: - PIL Image of the re-rendered page - """ - if not self.manager: - return None - - try: - # Update page style - self.page_style.inter_block_spacing = max(0, spacing) - - # Need to recreate the manager with new page style - current_pos = self.manager.current_position - current_font_scale = self.base_font_scale - self.manager.shutdown() - - self.manager = EreaderLayoutManager( - blocks=self.blocks, - page_size=self.page_size, - document_id=self.document_id, - buffer_size=self.buffer_size, - page_style=self.page_style, - bookmarks_dir=self.bookmarks_dir - ) - - # Restore position - self.manager.current_position = current_pos - - # Restore font scale using the method (not direct assignment) - if current_font_scale != 1.0: - self.manager.set_font_scale(current_font_scale) - - page = self.manager.get_current_page() - return page.render() - except Exception as e: - print(f"Error setting inter-block spacing: {e}") - return None - - def get_position_info(self) -> Dict[str, Any]: - """ - Get detailed information about the current position. - - Returns: - Dictionary with position details including: - - position: RenderingPosition details (chapter_index, block_index, word_index) - - chapter: Current chapter info (title, level) - - progress: Reading progress (0.0 to 1.0) - - font_scale: Current font scale - - book_title: Book title - - book_author: Book author - """ - if not self.manager: - return {} - - try: - info = self.manager.get_position_info() - info['book_title'] = self.book_title - info['book_author'] = self.book_author - return info - except Exception as e: - print(f"Error getting position info: {e}") - return {} - - def get_reading_progress(self) -> float: - """ - Get reading progress as a percentage. - - Returns: - Progress from 0.0 (beginning) to 1.0 (end) - """ - if not self.manager: - return 0.0 - - return self.manager.get_reading_progress() - - def get_current_chapter_info(self) -> Optional[Dict[str, Any]]: - """ - Get information about the current chapter. - - Returns: - Dictionary with chapter info (title, level) or None - """ - if not self.manager: - return None - - try: - chapter = self.manager.get_current_chapter() - if chapter: - return { - 'title': chapter.title, - 'level': chapter.level, - 'block_index': chapter.block_index - } - return None - except Exception as e: - print(f"Error getting current chapter: {e}") - return None - - def render_to_file(self, output_path: str) -> bool: - """ - Save the current page to an image file. - - Args: - output_path: Path where to save the image (e.g., "page.png") - - Returns: - True if saved successfully, False otherwise - """ - page_image = self.get_current_page() - if page_image: - try: - page_image.save(output_path) - return True - except Exception as e: - print(f"Error saving image: {e}") - return False - return False - - def get_book_info(self) -> Dict[str, Any]: - """ - Get information about the loaded book. - - Returns: - Dictionary with book information - """ - return { - 'title': self.book_title, - 'author': self.book_author, - 'document_id': self.document_id, - 'total_blocks': len(self.blocks) if self.blocks else 0, - 'total_chapters': len(self.get_chapters()), - 'page_size': self.page_size, - 'font_scale': self.base_font_scale - } - - def close(self): - """ - Close the reader and save current position. - Should be called when done with the reader. - """ - if self.manager: - self.manager.shutdown() - self.manager = None - - def __enter__(self): - """Context manager support.""" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager cleanup.""" - self.close() - - def __del__(self): - """Cleanup on deletion.""" - self.close() - - -# Convenience function -def create_ebook_reader(page_size: Tuple[int, int] = (800, 1000), **kwargs) -> EbookReader: - """ - Create an ebook reader with sensible defaults. - - Args: - page_size: Page dimensions (width, height) in pixels - **kwargs: Additional arguments passed to EbookReader - - Returns: - Configured EbookReader instance - """ - return EbookReader(page_size=page_size, **kwargs) diff --git a/tests/concrete/test_new_page_implementation.py b/tests/concrete/test_new_page_implementation.py index f0e689c..e446825 100644 --- a/tests/concrete/test_new_page_implementation.py +++ b/tests/concrete/test_new_page_implementation.py @@ -18,11 +18,11 @@ from pyWebLayout.core.base import Renderable, Queriable class SimpleTestRenderable(Renderable, Queriable): """A simple test renderable for testing the page system""" - + def __init__(self, text: str, size: tuple = (100, 50)): self._text = text self.size = size - self.origin = np.array([0, 0]) + self._origin = np.array([0, 0]) def render(self): """Render returns None - drawing is done via the page's draw object""" @@ -145,25 +145,28 @@ class TestPageImplementation(unittest.TestCase): def test_page_query_point(self): """Test querying points to find children""" page = Page(size=(400, 300)) - + # Add children with known positions and sizes child1 = SimpleTestRenderable("Child 1", (100, 50)) child2 = SimpleTestRenderable("Child 2", (80, 40)) - + page.add_child(child1).add_child(child2) # Query points # Point within first child - found_child = page.query_point((90, 30)) - self.assertEqual(found_child, child1) - + result = page.query_point((90, 30)) + self.assertIsNotNone(result) + self.assertEqual(result.object, child1) + # Point within second child - found_child = page.query_point((30, 30)) - self.assertEqual(found_child, child2) - - # Point outside any child - found_child = page.query_point((300, 250)) - self.assertIsNone(found_child) + result = page.query_point((30, 30)) + self.assertIsNotNone(result) + self.assertEqual(result.object, child2) + + # Point outside any child - returns QueryResult with object_type "empty" + result = page.query_point((300, 250)) + self.assertIsNotNone(result) + self.assertEqual(result.object_type, "empty") def test_page_in_object(self): """Test that page correctly implements in_object""" diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..60e07a9 --- /dev/null +++ b/tests/core/__init__.py @@ -0,0 +1 @@ +"""Tests for core pyWebLayout functionality.""" diff --git a/tests/core/test_highlight.py b/tests/core/test_highlight.py new file mode 100644 index 0000000..be09dea --- /dev/null +++ b/tests/core/test_highlight.py @@ -0,0 +1,353 @@ +""" +Unit tests for the highlight system. + +Tests Highlight, HighlightColor, HighlightManager, and integration with query system. +""" + +import unittest +import tempfile +import shutil +from pathlib import Path +import json + +from pyWebLayout.core.highlight import ( + Highlight, + HighlightColor, + HighlightManager, + create_highlight_from_query_result +) +from pyWebLayout.core.query import QueryResult, SelectionRange + + +class TestHighlightColor(unittest.TestCase): + """Test HighlightColor enum""" + + def test_colors_defined(self): + """Test all expected colors are defined""" + expected_colors = ['YELLOW', 'GREEN', 'BLUE', 'PINK', 'ORANGE', 'PURPLE', 'RED'] + + for color_name in expected_colors: + self.assertTrue(hasattr(HighlightColor, color_name)) + color = getattr(HighlightColor, color_name) + self.assertIsInstance(color.value, tuple) + self.assertEqual(len(color.value), 4) # RGBA + + def test_yellow_is_default(self): + """Test yellow highlight color""" + yellow = HighlightColor.YELLOW.value + self.assertEqual(yellow, (255, 255, 0, 100)) + + +class TestHighlight(unittest.TestCase): + """Test Highlight dataclass""" + + def test_init_basic(self): + """Test basic Highlight creation""" + highlight = Highlight( + id="test-id", + bounds=[(10, 20, 50, 15)], + color=(255, 255, 0, 100), + text="Hello" + ) + + self.assertEqual(highlight.id, "test-id") + self.assertEqual(len(highlight.bounds), 1) + self.assertEqual(highlight.bounds[0], (10, 20, 50, 15)) + self.assertEqual(highlight.color, (255, 255, 0, 100)) + self.assertEqual(highlight.text, "Hello") + self.assertIsNone(highlight.note) + self.assertEqual(highlight.tags, []) + + def test_init_with_metadata(self): + """Test Highlight with full metadata""" + highlight = Highlight( + id="test-id", + bounds=[(10, 20, 50, 15)], + color=(255, 255, 0, 100), + text="Hello", + note="Important word", + tags=["important", "keyword"], + timestamp=1234567890.0, + start_word_index=5, + end_word_index=5 + ) + + self.assertEqual(highlight.note, "Important word") + self.assertEqual(highlight.tags, ["important", "keyword"]) + self.assertEqual(highlight.timestamp, 1234567890.0) + self.assertEqual(highlight.start_word_index, 5) + self.assertEqual(highlight.end_word_index, 5) + + def test_to_dict(self): + """Test Highlight serialization""" + highlight = Highlight( + id="test-id", + bounds=[(10, 20, 50, 15), (70, 20, 40, 15)], + color=(255, 255, 0, 100), + text="Hello world", + note="Test note", + tags=["test"], + timestamp=1234567890.0 + ) + + data = highlight.to_dict() + + self.assertEqual(data['id'], "test-id") + self.assertEqual(len(data['bounds']), 2) + self.assertEqual(data['bounds'][0], (10, 20, 50, 15)) + self.assertEqual(data['color'], (255, 255, 0, 100)) + self.assertEqual(data['text'], "Hello world") + self.assertEqual(data['note'], "Test note") + self.assertEqual(data['tags'], ["test"]) + self.assertEqual(data['timestamp'], 1234567890.0) + + def test_from_dict(self): + """Test Highlight deserialization""" + data = { + 'id': "test-id", + 'bounds': [[10, 20, 50, 15], [70, 20, 40, 15]], + 'color': [255, 255, 0, 100], + 'text': "Hello world", + 'note': "Test note", + 'tags': ["test"], + 'timestamp': 1234567890.0, + 'start_word_index': 5, + 'end_word_index': 6 + } + + highlight = Highlight.from_dict(data) + + self.assertEqual(highlight.id, "test-id") + self.assertEqual(len(highlight.bounds), 2) + self.assertEqual(highlight.bounds[0], (10, 20, 50, 15)) + self.assertEqual(highlight.color, (255, 255, 0, 100)) + self.assertEqual(highlight.text, "Hello world") + self.assertEqual(highlight.note, "Test note") + self.assertEqual(highlight.tags, ["test"]) + self.assertEqual(highlight.start_word_index, 5) + self.assertEqual(highlight.end_word_index, 6) + + +class TestHighlightManager(unittest.TestCase): + """Test HighlightManager class""" + + def setUp(self): + """Create temporary directory for highlights""" + self.temp_dir = tempfile.mkdtemp() + self.manager = HighlightManager( + document_id="test-doc", + highlights_dir=self.temp_dir + ) + + def tearDown(self): + """Clean up temporary directory""" + shutil.rmtree(self.temp_dir) + + def test_init(self): + """Test HighlightManager initialization""" + self.assertEqual(self.manager.document_id, "test-doc") + self.assertEqual(self.manager.highlights_dir, Path(self.temp_dir)) + self.assertEqual(len(self.manager.highlights), 0) + + def test_add_highlight(self): + """Test adding a highlight""" + highlight = Highlight( + id="test-1", + bounds=[(10, 20, 50, 15)], + color=(255, 255, 0, 100), + text="Test" + ) + + self.manager.add_highlight(highlight) + + self.assertEqual(len(self.manager.highlights), 1) + self.assertIn("test-1", self.manager.highlights) + self.assertEqual(self.manager.highlights["test-1"], highlight) + + def test_remove_highlight(self): + """Test removing a highlight""" + highlight = Highlight( + id="test-1", + bounds=[(10, 20, 50, 15)], + color=(255, 255, 0, 100), + text="Test" + ) + + self.manager.add_highlight(highlight) + self.assertEqual(len(self.manager.highlights), 1) + + result = self.manager.remove_highlight("test-1") + self.assertTrue(result) + self.assertEqual(len(self.manager.highlights), 0) + + def test_remove_nonexistent_highlight(self): + """Test removing a highlight that doesn't exist""" + result = self.manager.remove_highlight("nonexistent") + self.assertFalse(result) + + def test_get_highlight(self): + """Test getting a highlight by ID""" + highlight = Highlight( + id="test-1", + bounds=[(10, 20, 50, 15)], + color=(255, 255, 0, 100), + text="Test" + ) + + self.manager.add_highlight(highlight) + retrieved = self.manager.get_highlight("test-1") + + self.assertIsNotNone(retrieved) + self.assertEqual(retrieved.id, "test-1") + self.assertEqual(retrieved.text, "Test") + + def test_list_highlights(self): + """Test listing all highlights""" + highlight1 = Highlight( + id="test-1", + bounds=[(10, 20, 50, 15)], + color=(255, 255, 0, 100), + text="First" + ) + highlight2 = Highlight( + id="test-2", + bounds=[(100, 20, 50, 15)], + color=(100, 255, 100, 100), + text="Second" + ) + + self.manager.add_highlight(highlight1) + self.manager.add_highlight(highlight2) + + highlights = self.manager.list_highlights() + self.assertEqual(len(highlights), 2) + self.assertIn(highlight1, highlights) + self.assertIn(highlight2, highlights) + + def test_clear_all(self): + """Test clearing all highlights""" + highlight1 = Highlight( + id="test-1", + bounds=[(10, 20, 50, 15)], + color=(255, 255, 0, 100), + text="First" + ) + highlight2 = Highlight( + id="test-2", + bounds=[(100, 20, 50, 15)], + color=(100, 255, 100, 100), + text="Second" + ) + + self.manager.add_highlight(highlight1) + self.manager.add_highlight(highlight2) + self.assertEqual(len(self.manager.highlights), 2) + + self.manager.clear_all() + self.assertEqual(len(self.manager.highlights), 0) + + def test_persistence(self): + """Test that highlights are persisted to disk""" + highlight = Highlight( + id="test-1", + bounds=[(10, 20, 50, 15)], + color=(255, 255, 0, 100), + text="Persisted" + ) + + self.manager.add_highlight(highlight) + + # Create new manager for same document + new_manager = HighlightManager( + document_id="test-doc", + highlights_dir=self.temp_dir + ) + + # Should load existing highlights + self.assertEqual(len(new_manager.highlights), 1) + self.assertIn("test-1", new_manager.highlights) + self.assertEqual(new_manager.highlights["test-1"].text, "Persisted") + + def test_get_highlights_for_page(self): + """Test filtering highlights by page bounds""" + # Highlight on page + highlight1 = Highlight( + id="test-1", + bounds=[(100, 100, 50, 15)], + color=(255, 255, 0, 100), + text="On page" + ) + + # Highlight off page + highlight2 = Highlight( + id="test-2", + bounds=[(1000, 1000, 50, 15)], + color=(255, 255, 0, 100), + text="Off page" + ) + + self.manager.add_highlight(highlight1) + self.manager.add_highlight(highlight2) + + # Page bounds (0, 0, 800, 1000) + page_bounds = (0, 0, 800, 1000) + page_highlights = self.manager.get_highlights_for_page(page_bounds) + + self.assertEqual(len(page_highlights), 1) + self.assertEqual(page_highlights[0].id, "test-1") + + +class TestCreateHighlightFromQueryResult(unittest.TestCase): + """Test create_highlight_from_query_result function""" + + def test_create_from_single_result(self): + """Test creating highlight from single QueryResult""" + result = QueryResult( + object=object(), + object_type="text", + bounds=(10, 20, 50, 15), + text="Hello" + ) + + highlight = create_highlight_from_query_result( + result, + color=(255, 255, 0, 100), + note="Test note", + tags=["test"] + ) + + self.assertIsNotNone(highlight.id) + self.assertEqual(len(highlight.bounds), 1) + self.assertEqual(highlight.bounds[0], (10, 20, 50, 15)) + self.assertEqual(highlight.color, (255, 255, 0, 100)) + self.assertEqual(highlight.text, "Hello") + self.assertEqual(highlight.note, "Test note") + self.assertEqual(highlight.tags, ["test"]) + self.assertIsNotNone(highlight.timestamp) + + def test_create_from_selection_range(self): + """Test creating highlight from SelectionRange""" + results = [ + QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"), + QueryResult(object(), "text", (45, 20, 35, 15), text="world") + ] + + sel_range = SelectionRange((10, 20), (80, 35), results) + + highlight = create_highlight_from_query_result( + sel_range, + color=(100, 255, 100, 100), + note="Multi-word" + ) + + self.assertIsNotNone(highlight.id) + self.assertEqual(len(highlight.bounds), 2) + self.assertEqual(highlight.bounds[0], (10, 20, 30, 15)) + self.assertEqual(highlight.bounds[1], (45, 20, 35, 15)) + self.assertEqual(highlight.color, (100, 255, 100, 100)) + self.assertEqual(highlight.text, "Hello world") + self.assertEqual(highlight.note, "Multi-word") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/core/test_query_system.py b/tests/core/test_query_system.py new file mode 100644 index 0000000..01e9f86 --- /dev/null +++ b/tests/core/test_query_system.py @@ -0,0 +1,424 @@ +""" +Unit tests for the query system (pixel-to-content mapping). + +Tests the QueryResult, SelectionRange, and query_point functionality +across Page, Line, and Text classes. +""" + +import unittest +import numpy as np +from PIL import Image, ImageDraw + +from pyWebLayout.core.query import QueryResult, SelectionRange +from pyWebLayout.concrete.page import Page +from pyWebLayout.concrete.text import Text, Line +from pyWebLayout.concrete.functional import LinkText +from pyWebLayout.abstract.inline import Word +from pyWebLayout.abstract.functional import Link, LinkType +from pyWebLayout.style import Font, Alignment +from pyWebLayout.style.page_style import PageStyle +from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests + + +class TestQueryResult(unittest.TestCase): + """Test QueryResult dataclass""" + + def test_init_basic(self): + """Test basic QueryResult creation""" + obj = object() + result = QueryResult( + object=obj, + object_type="text", + bounds=(100, 200, 50, 20) + ) + + self.assertEqual(result.object, obj) + self.assertEqual(result.object_type, "text") + self.assertEqual(result.bounds, (100, 200, 50, 20)) + self.assertIsNone(result.text) + self.assertFalse(result.is_interactive) + + def test_init_with_metadata(self): + """Test QueryResult with full metadata""" + obj = object() + result = QueryResult( + object=obj, + object_type="link", + bounds=(100, 200, 50, 20), + text="Click here", + is_interactive=True, + link_target="chapter2" + ) + + self.assertEqual(result.text, "Click here") + self.assertTrue(result.is_interactive) + self.assertEqual(result.link_target, "chapter2") + + def test_to_dict(self): + """Test QueryResult serialization""" + result = QueryResult( + object=object(), + object_type="link", + bounds=(100, 200, 50, 20), + text="Click here", + is_interactive=True, + link_target="chapter2" + ) + + d = result.to_dict() + self.assertEqual(d['object_type'], "link") + self.assertEqual(d['bounds'], (100, 200, 50, 20)) + self.assertEqual(d['text'], "Click here") + self.assertTrue(d['is_interactive']) + self.assertEqual(d['link_target'], "chapter2") + + +class TestSelectionRange(unittest.TestCase): + """Test SelectionRange dataclass""" + + def test_init(self): + """Test SelectionRange creation""" + results = [] + sel_range = SelectionRange( + start_point=(10, 20), + end_point=(100, 30), + results=results + ) + + self.assertEqual(sel_range.start_point, (10, 20)) + self.assertEqual(sel_range.end_point, (100, 30)) + self.assertEqual(sel_range.results, results) + + def test_text_property(self): + """Test concatenated text extraction""" + results = [ + QueryResult(object(), "text", (0, 0, 0, 0), text="Hello"), + QueryResult(object(), "text", (0, 0, 0, 0), text="world"), + QueryResult(object(), "text", (0, 0, 0, 0), text="test") + ] + + sel_range = SelectionRange((0, 0), (100, 100), results) + self.assertEqual(sel_range.text, "Hello world test") + + def test_bounds_list_property(self): + """Test bounds list extraction""" + results = [ + QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"), + QueryResult(object(), "text", (45, 20, 35, 15), text="world") + ] + + sel_range = SelectionRange((0, 0), (100, 100), results) + bounds = sel_range.bounds_list + + self.assertEqual(len(bounds), 2) + self.assertEqual(bounds[0], (10, 20, 30, 15)) + self.assertEqual(bounds[1], (45, 20, 35, 15)) + + def test_to_dict(self): + """Test SelectionRange serialization""" + results = [ + QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"), + QueryResult(object(), "text", (45, 20, 35, 15), text="world") + ] + + sel_range = SelectionRange((10, 20), (80, 35), results) + d = sel_range.to_dict() + + self.assertEqual(d['start'], (10, 20)) + self.assertEqual(d['end'], (80, 35)) + self.assertEqual(d['text'], "Hello world") + self.assertEqual(d['word_count'], 2) + self.assertEqual(len(d['bounds']), 2) + + +class TestTextQueryPoint(unittest.TestCase): + """Test Text class in_object (from Queriable mixin)""" + + def setUp(self): + ensure_consistent_font_in_tests() + self.canvas = Image.new('RGB', (800, 600), color='white') + self.draw = ImageDraw.Draw(self.canvas) + self.font = create_default_test_font() + + def test_in_object_hit(self): + """Test in_object returns True for point inside text""" + text = Text("Hello", self.font, self.draw) + text.set_origin(np.array([100, 100])) + + # Point inside text bounds + self.assertTrue(text.in_object(np.array([110, 105]))) + + def test_in_object_miss(self): + """Test in_object returns False for point outside text""" + text = Text("Hello", self.font, self.draw) + text.set_origin(np.array([100, 100])) + + # Point outside text bounds + self.assertFalse(text.in_object(np.array([50, 50]))) + self.assertFalse(text.in_object(np.array([200, 200]))) + + +class TestLineQueryPoint(unittest.TestCase): + """Test Line.query_point method""" + + def setUp(self): + ensure_consistent_font_in_tests() + self.canvas = Image.new('RGB', (800, 600), color='white') + self.draw = ImageDraw.Draw(self.canvas) + self.font = create_default_test_font() + + def test_query_point_finds_text(self): + """Test Line.query_point finds a text object""" + line = Line( + spacing=(5, 10), + origin=np.array([50, 100]), + size=(700, 30), + draw=self.draw, + font=self.font + ) + + # Add text objects + word1 = Word("Hello", self.font) + word2 = Word("world", self.font) + + line.add_word(word1) + line.add_word(word2) + line.render() + + # Query a point that should hit first word + # (after rendering, text objects have positions set) + if len(line._text_objects) > 0: + text_obj = line._text_objects[0] + point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5)) + + result = line.query_point(point) + + self.assertIsNotNone(result) + self.assertEqual(result.object_type, "text") + self.assertIsNotNone(result.text) + + def test_query_point_miss(self): + """Test Line.query_point returns None for miss""" + line = Line( + spacing=(5, 10), + origin=np.array([50, 100]), + size=(700, 30), + draw=self.draw, + font=self.font + ) + + word1 = Word("Hello", self.font) + line.add_word(word1) + line.render() + + # Query far outside line bounds + result = line.query_point((10, 10)) + self.assertIsNone(result) + + def test_query_point_finds_link(self): + """Test Line.query_point correctly identifies links""" + line = Line( + spacing=(5, 10), + origin=np.array([50, 100]), + size=(700, 30), + draw=self.draw, + font=self.font + ) + + # Create a linked word + from pyWebLayout.abstract.inline import LinkedWord + linked_word = LinkedWord("Click", self.font, "chapter2", LinkType.INTERNAL) + + line.add_word(linked_word) + line.render() + + # Query the link + if len(line._text_objects) > 0: + text_obj = line._text_objects[0] + point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5)) + + result = line.query_point(point) + + self.assertIsNotNone(result) + self.assertEqual(result.object_type, "link") + self.assertTrue(result.is_interactive) + self.assertEqual(result.link_target, "chapter2") + + +class TestPageQueryPoint(unittest.TestCase): + """Test Page.query_point method""" + + def setUp(self): + ensure_consistent_font_in_tests() + self.page = Page(size=(800, 1000), style=PageStyle()) + self.font = create_default_test_font() + + def test_query_point_empty_page(self): + """Test querying empty page returns empty result""" + result = self.page.query_point((400, 500)) + + self.assertIsNotNone(result) + self.assertEqual(result.object_type, "empty") + self.assertEqual(result.object, self.page) + + def test_query_point_finds_line(self): + """Test Page.query_point traverses to Line""" + line = Line( + spacing=(5, 10), + origin=np.array([50, 100]), + size=(700, 30), + draw=self.page.draw, + font=self.font + ) + + word = Word("Hello", self.font) + line.add_word(word) + line.render() + + self.page.add_child(line) + + # Query a point inside the line + if len(line._text_objects) > 0: + text_obj = line._text_objects[0] + point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5)) + + result = self.page.query_point(point) + + # Should traverse Page → Line → Text + self.assertIsNotNone(result) + self.assertEqual(result.object_type, "text") + self.assertEqual(result.parent_page, self.page) + + def test_query_point_multiple_lines(self): + """Test Page.query_point with multiple lines""" + # Add two lines at different Y positions + line1 = Line( + spacing=(5, 10), + origin=np.array([50, 100]), + size=(700, 30), + draw=self.page.draw, + font=self.font + ) + line2 = Line( + spacing=(5, 10), + origin=np.array([50, 150]), + size=(700, 30), + draw=self.page.draw, + font=self.font + ) + + word1 = Word("First", self.font) + word2 = Word("Second", self.font) + + line1.add_word(word1) + line2.add_word(word2) + + line1.render() + line2.render() + + self.page.add_child(line1) + self.page.add_child(line2) + + # Query first line + if len(line1._text_objects) > 0: + text_obj1 = line1._text_objects[0] + point1 = (int(text_obj1._origin[0] + 5), int(text_obj1._origin[1] + 5)) + result1 = self.page.query_point(point1) + + self.assertIsNotNone(result1) + self.assertEqual(result1.text, "First") + + # Query second line + if len(line2._text_objects) > 0: + text_obj2 = line2._text_objects[0] + point2 = (int(text_obj2._origin[0] + 5), int(text_obj2._origin[1] + 5)) + result2 = self.page.query_point(point2) + + self.assertIsNotNone(result2) + self.assertEqual(result2.text, "Second") + + +class TestPageQueryRange(unittest.TestCase): + """Test Page.query_range method for text selection""" + + def setUp(self): + ensure_consistent_font_in_tests() + self.page = Page(size=(800, 1000), style=PageStyle()) + self.font = create_default_test_font() + + def test_query_range_single_line(self): + """Test selecting text within a single line""" + line = Line( + spacing=(5, 10), + origin=np.array([50, 100]), + size=(700, 30), + draw=self.page.draw, + font=self.font + ) + + # Add multiple words + words = [Word(text, self.font) for text in ["Hello", "world", "test"]] + for word in words: + line.add_word(word) + + line.render() + self.page.add_child(line) + + if len(line._text_objects) >= 2: + # Select from first to second word + start_text = line._text_objects[0] + end_text = line._text_objects[1] + + start_point = (int(start_text._origin[0] + 5), int(start_text._origin[1] + 5)) + end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1] + 5)) + + sel_range = self.page.query_range(start_point, end_point) + + self.assertIsNotNone(sel_range) + self.assertGreater(len(sel_range.results), 0) + self.assertIn("Hello", sel_range.text) + + def test_query_range_invalid(self): + """Test query_range with invalid points returns empty""" + sel_range = self.page.query_range((10, 10), (20, 20)) + + self.assertEqual(len(sel_range.results), 0) + self.assertEqual(sel_range.text, "") + + +class TestPageMakeQueryResult(unittest.TestCase): + """Test Page._make_query_result helper""" + + def setUp(self): + ensure_consistent_font_in_tests() + self.page = Page(size=(800, 1000), style=PageStyle()) + self.font = create_default_test_font() + self.draw = self.page.draw + + def test_make_query_result_text(self): + """Test packaging regular Text object""" + text = Text("Hello", self.font, self.draw) + text.set_origin(np.array([100, 200])) + + result = self.page._make_query_result(text, (105, 205)) + + self.assertEqual(result.object_type, "text") + self.assertEqual(result.text, "Hello") + self.assertFalse(result.is_interactive) + + def test_make_query_result_link(self): + """Test packaging LinkText object""" + link = Link(location="chapter2", link_type=LinkType.INTERNAL, callback=None) + link_text = LinkText(link, "Click here", self.font, self.draw) + link_text.set_origin(np.array([100, 200])) + + result = self.page._make_query_result(link_text, (105, 205)) + + self.assertEqual(result.object_type, "link") + self.assertEqual(result.text, "Click here") + self.assertTrue(result.is_interactive) + self.assertEqual(result.link_target, "chapter2") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/io_tests/io/__init__.py b/tests/io_tests/io/__init__.py new file mode 100644 index 0000000..56b4aba --- /dev/null +++ b/tests/io_tests/io/__init__.py @@ -0,0 +1 @@ +"""Tests for I/O functionality.""" diff --git a/tests/io_tests/io/test_gesture.py b/tests/io_tests/io/test_gesture.py new file mode 100644 index 0000000..a6064a9 --- /dev/null +++ b/tests/io_tests/io/test_gesture.py @@ -0,0 +1,287 @@ +""" +Unit tests for gesture event system. + +Tests TouchEvent, GestureType, GestureResponse, and HAL integration. +""" + +import unittest +from pyWebLayout.io.gesture import ( + GestureType, + TouchEvent, + GestureResponse, + ActionType +) + + +class TestGestureType(unittest.TestCase): + """Test GestureType enum""" + + def test_gesture_types_exist(self): + """Test all gesture types are defined""" + self.assertEqual(GestureType.TAP.value, "tap") + self.assertEqual(GestureType.LONG_PRESS.value, "long_press") + self.assertEqual(GestureType.SWIPE_LEFT.value, "swipe_left") + self.assertEqual(GestureType.SWIPE_RIGHT.value, "swipe_right") + self.assertEqual(GestureType.SWIPE_UP.value, "swipe_up") + self.assertEqual(GestureType.SWIPE_DOWN.value, "swipe_down") + self.assertEqual(GestureType.PINCH_IN.value, "pinch_in") + self.assertEqual(GestureType.PINCH_OUT.value, "pinch_out") + self.assertEqual(GestureType.DRAG_START.value, "drag_start") + self.assertEqual(GestureType.DRAG_MOVE.value, "drag_move") + self.assertEqual(GestureType.DRAG_END.value, "drag_end") + + +class TestTouchEvent(unittest.TestCase): + """Test TouchEvent dataclass""" + + def test_init_basic(self): + """Test basic TouchEvent creation""" + event = TouchEvent( + gesture=GestureType.TAP, + x=450, + y=320 + ) + + self.assertEqual(event.gesture, GestureType.TAP) + self.assertEqual(event.x, 450) + self.assertEqual(event.y, 320) + self.assertIsNone(event.x2) + self.assertIsNone(event.y2) + self.assertEqual(event.timestamp_ms, 0) + + def test_init_with_secondary_point(self): + """Test TouchEvent with secondary point (pinch/drag)""" + event = TouchEvent( + gesture=GestureType.PINCH_OUT, + x=400, + y=300, + x2=450, + y2=350, + timestamp_ms=12345.678 + ) + + self.assertEqual(event.gesture, GestureType.PINCH_OUT) + self.assertEqual(event.x, 400) + self.assertEqual(event.y, 300) + self.assertEqual(event.x2, 450) + self.assertEqual(event.y2, 350) + self.assertEqual(event.timestamp_ms, 12345.678) + + def test_from_hal_basic(self): + """Test parsing TouchEvent from HAL format""" + hal_data = { + 'gesture': 'tap', + 'x': 450, + 'y': 320 + } + + event = TouchEvent.from_hal(hal_data) + + self.assertEqual(event.gesture, GestureType.TAP) + self.assertEqual(event.x, 450) + self.assertEqual(event.y, 320) + + def test_from_hal_complete(self): + """Test parsing TouchEvent with all fields from HAL""" + hal_data = { + 'gesture': 'pinch_out', + 'x': 400, + 'y': 300, + 'x2': 450, + 'y2': 350, + 'timestamp': 12345.678 + } + + event = TouchEvent.from_hal(hal_data) + + self.assertEqual(event.gesture, GestureType.PINCH_OUT) + self.assertEqual(event.x, 400) + self.assertEqual(event.y, 300) + self.assertEqual(event.x2, 450) + self.assertEqual(event.y2, 350) + self.assertEqual(event.timestamp_ms, 12345.678) + + def test_to_dict(self): + """Test TouchEvent serialization""" + event = TouchEvent( + gesture=GestureType.SWIPE_LEFT, + x=600, + y=400, + timestamp_ms=12345.0 + ) + + d = event.to_dict() + + self.assertEqual(d['gesture'], 'swipe_left') + self.assertEqual(d['x'], 600) + self.assertEqual(d['y'], 400) + self.assertIsNone(d['x2']) + self.assertIsNone(d['y2']) + self.assertEqual(d['timestamp_ms'], 12345.0) + + +class TestGestureResponse(unittest.TestCase): + """Test GestureResponse dataclass""" + + def test_init(self): + """Test GestureResponse creation""" + response = GestureResponse( + action="page_turn", + data={"direction": "forward", "progress": 0.42} + ) + + self.assertEqual(response.action, "page_turn") + self.assertEqual(response.data['direction'], "forward") + self.assertEqual(response.data['progress'], 0.42) + + def test_to_dict(self): + """Test GestureResponse serialization""" + response = GestureResponse( + action="define", + data={"word": "ephemeral", "bounds": (100, 200, 50, 20)} + ) + + d = response.to_dict() + + self.assertEqual(d['action'], "define") + self.assertEqual(d['data']['word'], "ephemeral") + self.assertEqual(d['data']['bounds'], (100, 200, 50, 20)) + + def test_to_dict_empty_data(self): + """Test GestureResponse with empty data""" + response = GestureResponse(action="none", data={}) + + d = response.to_dict() + + self.assertEqual(d['action'], "none") + self.assertEqual(d['data'], {}) + + +class TestActionType(unittest.TestCase): + """Test ActionType constants""" + + def test_action_types_defined(self): + """Test all action type constants are defined""" + self.assertEqual(ActionType.NONE, "none") + self.assertEqual(ActionType.PAGE_TURN, "page_turn") + self.assertEqual(ActionType.NAVIGATE, "navigate") + self.assertEqual(ActionType.DEFINE, "define") + self.assertEqual(ActionType.SELECT, "select") + self.assertEqual(ActionType.ZOOM, "zoom") + self.assertEqual(ActionType.BOOK_LOADED, "book_loaded") + self.assertEqual(ActionType.WORD_SELECTED, "word_selected") + self.assertEqual(ActionType.SHOW_MENU, "show_menu") + self.assertEqual(ActionType.SELECTION_START, "selection_start") + self.assertEqual(ActionType.SELECTION_UPDATE, "selection_update") + self.assertEqual(ActionType.SELECTION_COMPLETE, "selection_complete") + self.assertEqual(ActionType.AT_START, "at_start") + self.assertEqual(ActionType.AT_END, "at_end") + self.assertEqual(ActionType.ERROR, "error") + + +class TestHALIntegration(unittest.TestCase): + """Test HAL integration scenarios""" + + def test_hal_tap_flow(self): + """Test complete HAL tap event flow""" + # Simulate HAL sending tap event + hal_data = { + 'gesture': 'tap', + 'x': 450, + 'y': 320, + 'timestamp': 1234567890.123 + } + + # Parse event + event = TouchEvent.from_hal(hal_data) + + # Verify event + self.assertEqual(event.gesture, GestureType.TAP) + self.assertEqual(event.x, 450) + self.assertEqual(event.y, 320) + + # Simulate business logic response + response = GestureResponse( + action=ActionType.WORD_SELECTED, + data={"word": "hello", "bounds": (440, 310, 50, 20)} + ) + + # Serialize for Flask + response_dict = response.to_dict() + + self.assertEqual(response_dict['action'], "word_selected") + self.assertEqual(response_dict['data']['word'], "hello") + + def test_hal_pinch_flow(self): + """Test complete HAL pinch event flow""" + # Simulate HAL sending pinch event with two touch points + hal_data = { + 'gesture': 'pinch_out', + 'x': 400, + 'y': 500, + 'x2': 500, + 'y2': 500, + 'timestamp': 1234567891.456 + } + + event = TouchEvent.from_hal(hal_data) + + self.assertEqual(event.gesture, GestureType.PINCH_OUT) + self.assertEqual(event.x, 400) + self.assertEqual(event.x2, 500) + + def test_hal_swipe_flow(self): + """Test complete HAL swipe event flow""" + hal_data = { + 'gesture': 'swipe_left', + 'x': 600, + 'y': 400 + } + + event = TouchEvent.from_hal(hal_data) + + self.assertEqual(event.gesture, GestureType.SWIPE_LEFT) + + # Expected response + response = GestureResponse( + action=ActionType.PAGE_TURN, + data={"direction": "forward", "progress": 0.25} + ) + + self.assertEqual(response.action, "page_turn") + + def test_hal_drag_selection_flow(self): + """Test complete drag selection flow""" + # Drag start + start_data = { + 'gesture': 'drag_start', + 'x': 100, + 'y': 200 + } + + start_event = TouchEvent.from_hal(start_data) + self.assertEqual(start_event.gesture, GestureType.DRAG_START) + + # Drag move + move_data = { + 'gesture': 'drag_move', + 'x': 300, + 'y': 250 + } + + move_event = TouchEvent.from_hal(move_data) + self.assertEqual(move_event.gesture, GestureType.DRAG_MOVE) + + # Drag end + end_data = { + 'gesture': 'drag_end', + 'x': 500, + 'y': 300 + } + + end_event = TouchEvent.from_hal(end_data) + self.assertEqual(end_event.gesture, GestureType.DRAG_END) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/layout/test_ereader_application.py b/tests/layout/test_ereader_application.py deleted file mode 100644 index d5efe8f..0000000 --- a/tests/layout/test_ereader_application.py +++ /dev/null @@ -1,832 +0,0 @@ -""" -Comprehensive tests for the EbookReader application interface. - -Tests cover: -- EPUB loading and initialization -- Navigation (forward, backward, boundaries) -- Font scaling and styling -- Chapter navigation -- Position management (bookmarks) -- Information retrieval -- File operations -- Error handling -- Context manager -- Integration scenarios -""" - -import unittest -import tempfile -import shutil -from pathlib import Path -import numpy as np -from PIL import Image -import os - -from pyWebLayout.layout.ereader_application import EbookReader, create_ebook_reader - - -class TestEbookReaderInitialization(unittest.TestCase): - """Test EbookReader creation and EPUB loading""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - def tearDown(self): - """Clean up test environment""" - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_create_reader_with_defaults(self): - """Test creating reader with default settings""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - - self.assertEqual(reader.page_size, (800, 1000)) - self.assertEqual(reader.base_font_scale, 1.0) - self.assertIsNone(reader.manager) - self.assertFalse(reader.is_loaded()) - - reader.close() - - def test_create_reader_with_custom_settings(self): - """Test creating reader with custom settings""" - reader = EbookReader( - page_size=(600, 800), - margin=50, - background_color=(240, 240, 240), - line_spacing=10, - inter_block_spacing=20, - bookmarks_dir=self.temp_dir, - buffer_size=3 - ) - - self.assertEqual(reader.page_size, (600, 800)) - self.assertEqual(reader.page_style.line_spacing, 10) - self.assertEqual(reader.page_style.inter_block_spacing, 20) - self.assertEqual(reader.buffer_size, 3) - - reader.close() - - def test_load_valid_epub(self): - """Test loading a valid EPUB file""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - - success = reader.load_epub(self.epub_path) - - self.assertTrue(success) - self.assertTrue(reader.is_loaded()) - self.assertIsNotNone(reader.manager) - self.assertIsNotNone(reader.blocks) - self.assertIsNotNone(reader.document_id) - self.assertIsNotNone(reader.book_title) - self.assertIsNotNone(reader.book_author) - - reader.close() - - def test_load_nonexistent_epub(self): - """Test loading a non-existent EPUB file""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - - success = reader.load_epub("nonexistent.epub") - - self.assertFalse(success) - self.assertFalse(reader.is_loaded()) - - reader.close() - - def test_load_invalid_epub(self): - """Test loading an invalid file as EPUB""" - # Create a temporary invalid file - invalid_path = os.path.join(self.temp_dir, "invalid.epub") - with open(invalid_path, 'w') as f: - f.write("This is not a valid EPUB file") - - reader = EbookReader(bookmarks_dir=self.temp_dir) - - success = reader.load_epub(invalid_path) - - self.assertFalse(success) - self.assertFalse(reader.is_loaded()) - - reader.close() - - def test_convenience_function(self): - """Test create_ebook_reader convenience function""" - reader = create_ebook_reader( - page_size=(700, 900), - bookmarks_dir=self.temp_dir - ) - - self.assertIsInstance(reader, EbookReader) - self.assertEqual(reader.page_size, (700, 900)) - - reader.close() - - -class TestEbookReaderFontScaling(unittest.TestCase): - """Test font size control""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - self.reader = EbookReader( - bookmarks_dir=self.temp_dir, - buffer_size=0 # Disable buffering for tests - ) - self.reader.load_epub(self.epub_path) - - def tearDown(self): - """Clean up test environment""" - self.reader.close() - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_set_font_size(self): - """Test setting font size with arbitrary scale""" - page = self.reader.set_font_size(1.5) - - self.assertIsNotNone(page) - self.assertEqual(self.reader.get_font_size(), 1.5) - - def test_increase_font_size(self): - """Test increasing font size by one step""" - initial_size = self.reader.get_font_size() - - page = self.reader.increase_font_size() - - self.assertIsNotNone(page) - self.assertEqual(self.reader.get_font_size(), initial_size + 0.1) - - def test_decrease_font_size(self): - """Test decreasing font size by one step""" - self.reader.set_font_size(1.5) - - page = self.reader.decrease_font_size() - - self.assertIsNotNone(page) - self.assertAlmostEqual(self.reader.get_font_size(), 1.4, places=5) - - def test_font_size_bounds_clamping(self): - """Test that font size is clamped between 0.5x and 3.0x""" - # Test upper bound - self.reader.set_font_size(5.0) - self.assertEqual(self.reader.get_font_size(), 3.0) - - # Test lower bound - self.reader.set_font_size(0.1) - self.assertEqual(self.reader.get_font_size(), 0.5) - - def test_get_font_size(self): - """Test getting current font size""" - self.assertEqual(self.reader.get_font_size(), 1.0) - - self.reader.set_font_size(2.0) - self.assertEqual(self.reader.get_font_size(), 2.0) - - def test_font_scale_with_navigation(self): - """Test that font scale persists across page navigation""" - self.reader.set_font_size(1.5) - initial_font_size = self.reader.get_font_size() - - # Navigate forward - self.reader.next_page() - - # Font size should be preserved - self.assertEqual(self.reader.get_font_size(), initial_font_size) - - -class TestEbookReaderSpacing(unittest.TestCase): - """Test line and block spacing""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - self.reader = EbookReader( - bookmarks_dir=self.temp_dir, - buffer_size=0 - ) - self.reader.load_epub(self.epub_path) - - def tearDown(self): - """Clean up test environment""" - self.reader.close() - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_set_line_spacing(self): - """Test setting line spacing""" - page = self.reader.set_line_spacing(10) - - self.assertIsNotNone(page) - self.assertEqual(self.reader.page_style.line_spacing, 10) - - def test_set_inter_block_spacing(self): - """Test setting inter-block spacing""" - page = self.reader.set_inter_block_spacing(25) - - self.assertIsNotNone(page) - self.assertEqual(self.reader.page_style.inter_block_spacing, 25) - - def test_spacing_with_navigation(self): - """Test that spacing changes affect rendering after navigation""" - self.reader.set_line_spacing(15) - - page = self.reader.next_page() - - self.assertIsNotNone(page) - self.assertEqual(self.reader.page_style.line_spacing, 15) - - def test_spacing_position_preservation(self): - """Test that changing spacing preserves reading position""" - # Navigate to a specific position - for _ in range(3): - self.reader.next_page() - - position_before = self.reader.manager.current_position.copy() - - # Change spacing - self.reader.set_line_spacing(12) - - position_after = self.reader.manager.current_position - - # Position should be preserved - self.assertEqual(position_before.chapter_index, position_after.chapter_index) - self.assertEqual(position_before.block_index, position_after.block_index) - - -class TestEbookReaderChapterNavigation(unittest.TestCase): - """Test chapter navigation features""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - self.reader = EbookReader( - bookmarks_dir=self.temp_dir, - buffer_size=0 - ) - self.reader.load_epub(self.epub_path) - - def tearDown(self): - """Clean up test environment""" - self.reader.close() - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_get_chapters(self): - """Test getting list of chapters""" - chapters = self.reader.get_chapters() - - self.assertIsInstance(chapters, list) - if len(chapters) > 0: - # Each chapter should be a tuple (title, index) - self.assertIsInstance(chapters[0], tuple) - self.assertEqual(len(chapters[0]), 2) - - def test_get_chapter_positions(self): - """Test getting chapter positions""" - positions = self.reader.get_chapter_positions() - - self.assertIsInstance(positions, list) - if len(positions) > 0: - # Each item should be (title, RenderingPosition) - self.assertIsInstance(positions[0], tuple) - self.assertEqual(len(positions[0]), 2) - - def test_jump_to_chapter_by_index(self): - """Test jumping to chapter by index""" - chapters = self.reader.get_chapters() - - if len(chapters) > 0: - page = self.reader.jump_to_chapter(0) - self.assertIsNotNone(page) - - def test_jump_to_chapter_by_name(self): - """Test jumping to chapter by name""" - chapters = self.reader.get_chapters() - - if len(chapters) > 0: - chapter_title = chapters[0][0] - page = self.reader.jump_to_chapter(chapter_title) - self.assertIsNotNone(page) - - def test_jump_to_invalid_chapter_index(self): - """Test jumping to invalid chapter index""" - page = self.reader.jump_to_chapter(9999) - - self.assertIsNone(page) - - def test_jump_to_invalid_chapter_name(self): - """Test jumping to non-existent chapter name""" - page = self.reader.jump_to_chapter("Non-Existent Chapter") - - self.assertIsNone(page) - - -class TestEbookReaderInformation(unittest.TestCase): - """Test information retrieval methods""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - self.reader = EbookReader( - bookmarks_dir=self.temp_dir, - buffer_size=0 - ) - self.reader.load_epub(self.epub_path) - - def tearDown(self): - """Clean up test environment""" - self.reader.close() - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_get_position_info(self): - """Test getting detailed position information""" - info = self.reader.get_position_info() - - self.assertIsInstance(info, dict) - self.assertIn('position', info) - self.assertIn('chapter', info) - self.assertIn('progress', info) - self.assertIn('font_scale', info) - self.assertIn('book_title', info) - self.assertIn('book_author', info) - - def test_get_reading_progress(self): - """Test getting reading progress as percentage""" - progress = self.reader.get_reading_progress() - - self.assertIsInstance(progress, float) - self.assertGreaterEqual(progress, 0.0) - self.assertLessEqual(progress, 1.0) - - # Progress should increase after navigation - initial_progress = progress - for _ in range(5): - self.reader.next_page() - - new_progress = self.reader.get_reading_progress() - self.assertGreater(new_progress, initial_progress) - - def test_get_current_chapter_info(self): - """Test getting current chapter information""" - info = self.reader.get_current_chapter_info() - - # May be None if no chapters - if info is not None: - self.assertIsInstance(info, dict) - self.assertIn('title', info) - self.assertIn('level', info) - self.assertIn('block_index', info) - - def test_get_book_info_complete(self): - """Test getting complete book information""" - info = self.reader.get_book_info() - - self.assertIsInstance(info, dict) - self.assertIn('title', info) - self.assertIn('author', info) - self.assertIn('document_id', info) - self.assertIn('total_blocks', info) - self.assertIn('total_chapters', info) - self.assertIn('page_size', info) - self.assertIn('font_scale', info) - - self.assertGreater(info['total_blocks'], 0) - self.assertEqual(info['page_size'], self.reader.page_size) - - -class TestEbookReaderFileOperations(unittest.TestCase): - """Test file I/O operations""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - self.reader = EbookReader( - bookmarks_dir=self.temp_dir, - buffer_size=0 - ) - self.reader.load_epub(self.epub_path) - - def tearDown(self): - """Clean up test environment""" - self.reader.close() - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_render_to_file_png(self): - """Test saving current page as PNG""" - output_path = os.path.join(self.temp_dir, "page.png") - - success = self.reader.render_to_file(output_path) - - self.assertTrue(success) - self.assertTrue(os.path.exists(output_path)) - - # Verify it's a valid image - img = Image.open(output_path) - self.assertEqual(img.size, self.reader.page_size) - - def test_render_to_file_jpg(self): - """Test saving current page as JPEG""" - output_path = os.path.join(self.temp_dir, "page.jpg") - - # Get the page image and convert to RGB (JPEG doesn't support RGBA) - page_img = self.reader.get_current_page() - if page_img.mode == 'RGBA': - page_img = page_img.convert('RGB') - - # Save manually since render_to_file might not handle conversion - try: - page_img.save(output_path) - success = True - except Exception: - success = False - - self.assertTrue(success) - self.assertTrue(os.path.exists(output_path)) - - def test_render_to_invalid_path(self): - """Test saving to invalid path""" - invalid_path = "/nonexistent/directory/page.png" - - success = self.reader.render_to_file(invalid_path) - - self.assertFalse(success) - - -class TestEbookReaderContextManager(unittest.TestCase): - """Test context manager and cleanup""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - def tearDown(self): - """Clean up test environment""" - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_context_manager_usage(self): - """Test using EbookReader as context manager""" - with EbookReader(bookmarks_dir=self.temp_dir) as reader: - success = reader.load_epub(self.epub_path) - self.assertTrue(success) - - page = reader.get_current_page() - self.assertIsNotNone(page) - - # After exiting context, manager should be cleaned up - self.assertIsNone(reader.manager) - - def test_close_method(self): - """Test explicit close method""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - reader.load_epub(self.epub_path) - - self.assertIsNotNone(reader.manager) - - reader.close() - - self.assertIsNone(reader.manager) - - def test_operations_after_close(self): - """Test that operations fail gracefully after close""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - reader.load_epub(self.epub_path) - reader.close() - - # These should all return None or empty - self.assertIsNone(reader.get_current_page()) - self.assertIsNone(reader.next_page()) - self.assertIsNone(reader.previous_page()) - self.assertEqual(reader.get_chapters(), []) - - -class TestEbookReaderErrorHandling(unittest.TestCase): - """Test error handling and edge cases""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - def tearDown(self): - """Clean up test environment""" - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_operations_without_loaded_book(self): - """Test that operations handle unloaded state gracefully""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - - # All these should return None or empty/False - self.assertIsNone(reader.get_current_page()) - self.assertIsNone(reader.next_page()) - self.assertIsNone(reader.previous_page()) - self.assertFalse(reader.save_position("test")) - self.assertIsNone(reader.load_position("test")) - self.assertEqual(reader.list_saved_positions(), []) - self.assertFalse(reader.delete_position("test")) - self.assertEqual(reader.get_chapters(), []) - self.assertIsNone(reader.jump_to_chapter(0)) - self.assertIsNone(reader.set_font_size(1.5)) - self.assertEqual(reader.get_reading_progress(), 0.0) - self.assertIsNone(reader.get_current_chapter_info()) - - reader.close() - - def test_is_loaded(self): - """Test is_loaded method""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - - self.assertFalse(reader.is_loaded()) - - reader.load_epub(self.epub_path) - - self.assertTrue(reader.is_loaded()) - - reader.close() - - -class TestEbookReaderIntegration(unittest.TestCase): - """Test complex integration scenarios""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - self.reader = EbookReader( - bookmarks_dir=self.temp_dir, - buffer_size=0 - ) - self.reader.load_epub(self.epub_path) - - def tearDown(self): - """Clean up test environment""" - self.reader.close() - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_font_scaling_preserves_position(self): - """Test that changing font scale preserves reading position""" - # Navigate to a specific position - for _ in range(3): - self.reader.next_page() - - position_before = self.reader.manager.current_position.copy() - - # Change font size - self.reader.set_font_size(1.5) - - position_after = self.reader.manager.current_position - - # Position should be preserved - self.assertEqual(position_before.chapter_index, position_after.chapter_index) - self.assertEqual(position_before.block_index, position_after.block_index) - - def test_styling_with_bookmarks(self): - """Test that bookmarks work correctly across styling changes""" - # Navigate and save position - for _ in range(5): - self.reader.next_page() - - self.reader.save_position("test_bookmark") - - # Change styling - self.reader.set_font_size(1.5) - self.reader.set_line_spacing(12) - - # Navigate away - for _ in range(5): - self.reader.next_page() - - # Jump back to bookmark - page = self.reader.load_position("test_bookmark") - - self.assertIsNotNone(page) - - # Cleanup - self.reader.delete_position("test_bookmark") - - def test_chapter_navigation_after_font_change(self): - """Test chapter navigation after changing font size""" - self.reader.set_font_size(2.0) - - chapters = self.reader.get_chapters() - - if len(chapters) > 0: - page = self.reader.jump_to_chapter(0) - self.assertIsNotNone(page) - - -class TestEbookReaderNavigation(unittest.TestCase): - """Test EbookReader navigation functionality (existing tests)""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - def tearDown(self): - """Clean up test environment""" - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool: - """ - Check if two PIL Images are pixel-perfect identical. - """ - if img1 is None or img2 is None: - return False - - if img1.size != img2.size: - return False - - arr1 = np.array(img1) - arr2 = np.array(img2) - - return np.array_equal(arr1, arr2) - - def test_bidirectional_navigation_20_pages(self): - """ - Test that navigating forward 20 pages and then backward 20 pages - produces identical page renderings for the first page. - """ - reader = EbookReader( - page_size=(800, 1000), - bookmarks_dir=self.temp_dir, - buffer_size=0 - ) - - success = reader.load_epub(self.epub_path) - self.assertTrue(success, "Failed to load test EPUB") - self.assertTrue(reader.is_loaded(), "Reader should be loaded") - - initial_page = reader.get_current_page() - self.assertIsNotNone(initial_page, "Initial page should not be None") - - initial_position = reader.manager.current_position.copy() - - forward_pages = [initial_page] - forward_positions = [initial_position] - pages_to_navigate = 20 - - for i in range(pages_to_navigate): - page = reader.next_page() - if page is None: - break - forward_pages.append(page) - forward_positions.append(reader.manager.current_position.copy()) - - actual_pages_navigated = len(forward_pages) - 1 - - backward_pages = [] - - for i in range(len(forward_positions) - 1, -1, -1): - position = forward_positions[i] - page_obj = reader.manager.jump_to_position(position) - page_img = page_obj.render() - backward_pages.append(page_img) - - final_page = backward_pages[-1] - - self.assertTrue( - self.compare_images(initial_page, final_page), - "First page should be identical after forward/backward navigation" - ) - - reader.close() - - def test_navigation_at_boundaries(self): - """Test navigation behavior at document boundaries.""" - reader = EbookReader( - page_size=(800, 1000), - bookmarks_dir=self.temp_dir, - buffer_size=0 - ) - - success = reader.load_epub(self.epub_path) - self.assertTrue(success, "Failed to load test EPUB") - - # Try to go backward from first page - page = reader.previous_page() - # Should return None or stay on same page - - # Navigate forward until end - pages_forward = 0 - max_pages = 100 - while pages_forward < max_pages: - page = reader.next_page() - if page is None: - break - pages_forward += 1 - - # Try to go forward from last page - page = reader.next_page() - self.assertIsNone(page, "Should return None at end of document") - - reader.close() - - -class TestEbookReaderPositionManagement(unittest.TestCase): - """Test position tracking and bookmark features""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.epub_path = "tests/data/test.epub" - - if not Path(self.epub_path).exists(): - self.skipTest(f"Test EPUB not found at {self.epub_path}") - - self.reader = EbookReader( - bookmarks_dir=self.temp_dir, - buffer_size=0 - ) - self.reader.load_epub(self.epub_path) - - def tearDown(self): - """Clean up test environment""" - self.reader.close() - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_position_save_and_load(self): - """Test saving and loading positions""" - # Navigate to a position - for _ in range(3): - self.reader.next_page() - - # Save position - success = self.reader.save_position("test_pos") - self.assertTrue(success) - - # Navigate away - for _ in range(5): - self.reader.next_page() - - # Load saved position - page = self.reader.load_position("test_pos") - self.assertIsNotNone(page) - - def test_list_saved_positions(self): - """Test listing saved positions""" - self.reader.save_position("pos1") - self.reader.save_position("pos2") - - positions = self.reader.list_saved_positions() - - self.assertIn("pos1", positions) - self.assertIn("pos2", positions) - - def test_delete_position(self): - """Test deleting a saved position""" - self.reader.save_position("temp_pos") - - success = self.reader.delete_position("temp_pos") - self.assertTrue(success) - - positions = self.reader.list_saved_positions() - self.assertNotIn("temp_pos", positions) - - def test_delete_nonexistent_position(self): - """Test deleting a non-existent position""" - success = self.reader.delete_position("nonexistent") - self.assertFalse(success) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/layout/test_ereader_system.py b/tests/layout/test_ereader_system.py deleted file mode 100644 index 199d589..0000000 --- a/tests/layout/test_ereader_system.py +++ /dev/null @@ -1,455 +0,0 @@ -""" -Comprehensive tests for the ereader layout system. - -Tests the complete ereader functionality including position tracking, -font scaling, chapter navigation, and page buffering. -""" - -import unittest -import tempfile -import shutil -from pathlib import Path - -from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel -from pyWebLayout.abstract.inline import Word -from pyWebLayout.style import Font -from pyWebLayout.style.page_style import PageStyle -from pyWebLayout.layout.ereader_layout import RenderingPosition, ChapterNavigator, FontScaler, BidirectionalLayouter -from pyWebLayout.layout.ereader_manager import EreaderLayoutManager, BookmarkManager, create_ereader_manager - - -class TestRenderingPosition(unittest.TestCase): - """Test the RenderingPosition class""" - - def test_position_creation(self): - """Test creating a rendering position""" - pos = RenderingPosition( - chapter_index=1, - block_index=5, - word_index=10, - table_row=2, - table_col=3 - ) - - self.assertEqual(pos.chapter_index, 1) - self.assertEqual(pos.block_index, 5) - self.assertEqual(pos.word_index, 10) - self.assertEqual(pos.table_row, 2) - self.assertEqual(pos.table_col, 3) - - def test_position_serialization(self): - """Test position serialization and deserialization""" - pos = RenderingPosition( - chapter_index=1, - block_index=5, - word_index=10, - remaining_pretext="test" - ) - - # Serialize to dict - pos_dict = pos.to_dict() - self.assertIsInstance(pos_dict, dict) - self.assertEqual(pos_dict['chapter_index'], 1) - self.assertEqual(pos_dict['remaining_pretext'], "test") - - # Deserialize from dict - pos2 = RenderingPosition.from_dict(pos_dict) - self.assertEqual(pos, pos2) - - def test_position_copy(self): - """Test position copying""" - pos = RenderingPosition(chapter_index=1, block_index=5) - pos_copy = pos.copy() - - self.assertEqual(pos, pos_copy) - self.assertIsNot(pos, pos_copy) # Different objects - - # Modify copy - pos_copy.word_index = 10 - self.assertNotEqual(pos, pos_copy) - - def test_position_equality_and_hashing(self): - """Test position equality and hashing""" - pos1 = RenderingPosition(chapter_index=1, block_index=5) - pos2 = RenderingPosition(chapter_index=1, block_index=5) - pos3 = RenderingPosition(chapter_index=1, block_index=6) - - self.assertEqual(pos1, pos2) - self.assertNotEqual(pos1, pos3) - - # Test hashing (for use as dict keys) - pos_dict = {pos1: "test"} - self.assertEqual(pos_dict[pos2], "test") # Should work due to equality - - -class TestChapterNavigator(unittest.TestCase): - """Test the ChapterNavigator class""" - - def setUp(self): - """Set up test data""" - self.font = Font() - - # Create test blocks with headings - self.blocks = [ - Paragraph(self.font), # Block 0 - Heading(HeadingLevel.H1, self.font), # Block 1 - Chapter 1 - Paragraph(self.font), # Block 2 - Heading(HeadingLevel.H2, self.font), # Block 3 - Subsection - Paragraph(self.font), # Block 4 - Heading(HeadingLevel.H1, self.font), # Block 5 - Chapter 2 - Paragraph(self.font), # Block 6 - ] - - # Add text to headings - self.blocks[1].add_word(Word("Chapter", self.font)) - self.blocks[1].add_word(Word("One", self.font)) - - self.blocks[3].add_word(Word("Subsection", self.font)) - self.blocks[3].add_word(Word("A", self.font)) - - self.blocks[5].add_word(Word("Chapter", self.font)) - self.blocks[5].add_word(Word("Two", self.font)) - - def test_chapter_detection(self): - """Test that chapters are detected correctly""" - navigator = ChapterNavigator(self.blocks) - - self.assertEqual(len(navigator.chapters), 3) # 2 H1s + 1 H2 - - # Check chapter titles - titles = [chapter.title for chapter in navigator.chapters] - self.assertIn("Chapter One", titles) - self.assertIn("Subsection A", titles) - self.assertIn("Chapter Two", titles) - - def test_table_of_contents(self): - """Test table of contents generation""" - navigator = ChapterNavigator(self.blocks) - toc = navigator.get_table_of_contents() - - self.assertEqual(len(toc), 3) - - # Check first entry - title, level, position = toc[0] - self.assertEqual(title, "Chapter One") - self.assertEqual(level, HeadingLevel.H1) - self.assertIsInstance(position, RenderingPosition) - - def test_chapter_position_lookup(self): - """Test looking up chapter positions""" - navigator = ChapterNavigator(self.blocks) - - pos = navigator.get_chapter_position("Chapter One") - self.assertIsNotNone(pos) - self.assertEqual(pos.chapter_index, 0) - - pos = navigator.get_chapter_position("Nonexistent Chapter") - self.assertIsNone(pos) - - def test_current_chapter_detection(self): - """Test detecting current chapter from position""" - navigator = ChapterNavigator(self.blocks) - - # Position in first chapter - pos = RenderingPosition(chapter_index=0, block_index=2) - chapter = navigator.get_current_chapter(pos) - self.assertIsNotNone(chapter) - self.assertEqual(chapter.title, "Chapter One") - - -class TestFontScaler(unittest.TestCase): - """Test the FontScaler class""" - - def test_font_scaling(self): - """Test font scaling functionality""" - original_font = Font(font_size=12) - - # Test no scaling - scaled_font = FontScaler.scale_font(original_font, 1.0) - self.assertEqual(scaled_font.font_size, 12) - - # Test 2x scaling - scaled_font = FontScaler.scale_font(original_font, 2.0) - self.assertEqual(scaled_font.font_size, 24) - - # Test 0.5x scaling - scaled_font = FontScaler.scale_font(original_font, 0.5) - self.assertEqual(scaled_font.font_size, 6) - - # Test minimum size constraint - scaled_font = FontScaler.scale_font(original_font, 0.01) - self.assertGreaterEqual(scaled_font.font_size, 1) - - def test_word_spacing_scaling(self): - """Test word spacing scaling""" - original_spacing = (5, 15) - - # Test no scaling - scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 1.0) - self.assertEqual(scaled_spacing, (5, 15)) - - # Test 2x scaling - scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 2.0) - self.assertEqual(scaled_spacing, (10, 30)) - - # Test minimum constraints - scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 0.1) - self.assertGreaterEqual(scaled_spacing[0], 1) - self.assertGreaterEqual(scaled_spacing[1], 2) - - -class TestBookmarkManager(unittest.TestCase): - """Test the BookmarkManager class""" - - def setUp(self): - """Set up test environment""" - self.temp_dir = tempfile.mkdtemp() - self.document_id = "test_document" - self.bookmark_manager = BookmarkManager(self.document_id, self.temp_dir) - - def tearDown(self): - """Clean up test environment""" - shutil.rmtree(self.temp_dir) - - def test_bookmark_operations(self): - """Test bookmark add/remove/get operations""" - pos = RenderingPosition(chapter_index=1, block_index=5) - - # Add bookmark - self.bookmark_manager.add_bookmark("test_bookmark", pos) - - # Get bookmark - retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark") - self.assertEqual(retrieved_pos, pos) - - # List bookmarks - bookmarks = self.bookmark_manager.list_bookmarks() - self.assertEqual(len(bookmarks), 1) - self.assertEqual(bookmarks[0][0], "test_bookmark") - self.assertEqual(bookmarks[0][1], pos) - - # Remove bookmark - success = self.bookmark_manager.remove_bookmark("test_bookmark") - self.assertTrue(success) - - # Verify removal - retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark") - self.assertIsNone(retrieved_pos) - - def test_reading_position_persistence(self): - """Test saving and loading reading position""" - pos = RenderingPosition(chapter_index=2, block_index=10, word_index=5) - - # Save position - self.bookmark_manager.save_reading_position(pos) - - # Create new manager instance (simulates app restart) - new_manager = BookmarkManager(self.document_id, self.temp_dir) - - # Load position - loaded_pos = new_manager.load_reading_position() - self.assertEqual(loaded_pos, pos) - - def test_bookmark_persistence(self): - """Test that bookmarks persist across manager instances""" - pos = RenderingPosition(chapter_index=1, block_index=5) - - # Add bookmark - self.bookmark_manager.add_bookmark("persistent_bookmark", pos) - - # Create new manager instance - new_manager = BookmarkManager(self.document_id, self.temp_dir) - - # Verify bookmark exists - retrieved_pos = new_manager.get_bookmark("persistent_bookmark") - self.assertEqual(retrieved_pos, pos) - - -class TestEreaderLayoutManager(unittest.TestCase): - """Test the complete EreaderLayoutManager""" - - def setUp(self): - """Set up test data""" - self.temp_dir = tempfile.mkdtemp() - self.font = Font() - - # Create test document with multiple paragraphs and headings - self.blocks = [] - - # Add a heading - heading = Heading(HeadingLevel.H1, self.font) - heading.add_word(Word("Test", self.font)) - heading.add_word(Word("Chapter", self.font)) - self.blocks.append(heading) - - # Add several paragraphs with multiple words - for i in range(3): - paragraph = Paragraph(self.font) - for j in range(20): # 20 words per paragraph - paragraph.add_word(Word(f"Word{i}_{j}", self.font)) - self.blocks.append(paragraph) - - self.page_size = (400, 600) - self.document_id = "test_document" - - def tearDown(self): - """Clean up test environment""" - shutil.rmtree(self.temp_dir) - - def test_manager_initialization(self): - """Test ereader manager initialization""" - # Change to temp directory for bookmarks - original_cwd = Path.cwd() - try: - import os - os.chdir(self.temp_dir) - - manager = EreaderLayoutManager( - self.blocks, - self.page_size, - self.document_id, - bookmarks_dir=self.temp_dir - ) - - self.assertEqual(manager.page_size, self.page_size) - self.assertEqual(manager.document_id, self.document_id) - self.assertEqual(manager.font_scale, 1.0) - self.assertIsInstance(manager.current_position, RenderingPosition) - - manager.shutdown() - finally: - os.chdir(original_cwd) - - def test_font_scaling(self): - """Test font scaling functionality""" - original_cwd = Path.cwd() - try: - import os - os.chdir(self.temp_dir) - - manager = EreaderLayoutManager( - self.blocks, - self.page_size, - self.document_id, - bookmarks_dir=self.temp_dir - ) - - # Test initial scale - self.assertEqual(manager.get_font_scale(), 1.0) - - # Test scaling - page = manager.set_font_scale(1.5) - self.assertEqual(manager.get_font_scale(), 1.5) - self.assertIsNotNone(page) - - manager.shutdown() - finally: - os.chdir(original_cwd) - - def test_table_of_contents(self): - """Test table of contents functionality""" - original_cwd = Path.cwd() - try: - import os - os.chdir(self.temp_dir) - - manager = EreaderLayoutManager( - self.blocks, - self.page_size, - self.document_id, - bookmarks_dir=self.temp_dir - ) - - toc = manager.get_table_of_contents() - self.assertGreater(len(toc), 0) - - # Check first entry - title, level, position = toc[0] - self.assertEqual(title, "Test Chapter") - self.assertEqual(level, HeadingLevel.H1) - - manager.shutdown() - finally: - os.chdir(original_cwd) - - def test_bookmark_functionality(self): - """Test bookmark functionality""" - manager = EreaderLayoutManager( - self.blocks, - self.page_size, - self.document_id, - bookmarks_dir=self.temp_dir - ) - - # Add bookmark - success = manager.add_bookmark("test_bookmark") - self.assertTrue(success) - - # List bookmarks - bookmarks = manager.list_bookmarks() - self.assertEqual(len(bookmarks), 1) - self.assertEqual(bookmarks[0][0], "test_bookmark") - - # Jump to bookmark (should work even though it's the same position) - page = manager.jump_to_bookmark("test_bookmark") - self.assertIsNotNone(page) - - # Remove bookmark - success = manager.remove_bookmark("test_bookmark") - self.assertTrue(success) - - manager.shutdown() - - def test_progress_tracking(self): - """Test reading progress tracking""" - original_cwd = Path.cwd() - try: - import os - os.chdir(self.temp_dir) - - manager = EreaderLayoutManager( - self.blocks, - self.page_size, - self.document_id, - bookmarks_dir=self.temp_dir - ) - - # Initial progress should be 0 - progress = manager.get_reading_progress() - self.assertGreaterEqual(progress, 0.0) - self.assertLessEqual(progress, 1.0) - - # Get position info - info = manager.get_position_info() - self.assertIn('position', info) - self.assertIn('progress', info) - self.assertIn('font_scale', info) - - manager.shutdown() - finally: - os.chdir(original_cwd) - - def test_convenience_function(self): - """Test the convenience function""" - original_cwd = Path.cwd() - try: - import os - os.chdir(self.temp_dir) - - manager = create_ereader_manager( - self.blocks, - self.page_size, - self.document_id, - bookmarks_dir=self.temp_dir - ) - - self.assertIsInstance(manager, EreaderLayoutManager) - self.assertEqual(manager.page_size, self.page_size) - - manager.shutdown() - finally: - os.chdir(original_cwd) - - -if __name__ == '__main__': - unittest.main()