diff --git a/README.md b/README.md index 5493728..4b923c7 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This project serves as both a reference implementation and a ready-to-use ereade - ⬅️➡️ **Navigation** - Smooth forward and backward page navigation - 🔖 **Bookmarks** - Save and restore reading positions with persistence - 📑 **Chapter Navigation** - Jump to chapters by title or index via TOC +- 📋 **TOC Overlay** - Interactive table of contents overlay with gesture support - 📊 **Progress Tracking** - Real-time reading progress percentage ### Text Interaction @@ -86,11 +87,16 @@ Here are animated demonstrations of the key features: - + Text Highlighting
Highlighting
Highlight words and selections with custom colors and notes + + TOC Overlay
+ TOC Overlay
+ Interactive table of contents with gesture-based navigation + @@ -149,6 +155,11 @@ reader.jump_to_chapter(0) # By index reader.get_chapters() # List all chapters reader.get_current_chapter_info() reader.get_reading_progress() # Returns 0.0 to 1.0 + +# TOC Overlay +overlay_image = reader.open_toc_overlay() # Returns composited image with TOC +reader.close_overlay() +reader.is_overlay_open() ``` ### Styling & Display @@ -207,7 +218,7 @@ reader.get_highlights_for_current_page() ### Gesture Handling ```python -from pyWebLayout.io.gesture import TouchEvent, GestureType +from dreader.gesture import TouchEvent, GestureType, ActionType # Handle touch input event = TouchEvent(GestureType.TAP, x=400, y=300) @@ -218,6 +229,17 @@ if response.action == ActionType.PAGE_TURN: print(f"Page turned: {response.data['direction']}") elif response.action == ActionType.WORD_SELECTED: print(f"Word selected: {response.data['word']}") +elif response.action == ActionType.CHAPTER_SELECTED: + print(f"Chapter selected: {response.data['chapter_title']}") + +# Supported gestures: +# - TAP: Select words, activate links, navigate TOC +# - LONG_PRESS: Show definitions or context menu +# - SWIPE_LEFT/RIGHT: Page navigation +# - SWIPE_UP: Open TOC overlay (from bottom 20% of screen) +# - SWIPE_DOWN: Close overlay +# - PINCH_IN/OUT: Font size adjustment +# - DRAG: Text selection ``` ### File Operations diff --git a/docs/images/ereader_library.png b/docs/images/ereader_library.png new file mode 100644 index 0000000..c011d6e Binary files /dev/null and b/docs/images/ereader_library.png differ diff --git a/docs/images/toc_overlay_demo.gif b/docs/images/toc_overlay_demo.gif new file mode 100644 index 0000000..dbc5f9e Binary files /dev/null and b/docs/images/toc_overlay_demo.gif differ diff --git a/dreader/__init__.py b/dreader/__init__.py index 6bc6c01..3dc5a35 100644 --- a/dreader/__init__.py +++ b/dreader/__init__.py @@ -8,6 +8,52 @@ with all essential features for building ereader applications. from dreader.application import EbookReader, create_ebook_reader from dreader import html_generator from dreader import book_utils +from dreader.gesture import ( + TouchEvent, + GestureType, + GestureResponse, + ActionType +) +from dreader.state import ( + StateManager, + AppState, + BookState, + LibraryState, + Settings, + EreaderMode, + OverlayState +) +from dreader.library import LibraryManager +from dreader.overlay import OverlayManager __version__ = "0.1.0" -__all__ = ["EbookReader", "create_ebook_reader", "html_generator", "book_utils"] +__all__ = [ + # Core reader + "EbookReader", + "create_ebook_reader", + + # Utilities + "html_generator", + "book_utils", + + # Gesture system + "TouchEvent", + "GestureType", + "GestureResponse", + "ActionType", + + # State management + "StateManager", + "AppState", + "BookState", + "LibraryState", + "Settings", + "EreaderMode", + "OverlayState", + + # Library + "LibraryManager", + + # Overlay + "OverlayManager", +] diff --git a/dreader/application.py b/dreader/application.py index f828129..3c04464 100644 --- a/dreader/application.py +++ b/dreader/application.py @@ -42,7 +42,7 @@ import os from PIL import Image from pyWebLayout.io.readers.epub_reader import read_epub -from pyWebLayout.io.gesture import TouchEvent, GestureType, GestureResponse, ActionType +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 @@ -51,6 +51,10 @@ 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: """ @@ -121,6 +125,10 @@ class EbookReader: 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: """ @@ -178,10 +186,61 @@ class EbookReader: 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. @@ -646,7 +705,15 @@ class EbookReader: if not self.is_loaded(): return GestureResponse(ActionType.ERROR, {"message": "No book loaded"}) - # Dispatch based on gesture type + # 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: @@ -655,6 +722,9 @@ class EbookReader: 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.PINCH_IN: return self._handle_zoom_out() elif event.gesture == GestureType.PINCH_OUT: @@ -829,6 +899,77 @@ class EbookReader: "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_overlay_tap(self, x: int, y: int) -> GestureResponse: + """Handle tap when overlay is open - select chapter 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 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 # =================================================================== @@ -1037,6 +1178,107 @@ class EbookReader: 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. + + 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 + + # Open overlay and get composited image + result = self.overlay_manager.open_settings_overlay(base_page) + 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. diff --git a/dreader/book_utils.py b/dreader/book_utils.py index 5de0e08..c32bf11 100644 --- a/dreader/book_utils.py +++ b/dreader/book_utils.py @@ -7,6 +7,9 @@ from typing import List, Dict, Optional from dreader import create_ebook_reader import base64 from io import BytesIO +from PIL import Image +import ebooklib +from ebooklib import epub def scan_book_directory(directory: Path) -> List[Dict[str, str]]: @@ -53,9 +56,9 @@ def extract_book_metadata(epub_path: Path, include_cover: bool = True) -> Option 'author': reader.book_author or 'Unknown Author', } - # Extract cover image if requested + # Extract cover image if requested - use direct EPUB extraction if include_cover: - cover_data = extract_cover_as_base64(reader) + cover_data = extract_cover_from_epub(epub_path) metadata['cover_data'] = cover_data return metadata @@ -75,6 +78,9 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450) """ Extract cover image from reader and return as base64 encoded string. + This function is kept for backward compatibility but now uses extract_cover_from_epub + internally if the reader has an epub_path attribute. + Args: reader: EbookReader instance with loaded book max_width: Maximum width for cover image @@ -84,7 +90,11 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450) Base64 encoded PNG image string or None """ try: - # Get first page as cover + # If the reader has an epub path, try to extract actual cover + if hasattr(reader, '_epub_path') and reader._epub_path: + return extract_cover_from_epub(reader._epub_path, max_width, max_height) + + # Fallback to first page as cover cover_image = reader.get_current_page() # Resize if needed @@ -104,6 +114,70 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450) return None +def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: int = 450) -> Optional[str]: + """ + Extract the actual cover image from an EPUB file. + + Args: + epub_path: Path to EPUB file + max_width: Maximum width for cover image + max_height: Maximum height for cover image + + Returns: + Base64 encoded PNG image string or None + """ + try: + # Read the EPUB + book = epub.read_epub(str(epub_path)) + + # Look for cover image + cover_image = None + + # First, try to find item marked as cover + for item in book.get_items(): + if item.get_type() == ebooklib.ITEM_COVER: + cover_image = Image.open(BytesIO(item.get_content())) + break + + # If not found, look for files with 'cover' in the name + if not cover_image: + for item in book.get_items(): + if item.get_type() == ebooklib.ITEM_IMAGE: + name = item.get_name().lower() + if 'cover' in name: + cover_image = Image.open(BytesIO(item.get_content())) + break + + # If still not found, get the first image + if not cover_image: + for item in book.get_items(): + if item.get_type() == ebooklib.ITEM_IMAGE: + try: + cover_image = Image.open(BytesIO(item.get_content())) + break + except: + continue + + if not cover_image: + return None + + # Resize if needed (maintain aspect ratio) + if cover_image.width > max_width or cover_image.height > max_height: + cover_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + + # Convert to base64 + buffer = BytesIO() + cover_image.save(buffer, format='PNG') + img_bytes = buffer.getvalue() + img_base64 = base64.b64encode(img_bytes).decode('utf-8') + + return img_base64 + + except Exception as e: + print(f"Error extracting cover from EPUB {epub_path}: {e}") + return None + + def get_chapter_list(reader) -> List[Dict]: """ Get formatted chapter list from reader. diff --git a/dreader/html_generator.py b/dreader/html_generator.py index 8652f8d..6245b7d 100644 --- a/dreader/html_generator.py +++ b/dreader/html_generator.py @@ -8,14 +8,13 @@ for rendering. from pathlib import Path from typing import List, Dict, Optional -from dreader import create_ebook_reader import base64 from io import BytesIO -def generate_library_html(books: List[Dict[str, str]]) -> str: +def generate_library_html(books: List[Dict[str, str]], save_covers_to_disk: bool = False) -> str: """ - Generate HTML for the library view showing all books in a grid. + Generate HTML for the library view showing all books in a simple table. Args: books: List of book dictionaries with keys: @@ -23,140 +22,46 @@ def generate_library_html(books: List[Dict[str, str]]) -> str: - author: Book author - filename: EPUB filename - cover_data: Optional base64 encoded cover image + - cover_path: Optional path to saved cover image (if save_covers_to_disk=True) + save_covers_to_disk: If True, expect cover_path instead of cover_data Returns: Complete HTML string for library view """ - books_html = [] - for book in books: - cover_img = '' - if book.get('cover_data'): - cover_img = f'Cover' - else: - # Placeholder if no cover - cover_img = f'
{book["title"][:1]}
' - - books_html.append(f''' - - - - - - - - - - - -
- {cover_img} -
{book['title']}
{book['author']}
- - ''') - - # Arrange books in rows of 3 + # Build table rows rows = [] - for i in range(0, len(books_html), 3): - row_books = books_html[i:i+3] - # Pad with empty cells if needed - while len(row_books) < 3: - row_books.append('') - rows.append(f'{"".join(row_books)}') - html = f''' + for book in books: + # Add cover image cell if available + if save_covers_to_disk and book.get('cover_path'): + cover_cell = f'' + elif book.get('cover_data'): + cover_cell = f'' + else: + cover_cell = '[No cover]' + + # Add book info cell + info_cell = f'{book["title"]}
{book["author"]}' + + rows.append(f'{cover_cell}{info_cell}') + + table_html = '\n'.join(rows) + + return f''' - Library - -
-

My Library

-

{len(books)} books

-
- - - {"".join(rows)} -
+

My Library

+

{len(books)} books

+ +{table_html} +
- -''' - return html +''' def generate_reader_html(book_title: str, book_author: str, page_image_data: str) -> str: @@ -418,7 +323,7 @@ def generate_settings_overlay() -> str: return html -def generate_toc_overlay(chapters: List[Dict]) -> str: +def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -> str: """ Generate HTML for the table of contents overlay. @@ -426,105 +331,57 @@ def generate_toc_overlay(chapters: List[Dict]) -> str: chapters: List of chapter dictionaries with keys: - index: Chapter index - title: Chapter title + page_size: Page dimensions (width, height) for sizing the overlay Returns: - HTML string for TOC overlay + HTML string for TOC overlay (60% popup with transparent background) """ - chapter_rows = [] - for chapter in chapters: - chapter_rows.append(f''' - - {chapter['title']} - - ''') + # Build chapter list items with clickable links for pyWebLayout query + chapter_items = [] + for i, chapter in enumerate(chapters): + title = chapter["title"] + # Wrap each row in a paragraph with an inline link + # For very short titles (I, II), pad the link text to ensure it's clickable + link_text = f'{i+1}. {title}' + if len(title) <= 2: + # Add extra padding spaces inside the link to make it easier to click + link_text = f'{i+1}. {title} ' # Extra spaces for padding + + chapter_items.append( + f'

' + f'' + f'{link_text}

' + ) + + # Render simple white panel - compositing will be done by OverlayManager html = f''' - Table of Contents - - -
-
- Table of Contents - -
+ -
- - {"".join(chapter_rows)} -
-
+

+ Table of Contents +

+ +

+ {len(chapters)} chapters +

+ +
+ {"".join(chapter_items)}
+ +

+ Tap a chapter to navigate • Tap outside to close +

''' diff --git a/dreader/library.py b/dreader/library.py new file mode 100644 index 0000000..7e82990 --- /dev/null +++ b/dreader/library.py @@ -0,0 +1,410 @@ +""" +Library manager for browsing and selecting books. + +Handles: +- Scanning directories for EPUB files +- Extracting and caching book metadata and covers +- Rendering interactive library view using pyWebLayout +- Processing tap/click events to select books +""" + +from __future__ import annotations +import os +from pathlib import Path +from typing import List, Dict, Optional, Tuple +from PIL import Image, ImageDraw +import tempfile +import base64 +from io import BytesIO + +from pyWebLayout.concrete.page import Page +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.concrete.table import TableRenderer, TableStyle +from pyWebLayout.abstract.block import Table +from pyWebLayout.abstract.interactive_image import InteractiveImage +from pyWebLayout.abstract.inline import Word +from pyWebLayout.style.fonts import Font +from pyWebLayout.core.query import QueryResult + +from .book_utils import scan_book_directory, extract_book_metadata +from .state import LibraryState + + +class LibraryManager: + """ + Manages the book library view and interactions. + + Features: + - Scan EPUB directories + - Cache book metadata and covers + - Render interactive library table + - Handle tap events to select books + """ + + def __init__( + self, + library_path: str, + cache_dir: Optional[str] = None, + page_size: Tuple[int, int] = (800, 1200) + ): + """ + Initialize library manager. + + Args: + library_path: Path to directory containing EPUB files + cache_dir: Optional cache directory for covers. If None, uses default. + page_size: Page size for library view rendering + """ + self.library_path = Path(library_path) + self.page_size = page_size + + # Set cache directory + if cache_dir: + self.cache_dir = Path(cache_dir) + else: + self.cache_dir = self._get_default_cache_dir() + + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.covers_dir = self.cache_dir / 'covers' + self.covers_dir.mkdir(exist_ok=True) + + # Current library state + self.books: List[Dict] = [] + self.library_table: Optional[Table] = None + self.rendered_page: Optional[Page] = None + self.temp_cover_files: List[str] = [] # Track temp files for cleanup + self.row_bounds: List[Tuple[int, int, int, int]] = [] # Bounding boxes for rows (x, y, w, h) + self.table_renderer: Optional[TableRenderer] = None # Store renderer for bounds info + + @staticmethod + def _get_default_cache_dir() -> Path: + """Get default cache directory based on platform""" + if os.name == 'nt': # Windows + config_dir = Path(os.environ.get('APPDATA', '~/.config')) + else: # Linux/Mac + config_dir = Path.home() / '.config' + + return config_dir / 'dreader' + + def scan_library(self, force_refresh: bool = False) -> List[Dict]: + """ + Scan library directory for EPUB files and extract metadata. + + Args: + force_refresh: If True, re-scan even if cache exists + + Returns: + List of book dictionaries with metadata + """ + print(f"Scanning library: {self.library_path}") + + if not self.library_path.exists(): + print(f"Library path does not exist: {self.library_path}") + return [] + + # Scan directory + self.books = scan_book_directory(self.library_path) + + # Cache covers to disk if not already cached + for book in self.books: + self._cache_book_cover(book) + + print(f"Found {len(self.books)} books in library") + return self.books + + def _cache_book_cover(self, book: Dict) -> Optional[str]: + """ + Cache book cover image to disk. + + Args: + book: Book dictionary with cover_data (base64) or path + + Returns: + Path to cached cover file, or None if no cover + """ + if not book.get('cover_data'): + return None + + # Generate cache filename from book path + book_path = Path(book['path']) + cover_filename = f"{book_path.stem}_cover.png" + cover_path = self.covers_dir / cover_filename + + # Skip if already cached + if cover_path.exists(): + book['cover_path'] = str(cover_path) + return str(cover_path) + + try: + # Decode base64 and save to cache + img_data = base64.b64decode(book['cover_data']) + img = Image.open(BytesIO(img_data)) + img.save(cover_path, 'PNG') + + book['cover_path'] = str(cover_path) + print(f"Cached cover: {cover_filename}") + return str(cover_path) + + except Exception as e: + print(f"Error caching cover for {book['title']}: {e}") + return None + + def create_library_table(self, books: Optional[List[Dict]] = None) -> Table: + """ + Create interactive library table with book covers and info. + + Args: + books: List of books to display. If None, uses self.books + + Returns: + Table object ready for rendering + """ + if books is None: + books = self.books + + if not books: + print("No books to display in library") + books = [] + + print(f"Creating library table with {len(books)} books...") + + # Create table + table = Table(caption="My Library", style=Font(font_size=18, weight="bold")) + + # Add books as rows + for i, book in enumerate(books): + row = table.create_row("body") + + # Cover cell with interactive image + cover_cell = row.create_cell() + cover_path = book.get('cover_path') + book_path = book['path'] + + # Create callback that returns book path + callback = lambda point, path=book_path: path + + if cover_path and Path(cover_path).exists(): + # Use cached cover with callback + img = InteractiveImage.create_and_add_to( + cover_cell, + source=cover_path, + alt_text=book['title'], + callback=callback + ) + elif book.get('cover_data'): + # Decode base64 and save to temp file for InteractiveImage + try: + img_data = base64.b64decode(book['cover_data']) + img = Image.open(BytesIO(img_data)) + + # Save to temp file + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + img.save(tmp.name, 'PNG') + temp_path = tmp.name + self.temp_cover_files.append(temp_path) + + img = InteractiveImage.create_and_add_to( + cover_cell, + source=temp_path, + alt_text=book['title'], + callback=callback + ) + except Exception as e: + print(f"Error creating cover image for {book['title']}: {e}") + self._add_no_cover_text(cover_cell) + else: + # No cover available + self._add_no_cover_text(cover_cell) + + # Book info cell + info_cell = row.create_cell() + + # Title paragraph + title_para = info_cell.create_paragraph() + for word in book['title'].split(): + title_para.add_word(Word(word, Font(font_size=14, weight="bold"))) + + # Author paragraph + author_para = info_cell.create_paragraph() + for word in book.get('author', 'Unknown').split(): + author_para.add_word(Word(word, Font(font_size=12))) + + # Filename paragraph (small, gray) + filename_para = info_cell.create_paragraph() + filename_para.add_word(Word( + Path(book['path']).name, + Font(font_size=10, colour=(150, 150, 150)) + )) + + self.library_table = table + return table + + def _add_no_cover_text(self, cell): + """Add placeholder text when no cover is available""" + para = cell.create_paragraph() + para.add_word(Word("[No", Font(font_size=10, colour=(128, 128, 128)))) + para.add_word(Word("cover]", Font(font_size=10, colour=(128, 128, 128)))) + + def render_library(self, table: Optional[Table] = None) -> Image.Image: + """ + Render the library table to an image. + + Args: + table: Table to render. If None, uses self.library_table + + Returns: + PIL Image of the rendered library + """ + if table is None: + if self.library_table is None: + print("No table to render, creating one first...") + self.create_library_table() + table = self.library_table + + print("Rendering library table...") + + # Create page + page_style = PageStyle( + border_width=0, + padding=(30, 30, 30, 30), + background_color=(255, 255, 255) + ) + + page = Page(size=self.page_size, style=page_style) + canvas = page.render() + draw = ImageDraw.Draw(canvas) + + # Table style + table_style = TableStyle( + border_width=1, + border_color=(200, 200, 200), + cell_padding=(10, 15, 10, 15), + header_bg_color=(240, 240, 240), + cell_bg_color=(255, 255, 255), + alternate_row_color=(250, 250, 250) + ) + + # Position table + table_origin = (page_style.padding[3], page_style.padding[0]) + table_width = page.size[0] - page_style.padding[1] - page_style.padding[3] + + # Render table with canvas support for images + self.table_renderer = TableRenderer( + table, + table_origin, + table_width, + draw, + table_style, + canvas # Pass canvas to enable image rendering + ) + self.table_renderer.render() + + # Store rendered page for query support + self.rendered_page = page + + return canvas + + def handle_library_tap(self, x: int, y: int) -> Optional[str]: + """ + Handle tap event on library view. + + Checks if the tap is within any row's bounds and returns the corresponding + book path. This makes the entire row interactive, not just the cover image. + + Args: + x: X coordinate of tap + y: Y coordinate of tap + + Returns: + Path to selected book, or None if no book tapped + """ + if not self.library_table or not self.table_renderer: + print("No library table available") + return None + + try: + # Build a mapping of row sections in order + all_rows = list(self.library_table.all_rows()) + + # Find which row was tapped by checking row renderers + for row_idx, row_renderer in enumerate(self.table_renderer._row_renderers): + # Get the row renderer's bounds + row_x, row_y = row_renderer._origin + row_w, row_h = row_renderer._size + + # Check if tap is within this row's bounds + if (row_x <= x <= row_x + row_w and + row_y <= y <= row_y + row_h): + + # Get the section and row for this renderer index + if row_idx < len(all_rows): + section, row = all_rows[row_idx] + + # Only handle body rows + if section == "body": + # Find which body row this is (0-indexed) + body_row_index = sum(1 for s, _ in all_rows[:row_idx] if s == "body") + + # Return the corresponding book + if body_row_index < len(self.books): + book_path = self.books[body_row_index]['path'] + print(f"Book selected (row {body_row_index}): {book_path}") + return book_path + + print(f"No book tapped at ({x}, {y})") + return None + + except Exception as e: + print(f"Error handling library tap: {e}") + import traceback + traceback.print_exc() + return None + + def get_book_at_index(self, index: int) -> Optional[Dict]: + """ + Get book by index in library. + + Args: + index: Book index + + Returns: + Book dictionary or None + """ + if 0 <= index < len(self.books): + return self.books[index] + return None + + def get_library_state(self) -> LibraryState: + """ + Get current library state for persistence. + + Returns: + LibraryState object + """ + return LibraryState( + books_path=str(self.library_path), + last_selected_index=0, # TODO: Track last selection + scan_cache=[ + { + 'path': book['path'], + 'title': book['title'], + 'author': book.get('author', 'Unknown'), + 'cover_cached': bool(book.get('cover_path')) + } + for book in self.books + ] + ) + + def cleanup(self): + """Clean up temporary files""" + for temp_file in self.temp_cover_files: + try: + if os.path.exists(temp_file): + os.unlink(temp_file) + except Exception as e: + print(f"Error cleaning up temp file {temp_file}: {e}") + self.temp_cover_files.clear() + + def __del__(self): + """Destructor to ensure cleanup""" + self.cleanup() diff --git a/dreader/overlay.py b/dreader/overlay.py new file mode 100644 index 0000000..fe7b4c8 --- /dev/null +++ b/dreader/overlay.py @@ -0,0 +1,332 @@ +""" +Overlay management for dreader application. + +Handles rendering and compositing of overlay screens (TOC, Settings, Bookmarks) +on top of the base reading page. +""" + +from __future__ import annotations +from typing import Optional, List, Dict, Any, Tuple +from pathlib import Path +from PIL import Image + +from .state import OverlayState +from .html_generator import ( + generate_toc_overlay, + generate_settings_overlay, + generate_bookmarks_overlay +) + + +class OverlayManager: + """ + Manages overlay rendering and interaction. + + Handles: + - Generating overlay HTML + - Rendering HTML to images using pyWebLayout + - Compositing overlays on top of base pages + - Tracking current overlay state + """ + + def __init__(self, page_size: Tuple[int, int] = (800, 1200)): + """ + Initialize overlay manager. + + Args: + page_size: Size of the page/overlay (width, height) + """ + self.page_size = page_size + self.current_overlay = OverlayState.NONE + self._cached_base_page: Optional[Image.Image] = None + self._cached_overlay_image: Optional[Image.Image] = None + self._overlay_reader = None # Will be EbookReader instance for rendering overlays + self._overlay_panel_offset: Tuple[int, int] = (0, 0) # Panel position on screen + + def render_html_to_image(self, html: str, size: Optional[Tuple[int, int]] = None) -> Image.Image: + """ + Render HTML content to a PIL Image using pyWebLayout. + + This creates a temporary EbookReader instance to render the HTML, + then extracts the rendered page as an image. + + Args: + html: HTML string to render + size: Optional (width, height) for rendering size. Defaults to self.page_size + + Returns: + PIL Image of the rendered HTML + """ + # Import here to avoid circular dependency + from .application import EbookReader + + render_size = size if size else self.page_size + + # Create a temporary reader for rendering this HTML + temp_reader = EbookReader( + page_size=render_size, + margin=15, + background_color=(255, 255, 255) + ) + + # Load the HTML content + success = temp_reader.load_html( + html_string=html, + title="Overlay", + author="", + document_id="temp_overlay" + ) + + if not success: + raise ValueError("Failed to load HTML for overlay rendering") + + # Get the rendered page + image = temp_reader.get_current_page() + + # Clean up + temp_reader.close() + + return image + + def composite_overlay(self, base_image: Image.Image, overlay_panel: Image.Image) -> Image.Image: + """ + Composite overlay panel on top of base image with darkened background. + + Creates a popup effect by: + 1. Darkening the base image (multiply by 0.5) + 2. Placing the overlay panel (60% size) centered on top + + Args: + base_image: Base page image (reading page) + overlay_panel: Rendered overlay panel (TOC, settings, etc.) + + Returns: + Composited PIL Image with popup overlay effect + """ + from PIL import ImageDraw, ImageEnhance + import numpy as np + + # Convert base image to RGB + result = base_image.convert('RGB').copy() + + # Lighten the background slightly (70% brightness for e-ink visibility) + enhancer = ImageEnhance.Brightness(result) + result = enhancer.enhance(0.7) + + # Convert overlay panel to RGB + if overlay_panel.mode != 'RGB': + overlay_panel = overlay_panel.convert('RGB') + + # Calculate centered position for the panel + panel_x = int((self.page_size[0] - overlay_panel.width) / 2) + panel_y = int((self.page_size[1] - overlay_panel.height) / 2) + + # Add a thick black border around the panel for e-ink clarity + draw = ImageDraw.Draw(result) + border_width = 3 + draw.rectangle( + [panel_x - border_width, panel_y - border_width, + panel_x + overlay_panel.width + border_width, panel_y + overlay_panel.height + border_width], + outline=(0, 0, 0), + width=border_width + ) + + # Paste the panel onto the dimmed background + result.paste(overlay_panel, (panel_x, panel_y)) + + return result + + def open_toc_overlay(self, chapters: List[Tuple[str, int]], base_page: Image.Image) -> Image.Image: + """ + Open the table of contents overlay. + + Args: + chapters: List of (chapter_title, chapter_index) tuples + base_page: Current reading page to show underneath + + Returns: + Composited image with TOC overlay on top + """ + # Import here to avoid circular dependency + from .application import EbookReader + + # Calculate panel size (60% of screen) + panel_width = int(self.page_size[0] * 0.6) + panel_height = int(self.page_size[1] * 0.7) + + # Convert chapters to format expected by HTML generator + chapter_data = [ + {"index": idx, "title": title} + for title, idx in chapters + ] + + # Generate TOC HTML with clickable links + html = generate_toc_overlay(chapter_data, page_size=(panel_width, panel_height)) + + # Create reader for overlay and keep it alive for querying + if self._overlay_reader: + self._overlay_reader.close() + + self._overlay_reader = EbookReader( + page_size=(panel_width, panel_height), + margin=15, + background_color=(255, 255, 255) + ) + + # Load the HTML content + success = self._overlay_reader.load_html( + html_string=html, + title="Table of Contents", + author="", + document_id="toc_overlay" + ) + + if not success: + raise ValueError("Failed to load TOC overlay HTML") + + # Get the rendered page + overlay_panel = self._overlay_reader.get_current_page() + + # Calculate and store panel position for coordinate translation + panel_x = int((self.page_size[0] - panel_width) / 2) + panel_y = int((self.page_size[1] - panel_height) / 2) + self._overlay_panel_offset = (panel_x, panel_y) + + # Cache for later use + self._cached_base_page = base_page.copy() + self._cached_overlay_image = overlay_panel + self.current_overlay = OverlayState.TOC + + # Composite and return + return self.composite_overlay(base_page, overlay_panel) + + def open_settings_overlay(self, base_page: Image.Image) -> Image.Image: + """ + Open the settings overlay. + + Args: + base_page: Current reading page to show underneath + + Returns: + Composited image with settings overlay on top + """ + # Generate settings HTML + html = generate_settings_overlay() + + # Render HTML to image + overlay_image = self.render_html_to_image(html) + + # Cache for later use + self._cached_base_page = base_page.copy() + self._cached_overlay_image = overlay_image + self.current_overlay = OverlayState.SETTINGS + + # Composite and return + return self.composite_overlay(base_page, overlay_image) + + def open_bookmarks_overlay(self, bookmarks: List[Dict[str, Any]], base_page: Image.Image) -> Image.Image: + """ + Open the bookmarks overlay. + + Args: + bookmarks: List of bookmark dictionaries with 'name' and 'position' keys + base_page: Current reading page to show underneath + + Returns: + Composited image with bookmarks overlay on top + """ + # Generate bookmarks HTML + html = generate_bookmarks_overlay(bookmarks) + + # Render HTML to image + overlay_image = self.render_html_to_image(html) + + # Cache for later use + self._cached_base_page = base_page.copy() + self._cached_overlay_image = overlay_image + self.current_overlay = OverlayState.BOOKMARKS + + # Composite and return + return self.composite_overlay(base_page, overlay_image) + + def close_overlay(self) -> Optional[Image.Image]: + """ + Close the current overlay and return to base page. + + Returns: + Base page image (without overlay), or None if no overlay was open + """ + if self.current_overlay == OverlayState.NONE: + return None + + self.current_overlay = OverlayState.NONE + base_page = self._cached_base_page + + # Clear caches + self._cached_base_page = None + self._cached_overlay_image = None + self._overlay_panel_offset = (0, 0) + + # Close overlay reader + if self._overlay_reader: + self._overlay_reader.close() + self._overlay_reader = None + + return base_page + + def is_overlay_open(self) -> bool: + """Check if an overlay is currently open.""" + return self.current_overlay != OverlayState.NONE + + def get_current_overlay_type(self) -> OverlayState: + """Get the type of currently open overlay.""" + return self.current_overlay + + def query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]: + """ + Query a pixel in the current overlay to detect interactions. + + Uses pyWebLayout's query_point() to detect which element was tapped, + including link targets and data attributes. + + Args: + x, y: Pixel coordinates to query (in screen space) + + Returns: + Dictionary with query result data (text, link_target, is_interactive), + or None if no overlay open or query failed + """ + if not self.is_overlay_open() or not self._overlay_reader: + return None + + # Translate screen coordinates to overlay panel coordinates + panel_x, panel_y = self._overlay_panel_offset + overlay_x = x - panel_x + overlay_y = y - panel_y + + # Check if coordinates are within the overlay panel + if overlay_x < 0 or overlay_y < 0: + return None + + # Get the current page from the overlay reader + if not self._overlay_reader.manager: + return None + + current_page = self._overlay_reader.manager.get_current_page() + if not current_page: + return None + + # Query the point + result = current_page.query_point((overlay_x, overlay_y)) + + if not result: + return None + + # Extract relevant data from QueryResult + return { + "text": result.text, + "link_target": result.link_target, + "is_interactive": result.is_interactive, + "bounds": result.bounds, + "object_type": result.object_type + } diff --git a/dreader/state.py b/dreader/state.py new file mode 100644 index 0000000..777a20d --- /dev/null +++ b/dreader/state.py @@ -0,0 +1,392 @@ +""" +State management for dreader application. + +Handles application state persistence with asyncio-based auto-save functionality. +State is saved to a JSON file and includes current mode, book position, settings, etc. +""" + +from __future__ import annotations +import asyncio +import json +import os +from dataclasses import dataclass, asdict, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional, Dict, Any, List +import tempfile +import shutil + + +class EreaderMode(Enum): + """Application mode states""" + LIBRARY = "library" + READING = "reading" + + +class OverlayState(Enum): + """Overlay states within READING mode""" + NONE = "none" + TOC = "toc" + SETTINGS = "settings" + BOOKMARKS = "bookmarks" + + +@dataclass +class BookState: + """State for currently open book - just the path and metadata""" + path: str + title: str = "" + author: str = "" + last_read_timestamp: str = "" + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'BookState': + """Create from dictionary""" + return cls( + path=data['path'], + title=data.get('title', ''), + author=data.get('author', ''), + last_read_timestamp=data.get('last_read_timestamp', '') + ) + + +@dataclass +class LibraryState: + """State for library view""" + books_path: str = "" + last_selected_index: int = 0 + scan_cache: List[Dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'LibraryState': + """Create from dictionary""" + return cls( + books_path=data.get('books_path', ''), + last_selected_index=data.get('last_selected_index', 0), + scan_cache=data.get('scan_cache', []) + ) + + +@dataclass +class Settings: + """User settings""" + font_scale: float = 1.0 + line_spacing: int = 5 + inter_block_spacing: int = 15 + brightness: int = 8 + theme: str = "day" + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Settings': + """Create from dictionary""" + return cls( + font_scale=data.get('font_scale', 1.0), + line_spacing=data.get('line_spacing', 5), + inter_block_spacing=data.get('inter_block_spacing', 15), + brightness=data.get('brightness', 8), + theme=data.get('theme', 'day') + ) + + +@dataclass +class AppState: + """Complete application state""" + version: str = "1.0" + mode: EreaderMode = EreaderMode.LIBRARY + overlay: OverlayState = OverlayState.NONE + current_book: Optional[BookState] = None + library: LibraryState = field(default_factory=LibraryState) + settings: Settings = field(default_factory=Settings) + bookmarks: Dict[str, List[str]] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization""" + return { + 'version': self.version, + 'mode': self.mode.value, + 'overlay': self.overlay.value, + 'current_book': self.current_book.to_dict() if self.current_book else None, + 'library': self.library.to_dict(), + 'settings': self.settings.to_dict(), + 'bookmarks': self.bookmarks + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'AppState': + """Create from dictionary""" + current_book = None + if data.get('current_book'): + current_book = BookState.from_dict(data['current_book']) + + return cls( + version=data.get('version', '1.0'), + mode=EreaderMode(data.get('mode', 'library')), + overlay=OverlayState(data.get('overlay', 'none')), + current_book=current_book, + library=LibraryState.from_dict(data.get('library', {})), + settings=Settings.from_dict(data.get('settings', {})), + bookmarks=data.get('bookmarks', {}) + ) + + +class StateManager: + """ + Manages application state with persistence and auto-save. + + Features: + - Load/save state to JSON file + - Asyncio-based auto-save timer (every 60 seconds) + - Atomic writes (write to temp file, then rename) + - Backup of previous state on corruption + - Thread-safe state updates + """ + + def __init__(self, state_file: Optional[str] = None, auto_save_interval: int = 60): + """ + Initialize state manager. + + Args: + state_file: Path to state file. If None, uses default location. + auto_save_interval: Auto-save interval in seconds (default: 60) + """ + if state_file: + self.state_file = Path(state_file) + else: + self.state_file = self._get_default_state_file() + + self.auto_save_interval = auto_save_interval + self.state = AppState() + self._dirty = False + self._save_task: Optional[asyncio.Task] = None + self._lock = asyncio.Lock() + + # Ensure state directory exists + self.state_file.parent.mkdir(parents=True, exist_ok=True) + + @staticmethod + def _get_default_state_file() -> Path: + """Get default state file location based on platform""" + if os.name == 'nt': # Windows + config_dir = Path(os.environ.get('APPDATA', '~/.config')) + else: # Linux/Mac + config_dir = Path.home() / '.config' + + return config_dir / 'dreader' / 'state.json' + + def load_state(self) -> AppState: + """ + Load state from file. + + Returns: + Loaded AppState, or default AppState if file doesn't exist or is corrupt + """ + if not self.state_file.exists(): + print(f"No state file found at {self.state_file}, using defaults") + return AppState() + + try: + with open(self.state_file, 'r') as f: + data = json.load(f) + + self.state = AppState.from_dict(data) + self._dirty = False + print(f"State loaded from {self.state_file}") + + # Clear overlay state on boot (always start without overlays) + if self.state.overlay != OverlayState.NONE: + print("Clearing overlay state on boot") + self.state.overlay = OverlayState.NONE + self._dirty = True + + return self.state + + except Exception as e: + print(f"Error loading state from {self.state_file}: {e}") + + # Backup corrupt file + backup_path = self.state_file.with_suffix('.json.backup') + try: + shutil.copy2(self.state_file, backup_path) + print(f"Backed up corrupt state to {backup_path}") + except Exception as backup_error: + print(f"Failed to backup corrupt state: {backup_error}") + + # Return default state + self.state = AppState() + self._dirty = True + return self.state + + def save_state(self, force: bool = False) -> bool: + """ + Save state to file (synchronous). + + Args: + force: Save even if state is not dirty + + Returns: + True if saved successfully, False otherwise + """ + if not force and not self._dirty: + return True + + try: + # Atomic write: write to temp file, then rename + temp_fd, temp_path = tempfile.mkstemp( + dir=self.state_file.parent, + prefix='.state_', + suffix='.json.tmp' + ) + + try: + with os.fdopen(temp_fd, 'w') as f: + json.dump(self.state.to_dict(), f, indent=2) + + # Atomic rename + os.replace(temp_path, self.state_file) + self._dirty = False + print(f"State saved to {self.state_file}") + return True + + except Exception as e: + # Clean up temp file on error + try: + os.unlink(temp_path) + except: + pass + raise e + + except Exception as e: + print(f"Error saving state: {e}") + return False + + async def save_state_async(self, force: bool = False) -> bool: + """ + Save state to file (async version). + + Args: + force: Save even if state is not dirty + + Returns: + True if saved successfully, False otherwise + """ + async with self._lock: + # Run sync save in executor to avoid blocking + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self.save_state, force) + + async def _auto_save_loop(self): + """Background task for automatic state saving""" + while True: + try: + await asyncio.sleep(self.auto_save_interval) + if self._dirty: + print(f"Auto-saving state (interval: {self.auto_save_interval}s)") + await self.save_state_async() + except asyncio.CancelledError: + print("Auto-save loop cancelled") + break + except Exception as e: + print(f"Error in auto-save loop: {e}") + + def start_auto_save(self): + """Start the auto-save background task""" + if self._save_task is None or self._save_task.done(): + self._save_task = asyncio.create_task(self._auto_save_loop()) + print(f"Auto-save started (interval: {self.auto_save_interval}s)") + + async def stop_auto_save(self, save_final: bool = True): + """ + Stop the auto-save background task. + + Args: + save_final: Whether to perform a final save before stopping + """ + if self._save_task and not self._save_task.done(): + self._save_task.cancel() + try: + await self._save_task + except asyncio.CancelledError: + pass + + if save_final: + await self.save_state_async(force=True) + print("Final state save completed") + + # Convenience methods for state access + + def get_mode(self) -> EreaderMode: + """Get current application mode""" + return self.state.mode + + def set_mode(self, mode: EreaderMode): + """Set application mode""" + if self.state.mode != mode: + self.state.mode = mode + self._dirty = True + + def get_overlay(self) -> OverlayState: + """Get current overlay state""" + return self.state.overlay + + def set_overlay(self, overlay: OverlayState): + """Set overlay state""" + if self.state.overlay != overlay: + self.state.overlay = overlay + self._dirty = True + + def get_current_book(self) -> Optional[BookState]: + """Get current book state""" + return self.state.current_book + + def set_current_book(self, book: Optional[BookState]): + """Set current book state""" + self.state.current_book = book + if book: + book.last_read_timestamp = datetime.now().isoformat() + self._dirty = True + + def update_book_timestamp(self): + """Update current book's last read timestamp""" + if self.state.current_book: + self.state.current_book.last_read_timestamp = datetime.now().isoformat() + self._dirty = True + + def get_settings(self) -> Settings: + """Get user settings""" + return self.state.settings + + def update_setting(self, key: str, value: Any): + """Update a single setting""" + if hasattr(self.state.settings, key): + setattr(self.state.settings, key, value) + self._dirty = True + + def get_library_state(self) -> LibraryState: + """Get library state""" + return self.state.library + + def update_library_cache(self, cache: List[Dict[str, Any]]): + """Update library scan cache""" + self.state.library.scan_cache = cache + self._dirty = True + + def is_dirty(self) -> bool: + """Check if state has unsaved changes""" + return self._dirty + + def mark_dirty(self): + """Mark state as having unsaved changes""" + self._dirty = True diff --git a/examples/demo_toc_overlay.py b/examples/demo_toc_overlay.py new file mode 100755 index 0000000..9b26794 --- /dev/null +++ b/examples/demo_toc_overlay.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +""" +Demo script for TOC overlay feature. + +This script demonstrates the complete TOC overlay workflow: +1. Display reading page +2. Swipe up from bottom to open TOC overlay +3. Display TOC overlay with chapter list +4. Tap on a chapter to navigate +5. Close overlay and show new page + +Generates a GIF showing all these interactions. +""" + +from pathlib import Path +from dreader import EbookReader, TouchEvent, GestureType +from PIL import Image, ImageDraw, ImageFont +import time + + +def add_gesture_annotation(image: Image.Image, text: str, position: str = "top") -> Image.Image: + """ + Add a text annotation to an image showing what gesture is being performed. + + Args: + image: Base image + text: Annotation text + position: "top" or "bottom" + + Returns: + Image with annotation + """ + # Create a copy + annotated = image.copy() + draw = ImageDraw.Draw(annotated) + + # Try to use a nice font, fall back to default + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24) + except: + font = ImageFont.load_default() + + # Calculate text position + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + x = (image.width - text_width) // 2 + if position == "top": + y = 20 + else: + y = image.height - text_height - 20 + + # Draw background rectangle + padding = 10 + draw.rectangle( + [x - padding, y - padding, x + text_width + padding, y + text_height + padding], + fill=(0, 0, 0, 200) + ) + + # Draw text + draw.text((x, y), text, fill=(255, 255, 255), font=font) + + return annotated + + +def add_swipe_arrow(image: Image.Image, start_y: int, end_y: int) -> Image.Image: + """ + Add a visual swipe arrow to show gesture direction. + + Args: + image: Base image + start_y: Starting Y position + end_y: Ending Y position + + Returns: + Image with arrow overlay + """ + annotated = image.copy() + draw = ImageDraw.Draw(annotated) + + # Draw arrow in center of screen + x = image.width // 2 + + # Draw line + draw.line([(x, start_y), (x, end_y)], fill=(255, 100, 100), width=5) + + # Draw arrowhead + arrow_size = 20 + if end_y < start_y: # Upward arrow + draw.polygon([ + (x, end_y), + (x - arrow_size, end_y + arrow_size), + (x + arrow_size, end_y + arrow_size) + ], fill=(255, 100, 100)) + else: # Downward arrow + draw.polygon([ + (x, end_y), + (x - arrow_size, end_y - arrow_size), + (x + arrow_size, end_y - arrow_size) + ], fill=(255, 100, 100)) + + return annotated + + +def add_tap_indicator(image: Image.Image, x: int, y: int, label: str = "") -> Image.Image: + """ + Add a visual tap indicator to show where user tapped. + + Args: + image: Base image + x, y: Tap coordinates + label: Optional label for the tap + + Returns: + Image with tap indicator + """ + annotated = image.copy() + draw = ImageDraw.Draw(annotated) + + # Draw circle at tap location + radius = 30 + draw.ellipse( + [x - radius, y - radius, x + radius, y + radius], + outline=(255, 100, 100), + width=5 + ) + + # Draw crosshair + draw.line([(x - radius - 10, y), (x + radius + 10, y)], fill=(255, 100, 100), width=3) + draw.line([(x, y - radius - 10), (x, y + radius + 10)], fill=(255, 100, 100), width=3) + + # Add label if provided + if label: + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18) + except: + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), label, font=font) + text_width = bbox[2] - bbox[0] + + # Position label above tap point + label_x = x - text_width // 2 + label_y = y - radius - 40 + + draw.text((label_x, label_y), label, fill=(255, 100, 100), font=font) + + return annotated + + +def main(): + """Generate TOC overlay demo GIF""" + print("=== TOC Overlay Demo ===") + print() + + # Find a test EPUB + epub_dir = Path(__file__).parent.parent / 'tests' / 'data' / 'library-epub' + epubs = list(epub_dir.glob('*.epub')) + + if not epubs: + print("Error: No test EPUB files found!") + print(f"Looked in: {epub_dir}") + return + + epub_path = epubs[0] + print(f"Using book: {epub_path.name}") + + # Create reader + reader = EbookReader(page_size=(800, 1200)) + + # Load book + print("Loading book...") + success = reader.load_epub(str(epub_path)) + + if not success: + print("Error: Failed to load EPUB!") + return + + print(f"Loaded: {reader.book_title} by {reader.book_author}") + print(f"Chapters: {len(reader.get_chapters())}") + print() + + # Prepare frames for GIF + frames = [] + frame_duration = [] # Duration in milliseconds for each frame + + # Frame 1: Initial reading page + print("Frame 1: Initial reading page...") + page1 = reader.get_current_page() + annotated1 = add_gesture_annotation(page1, f"Reading: {reader.book_title}", "top") + frames.append(annotated1) + frame_duration.append(2000) # 2 seconds + + # Frame 2: Show swipe up gesture + print("Frame 2: Swipe up gesture...") + swipe_visual = add_swipe_arrow(page1, 1100, 900) + annotated2 = add_gesture_annotation(swipe_visual, "Swipe up from bottom", "bottom") + frames.append(annotated2) + frame_duration.append(1000) # 1 second + + # Frame 3: TOC overlay appears + print("Frame 3: TOC overlay opens...") + event_swipe_up = TouchEvent(gesture=GestureType.SWIPE_UP, x=400, y=1100) + response = reader.handle_touch(event_swipe_up) + print(f" Response: {response.action}") + + # Get the overlay image by calling open_toc_overlay again + # (handle_touch already opened it, but we need the image) + overlay_image = reader.open_toc_overlay() + annotated3 = add_gesture_annotation(overlay_image, "Table of Contents", "top") + frames.append(annotated3) + frame_duration.append(3000) # 3 seconds to read + + # Frame 4: Show tap on chapter III (index 6) + print("Frame 4: Tap on chapter III...") + chapters = reader.get_chapters() + if len(chapters) >= 7: + # Calculate tap position for chapter III (7th in list, index 6) + # Based on actual measurements from pyWebLayout link query: + # Chapter 6 "III" link is clickable at screen position (200, 378) + tap_x = 200 + tap_y = 378 + + tap_visual = add_tap_indicator(overlay_image, tap_x, tap_y, "III") + annotated4 = add_gesture_annotation(tap_visual, "Tap chapter to navigate", "bottom") + frames.append(annotated4) + frame_duration.append(1500) # 1.5 seconds + + # Frame 5: Navigate to chapter III + print(f"Frame 5: Jump to chapter III (tapping at {tap_x}, {tap_y})...") + event_tap = TouchEvent(gesture=GestureType.TAP, x=tap_x, y=tap_y) + response = reader.handle_touch(event_tap) + print(f" Response: {response.action}") + + new_page = reader.get_current_page() + + # Use the chapter title from the response data (more accurate) + if response.action == "chapter_selected" and "chapter_title" in response.data: + chapter_title = response.data['chapter_title'] + else: + chapter_title = "Chapter" + + annotated5 = add_gesture_annotation(new_page, f"Navigated to: {chapter_title}", "top") + frames.append(annotated5) + frame_duration.append(2000) # 2 seconds + else: + print(" Skipping chapter selection (not enough chapters)") + + # Frame 6: Another page for context + print("Frame 6: Next page...") + reader.next_page() + page_final = reader.get_current_page() + annotated6 = add_gesture_annotation(page_final, "Reading continues...", "top") + frames.append(annotated6) + frame_duration.append(2000) # 2 seconds + + # Save as GIF + output_path = Path(__file__).parent.parent / 'docs' / 'images' / 'toc_overlay_demo.gif' + output_path.parent.mkdir(parents=True, exist_ok=True) + + print() + print(f"Saving GIF with {len(frames)} frames...") + frames[0].save( + output_path, + save_all=True, + append_images=frames[1:], + duration=frame_duration, + loop=0, + optimize=False + ) + + print(f"✓ GIF saved to: {output_path}") + print(f" Size: {output_path.stat().st_size / 1024:.1f} KB") + print(f" Frames: {len(frames)}") + print(f" Total duration: {sum(frame_duration) / 1000:.1f}s") + + # Also save individual frames for documentation + frames_dir = output_path.parent / 'toc_overlay_frames' + frames_dir.mkdir(exist_ok=True) + + for i, frame in enumerate(frames): + frame_path = frames_dir / f'frame_{i+1:02d}.png' + frame.save(frame_path) + + print(f"✓ Individual frames saved to: {frames_dir}") + + # Cleanup + reader.close() + + print() + print("=== Demo Complete ===") + + +if __name__ == '__main__': + main() diff --git a/tests/test_library_interaction.py b/tests/test_library_interaction.py new file mode 100644 index 0000000..ca2f6e6 --- /dev/null +++ b/tests/test_library_interaction.py @@ -0,0 +1,167 @@ +""" +Unit tests for library interaction and tap detection. + +These tests demonstrate the issue with interactive images in the library +and verify that tap detection works correctly. +""" + +import unittest +from pathlib import Path +from dreader import LibraryManager + + +class TestLibraryInteraction(unittest.TestCase): + """Test library browsing and tap interaction""" + + def setUp(self): + """Set up test library""" + self.library_path = Path(__file__).parent / 'data' / 'library-epub' + self.library = LibraryManager( + library_path=str(self.library_path), + page_size=(800, 1200) + ) + + def tearDown(self): + """Clean up""" + self.library.cleanup() + + def test_library_scan(self): + """Test that library scanning finds books""" + books = self.library.scan_library() + + # Should find at least one book + self.assertGreater(len(books), 0, "Library should contain at least one book") + + # Each book should have required fields + for book in books: + self.assertIn('path', book) + self.assertIn('title', book) + self.assertIn('filename', book) + + def test_library_table_creation(self): + """Test that library table can be created""" + books = self.library.scan_library() + table = self.library.create_library_table() + + # Table should exist + self.assertIsNotNone(table) + + # Table should have body rows matching book count + body_rows = list(table.body_rows()) + self.assertEqual(len(body_rows), len(books)) + + def test_library_rendering(self): + """Test that library can be rendered to image""" + self.library.scan_library() + self.library.create_library_table() + + # Render library + image = self.library.render_library() + + # Image should be created with correct size + self.assertIsNotNone(image) + self.assertEqual(image.size, self.library.page_size) + + def test_tap_detection_first_book(self): + """Test that tapping on first book row selects it + + The entire row is interactive, so tapping anywhere in the row + (not just on the cover image) will select the book. + """ + books = self.library.scan_library() + self.library.create_library_table() + self.library.render_library() + + # Tap anywhere in the first book's row + # Based on layout: padding 30px, caption ~40px, first row starts at ~70px + selected_path = self.library.handle_library_tap(x=100, y=100) + + # Should select the first book + self.assertIsNotNone(selected_path, "Tap should select a book") + self.assertEqual(selected_path, books[0]['path'], "Should select first book") + + def test_tap_detection_second_book(self): + """Test that tapping on second book selects it""" + books = self.library.scan_library() + + if len(books) < 2: + self.skipTest("Need at least 2 books for this test") + + self.library.create_library_table() + self.library.render_library() + + # Tap in the region of the second book + # Row height is ~180px, so second book is at ~70 + 180 = 250px + selected_path = self.library.handle_library_tap(x=400, y=250) + + # Should select the second book + self.assertIsNotNone(selected_path, "Tap should select a book") + self.assertEqual(selected_path, books[1]['path'], "Should select second book") + + def test_tap_outside_table(self): + """Test that tapping outside table returns None""" + self.library.scan_library() + self.library.create_library_table() + self.library.render_library() + + # Tap outside the table area (far right) + selected_path = self.library.handle_library_tap(x=1000, y=100) + + # Should not select anything + self.assertIsNone(selected_path, "Tap outside table should not select anything") + + def test_tap_above_table(self): + """Test that tapping in caption area returns None""" + self.library.scan_library() + self.library.create_library_table() + self.library.render_library() + + # Tap in caption area (above first row) + selected_path = self.library.handle_library_tap(x=400, y=40) + + # Should not select anything + self.assertIsNone(selected_path, "Tap in caption should not select anything") + + def test_tap_below_last_book(self): + """Test that tapping below all books returns None""" + books = self.library.scan_library() + self.library.create_library_table() + self.library.render_library() + + # Tap way below the last book + # With 5 books and row height 180px: ~70 + (5 * 180) = 970px + selected_path = self.library.handle_library_tap(x=400, y=1100) + + # Should not select anything + self.assertIsNone(selected_path, "Tap below last book should not select anything") + + def test_multiple_taps(self): + """Test that multiple taps work correctly""" + books = self.library.scan_library() + + if len(books) < 3: + self.skipTest("Need at least 3 books for this test") + + self.library.create_library_table() + self.library.render_library() + + # Tap first book (row 0: y=60-180) + path1 = self.library.handle_library_tap(x=100, y=100) + self.assertEqual(path1, books[0]['path']) + + # Tap second book (row 1: y=181-301) + path2 = self.library.handle_library_tap(x=400, y=250) + self.assertEqual(path2, books[1]['path']) + + # Tap third book (row 2: y=302-422) + path3 = self.library.handle_library_tap(x=400, y=360) + self.assertEqual(path3, books[2]['path']) + + # All should be different + self.assertNotEqual(path1, path2) + self.assertNotEqual(path2, path3) + self.assertNotEqual(path1, path3) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_toc_overlay.py b/tests/test_toc_overlay.py new file mode 100644 index 0000000..4fb934b --- /dev/null +++ b/tests/test_toc_overlay.py @@ -0,0 +1,308 @@ +""" +Unit tests for TOC overlay functionality. + +Tests the complete workflow of: +1. Opening TOC overlay with swipe up gesture +2. Selecting a chapter from the TOC +3. Closing overlay by tapping outside or swiping down +""" + +import unittest +from pathlib import Path +from dreader import ( + EbookReader, + TouchEvent, + GestureType, + ActionType, + OverlayState +) + + +class TestTOCOverlay(unittest.TestCase): + """Test TOC overlay opening, interaction, and closing""" + + def setUp(self): + """Set up test reader with a book""" + self.reader = EbookReader(page_size=(800, 1200)) + + # Load a test EPUB + test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub' + if not test_epub.exists(): + # Try to find any EPUB in test data + epub_dir = Path(__file__).parent / 'data' / 'library-epub' + epubs = list(epub_dir.glob('*.epub')) + if epubs: + test_epub = epubs[0] + else: + self.skipTest("No test EPUB files available") + + success = self.reader.load_epub(str(test_epub)) + self.assertTrue(success, "Failed to load test EPUB") + + def tearDown(self): + """Clean up""" + self.reader.close() + + def test_overlay_manager_initialization(self): + """Test that overlay manager is properly initialized""" + self.assertIsNotNone(self.reader.overlay_manager) + self.assertEqual(self.reader.overlay_manager.page_size, (800, 1200)) + self.assertFalse(self.reader.is_overlay_open()) + self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE) + + def test_open_toc_overlay_directly(self): + """Test opening TOC overlay using direct API call""" + # Initially no overlay + self.assertFalse(self.reader.is_overlay_open()) + + # Open TOC overlay + overlay_image = self.reader.open_toc_overlay() + + # Should return an image + self.assertIsNotNone(overlay_image) + self.assertEqual(overlay_image.size, (800, 1200)) + + # Overlay should be open + self.assertTrue(self.reader.is_overlay_open()) + self.assertEqual(self.reader.get_overlay_state(), OverlayState.TOC) + + def test_close_toc_overlay_directly(self): + """Test closing TOC overlay using direct API call""" + # Open overlay first + self.reader.open_toc_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Close overlay + page_image = self.reader.close_overlay() + + # Should return base page + self.assertIsNotNone(page_image) + + # Overlay should be closed + self.assertFalse(self.reader.is_overlay_open()) + self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE) + + def test_swipe_up_from_bottom_opens_toc(self): + """Test that swipe up from bottom of screen opens TOC overlay""" + # Create swipe up event from bottom of screen (y=1100, which is > 80% of 1200) + event = TouchEvent( + gesture=GestureType.SWIPE_UP, + x=400, + y=1100 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should open overlay + self.assertEqual(response.action, ActionType.OVERLAY_OPENED) + self.assertEqual(response.data['overlay_type'], 'toc') + self.assertTrue(self.reader.is_overlay_open()) + + def test_swipe_up_from_middle_does_not_open_toc(self): + """Test that swipe up from middle of screen does NOT open TOC""" + # Create swipe up event from middle of screen (y=600, which is < 80% of 1200) + event = TouchEvent( + gesture=GestureType.SWIPE_UP, + x=400, + y=600 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should not open overlay + self.assertEqual(response.action, ActionType.NONE) + self.assertFalse(self.reader.is_overlay_open()) + + def test_swipe_down_closes_overlay(self): + """Test that swipe down closes the overlay""" + # Open overlay first + self.reader.open_toc_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Create swipe down event + event = TouchEvent( + gesture=GestureType.SWIPE_DOWN, + x=400, + y=300 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should close overlay + self.assertEqual(response.action, ActionType.OVERLAY_CLOSED) + self.assertFalse(self.reader.is_overlay_open()) + + def test_tap_outside_overlay_closes_it(self): + """Test that tapping outside the overlay panel closes it""" + # Open overlay first + self.reader.open_toc_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Tap in the far left (outside the centered panel) + # Panel is 60% wide centered, so left edge is at 20% + event = TouchEvent( + gesture=GestureType.TAP, + x=50, # Well outside panel + y=600 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should close overlay + self.assertEqual(response.action, ActionType.OVERLAY_CLOSED) + self.assertFalse(self.reader.is_overlay_open()) + + def test_tap_on_chapter_selects_and_closes(self): + """Test that tapping on a chapter navigates to it and closes overlay""" + # Open overlay first + self.reader.open_toc_overlay() + chapters = self.reader.get_chapters() + + if len(chapters) < 2: + self.skipTest("Need at least 2 chapters for this test") + + # Calculate tap position for second chapter (index 1 - "Metamorphosis") + # Based on actual measurements: chapter 1 link is at screen Y=282, X=200-300 + tap_x = 250 # Within the link text bounds + tap_y = 282 # Chapter 1 "Metamorphosis" + + event = TouchEvent( + gesture=GestureType.TAP, + x=tap_x, + y=tap_y + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should select chapter + self.assertEqual(response.action, ActionType.CHAPTER_SELECTED) + self.assertIn('chapter_index', response.data) + + # Overlay should be closed + self.assertFalse(self.reader.is_overlay_open()) + + def test_multiple_overlay_operations(self): + """Test opening and closing overlay multiple times""" + # Open and close 3 times + for i in range(3): + # Open + self.reader.open_toc_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Close + self.reader.close_overlay() + self.assertFalse(self.reader.is_overlay_open()) + + def test_overlay_with_page_navigation(self): + """Test that overlay works correctly after navigating pages""" + # Navigate to page 2 + self.reader.next_page() + + # Open overlay + overlay_image = self.reader.open_toc_overlay() + self.assertIsNotNone(overlay_image) + self.assertTrue(self.reader.is_overlay_open()) + + # Close overlay + self.reader.close_overlay() + self.assertFalse(self.reader.is_overlay_open()) + + def test_toc_overlay_contains_all_chapters(self): + """Test that TOC overlay includes all book chapters""" + chapters = self.reader.get_chapters() + + # Open overlay (this generates the HTML with chapters) + overlay_image = self.reader.open_toc_overlay() + self.assertIsNotNone(overlay_image) + + # Verify overlay manager has correct chapter count + # This is implicit in the rendering - if it renders without error, + # all chapters were included + self.assertTrue(self.reader.is_overlay_open()) + + def test_overlay_state_persistence_ready(self): + """Test that overlay state can be tracked (for future state persistence)""" + # This test verifies the state tracking is ready for StateManager integration + + # Start with no overlay + self.assertEqual(self.reader.current_overlay_state, OverlayState.NONE) + + # Open TOC + self.reader.open_toc_overlay() + self.assertEqual(self.reader.current_overlay_state, OverlayState.TOC) + + # Close overlay + self.reader.close_overlay() + self.assertEqual(self.reader.current_overlay_state, OverlayState.NONE) + + +class TestOverlayRendering(unittest.TestCase): + """Test overlay rendering and compositing""" + + def setUp(self): + """Set up test reader""" + self.reader = EbookReader(page_size=(800, 1200)) + test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub' + + if not test_epub.exists(): + epub_dir = Path(__file__).parent / 'data' / 'library-epub' + epubs = list(epub_dir.glob('*.epub')) + if epubs: + test_epub = epubs[0] + else: + self.skipTest("No test EPUB files available") + + self.reader.load_epub(str(test_epub)) + + def tearDown(self): + """Clean up""" + self.reader.close() + + def test_overlay_image_size(self): + """Test that overlay image matches page size""" + overlay_image = self.reader.open_toc_overlay() + self.assertEqual(overlay_image.size, (800, 1200)) + + def test_overlay_compositing(self): + """Test that overlay is properly composited on base page""" + # Get base page + base_page = self.reader.get_current_page() + + # Open overlay (creates composited image) + overlay_image = self.reader.open_toc_overlay() + + # Composited image should be different from base page + self.assertIsNotNone(overlay_image) + + # Images should have same size but different content + self.assertEqual(base_page.size, overlay_image.size) + + def test_overlay_html_to_image_conversion(self): + """Test that HTML overlay is correctly converted to image""" + from dreader.html_generator import generate_toc_overlay + + # Get chapters + chapters = self.reader.get_chapters() + chapter_data = [{"index": idx, "title": title} for title, idx in chapters] + + # Generate HTML + html = generate_toc_overlay(chapter_data) + self.assertIsNotNone(html) + self.assertIn("Table of Contents", html) + + # Render HTML to image using overlay manager + overlay_manager = self.reader.overlay_manager + image = overlay_manager.render_html_to_image(html) + + # Should produce valid image + self.assertIsNotNone(image) + self.assertEqual(image.size, (800, 1200)) + + +if __name__ == '__main__': + unittest.main()