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

Highlight words and selections with custom colors and notes
|
+
+ 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'
'
- 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
+
-
-'''
- return html
+