#!/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.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, 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: """ 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 ) # 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 = None # Will delegate to highlight_coordinator # Font scale state (delegated to settings_manager but kept for compatibility) self.base_font_scale = 1.0 self.font_scale_step = 0.1 # 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 """ # Use DocumentManager to load the EPUB success = self.doc_manager.load_epub(epub_path) 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: """ 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 """ # Use DocumentManager to load HTML success = self.doc_manager.load_html(html_string, title, author, document_id) 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 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 """ 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 """ 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 """ 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.settings_manager.get_font_size() 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 """ return self.settings_manager.set_line_spacing(spacing) 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 """ return self.settings_manager.set_inter_block_spacing(spacing) 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 """ return self.settings_manager.set_word_spacing(spacing) 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 } # ===== Settings Persistence ===== def get_current_settings(self) -> Dict[str, Any]: """ Get current rendering settings. Returns: Dictionary with all current settings """ return self.settings_manager.get_current_settings() 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 """ 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 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 """ # Delegate to gesture router return self.gesture_router.handle_touch(event) 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_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 }) # Parse "action:command" format for other actions elif link_target.startswith("action:"): action = link_target.split(":", 1)[1] if action == "back_to_library": # Close the overlay first self.close_overlay() # Return a special action for the application to handle return GestureResponse(ActionType.BACK_TO_LIBRARY, {}) # Not a setting control, close overlay self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) # For navigation overlay, handle tab switching, chapter/bookmark selection, and close elif self.current_overlay_state == OverlayState.NAVIGATION: # 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 if query_result.get("is_interactive") and query_result.get("link_target"): link_target = query_result["link_target"] # Parse "tab:tabname" format for tab switching if link_target.startswith("tab:"): tab_name = link_target.split(":", 1)[1] # Switch to the selected tab self.switch_navigation_tab(tab_name) return GestureResponse(ActionType.TAB_SWITCHED, { "tab": tab_name }) # Parse "chapter:N" format for chapter navigation elif 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 # Parse "bookmark:name" format for bookmark navigation elif link_target.startswith("bookmark:"): bookmark_name = link_target.split(":", 1)[1] # Load the bookmark position page = self.load_position(bookmark_name) if page: # Close overlay self.close_overlay() return GestureResponse(ActionType.BOOKMARK_SELECTED, { "bookmark_name": bookmark_name }) else: # Failed to load bookmark return GestureResponse(ActionType.ERROR, { "message": f"Failed to load bookmark: {bookmark_name}" }) # Parse "action:close" format for close button elif link_target.startswith("action:"): action = link_target.split(":", 1)[1] if action == "close": self.close_overlay() return GestureResponse(ActionType.OVERLAY_CLOSED, {}) # Not an interactive element, 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, {}) # =================================================================== # 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 open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]: """ Open the unified navigation overlay with Contents and Bookmarks tabs. This is the new unified overlay that replaces separate TOC and Bookmarks overlays. It provides a tabbed interface for switching between table of contents and bookmarks. Args: active_tab: Which tab to show initially ("contents" or "bookmarks") Returns: Composited image with navigation 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 for Contents tab chapters = self.get_chapters() # Get bookmarks for Bookmarks tab 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_navigation_overlay( chapters=chapters, bookmarks=bookmarks, base_page=base_page, active_tab=active_tab ) self.current_overlay_state = OverlayState.NAVIGATION return result def switch_navigation_tab(self, new_tab: str) -> Optional[Image.Image]: """ Switch between tabs in the navigation overlay. Args: new_tab: Tab to switch to ("contents" or "bookmarks") Returns: Updated image with new tab active, or None if navigation overlay is not open """ if self.current_overlay_state != OverlayState.NAVIGATION: return None result = self.overlay_manager.switch_navigation_tab(new_tab) return result if result else self.get_current_page() 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)