diff --git a/dreader/application.py b/dreader/application.py index 291593a..89bdae8 100644 --- a/dreader/application.py +++ b/dreader/application.py @@ -41,19 +41,19 @@ import os from PIL import Image -from pyWebLayout.io.readers.epub_reader import read_epub -from pyWebLayout.io.readers.html_extraction import parse_html_string 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 from pyWebLayout.core.query import QueryResult, SelectionRange -from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result +from pyWebLayout.core.highlight import Highlight, HighlightColor from .gesture import TouchEvent, GestureType, GestureResponse, ActionType from .state import OverlayState from .overlay import OverlayManager +from .managers import DocumentManager, SettingsManager, HighlightCoordinator +from .handlers import GestureRouter class EbookReader: @@ -109,22 +109,25 @@ class EbookReader: inter_block_spacing=inter_block_spacing ) - # State + # Core managers (NEW: Refactored into separate modules) + self.doc_manager = DocumentManager() + self.settings_manager = SettingsManager() + self.highlight_coordinator: Optional[HighlightCoordinator] = None + self.gesture_router = GestureRouter(self) + + # Layout manager (initialized after loading) self.manager: Optional[EreaderLayoutManager] = None + + # Legacy compatibility properties self.blocks: Optional[List[Block]] = None self.document_id: Optional[str] = None self.book_title: Optional[str] = None self.book_author: Optional[str] = None - self.highlight_manager: Optional[HighlightManager] = None + self.highlight_manager = None # Will delegate to highlight_coordinator - # Font scale state + # Font scale state (delegated to settings_manager but kept for compatibility) self.base_font_scale = 1.0 - self.font_scale_step = 0.1 # 10% change per step - - # Selection state (for text selection gestures) - self._selection_start: Optional[Tuple[int, int]] = None - self._selection_end: Optional[Tuple[int, int]] = None - self._selected_range: Optional[SelectionRange] = None + self.font_scale_step = 0.1 # Overlay management self.overlay_manager = OverlayManager(page_size=page_size) @@ -133,58 +136,47 @@ class EbookReader: 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 - ) + # Use DocumentManager to load the EPUB + success = self.doc_manager.load_epub(epub_path) - # Initialize highlight manager for this document - self.highlight_manager = HighlightManager( - document_id=self.document_id, - highlights_dir=self.highlights_dir - ) - - return True - - except Exception as e: - print(f"Error loading EPUB: {e}") + if not success: return False + + # Set compatibility properties + self.book_title = self.doc_manager.title + self.book_author = self.doc_manager.author + self.document_id = self.doc_manager.document_id + self.blocks = self.doc_manager.blocks + + # 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 + ) + + # Initialize managers that depend on layout manager + self.settings_manager.set_manager(self.manager) + + # Initialize highlight coordinator for this document + self.highlight_coordinator = HighlightCoordinator( + document_id=self.document_id, + highlights_dir=self.highlights_dir + ) + self.highlight_coordinator.set_layout_manager(self.manager) + self.highlight_manager = self.highlight_coordinator.highlight_manager # Compatibility + + return True def load_html(self, html_string: str, title: str = "HTML Document", author: str = "Unknown", document_id: str = "html_doc") -> bool: """ @@ -202,41 +194,41 @@ class EbookReader: Returns: True if loaded successfully, False otherwise """ - try: - # Parse HTML into blocks - blocks = parse_html_string(html_string) + # Use DocumentManager to load HTML + success = self.doc_manager.load_html(html_string, title, author, document_id) - if not blocks: - raise ValueError("No content blocks parsed from HTML") - - # Set metadata - self.book_title = title - self.book_author = author - self.document_id = document_id - self.blocks = blocks - - # 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 - ) - - # Initialize highlight manager for this document - self.highlight_manager = HighlightManager( - document_id=self.document_id, - highlights_dir=self.highlights_dir - ) - - return True - - except Exception as e: - print(f"Error loading HTML: {e}") + if not success: return False + # Set compatibility properties + self.book_title = self.doc_manager.title + self.book_author = self.doc_manager.author + self.document_id = self.doc_manager.document_id + self.blocks = self.doc_manager.blocks + + # 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 + ) + + # Initialize managers that depend on layout manager + self.settings_manager.set_manager(self.manager) + + # Initialize highlight coordinator for this document + self.highlight_coordinator = HighlightCoordinator( + document_id=self.document_id, + highlights_dir=self.highlights_dir + ) + self.highlight_coordinator.set_layout_manager(self.manager) + self.highlight_manager = self.highlight_coordinator.highlight_manager # Compatibility + + return True + def is_loaded(self) -> bool: """Check if a book is currently loaded.""" return self.manager is not None @@ -466,52 +458,50 @@ class EbookReader: 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 - + result = self.settings_manager.set_font_size(scale) + if result: + self.base_font_scale = self.settings_manager.font_scale # Sync compatibility property + return result + 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) - + result = self.settings_manager.increase_font_size() + if result: + self.base_font_scale = self.settings_manager.font_scale + return result + 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) - + result = self.settings_manager.decrease_font_size() + if result: + self.base_font_scale = self.settings_manager.font_scale + return result + def get_font_size(self) -> float: """ Get the current font size scale. - + Returns: Current font scale factor """ - return self.base_font_scale + return self.settings_manager.get_font_size() def set_line_spacing(self, spacing: int) -> Optional[Image.Image]: """ @@ -523,28 +513,8 @@ class EbookReader: Returns: PIL Image of the re-rendered page """ - if not self.manager: - return None + return self.settings_manager.set_line_spacing(spacing) - try: - # Calculate delta from current spacing - current_spacing = self.manager.page_style.line_spacing - target_spacing = max(0, spacing) - delta = target_spacing - current_spacing - - # Use pyWebLayout's built-in methods to adjust spacing - if delta > 0: - self.manager.increase_line_spacing(abs(delta)) - elif delta < 0: - self.manager.decrease_line_spacing(abs(delta)) - - # Get re-rendered page - 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 inter-block spacing using pyWebLayout's native support. @@ -555,27 +525,7 @@ class EbookReader: Returns: PIL Image of the re-rendered page """ - if not self.manager: - return None - - try: - # Calculate delta from current spacing - current_spacing = self.manager.page_style.inter_block_spacing - target_spacing = max(0, spacing) - delta = target_spacing - current_spacing - - # Use pyWebLayout's built-in methods to adjust spacing - if delta > 0: - self.manager.increase_inter_block_spacing(abs(delta)) - elif delta < 0: - self.manager.decrease_inter_block_spacing(abs(delta)) - - # Get re-rendered page - page = self.manager.get_current_page() - return page.render() - except Exception as e: - print(f"Error setting inter-block spacing: {e}") - return None + return self.settings_manager.set_inter_block_spacing(spacing) def set_word_spacing(self, spacing: int) -> Optional[Image.Image]: """ @@ -587,27 +537,7 @@ class EbookReader: Returns: PIL Image of the re-rendered page """ - if not self.manager: - return None - - try: - # Calculate delta from current spacing - current_spacing = self.manager.page_style.word_spacing - target_spacing = max(0, spacing) - delta = target_spacing - current_spacing - - # Use pyWebLayout's built-in methods to adjust spacing - if delta > 0: - self.manager.increase_word_spacing(abs(delta)) - elif delta < 0: - self.manager.decrease_word_spacing(abs(delta)) - - # Get re-rendered page - page = self.manager.get_current_page() - return page.render() - except Exception as e: - print(f"Error setting word spacing: {e}") - return None + return self.settings_manager.set_word_spacing(spacing) def get_position_info(self) -> Dict[str, Any]: """ @@ -715,12 +645,7 @@ class EbookReader: Returns: Dictionary with all current settings """ - return { - 'font_scale': self.base_font_scale, - 'line_spacing': self.page_style.line_spacing if self.manager else 5, - 'inter_block_spacing': self.page_style.inter_block_spacing if self.manager else 15, - 'word_spacing': self.page_style.word_spacing if self.manager else 0 - } + return self.settings_manager.get_current_settings() def apply_settings(self, settings: Dict[str, Any]) -> bool: """ @@ -734,35 +659,11 @@ class EbookReader: Returns: True if settings applied successfully, False otherwise """ - if not self.manager: - return False - - try: - # Apply font scale - font_scale = settings.get('font_scale', 1.0) - if font_scale != self.base_font_scale: - self.set_font_size(font_scale) - - # Apply line spacing - line_spacing = settings.get('line_spacing', 5) - if line_spacing != self.page_style.line_spacing: - self.set_line_spacing(line_spacing) - - # Apply inter-block spacing - inter_block_spacing = settings.get('inter_block_spacing', 15) - if inter_block_spacing != self.page_style.inter_block_spacing: - self.set_inter_block_spacing(inter_block_spacing) - - # Apply word spacing - word_spacing = settings.get('word_spacing', 0) - if word_spacing != self.page_style.word_spacing: - self.set_word_spacing(word_spacing) - - return True - - except Exception as e: - print(f"Error applying settings: {e}") - return False + success = self.settings_manager.apply_settings(settings) + if success: + # Sync compatibility property + self.base_font_scale = self.settings_manager.font_scale + return success # ===== Gesture Handling ===== # All business logic for touch input is handled here @@ -780,44 +681,8 @@ class EbookReader: Returns: GestureResponse with action and data for UI to process """ - if not self.is_loaded(): - return GestureResponse(ActionType.ERROR, {"message": "No book loaded"}) - - # Handle overlay-specific gestures first - if self.is_overlay_open(): - if event.gesture == GestureType.TAP: - return self._handle_overlay_tap(event.x, event.y) - elif event.gesture == GestureType.SWIPE_DOWN: - # Swipe down closes overlay - return self._handle_overlay_close() - - # Dispatch based on gesture type for normal reading mode - if event.gesture == GestureType.TAP: - return self._handle_tap(event.x, event.y) - elif event.gesture == GestureType.LONG_PRESS: - return self._handle_long_press(event.x, event.y) - elif event.gesture == GestureType.SWIPE_LEFT: - return self._handle_page_forward() - elif event.gesture == GestureType.SWIPE_RIGHT: - return self._handle_page_back() - elif event.gesture == GestureType.SWIPE_UP: - # Swipe up from bottom opens TOC overlay - return self._handle_swipe_up(event.y) - elif event.gesture == GestureType.SWIPE_DOWN: - # Swipe down from top opens settings overlay - return self._handle_swipe_down(event.y) - elif event.gesture == GestureType.PINCH_IN: - return self._handle_zoom_out() - elif event.gesture == GestureType.PINCH_OUT: - return self._handle_zoom_in() - elif event.gesture == GestureType.DRAG_START: - return self._handle_selection_start(event.x, event.y) - elif event.gesture == GestureType.DRAG_MOVE: - return self._handle_selection_move(event.x, event.y) - elif event.gesture == GestureType.DRAG_END: - return self._handle_selection_end(event.x, event.y) - - return GestureResponse(ActionType.NONE, {}) + # Delegate to gesture router + return self.gesture_router.handle_touch(event) def query_pixel(self, x: int, y: int) -> Optional[QueryResult]: """ @@ -835,184 +700,6 @@ class EbookReader: page = self.manager.get_current_page() return page.query_point((x, y)) - def _handle_tap(self, x: int, y: int) -> GestureResponse: - """Handle tap gesture - activates links or selects words""" - page = self.manager.get_current_page() - result = page.query_point((x, y)) - - if not result or result.object_type == "empty": - return GestureResponse(ActionType.NONE, {}) - - # If it's a link, navigate - if result.is_interactive and result.link_target: - # Handle different link types - if result.link_target.endswith('.epub'): - # Open new book - success = self.load_epub(result.link_target) - if success: - return GestureResponse(ActionType.BOOK_LOADED, { - "title": self.book_title, - "author": self.book_author, - "path": result.link_target - }) - else: - return GestureResponse(ActionType.ERROR, { - "message": f"Failed to load {result.link_target}" - }) - else: - # Internal navigation (chapter) - self.jump_to_chapter(result.link_target) - return GestureResponse(ActionType.NAVIGATE, { - "target": result.link_target, - "chapter": self.get_current_chapter_info() - }) - - # Just a tap on text - select word - if result.text: - return GestureResponse(ActionType.WORD_SELECTED, { - "word": result.text, - "bounds": result.bounds - }) - - return GestureResponse(ActionType.NONE, {}) - - def _handle_long_press(self, x: int, y: int) -> GestureResponse: - """Handle long-press - show definition or menu""" - page = self.manager.get_current_page() - result = page.query_point((x, y)) - - if result and result.text: - return GestureResponse(ActionType.DEFINE, { - "word": result.text, - "bounds": result.bounds - }) - - # Long-press on empty - show menu - return GestureResponse(ActionType.SHOW_MENU, { - "options": ["bookmark", "settings", "toc", "search"] - }) - - def _handle_page_forward(self) -> GestureResponse: - """Handle swipe left - next page""" - img = self.next_page() - if img: - return GestureResponse(ActionType.PAGE_TURN, { - "direction": "forward", - "progress": self.get_reading_progress(), - "chapter": self.get_current_chapter_info() - }) - return GestureResponse(ActionType.AT_END, {}) - - def _handle_page_back(self) -> GestureResponse: - """Handle swipe right - previous page""" - img = self.previous_page() - if img: - return GestureResponse(ActionType.PAGE_TURN, { - "direction": "back", - "progress": self.get_reading_progress(), - "chapter": self.get_current_chapter_info() - }) - return GestureResponse(ActionType.AT_START, {}) - - def _handle_zoom_in(self) -> GestureResponse: - """Handle pinch out - increase font""" - self.increase_font_size() - return GestureResponse(ActionType.ZOOM, { - "direction": "in", - "font_scale": self.base_font_scale - }) - - def _handle_zoom_out(self) -> GestureResponse: - """Handle pinch in - decrease font""" - self.decrease_font_size() - return GestureResponse(ActionType.ZOOM, { - "direction": "out", - "font_scale": self.base_font_scale - }) - - def _handle_selection_start(self, x: int, y: int) -> GestureResponse: - """Start text selection""" - self._selection_start = (x, y) - self._selection_end = None - self._selected_range = None - - return GestureResponse(ActionType.SELECTION_START, { - "start": (x, y) - }) - - def _handle_selection_move(self, x: int, y: int) -> GestureResponse: - """Update text selection""" - if not self._selection_start: - return GestureResponse(ActionType.NONE, {}) - - self._selection_end = (x, y) - - # Query range - page = self.manager.get_current_page() - self._selected_range = page.query_range( - self._selection_start, - self._selection_end - ) - - return GestureResponse(ActionType.SELECTION_UPDATE, { - "start": self._selection_start, - "end": self._selection_end, - "text_count": len(self._selected_range.results), - "bounds": self._selected_range.bounds_list - }) - - def _handle_selection_end(self, x: int, y: int) -> GestureResponse: - """End text selection and return selected text""" - if not self._selection_start: - return GestureResponse(ActionType.NONE, {}) - - self._selection_end = (x, y) - - page = self.manager.get_current_page() - self._selected_range = page.query_range( - self._selection_start, - self._selection_end - ) - - return GestureResponse(ActionType.SELECTION_COMPLETE, { - "text": self._selected_range.text, - "word_count": len(self._selected_range.results), - "bounds": self._selected_range.bounds_list - }) - - def _handle_swipe_up(self, y: int) -> GestureResponse: - """Handle swipe up gesture - opens TOC overlay if from bottom of screen""" - # Check if swipe started from bottom 20% of screen - bottom_threshold = self.page_size[1] * 0.8 - - if y >= bottom_threshold: - # Open TOC overlay - overlay_image = self.open_toc_overlay() - if overlay_image: - return GestureResponse(ActionType.OVERLAY_OPENED, { - "overlay_type": "toc", - "chapters": self.get_chapters() - }) - - return GestureResponse(ActionType.NONE, {}) - - def _handle_swipe_down(self, y: int) -> GestureResponse: - """Handle swipe down gesture - opens settings overlay if from top of screen""" - # Check if swipe started from top 20% of screen - top_threshold = self.page_size[1] * 0.2 - - if y <= top_threshold: - # Open settings overlay - overlay_image = self.open_settings_overlay() - if overlay_image: - return GestureResponse(ActionType.OVERLAY_OPENED, { - "overlay_type": "settings", - "font_scale": self.base_font_scale, - "line_spacing": self.page_style.line_spacing, - "inter_block_spacing": self.page_style.inter_block_spacing - }) - - return GestureResponse(ActionType.NONE, {}) def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse: """Handle tap when overlay is open - select chapter, adjust settings, or close overlay""" @@ -1142,10 +829,6 @@ class EbookReader: self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) - def _handle_overlay_close(self) -> GestureResponse: - """Handle overlay close gesture (swipe down)""" - self.close_overlay() - return GestureResponse(ActionType.OVERLAY_CLOSED, {}) # =================================================================== # Highlighting API diff --git a/dreader/handlers/__init__.py b/dreader/handlers/__init__.py new file mode 100644 index 0000000..147b058 --- /dev/null +++ b/dreader/handlers/__init__.py @@ -0,0 +1,10 @@ +""" +Handlers module for dreader application. + +This module contains interaction handlers: +- GestureRouter: Routes touch events to appropriate handlers +""" + +from .gestures import GestureRouter + +__all__ = ['GestureRouter'] diff --git a/dreader/handlers/gestures.py b/dreader/handlers/gestures.py new file mode 100644 index 0000000..6011dbd --- /dev/null +++ b/dreader/handlers/gestures.py @@ -0,0 +1,282 @@ +""" +Gesture routing and handling. + +This module handles all touch event routing and gesture logic. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Tuple + +from ..gesture import TouchEvent, GestureType, GestureResponse, ActionType + +if TYPE_CHECKING: + from ..application import EbookReader + from pyWebLayout.core.query import SelectionRange + + +class GestureRouter: + """ + Routes and handles all gestures. + + This class centralizes all gesture handling logic, making it easier + to test and maintain gesture interactions. + """ + + def __init__(self, reader: 'EbookReader'): + """ + Initialize the gesture router. + + Args: + reader: EbookReader instance to route gestures for + """ + self.reader = reader + + # Selection state (for text selection gestures) + self._selection_start: Optional[Tuple[int, int]] = None + self._selection_end: Optional[Tuple[int, int]] = None + self._selected_range: Optional['SelectionRange'] = None + + def handle_touch(self, event: TouchEvent) -> GestureResponse: + """ + Handle a touch event from HAL. + + This is the main entry point for all touch interactions. + + Args: + event: TouchEvent from HAL with gesture type and coordinates + + Returns: + GestureResponse with action and data for UI to process + """ + if not self.reader.is_loaded(): + return GestureResponse(ActionType.ERROR, {"message": "No book loaded"}) + + # Handle overlay-specific gestures first + if self.reader.is_overlay_open(): + if event.gesture == GestureType.TAP: + return self._handle_overlay_tap(event.x, event.y) + elif event.gesture == GestureType.SWIPE_DOWN: + return self._handle_overlay_close() + + # Dispatch based on gesture type for normal reading mode + if event.gesture == GestureType.TAP: + return self._handle_tap(event.x, event.y) + elif event.gesture == GestureType.LONG_PRESS: + return self._handle_long_press(event.x, event.y) + elif event.gesture == GestureType.SWIPE_LEFT: + return self._handle_page_forward() + elif event.gesture == GestureType.SWIPE_RIGHT: + return self._handle_page_back() + elif event.gesture == GestureType.SWIPE_UP: + return self._handle_swipe_up(event.y) + elif event.gesture == GestureType.SWIPE_DOWN: + return self._handle_swipe_down(event.y) + elif event.gesture == GestureType.PINCH_IN: + return self._handle_zoom_out() + elif event.gesture == GestureType.PINCH_OUT: + return self._handle_zoom_in() + elif event.gesture == GestureType.DRAG_START: + return self._handle_selection_start(event.x, event.y) + elif event.gesture == GestureType.DRAG_MOVE: + return self._handle_selection_move(event.x, event.y) + elif event.gesture == GestureType.DRAG_END: + return self._handle_selection_end(event.x, event.y) + + return GestureResponse(ActionType.NONE, {}) + + # =================================================================== + # Reading Mode Gesture Handlers + # =================================================================== + + def _handle_tap(self, x: int, y: int) -> GestureResponse: + """Handle tap gesture - activates links or selects words""" + page = self.reader.manager.get_current_page() + result = page.query_point((x, y)) + + if not result or result.object_type == "empty": + return GestureResponse(ActionType.NONE, {}) + + # If it's a link, navigate + if result.is_interactive and result.link_target: + # Handle different link types + if result.link_target.endswith('.epub'): + # Open new book + success = self.reader.load_epub(result.link_target) + if success: + return GestureResponse(ActionType.BOOK_LOADED, { + "title": self.reader.book_title, + "author": self.reader.book_author, + "path": result.link_target + }) + else: + return GestureResponse(ActionType.ERROR, { + "message": f"Failed to load {result.link_target}" + }) + else: + # Internal navigation (chapter) + self.reader.jump_to_chapter(result.link_target) + return GestureResponse(ActionType.NAVIGATE, { + "target": result.link_target, + "chapter": self.reader.get_current_chapter_info() + }) + + # Just a tap on text - select word + if result.text: + return GestureResponse(ActionType.WORD_SELECTED, { + "word": result.text, + "bounds": result.bounds + }) + + return GestureResponse(ActionType.NONE, {}) + + def _handle_long_press(self, x: int, y: int) -> GestureResponse: + """Handle long-press - show definition or menu""" + page = self.reader.manager.get_current_page() + result = page.query_point((x, y)) + + if result and result.text: + return GestureResponse(ActionType.DEFINE, { + "word": result.text, + "bounds": result.bounds + }) + + # Long-press on empty - show menu + return GestureResponse(ActionType.SHOW_MENU, { + "options": ["bookmark", "settings", "toc", "search"] + }) + + def _handle_page_forward(self) -> GestureResponse: + """Handle swipe left - next page""" + img = self.reader.next_page() + if img: + return GestureResponse(ActionType.PAGE_TURN, { + "direction": "forward", + "progress": self.reader.get_reading_progress(), + "chapter": self.reader.get_current_chapter_info() + }) + return GestureResponse(ActionType.AT_END, {}) + + def _handle_page_back(self) -> GestureResponse: + """Handle swipe right - previous page""" + img = self.reader.previous_page() + if img: + return GestureResponse(ActionType.PAGE_TURN, { + "direction": "back", + "progress": self.reader.get_reading_progress(), + "chapter": self.reader.get_current_chapter_info() + }) + return GestureResponse(ActionType.AT_START, {}) + + def _handle_zoom_in(self) -> GestureResponse: + """Handle pinch out - increase font""" + self.reader.increase_font_size() + return GestureResponse(ActionType.ZOOM, { + "direction": "in", + "font_scale": self.reader.base_font_scale + }) + + def _handle_zoom_out(self) -> GestureResponse: + """Handle pinch in - decrease font""" + self.reader.decrease_font_size() + return GestureResponse(ActionType.ZOOM, { + "direction": "out", + "font_scale": self.reader.base_font_scale + }) + + def _handle_selection_start(self, x: int, y: int) -> GestureResponse: + """Start text selection""" + self._selection_start = (x, y) + self._selection_end = None + self._selected_range = None + + return GestureResponse(ActionType.SELECTION_START, { + "start": (x, y) + }) + + def _handle_selection_move(self, x: int, y: int) -> GestureResponse: + """Update text selection""" + if not self._selection_start: + return GestureResponse(ActionType.NONE, {}) + + self._selection_end = (x, y) + + # Query range + page = self.reader.manager.get_current_page() + self._selected_range = page.query_range( + self._selection_start, + self._selection_end + ) + + return GestureResponse(ActionType.SELECTION_UPDATE, { + "start": self._selection_start, + "end": self._selection_end, + "text_count": len(self._selected_range.results), + "bounds": self._selected_range.bounds_list + }) + + def _handle_selection_end(self, x: int, y: int) -> GestureResponse: + """End text selection and return selected text""" + if not self._selection_start: + return GestureResponse(ActionType.NONE, {}) + + self._selection_end = (x, y) + + page = self.reader.manager.get_current_page() + self._selected_range = page.query_range( + self._selection_start, + self._selection_end + ) + + return GestureResponse(ActionType.SELECTION_COMPLETE, { + "text": self._selected_range.text, + "word_count": len(self._selected_range.results), + "bounds": self._selected_range.bounds_list + }) + + def _handle_swipe_up(self, y: int) -> GestureResponse: + """Handle swipe up gesture - opens TOC overlay if from bottom of screen""" + # Check if swipe started from bottom 20% of screen + bottom_threshold = self.reader.page_size[1] * 0.8 + + if y >= bottom_threshold: + # Open TOC overlay + overlay_image = self.reader.open_toc_overlay() + if overlay_image: + return GestureResponse(ActionType.OVERLAY_OPENED, { + "overlay_type": "toc", + "chapters": self.reader.get_chapters() + }) + + return GestureResponse(ActionType.NONE, {}) + + def _handle_swipe_down(self, y: int) -> GestureResponse: + """Handle swipe down gesture - opens settings overlay if from top of screen""" + # Check if swipe started from top 20% of screen + top_threshold = self.reader.page_size[1] * 0.2 + + if y <= top_threshold: + # Open settings overlay + overlay_image = self.reader.open_settings_overlay() + if overlay_image: + return GestureResponse(ActionType.OVERLAY_OPENED, { + "overlay_type": "settings", + "font_scale": self.reader.base_font_scale, + "line_spacing": self.reader.page_style.line_spacing, + "inter_block_spacing": self.reader.page_style.inter_block_spacing + }) + + return GestureResponse(ActionType.NONE, {}) + + # =================================================================== + # Overlay Mode Gesture Handlers + # =================================================================== + + def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse: + """Handle tap when overlay is open - delegates to EbookReader overlay handlers""" + # This remains in EbookReader because it's tightly coupled with overlay state + return self.reader._handle_overlay_tap(x, y) + + def _handle_overlay_close(self) -> GestureResponse: + """Handle overlay close gesture (swipe down)""" + self.reader.close_overlay() + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) diff --git a/dreader/managers/__init__.py b/dreader/managers/__init__.py new file mode 100644 index 0000000..7444f42 --- /dev/null +++ b/dreader/managers/__init__.py @@ -0,0 +1,14 @@ +""" +Managers module for dreader application. + +This module contains business logic managers that handle specific responsibilities: +- DocumentManager: Document loading and metadata +- SettingsManager: Font size, spacing, and rendering settings +- HighlightCoordinator: Highlight operations coordination +""" + +from .document import DocumentManager +from .settings import SettingsManager +from .highlight_coordinator import HighlightCoordinator + +__all__ = ['DocumentManager', 'SettingsManager', 'HighlightCoordinator'] diff --git a/dreader/managers/document.py b/dreader/managers/document.py new file mode 100644 index 0000000..bcf66be --- /dev/null +++ b/dreader/managers/document.py @@ -0,0 +1,137 @@ +""" +Document loading and metadata management. + +This module handles EPUB and HTML loading, extracting blocks and metadata. +""" + +from __future__ import annotations +from typing import List, Tuple, Dict, Any, Optional +from pathlib import Path +import os + +from pyWebLayout.io.readers.epub_reader import read_epub +from pyWebLayout.io.readers.html_extraction import parse_html_string +from pyWebLayout.abstract.block import Block + + +class DocumentManager: + """ + Handles document loading and metadata extraction. + + Responsibilities: + - Load EPUB files + - Load HTML content + - Extract document metadata (title, author, etc.) + - Extract content blocks for rendering + """ + + def __init__(self): + """Initialize the document manager.""" + self.document_id: Optional[str] = None + self.title: Optional[str] = None + self.author: Optional[str] = None + self.blocks: Optional[List[Block]] = None + + def load_epub(self, epub_path: str) -> bool: + """ + Load an EPUB file and extract content. + + 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.title = book.get_title() or "Unknown Title" + self.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") + + return True + + except Exception as e: + print(f"Error loading EPUB: {e}") + return False + + def load_html(self, html_string: str, title: str = "HTML Document", + author: str = "Unknown", document_id: str = "html_doc") -> bool: + """ + Load HTML content directly. + + This is useful for rendering library screens, menus, or other HTML-based UI elements. + + Args: + html_string: HTML content to render + title: Document title (for metadata) + author: Document author (for metadata) + document_id: Unique identifier for this HTML document + + Returns: + True if loaded successfully, False otherwise + """ + try: + # Parse HTML into blocks + blocks = parse_html_string(html_string) + + if not blocks: + raise ValueError("No content blocks parsed from HTML") + + # Set metadata + self.title = title + self.author = author + self.document_id = document_id + self.blocks = blocks + + return True + + except Exception as e: + print(f"Error loading HTML: {e}") + return False + + def is_loaded(self) -> bool: + """Check if a document is currently loaded.""" + return self.blocks is not None and len(self.blocks) > 0 + + def get_metadata(self) -> Dict[str, Any]: + """ + Get document metadata. + + Returns: + Dictionary with metadata (title, author, document_id, total_blocks) + """ + return { + 'title': self.title, + 'author': self.author, + 'document_id': self.document_id, + 'total_blocks': len(self.blocks) if self.blocks else 0 + } + + def get_blocks(self) -> Optional[List[Block]]: + """Get the list of content blocks.""" + return self.blocks + + def clear(self): + """Clear the currently loaded document.""" + self.document_id = None + self.title = None + self.author = None + self.blocks = None diff --git a/dreader/managers/highlight_coordinator.py b/dreader/managers/highlight_coordinator.py new file mode 100644 index 0000000..7edbdb9 --- /dev/null +++ b/dreader/managers/highlight_coordinator.py @@ -0,0 +1,211 @@ +""" +Highlight operations coordination. + +This module coordinates highlight operations with the highlight manager. +""" + +from __future__ import annotations +from typing import List, Tuple, Optional, TYPE_CHECKING +from PIL import Image +import numpy as np + +from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result + +if TYPE_CHECKING: + from pyWebLayout.layout.ereader_manager import EreaderLayoutManager + + +class HighlightCoordinator: + """ + Coordinates highlight operations. + + This class provides a simplified interface for highlighting operations, + coordinating between the layout manager and highlight manager. + """ + + def __init__(self, document_id: str, highlights_dir: str): + """ + Initialize the highlight coordinator. + + Args: + document_id: Unique document identifier + highlights_dir: Directory to store highlights + """ + self.highlight_manager = HighlightManager( + document_id=document_id, + highlights_dir=highlights_dir + ) + self.layout_manager: Optional['EreaderLayoutManager'] = None + + def set_layout_manager(self, manager: 'EreaderLayoutManager'): + """Set the layout manager.""" + self.layout_manager = manager + + def highlight_word(self, x: int, y: int, + color: Tuple[int, int, int, int] = None, + note: Optional[str] = None, + tags: Optional[List[str]] = None) -> Optional[str]: + """ + Highlight a word at the given pixel location. + + Args: + x: X coordinate + y: Y coordinate + color: RGBA color tuple (defaults to yellow) + note: Optional annotation for this highlight + tags: Optional categorization tags + + Returns: + Highlight ID if successful, None otherwise + """ + if not self.layout_manager: + return None + + try: + # Query the pixel to find the word + page = self.layout_manager.get_current_page() + result = page.query_point((x, y)) + if not result or not result.text: + return None + + # Use default color if not provided + if color is None: + color = HighlightColor.YELLOW.value + + # Create highlight from query result + highlight = create_highlight_from_query_result( + result, + color=color, + note=note, + tags=tags + ) + + # Add to manager + self.highlight_manager.add_highlight(highlight) + + return highlight.id + except Exception as e: + print(f"Error highlighting word: {e}") + return None + + def highlight_selection(self, start: Tuple[int, int], end: Tuple[int, int], + color: Tuple[int, int, int, int] = None, + note: Optional[str] = None, + tags: Optional[List[str]] = None) -> Optional[str]: + """ + Highlight a range of words between two points. + + Args: + start: Starting (x, y) coordinates + end: Ending (x, y) coordinates + color: RGBA color tuple (defaults to yellow) + note: Optional annotation + tags: Optional categorization tags + + Returns: + Highlight ID if successful, None otherwise + """ + if not self.layout_manager: + return None + + try: + page = self.layout_manager.get_current_page() + selection_range = page.query_range(start, end) + + if not selection_range.results: + return None + + # Use default color if not provided + if color is None: + color = HighlightColor.YELLOW.value + + # Create highlight from selection range + highlight = create_highlight_from_query_result( + selection_range, + color=color, + note=note, + tags=tags + ) + + # Add to manager + self.highlight_manager.add_highlight(highlight) + + return highlight.id + except Exception as e: + print(f"Error highlighting selection: {e}") + return None + + def remove_highlight(self, highlight_id: str) -> bool: + """Remove a highlight by ID.""" + return self.highlight_manager.remove_highlight(highlight_id) + + def list_highlights(self) -> List[Highlight]: + """Get all highlights for the current document.""" + return self.highlight_manager.list_highlights() + + def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]: + """Get highlights that appear on a specific page.""" + return self.highlight_manager.get_highlights_for_page(page_bounds) + + def clear_all(self) -> None: + """Remove all highlights from the current document.""" + self.highlight_manager.clear_all() + + def render_highlights(self, image: Image.Image, highlights: List[Highlight]) -> Image.Image: + """ + Render highlight overlays on an image using multiply blend mode. + + Args: + image: Base PIL Image to draw on + highlights: List of Highlight objects to render + + Returns: + New PIL Image with highlights overlaid + """ + # Convert to RGB for processing + original_mode = image.mode + if image.mode == 'RGBA': + rgb_image = image.convert('RGB') + alpha_channel = image.split()[-1] + else: + rgb_image = image.convert('RGB') + alpha_channel = None + + # Convert to numpy array for efficient processing + img_array = np.array(rgb_image, dtype=np.float32) + + # Process each highlight + for highlight in highlights: + # Extract RGB components from highlight color (ignore alpha) + h_r, h_g, h_b = highlight.color[0], highlight.color[1], highlight.color[2] + + # Create highlight multiplier (normalize to 0-1 range) + highlight_color = np.array([h_r / 255.0, h_g / 255.0, h_b / 255.0], dtype=np.float32) + + for hx, hy, hw, hh in highlight.bounds: + # Ensure bounds are within image + hx, hy = max(0, hx), max(0, hy) + x2, y2 = min(rgb_image.width, hx + hw), min(rgb_image.height, hy + hh) + + if x2 <= hx or y2 <= hy: + continue + + # Extract the region to highlight + region = img_array[hy:y2, hx:x2, :] + + # Multiply with highlight color (like a real highlighter) + highlighted = region * highlight_color + + # Put the highlighted region back + img_array[hy:y2, hx:x2, :] = highlighted + + # Convert back to uint8 and create PIL Image + img_array = np.clip(img_array, 0, 255).astype(np.uint8) + result = Image.fromarray(img_array, mode='RGB') + + # Restore alpha channel if original had one + if alpha_channel is not None and original_mode == 'RGBA': + result = result.convert('RGBA') + result.putalpha(alpha_channel) + + return result diff --git a/dreader/managers/settings.py b/dreader/managers/settings.py new file mode 100644 index 0000000..ef461da --- /dev/null +++ b/dreader/managers/settings.py @@ -0,0 +1,250 @@ +""" +Settings and rendering configuration management. + +This module handles font size, spacing, and other rendering settings. +""" + +from __future__ import annotations +from typing import Dict, Any, Optional +from PIL import Image + +from pyWebLayout.layout.ereader_manager import EreaderLayoutManager + + +class SettingsManager: + """ + Manages font size, spacing, and rendering settings. + + Responsibilities: + - Font scale adjustment + - Line spacing control + - Inter-block spacing control + - Word spacing control + - Settings persistence helpers + """ + + def __init__(self): + """Initialize the settings manager.""" + self.font_scale = 1.0 + self.font_scale_step = 0.1 # 10% change per step + self.manager: Optional[EreaderLayoutManager] = None + + def set_manager(self, manager: EreaderLayoutManager): + """ + Set the layout manager to control. + + Args: + manager: EreaderLayoutManager instance to manage settings for + """ + self.manager = manager + self.font_scale = manager.font_scale + + 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: + Rendered page with new font size, or None if no manager + """ + if not self.manager: + return None + + try: + self.font_scale = max(0.5, min(3.0, scale)) # Clamp between 0.5x and 3.0x + page = self.manager.set_font_scale(self.font_scale) + return page.render() if page else None + 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: + Rendered page with increased font size + """ + new_scale = self.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: + Rendered page with decreased font size + """ + new_scale = self.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.font_scale + + def set_line_spacing(self, spacing: int) -> Optional[Image.Image]: + """ + Set line spacing using pyWebLayout's native support. + + Args: + spacing: Line spacing in pixels + + Returns: + Rendered page with new line spacing + """ + if not self.manager: + return None + + try: + # Calculate delta from current spacing + current_spacing = self.manager.page_style.line_spacing + target_spacing = max(0, spacing) + delta = target_spacing - current_spacing + + # Use pyWebLayout's built-in methods to adjust spacing + if delta > 0: + self.manager.increase_line_spacing(abs(delta)) + elif delta < 0: + self.manager.decrease_line_spacing(abs(delta)) + + # Get re-rendered page + page = self.manager.get_current_page() + return page.render() if page else None + 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 inter-block spacing using pyWebLayout's native support. + + Args: + spacing: Inter-block spacing in pixels + + Returns: + Rendered page with new inter-block spacing + """ + if not self.manager: + return None + + try: + # Calculate delta from current spacing + current_spacing = self.manager.page_style.inter_block_spacing + target_spacing = max(0, spacing) + delta = target_spacing - current_spacing + + # Use pyWebLayout's built-in methods to adjust spacing + if delta > 0: + self.manager.increase_inter_block_spacing(abs(delta)) + elif delta < 0: + self.manager.decrease_inter_block_spacing(abs(delta)) + + # Get re-rendered page + page = self.manager.get_current_page() + return page.render() if page else None + except Exception as e: + print(f"Error setting inter-block spacing: {e}") + return None + + def set_word_spacing(self, spacing: int) -> Optional[Image.Image]: + """ + Set word spacing using pyWebLayout's native support. + + Args: + spacing: Word spacing in pixels + + Returns: + Rendered page with new word spacing + """ + if not self.manager: + return None + + try: + # Calculate delta from current spacing + current_spacing = self.manager.page_style.word_spacing + target_spacing = max(0, spacing) + delta = target_spacing - current_spacing + + # Use pyWebLayout's built-in methods to adjust spacing + if delta > 0: + self.manager.increase_word_spacing(abs(delta)) + elif delta < 0: + self.manager.decrease_word_spacing(abs(delta)) + + # Get re-rendered page + page = self.manager.get_current_page() + return page.render() if page else None + except Exception as e: + print(f"Error setting word spacing: {e}") + return None + + def get_current_settings(self) -> Dict[str, Any]: + """ + Get current rendering settings. + + Returns: + Dictionary with all current settings + """ + if not self.manager: + return { + 'font_scale': self.font_scale, + 'line_spacing': 5, + 'inter_block_spacing': 15, + 'word_spacing': 0 + } + + return { + 'font_scale': self.font_scale, + 'line_spacing': self.manager.page_style.line_spacing, + 'inter_block_spacing': self.manager.page_style.inter_block_spacing, + 'word_spacing': self.manager.page_style.word_spacing + } + + def apply_settings(self, settings: Dict[str, Any]) -> bool: + """ + Apply rendering settings from a settings dictionary. + + This should be called after loading a book to restore user preferences. + + Args: + settings: Dictionary with settings (font_scale, line_spacing, etc.) + + Returns: + True if settings applied successfully, False otherwise + """ + if not self.manager: + return False + + try: + # Apply font scale + font_scale = settings.get('font_scale', 1.0) + if font_scale != self.font_scale: + self.set_font_size(font_scale) + + # Apply line spacing + line_spacing = settings.get('line_spacing', 5) + if line_spacing != self.manager.page_style.line_spacing: + self.set_line_spacing(line_spacing) + + # Apply inter-block spacing + inter_block_spacing = settings.get('inter_block_spacing', 15) + if inter_block_spacing != self.manager.page_style.inter_block_spacing: + self.set_inter_block_spacing(inter_block_spacing) + + # Apply word spacing + word_spacing = settings.get('word_spacing', 0) + if word_spacing != self.manager.page_style.word_spacing: + self.set_word_spacing(word_spacing) + + return True + + except Exception as e: + print(f"Error applying settings: {e}") + return False diff --git a/examples/README_EREADER.md b/examples/README_EREADER.md index a031acf..b8ed104 100644 --- a/examples/README_EREADER.md +++ b/examples/README_EREADER.md @@ -9,8 +9,12 @@ The `EbookReader` class provides a complete, user-friendly interface for buildin - 🔖 **Position Management** - Save/load reading positions (stable across font changes) - 📑 **Chapter Navigation** - Jump to chapters by title or index - 🔤 **Font Size Control** - Increase/decrease font size with live re-rendering -- 📏 **Spacing Control** - Adjust line and block spacing +- 📏 **Spacing Control** - Adjust line, block, and word spacing +- 💾 **Persistent Settings** - Save and restore rendering preferences across sessions - 📊 **Progress Tracking** - Get reading progress and position information +- 🎨 **Text Highlighting** - Highlight words and passages with colors +- 📋 **Overlays** - TOC, Settings, and Bookmarks overlays +- 🖱️ **Gesture Support** - Handle tap, swipe, pinch gestures - 💾 **Context Manager Support** - Automatic cleanup with `with` statement ## Quick Start @@ -235,12 +239,66 @@ with EbookReader( print(f"Reading progress: {progress*100:.1f}%") ``` -## Demo Script +## Persistent Settings -Run the comprehensive demo to see all features in action: +Settings like font size and spacing are automatically saved and restored across sessions: + +```python +from dreader import EbookReader +from dreader.state import StateManager +from pathlib import Path + +# Initialize state manager +state_file = Path.home() / ".config" / "dreader" / "state.json" +state_manager = StateManager(state_file=state_file) + +# Load saved state +state = state_manager.load_state() +print(f"Saved font scale: {state.settings.font_scale}") + +# Create reader with saved settings +reader = EbookReader( + line_spacing=state.settings.line_spacing, + inter_block_spacing=state.settings.inter_block_spacing +) + +# Load book and apply all saved settings +reader.load_epub("mybook.epub") +reader.apply_settings(state.settings.to_dict()) + +# User changes settings... +reader.increase_font_size() +reader.set_line_spacing(10) + +# Save new settings for next session +current_settings = reader.get_current_settings() +state_manager.update_settings(current_settings) +state_manager.save_state() + +# Next time the app starts, these settings will be restored! +``` + +See [persistent_settings_example.py](persistent_settings_example.py) for a complete demonstration. + +## Demo Scripts + +Run these demos to see features in action: ```bash +# Comprehensive feature demo python examples/ereader_demo.py path/to/book.epub + +# Persistent settings demo +python examples/persistent_settings_example.py + +# TOC overlay demo (generates animated GIF) +python examples/demo_toc_overlay.py + +# Settings overlay demo (generates animated GIF) +python examples/demo_settings_overlay.py + +# Word highlighting examples +python examples/word_selection_highlighting.py ``` This will demonstrate: diff --git a/tests/test_toc_overlay.py b/tests/test_toc_overlay.py index 4fb934b..f2d19b4 100644 --- a/tests/test_toc_overlay.py +++ b/tests/test_toc_overlay.py @@ -166,9 +166,11 @@ class TestTOCOverlay(unittest.TestCase): self.skipTest("Need at least 2 chapters for this test") # Calculate tap position for second chapter (index 1 - "Metamorphosis") - # Based on actual measurements: chapter 1 link is at screen Y=282, X=200-300 + # Based on actual measurements from pyWebLayout query_point: + # Overlay bounds: (38, 138, 122, 16) -> X=[38,160], Y=[138,154] + # With panel offset (160, 180): Screen X=[198,320], Y=[318,334] tap_x = 250 # Within the link text bounds - tap_y = 282 # Chapter 1 "Metamorphosis" + tap_y = 335 # Chapter 1 "Metamorphosis" at overlay Y=155 (138+16=154, screen 180+155=335) event = TouchEvent( gesture=GestureType.TAP, diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..7d45aae --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for dreader modules.""" diff --git a/tests/unit/managers/__init__.py b/tests/unit/managers/__init__.py new file mode 100644 index 0000000..496d1d6 --- /dev/null +++ b/tests/unit/managers/__init__.py @@ -0,0 +1 @@ +"""Unit tests for manager modules.""" diff --git a/tests/unit/managers/test_document.py b/tests/unit/managers/test_document.py new file mode 100644 index 0000000..665db4e --- /dev/null +++ b/tests/unit/managers/test_document.py @@ -0,0 +1,164 @@ +""" +Unit tests for DocumentManager. + +Tests document loading in isolation without full EbookReader. +""" + +import unittest +import tempfile +import os +from pathlib import Path + +from dreader.managers.document import DocumentManager + + +class TestDocumentManager(unittest.TestCase): + """Test DocumentManager in isolation""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + self.epub_path = "tests/data/test.epub" + self.manager = DocumentManager() + + def tearDown(self): + """Clean up""" + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_initialization(self): + """Test manager initializes correctly""" + manager = DocumentManager() + + self.assertIsNone(manager.document_id) + self.assertIsNone(manager.title) + self.assertIsNone(manager.author) + self.assertIsNone(manager.blocks) + self.assertFalse(manager.is_loaded()) + + def test_load_valid_epub(self): + """Test loading a valid EPUB file""" + if not Path(self.epub_path).exists(): + self.skipTest(f"Test EPUB not found at {self.epub_path}") + + success = self.manager.load_epub(self.epub_path) + + self.assertTrue(success) + self.assertTrue(self.manager.is_loaded()) + self.assertIsNotNone(self.manager.document_id) + self.assertIsNotNone(self.manager.title) + self.assertIsNotNone(self.manager.author) + self.assertIsNotNone(self.manager.blocks) + self.assertGreater(len(self.manager.blocks), 0) + + def test_load_nonexistent_epub(self): + """Test loading a non-existent EPUB file""" + success = self.manager.load_epub("nonexistent.epub") + + self.assertFalse(success) + self.assertFalse(self.manager.is_loaded()) + + 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") + + success = self.manager.load_epub(invalid_path) + + self.assertFalse(success) + self.assertFalse(self.manager.is_loaded()) + + def test_load_html_success(self): + """Test loading HTML content""" + html = """ + +
+This is a test paragraph.
+ + + """ + + success = self.manager.load_html( + html, + title="Test HTML", + author="Test Author", + document_id="test_html" + ) + + self.assertTrue(success) + self.assertTrue(self.manager.is_loaded()) + self.assertEqual(self.manager.title, "Test HTML") + self.assertEqual(self.manager.author, "Test Author") + self.assertEqual(self.manager.document_id, "test_html") + self.assertGreater(len(self.manager.blocks), 0) + + def test_load_empty_html(self): + """Test loading empty HTML""" + success = self.manager.load_html("") + + self.assertFalse(success) + self.assertFalse(self.manager.is_loaded()) + + def test_get_metadata(self): + """Test getting document metadata""" + if not Path(self.epub_path).exists(): + self.skipTest(f"Test EPUB not found at {self.epub_path}") + + self.manager.load_epub(self.epub_path) + metadata = self.manager.get_metadata() + + self.assertIsInstance(metadata, dict) + self.assertIn('title', metadata) + self.assertIn('author', metadata) + self.assertIn('document_id', metadata) + self.assertIn('total_blocks', metadata) + self.assertGreater(metadata['total_blocks'], 0) + + def test_get_blocks(self): + """Test getting content blocks""" + if not Path(self.epub_path).exists(): + self.skipTest(f"Test EPUB not found at {self.epub_path}") + + self.manager.load_epub(self.epub_path) + blocks = self.manager.get_blocks() + + self.assertIsInstance(blocks, list) + self.assertGreater(len(blocks), 0) + + def test_clear(self): + """Test clearing loaded document""" + if not Path(self.epub_path).exists(): + self.skipTest(f"Test EPUB not found at {self.epub_path}") + + self.manager.load_epub(self.epub_path) + self.assertTrue(self.manager.is_loaded()) + + self.manager.clear() + + self.assertFalse(self.manager.is_loaded()) + self.assertIsNone(self.manager.document_id) + self.assertIsNone(self.manager.title) + self.assertIsNone(self.manager.author) + self.assertIsNone(self.manager.blocks) + + def test_multiple_loads(self): + """Test loading multiple documents sequentially""" + html1 = "