Compare commits
2 Commits
06c4a8504b
...
284a6e3393
| Author | SHA1 | Date | |
|---|---|---|---|
| 284a6e3393 | |||
| 60426432a0 |
26
README.md
26
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
|
- ⬅️➡️ **Navigation** - Smooth forward and backward page navigation
|
||||||
- 🔖 **Bookmarks** - Save and restore reading positions with persistence
|
- 🔖 **Bookmarks** - Save and restore reading positions with persistence
|
||||||
- 📑 **Chapter Navigation** - Jump to chapters by title or index via TOC
|
- 📑 **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
|
- 📊 **Progress Tracking** - Real-time reading progress percentage
|
||||||
|
|
||||||
### Text Interaction
|
### Text Interaction
|
||||||
@ -86,11 +87,16 @@ Here are animated demonstrations of the key features:
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" colspan="2">
|
<td align="center">
|
||||||
<b>Text Highlighting</b><br>
|
<b>Text Highlighting</b><br>
|
||||||
<img src="docs/images/ereader_highlighting.gif" width="300" alt="Highlighting"><br>
|
<img src="docs/images/ereader_highlighting.gif" width="300" alt="Highlighting"><br>
|
||||||
<em>Highlight words and selections with custom colors and notes</em>
|
<em>Highlight words and selections with custom colors and notes</em>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<b>TOC Overlay</b><br>
|
||||||
|
<img src="docs/images/toc_overlay_demo.gif" width="300" alt="TOC Overlay"><br>
|
||||||
|
<em>Interactive table of contents with gesture-based navigation</em>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@ -149,6 +155,11 @@ reader.jump_to_chapter(0) # By index
|
|||||||
reader.get_chapters() # List all chapters
|
reader.get_chapters() # List all chapters
|
||||||
reader.get_current_chapter_info()
|
reader.get_current_chapter_info()
|
||||||
reader.get_reading_progress() # Returns 0.0 to 1.0
|
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
|
### Styling & Display
|
||||||
@ -207,7 +218,7 @@ reader.get_highlights_for_current_page()
|
|||||||
### Gesture Handling
|
### Gesture Handling
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from pyWebLayout.io.gesture import TouchEvent, GestureType
|
from dreader.gesture import TouchEvent, GestureType, ActionType
|
||||||
|
|
||||||
# Handle touch input
|
# Handle touch input
|
||||||
event = TouchEvent(GestureType.TAP, x=400, y=300)
|
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']}")
|
print(f"Page turned: {response.data['direction']}")
|
||||||
elif response.action == ActionType.WORD_SELECTED:
|
elif response.action == ActionType.WORD_SELECTED:
|
||||||
print(f"Word selected: {response.data['word']}")
|
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
|
### File Operations
|
||||||
|
|||||||
BIN
docs/images/ereader_library.png
Normal file
BIN
docs/images/ereader_library.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/images/toc_overlay_demo.gif
Normal file
BIN
docs/images/toc_overlay_demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 543 KiB |
@ -8,6 +8,52 @@ with all essential features for building ereader applications.
|
|||||||
from dreader.application import EbookReader, create_ebook_reader
|
from dreader.application import EbookReader, create_ebook_reader
|
||||||
from dreader import html_generator
|
from dreader import html_generator
|
||||||
from dreader import book_utils
|
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"
|
__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",
|
||||||
|
]
|
||||||
|
|||||||
@ -42,7 +42,7 @@ import os
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from pyWebLayout.io.readers.epub_reader import read_epub
|
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.abstract.block import Block, HeadingLevel
|
||||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||||
from pyWebLayout.layout.ereader_layout import RenderingPosition
|
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.query import QueryResult, SelectionRange
|
||||||
from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result
|
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:
|
class EbookReader:
|
||||||
"""
|
"""
|
||||||
@ -121,6 +125,10 @@ class EbookReader:
|
|||||||
self._selection_start: Optional[Tuple[int, int]] = None
|
self._selection_start: Optional[Tuple[int, int]] = None
|
||||||
self._selection_end: Optional[Tuple[int, int]] = None
|
self._selection_end: Optional[Tuple[int, int]] = None
|
||||||
self._selected_range: Optional[SelectionRange] = 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:
|
def load_epub(self, epub_path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@ -178,10 +186,61 @@ class EbookReader:
|
|||||||
print(f"Error loading EPUB: {e}")
|
print(f"Error loading EPUB: {e}")
|
||||||
return False
|
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:
|
def is_loaded(self) -> bool:
|
||||||
"""Check if a book is currently loaded."""
|
"""Check if a book is currently loaded."""
|
||||||
return self.manager is not None
|
return self.manager is not None
|
||||||
|
|
||||||
def get_current_page(self, include_highlights: bool = True) -> Optional[Image.Image]:
|
def get_current_page(self, include_highlights: bool = True) -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
Get the current page as a PIL Image.
|
Get the current page as a PIL Image.
|
||||||
@ -646,7 +705,15 @@ class EbookReader:
|
|||||||
if not self.is_loaded():
|
if not self.is_loaded():
|
||||||
return GestureResponse(ActionType.ERROR, {"message": "No book 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:
|
if event.gesture == GestureType.TAP:
|
||||||
return self._handle_tap(event.x, event.y)
|
return self._handle_tap(event.x, event.y)
|
||||||
elif event.gesture == GestureType.LONG_PRESS:
|
elif event.gesture == GestureType.LONG_PRESS:
|
||||||
@ -655,6 +722,9 @@ class EbookReader:
|
|||||||
return self._handle_page_forward()
|
return self._handle_page_forward()
|
||||||
elif event.gesture == GestureType.SWIPE_RIGHT:
|
elif event.gesture == GestureType.SWIPE_RIGHT:
|
||||||
return self._handle_page_back()
|
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:
|
elif event.gesture == GestureType.PINCH_IN:
|
||||||
return self._handle_zoom_out()
|
return self._handle_zoom_out()
|
||||||
elif event.gesture == GestureType.PINCH_OUT:
|
elif event.gesture == GestureType.PINCH_OUT:
|
||||||
@ -829,6 +899,77 @@ class EbookReader:
|
|||||||
"bounds": self._selected_range.bounds_list
|
"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
|
# Highlighting API
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
@ -1037,6 +1178,107 @@ class EbookReader:
|
|||||||
|
|
||||||
return result
|
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):
|
def close(self):
|
||||||
"""
|
"""
|
||||||
Close the reader and save current position.
|
Close the reader and save current position.
|
||||||
|
|||||||
@ -7,6 +7,9 @@ from typing import List, Dict, Optional
|
|||||||
from dreader import create_ebook_reader
|
from dreader import create_ebook_reader
|
||||||
import base64
|
import base64
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from PIL import Image
|
||||||
|
import ebooklib
|
||||||
|
from ebooklib import epub
|
||||||
|
|
||||||
|
|
||||||
def scan_book_directory(directory: Path) -> List[Dict[str, str]]:
|
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',
|
'author': reader.book_author or 'Unknown Author',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract cover image if requested
|
# Extract cover image if requested - use direct EPUB extraction
|
||||||
if include_cover:
|
if include_cover:
|
||||||
cover_data = extract_cover_as_base64(reader)
|
cover_data = extract_cover_from_epub(epub_path)
|
||||||
metadata['cover_data'] = cover_data
|
metadata['cover_data'] = cover_data
|
||||||
|
|
||||||
return metadata
|
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.
|
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:
|
Args:
|
||||||
reader: EbookReader instance with loaded book
|
reader: EbookReader instance with loaded book
|
||||||
max_width: Maximum width for cover image
|
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
|
Base64 encoded PNG image string or None
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
cover_image = reader.get_current_page()
|
||||||
|
|
||||||
# Resize if needed
|
# Resize if needed
|
||||||
@ -104,6 +114,70 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450)
|
|||||||
return None
|
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]:
|
def get_chapter_list(reader) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Get formatted chapter list from reader.
|
Get formatted chapter list from reader.
|
||||||
|
|||||||
127
dreader/gesture.py
Normal file
127
dreader/gesture.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Gesture event types for touch input.
|
||||||
|
|
||||||
|
This module defines touch gestures that can be received from a HAL (Hardware Abstraction Layer)
|
||||||
|
or touch input system, and the response format for actions to be performed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from enum import Enum
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class GestureType(Enum):
|
||||||
|
"""Touch gesture types from HAL"""
|
||||||
|
TAP = "tap" # Single finger tap
|
||||||
|
LONG_PRESS = "long_press" # Hold for 500ms+
|
||||||
|
SWIPE_LEFT = "swipe_left" # Swipe left (page forward)
|
||||||
|
SWIPE_RIGHT = "swipe_right" # Swipe right (page back)
|
||||||
|
SWIPE_UP = "swipe_up" # Swipe up (scroll down)
|
||||||
|
SWIPE_DOWN = "swipe_down" # Swipe down (scroll up)
|
||||||
|
PINCH_IN = "pinch_in" # Pinch fingers together (zoom out)
|
||||||
|
PINCH_OUT = "pinch_out" # Spread fingers apart (zoom in)
|
||||||
|
DRAG_START = "drag_start" # Start dragging/selection
|
||||||
|
DRAG_MOVE = "drag_move" # Continue dragging
|
||||||
|
DRAG_END = "drag_end" # End dragging/selection
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TouchEvent:
|
||||||
|
"""
|
||||||
|
Touch event from HAL.
|
||||||
|
|
||||||
|
Represents a single touch gesture with its coordinates and metadata.
|
||||||
|
"""
|
||||||
|
gesture: GestureType
|
||||||
|
x: int # Primary touch point X coordinate
|
||||||
|
y: int # Primary touch point Y coordinate
|
||||||
|
x2: Optional[int] = None # Secondary point X (for pinch/drag)
|
||||||
|
y2: Optional[int] = None # Secondary point Y (for pinch/drag)
|
||||||
|
timestamp_ms: float = 0 # Timestamp in milliseconds
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_hal(cls, hal_data: dict) -> 'TouchEvent':
|
||||||
|
"""
|
||||||
|
Parse a touch event from HAL format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hal_data: Dictionary with gesture data from HAL
|
||||||
|
Expected keys: 'gesture', 'x', 'y', optionally 'x2', 'y2', 'timestamp'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TouchEvent instance
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> event = TouchEvent.from_hal({
|
||||||
|
... 'gesture': 'tap',
|
||||||
|
... 'x': 450,
|
||||||
|
... 'y': 320
|
||||||
|
... })
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
gesture=GestureType(hal_data['gesture']),
|
||||||
|
x=hal_data['x'],
|
||||||
|
y=hal_data['y'],
|
||||||
|
x2=hal_data.get('x2'),
|
||||||
|
y2=hal_data.get('y2'),
|
||||||
|
timestamp_ms=hal_data.get('timestamp', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for serialization"""
|
||||||
|
return {
|
||||||
|
'gesture': self.gesture.value,
|
||||||
|
'x': self.x,
|
||||||
|
'y': self.y,
|
||||||
|
'x2': self.x2,
|
||||||
|
'y2': self.y2,
|
||||||
|
'timestamp_ms': self.timestamp_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GestureResponse:
|
||||||
|
"""
|
||||||
|
Response from handling a gesture.
|
||||||
|
|
||||||
|
This encapsulates the action that should be performed by the UI
|
||||||
|
in response to a gesture, keeping all business logic in the library.
|
||||||
|
"""
|
||||||
|
action: str # Action type: "navigate", "define", "select", "zoom", "page_turn", "none", etc.
|
||||||
|
data: Dict[str, Any] # Action-specific data
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""
|
||||||
|
Convert to dictionary for Flask JSON response.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with action and data
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'action': self.action,
|
||||||
|
'data': self.data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Action type constants for clarity
|
||||||
|
class ActionType:
|
||||||
|
"""Constants for gesture response action types"""
|
||||||
|
NONE = "none"
|
||||||
|
PAGE_TURN = "page_turn"
|
||||||
|
NAVIGATE = "navigate"
|
||||||
|
DEFINE = "define"
|
||||||
|
SELECT = "select"
|
||||||
|
ZOOM = "zoom"
|
||||||
|
BOOK_LOADED = "book_loaded"
|
||||||
|
WORD_SELECTED = "word_selected"
|
||||||
|
SHOW_MENU = "show_menu"
|
||||||
|
SELECTION_START = "selection_start"
|
||||||
|
SELECTION_UPDATE = "selection_update"
|
||||||
|
SELECTION_COMPLETE = "selection_complete"
|
||||||
|
AT_START = "at_start"
|
||||||
|
AT_END = "at_end"
|
||||||
|
ERROR = "error"
|
||||||
|
OVERLAY_OPENED = "overlay_opened"
|
||||||
|
OVERLAY_CLOSED = "overlay_closed"
|
||||||
|
CHAPTER_SELECTED = "chapter_selected"
|
||||||
@ -8,14 +8,13 @@ for rendering.
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from dreader import create_ebook_reader
|
|
||||||
import base64
|
import base64
|
||||||
from io import BytesIO
|
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:
|
Args:
|
||||||
books: List of book dictionaries with keys:
|
books: List of book dictionaries with keys:
|
||||||
@ -23,140 +22,46 @@ def generate_library_html(books: List[Dict[str, str]]) -> str:
|
|||||||
- author: Book author
|
- author: Book author
|
||||||
- filename: EPUB filename
|
- filename: EPUB filename
|
||||||
- cover_data: Optional base64 encoded cover image
|
- 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:
|
Returns:
|
||||||
Complete HTML string for library view
|
Complete HTML string for library view
|
||||||
"""
|
"""
|
||||||
books_html = []
|
# Build table rows
|
||||||
for book in books:
|
|
||||||
cover_img = ''
|
|
||||||
if book.get('cover_data'):
|
|
||||||
cover_img = f'<img src="data:image/png;base64,{book["cover_data"]}" alt="Cover">'
|
|
||||||
else:
|
|
||||||
# Placeholder if no cover
|
|
||||||
cover_img = f'<div class="no-cover">{book["title"][:1]}</div>'
|
|
||||||
|
|
||||||
books_html.append(f'''
|
|
||||||
<td class="book-item" data-filename="{book['filename']}">
|
|
||||||
<table style="width: 100%;">
|
|
||||||
<tr>
|
|
||||||
<td class="cover-cell">
|
|
||||||
{cover_img}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="title-cell">{book['title']}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="author-cell">{book['author']}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
''')
|
|
||||||
|
|
||||||
# Arrange books in rows of 3
|
|
||||||
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('<td class="book-item empty"></td>')
|
|
||||||
rows.append(f'<tr>{"".join(row_books)}</tr>')
|
|
||||||
|
|
||||||
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'<td><img src="{book["cover_path"]}" width="150"/></td>'
|
||||||
|
elif book.get('cover_data'):
|
||||||
|
cover_cell = f'<td><img src="data:image/png;base64,{book["cover_data"]}" width="150"/></td>'
|
||||||
|
else:
|
||||||
|
cover_cell = '<td>[No cover]</td>'
|
||||||
|
|
||||||
|
# Add book info cell
|
||||||
|
info_cell = f'<td><b>{book["title"]}</b><br/>{book["author"]}</td>'
|
||||||
|
|
||||||
|
rows.append(f'<tr>{cover_cell}{info_cell}</tr>')
|
||||||
|
|
||||||
|
table_html = '\n'.join(rows)
|
||||||
|
|
||||||
|
return f'''
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Library</title>
|
<title>Library</title>
|
||||||
<style>
|
|
||||||
* {{
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}}
|
|
||||||
body {{
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
padding: 20px;
|
|
||||||
}}
|
|
||||||
.header {{
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #333;
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}}
|
|
||||||
.library-grid {{
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 20px;
|
|
||||||
}}
|
|
||||||
.book-item {{
|
|
||||||
background-color: white;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
cursor: pointer;
|
|
||||||
vertical-align: top;
|
|
||||||
width: 33%;
|
|
||||||
}}
|
|
||||||
.book-item:hover {{
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
|
||||||
}}
|
|
||||||
.book-item.empty {{
|
|
||||||
background: none;
|
|
||||||
box-shadow: none;
|
|
||||||
cursor: default;
|
|
||||||
}}
|
|
||||||
.cover-cell {{
|
|
||||||
text-align: center;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}}
|
|
||||||
.cover-cell img {{
|
|
||||||
max-width: 200px;
|
|
||||||
max-height: 300px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}}
|
|
||||||
.no-cover {{
|
|
||||||
width: 200px;
|
|
||||||
height: 300px;
|
|
||||||
background-color: #ddd;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 72px;
|
|
||||||
color: #999;
|
|
||||||
margin: 0 auto;
|
|
||||||
}}
|
|
||||||
.title-cell {{
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 5px;
|
|
||||||
}}
|
|
||||||
.author-cell {{
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 5px;
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<h1>My Library</h1>
|
||||||
<h1>My Library</h1>
|
<p>{len(books)} books</p>
|
||||||
<p>{len(books)} books</p>
|
<table>
|
||||||
</div>
|
{table_html}
|
||||||
|
</table>
|
||||||
<table class="library-grid">
|
|
||||||
{"".join(rows)}
|
|
||||||
</table>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>'''
|
||||||
'''
|
|
||||||
return html
|
|
||||||
|
|
||||||
|
|
||||||
def generate_reader_html(book_title: str, book_author: str, page_image_data: str) -> str:
|
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
|
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.
|
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:
|
chapters: List of chapter dictionaries with keys:
|
||||||
- index: Chapter index
|
- index: Chapter index
|
||||||
- title: Chapter title
|
- title: Chapter title
|
||||||
|
page_size: Page dimensions (width, height) for sizing the overlay
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HTML string for TOC overlay
|
HTML string for TOC overlay (60% popup with transparent background)
|
||||||
"""
|
"""
|
||||||
chapter_rows = []
|
# Build chapter list items with clickable links for pyWebLayout query
|
||||||
for chapter in chapters:
|
chapter_items = []
|
||||||
chapter_rows.append(f'''
|
for i, chapter in enumerate(chapters):
|
||||||
<tr class="chapter-row" data-chapter-index="{chapter['index']}">
|
title = chapter["title"]
|
||||||
<td class="chapter-cell">{chapter['title']}</td>
|
|
||||||
</tr>
|
|
||||||
''')
|
|
||||||
|
|
||||||
|
# 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'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
|
||||||
|
f'border-left: 3px solid #000;">'
|
||||||
|
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000;">'
|
||||||
|
f'{link_text}</a></p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render simple white panel - compositing will be done by OverlayManager
|
||||||
html = f'''
|
html = f'''
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Table of Contents</title>
|
<title>Table of Contents</title>
|
||||||
<style>
|
|
||||||
* {{
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}}
|
|
||||||
body {{
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
}}
|
|
||||||
.overlay-panel {{
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
|
||||||
padding: 20px;
|
|
||||||
min-width: 500px;
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}}
|
|
||||||
.overlay-header {{
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
border-bottom: 2px solid #ddd;
|
|
||||||
}}
|
|
||||||
.overlay-title {{
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
}}
|
|
||||||
.close-button {{
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}}
|
|
||||||
.close-button:hover {{
|
|
||||||
background-color: #c82333;
|
|
||||||
}}
|
|
||||||
.chapters-container {{
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}}
|
|
||||||
.chapters-table {{
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}}
|
|
||||||
.chapter-row {{
|
|
||||||
cursor: pointer;
|
|
||||||
}}
|
|
||||||
.chapter-row:hover {{
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}}
|
|
||||||
.chapter-cell {{
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body style="background-color: white; margin: 0; padding: 25px; font-family: Arial, sans-serif;">
|
||||||
<div class="overlay-panel">
|
|
||||||
<div class="overlay-header">
|
|
||||||
<span class="overlay-title">Table of Contents</span>
|
|
||||||
<button class="close-button" id="btn-close">Close</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chapters-container">
|
<h1 style="color: #000; margin: 0 0 8px 0; font-size: 24px; text-align: center; font-weight: bold;">
|
||||||
<table class="chapters-table">
|
Table of Contents
|
||||||
{"".join(chapter_rows)}
|
</h1>
|
||||||
</table>
|
|
||||||
</div>
|
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
|
||||||
|
border-bottom: 2px solid #ccc; font-size: 13px;">
|
||||||
|
{len(chapters)} chapters
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="max-height: 600px; overflow-y: auto;">
|
||||||
|
{"".join(chapter_items)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p style="text-align: center; margin: 15px 0 0 0; padding-top: 12px;
|
||||||
|
border-top: 2px solid #ccc; color: #888; font-size: 11px;">
|
||||||
|
Tap a chapter to navigate • Tap outside to close
|
||||||
|
</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
'''
|
'''
|
||||||
|
|||||||
410
dreader/library.py
Normal file
410
dreader/library.py
Normal file
@ -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()
|
||||||
332
dreader/overlay.py
Normal file
332
dreader/overlay.py
Normal file
@ -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
|
||||||
|
}
|
||||||
392
dreader/state.py
Normal file
392
dreader/state.py
Normal file
@ -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
|
||||||
296
examples/demo_toc_overlay.py
Executable file
296
examples/demo_toc_overlay.py
Executable file
@ -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()
|
||||||
287
tests/test_gesture.py
Normal file
287
tests/test_gesture.py
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for gesture event system.
|
||||||
|
|
||||||
|
Tests TouchEvent, GestureType, GestureResponse, and HAL integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from dreader.gesture import (
|
||||||
|
GestureType,
|
||||||
|
TouchEvent,
|
||||||
|
GestureResponse,
|
||||||
|
ActionType
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGestureType(unittest.TestCase):
|
||||||
|
"""Test GestureType enum"""
|
||||||
|
|
||||||
|
def test_gesture_types_exist(self):
|
||||||
|
"""Test all gesture types are defined"""
|
||||||
|
self.assertEqual(GestureType.TAP.value, "tap")
|
||||||
|
self.assertEqual(GestureType.LONG_PRESS.value, "long_press")
|
||||||
|
self.assertEqual(GestureType.SWIPE_LEFT.value, "swipe_left")
|
||||||
|
self.assertEqual(GestureType.SWIPE_RIGHT.value, "swipe_right")
|
||||||
|
self.assertEqual(GestureType.SWIPE_UP.value, "swipe_up")
|
||||||
|
self.assertEqual(GestureType.SWIPE_DOWN.value, "swipe_down")
|
||||||
|
self.assertEqual(GestureType.PINCH_IN.value, "pinch_in")
|
||||||
|
self.assertEqual(GestureType.PINCH_OUT.value, "pinch_out")
|
||||||
|
self.assertEqual(GestureType.DRAG_START.value, "drag_start")
|
||||||
|
self.assertEqual(GestureType.DRAG_MOVE.value, "drag_move")
|
||||||
|
self.assertEqual(GestureType.DRAG_END.value, "drag_end")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTouchEvent(unittest.TestCase):
|
||||||
|
"""Test TouchEvent dataclass"""
|
||||||
|
|
||||||
|
def test_init_basic(self):
|
||||||
|
"""Test basic TouchEvent creation"""
|
||||||
|
event = TouchEvent(
|
||||||
|
gesture=GestureType.TAP,
|
||||||
|
x=450,
|
||||||
|
y=320
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(event.gesture, GestureType.TAP)
|
||||||
|
self.assertEqual(event.x, 450)
|
||||||
|
self.assertEqual(event.y, 320)
|
||||||
|
self.assertIsNone(event.x2)
|
||||||
|
self.assertIsNone(event.y2)
|
||||||
|
self.assertEqual(event.timestamp_ms, 0)
|
||||||
|
|
||||||
|
def test_init_with_secondary_point(self):
|
||||||
|
"""Test TouchEvent with secondary point (pinch/drag)"""
|
||||||
|
event = TouchEvent(
|
||||||
|
gesture=GestureType.PINCH_OUT,
|
||||||
|
x=400,
|
||||||
|
y=300,
|
||||||
|
x2=450,
|
||||||
|
y2=350,
|
||||||
|
timestamp_ms=12345.678
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
|
||||||
|
self.assertEqual(event.x, 400)
|
||||||
|
self.assertEqual(event.y, 300)
|
||||||
|
self.assertEqual(event.x2, 450)
|
||||||
|
self.assertEqual(event.y2, 350)
|
||||||
|
self.assertEqual(event.timestamp_ms, 12345.678)
|
||||||
|
|
||||||
|
def test_from_hal_basic(self):
|
||||||
|
"""Test parsing TouchEvent from HAL format"""
|
||||||
|
hal_data = {
|
||||||
|
'gesture': 'tap',
|
||||||
|
'x': 450,
|
||||||
|
'y': 320
|
||||||
|
}
|
||||||
|
|
||||||
|
event = TouchEvent.from_hal(hal_data)
|
||||||
|
|
||||||
|
self.assertEqual(event.gesture, GestureType.TAP)
|
||||||
|
self.assertEqual(event.x, 450)
|
||||||
|
self.assertEqual(event.y, 320)
|
||||||
|
|
||||||
|
def test_from_hal_complete(self):
|
||||||
|
"""Test parsing TouchEvent with all fields from HAL"""
|
||||||
|
hal_data = {
|
||||||
|
'gesture': 'pinch_out',
|
||||||
|
'x': 400,
|
||||||
|
'y': 300,
|
||||||
|
'x2': 450,
|
||||||
|
'y2': 350,
|
||||||
|
'timestamp': 12345.678
|
||||||
|
}
|
||||||
|
|
||||||
|
event = TouchEvent.from_hal(hal_data)
|
||||||
|
|
||||||
|
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
|
||||||
|
self.assertEqual(event.x, 400)
|
||||||
|
self.assertEqual(event.y, 300)
|
||||||
|
self.assertEqual(event.x2, 450)
|
||||||
|
self.assertEqual(event.y2, 350)
|
||||||
|
self.assertEqual(event.timestamp_ms, 12345.678)
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Test TouchEvent serialization"""
|
||||||
|
event = TouchEvent(
|
||||||
|
gesture=GestureType.SWIPE_LEFT,
|
||||||
|
x=600,
|
||||||
|
y=400,
|
||||||
|
timestamp_ms=12345.0
|
||||||
|
)
|
||||||
|
|
||||||
|
d = event.to_dict()
|
||||||
|
|
||||||
|
self.assertEqual(d['gesture'], 'swipe_left')
|
||||||
|
self.assertEqual(d['x'], 600)
|
||||||
|
self.assertEqual(d['y'], 400)
|
||||||
|
self.assertIsNone(d['x2'])
|
||||||
|
self.assertIsNone(d['y2'])
|
||||||
|
self.assertEqual(d['timestamp_ms'], 12345.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGestureResponse(unittest.TestCase):
|
||||||
|
"""Test GestureResponse dataclass"""
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
"""Test GestureResponse creation"""
|
||||||
|
response = GestureResponse(
|
||||||
|
action="page_turn",
|
||||||
|
data={"direction": "forward", "progress": 0.42}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.action, "page_turn")
|
||||||
|
self.assertEqual(response.data['direction'], "forward")
|
||||||
|
self.assertEqual(response.data['progress'], 0.42)
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Test GestureResponse serialization"""
|
||||||
|
response = GestureResponse(
|
||||||
|
action="define",
|
||||||
|
data={"word": "ephemeral", "bounds": (100, 200, 50, 20)}
|
||||||
|
)
|
||||||
|
|
||||||
|
d = response.to_dict()
|
||||||
|
|
||||||
|
self.assertEqual(d['action'], "define")
|
||||||
|
self.assertEqual(d['data']['word'], "ephemeral")
|
||||||
|
self.assertEqual(d['data']['bounds'], (100, 200, 50, 20))
|
||||||
|
|
||||||
|
def test_to_dict_empty_data(self):
|
||||||
|
"""Test GestureResponse with empty data"""
|
||||||
|
response = GestureResponse(action="none", data={})
|
||||||
|
|
||||||
|
d = response.to_dict()
|
||||||
|
|
||||||
|
self.assertEqual(d['action'], "none")
|
||||||
|
self.assertEqual(d['data'], {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestActionType(unittest.TestCase):
|
||||||
|
"""Test ActionType constants"""
|
||||||
|
|
||||||
|
def test_action_types_defined(self):
|
||||||
|
"""Test all action type constants are defined"""
|
||||||
|
self.assertEqual(ActionType.NONE, "none")
|
||||||
|
self.assertEqual(ActionType.PAGE_TURN, "page_turn")
|
||||||
|
self.assertEqual(ActionType.NAVIGATE, "navigate")
|
||||||
|
self.assertEqual(ActionType.DEFINE, "define")
|
||||||
|
self.assertEqual(ActionType.SELECT, "select")
|
||||||
|
self.assertEqual(ActionType.ZOOM, "zoom")
|
||||||
|
self.assertEqual(ActionType.BOOK_LOADED, "book_loaded")
|
||||||
|
self.assertEqual(ActionType.WORD_SELECTED, "word_selected")
|
||||||
|
self.assertEqual(ActionType.SHOW_MENU, "show_menu")
|
||||||
|
self.assertEqual(ActionType.SELECTION_START, "selection_start")
|
||||||
|
self.assertEqual(ActionType.SELECTION_UPDATE, "selection_update")
|
||||||
|
self.assertEqual(ActionType.SELECTION_COMPLETE, "selection_complete")
|
||||||
|
self.assertEqual(ActionType.AT_START, "at_start")
|
||||||
|
self.assertEqual(ActionType.AT_END, "at_end")
|
||||||
|
self.assertEqual(ActionType.ERROR, "error")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHALIntegration(unittest.TestCase):
|
||||||
|
"""Test HAL integration scenarios"""
|
||||||
|
|
||||||
|
def test_hal_tap_flow(self):
|
||||||
|
"""Test complete HAL tap event flow"""
|
||||||
|
# Simulate HAL sending tap event
|
||||||
|
hal_data = {
|
||||||
|
'gesture': 'tap',
|
||||||
|
'x': 450,
|
||||||
|
'y': 320,
|
||||||
|
'timestamp': 1234567890.123
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse event
|
||||||
|
event = TouchEvent.from_hal(hal_data)
|
||||||
|
|
||||||
|
# Verify event
|
||||||
|
self.assertEqual(event.gesture, GestureType.TAP)
|
||||||
|
self.assertEqual(event.x, 450)
|
||||||
|
self.assertEqual(event.y, 320)
|
||||||
|
|
||||||
|
# Simulate business logic response
|
||||||
|
response = GestureResponse(
|
||||||
|
action=ActionType.WORD_SELECTED,
|
||||||
|
data={"word": "hello", "bounds": (440, 310, 50, 20)}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Serialize for Flask
|
||||||
|
response_dict = response.to_dict()
|
||||||
|
|
||||||
|
self.assertEqual(response_dict['action'], "word_selected")
|
||||||
|
self.assertEqual(response_dict['data']['word'], "hello")
|
||||||
|
|
||||||
|
def test_hal_pinch_flow(self):
|
||||||
|
"""Test complete HAL pinch event flow"""
|
||||||
|
# Simulate HAL sending pinch event with two touch points
|
||||||
|
hal_data = {
|
||||||
|
'gesture': 'pinch_out',
|
||||||
|
'x': 400,
|
||||||
|
'y': 500,
|
||||||
|
'x2': 500,
|
||||||
|
'y2': 500,
|
||||||
|
'timestamp': 1234567891.456
|
||||||
|
}
|
||||||
|
|
||||||
|
event = TouchEvent.from_hal(hal_data)
|
||||||
|
|
||||||
|
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
|
||||||
|
self.assertEqual(event.x, 400)
|
||||||
|
self.assertEqual(event.x2, 500)
|
||||||
|
|
||||||
|
def test_hal_swipe_flow(self):
|
||||||
|
"""Test complete HAL swipe event flow"""
|
||||||
|
hal_data = {
|
||||||
|
'gesture': 'swipe_left',
|
||||||
|
'x': 600,
|
||||||
|
'y': 400
|
||||||
|
}
|
||||||
|
|
||||||
|
event = TouchEvent.from_hal(hal_data)
|
||||||
|
|
||||||
|
self.assertEqual(event.gesture, GestureType.SWIPE_LEFT)
|
||||||
|
|
||||||
|
# Expected response
|
||||||
|
response = GestureResponse(
|
||||||
|
action=ActionType.PAGE_TURN,
|
||||||
|
data={"direction": "forward", "progress": 0.25}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.action, "page_turn")
|
||||||
|
|
||||||
|
def test_hal_drag_selection_flow(self):
|
||||||
|
"""Test complete drag selection flow"""
|
||||||
|
# Drag start
|
||||||
|
start_data = {
|
||||||
|
'gesture': 'drag_start',
|
||||||
|
'x': 100,
|
||||||
|
'y': 200
|
||||||
|
}
|
||||||
|
|
||||||
|
start_event = TouchEvent.from_hal(start_data)
|
||||||
|
self.assertEqual(start_event.gesture, GestureType.DRAG_START)
|
||||||
|
|
||||||
|
# Drag move
|
||||||
|
move_data = {
|
||||||
|
'gesture': 'drag_move',
|
||||||
|
'x': 300,
|
||||||
|
'y': 250
|
||||||
|
}
|
||||||
|
|
||||||
|
move_event = TouchEvent.from_hal(move_data)
|
||||||
|
self.assertEqual(move_event.gesture, GestureType.DRAG_MOVE)
|
||||||
|
|
||||||
|
# Drag end
|
||||||
|
end_data = {
|
||||||
|
'gesture': 'drag_end',
|
||||||
|
'x': 500,
|
||||||
|
'y': 300
|
||||||
|
}
|
||||||
|
|
||||||
|
end_event = TouchEvent.from_hal(end_data)
|
||||||
|
self.assertEqual(end_event.gesture, GestureType.DRAG_END)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
167
tests/test_library_interaction.py
Normal file
167
tests/test_library_interaction.py
Normal file
@ -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()
|
||||||
308
tests/test_toc_overlay.py
Normal file
308
tests/test_toc_overlay.py
Normal file
@ -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()
|
||||||
Loading…
x
Reference in New Issue
Block a user