#!/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.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 .gesture import TouchEvent, GestureType, GestureResponse, ActionType from .state import OverlayState from .overlay import OverlayManager 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", highlights_dir: str = "highlights", 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 highlights_dir: Directory to store highlights buffer_size: Number of pages to cache for performance """ self.page_size = page_size self.bookmarks_dir = bookmarks_dir self.highlights_dir = highlights_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 self.highlight_manager: Optional[HighlightManager] = None # Font scale state 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 # Overlay management self.overlay_manager = OverlayManager(page_size=page_size) self.current_overlay_state = OverlayState.NONE 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 ) # 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}") 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 into the reader. This is useful for rendering library screens, menus, or other HTML-based UI elements using the same rendering engine as the ebook reader. 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.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}") 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, include_highlights: bool = True) -> Optional[Image.Image]: """ Get the current page as a PIL Image. If an overlay is currently open, returns the composited overlay image. Otherwise returns the base reading page. Args: include_highlights: Whether to overlay highlights on the page (only applies to base page) Returns: PIL Image of the current page (or overlay), or None if no book is loaded """ if not self.manager: return None # If an overlay is open, return the cached composited overlay image if self.is_overlay_open() and self.overlay_manager._cached_base_page: # Return the last composited overlay image # The overlay manager keeps this updated when settings change return self.overlay_manager.composite_overlay( self.overlay_manager._cached_base_page, self.overlay_manager._cached_overlay_image ) try: page = self.manager.get_current_page() img = page.render() # Overlay highlights if requested and available if include_highlights and self.highlight_manager: # Get page bounds page_bounds = (0, 0, self.page_size[0], self.page_size[1]) highlights = self.highlight_manager.get_highlights_for_page(page_bounds) if highlights: img = self._render_highlights(img, highlights) return img 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 using pyWebLayout's native support. Args: spacing: Line spacing in pixels 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.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. Args: spacing: Inter-block spacing in pixels 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 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: 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 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 } # ===== Gesture Handling ===== # All business logic for touch input is handled here def handle_touch(self, event: TouchEvent) -> GestureResponse: """ Handle a touch event from HAL. **This is the main business logic entry point for all touch interactions.** Flask should call this and use the response to generate HTML/JSON. Args: event: TouchEvent from HAL with gesture type and coordinates 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, {}) def query_pixel(self, x: int, y: int) -> Optional[QueryResult]: """ Direct pixel query for debugging/tools. Args: x, y: Pixel coordinates Returns: QueryResult or None if nothing at that location """ if not self.manager: return None 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""" # For TOC overlay, use pyWebLayout link query to detect chapter clicks if self.current_overlay_state == OverlayState.TOC: # Query the overlay to see what was tapped query_result = self.overlay_manager.query_overlay_pixel(x, y) # If query failed (tap outside overlay), close it if not query_result: self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) # Check if tapped on a link (chapter) if query_result.get("is_interactive") and query_result.get("link_target"): link_target = query_result["link_target"] # Parse "chapter:N" format if link_target.startswith("chapter:"): try: chapter_idx = int(link_target.split(":")[1]) # Get chapter title for response chapters = self.get_chapters() chapter_title = None for title, idx in chapters: if idx == chapter_idx: chapter_title = title break # Jump to selected chapter self.jump_to_chapter(chapter_idx) # Close overlay self.close_overlay() return GestureResponse(ActionType.CHAPTER_SELECTED, { "chapter_index": chapter_idx, "chapter_title": chapter_title or f"Chapter {chapter_idx}" }) except (ValueError, IndexError): pass # Not a chapter link, close overlay self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) # For settings overlay, handle setting adjustments elif self.current_overlay_state == OverlayState.SETTINGS: # Query the overlay to see what was tapped query_result = self.overlay_manager.query_overlay_pixel(x, y) # If query failed (tap outside overlay), close it if not query_result: self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) # Check if tapped on a settings control link if query_result.get("is_interactive") and query_result.get("link_target"): link_target = query_result["link_target"] # Parse "setting:action" format if link_target.startswith("setting:"): action = link_target.split(":", 1)[1] # Apply the setting change if action == "font_increase": self.increase_font_size() elif action == "font_decrease": self.decrease_font_size() elif action == "line_spacing_increase": new_spacing = self.page_style.line_spacing + 2 self.set_line_spacing(new_spacing) elif action == "line_spacing_decrease": new_spacing = max(0, self.page_style.line_spacing - 2) self.set_line_spacing(new_spacing) elif action == "block_spacing_increase": new_spacing = self.page_style.inter_block_spacing + 3 self.set_inter_block_spacing(new_spacing) elif action == "block_spacing_decrease": new_spacing = max(0, self.page_style.inter_block_spacing - 3) self.set_inter_block_spacing(new_spacing) elif action == "word_spacing_increase": new_spacing = self.page_style.word_spacing + 2 self.set_word_spacing(new_spacing) elif action == "word_spacing_decrease": new_spacing = max(0, self.page_style.word_spacing - 2) self.set_word_spacing(new_spacing) # Re-render the base page with new settings applied # Must get directly from manager, not get_current_page() which returns overlay page = self.manager.get_current_page() updated_page = page.render() # Refresh the settings overlay with updated values and page self.overlay_manager.refresh_settings_overlay( updated_base_page=updated_page, font_scale=self.base_font_scale, line_spacing=self.page_style.line_spacing, inter_block_spacing=self.page_style.inter_block_spacing, word_spacing=self.page_style.word_spacing ) return GestureResponse(ActionType.SETTING_CHANGED, { "action": action, "font_scale": self.base_font_scale, "line_spacing": self.page_style.line_spacing, "inter_block_spacing": self.page_style.inter_block_spacing, "word_spacing": self.page_style.word_spacing }) # Not a setting control, close overlay self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) # For other overlays, just close on any tap for now 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 # =================================================================== 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.manager or not self.highlight_manager: return None try: # Query the pixel to find the word result = self.query_pixel(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.manager or not self.highlight_manager: return None try: page = self.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. Args: highlight_id: ID of the highlight to remove Returns: True if removed successfully, False otherwise """ if not self.highlight_manager: return False return self.highlight_manager.remove_highlight(highlight_id) def list_highlights(self) -> List[Highlight]: """ Get all highlights for the current document. Returns: List of Highlight objects """ if not self.highlight_manager: return [] return self.highlight_manager.list_highlights() def get_highlights_for_current_page(self) -> List[Highlight]: """ Get highlights that appear on the current page. Returns: List of Highlight objects on this page """ if not self.manager or not self.highlight_manager: return [] page_bounds = (0, 0, self.page_size[0], self.page_size[1]) return self.highlight_manager.get_highlights_for_page(page_bounds) def clear_highlights(self) -> None: """Remove all highlights from the current document.""" if self.highlight_manager: 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. This preserves text contrast by multiplying the highlight color with the underlying pixels, like a real highlighter pen. Args: image: Base PIL Image to draw on highlights: List of Highlight objects to render Returns: New PIL Image with highlights overlaid """ import numpy as np # Convert to RGB for processing (we'll add alpha back later if needed) original_mode = image.mode if image.mode == 'RGBA': # Separate alpha channel 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) # This darkens the image proportionally to the highlight color 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 # =================================================================== # Overlay Management API # =================================================================== def open_toc_overlay(self) -> Optional[Image.Image]: """ Open the table of contents overlay. Returns: Composited image with TOC overlay on top of current page, or None if no book loaded """ if not self.is_loaded(): return None # Get current page as base base_page = self.get_current_page(include_highlights=False) if not base_page: return None # Get chapters chapters = self.get_chapters() # Open overlay and get composited image result = self.overlay_manager.open_toc_overlay(chapters, base_page) self.current_overlay_state = OverlayState.TOC return result def open_settings_overlay(self) -> Optional[Image.Image]: """ Open the settings overlay with current settings values. Returns: Composited image with settings overlay on top of current page, or None if no book loaded """ if not self.is_loaded(): return None # Get current page as base base_page = self.get_current_page(include_highlights=False) if not base_page: return None # Get current settings font_scale = self.base_font_scale line_spacing = self.page_style.line_spacing inter_block_spacing = self.page_style.inter_block_spacing word_spacing = self.page_style.word_spacing # Open overlay and get composited image result = self.overlay_manager.open_settings_overlay( base_page, font_scale=font_scale, line_spacing=line_spacing, inter_block_spacing=inter_block_spacing, word_spacing=word_spacing ) self.current_overlay_state = OverlayState.SETTINGS return result def open_bookmarks_overlay(self) -> Optional[Image.Image]: """ Open the bookmarks overlay. Returns: Composited image with bookmarks overlay on top of current page, or None if no book loaded """ if not self.is_loaded(): return None # Get current page as base base_page = self.get_current_page(include_highlights=False) if not base_page: return None # Get bookmarks bookmark_names = self.list_saved_positions() bookmarks = [ {"name": name, "position": f"Saved position"} for name in bookmark_names ] # Open overlay and get composited image result = self.overlay_manager.open_bookmarks_overlay(bookmarks, base_page) self.current_overlay_state = OverlayState.BOOKMARKS return result def close_overlay(self) -> Optional[Image.Image]: """ Close the current overlay and return to reading view. Returns: Base page image without overlay, or None if no overlay was open """ if self.current_overlay_state == OverlayState.NONE: return None result = self.overlay_manager.close_overlay() self.current_overlay_state = OverlayState.NONE # Return fresh current page return self.get_current_page() def is_overlay_open(self) -> bool: """Check if an overlay is currently open.""" return self.current_overlay_state != OverlayState.NONE def get_overlay_state(self) -> OverlayState: """Get current overlay state.""" return self.current_overlay_state 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)