Compare commits

...

2 Commits

Author SHA1 Message Date
284a6e3393 library and toc navigation
All checks were successful
Python CI / test (push) Successful in 4m30s
2025-11-08 12:20:23 +01:00
60426432a0 add gestures from pyweblayout 2025-11-08 10:54:50 +01:00
15 changed files with 2779 additions and 219 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

View File

@ -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",
]

View File

@ -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.

View File

@ -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
View 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"

View File

@ -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
View 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
View 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
View 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
View 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
View 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()

View 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
View 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()