Compare commits
No commits in common. "284a6e339306ccacf3f0cac3243d996749f1fda4" and "06c4a8504b42da6ec77743d6edba010ff110bd77" have entirely different histories.
284a6e3393
...
06c4a8504b
26
README.md
26
README.md
@ -24,7 +24,6 @@ This project serves as both a reference implementation and a ready-to-use ereade
|
||||
- ⬅️➡️ **Navigation** - Smooth forward and backward page navigation
|
||||
- 🔖 **Bookmarks** - Save and restore reading positions with persistence
|
||||
- 📑 **Chapter Navigation** - Jump to chapters by title or index via TOC
|
||||
- 📋 **TOC Overlay** - Interactive table of contents overlay with gesture support
|
||||
- 📊 **Progress Tracking** - Real-time reading progress percentage
|
||||
|
||||
### Text Interaction
|
||||
@ -87,16 +86,11 @@ Here are animated demonstrations of the key features:
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<td align="center" colspan="2">
|
||||
<b>Text Highlighting</b><br>
|
||||
<img src="docs/images/ereader_highlighting.gif" width="300" alt="Highlighting"><br>
|
||||
<em>Highlight words and selections with custom colors and notes</em>
|
||||
</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>
|
||||
</table>
|
||||
|
||||
@ -155,11 +149,6 @@ reader.jump_to_chapter(0) # By index
|
||||
reader.get_chapters() # List all chapters
|
||||
reader.get_current_chapter_info()
|
||||
reader.get_reading_progress() # Returns 0.0 to 1.0
|
||||
|
||||
# TOC Overlay
|
||||
overlay_image = reader.open_toc_overlay() # Returns composited image with TOC
|
||||
reader.close_overlay()
|
||||
reader.is_overlay_open()
|
||||
```
|
||||
|
||||
### Styling & Display
|
||||
@ -218,7 +207,7 @@ reader.get_highlights_for_current_page()
|
||||
### Gesture Handling
|
||||
|
||||
```python
|
||||
from dreader.gesture import TouchEvent, GestureType, ActionType
|
||||
from pyWebLayout.io.gesture import TouchEvent, GestureType
|
||||
|
||||
# Handle touch input
|
||||
event = TouchEvent(GestureType.TAP, x=400, y=300)
|
||||
@ -229,17 +218,6 @@ if response.action == ActionType.PAGE_TURN:
|
||||
print(f"Page turned: {response.data['direction']}")
|
||||
elif response.action == ActionType.WORD_SELECTED:
|
||||
print(f"Word selected: {response.data['word']}")
|
||||
elif response.action == ActionType.CHAPTER_SELECTED:
|
||||
print(f"Chapter selected: {response.data['chapter_title']}")
|
||||
|
||||
# Supported gestures:
|
||||
# - TAP: Select words, activate links, navigate TOC
|
||||
# - LONG_PRESS: Show definitions or context menu
|
||||
# - SWIPE_LEFT/RIGHT: Page navigation
|
||||
# - SWIPE_UP: Open TOC overlay (from bottom 20% of screen)
|
||||
# - SWIPE_DOWN: Close overlay
|
||||
# - PINCH_IN/OUT: Font size adjustment
|
||||
# - DRAG: Text selection
|
||||
```
|
||||
|
||||
### File Operations
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 543 KiB |
@ -8,52 +8,6 @@ with all essential features for building ereader applications.
|
||||
from dreader.application import EbookReader, create_ebook_reader
|
||||
from dreader import html_generator
|
||||
from dreader import book_utils
|
||||
from dreader.gesture import (
|
||||
TouchEvent,
|
||||
GestureType,
|
||||
GestureResponse,
|
||||
ActionType
|
||||
)
|
||||
from dreader.state import (
|
||||
StateManager,
|
||||
AppState,
|
||||
BookState,
|
||||
LibraryState,
|
||||
Settings,
|
||||
EreaderMode,
|
||||
OverlayState
|
||||
)
|
||||
from dreader.library import LibraryManager
|
||||
from dreader.overlay import OverlayManager
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
# 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",
|
||||
]
|
||||
__all__ = ["EbookReader", "create_ebook_reader", "html_generator", "book_utils"]
|
||||
|
||||
@ -42,7 +42,7 @@ import os
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.io.readers.epub_reader import read_epub
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.io.gesture import TouchEvent, GestureType, GestureResponse, ActionType
|
||||
from pyWebLayout.abstract.block import Block, HeadingLevel
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
from pyWebLayout.layout.ereader_layout import RenderingPosition
|
||||
@ -51,10 +51,6 @@ from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result
|
||||
|
||||
from .gesture import TouchEvent, GestureType, GestureResponse, ActionType
|
||||
from .state import OverlayState
|
||||
from .overlay import OverlayManager
|
||||
|
||||
|
||||
class EbookReader:
|
||||
"""
|
||||
@ -126,10 +122,6 @@ class EbookReader:
|
||||
self._selection_end: Optional[Tuple[int, int]] = None
|
||||
self._selected_range: Optional[SelectionRange] = None
|
||||
|
||||
# Overlay management
|
||||
self.overlay_manager = OverlayManager(page_size=page_size)
|
||||
self.current_overlay_state = OverlayState.NONE
|
||||
|
||||
def load_epub(self, epub_path: str) -> bool:
|
||||
"""
|
||||
Load an EPUB file into the reader.
|
||||
@ -186,57 +178,6 @@ class EbookReader:
|
||||
print(f"Error loading EPUB: {e}")
|
||||
return False
|
||||
|
||||
def load_html(self, html_string: str, title: str = "HTML Document", author: str = "Unknown", document_id: str = "html_doc") -> bool:
|
||||
"""
|
||||
Load HTML content directly into the reader.
|
||||
|
||||
This is useful for rendering library screens, menus, or other HTML-based UI elements
|
||||
using the same rendering engine as the ebook reader.
|
||||
|
||||
Args:
|
||||
html_string: HTML content to render
|
||||
title: Document title (for metadata)
|
||||
author: Document author (for metadata)
|
||||
document_id: Unique identifier for this HTML document
|
||||
|
||||
Returns:
|
||||
True if loaded successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Parse HTML into blocks
|
||||
blocks = parse_html_string(html_string)
|
||||
|
||||
if not blocks:
|
||||
raise ValueError("No content blocks parsed from HTML")
|
||||
|
||||
# Set metadata
|
||||
self.book_title = title
|
||||
self.book_author = author
|
||||
self.document_id = document_id
|
||||
self.blocks = blocks
|
||||
|
||||
# Initialize the ereader manager
|
||||
self.manager = EreaderLayoutManager(
|
||||
blocks=self.blocks,
|
||||
page_size=self.page_size,
|
||||
document_id=self.document_id,
|
||||
buffer_size=self.buffer_size,
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.bookmarks_dir
|
||||
)
|
||||
|
||||
# Initialize highlight manager for this document
|
||||
self.highlight_manager = HighlightManager(
|
||||
document_id=self.document_id,
|
||||
highlights_dir=self.highlights_dir
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading HTML: {e}")
|
||||
return False
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if a book is currently loaded."""
|
||||
return self.manager is not None
|
||||
@ -705,15 +646,7 @@ class EbookReader:
|
||||
if not self.is_loaded():
|
||||
return GestureResponse(ActionType.ERROR, {"message": "No book loaded"})
|
||||
|
||||
# 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
|
||||
# Dispatch based on gesture type
|
||||
if event.gesture == GestureType.TAP:
|
||||
return self._handle_tap(event.x, event.y)
|
||||
elif event.gesture == GestureType.LONG_PRESS:
|
||||
@ -722,9 +655,6 @@ class EbookReader:
|
||||
return self._handle_page_forward()
|
||||
elif event.gesture == GestureType.SWIPE_RIGHT:
|
||||
return self._handle_page_back()
|
||||
elif event.gesture == GestureType.SWIPE_UP:
|
||||
# Swipe up from bottom opens TOC overlay
|
||||
return self._handle_swipe_up(event.y)
|
||||
elif event.gesture == GestureType.PINCH_IN:
|
||||
return self._handle_zoom_out()
|
||||
elif event.gesture == GestureType.PINCH_OUT:
|
||||
@ -899,77 +829,6 @@ class EbookReader:
|
||||
"bounds": self._selected_range.bounds_list
|
||||
})
|
||||
|
||||
def _handle_swipe_up(self, y: int) -> GestureResponse:
|
||||
"""Handle swipe up gesture - opens TOC overlay if from bottom of screen"""
|
||||
# Check if swipe started from bottom 20% of screen
|
||||
bottom_threshold = self.page_size[1] * 0.8
|
||||
|
||||
if y >= bottom_threshold:
|
||||
# Open TOC overlay
|
||||
overlay_image = self.open_toc_overlay()
|
||||
if overlay_image:
|
||||
return GestureResponse(ActionType.OVERLAY_OPENED, {
|
||||
"overlay_type": "toc",
|
||||
"chapters": self.get_chapters()
|
||||
})
|
||||
|
||||
return GestureResponse(ActionType.NONE, {})
|
||||
|
||||
def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""Handle tap when overlay is open - select chapter or close overlay"""
|
||||
# For TOC overlay, use pyWebLayout link query to detect chapter clicks
|
||||
if self.current_overlay_state == OverlayState.TOC:
|
||||
# Query the overlay to see what was tapped
|
||||
query_result = self.overlay_manager.query_overlay_pixel(x, y)
|
||||
|
||||
# If query failed (tap outside overlay), close it
|
||||
if not query_result:
|
||||
self.close_overlay()
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# Check if tapped on a link (chapter)
|
||||
if query_result.get("is_interactive") and query_result.get("link_target"):
|
||||
link_target = query_result["link_target"]
|
||||
|
||||
# Parse "chapter:N" format
|
||||
if link_target.startswith("chapter:"):
|
||||
try:
|
||||
chapter_idx = int(link_target.split(":")[1])
|
||||
|
||||
# Get chapter title for response
|
||||
chapters = self.get_chapters()
|
||||
chapter_title = None
|
||||
for title, idx in chapters:
|
||||
if idx == chapter_idx:
|
||||
chapter_title = title
|
||||
break
|
||||
|
||||
# Jump to selected chapter
|
||||
self.jump_to_chapter(chapter_idx)
|
||||
|
||||
# Close overlay
|
||||
self.close_overlay()
|
||||
|
||||
return GestureResponse(ActionType.CHAPTER_SELECTED, {
|
||||
"chapter_index": chapter_idx,
|
||||
"chapter_title": chapter_title or f"Chapter {chapter_idx}"
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Not a chapter link, close overlay
|
||||
self.close_overlay()
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# For other overlays, just close on any tap for now
|
||||
self.close_overlay()
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
def _handle_overlay_close(self) -> GestureResponse:
|
||||
"""Handle overlay close gesture (swipe down)"""
|
||||
self.close_overlay()
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
|
||||
# ===================================================================
|
||||
# Highlighting API
|
||||
# ===================================================================
|
||||
@ -1178,107 +1037,6 @@ class EbookReader:
|
||||
|
||||
return result
|
||||
|
||||
# ===================================================================
|
||||
# Overlay Management API
|
||||
# ===================================================================
|
||||
|
||||
def open_toc_overlay(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Open the table of contents overlay.
|
||||
|
||||
Returns:
|
||||
Composited image with TOC overlay on top of current page, or None if no book loaded
|
||||
"""
|
||||
if not self.is_loaded():
|
||||
return None
|
||||
|
||||
# Get current page as base
|
||||
base_page = self.get_current_page(include_highlights=False)
|
||||
if not base_page:
|
||||
return None
|
||||
|
||||
# Get chapters
|
||||
chapters = self.get_chapters()
|
||||
|
||||
# Open overlay and get composited image
|
||||
result = self.overlay_manager.open_toc_overlay(chapters, base_page)
|
||||
self.current_overlay_state = OverlayState.TOC
|
||||
|
||||
return result
|
||||
|
||||
def open_settings_overlay(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Open the settings overlay.
|
||||
|
||||
Returns:
|
||||
Composited image with settings overlay on top of current page, or None if no book loaded
|
||||
"""
|
||||
if not self.is_loaded():
|
||||
return None
|
||||
|
||||
# Get current page as base
|
||||
base_page = self.get_current_page(include_highlights=False)
|
||||
if not base_page:
|
||||
return None
|
||||
|
||||
# Open overlay and get composited image
|
||||
result = self.overlay_manager.open_settings_overlay(base_page)
|
||||
self.current_overlay_state = OverlayState.SETTINGS
|
||||
|
||||
return result
|
||||
|
||||
def open_bookmarks_overlay(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Open the bookmarks overlay.
|
||||
|
||||
Returns:
|
||||
Composited image with bookmarks overlay on top of current page, or None if no book loaded
|
||||
"""
|
||||
if not self.is_loaded():
|
||||
return None
|
||||
|
||||
# Get current page as base
|
||||
base_page = self.get_current_page(include_highlights=False)
|
||||
if not base_page:
|
||||
return None
|
||||
|
||||
# Get bookmarks
|
||||
bookmark_names = self.list_saved_positions()
|
||||
bookmarks = [
|
||||
{"name": name, "position": f"Saved position"}
|
||||
for name in bookmark_names
|
||||
]
|
||||
|
||||
# Open overlay and get composited image
|
||||
result = self.overlay_manager.open_bookmarks_overlay(bookmarks, base_page)
|
||||
self.current_overlay_state = OverlayState.BOOKMARKS
|
||||
|
||||
return result
|
||||
|
||||
def close_overlay(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Close the current overlay and return to reading view.
|
||||
|
||||
Returns:
|
||||
Base page image without overlay, or None if no overlay was open
|
||||
"""
|
||||
if self.current_overlay_state == OverlayState.NONE:
|
||||
return None
|
||||
|
||||
result = self.overlay_manager.close_overlay()
|
||||
self.current_overlay_state = OverlayState.NONE
|
||||
|
||||
# Return fresh current page
|
||||
return self.get_current_page()
|
||||
|
||||
def is_overlay_open(self) -> bool:
|
||||
"""Check if an overlay is currently open."""
|
||||
return self.current_overlay_state != OverlayState.NONE
|
||||
|
||||
def get_overlay_state(self) -> OverlayState:
|
||||
"""Get current overlay state."""
|
||||
return self.current_overlay_state
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the reader and save current position.
|
||||
|
||||
@ -7,9 +7,6 @@ from typing import List, Dict, Optional
|
||||
from dreader import create_ebook_reader
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
import ebooklib
|
||||
from ebooklib import epub
|
||||
|
||||
|
||||
def scan_book_directory(directory: Path) -> List[Dict[str, str]]:
|
||||
@ -56,9 +53,9 @@ def extract_book_metadata(epub_path: Path, include_cover: bool = True) -> Option
|
||||
'author': reader.book_author or 'Unknown Author',
|
||||
}
|
||||
|
||||
# Extract cover image if requested - use direct EPUB extraction
|
||||
# Extract cover image if requested
|
||||
if include_cover:
|
||||
cover_data = extract_cover_from_epub(epub_path)
|
||||
cover_data = extract_cover_as_base64(reader)
|
||||
metadata['cover_data'] = cover_data
|
||||
|
||||
return metadata
|
||||
@ -78,9 +75,6 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450)
|
||||
"""
|
||||
Extract cover image from reader and return as base64 encoded string.
|
||||
|
||||
This function is kept for backward compatibility but now uses extract_cover_from_epub
|
||||
internally if the reader has an epub_path attribute.
|
||||
|
||||
Args:
|
||||
reader: EbookReader instance with loaded book
|
||||
max_width: Maximum width for cover image
|
||||
@ -90,11 +84,7 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450)
|
||||
Base64 encoded PNG image string or None
|
||||
"""
|
||||
try:
|
||||
# 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
|
||||
# Get first page as cover
|
||||
cover_image = reader.get_current_page()
|
||||
|
||||
# Resize if needed
|
||||
@ -114,70 +104,6 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450)
|
||||
return None
|
||||
|
||||
|
||||
def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: int = 450) -> Optional[str]:
|
||||
"""
|
||||
Extract the actual cover image from an EPUB file.
|
||||
|
||||
Args:
|
||||
epub_path: Path to EPUB file
|
||||
max_width: Maximum width for cover image
|
||||
max_height: Maximum height for cover image
|
||||
|
||||
Returns:
|
||||
Base64 encoded PNG image string or None
|
||||
"""
|
||||
try:
|
||||
# Read the EPUB
|
||||
book = epub.read_epub(str(epub_path))
|
||||
|
||||
# Look for cover image
|
||||
cover_image = None
|
||||
|
||||
# First, try to find item marked as cover
|
||||
for item in book.get_items():
|
||||
if item.get_type() == ebooklib.ITEM_COVER:
|
||||
cover_image = Image.open(BytesIO(item.get_content()))
|
||||
break
|
||||
|
||||
# If not found, look for files with 'cover' in the name
|
||||
if not cover_image:
|
||||
for item in book.get_items():
|
||||
if item.get_type() == ebooklib.ITEM_IMAGE:
|
||||
name = item.get_name().lower()
|
||||
if 'cover' in name:
|
||||
cover_image = Image.open(BytesIO(item.get_content()))
|
||||
break
|
||||
|
||||
# If still not found, get the first image
|
||||
if not cover_image:
|
||||
for item in book.get_items():
|
||||
if item.get_type() == ebooklib.ITEM_IMAGE:
|
||||
try:
|
||||
cover_image = Image.open(BytesIO(item.get_content()))
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not cover_image:
|
||||
return None
|
||||
|
||||
# Resize if needed (maintain aspect ratio)
|
||||
if cover_image.width > max_width or cover_image.height > max_height:
|
||||
cover_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert to base64
|
||||
buffer = BytesIO()
|
||||
cover_image.save(buffer, format='PNG')
|
||||
img_bytes = buffer.getvalue()
|
||||
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
|
||||
|
||||
return img_base64
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting cover from EPUB {epub_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_chapter_list(reader) -> List[Dict]:
|
||||
"""
|
||||
Get formatted chapter list from reader.
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
"""
|
||||
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,13 +8,14 @@ for rendering.
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from dreader import create_ebook_reader
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def generate_library_html(books: List[Dict[str, str]], save_covers_to_disk: bool = False) -> str:
|
||||
def generate_library_html(books: List[Dict[str, str]]) -> str:
|
||||
"""
|
||||
Generate HTML for the library view showing all books in a simple table.
|
||||
Generate HTML for the library view showing all books in a grid.
|
||||
|
||||
Args:
|
||||
books: List of book dictionaries with keys:
|
||||
@ -22,46 +23,140 @@ def generate_library_html(books: List[Dict[str, str]], save_covers_to_disk: bool
|
||||
- author: Book author
|
||||
- filename: EPUB filename
|
||||
- cover_data: Optional base64 encoded cover image
|
||||
- cover_path: Optional path to saved cover image (if save_covers_to_disk=True)
|
||||
save_covers_to_disk: If True, expect cover_path instead of cover_data
|
||||
|
||||
Returns:
|
||||
Complete HTML string for library view
|
||||
"""
|
||||
# Build table rows
|
||||
rows = []
|
||||
|
||||
books_html = []
|
||||
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>'
|
||||
cover_img = ''
|
||||
if book.get('cover_data'):
|
||||
cover_img = f'<img src="data:image/png;base64,{book["cover_data"]}" alt="Cover">'
|
||||
else:
|
||||
cover_cell = '<td>[No cover]</td>'
|
||||
# Placeholder if no cover
|
||||
cover_img = f'<div class="no-cover">{book["title"][:1]}</div>'
|
||||
|
||||
# Add book info cell
|
||||
info_cell = f'<td><b>{book["title"]}</b><br/>{book["author"]}</td>'
|
||||
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>
|
||||
''')
|
||||
|
||||
rows.append(f'<tr>{cover_cell}{info_cell}</tr>')
|
||||
# Arrange books in rows of 3
|
||||
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>')
|
||||
|
||||
table_html = '\n'.join(rows)
|
||||
|
||||
return f'''
|
||||
html = f'''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<h1>My Library</h1>
|
||||
<p>{len(books)} books</p>
|
||||
<table>
|
||||
{table_html}
|
||||
</table>
|
||||
<div class="header">
|
||||
<h1>My Library</h1>
|
||||
<p>{len(books)} books</p>
|
||||
</div>
|
||||
|
||||
<table class="library-grid">
|
||||
{"".join(rows)}
|
||||
</table>
|
||||
</body>
|
||||
</html>'''
|
||||
</html>
|
||||
'''
|
||||
return html
|
||||
|
||||
|
||||
def generate_reader_html(book_title: str, book_author: str, page_image_data: str) -> str:
|
||||
@ -323,7 +418,7 @@ def generate_settings_overlay() -> str:
|
||||
return html
|
||||
|
||||
|
||||
def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -> str:
|
||||
def generate_toc_overlay(chapters: List[Dict]) -> str:
|
||||
"""
|
||||
Generate HTML for the table of contents overlay.
|
||||
|
||||
@ -331,57 +426,105 @@ def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -
|
||||
chapters: List of chapter dictionaries with keys:
|
||||
- index: Chapter index
|
||||
- title: Chapter title
|
||||
page_size: Page dimensions (width, height) for sizing the overlay
|
||||
|
||||
Returns:
|
||||
HTML string for TOC overlay (60% popup with transparent background)
|
||||
HTML string for TOC overlay
|
||||
"""
|
||||
# Build chapter list items with clickable links for pyWebLayout query
|
||||
chapter_items = []
|
||||
for i, chapter in enumerate(chapters):
|
||||
title = chapter["title"]
|
||||
chapter_rows = []
|
||||
for chapter in chapters:
|
||||
chapter_rows.append(f'''
|
||||
<tr class="chapter-row" data-chapter-index="{chapter['index']}">
|
||||
<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'''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body style="background-color: white; margin: 0; padding: 25px; font-family: Arial, sans-serif;">
|
||||
|
||||
<h1 style="color: #000; margin: 0 0 8px 0; font-size: 24px; text-align: center; font-weight: bold;">
|
||||
Table of Contents
|
||||
</h1>
|
||||
|
||||
<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)}
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<div class="chapters-container">
|
||||
<table class="chapters-table">
|
||||
{"".join(chapter_rows)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
@ -1,410 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@ -1,332 +0,0 @@
|
||||
"""
|
||||
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
392
dreader/state.py
@ -1,392 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@ -1,296 +0,0 @@
|
||||
#!/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()
|
||||
@ -1,287 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@ -1,167 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@ -1,308 +0,0 @@
|
||||
"""
|
||||
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