dreader-application/dreader/application.py

1326 lines
46 KiB
Python

#!/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)