Update coverage badges [skip ci]

This commit is contained in:
Gitea Action 2025-11-12 18:52:08 +00:00
commit 4971c0fc59
107 changed files with 22935 additions and 0 deletions

31
.coveragerc Normal file
View File

@ -0,0 +1,31 @@
[run]
source = dreader
branch = True
omit =
*/tests/*
*/test_*
setup.py
*/examples/*
*/__main__.py
[report]
exclude_lines =
pragma: no cover
def __repr__
if self.debug:
if settings.DEBUG
raise AssertionError
raise NotImplementedError
if 0:
if __name__ == .__main__.:
# Exclude docstrings
^\s*"""
^\s*'''
^\s*r"""
^\s*r'''
[xml]
output = coverage.xml
[html]
directory = htmlcov

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Mark EPUB files as binary to prevent any text transformations
*.epub binary

183
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,183 @@
name: Python CI
on:
push:
branches: [ main, master, develop ]
paths-ignore:
- 'coverage*.svg'
- 'README.md'
pull_request:
branches: [ main, master, develop ]
jobs:
test:
runs-on: self-hosted
strategy:
matrix:
python-version: ['3.12', '3.13']
fail-fast: false
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# Install package in development mode (force reinstall pyweblayout to get latest from master)
pip install --upgrade --force-reinstall --no-deps --no-cache-dir git+https://gitea.tourolle.paris/dtourolle/pyWebLayout@master
pip install -e .
# Install test dependencies if they exist
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
if [ -f requirements/test.txt ]; then pip install -r requirements/test.txt; fi
# Install common test packages
pip install pytest pytest-cov flake8 coverage-badge interrogate
# Debug: Show pyWebLayout version info
python -c "import pyWebLayout; print(f'pyWebLayout location: {pyWebLayout.__file__}')"
- name: Download initial failed badges
run: |
echo "Downloading initial failed badges..."
# Create cov_info directory first
mkdir -p cov_info
# Download failed badges as defaults
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
echo "Initial failed badges created:"
ls -la cov_info/coverage*.svg
- name: Run tests with pytest
id: pytest
continue-on-error: true
run: |
# Run tests with coverage
python -m pytest tests/ -v --cov=dreader --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
- name: Check documentation coverage
id: docs
continue-on-error: true
run: |
# Generate documentation coverage report
interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 dreader/
- name: Lint with flake8
run: |
# Stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# Exit-zero treats all errors as warnings
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Create coverage info directory
if: always()
run: |
mkdir -p cov_info
echo "Created cov_info directory for coverage data"
- name: Update test coverage badge on success
if: steps.pytest.outcome == 'success' && always()
run: |
echo "Tests passed! Generating successful coverage badge..."
if [ -f coverage.json ]; then
coverage-badge -o cov_info/coverage.svg -f
echo "✅ Test coverage badge updated with actual results"
else
echo "⚠️ No coverage.json found, keeping failed badge"
fi
- name: Update docs coverage badge on success
if: steps.docs.outcome == 'success' && always()
run: |
echo "Docs check passed! Generating successful docs badge..."
# Remove existing badge first to avoid overwrite error
rm -f cov_info/coverage-docs.svg
interrogate --generate-badge cov_info/coverage-docs.svg dreader/
echo "✅ Docs coverage badge updated with actual results"
- name: Generate coverage reports
if: steps.pytest.outcome == 'success'
run: |
# Generate coverage summary for README
python -c "
import json
import os
# Read coverage data
if os.path.exists('coverage.json'):
with open('coverage.json', 'r') as f:
coverage_data = json.load(f)
total_coverage = round(coverage_data['totals']['percent_covered'], 1)
# Create coverage summary file in cov_info directory
with open('cov_info/coverage-summary.txt', 'w') as f:
f.write(f'{total_coverage}%')
print(f'Test Coverage: {total_coverage}%')
covered_lines = coverage_data['totals']['covered_lines']
total_lines = coverage_data['totals']['num_statements']
print(f'Lines Covered: {covered_lines}/{total_lines}')
else:
print('No coverage data found')
"
# Copy other coverage files to cov_info
if [ -f coverage.json ]; then cp coverage.json cov_info/; fi
if [ -f coverage.xml ]; then cp coverage.xml cov_info/; fi
if [ -d htmlcov ]; then cp -r htmlcov cov_info/; fi
- name: Final badge status
if: always()
run: |
echo "=== FINAL BADGE STATUS ==="
echo "Test outcome: ${{ steps.pytest.outcome }}"
echo "Docs outcome: ${{ steps.docs.outcome }}"
if [ -f cov_info/coverage.svg ]; then
echo "✅ Test coverage badge: $(ls -lh cov_info/coverage.svg)"
else
echo "❌ Test coverage badge: MISSING"
fi
if [ -f cov_info/coverage-docs.svg ]; then
echo "✅ Docs coverage badge: $(ls -lh cov_info/coverage-docs.svg)"
else
echo "❌ Docs coverage badge: MISSING"
fi
echo "Coverage info directory contents:"
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory found"
- name: Upload coverage artifacts
uses: actions/upload-artifact@v3
with:
name: coverage-reports
path: |
cov_info/
- name: Commit badges to badges branch
if: github.ref == 'refs/heads/master'
run: |
git config --local user.email "action@gitea.local"
git config --local user.name "Gitea Action"
# Set the remote URL to use the token
git remote set-url origin https://${{ secrets.PUSH_TOKEN }}@gitea.tourolle.paris/dtourolle/dreader-application.git
# Create a new orphan branch for badges (this discards any existing badges branch)
git checkout --orphan badges
# Remove all files except cov_info
find . -maxdepth 1 -not -name '.git' -not -name 'cov_info' -exec rm -rf {} + 2>/dev/null || true
# Add only the coverage info directory
git add -f cov_info/
# Always commit (force overwrite)
echo "Force updating badges branch with new coverage data..."
git commit -m "Update coverage badges [skip ci]"
git push -f origin badges

75
.gitignore vendored Normal file
View File

@ -0,0 +1,75 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*/__pycache__
# Distribution / packaging
dist/
build/
*.egg-info/
# Environment
venv/
env/
.env/
.venv/
# Tests
.pytest_cache/
.coverage
htmlcov/
coverage.xml
coverage.json
.tox/
# IDE files
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# Project specific - images and outputs
*.png
*.jpg
*.jpeg
*.gif
*.svg
# But allow documentation images if any
!docs/images/*.gif
!docs/images/*.png
!docs/images/*.jpg
# Output directories
output/
my_output/
test_output/
*_output/
examples/output/
# Generated data
ereader_bookmarks/
highlights/
positions/
# Examples output
examples/ereader_bookmarks/
examples/highlights/
examples/*.png
examples/*.gif
# Keep test data
!tests/data/**
# Debug scripts output
debug_*.png
# CI/CD artifacts
cov_info/
# OS specific
.DS_Store
.fish*

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "external/dreader-hal"]
path = external/dreader-hal
url = https://gitea.tourolle.paris/dtourolle/dreader-hal

410
ACCELEROMETER_PAGE_FLIP.md Normal file
View File

@ -0,0 +1,410 @@
# Accelerometer-Based Page Flipping
This document describes the accelerometer-based page flipping feature that allows users to navigate pages by tilting the device.
## Overview
The accelerometer page flipping feature uses the BMA400 3-axis accelerometer to detect device tilt and automatically turn pages. This provides a hands-free way to read, which is useful when:
- Eating or drinking while reading
- Holding the device with one hand
- Device is mounted (e.g., on a stand)
- Accessibility needs
## Architecture
### Components
1. **Gesture Types** ([dreader/gesture.py](dreader/gesture.py:29-30))
- `TILT_FORWARD` - Tilt device forward to go to next page
- `TILT_BACKWARD` - Tilt device backward to go to previous page
2. **HAL Integration** ([dreader/hal_hardware.py](dreader/hal_hardware.py:414-563))
- `load_accelerometer_calibration()` - Loads calibration from JSON file
- `get_tilt_gesture()` - Polls accelerometer and detects tilt gestures
- Gravity direction calculation based on calibrated "up" vector
- Debouncing to prevent multiple page flips from single tilt
3. **Gesture Handlers** ([dreader/handlers/gestures.py](dreader/handlers/gestures.py:84-87))
- `TILT_FORWARD` → calls `_handle_page_forward()`
- `TILT_BACKWARD` → calls `_handle_page_back()`
- Uses same page navigation logic as swipe gestures
4. **Calibration Tool** ([examples/calibrate_accelerometer.py](examples/calibrate_accelerometer.py))
- Interactive calibration using display
- Shows live arrow pointing in gravity direction
- User rotates device until arrow points "up"
- Saves calibration to JSON file
5. **Demo Application** ([examples/demo_accelerometer_page_flip.py](examples/demo_accelerometer_page_flip.py))
- Complete integration example
- Combines touch and accelerometer gestures
- Shows how to poll both input sources
## How It Works
### Calibration
The calibration process establishes which direction is "up" for the device:
1. Run `python examples/calibrate_accelerometer.py`
2. Device displays an arrow showing gravity direction
3. Rotate device until arrow points up
4. Tap screen to save calibration
5. Calibration stored in `accelerometer_config.json`
**Calibration Data:**
```json
{
"up_vector": {
"x": 0.0,
"y": 9.8,
"z": 0.0
},
"tilt_threshold": 0.3,
"debounce_time": 0.5
}
```
### Tilt Detection Algorithm
The algorithm detects when the device is tilted beyond a threshold angle from the calibrated "up" position:
1. **Read Accelerometer**: Get (x, y, z) acceleration in m/s²
2. **Normalize Vectors**: Normalize both current gravity and calibrated up vector
3. **Calculate Tilt Angle**:
- Project gravity onto plane perpendicular to up vector
- Calculate angle using `atan2(perpendicular_magnitude, vertical_component)`
4. **Compare to Threshold**: Default 0.3 radians (~17 degrees)
5. **Determine Direction**:
- Positive perpendicular y-component → Forward tilt → Next page
- Negative perpendicular y-component → Backward tilt → Previous page
6. **Debounce**: Prevent repeated triggers within debounce time (default 0.5s)
**Math Details:**
Given:
- Up vector (calibrated): `U = (ux, uy, uz)`
- Current gravity: `G = (gx, gy, gz)`
Calculate:
```python
# Dot product: component of G along U
dot = gx*ux + gy*uy + gz*uz
# Perpendicular component
perp = G - dot*U
perp_magnitude = |perp|
# Tilt angle
angle = atan2(perp_magnitude, |dot|)
# Direction (simplified)
if perp_y > 0:
gesture = TILT_FORWARD
else:
gesture = TILT_BACKWARD
```
### Event Loop Integration
The main application event loop polls both touch and accelerometer:
```python
while running:
# Check touch events
touch_event = await hal.get_touch_event()
if touch_event:
handle_gesture(touch_event)
# Check accelerometer tilt (if calibrated)
if calibrated:
tilt_event = await hal.get_tilt_gesture()
if tilt_event:
handle_gesture(tilt_event)
await asyncio.sleep(0.05) # ~20Hz polling
```
## Usage
### 1. Calibration (One-time)
```bash
python examples/calibrate_accelerometer.py
```
This creates `accelerometer_config.json` in the current directory.
### 2. Load Calibration in Your Application
```python
from dreader.hal_hardware import HardwareDisplayHAL
# Create HAL with accelerometer enabled
hal = HardwareDisplayHAL(
width=1872,
height=1404,
enable_orientation=True # Important!
)
await hal.initialize()
# Load calibration
if hal.load_accelerometer_calibration("accelerometer_config.json"):
print("Accelerometer calibrated!")
else:
print("No calibration found - tilt gestures disabled")
```
### 3. Poll for Gestures
**Option A: Unified Event API (Recommended)**
```python
# Main event loop - simplest approach
while True:
# Get event from any source (touch or accelerometer)
event = await hal.get_event()
if event:
response = gesture_router.handle_touch(event)
# ... process response
await asyncio.sleep(0.01)
```
**Option B: Separate Polling (More Control)**
```python
# Main event loop - explicit control
while True:
# Get touch events
touch_event = await hal.get_touch_event()
# Get tilt events (returns None if not calibrated)
tilt_event = await hal.get_tilt_gesture()
# Handle events
if touch_event:
response = gesture_router.handle_touch(touch_event)
# ... process response
if tilt_event:
response = gesture_router.handle_touch(tilt_event)
# ... process response
await asyncio.sleep(0.05)
```
### 4. Run Demo
```bash
# Simple demo using unified API
python examples/demo_accelerometer_simple.py ~/Books/mybook.epub
# Full-featured demo with separate polling
python examples/demo_accelerometer_page_flip.py ~/Books/mybook.epub
```
## Configuration
### Tilt Threshold
Adjust sensitivity by changing `tilt_threshold` in the config file:
- **0.1 rad (~6°)**: Very sensitive, small tilts trigger pages
- **0.3 rad (~17°)**: Default, moderate sensitivity
- **0.5 rad (~29°)**: Less sensitive, requires larger tilt
### Debounce Time
Adjust `debounce_time` to control how quickly you can trigger repeated page flips:
- **0.2s**: Fast, can quickly flip multiple pages
- **0.5s**: Default, prevents accidental double-flips
- **1.0s**: Slow, requires deliberate pauses between flips
### Example Custom Configuration
```json
{
"up_vector": {
"x": 0.0,
"y": 9.8,
"z": 0.0
},
"tilt_threshold": 0.2,
"debounce_time": 0.3
}
```
## Testing
Run the test suite:
```bash
python -m pytest tests/test_accelerometer_gestures.py -v
```
**Tests include:**
- Calibration loading
- Tilt angle calculation (forward, backward, upright)
- Threshold detection
- Gesture type definitions
## Troubleshooting
### "Accelerometer calibration file not found"
Run the calibration script first:
```bash
python examples/calibrate_accelerometer.py
```
### Tilt gestures not working
1. Check accelerometer is enabled in HAL:
```python
hal = HardwareDisplayHAL(enable_orientation=True)
```
2. Verify calibration loaded:
```python
result = hal.load_accelerometer_calibration()
print(f"Calibrated: {result}")
```
3. Check you're polling tilt events:
```python
tilt_event = await hal.get_tilt_gesture()
```
### Tilt too sensitive / not sensitive enough
Edit `accelerometer_config.json` and adjust `tilt_threshold`:
- Lower value = more sensitive
- Higher value = less sensitive
### Pages flip too fast / too slow
Edit `accelerometer_config.json` and adjust `debounce_time`:
- Lower value = faster repeat flips
- Higher value = slower repeat flips
### Wrong direction (forward goes backward)
The tilt direction detection is device-specific. You may need to adjust the direction logic in [dreader/hal_hardware.py](dreader/hal_hardware.py:547-550):
```python
# Current logic (line 547)
if perp_y > 0:
gesture = AppGestureType.TILT_FORWARD
else:
gesture = AppGestureType.TILT_BACKWARD
# Try inverting:
if perp_y < 0: # Changed > to <
gesture = AppGestureType.TILT_FORWARD
else:
gesture = AppGestureType.TILT_BACKWARD
```
## Limitations
1. **Orientation Lock**: Tilt detection assumes fixed device orientation. Auto-rotation may interfere.
2. **Walking/Movement**: May trigger false positives when walking. Use higher threshold or disable while moving.
3. **Calibration Drift**: Accelerometer may drift over time. Re-calibrate periodically.
4. **Direction Heuristic**: Current direction detection is simplified. Complex orientations may not work correctly.
5. **Single Axis**: Only detects tilt in one plane. Doesn't distinguish left/right tilts.
## Future Improvements
- [ ] Shake gesture to open TOC/settings
- [ ] Multi-axis tilt for 4-direction navigation
- [ ] Auto-calibration on startup
- [ ] Gyroscope integration for rotation gestures
- [ ] Adaptive threshold based on reading posture
- [ ] Tilt gesture visualization for debugging
## API Reference
### HardwareDisplayHAL
#### `load_accelerometer_calibration(config_path: str = "accelerometer_config.json") -> bool`
Load accelerometer calibration from JSON file.
**Parameters:**
- `config_path`: Path to calibration JSON file
**Returns:**
- `True` if calibration loaded successfully, `False` otherwise
#### `async get_event() -> Optional[TouchEvent]`
**[Recommended]** Get the next event from any input source (touch or accelerometer).
This is a convenience method that polls both touch and accelerometer in a single call.
**Returns:**
- `TouchEvent` from either touch sensor or accelerometer
- `None` if no event available
- Touch events are prioritized over tilt events
**Example:**
```python
while running:
event = await hal.get_event()
if event:
handle_gesture(event)
await asyncio.sleep(0.01)
```
#### `async get_tilt_gesture() -> Optional[TouchEvent]`
Poll accelerometer and check for tilt gestures.
**Returns:**
- `TouchEvent` with `TILT_FORWARD` or `TILT_BACKWARD` gesture if tilt detected
- `None` if no tilt, not calibrated, or within debounce period
**Note:** Must call `load_accelerometer_calibration()` first. Consider using `get_event()` instead for simpler code.
### GestureType
#### `TILT_FORWARD = "tilt_forward"`
Gesture type for forward tilt (next page)
#### `TILT_BACKWARD = "tilt_backward"`
Gesture type for backward tilt (previous page)
### Calibration File Format
```json
{
"up_vector": {
"x": float, // X-component of gravity when upright (m/s²)
"y": float, // Y-component of gravity when upright (m/s²)
"z": float // Z-component of gravity when upright (m/s²)
},
"tilt_threshold": float, // Tilt angle threshold in radians
"debounce_time": float // Minimum time between gestures in seconds
}
```
## Examples
See the `examples/` directory for complete working examples:
- **[calibrate_accelerometer.py](examples/calibrate_accelerometer.py)** - Interactive calibration tool
- **[demo_accelerometer_simple.py](examples/demo_accelerometer_simple.py)** - Simple demo using unified `get_event()` API
- **[demo_accelerometer_page_flip.py](examples/demo_accelerometer_page_flip.py)** - Full-featured demo with separate event polling
## License
Same as the main DReader project.

552
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,552 @@
# DReader Application Architecture
## Overview
DReader is a full-featured ebook reader application built on top of [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout). It provides a complete reading experience with navigation, bookmarks, highlights, and customizable display settings.
## System Architecture
### High-Level Component Structure
```
dreader/
├── application.py # Main EbookReader class (coordinator)
├── managers/ # Specialized management modules
│ ├── document.py # Document loading (EPUB/HTML)
│ ├── settings.py # Font and spacing controls
│ └── highlight_coordinator.py # Text highlighting
├── handlers/
│ └── gestures.py # Touch event routing
├── overlays/ # UI overlay system
│ ├── base.py # Base overlay functionality
│ ├── navigation.py # TOC and bookmarks overlay
│ └── settings.py # Settings overlay
├── library.py # Library browsing and book selection
├── state.py # Application state persistence
├── html_generator.py # HTML generation for overlays
└── gesture.py # Gesture definitions and responses
```
### Relationship to pyWebLayout
**pyWebLayout** provides low-level rendering primitives:
- Text layout and rendering algorithms
- Document structure and pagination
- Query systems for interactive content
- Core rendering infrastructure
**DReader** is an application framework that:
- Combines pyWebLayout components into a complete reader
- Provides high-level APIs for common ereader tasks
- Manages application state (bookmarks, highlights, positions)
- Handles business logic for gestures and interactions
Think of it as:
- **pyWebLayout** = React (library)
- **DReader** = Next.js (framework)
## Core Components
### 1. EbookReader (Main Coordinator)
**Location**: [application.py](dreader/application.py)
The central orchestrator that coordinates all subsystems:
```python
class EbookReader:
"""Main ebook reader application"""
# Core dependencies
manager: EreaderLayoutManager # pyWebLayout layout engine
doc_manager: DocumentManager # Document loading
settings_manager: SettingsManager # Display settings
highlight_coordinator: HighlightCoordinator # Text highlighting
gesture_router: GestureRouter # Gesture handling
overlay_manager: OverlayManager # Overlay rendering
```
**Key Responsibilities**:
- Document lifecycle (load, close)
- Page navigation (next, previous, chapters)
- Bookmark management
- Position persistence
- Settings coordination
- Gesture event routing
### 2. Document Manager
**Location**: [managers/document.py](dreader/managers/document.py)
Handles document loading and metadata extraction.
**Responsibilities**:
- Load EPUB files via pyWebLayout
- Extract book metadata (title, author, etc.)
- Provide document info to other components
### 3. Settings Manager
**Location**: [managers/settings.py](dreader/managers/settings.py)
Manages all display settings with persistence.
**Settings**:
- Font scale (adjustable font size)
- Line spacing
- Inter-block spacing (paragraph spacing)
- Word spacing
**Features**:
- Real-time preview in settings overlay
- Persistent across sessions
- Position preservation when settings change
### 4. Gesture Router
**Location**: [handlers/gestures.py](dreader/handlers/gestures.py)
Routes touch events to appropriate handlers based on application state.
**Gesture Types**:
- `TAP` - Word selection, link following, overlay interaction
- `SWIPE_LEFT` - Next page
- `SWIPE_RIGHT` - Previous page
- `SWIPE_UP` - Open navigation overlay (from bottom 20%)
- `SWIPE_DOWN` - Open settings overlay (from top) or close overlay
- `PINCH_IN/OUT` - Font size adjustment
- `DRAG` - Text selection (start, move, end)
**Routing Logic**:
```
Touch Event → GestureRouter
├─ Is overlay open?
│ ├─ Yes → Route to overlay handler
│ └─ No → Route to reading mode handler
└─ Return GestureResponse
```
### 5. Overlay System
**Location**: [overlays/](dreader/overlays/)
The overlay system provides modal UI panels over the reading content.
#### Overlay Manager
**Location**: [overlays/base.py](dreader/overlays/base.py)
Core overlay rendering and compositing infrastructure.
**Responsibilities**:
- Render overlay HTML to images
- Composite overlays over base page
- Darken background for modal effect
- Handle coordinate translation for interaction
- Cache for performance
#### Navigation Overlay
**Location**: [overlays/navigation.py](dreader/overlays/navigation.py)
Unified overlay with tabbed interface for:
- **Contents Tab**: Chapter navigation (TOC)
- **Bookmarks Tab**: Saved position management
**Features**:
- Tab switching without closing overlay
- Chapter selection with jump
- Bookmark selection with jump
- Add/delete bookmarks
#### Settings Overlay
**Location**: [overlays/settings.py](dreader/overlays/settings.py)
Interactive settings panel with real-time preview.
**Controls**:
- Font size: A- / A+ buttons
- Line spacing: +/- buttons
- Block spacing: +/- buttons
- Word spacing: +/- buttons
**Interaction**: Changes apply immediately, overlay refreshes to show updated values.
### 6. Library Manager
**Location**: [library.py](dreader/library.py)
Manages the library browsing experience.
**Features**:
- Scan directory for EPUB files
- Extract and cache metadata
- Render library grid view
- Handle book selection via tap
- Cache cover images for performance
**Display**: Renders books in a grid with cover thumbnails and metadata.
### 7. State Manager
**Location**: [state.py](dreader/state.py)
Persistent application state across sessions.
**State Structure**:
```python
class AppState:
mode: EreaderMode # LIBRARY or READING
overlay: OverlayState # Current overlay type
current_book: BookState # Currently open book
library: LibraryState # Library scan cache
settings: SettingsState # Display settings
```
**Persistence**:
- Location: `~/.config/dreader/state.json`
- Auto-save every 60 seconds
- Immediate save on mode change, settings change, shutdown
- Atomic writes for safety
**Boot Behavior**:
- Resume last book at last position
- Restore all settings
- Fall back to library if book missing
## Data Flow Diagrams
### Opening an Overlay
```
User Action
EbookReader.open_navigation_overlay()
├─ Get current page (base layer)
├─ Get chapters and bookmarks
OverlayManager.open_navigation_overlay()
├─ Generate HTML
├─ Render to image (using temp reader)
├─ Composite over base page
│ ├─ Darken background
│ ├─ Add border
│ └─ Paste panel at center
└─ Cache base page, overlay, offset
Return composited image
```
### Overlay Interaction
```
User Touch (x, y)
GestureRouter.handle_touch()
├─ Overlay open? YES
EbookReader._handle_overlay_tap(x, y)
OverlayManager.query_overlay_pixel(x, y)
├─ Translate screen coords to overlay coords
├─ Query pyWebLayout for link at position
└─ Return link_target (e.g., "chapter:5")
Parse link_target and execute action:
├─ "chapter:N" → jump_to_chapter(N), close overlay
├─ "bookmark:name" → load_position(name), close overlay
├─ "setting:action" → apply setting, refresh overlay
└─ "tab:name" → switch tab, keep overlay open
Return GestureResponse
```
### State Persistence
```
Application Running
StateManager auto-save timer (every 60s)
├─ Gather current state
├─ Serialize to JSON
└─ Atomic write to disk
OR
User performs action (page turn, setting change)
├─ StateManager.save_state()
└─ Immediate write
Application Shutdown
├─ Save position: reader.save_position("__auto_resume__")
├─ Stop auto-save
└─ Final state.json write
```
### Boot Sequence
```
Application Start
StateManager.load_state()
├─ Read state.json
├─ Validate and parse
└─ Create AppState object
Check previous mode:
├─ READING mode?
│ ├─ Load last book
│ ├─ Apply saved settings
│ └─ Restore position ("__auto_resume__")
└─ LIBRARY mode?
└─ Show library grid
```
## File Organization
### Application State Files
```
~/.config/dreader/
├── state.json # Application state
├── covers/ # Cached book covers
│ └── {book_id}.png
├── bookmarks/ # Per-book bookmarks
│ └── {document_id}_{bookmark_name}.json
└── highlights/ # Per-book highlights
└── {document_id}_highlights.json
```
### Bookmark Format
Each book's position is stored separately using document ID:
```json
{
"document_id": "book123",
"bookmark_name": "__auto_resume__",
"position": {
"offset": 1234,
"chapter": 5
},
"timestamp": "2025-11-09T10:30:00Z"
}
```
## Gesture Handling
### Gesture Priority and Routing
```
Touch Event
Is overlay open?
├─ YES → Overlay Mode
│ ├─ TAP → Handle overlay interaction
│ ├─ SWIPE_DOWN → Close overlay
│ └─ Other → Ignore (modal behavior)
└─ NO → Reading Mode
├─ TAP
│ ├─ On link → Follow link
│ ├─ On word → Select word
│ ├─ Left edge → Previous page
│ └─ Right edge → Next page
├─ SWIPE
│ ├─ LEFT → Next page
│ ├─ RIGHT → Previous page
│ ├─ UP (from bottom 20%) → Open navigation
│ └─ DOWN (from top 20%) → Open settings
├─ PINCH
│ ├─ IN → Decrease font size
│ └─ OUT → Increase font size
└─ DRAG
├─ START → Begin text selection
├─ MOVE → Extend selection
└─ END → Complete selection
```
### Response Types
```python
class ActionType(Enum):
NONE = "none"
PAGE_TURN = "page_turn"
WORD_SELECTED = "word_selected"
LINK_FOLLOWED = "link_followed"
CHAPTER_SELECTED = "chapter_selected"
BOOKMARK_SELECTED = "bookmark_selected"
SETTING_CHANGED = "setting_changed"
OVERLAY_OPENED = "overlay_opened"
OVERLAY_CLOSED = "overlay_closed"
TAB_SWITCHED = "tab_switched"
```
## Performance Characteristics
### Rendering Performance
- **Page Turn**: ~50-100ms (depends on page complexity)
- **Overlay Open**: ~200-250ms (includes HTML generation and rendering)
- **Tab Switch**: ~125ms (uses cached base page)
- **Setting Change**: ~150ms (re-render with new settings)
- **Tap Interaction**: ~5-10ms (coordinate query)
### Memory Usage
- **Base Application**: ~20-30MB
- **Per Book**: ~10-50MB (depends on images)
- **Overlay Cache**: ~5-10MB
### Optimization Strategies
1. **Caching**:
- Base page cached during overlay display
- Overlay panel cached for tab switching
- Cover images cached to disk
- Metadata cached between sessions
2. **Lazy Loading**:
- Library covers loaded on-demand
- Book content loaded only when opened
- Overlays rendered only when needed
3. **Efficient Updates**:
- Tab switching reuses base page
- Setting changes use incremental rendering
- Position saves are debounced
## Extension Points
### Adding New Overlays
To add a new overlay type:
1. Define new `OverlayState` enum value in [state.py](dreader/state.py#L27-L33)
2. Create HTML generator in [html_generator.py](dreader/html_generator.py)
3. Add overlay class in `overlays/` directory
4. Implement open/close methods in [overlay manager](dreader/overlays/base.py)
5. Add gesture handling in [application.py](dreader/application.py)
### Custom Gesture Handlers
To add custom gestures:
1. Define gesture type in [gesture.py](dreader/gesture.py)
2. Add handler in [gestures.py](dreader/handlers/gestures.py)
3. Define action type for response
4. Update gesture router logic
### HAL Integration
To integrate with hardware:
Create a display abstraction layer implementing:
```python
class DisplayHAL(ABC):
@abstractmethod
def show_image(self, image: Image.Image):
"""Display image on hardware"""
@abstractmethod
def get_touch_events(self) -> Iterator[TouchEvent]:
"""Get touch input from hardware"""
@abstractmethod
def set_brightness(self, level: int):
"""Control display brightness"""
```
Examples:
- **E-ink**: IT8951, Remarkable device SDK
- **Desktop**: pygame, tkinter
- **Web**: Flask + HTML canvas
- **Qt**: QPixmap + QTouchEvent
## Testing Strategy
### Unit Tests
- State serialization and persistence
- Gesture routing logic
- Coordinate translation
- HTML generation
### Integration Tests
- Mode transitions (LIBRARY ↔ READING)
- Overlay lifecycle (open → interact → close)
- Boot recovery and resume
- Settings persistence
### Example-Based Testing
Working examples demonstrate full integration:
- [simple_ereader_example.py](examples/simple_ereader_example.py)
- [library_reading_integration.py](examples/library_reading_integration.py)
- [navigation_overlay_example.py](examples/navigation_overlay_example.py)
- [demo_settings_overlay.py](examples/demo_settings_overlay.py)
## Design Patterns
### Component-Based Architecture
- **Managers**: Single-responsibility modules for specific tasks
- **Handlers**: Event routing and processing
- **Overlays**: Self-contained UI components
### Delegation Over Inheritance
- EbookReader delegates to specialized managers
- No deep inheritance hierarchies
- Composition for flexibility
### State Machine Pattern
- Clear state transitions (modes, overlays)
- State persistence for resume
- Predictable behavior
### Event-Driven Architecture
- Touch events drive all interactions
- Response objects communicate results
- Decoupled components
## Future Architecture Considerations
### Sub-Application Pattern
Current overlay handling uses a monolithic approach. Future refactoring could extract overlays into sub-applications:
```python
class OverlaySubApplication(ABC):
def open(self, context: OverlayContext) -> Image.Image: ...
def handle_tap(self, x: int, y: int) -> GestureResponse: ...
def close(self) -> Image.Image: ...
```
Benefits:
- Self-contained overlay logic
- Easier testing
- Plugin support
- Composable overlays
### Plugin System
Enable third-party extensions:
- Custom overlay types
- Additional gestures
- Export formats
- Cloud sync providers
## References
- [pyWebLayout Documentation](https://gitea.tourolle.paris/dtourolle/pyWebLayout)
- [REQUIREMENTS.md](REQUIREMENTS.md) - Detailed feature specifications
- [README.md](README.md) - User-facing documentation
- [examples/](examples/) - Working code examples

286
GPIO_BUTTONS.md Normal file
View File

@ -0,0 +1,286 @@
# GPIO Button Configuration Guide
This guide explains how to configure physical buttons for the DReader e-reader.
## Overview
Physical buttons provide tactile feedback for page turns and navigation without requiring touch input. Buttons are connected between GPIO pins and GND, using internal pull-up resistors.
## Hardware Setup
### Basic Button Wiring
```
+3.3V
|
R (internal pull-up)
|
GPIO --|------ Button ------ GND
|
(to BCM2835)
```
When the button is pressed, it connects the GPIO pin to GND (0V), pulling the pin LOW.
### Recommended Button Layout
For this e-reader device with 3 buttons:
```
┌─────────────────────┐
│ │
│ [Power Off] │ ← Side button (GPIO 21)
│ │
│ │
│ E-INK │
│ DISPLAY │
│ 1872x1404 │
│ │
│ [Prev] [Next] │ ← Bottom edge
│ │
└─────────────────────┘
```
**Button Mapping:**
- **Previous Page** (GPIO 22) - Bottom left - Previous page
- **Next Page** (GPIO 27) - Bottom right - Next page
- **Power Off** (GPIO 21) - Side button - Shutdown device (long press)
## Software Configuration
### Using Interactive Setup (Recommended)
```bash
sudo python3 setup_rpi.py
```
The setup script will:
1. Ask if you want GPIO buttons enabled
2. Let you configure each button individually
3. Allow custom GPIO pin assignments
4. Generate hardware_config.json automatically
### Manual Configuration
Edit `hardware_config.json`:
```json
{
"gpio_buttons": {
"enabled": true,
"pull_up": true,
"bounce_time_ms": 200,
"buttons": [
{
"name": "prev_page",
"gpio": 22,
"gesture": "swipe_right",
"description": "Previous page button"
},
{
"name": "next_page",
"gpio": 27,
"gesture": "swipe_left",
"description": "Next page button"
},
{
"name": "power_off",
"gpio": 21,
"gesture": "long_press",
"description": "Power off button (long press to shutdown)"
}
]
}
}
```
### Configuration Options
- **enabled** (bool): Enable/disable all GPIO buttons
- **pull_up** (bool): Use internal pull-up resistors (always true for button-to-GND wiring)
- **bounce_time_ms** (int): Debounce time in milliseconds (default 200ms)
- **buttons** (array): List of button configurations
### Button Configuration
Each button has:
- **name** (string): Unique identifier for the button
- **gpio** (int): BCM GPIO pin number (2-27)
- **gesture** (string): Gesture type to generate when pressed
- **description** (string): Human-readable description
### Available Gestures
Buttons can trigger any gesture type:
| Gesture | Description | Typical Use |
|---------|-------------|-------------|
| `swipe_left` | Swipe left | Next page |
| `swipe_right` | Swipe right | Previous page |
| `swipe_up` | Swipe up from bottom | Open navigation/TOC |
| `swipe_down` | Swipe down from top | Open settings |
| `tap` | Single tap | Select item |
| `long_press` | Hold | Context menu |
| `pinch_in` | Pinch zoom out | Decrease font size |
| `pinch_out` | Pinch zoom in | Increase font size |
## GPIO Pin Selection
### Safe GPIO Pins (BCM numbering)
**Recommended for buttons:**
- GPIO 5, 6, 12, 13, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27
**Avoid these pins:**
- GPIO 2, 3 - I2C (SDA, SCL) - Used for touch, sensors
- GPIO 7-11 - SPI - Used for e-ink display
- GPIO 14, 15 - UART - Used for serial console
- GPIO 0, 1 - Reserved for ID EEPROM
### Pin Layout (BCM Mode)
```
3V3 (1) (2) 5V
GPIO2 (3) (4) 5V GPIO 2,3 = I2C
GPIO3 (5) (6) GND
GPIO4 (7) (8) GPIO14 GPIO 7-11 = SPI
GND (9) (10) GPIO15 GPIO 14,15 = UART
GPIO17 (11) (12) GPIO18
GPIO27 (13) (14) GND
GPIO22 (15) (16) GPIO23 ← Good for buttons
3V3 (17) (18) GPIO24 ← Good for buttons
GPIO10 (19) (20) GND
GPIO9 (21) (22) GPIO25 ← Good for buttons
GPIO11 (23) (24) GPIO8
GND (25) (26) GPIO7
GPIO0 (27) (28) GPIO1
GPIO5 (29) (30) GND ← Good for buttons
GPIO6 (31) (32) GPIO12 ← Good for buttons
GPIO13 (33) (34) GND ← Good for buttons
GPIO19 (35) (36) GPIO16 ← Good for buttons
GPIO26 (37) (38) GPIO20 ← Good for buttons
GND (39) (40) GPIO21 ← Good for buttons
```
## Testing Buttons
### Test Button Connections
```bash
# Install GPIO utilities
sudo apt install gpiod
# Monitor GPIO 23 (next page button)
gpioget gpiochip0 23
# Press button - should show 0 (LOW)
# Release button - should show 1 (HIGH with pull-up)
```
### Test in Application
Run with verbose logging to see button events:
```bash
python examples/run_on_hardware_config.py --verbose
```
Press each button and verify you see log messages like:
```
Button pressed: next_page (GPIO 23)
Button event queued: next_page -> swipe_left
```
## Troubleshooting
### Buttons Not Working
**Problem:** Buttons don't generate events
**Solutions:**
1. Check wiring - button should connect GPIO to GND
2. Verify GPIO pin number in config (BCM mode, not physical pin)
3. Check permissions: `sudo usermod -a -G gpio $USER` then log out/in
4. Test GPIO with `gpioget` (see above)
5. Check logs: `python examples/run_on_hardware_config.py --verbose`
### False Triggers
**Problem:** Button triggers multiple times from single press
**Solutions:**
1. Increase `bounce_time_ms` in config (try 300-500ms)
2. Add hardware debounce capacitor (0.1µF between GPIO and GND)
3. Check for loose connections
### Wrong Action
**Problem:** Button does wrong action
**Solutions:**
1. Check `gesture` field in button config
2. Verify button name matches intended function
3. Check logs to see what gesture is generated
## Advanced: Custom Button Functions
You can map buttons to any gesture, creating custom layouts:
### Example: Reading Mode Buttons
```json
{
"gpio_buttons": {
"enabled": true,
"buttons": [
{"name": "next", "gpio": 23, "gesture": "swipe_left"},
{"name": "prev", "gpio": 24, "gesture": "swipe_right"},
{"name": "zoom_in", "gpio": 25, "gesture": "pinch_out"},
{"name": "zoom_out", "gpio": 22, "gesture": "pinch_in"}
]
}
}
```
### Example: Simple 2-Button Layout
```json
{
"gpio_buttons": {
"enabled": true,
"buttons": [
{"name": "next", "gpio": 23, "gesture": "swipe_left"},
{"name": "prev", "gpio": 24, "gesture": "swipe_right"}
]
}
}
```
## Hardware Tips
### Button Quality
- Use momentary pushbuttons (normally open)
- Tactile switches provide good feedback
- Rated for at least 10,000 cycles
- Consider waterproof buttons for outdoor use
### Mounting
- Mount buttons accessible from device edge
- Label buttons for user convenience
- Consider button guards to prevent accidental presses
- Use hot glue or button caps for secure mounting
### Wiring
- Keep wires short to reduce noise
- Use stranded wire for flexibility
- Consider using a ribbon cable for clean routing
- Add strain relief at connection points
## See Also
- [HARDWARE_SETUP.md](HARDWARE_SETUP.md) - Complete hardware integration guide
- [hardware_config.json](hardware_config.json) - Example configuration
- [dreader/gpio_buttons.py](dreader/gpio_buttons.py) - Button handler source code

1303
HAL_IMPLEMENTATION_SPEC.md Normal file

File diff suppressed because it is too large Load Diff

230
HARDWARE_PINOUT.md Executable file
View File

@ -0,0 +1,230 @@
# DReader Hardware Pinout Reference
Quick reference for the DReader e-ink device hardware configuration.
## Display Specifications
- **Resolution:** 1872 × 1404 pixels
- **Controller:** IT8951 (SPI)
- **Touch Panel:** FT5316 (I2C)
## GPIO Pin Assignments
### Buttons (BCM Numbering)
| GPIO | Function | Action | Notes |
|------|----------|--------|-------|
| 21 | Power Off | Long Press (500ms+) | Shutdown button |
| 22 | Previous Page | Swipe Right | Left button |
| 27 | Next Page | Swipe Left | Right button |
**Wiring:** All buttons connect between GPIO and GND (pull-up resistors enabled in software)
### SPI (IT8951 E-ink Display)
| GPIO | Function | Pin |
|------|----------|-----|
| 8 | SPI0 CE0 | 24 |
| 9 | SPI0 MISO | 21 |
| 10 | SPI0 MOSI | 19 |
| 11 | SPI0 SCLK | 23 |
| 17 | RST | 11 |
| 24 | HRDY | 18 |
### I2C (Touch, Sensors, RTC, Power Monitor)
| GPIO | Function | Pin | Devices |
|------|----------|-----|---------|
| 2 | I2C1 SDA | 3 | FT5316 (0x38), BMA400 (0x14), PCF8523 (0x68), INA219 (0x40) |
| 3 | I2C1 SCL | 5 | All I2C devices |
**Note:** I2C bus is shared by all I2C devices. Each device has a unique address.
## I2C Device Addresses
| Address | Device | Description |
|---------|--------|-------------|
| 0x38 | FT5316 | Capacitive touch panel |
| 0x14 | BMA400 | 3-axis accelerometer (optional) |
| 0x68 | PCF8523 | Real-time clock (optional) |
| 0x40 | INA219 | Power monitor (optional) |
## Physical Layout
```
Raspberry Pi GPIO Header (BCM Numbering)
3V3 (1) (2) 5V
GPIO2 (3) (4) 5V ← I2C1 SDA (touch, sensors)
GPIO3 (5) (6) GND ← I2C1 SCL
GPIO4 (7) (8) GPIO14
GND (9) (10) GPIO15
GPIO17 (11) (12) GPIO18 ← Display RST
GPIO27 (13) (14) GND
GPIO22 (15) (16) GPIO23
3V3 (17) (18) GPIO24 ← Display HRDY
GPIO10 (19) (20) GND ← SPI0 MOSI
GPIO9 (21) (22) GPIO25
GPIO11 (23) (24) GPIO8 ← SPI0 SCLK, CE0
GND (25) (26) GPIO7
GPIO0 (27) (28) GPIO1
GPIO5 (29) (30) GND
GPIO6 (31) (32) GPIO12
GPIO13 (33) (34) GND
GPIO19 (35) (36) GPIO16
GPIO26 (37) (38) GPIO20
GND (39) (40) GPIO21 ← Power off button
Button Connections:
GPIO21 ──┤ ├── GND (Power off)
GPIO22 ──┤ ├── GND (Previous page)
GPIO27 ──┤ ├── GND (Next page)
```
## Power Requirements
- **Input:** 5V via USB-C or GPIO header
- **Display:** ~3.3V, peak 500mA during refresh
- **Touch Panel:** 3.3V, ~20mA
- **Total (active):** ~1-2W
- **Total (sleep):** ~50-100mW
## Configuration Files
### hardware_config.json
```json
{
"display": {
"width": 1872,
"height": 1404,
"vcom": -2.0,
"spi_hz": 24000000
},
"gpio_buttons": {
"enabled": true,
"buttons": [
{"name": "prev_page", "gpio": 22, "gesture": "swipe_right"},
{"name": "next_page", "gpio": 27, "gesture": "swipe_left"},
{"name": "power_off", "gpio": 21, "gesture": "long_press"}
]
}
}
```
## Testing Connections
### Check I2C Devices
```bash
# Scan I2C bus 1 (GPIO 2/3)
i2cdetect -y 1
# Expected output:
# 0 1 2 3 4 5 6 7 8 9 a b c d e f
# 00: -- -- -- -- -- -- -- -- -- -- -- -- --
# 10: -- -- -- -- 14 -- -- -- -- -- -- -- -- -- -- --
# 20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
# 30: -- -- -- -- -- -- -- -- 38 -- -- -- -- -- -- --
# 40: 40 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
# 50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
# 60: -- -- -- -- -- -- -- -- 68 -- -- -- -- -- -- --
# 70: -- -- -- -- -- -- -- --
```
### Check SPI
```bash
ls /dev/spi*
# Should show: /dev/spidev0.0 /dev/spidev0.1
```
### Test GPIO Buttons
```bash
# Install GPIO tools
sudo apt install gpiod
# Test previous page button (GPIO 22)
gpioget gpiochip0 22
# Press button: shows 0 (LOW)
# Release button: shows 1 (HIGH, pulled up)
# Test next page button (GPIO 27)
gpioget gpiochip0 27
# Test power button (GPIO 21)
gpioget gpiochip0 21
```
## Quick Start
```bash
# 1. Clone and install
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
cd dreader-application
python3 -m venv venv
source venv/bin/activate
pip install -e .
./install_hardware_drivers.sh
# 2. Run interactive setup
sudo python3 setup_rpi.py
# 3. Run DReader
python examples/run_on_hardware_config.py
```
## Troubleshooting
### No I2C Devices Detected
```bash
# Enable I2C
sudo raspi-config
# Navigate to: Interface Options -> I2C -> Enable
# Check I2C is loaded
lsmod | grep i2c
# Should show: i2c_dev, i2c_bcm2835
# Add user to i2c group
sudo usermod -a -G i2c $USER
# Log out and back in
```
### SPI Not Working
```bash
# Enable SPI
sudo raspi-config
# Navigate to: Interface Options -> SPI -> Enable
# Check SPI devices
ls -l /dev/spi*
# Add user to spi group
sudo usermod -a -G spi $USER
```
### Buttons Not Responding
```bash
# Add user to gpio group
sudo usermod -a -G gpio $USER
# Test button with direct GPIO access
sudo gpioget gpiochip0 22 # Prev button
sudo gpioget gpiochip0 27 # Next button
sudo gpioget gpiochip0 21 # Power button
# Check for conflicts
# Make sure no other programs are using these GPIOs
```
## See Also
- [HARDWARE_SETUP.md](HARDWARE_SETUP.md) - Complete setup guide
- [GPIO_BUTTONS.md](GPIO_BUTTONS.md) - Button configuration reference
- [hardware_config.json](hardware_config.json) - Hardware configuration file

472
HARDWARE_SETUP.md Normal file
View File

@ -0,0 +1,472 @@
# Hardware Integration Guide
This guide explains how to run DReader on real e-ink hardware using the dreader-hal library.
## Quick Start
**On Raspberry Pi:**
```bash
# 1. Clone and setup
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
cd dreader-application
python3 -m venv venv
source venv/bin/activate
# 2. Install everything
pip install -e .
./install_hardware_drivers.sh
# 3. Run interactive setup (detects hardware and configures)
sudo python3 setup_rpi.py
# 4. Run DReader (uses hardware_config.json)
python examples/run_on_hardware_config.py
```
## Overview
The DReader application uses a plugin-based Hardware Abstraction Layer (HAL) architecture. You can choose different HAL implementations:
- **PygameDisplayHAL** ([dreader/hal_pygame.py](dreader/hal_pygame.py)) - Desktop testing with pygame window
- **HardwareDisplayHAL** ([dreader/hal_hardware.py](dreader/hal_hardware.py)) - Real e-ink hardware via dreader-hal
### Configuration Files
- **hardware_config.json** - Hardware configuration (display, buttons, sensors)
- **accelerometer_config.json** - Accelerometer calibration for tilt gestures
## Hardware Requirements
### Required Components
- **Raspberry Pi** (or compatible SBC)
- **IT8951 E-ink Display Controller** (1872×1404 resolution)
- **FT5316 Capacitive Touch Panel**
### Optional Components
- **BMA400 Accelerometer** - Auto-rotation based on device orientation
- **PCF8523 RTC** - Timekeeping with battery backup
- **INA219 Power Monitor** - Battery level monitoring
## Software Installation
### 1. Install System Dependencies
On Raspberry Pi:
```bash
# Update package list
sudo apt update
# Install system dependencies
sudo apt install -y python3-dev python3-pip python3-venv
sudo apt install -y i2c-tools python3-smbus
# Enable I2C and SPI interfaces
sudo raspi-config
# Navigate to: Interface Options -> Enable I2C and SPI
```
### 2. Clone and Set Up DReader Application
```bash
# Clone the application
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
cd dreader-application
# Initialize and update submodules (includes dreader-hal)
git submodule update --init --recursive
# Create virtual environment
python3 -m venv venv
source venv/bin/activate
# Install dreader-application
pip install -e .
```
### 3. Install dreader-hal and Dependencies
The dreader-hal library has external driver dependencies in its `external/` directory:
```bash
# Install dreader-hal in editable mode
pip install -e external/dreader-hal
# Install external driver libraries
cd external/dreader-hal/external
# Install each driver
pip install -e IT8951
pip install -e PyFTtxx6
pip install -e PyBMA400
pip install -e PyPCF8523
pip install -e pi_ina219
cd ../../.. # Back to dreader-application root
```
### 4. Install Raspberry Pi GPIO (if on RPi)
```bash
pip install RPi.GPIO spidev
```
## Hardware Wiring
### IT8951 E-ink Display (SPI)
| IT8951 Pin | Raspberry Pi Pin | Description |
|------------|------------------|-------------|
| VCC | 3.3V (Pin 1) | Power supply |
| GND | GND (Pin 6) | Ground |
| MISO | GPIO 9 (Pin 21) | SPI MISO |
| MOSI | GPIO 10 (Pin 19) | SPI MOSI |
| SCK | GPIO 11 (Pin 23) | SPI Clock |
| CS | GPIO 8 (Pin 24) | SPI Chip Select |
| RST | GPIO 17 (Pin 11) | Reset |
| HRDY | GPIO 24 (Pin 18) | Ready signal |
### FT5316 Touch Panel (I2C)
| FT5316 Pin | Raspberry Pi Pin | Description |
|------------|------------------|-------------|
| VCC | 3.3V (Pin 1) | Power supply |
| GND | GND (Pin 6) | Ground |
| SDA | GPIO 2 (Pin 3) | I2C Data |
| SCL | GPIO 3 (Pin 5) | I2C Clock |
| INT | GPIO 27 (Pin 13) | Interrupt (optional) |
### BMA400 Accelerometer (I2C) - Optional
| BMA400 Pin | Raspberry Pi Pin | Description |
|------------|------------------|-------------|
| VCC | 3.3V (Pin 1) | Power supply |
| GND | GND (Pin 6) | Ground |
| SDA | GPIO 2 (Pin 3) | I2C Data |
| SCL | GPIO 3 (Pin 5) | I2C Clock |
| I2C Address | 0x14 or 0x15 | Check your module |
### PCF8523 RTC (I2C) - Optional
| PCF8523 Pin | Raspberry Pi Pin | Description |
|------------|------------------|-------------|
| VCC | 3.3V (Pin 1) | Power supply |
| GND | GND (Pin 6) | Ground |
| SDA | GPIO 2 (Pin 3) | I2C Data |
| SCL | GPIO 3 (Pin 5) | I2C Clock |
| BAT | CR2032 Battery | Backup battery |
### INA219 Power Monitor (I2C) - Optional
| INA219 Pin | Raspberry Pi Pin | Description |
|------------|------------------|-------------|
| VCC | 3.3V (Pin 1) | Power supply |
| GND | GND (Pin 6) | Ground |
| SDA | GPIO 2 (Pin 3) | I2C Data |
| SCL | GPIO 3 (Pin 5) | I2C Clock |
| VIN+ | Battery + | Positive voltage sense |
| VIN- | Shunt resistor | Through shunt to load |
**Note**: Multiple I2C devices can share the same SDA/SCL pins. Ensure each has a unique I2C address.
### GPIO Buttons (Optional)
Physical buttons for navigation:
| Button Function | GPIO Pin | Connection |
|----------------|----------|------------|
| Previous Page | GPIO 22 | Button between GPIO 22 and GND |
| Next Page | GPIO 27 | Button between GPIO 27 and GND |
| Power Off | GPIO 21 | Button between GPIO 21 and GND |
**Wiring:**
- Connect one side of button to GPIO pin
- Connect other side to GND
- Internal pull-up resistors are enabled in software
- Button press pulls GPIO LOW (0V)
**Available GPIOs** (BCM numbering):
- Safe to use: 5-27 (except 14, 15 if using UART)
- Avoid: GPIO 2, 3 (I2C), GPIO 7-11 (SPI), GPIO 14, 15 (UART)
## Verify Hardware Connections
### Check I2C Devices
```bash
# Scan I2C bus
i2cdetect -y 1
# Expected addresses (approximate):
# 0x38 - FT5316 touch panel
# 0x14 - BMA400 accelerometer
# 0x68 - PCF8523 RTC
# 0x40 - INA219 power monitor
```
### Check SPI
```bash
# List SPI devices
ls /dev/spi*
# Should show: /dev/spidev0.0 /dev/spidev0.1
```
## Important: VCOM Voltage
⚠️ **CRITICAL**: Each e-ink display has a unique VCOM voltage printed on a label (usually on the back).
- Check your display label for VCOM voltage (e.g., -2.06V, -1.98V, etc.)
- Pass this value to the HAL using the `--vcom` parameter
- Using incorrect VCOM can damage your display!
Example from label: `VCOM = -2.06V` → use `--vcom -2.06`
## Running on Hardware
### Recommended Method: Interactive Setup
The easiest way to get started is using the interactive setup script:
```bash
# 1. Run setup (detects hardware, configures GPIO buttons, etc.)
sudo python3 setup_rpi.py
# 2. Run DReader using generated config
python examples/run_on_hardware_config.py
```
The setup script will:
- Detect connected I2C devices (touch, accelerometer, RTC, power monitor)
- Enable I2C/SPI interfaces if needed
- Configure GPIO button mappings
- Set VCOM voltage
- Generate hardware_config.json
### Manual Configuration
Edit `hardware_config.json` to customize your hardware setup:
```json
{
"display": {
"width": 1872,
"height": 1404,
"vcom": -2.06
},
"gpio_buttons": {
"enabled": true,
"buttons": [
{"name": "prev_page", "gpio": 22, "gesture": "swipe_right"},
{"name": "next_page", "gpio": 27, "gesture": "swipe_left"},
{"name": "power_off", "gpio": 21, "gesture": "long_press"}
]
}
}
```
Then run:
```bash
python examples/run_on_hardware_config.py
```
### Direct Command Line (No Config File)
```bash
# Activate virtual environment
source venv/bin/activate
# Run with correct VCOM voltage (CHECK YOUR DISPLAY LABEL!)
python examples/run_on_hardware.py /path/to/books --vcom -2.06
```
### Testing Without Hardware (Virtual Display)
You can test the integration on your development machine using virtual display mode:
```bash
python examples/run_on_hardware.py /path/to/books \
--virtual \
--no-orientation \
--no-rtc \
--no-power
```
This creates a Tkinter window simulating the e-ink display.
### Advanced Options
```bash
# Disable optional hardware components
python examples/run_on_hardware.py /path/to/books \
--vcom -2.06 \
--no-orientation \ # Disable accelerometer
--no-rtc \ # Disable RTC
--no-power # Disable battery monitor
# Show battery level periodically
python examples/run_on_hardware.py /path/to/books \
--vcom -2.06 \
--show-battery
# Custom battery capacity
python examples/run_on_hardware.py /path/to/books \
--vcom -2.06 \
--battery-capacity 5000 # mAh
# Enable verbose debug logging
python examples/run_on_hardware.py /path/to/books \
--vcom -2.06 \
--verbose
# Always start in library mode (ignore saved state)
python examples/run_on_hardware.py /path/to/books \
--vcom -2.06 \
--force-library
```
### Full Options Reference
```bash
python examples/run_on_hardware.py --help
```
## Touch Gestures
Once running, the following touch gestures are supported:
| Gesture | Action |
|---------|--------|
| **Swipe Left** | Next page |
| **Swipe Right** | Previous page |
| **Swipe Up** (from bottom) | Open navigation/TOC overlay |
| **Swipe Down** (from top) | Open settings overlay |
| **Tap** | Select book, word, or link |
| **Long Press** | Show word definition (if implemented) |
| **Pinch In/Out** | Adjust font size |
## Troubleshooting
### Display Not Working
1. **Check VCOM voltage** - Must match label on display
2. **Check SPI connections** - Run `ls /dev/spi*`
3. **Check SPI permissions** - Add user to `spi` group: `sudo usermod -a -G spi $USER`
4. **Try virtual display mode** - Test software without hardware
### Touch Not Working
1. **Check I2C connections** - Run `i2cdetect -y 1`
2. **Check I2C permissions** - Add user to `i2c` group: `sudo usermod -a -G i2c $USER`
3. **Check touch panel I2C address** - Should be 0x38 for FT5316
4. **Calibrate touch** - See dreader-hal calibration docs
### Import Errors
If you see `ModuleNotFoundError` for drivers:
```bash
# Ensure all external drivers are installed
cd external/dreader-hal/external
for dir in */; do pip install -e "$dir"; done
cd ../../..
```
### Permission Errors
```bash
# Add user to required groups
sudo usermod -a -G spi,i2c,gpio $USER
# Log out and back in for changes to take effect
```
### Display Ghosting
E-ink displays can show ghosting (image retention). The HAL automatically performs full refreshes every 10 page turns, but you can force one:
- The `RefreshMode.FULL` is automatically triggered periodically
- Check dreader-hal documentation for manual refresh control
## Programmatic Usage
You can also use the hardware HAL programmatically in your own scripts:
```python
import asyncio
from dreader.hal_hardware import HardwareDisplayHAL
from dreader.main import DReaderApplication, AppConfig
async def main():
# Create hardware HAL
hal = HardwareDisplayHAL(
width=1872,
height=1404,
vcom=-2.06, # YOUR DISPLAY'S VCOM!
virtual_display=False,
enable_orientation=True,
enable_rtc=True,
enable_power_monitor=True,
)
# Create application config
config = AppConfig(
display_hal=hal,
library_path="/home/pi/Books",
page_size=(1872, 1404),
)
# Create and run application
app = DReaderApplication(config)
try:
await hal.initialize()
await app.start()
# Main event loop
while app.is_running():
event = await hal.get_touch_event()
if event:
await app.handle_touch(event)
await asyncio.sleep(0.01)
finally:
await app.shutdown()
await hal.cleanup()
if __name__ == '__main__':
asyncio.run(main())
```
## Performance Notes
### E-ink Refresh Modes
The dreader-hal library automatically selects refresh modes:
- **Fast (DU mode)**: ~200ms - Used for text updates
- **Quality (GC16 mode)**: ~1000ms - Used for images
- **Full (INIT mode)**: ~1000ms - Used every 10 pages to clear ghosting
### Battery Life
With default settings:
- Active reading: ~10-20 hours
- Standby (display sleeping): ~1-2 weeks
- Enable low power mode for extended battery life
### Memory Usage
- Base application: ~30-50MB
- Per book: ~10-30MB (depends on book size)
- Ensure Raspberry Pi has at least 512MB RAM
## Next Steps
- See [README.md](README.md) for application features
- See [external/dreader-hal/README.md](external/dreader-hal/README.md) for HAL details
- See [examples/](examples/) for more usage examples
- Check dreader-hal documentation for touch calibration and advanced features
## Support
For hardware-specific issues, check:
- [dreader-hal issues](https://gitea.tourolle.paris/dtourolle/dreader-hal/issues)
For application issues, check:
- [dreader-application issues](https://gitea.tourolle.paris/dtourolle/dreader-application/issues)

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 pyWebLayout Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

571
README.md Normal file
View File

@ -0,0 +1,571 @@
# DReader Application
## Project Status
| Badge | Description |
|-------|-------------|
| ![Test Coverage](https://gitea.tourolle.paris/dtourolle/dreader-application/raw/branch/badges/cov_info/coverage.svg) | **Test Coverage** - Percentage of code covered by unit tests |
| ![Documentation Coverage](https://gitea.tourolle.paris/dtourolle/dreader-application/raw/branch/badges/cov_info/coverage-docs.svg) | **Documentation Coverage** - Percentage of code with docstrings |
| ![License](https://img.shields.io/badge/license-MIT-blue.svg) | **License** - Project licensing information |
## Description
DReader Application is a complete, production-ready ebook reader built on [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout). It demonstrates how to build a full-featured ebook reader with library browsing, text highlighting, bookmarks, gesture support, overlays, and position persistence.
This project serves as both a reference implementation and a ready-to-use ereader library for building desktop, web-based, or embedded reading applications.
## Key Features
### Core Reading Features
- 📖 **EPUB Support** - Load and render EPUB files with full text extraction
- 📚 **Library Management** - Browse and select books from your collection
- 📄 **Page Rendering** - Render pages as PIL Images optimized for any display
- ⬅️➡️ **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
- 📋 **Unified Overlays** - Navigation (TOC + Bookmarks) and Settings overlays
- 📊 **Progress Tracking** - Real-time reading progress percentage
### Text Interaction
- 🎨 **Text Highlighting** - Highlight words and text selections with custom colors
- 💡 **Highlight Notes** - Attach notes and annotations to highlights
- 🔍 **Text Selection** - Select words or ranges via pixel coordinates
- 👆 **Gesture Support** - Handle tap, swipe, pinch, long-press events
- 🎯 **Pixel Queries** - Query text content at any screen position
### Customization & Display
- 🔤 **Font Control** - Dynamically adjust font size with live preview
- 📏 **Spacing Control** - Customize line and paragraph spacing
- 💾 **Position Persistence** - Stable positions across style changes
- ⚡ **Smart Reflow** - Automatic text reflow on font/spacing changes
- 🎨 **Custom Styling** - Full control over colors, fonts, and layout
- 💾 **Settings Persistence** - Save and restore preferences across sessions
## Installation
```bash
# Clone the repository
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
cd dreader-application
# Install in development mode
pip install -e .
# Or install with dev dependencies
pip install -e ".[dev]"
```
The pyWebLayout dependency will be automatically installed from the git repository.
## DReader in Action
Here are animated demonstrations of the key features:
<table>
<tr>
<td align="center">
<b>Page Navigation</b><br>
<img src="docs/images/ereader_page_navigation.gif" width="300" alt="Page Navigation"><br>
<em>Forward and backward navigation through pages</em>
</td>
<td align="center">
<b>Font Size Adjustment</b><br>
<img src="docs/images/ereader_font_size.gif" width="300" alt="Font Size"><br>
<em>Dynamic font size scaling with position preservation</em>
</td>
</tr>
<tr>
<td align="center">
<b>Chapter Navigation</b><br>
<img src="docs/images/ereader_chapter_navigation.gif" width="300" alt="Chapter Navigation"><br>
<em>Jump directly to chapters by title or index</em>
</td>
<td align="center">
<b>Bookmarks & Positions</b><br>
<img src="docs/images/ereader_bookmarks.gif" width="300" alt="Bookmarks"><br>
<em>Save and restore reading positions anywhere in the book</em>
</td>
</tr>
<tr>
<td align="center">
<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>
## Quick Start
```python
from dreader.application import EbookReader
# Create an ebook reader
with EbookReader(page_size=(800, 1000)) as reader:
# Load an EPUB file
reader.load_epub("mybook.epub")
# Get current page as PIL Image
page = reader.get_current_page()
page.save("page_001.png")
# Navigate through pages
reader.next_page()
reader.previous_page()
# Save reading position
reader.save_position("chapter_3")
# Jump to a chapter
reader.jump_to_chapter("Chapter 5")
# Adjust font size
reader.increase_font_size()
# Highlight text at coordinates
highlight_id = reader.highlight_word(x=200, y=300, note="Important!")
# Get progress
progress = reader.get_reading_progress()
print(f"Progress: {progress*100:.1f}%")
```
## API Overview
### Loading Content
```python
reader.load_epub("book.epub")
reader.is_loaded() # Check if book loaded
reader.get_book_info() # Get metadata (title, author, etc.)
```
### Navigation
```python
reader.next_page()
reader.previous_page()
reader.jump_to_chapter("Chapter 1") # By title
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
# Navigation Overlay (unified TOC + Bookmarks)
overlay_image = reader.open_navigation_overlay() # Opens with tabs
reader.close_overlay()
reader.is_overlay_open()
```
### Styling & Display
```python
reader.increase_font_size()
reader.decrease_font_size()
reader.set_font_size(1.5) # 150% scale
reader.get_font_size()
reader.set_line_spacing(8)
reader.set_inter_block_spacing(20)
```
### Bookmarks & Position Management
```python
reader.save_position("my_bookmark")
reader.load_position("my_bookmark")
reader.list_saved_positions()
reader.delete_position("my_bookmark")
```
### Text Highlighting
```python
from pyWebLayout.core.highlight import HighlightColor
# Highlight a word at pixel coordinates
highlight_id = reader.highlight_word(
x=100,
y=200,
color=HighlightColor.YELLOW,
note="Important concept!"
)
# Highlight a text selection
highlight_id = reader.highlight_selection(
start=(100, 200),
end=(300, 250),
color=(255, 255, 0, 128) # RGBA
)
# Query word at position
result = reader.query_pixel(x=200, y=300)
if result:
print(f"Word: {result.word.text}")
# Manage highlights
highlights = reader.list_highlights()
reader.remove_highlight(highlight_id)
reader.clear_highlights()
reader.get_highlights_for_current_page()
```
### Gesture Handling
```python
from dreader.gesture import TouchEvent, GestureType, ActionType
# Handle touch input
event = TouchEvent(GestureType.TAP, x=400, y=300)
response = reader.handle_touch(event)
# Response contains action type and data
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 navigation overlay (from bottom 20% of screen)
# - SWIPE_DOWN: Close overlay or open settings (from top 20%)
# - PINCH_IN/OUT: Font size adjustment
# - DRAG: Text selection
```
### Settings Persistence
```python
from dreader.state import StateManager
from pathlib import Path
# Initialize state manager
state_file = Path.home() / ".config" / "dreader" / "state.json"
state_manager = StateManager(state_file=state_file)
# Load saved state
state = state_manager.load_state()
# Create reader and apply saved settings
reader = EbookReader(page_size=(800, 1000))
reader.load_epub("mybook.epub")
reader.apply_settings(state.settings.to_dict())
# Settings are automatically saved
reader.increase_font_size()
state_manager.update_settings(reader.get_current_settings())
state_manager.save_state()
```
### Library Management
```python
from dreader.library import LibraryManager
# Initialize library
library = LibraryManager(
library_path="/path/to/books",
page_size=(800, 1200)
)
# Scan for EPUB files
library.scan_library()
# Render library view
library_image = library.render_library()
# Handle book selection
book_path = library.handle_library_tap(x=400, y=300)
if book_path:
reader.load_epub(book_path)
```
## Examples
Check out the [examples/](examples/) directory for complete working examples:
### Basic Examples
- **[simple_ereader_example.py](examples/simple_ereader_example.py)** - Basic ereader usage with EPUB loading and navigation
- **[ereader_demo.py](examples/ereader_demo.py)** - Comprehensive demo showcasing all features
- **[simple_word_highlight.py](examples/simple_word_highlight.py)** - Minimal highlighting example
### Text Highlighting
- **[word_selection_highlighting.py](examples/word_selection_highlighting.py)** - Text selection and highlighting
### Overlays
- **[demo_toc_overlay.py](examples/demo_toc_overlay.py)** - Interactive table of contents overlay
- **[navigation_overlay_example.py](examples/navigation_overlay_example.py)** - Unified navigation overlay (TOC + Bookmarks)
- **[demo_settings_overlay.py](examples/demo_settings_overlay.py)** - Settings panel with font/spacing controls
### Library & State
- **[library_reading_integration.py](examples/library_reading_integration.py)** - Complete library → reading → resume workflow
- **[persistent_settings_example.py](examples/persistent_settings_example.py)** - Save/restore settings across sessions
### Advanced
- **[demo_pagination.py](examples/demo_pagination.py)** - Pagination system demonstration
- **[generate_ereader_gifs.py](examples/generate_ereader_gifs.py)** - Generate animated GIF demonstrations
- **[generate_library_demo_gif.py](examples/generate_library_demo_gif.py)** - Generate library demo animations
## Architecture
DReader Application is a **high-level application layer** that combines pyWebLayout's low-level components:
```
dreader.application.EbookReader (High-Level API)
├── pyWebLayout.layout.EreaderLayoutManager # Layout engine & pagination
├── pyWebLayout.layout.EreaderLayout # Bidirectional layout
├── pyWebLayout.core.HighlightManager # Highlighting system
├── pyWebLayout.io.gesture # Touch/gesture handling
└── pyWebLayout.io.readers.epub_reader # EPUB parsing
```
### Component Structure
```
dreader/
├── application.py # Main EbookReader class (coordinator)
├── managers/ # Specialized management modules
│ ├── document.py # Document loading (EPUB/HTML)
│ ├── settings.py # Font and spacing controls
│ └── highlight_coordinator.py # Text highlighting
├── handlers/
│ └── gestures.py # Touch event routing
├── overlays/ # UI overlay system
│ ├── base.py # Base overlay functionality
│ ├── navigation.py # TOC and bookmarks overlay
│ └── settings.py # Settings overlay
├── library.py # Library browsing and book selection
├── state.py # Application state persistence
├── html_generator.py # HTML generation for overlays
└── gesture.py # Gesture definitions and responses
```
### Relationship to pyWebLayout
**pyWebLayout** is a layout engine library providing low-level primitives:
- Text rendering and layout algorithms
- Document structure and pagination
- Query systems for interactive content
- Core rendering infrastructure
**DReader Application** is an application framework that:
- Combines pyWebLayout components into a complete reader
- Provides user-friendly APIs for common ereader tasks
- Manages application state (bookmarks, highlights, positions)
- Handles business logic for gestures and interactions
Think of it like this:
- **pyWebLayout** = React (library)
- **DReader Application** = Next.js (framework)
## State Management
### File Structure
```
~/.config/dreader/
├── state.json # Application state
├── covers/ # Cached book covers
├── bookmarks/ # Per-book bookmarks
├── highlights/ # Per-book highlights
└── xray/ # X-Ray data (future)
```
### State Persistence
- **Auto-save**: Every 60 seconds
- **Immediate save**: On mode change, settings change, shutdown
- **Boot behavior**: Resume last book at last position or show library
- **Error handling**: Fall back to library if book missing or state corrupt
### Position Stability
- Positions stored by abstract document structure (chapter/block/word indices)
- Stable across font size changes, spacing changes, page size changes
- Per-book storage using document IDs
- Special `__auto_resume__` bookmark for last reading position
## Use Cases
- 📱 **Desktop Ereader Applications** - Build native ereader apps with Python
- 🌐 **Web-based Readers** - Serve rendered pages via Flask/FastAPI
- 📟 **E-ink Device Firmware** - Optimized rendering for e-ink displays
- 📊 **Reading Analytics** - Track reading patterns, highlights, and engagement
- 🎓 **Educational Tools** - Create annotated reading experiences with highlights and notes
- 🔬 **Research Applications** - Build specialized reading tools for academic work
## Development
```bash
# Install in development mode with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Run tests with coverage
pytest --cov=dreader --cov-report=html
# Format code
black dreader/ tests/
# Type checking
mypy dreader/
# Linting
flake8 dreader/ tests/
```
### Running Examples
```bash
# Ensure you have an EPUB file for testing
cd examples
# Run simple example
python simple_ereader_example.py /path/to/book.epub
# Run comprehensive demo
python ereader_demo.py /path/to/book.epub
# Run library integration demo
python library_reading_integration.py /path/to/library/
# Generate animated GIFs
python generate_ereader_gifs.py /path/to/book.epub
```
## Testing
The project includes comprehensive tests covering:
- **Application API** - All EbookReader methods and workflows
- **System Integration** - Layout manager, bookmarks, and state management
- **Highlighting** - Word and selection highlighting with persistence
- **Overlays** - Navigation and settings overlay interactions
- **Gestures** - Touch event handling and routing
- **Boot Recovery** - State persistence and position restoration
- **Library** - Book scanning, selection, and metadata
- **Edge Cases** - Error handling, boundary conditions, and recovery
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/test_ereader_application.py
# Run with verbose output
pytest -v
# Run with coverage report
pytest --cov=dreader --cov-report=term-missing
```
## Hardware Integration
DReader includes complete hardware support for e-ink displays via the **dreader-hal** library.
### Supported Hardware
- **Display**: IT8951 e-ink controller (1872×1404)
- **Touch**: FT5316 capacitive touch panel
- **Buttons**: GPIO buttons (configurable)
- **Sensors**: BMA400 accelerometer, PCF8523 RTC, INA219 power monitor
### Quick Setup on Raspberry Pi
```bash
# 1. Clone and install
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
cd dreader-application
python3 -m venv venv
source venv/bin/activate
pip install -e .
./install_hardware_drivers.sh
# 2. Interactive hardware setup
sudo python3 setup_rpi.py
# 3. Run on hardware
python examples/run_on_hardware_config.py
```
### Hardware Configuration
The repository includes a pre-configured **[hardware_config.json](hardware_config.json)** for the reference hardware:
- **Buttons**: GPIO 22 (prev), GPIO 27 (next), GPIO 21 (power)
- **Display**: 1872×1404 IT8951 e-ink
- **I2C Bus**: GPIO 2/3 (touch, sensors, RTC, power)
See [HARDWARE_SETUP.md](HARDWARE_SETUP.md) for complete wiring diagrams and setup instructions.
### HAL Architecture
```python
from dreader.hal_hardware import HardwareDisplayHAL
from dreader.main import DReaderApplication, AppConfig
# Hardware HAL with GPIO buttons
hal = HardwareDisplayHAL(width=1872, height=1404, vcom=-2.0)
config = AppConfig(display_hal=hal, library_path="~/Books")
app = DReaderApplication(config)
```
**Available HAL Implementations:**
- **HardwareDisplayHAL** - Real e-ink hardware (IT8951 + dreader-hal)
- **PygameDisplayHAL** - Desktop testing with pygame window
See [HARDWARE_PINOUT.md](HARDWARE_PINOUT.md) for pin assignments and [GPIO_BUTTONS.md](GPIO_BUTTONS.md) for button configuration.
## Documentation
- [README.md](README.md) - This file, main project documentation
- [REQUIREMENTS.md](REQUIREMENTS.md) - Application requirements specification
- [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture and design details
- [HAL_IMPLEMENTATION_SPEC.md](HAL_IMPLEMENTATION_SPEC.md) - Hardware integration guide
## Performance
- **Boot Time**: ~2-3 seconds to resume reading
- **Page Turn**: ~50-100ms (depends on page complexity)
- **Overlay Open**: ~200-250ms (includes HTML generation and rendering)
- **Memory Usage**: ~20-30MB base + 10-50MB per book
- **Cache**: Automatic cover image and metadata caching for fast library loading
## Contributing
Contributions welcome! This project demonstrates what's possible with pyWebLayout. If you build something cool or find ways to improve the reader, please share!
### How to Contribute
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes with tests
4. Run tests and ensure coverage stays high
5. Format code with black
6. Submit a pull request
## License
MIT License - see [LICENSE](LICENSE) file for details
## Author
Duncan Tourolle - duncan@tourolle.paris
## Related Projects
- [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout) - The underlying layout engine library
## Acknowledgments
Built with [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout) - A powerful Python library for HTML-like layout and rendering.

196
REQUIREMENTS.md Normal file
View File

@ -0,0 +1,196 @@
# E-Reader Application Requirements
## Overview
This document defines the core requirements for a full-featured e-reader application built on dreader/pyWebLayout. The application supports library browsing, reading with overlays, state persistence, and gesture-based interaction.
## Application Modes
### LIBRARY Mode
Browse and select books from the user's library.
**Features**:
- Grid display of books with covers and metadata (title, author)
- Book selection via tap/click
- Visual feedback on selection
**Interactions**:
- **Tap book**: Open in READING mode
- **Swipe**: Scroll library (future)
### READING Mode
Read the current book with page navigation.
**Features**:
- Page rendering via pyWebLayout
- Page navigation (next/previous)
- Access to overlays
- Progress indicator
**Interactions**:
- **Tap edges**: Previous/Next page
- **Swipe left/right**: Page navigation
- **Swipe up (from bottom)**: Open navigation overlay
- **Swipe down (from top)**: Open settings overlay
- **Pinch in/out**: Adjust font size
- **Long-press on word**: Highlight/lookup (future)
## Overlay System
### Navigation Overlay
Unified overlay with tabbed interface for navigation.
**Tabs**:
- **Contents**: Chapter list for TOC navigation
- **Bookmarks**: Saved positions with jump/delete/add
**Interactions**:
- **Tap chapter/bookmark**: Jump to location, close overlay
- **Tap tab**: Switch between Contents and Bookmarks
- **Swipe down**: Close overlay
### Settings Overlay
Adjust reading preferences with real-time preview.
**Controls**:
- Font size (A-, A+)
- Line spacing (+/-)
- Block spacing (+/-)
- Word spacing (+/-)
- Back to Library button
**Interactions**:
- **Tap buttons**: Adjust settings immediately
- **Swipe down**: Close overlay
### Word Lookup Overlay (Planned - Phase 2)
Provide word definitions and contextual information.
**Features**:
- **Dictionary**: Word definition, pronunciation
- **X-Ray**: LLM-generated spoiler-free character/place/concept information up to current reading position
- **Highlight**: Add colored highlight
- **Copy**: Copy to clipboard
**X-Ray Behavior**:
- Pre-generated per book via offline LLM analysis
- Only shows information revealed up to current page (spoiler-free)
- Character relationships, place descriptions, concept explanations
- Entity occurrence tracking
## State Management
### Persistent State Structure
```json
{
"mode": "reading|library",
"overlay": "none|navigation|settings|word_lookup",
"current_book": {
"path": "/path/to/book.epub",
"title": "Book Title",
"author": "Author Name"
},
"library": {
"books_path": "/path/to/library",
"scan_cache": [...]
},
"settings": {
"font_scale": 1.0,
"line_spacing": 5,
"inter_block_spacing": 15,
"brightness": 8
}
}
```
**State Location**: `~/.config/dreader/state.json`
**Save Triggers**:
- Every 60 seconds (auto-save)
- On mode change
- On settings change
- On application shutdown
**Boot Behavior**:
- **Cold start**: Show library
- **Resume**: Reopen last book at saved position with restored settings
- **Error handling**: Fall back to library if book missing or state corrupt
### Position Persistence
- Per-book positions stored via EbookReader bookmark system
- Special bookmark `__auto_resume__` for last reading position
- Position stable across font size and spacing changes
## Library Management
**Features**:
- Scan directory for EPUB files
- Extract metadata (title, author) and cover images
- Cache covers to disk for performance
- Incremental updates (scan only new/modified files)
**Cache Structure**:
```
~/.config/dreader/
├── state.json # Application state
├── covers/ # Cached cover images
├── bookmarks/ # Per-book bookmarks
├── highlights/ # Per-book highlights
└── xray/ # X-Ray data (future)
```
## Gesture Handling
### Reading Mode Gestures
- `TAP`: Word selection, link following, page turn (edges)
- `SWIPE_LEFT/RIGHT`: Page navigation
- `SWIPE_UP` (from bottom 20%): Open navigation overlay
- `SWIPE_DOWN` (from top 20%): Open settings overlay
- `PINCH_IN/OUT`: Font size adjustment
- `LONG_PRESS`: Word lookup (future)
### Overlay Mode Gestures
- `TAP`: Interact with overlay elements
- `SWIPE_DOWN`: Close overlay
### Library Mode Gestures
- `TAP`: Select book
## Technical Requirements
### Performance Targets
- Boot time: < 3 seconds
- Page turn: < 200ms
- Library load: < 1 second (up to 100 books)
- State save: < 50ms (non-blocking)
### Platform Integration
Application requires a display HAL (Hardware Abstraction Layer):
```python
class DisplayHAL(ABC):
def show_image(image: Image.Image)
def get_touch_events() -> Iterator[TouchEvent]
def set_brightness(level: int)
```
## Implementation Phases
### Phase 1 (MVP) - Complete ✅
- Core reading (page navigation, bookmarks)
- Library browsing and book selection
- Navigation overlay (TOC + Bookmarks)
- Settings overlay with persistence
- State management and auto-resume
- Gesture handling
- Highlighting system
### Phase 2 - In Progress
- Word lookup overlay with dictionary
- X-Ray feature (spoiler-free contextual info)
- Enhanced library features (search, sort)
### Phase 3 - Future
- Night/sepia themes
- Full-text search within books
- Cloud sync for bookmarks
- PDF support
- Reading statistics

View File

@ -0,0 +1,58 @@
<svg width="140" height="20" viewBox="0 0 140 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<title>interrogate: 99.7%</title>
<g transform="matrix(1,0,0,1,22,0)">
<g id="backgrounds" transform="matrix(1.32789,0,0,1,-22.3892,0)">
<rect x="0" y="0" width="71" height="20" style="fill:rgb(85,85,85);"/>
</g>
<rect x="71" y="0" width="47" height="20" data-interrogate="color" style="fill:#4c1"/>
<g transform="matrix(1.19746,0,0,1,-22.3744,-4.85723e-16)">
<rect x="0" y="0" width="118" height="20" style="fill:url(#_Linear1);"/>
</g>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
<text x="590" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="610">interrogate</text>
<text x="590" y="140" transform="scale(.1)" textLength="610">interrogate</text>
<text x="1160" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370" data-interrogate="result">99.7%</text>
<text x="1160" y="140" transform="scale(.1)" textLength="370" data-interrogate="result">99.7%</text>
</g>
<g id="logo-shadow" serif:id="logo shadow" transform="matrix(0.854876,0,0,0.854876,-6.73514,1.732)">
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<path d="M50,64.25C52.76,64.25 55,61.13 55,59.75C55,58.37 52.76,57.25 50,57.25C47.24,57.25 45,58.37 45,59.75C45,61.13 47.24,64.25 50,64.25Z" style="fill:rgb(1,1,1);fill-opacity:0.3;fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<path d="M88,49.05C86.506,43.475 83.018,38.638 78.2,35.46C72.969,32.002 66.539,30.844 60.43,32.26C56.576,33.145 52.995,34.958 50,37.54C46.998,34.958 43.411,33.149 39.55,32.27C33.441,30.853 27.011,32.011 21.78,35.47C16.97,38.652 13.489,43.489 12,49.06L12,49.13C11.82,49.79 11.66,50.46 11.53,51.13C11.146,53.207 11.021,55.323 11.16,57.43C11.16,58.03 11.26,58.63 11.34,59.23C11.34,59.51 11.43,59.79 11.48,60.07C11.53,60.35 11.58,60.68 11.64,60.98C11.7,61.28 11.8,61.69 11.89,62.05C11.98,62.41 11.99,62.47 12.05,62.68C12.16,63.07 12.28,63.46 12.41,63.84L12.58,64.34C12.72,64.74 12.88,65.14 13.04,65.53L13.23,65.98C13.403,66.373 13.583,66.767 13.77,67.16L13.99,67.59C14.19,67.97 14.39,68.35 14.61,68.73L14.87,69.15C15.1,69.52 15.33,69.89 15.58,70.26L15.58,70.32L15.99,70.93C16.14,71.14 16.29,71.36 16.45,71.57C20.206,75.83 25.086,78.95 30.53,80.57C36.839,82.48 43.41,83.385 50,83.25C56.599,83.374 63.177,82.456 69.49,80.53C74.644,78.978 79.303,76.102 83,72.19C83.34,71.78 83.65,71.35 84,70.92L84.18,70.66L84.33,70.44L84.41,70.32C84.55,70.12 84.67,69.9 84.81,69.7C85.07,69.3 85.32,68.89 85.55,68.48C85.78,68.07 86.02,67.65 86.23,67.22C86.31,67.05 86.39,66.88 86.47,66.7C86.67,66.28 86.85,65.87 87.03,65.44L87.23,64.92C87.397,64.487 87.55,64.05 87.69,63.61L87.85,63.09C87.98,62.64 88.1,62.19 88.21,61.74C88.21,61.57 88.3,61.39 88.33,61.22C88.43,60.75 88.52,60.22 88.6,59.79C88.6,59.64 88.66,59.49 88.68,59.33C88.77,58.71 88.84,58.08 88.88,57.45L88.88,54.17C88.817,53.164 88.693,52.162 88.51,51.17C88.38,50.5 88.23,49.84 88.05,49.17L88,49.05ZM85.89,56.44L85.89,57.23C85.89,57.78 85.79,58.32 85.72,58.86C85.72,59.01 85.72,59.15 85.65,59.3C85.59,59.7 85.51,60.11 85.43,60.51L85.32,60.99C85.23,61.38 85.12,61.77 85.01,62.16C85.01,62.31 84.93,62.46 84.88,62.6C84.74,63.04 84.59,63.47 84.42,63.9L84.27,64.28C84.1,64.71 83.91,65.14 83.71,65.56C83.51,65.98 83.43,66.12 83.28,66.4L83.01,66.91C82.83,67.223 82.643,67.537 82.45,67.85L82.35,68.01C79.121,68.047 75.918,67.434 72.93,66.21C64.27,62.74 59,55.52 61.18,50.11C62.18,47.6 64.7,45.82 68.26,45.11C72.489,44.395 76.835,44.908 80.78,46.59C82.141,47.144 83.453,47.813 84.7,48.59C84.76,48.76 84.82,48.93 84.88,49.1C84.94,49.27 85.05,49.63 85.12,49.9C85.28,50.5 85.44,51.1 85.55,51.73C85.691,52.507 85.792,53.292 85.85,54.08L85.85,55.89C85.85,56.12 85.91,56.25 85.91,56.45L85.89,56.44ZM17.66,68C16.668,66.435 15.869,64.756 15.28,63L15.17,62.68C15.06,62.35 14.96,62.01 14.87,61.68C14.823,61.493 14.777,61.31 14.73,61.13C14.66,60.84 14.59,60.55 14.53,60.27C14.47,59.99 14.43,59.72 14.38,59.44C14.33,59.16 14.3,59 14.27,58.78C14.2,58.27 14.15,57.78 14.11,57.23L14.11,57.03C14.008,55.236 14.122,53.437 14.45,51.67C14.56,51.06 14.71,50.46 14.88,49.87C14.96,49.59 15.04,49.32 15.13,49.05C15.22,48.78 15.24,48.72 15.3,48.55C16.548,47.774 17.859,47.105 19.22,46.55C27.86,43.09 36.65,44.67 38.82,50.08C40.99,55.49 35.73,62.74 27.09,66.2C24.101,67.431 20.893,68.043 17.66,68ZM68.57,77.68C62.554,79.508 56.287,80.376 50,80.25C43.737,80.37 37.495,79.506 31.5,77.69C27.185,76.38 23.243,74.062 20,70.93C22.815,70.706 25.58,70.055 28.2,69C38.37,64.92 44.39,56 41.6,49C38.81,42 28.27,39.72 18.1,43.8L17.43,44.09C18.973,41.648 21.019,39.561 23.43,37.97C26.671,35.824 30.473,34.68 34.36,34.68C35.884,34.681 37.404,34.852 38.89,35.19C42.694,36.049 46.191,37.935 49,40.64L50,41.64L51,40.64C53.797,37.937 57.279,36.049 61.07,35.18C66.402,33.947 72.014,34.968 76.57,38C78.98,39.588 81.026,41.671 82.57,44.11L81.9,43.82C77.409,41.921 72.464,41.355 67.66,42.19C63.08,43.12 59.79,45.54 58.39,49.02C55.6,55.97 61.62,64.94 71.79,69.02C74.414,70.07 77.182,70.714 80,70.93C76.776,74.05 72.859,76.363 68.57,77.68Z" style="fill:rgb(1,1,1);fill-opacity:0.3;fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<circle cx="71.33" cy="56" r="5.16" style="fill:rgb(1,1,1);fill-opacity:0.3;"/>
</g>
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<circle cx="28.67" cy="56" r="5.16" style="fill:rgb(1,1,1);fill-opacity:0.3;"/>
</g>
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<path d="M58,66C55.912,68.161 53.003,69.339 50,69.24C46.997,69.339 44.088,68.161 42,66C41.714,65.677 41.302,65.491 40.87,65.491C40.042,65.491 39.361,66.172 39.361,67C39.361,67.368 39.496,67.724 39.74,68C42.403,70.804 46.134,72.35 50,72.25C53.862,72.347 57.59,70.802 60.25,68C60.495,67.725 60.63,67.369 60.63,67C60.63,66.174 59.951,65.495 59.125,65.495C58.695,65.495 58.285,65.679 58,66Z" style="fill:rgb(1,1,1);fill-opacity:0.3;fill-rule:nonzero;"/>
</g>
</g>
<g id="logo-pink" serif:id="logo pink" transform="matrix(0.854876,0,0,0.854876,-6.73514,0.877124)">
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<path d="M50,64.25C52.76,64.25 55,61.13 55,59.75C55,58.37 52.76,57.25 50,57.25C47.24,57.25 45,58.37 45,59.75C45,61.13 47.24,64.25 50,64.25Z" style="fill:rgb(222,120,160);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<path d="M88,49.05C86.506,43.475 83.018,38.638 78.2,35.46C72.969,32.002 66.539,30.844 60.43,32.26C56.576,33.145 52.995,34.958 50,37.54C46.998,34.958 43.411,33.149 39.55,32.27C33.441,30.853 27.011,32.011 21.78,35.47C16.97,38.652 13.489,43.489 12,49.06L12,49.13C11.82,49.79 11.66,50.46 11.53,51.13C11.146,53.207 11.021,55.323 11.16,57.43C11.16,58.03 11.26,58.63 11.34,59.23C11.34,59.51 11.43,59.79 11.48,60.07C11.53,60.35 11.58,60.68 11.64,60.98C11.7,61.28 11.8,61.69 11.89,62.05C11.98,62.41 11.99,62.47 12.05,62.68C12.16,63.07 12.28,63.46 12.41,63.84L12.58,64.34C12.72,64.74 12.88,65.14 13.04,65.53L13.23,65.98C13.403,66.373 13.583,66.767 13.77,67.16L13.99,67.59C14.19,67.97 14.39,68.35 14.61,68.73L14.87,69.15C15.1,69.52 15.33,69.89 15.58,70.26L15.58,70.32L15.99,70.93C16.14,71.14 16.29,71.36 16.45,71.57C20.206,75.83 25.086,78.95 30.53,80.57C36.839,82.48 43.41,83.385 50,83.25C56.599,83.374 63.177,82.456 69.49,80.53C74.644,78.978 79.303,76.102 83,72.19C83.34,71.78 83.65,71.35 84,70.92L84.18,70.66L84.33,70.44L84.41,70.32C84.55,70.12 84.67,69.9 84.81,69.7C85.07,69.3 85.32,68.89 85.55,68.48C85.78,68.07 86.02,67.65 86.23,67.22C86.31,67.05 86.39,66.88 86.47,66.7C86.67,66.28 86.85,65.87 87.03,65.44L87.23,64.92C87.397,64.487 87.55,64.05 87.69,63.61L87.85,63.09C87.98,62.64 88.1,62.19 88.21,61.74C88.21,61.57 88.3,61.39 88.33,61.22C88.43,60.75 88.52,60.22 88.6,59.79C88.6,59.64 88.66,59.49 88.68,59.33C88.77,58.71 88.84,58.08 88.88,57.45L88.88,54.17C88.817,53.164 88.693,52.162 88.51,51.17C88.38,50.5 88.23,49.84 88.05,49.17L88,49.05ZM85.89,56.44L85.89,57.23C85.89,57.78 85.79,58.32 85.72,58.86C85.72,59.01 85.72,59.15 85.65,59.3C85.59,59.7 85.51,60.11 85.43,60.51L85.32,60.99C85.23,61.38 85.12,61.77 85.01,62.16C85.01,62.31 84.93,62.46 84.88,62.6C84.74,63.04 84.59,63.47 84.42,63.9L84.27,64.28C84.1,64.71 83.91,65.14 83.71,65.56C83.51,65.98 83.43,66.12 83.28,66.4L83.01,66.91C82.83,67.223 82.643,67.537 82.45,67.85L82.35,68.01C79.121,68.047 75.918,67.434 72.93,66.21C64.27,62.74 59,55.52 61.18,50.11C62.18,47.6 64.7,45.82 68.26,45.11C72.489,44.395 76.835,44.908 80.78,46.59C82.141,47.144 83.453,47.813 84.7,48.59C84.76,48.76 84.82,48.93 84.88,49.1C84.94,49.27 85.05,49.63 85.12,49.9C85.28,50.5 85.44,51.1 85.55,51.73C85.691,52.507 85.792,53.292 85.85,54.08L85.85,55.89C85.85,56.12 85.91,56.25 85.91,56.45L85.89,56.44ZM17.66,68C16.668,66.435 15.869,64.756 15.28,63L15.17,62.68C15.06,62.35 14.96,62.01 14.87,61.68C14.823,61.493 14.777,61.31 14.73,61.13C14.66,60.84 14.59,60.55 14.53,60.27C14.47,59.99 14.43,59.72 14.38,59.44C14.33,59.16 14.3,59 14.27,58.78C14.2,58.27 14.15,57.78 14.11,57.23L14.11,57.03C14.008,55.236 14.122,53.437 14.45,51.67C14.56,51.06 14.71,50.46 14.88,49.87C14.96,49.59 15.04,49.32 15.13,49.05C15.22,48.78 15.24,48.72 15.3,48.55C16.548,47.774 17.859,47.105 19.22,46.55C27.86,43.09 36.65,44.67 38.82,50.08C40.99,55.49 35.73,62.74 27.09,66.2C24.101,67.431 20.893,68.043 17.66,68ZM68.57,77.68C62.554,79.508 56.287,80.376 50,80.25C43.737,80.37 37.495,79.506 31.5,77.69C27.185,76.38 23.243,74.062 20,70.93C22.815,70.706 25.58,70.055 28.2,69C38.37,64.92 44.39,56 41.6,49C38.81,42 28.27,39.72 18.1,43.8L17.43,44.09C18.973,41.648 21.019,39.561 23.43,37.97C26.671,35.824 30.473,34.68 34.36,34.68C35.884,34.681 37.404,34.852 38.89,35.19C42.694,36.049 46.191,37.935 49,40.64L50,41.64L51,40.64C53.797,37.937 57.279,36.049 61.07,35.18C66.402,33.947 72.014,34.968 76.57,38C78.98,39.588 81.026,41.671 82.57,44.11L81.9,43.82C77.409,41.921 72.464,41.355 67.66,42.19C63.08,43.12 59.79,45.54 58.39,49.02C55.6,55.97 61.62,64.94 71.79,69.02C74.414,70.07 77.182,70.714 80,70.93C76.776,74.05 72.859,76.363 68.57,77.68Z" style="fill:rgb(222,120,160);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<circle cx="71.33" cy="56" r="5.16" style="fill:rgb(222,120,160);"/>
</g>
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<circle cx="28.67" cy="56" r="5.16" style="fill:rgb(222,120,160);"/>
</g>
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
<path d="M58,66C55.912,68.161 53.003,69.339 50,69.24C46.997,69.339 44.088,68.161 42,66C41.714,65.677 41.302,65.491 40.87,65.491C40.042,65.491 39.361,66.172 39.361,67C39.361,67.368 39.496,67.724 39.74,68C42.403,70.804 46.134,72.35 50,72.25C53.862,72.347 57.59,70.802 60.25,68C60.495,67.725 60.63,67.369 60.63,67C60.63,66.174 59.951,65.495 59.125,65.495C58.695,65.495 58.285,65.679 58,66Z" style="fill:rgb(222,120,160);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.22465e-15,20,-20,1.22465e-15,0,0)">
<stop offset="0" style="stop-color:rgb(187,187,187);stop-opacity:0.1"/>
<stop offset="1" style="stop-color:black;stop-opacity:0.1"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

1
cov_info/coverage.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="20" role="img" aria-label="coverage: failed"><title>coverage: failed</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="100" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="39" height="20" fill="#e05d44"/><rect width="100" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="795" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="290">failed</text><text x="795" y="140" transform="scale(.1)" fill="#fff" textLength="290">failed</text></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

207
debug_overlay_links.py Normal file
View File

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
Debug script to visualize interactive elements in overlays.
Shows where clickable links are located.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from dreader.application import EbookReader
from dreader.overlays.settings import SettingsOverlay
from dreader.overlays.navigation import NavigationOverlay
from PIL import Image, ImageDraw, ImageFont
def find_all_links(overlay_reader, panel_width, panel_height):
"""Scan overlay to find all interactive link positions."""
link_positions = {}
if not overlay_reader or not overlay_reader.manager:
print("No overlay reader available")
return link_positions
page = overlay_reader.manager.get_current_page()
if not page:
print("No page available")
return link_positions
print(f"Scanning {panel_width}x{panel_height} overlay for interactive elements...")
# Scan with moderate granularity (every 5 pixels)
for y in range(0, panel_height, 5):
for x in range(0, panel_width, 5):
result = page.query_point((x, y))
if result and result.link_target:
if result.link_target not in link_positions:
link_positions[result.link_target] = {
'first_pos': (x, y),
'bounds': result.bounds,
'text': result.text
}
return link_positions
def visualize_settings_overlay():
"""Visualize interactive elements in settings overlay."""
print("\n" + "="*70)
print("SETTINGS OVERLAY - Interactive Element Map")
print("="*70)
# Create reader
reader = EbookReader(page_size=(800, 1200))
# Load a test book
test_book = Path(__file__).parent / "tests" / "data" / "library-epub" / "pg11-images-3.epub"
if not test_book.exists():
print(f"Test book not found: {test_book}")
return
reader.load_epub(str(test_book))
# Create settings overlay
settings_overlay = SettingsOverlay(reader)
base_page = reader.get_current_page()
# Open overlay
overlay_image = settings_overlay.open(
base_page,
font_scale=1.0,
line_spacing=5,
inter_block_spacing=15,
word_spacing=0
)
# Find all interactive elements
panel_width = 480 # 60% of 800
panel_height = 840 # 70% of 1200
link_positions = find_all_links(
settings_overlay._overlay_reader,
panel_width,
panel_height
)
print(f"\nFound {len(link_positions)} interactive elements:")
for link_target, info in sorted(link_positions.items()):
x, y = info['first_pos']
bounds = info['bounds']
text = info['text']
print(f" {link_target:30s} at ({x:3d}, {y:3d}) - \"{text}\"")
print(f" Bounds: {bounds}")
# Create visualization
print("\nCreating visualization...")
# Get just the overlay panel (not the composited image)
overlay_panel = settings_overlay._cached_overlay_image.copy()
draw = ImageDraw.Draw(overlay_panel)
# Draw markers on each interactive element
for link_target, info in link_positions.items():
x, y = info['first_pos']
# Draw red circle at first detected position
radius = 8
draw.ellipse(
[x - radius, y - radius, x + radius, y + radius],
outline=(255, 0, 0),
width=2
)
# Draw crosshair
draw.line([(x - 15, y), (x + 15, y)], fill=(255, 0, 0), width=1)
draw.line([(x, y - 15), (x, y + 15)], fill=(255, 0, 0), width=1)
# Save visualization
output_path = Path(__file__).parent / "overlay_links_debug.png"
overlay_panel.save(output_path)
print(f"\nVisualization saved to: {output_path}")
print("Red circles show clickable link positions")
reader.close()
def visualize_navigation_overlay():
"""Visualize interactive elements in navigation overlay."""
print("\n" + "="*70)
print("NAVIGATION OVERLAY - Interactive Element Map")
print("="*70)
# Create reader
reader = EbookReader(page_size=(800, 1200))
# Load a test book
test_book = Path(__file__).parent / "tests" / "data" / "library-epub" / "pg11-images-3.epub"
if not test_book.exists():
print(f"Test book not found: {test_book}")
return
reader.load_epub(str(test_book))
# Create navigation overlay
nav_overlay = NavigationOverlay(reader)
base_page = reader.get_current_page()
# Get chapters
chapters = reader.get_chapters()
# Open overlay
overlay_image = nav_overlay.open(
base_page,
chapters=chapters,
bookmarks=[],
active_tab="contents"
)
# Find all interactive elements
panel_width = 480 # 60% of 800
panel_height = 840 # 70% of 1200
link_positions = find_all_links(
nav_overlay._overlay_reader,
panel_width,
panel_height
)
print(f"\nFound {len(link_positions)} interactive elements:")
for link_target, info in sorted(link_positions.items()):
x, y = info['first_pos']
text = info['text']
print(f" {link_target:30s} at ({x:3d}, {y:3d}) - \"{text}\"")
# Create visualization
print("\nCreating visualization...")
# Get just the overlay panel
overlay_panel = nav_overlay._cached_overlay_image.copy()
draw = ImageDraw.Draw(overlay_panel)
# Draw markers on each interactive element
for link_target, info in link_positions.items():
x, y = info['first_pos']
# Draw green circle
radius = 8
draw.ellipse(
[x - radius, y - radius, x + radius, y + radius],
outline=(0, 255, 0),
width=2
)
# Save visualization
output_path = Path(__file__).parent / "nav_overlay_links_debug.png"
overlay_panel.save(output_path)
print(f"\nVisualization saved to: {output_path}")
print("Green circles show clickable link positions")
reader.close()
if __name__ == "__main__":
visualize_settings_overlay()
visualize_navigation_overlay()
print("\n" + "="*70)
print("Debug complete! Check the generated PNG files.")
print("="*70)

88
debug_previous_page.py Normal file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Debug previous_page issue.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from dreader.application import EbookReader
def debug_previous():
"""Debug previous_page functionality."""
epub_path = Path("tests/data/library-epub/pg11-images-3.epub")
print("=" * 70)
print("Debug Previous Page")
print("=" * 70)
reader = EbookReader(page_size=(800, 1200))
reader.load_epub(str(epub_path))
print(f"\nLoaded: {reader.book_title}")
print(f"Manager type: {type(reader.manager)}")
print(f"Manager has previous_page: {hasattr(reader.manager, 'previous_page')}")
# Check manager's state
if reader.manager:
print(f"\nManager state:")
print(f" current_position: {reader.manager.current_position}")
if hasattr(reader.manager, 'page_buffer'):
print(f" page_buffer length: {len(reader.manager.page_buffer)}")
if hasattr(reader.manager, 'buffer'):
print(f" buffer: {reader.manager.buffer}")
# Try going forward first
print("\n" + "-" * 70)
print("Going forward 3 pages...")
print("-" * 70)
for i in range(3):
page = reader.next_page()
if page:
print(f" Forward {i+1}: position = {reader.manager.current_position}")
else:
print(f" Forward {i+1}: FAILED")
if reader.manager:
print(f"\nAfter forward navigation:")
print(f" current_position: {reader.manager.current_position}")
if hasattr(reader.manager, 'page_buffer'):
print(f" page_buffer length: {len(reader.manager.page_buffer)}")
if len(reader.manager.page_buffer) > 0:
print(f" page_buffer[0]: {reader.manager.page_buffer[0].position if hasattr(reader.manager.page_buffer[0], 'position') else 'N/A'}")
# Now try going backward
print("\n" + "-" * 70)
print("Trying to go backward...")
print("-" * 70)
# Try calling previous_page directly on manager
if reader.manager:
print("\nCalling manager.previous_page() directly...")
result = reader.manager.previous_page()
print(f" Result: {type(result) if result else None}")
if result:
print(f" Result has render(): {hasattr(result, 'render')}")
print(f" Position after: {reader.manager.current_position}")
else:
print(f" Result is None")
print(f" Position still: {reader.manager.current_position}")
# Try via reader.previous_page()
print("\nCalling reader.previous_page()...")
page = reader.previous_page()
if page:
print(f" SUCCESS: Got page {page.size}")
print(f" Position: {reader.manager.current_position}")
else:
print(f" FAILED: Got None")
print(f" Position: {reader.manager.current_position}")
reader.close()
if __name__ == "__main__":
debug_previous()

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

66
dreader/__init__.py Normal file
View File

@ -0,0 +1,66 @@
"""
pyWebLayout-ereader: A complete ebook reader application built with pyWebLayout.
This package provides a high-level, user-friendly ebook reader implementation
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.main import DReaderApplication, AppConfig
from dreader.hal import DisplayHAL, KeyboardInputHAL, EventLoopHAL
__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",
# Main application
"DReaderApplication",
"AppConfig",
# HAL interfaces
"DisplayHAL",
"KeyboardInputHAL",
"EventLoopHAL",
]

1173
dreader/application.py Normal file

File diff suppressed because it is too large Load Diff

253
dreader/book_utils.py Normal file
View File

@ -0,0 +1,253 @@
"""
Utilities for managing book library, scanning EPUBs, and extracting metadata.
"""
from pathlib import Path
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]]:
"""
Scan a directory for EPUB files and extract metadata.
Args:
directory: Path to directory containing EPUB files
Returns:
List of book dictionaries with metadata
"""
books = []
epub_files = list(directory.glob('*.epub'))
for epub_path in epub_files:
metadata = extract_book_metadata(epub_path)
if metadata:
books.append(metadata)
return sorted(books, key=lambda b: b['title'].lower())
def extract_book_metadata(epub_path: Path, include_cover: bool = True) -> Optional[Dict]:
"""
Extract metadata from an EPUB file.
Args:
epub_path: Path to EPUB file
include_cover: Whether to extract and include cover image as base64
Returns:
Dictionary with book metadata or None if extraction fails
"""
try:
# Create temporary reader to extract metadata
reader = create_ebook_reader(page_size=(400, 600))
reader.load_epub(str(epub_path))
metadata = {
'filename': epub_path.name,
'path': str(epub_path),
'title': reader.book_title or epub_path.stem,
'author': reader.book_author or 'Unknown Author',
}
# Extract cover image if requested - use direct EPUB extraction
if include_cover:
cover_data = extract_cover_from_epub(epub_path)
metadata['cover_data'] = cover_data
return metadata
except Exception as e:
print(f"Error extracting metadata from {epub_path}: {e}")
return {
'filename': epub_path.name,
'path': str(epub_path),
'title': epub_path.stem,
'author': 'Unknown',
'cover_data': None
}
def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450) -> Optional[str]:
"""
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
max_height: Maximum height for cover image
Returns:
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
cover_image = reader.get_current_page()
# Resize if needed
if cover_image.width > max_width or cover_image.height > max_height:
cover_image.thumbnail((max_width, max_height))
# 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 image: {e}")
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.
Args:
reader: EbookReader instance with loaded book
Returns:
List of chapter dictionaries with index and title
"""
try:
chapters = reader.get_chapters()
result = []
for i, chapter in enumerate(chapters):
# Handle different chapter formats
if isinstance(chapter, str):
title = chapter
elif isinstance(chapter, dict):
title = chapter.get('title', f'Chapter {i+1}')
elif isinstance(chapter, tuple) and len(chapter) >= 2:
# Tuple format: (title, ...)
title = chapter[0] if chapter[0] else f'Chapter {i+1}'
else:
title = f'Chapter {i+1}'
result.append({
'index': i,
'title': title
})
return result
except Exception as e:
print(f"Error getting chapters: {e}")
return []
def get_bookmark_list(reader) -> List[Dict]:
"""
Get formatted bookmark list from reader.
Args:
reader: EbookReader instance with loaded book
Returns:
List of bookmark dictionaries
"""
try:
bookmarks = reader.list_saved_positions()
return [
{
'name': bookmark,
'position': '' # Could be enhanced to show chapter/page info
}
for bookmark in bookmarks
]
except Exception as e:
print(f"Error getting bookmarks: {e}")
return []
def page_image_to_base64(page_image) -> str:
"""
Convert PIL Image to base64 encoded string.
Args:
page_image: PIL Image object
Returns:
Base64 encoded PNG string
"""
buffer = BytesIO()
page_image.save(buffer, format='PNG')
img_bytes = buffer.getvalue()
return base64.b64encode(img_bytes).decode('utf-8')

135
dreader/gesture.py Normal file
View File

@ -0,0 +1,135 @@
"""
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
# Accelerometer-based gestures
TILT_FORWARD = "tilt_forward" # Tilt device forward (page forward)
TILT_BACKWARD = "tilt_backward" # Tilt device backward (page back)
@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"
BOOKMARK_SELECTED = "bookmark_selected"
TAB_SWITCHED = "tab_switched"
SETTING_CHANGED = "setting_changed"
BACK_TO_LIBRARY = "back_to_library"

291
dreader/gpio_buttons.py Normal file
View File

@ -0,0 +1,291 @@
"""
GPIO Button Handler for DReader.
This module provides GPIO button support for physical buttons on the e-reader device.
Buttons can be mapped to touch gestures for navigation and control.
Usage:
from dreader.gpio_buttons import GPIOButtonHandler
buttons = GPIOButtonHandler(config)
await buttons.initialize()
# Check for button events
event = await buttons.get_button_event()
if event:
print(f"Button pressed: {event.gesture}")
await buttons.cleanup()
"""
import asyncio
import logging
from typing import Optional, Dict, List
from dataclasses import dataclass
from .gesture import TouchEvent, GestureType
logger = logging.getLogger(__name__)
# Try to import RPi.GPIO
try:
import RPi.GPIO as GPIO
GPIO_AVAILABLE = True
except ImportError:
GPIO_AVAILABLE = False
logger.warning("RPi.GPIO not available. Button support disabled.")
@dataclass
class ButtonConfig:
"""Configuration for a single GPIO button."""
name: str
gpio: int
gesture: GestureType
description: str = ""
class GPIOButtonHandler:
"""
Handler for GPIO buttons that generates touch events.
This class manages physical buttons connected to GPIO pins and converts
button presses into TouchEvent objects that can be handled by the application.
Args:
buttons: List of ButtonConfig objects defining button mappings
pull_up: Use pull-up resistors (default True)
bounce_time_ms: Debounce time in milliseconds (default 200)
screen_width: Screen width for generating touch coordinates (default 1872)
screen_height: Screen height for generating touch coordinates (default 1404)
Example:
buttons_config = [
ButtonConfig("next", 23, GestureType.SWIPE_LEFT, "Next page"),
ButtonConfig("prev", 24, GestureType.SWIPE_RIGHT, "Previous page"),
]
handler = GPIOButtonHandler(buttons_config)
await handler.initialize()
# In main loop
event = await handler.get_button_event()
if event:
await app.handle_touch(event)
"""
def __init__(
self,
buttons: List[ButtonConfig],
pull_up: bool = True,
bounce_time_ms: int = 200,
screen_width: int = 1872,
screen_height: int = 1404,
):
"""Initialize GPIO button handler."""
self.buttons = buttons
self.pull_up = pull_up
self.bounce_time_ms = bounce_time_ms
self.screen_width = screen_width
self.screen_height = screen_height
self._initialized = False
self._event_queue: asyncio.Queue = asyncio.Queue()
self._gpio_map: Dict[int, ButtonConfig] = {}
if not GPIO_AVAILABLE:
logger.error("RPi.GPIO not available. Buttons will not work.")
return
logger.info(f"GPIO button handler created with {len(buttons)} buttons")
for btn in buttons:
logger.info(f" Button '{btn.name}' on GPIO {btn.gpio} -> {btn.gesture.value}")
async def initialize(self):
"""Initialize GPIO pins and set up button callbacks."""
if self._initialized:
return
if not GPIO_AVAILABLE:
logger.warning("Cannot initialize buttons: RPi.GPIO not available")
return
logger.info("Initializing GPIO buttons...")
# Set GPIO mode
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
# Configure each button
for button in self.buttons:
try:
# Configure pin
if self.pull_up:
GPIO.setup(button.gpio, GPIO.IN, pull_up_down=GPIO.PUD_UP)
# Button pressed = LOW (0)
edge = GPIO.FALLING
else:
GPIO.setup(button.gpio, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
# Button pressed = HIGH (1)
edge = GPIO.RISING
# Add event detection with debounce
GPIO.add_event_detect(
button.gpio,
edge,
callback=lambda channel, btn=button: self._button_callback(btn),
bouncetime=self.bounce_time_ms
)
self._gpio_map[button.gpio] = button
logger.info(f"✓ Configured button '{button.name}' on GPIO {button.gpio}")
except Exception as e:
logger.error(f"Failed to configure button '{button.name}' on GPIO {button.gpio}: {e}")
self._initialized = True
logger.info("GPIO buttons initialized successfully")
def _button_callback(self, button: ButtonConfig):
"""
Callback function for button press (runs in GPIO event thread).
This is called by RPi.GPIO when a button is pressed. We put the event
in a queue for async processing.
"""
logger.debug(f"Button pressed: {button.name} (GPIO {button.gpio})")
# Create touch event
# Use center of screen for button events (x, y don't matter for swipes)
event = TouchEvent(
gesture=button.gesture,
x=self.screen_width // 2,
y=self.screen_height // 2,
)
# Put in queue (non-blocking)
try:
self._event_queue.put_nowait(event)
logger.info(f"Button event queued: {button.name} -> {button.gesture.value}")
except asyncio.QueueFull:
logger.warning("Button event queue full, dropping event")
async def get_button_event(self) -> Optional[TouchEvent]:
"""
Get the next button event from the queue.
Returns:
TouchEvent if a button was pressed, None if no events
"""
if not self._initialized:
return None
try:
# Non-blocking get
event = self._event_queue.get_nowait()
return event
except asyncio.QueueEmpty:
return None
async def cleanup(self):
"""Clean up GPIO resources."""
if not self._initialized:
return
if not GPIO_AVAILABLE:
return
logger.info("Cleaning up GPIO buttons...")
try:
# Remove event detection for all buttons
for button in self.buttons:
try:
GPIO.remove_event_detect(button.gpio)
except Exception as e:
logger.debug(f"Error removing event detect for GPIO {button.gpio}: {e}")
# Clean up GPIO
GPIO.cleanup()
logger.info("GPIO buttons cleaned up")
except Exception as e:
logger.error(f"Error during GPIO cleanup: {e}")
self._initialized = False
def load_button_config_from_dict(config: dict, screen_width: int = 1872, screen_height: int = 1404) -> Optional[GPIOButtonHandler]:
"""
Load GPIO button configuration from a dictionary.
Args:
config: Configuration dictionary with 'gpio_buttons' section
screen_width: Screen width for touch coordinates
screen_height: Screen height for touch coordinates
Returns:
GPIOButtonHandler instance if buttons enabled, None otherwise
Example config:
{
"gpio_buttons": {
"enabled": true,
"pull_up": true,
"bounce_time_ms": 200,
"buttons": [
{
"name": "next_page",
"gpio": 23,
"gesture": "swipe_left",
"description": "Next page"
}
]
}
}
"""
gpio_config = config.get("gpio_buttons", {})
if not gpio_config.get("enabled", False):
logger.info("GPIO buttons disabled in config")
return None
if not GPIO_AVAILABLE:
logger.warning("GPIO buttons enabled in config but RPi.GPIO not available")
return None
# Parse button configurations
buttons = []
for btn_cfg in gpio_config.get("buttons", []):
try:
# Parse gesture type
gesture_str = btn_cfg["gesture"]
gesture = GestureType(gesture_str)
button = ButtonConfig(
name=btn_cfg["name"],
gpio=btn_cfg["gpio"],
gesture=gesture,
description=btn_cfg.get("description", "")
)
buttons.append(button)
except Exception as e:
logger.error(f"Error parsing button config: {e}")
logger.error(f" Config: {btn_cfg}")
continue
if not buttons:
logger.warning("No valid button configurations found")
return None
# Create handler
handler = GPIOButtonHandler(
buttons=buttons,
pull_up=gpio_config.get("pull_up", True),
bounce_time_ms=gpio_config.get("bounce_time_ms", 200),
screen_width=screen_width,
screen_height=screen_height,
)
return handler

188
dreader/hal.py Normal file
View File

@ -0,0 +1,188 @@
"""
Hardware Abstraction Layer (HAL) interface for DReader.
This module defines the abstract interface that platform-specific
display/input implementations must provide.
The HAL separates the core e-reader logic from platform-specific
hardware details (display, touch input, buttons, etc.).
"""
from abc import ABC, abstractmethod
from typing import AsyncIterator, Optional
from PIL import Image
from .gesture import TouchEvent
class DisplayHAL(ABC):
"""
Abstract interface for display and input hardware.
Platform-specific implementations should subclass this and provide
concrete implementations for all abstract methods.
The HAL is responsible for:
- Displaying images on the screen
- Capturing touch/click input and converting to TouchEvent
- Hardware-specific features (brightness, sleep, etc.)
All methods are async to support non-blocking I/O.
"""
@abstractmethod
async def show_image(self, image: Image.Image):
"""
Display a PIL Image on the screen.
Args:
image: PIL Image to display
This method should handle:
- Converting image format if needed for the display
- Scaling/cropping if image size doesn't match display
- Updating the physical display hardware
"""
pass
@abstractmethod
async def get_touch_event(self) -> Optional[TouchEvent]:
"""
Wait for and return the next touch event.
Returns:
TouchEvent if available, None if no event (non-blocking mode)
This method should:
- Read from touch hardware
- Convert raw coordinates to TouchEvent
- Detect gesture type (tap, swipe, etc.)
- Return None immediately if no event available
Note: For blocking behavior, implement a loop that awaits this
method in the main event loop.
"""
pass
@abstractmethod
async def set_brightness(self, level: int):
"""
Set display brightness.
Args:
level: Brightness level (0-10, where 0=dimmest, 10=brightest)
Platform implementations should map this to their hardware's
actual brightness range.
"""
pass
async def initialize(self):
"""
Initialize the display hardware.
This optional method is called once before the application starts.
Override to perform platform-specific initialization.
"""
pass
async def cleanup(self):
"""
Clean up display hardware resources.
This optional method is called during application shutdown.
Override to perform platform-specific cleanup.
"""
pass
async def show_message(self, message: str, duration: float = 2.0):
"""
Display a text message (for loading screens, errors, etc.).
Args:
message: Text message to display
duration: How long to show message (seconds)
Default implementation creates a simple text image.
Override for platform-specific message display.
"""
from PIL import ImageDraw, ImageFont
# Create simple text image
img = Image.new('RGB', (800, 1200), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
# Try to use a decent font, fall back to default
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
except:
font = ImageFont.load_default()
# Draw centered text
bbox = draw.textbbox((0, 0), message, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (800 - text_width) // 2
y = (1200 - text_height) // 2
draw.text((x, y), message, fill=(0, 0, 0), font=font)
await self.show_image(img)
# Wait for duration
if duration > 0:
import asyncio
await asyncio.sleep(duration)
class KeyboardInputHAL(ABC):
"""
Optional abstract interface for keyboard input.
This is separate from DisplayHAL to support platforms that have
both touch and keyboard input (e.g., desktop testing).
"""
@abstractmethod
async def get_key_event(self) -> Optional[str]:
"""
Get the next keyboard event.
Returns:
Key name as string (e.g., "up", "down", "enter", "q")
None if no key event available
"""
pass
class EventLoopHAL(DisplayHAL):
"""
Extended HAL interface that provides its own event loop.
Some platforms (e.g., Pygame, Qt) have their own event loop that
must be used. This interface allows the HAL to run the main loop
and call back to the application.
Usage:
hal = MyEventLoopHAL()
app = DReaderApplication(AppConfig(display_hal=hal, ...))
await hal.run_event_loop(app)
"""
@abstractmethod
async def run_event_loop(self, app):
"""
Run the platform's event loop.
Args:
app: DReaderApplication instance to send events to
This method should:
1. Initialize the display
2. Call app.start()
3. Enter event loop
4. Call app.handle_touch(event) for each event
5. Handle quit events and call app.shutdown()
"""
pass

593
dreader/hal_hardware.py Normal file
View File

@ -0,0 +1,593 @@
"""
Hardware HAL implementation using dreader-hal library.
This module provides the HardwareDisplayHAL class that bridges the DReader
application HAL interface with the dreader-hal hardware abstraction layer.
The dreader-hal library provides complete e-ink display integration with:
- IT8951 e-ink display driver
- FT5xx6 capacitive touch sensor
- BMA400 accelerometer (orientation)
- PCF8523 RTC (timekeeping)
- INA219 power monitor (battery)
Usage:
from dreader.hal_hardware import HardwareDisplayHAL
from dreader.main import DReaderApplication, AppConfig
# For real hardware
hal = HardwareDisplayHAL(width=800, height=1200, vcom=-2.0)
# For testing without hardware
hal = HardwareDisplayHAL(
width=800,
height=1200,
virtual_display=True,
enable_orientation=False,
enable_rtc=False,
enable_power_monitor=False
)
config = AppConfig(display_hal=hal, library_path="~/Books")
app = DReaderApplication(config)
await hal.initialize()
await app.start()
# Main loop
while app.is_running():
event = await hal.get_touch_event()
if event:
await app.handle_touch(event)
await asyncio.sleep(0.01)
await app.shutdown()
await hal.cleanup()
"""
import asyncio
import logging
import json
import math
import time
from typing import Optional
from pathlib import Path
from PIL import Image
from .hal import DisplayHAL
from .gesture import TouchEvent as AppTouchEvent, GestureType as AppGestureType
# Import dreader-hal components
try:
from dreader_hal import (
EReaderDisplayHAL,
TouchEvent as HalTouchEvent,
GestureType as HalGestureType,
RefreshMode,
PowerStats,
Orientation
)
DREADER_HAL_AVAILABLE = True
except ImportError as e:
DREADER_HAL_AVAILABLE = False
_import_error = e
logger = logging.getLogger(__name__)
# Gesture type mapping between dreader-hal and dreader-application
GESTURE_TYPE_MAP = {
HalGestureType.TAP: AppGestureType.TAP,
HalGestureType.LONG_PRESS: AppGestureType.LONG_PRESS,
HalGestureType.SWIPE_LEFT: AppGestureType.SWIPE_LEFT,
HalGestureType.SWIPE_RIGHT: AppGestureType.SWIPE_RIGHT,
HalGestureType.SWIPE_UP: AppGestureType.SWIPE_UP,
HalGestureType.SWIPE_DOWN: AppGestureType.SWIPE_DOWN,
HalGestureType.PINCH_IN: AppGestureType.PINCH_IN,
HalGestureType.PINCH_OUT: AppGestureType.PINCH_OUT,
HalGestureType.DRAG_START: AppGestureType.DRAG_START,
HalGestureType.DRAG_MOVE: AppGestureType.DRAG_MOVE,
HalGestureType.DRAG_END: AppGestureType.DRAG_END,
}
class HardwareDisplayHAL(DisplayHAL):
"""
Hardware HAL implementation using dreader-hal library.
This class adapts the dreader-hal EReaderDisplayHAL to work with the
DReader application's DisplayHAL interface.
Args:
width: Display width in pixels (default 1872)
height: Display height in pixels (default 1404)
vcom: E-ink VCOM voltage (default -2.0, check device label!)
spi_hz: SPI clock frequency (default 24MHz)
virtual_display: Use virtual display for testing (default False)
auto_sleep_display: Auto-sleep display after updates (default True)
enable_orientation: Enable orientation sensing (default True)
enable_rtc: Enable RTC timekeeping (default True)
enable_power_monitor: Enable battery monitoring (default True)
shunt_ohms: Power monitor shunt resistor (default 0.1)
battery_capacity_mah: Battery capacity in mAh (default 3000)
Example:
# For real hardware (Raspberry Pi with e-ink display)
hal = HardwareDisplayHAL(width=1872, height=1404, vcom=-2.0)
# For testing on development machine
hal = HardwareDisplayHAL(
width=1872,
height=1404,
virtual_display=True,
enable_orientation=False,
enable_rtc=False,
enable_power_monitor=False
)
"""
def __init__(
self,
width: int = 1872,
height: int = 1404,
vcom: float = -2.0,
spi_hz: int = 24_000_000,
virtual_display: bool = False,
auto_sleep_display: bool = True,
enable_orientation: bool = True,
enable_rtc: bool = True,
enable_power_monitor: bool = True,
shunt_ohms: float = 0.1,
battery_capacity_mah: float = 3000,
):
"""
Initialize hardware HAL.
Raises:
ImportError: If dreader-hal library is not installed
"""
if not DREADER_HAL_AVAILABLE:
raise ImportError(
f"dreader-hal library is required for HardwareDisplayHAL.\n"
f"Install with: pip install -e external/dreader-hal\n"
f"Original error: {_import_error}"
)
self.width = width
self.height = height
logger.info(f"Initializing HardwareDisplayHAL: {width}x{height}")
logger.info(f" VCOM: {vcom}V")
logger.info(f" Virtual display: {virtual_display}")
logger.info(f" Orientation: {enable_orientation}")
logger.info(f" RTC: {enable_rtc}")
logger.info(f" Power monitor: {enable_power_monitor}")
# Create the underlying dreader-hal implementation
self.hal = EReaderDisplayHAL(
width=width,
height=height,
vcom=vcom,
spi_hz=spi_hz,
virtual_display=virtual_display,
auto_sleep_display=auto_sleep_display,
enable_orientation=enable_orientation,
enable_rtc=enable_rtc,
enable_power_monitor=enable_power_monitor,
shunt_ohms=shunt_ohms,
battery_capacity_mah=battery_capacity_mah,
)
self._initialized = False
async def initialize(self):
"""
Initialize all hardware components.
This initializes:
- E-ink display controller
- Touch sensor
- Accelerometer (if enabled)
- RTC (if enabled)
- Power monitor (if enabled)
"""
if self._initialized:
logger.warning("Hardware HAL already initialized")
return
logger.info("Initializing hardware components...")
await self.hal.initialize()
self._initialized = True
logger.info("Hardware HAL initialized successfully")
async def cleanup(self):
"""Clean up all hardware resources."""
if not self._initialized:
return
logger.info("Cleaning up hardware HAL")
await self.hal.cleanup()
self._initialized = False
logger.info("Hardware HAL cleaned up")
async def show_image(self, image: Image.Image):
"""
Display a PIL Image on the e-ink screen.
Args:
image: PIL Image to display
The dreader-hal library handles:
- Format conversion (RGB -> grayscale)
- Dithering for e-ink
- Refresh mode selection (auto, fast, quality, full)
- Orientation rotation (if enabled)
"""
if not self._initialized:
logger.warning("Hardware HAL not initialized, initializing now...")
await self.initialize()
logger.debug(f"Displaying image: {image.size} {image.mode}")
await self.hal.show_image(image)
async def get_touch_event(self) -> Optional[AppTouchEvent]:
"""
Get the next touch event from hardware.
Returns:
TouchEvent if available, None if no event
The dreader-hal library handles gesture classification:
- TAP: Quick tap (< 30px movement, < 300ms)
- LONG_PRESS: Hold (< 30px movement, >= 500ms)
- SWIPE_*: Directional swipes (>= 30px movement)
- PINCH_IN/OUT: Two-finger pinch gestures
"""
if not self._initialized:
return None
# Get event from dreader-hal
hal_event = await self.hal.get_touch_event()
if hal_event is None:
return None
# Convert from dreader-hal TouchEvent to application TouchEvent
app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture)
if app_gesture is None:
logger.warning(f"Unknown gesture type from HAL: {hal_event.gesture}")
return None
logger.debug(f"Touch event: {app_gesture.value} at ({hal_event.x}, {hal_event.y})")
return AppTouchEvent(
gesture=app_gesture,
x=hal_event.x,
y=hal_event.y
)
async def set_brightness(self, level: int):
"""
Set display brightness.
Args:
level: Brightness level (0-10)
Note:
Basic IT8951 e-ink displays don't have brightness control.
This is a no-op unless frontlight hardware is connected.
"""
if not 0 <= level <= 10:
raise ValueError("Brightness must be 0-10")
logger.debug(f"Setting brightness to {level}")
await self.hal.set_brightness(level)
# ========== Extended Methods (Hardware-Specific Features) ==========
async def get_battery_level(self) -> float:
"""
Get battery percentage.
Returns:
Battery level 0-100%, or 0.0 if power monitor unavailable
"""
if not self._initialized:
return 0.0
return await self.hal.get_battery_level()
async def get_power_stats(self) -> PowerStats:
"""
Get detailed power statistics.
Returns:
PowerStats with voltage, current, power, battery %, etc.
Raises:
RuntimeError: If power monitor not enabled
"""
if not self._initialized:
raise RuntimeError("Hardware HAL not initialized")
return await self.hal.get_power_stats()
async def is_low_battery(self, threshold: float = 20.0) -> bool:
"""
Check if battery is low.
Args:
threshold: Battery percentage threshold (default 20%)
Returns:
True if battery below threshold, False otherwise
"""
if not self._initialized:
return False
return await self.hal.is_low_battery(threshold)
async def set_low_power_mode(self, enabled: bool):
"""
Enable/disable low power mode.
In low power mode:
- Display goes to sleep
- Touch polling rate reduced
- Sensors put to low power
Args:
enabled: True to enable low power mode
"""
if not self._initialized:
return
logger.info(f"Setting low power mode: {enabled}")
await self.hal.set_low_power_mode(enabled)
async def enable_orientation_monitoring(self):
"""
Start monitoring device orientation changes.
When orientation changes, display auto-rotates.
"""
if not self._initialized:
return
logger.info("Enabling orientation monitoring")
await self.hal.enable_orientation_monitoring()
async def disable_orientation_monitoring(self):
"""Stop monitoring orientation changes."""
if not self._initialized:
return
logger.info("Disabling orientation monitoring")
await self.hal.disable_orientation_monitoring()
@property
def current_orientation(self) -> Optional[Orientation]:
"""Get current device orientation."""
if not self._initialized:
return None
return self.hal.current_orientation
@property
def refresh_count(self) -> int:
"""Get number of display refreshes since initialization."""
if not self._initialized:
return 0
return self.hal.refresh_count
async def get_datetime(self):
"""
Get current date/time from RTC.
Returns:
struct_time with current date and time, or None if RTC unavailable
"""
if not self._initialized:
return None
return await self.hal.get_datetime()
async def set_datetime(self, dt):
"""
Set the RTC date/time.
Args:
dt: time.struct_time object with date and time to set
Raises:
RuntimeError: If RTC not enabled
"""
if not self._initialized:
raise RuntimeError("Hardware HAL not initialized")
await self.hal.set_datetime(dt)
# ========== Accelerometer Tilt Detection ==========
def load_accelerometer_calibration(self, config_path: str = "accelerometer_config.json") -> bool:
"""
Load accelerometer calibration from file.
Args:
config_path: Path to calibration JSON file
Returns:
True if calibration loaded successfully, False otherwise
"""
config_file = Path(config_path)
if not config_file.exists():
logger.warning(f"Accelerometer calibration file not found: {config_path}")
return False
try:
with open(config_file, 'r') as f:
config = json.load(f)
# Load up vector
up = config.get("up_vector", {})
self.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0))
# Load thresholds
self.accel_tilt_threshold = config.get("tilt_threshold", 0.3)
self.accel_debounce_time = config.get("debounce_time", 0.5)
# State tracking
self.accel_last_tilt_time = 0
logger.info(f"Accelerometer calibration loaded: up_vector={self.accel_up_vector}")
logger.info(f" Tilt threshold: {self.accel_tilt_threshold:.2f} rad (~{math.degrees(self.accel_tilt_threshold):.1f}°)")
logger.info(f" Debounce time: {self.accel_debounce_time:.2f}s")
return True
except Exception as e:
logger.error(f"Error loading accelerometer calibration: {e}")
return False
async def get_tilt_gesture(self) -> Optional[AppTouchEvent]:
"""
Check accelerometer for tilt gestures.
Returns:
TouchEvent with TILT_FORWARD or TILT_BACKWARD gesture if detected,
None otherwise
Note:
Requires accelerometer calibration to be loaded first via
load_accelerometer_calibration()
"""
if not self._initialized:
return None
if not self.hal.orientation:
return None
if not hasattr(self, 'accel_up_vector'):
return None
# Get current acceleration
try:
ax, ay, az = await self.hal.orientation.get_acceleration()
except Exception as e:
logger.debug(f"Error reading accelerometer: {e}")
return None
# Check debounce
current_time = time.time()
if current_time - self.accel_last_tilt_time < self.accel_debounce_time:
return None
# Calculate angle between current gravity and calibrated "up" vector
# Gravity vector is the acceleration (pointing down)
gx, gy, gz = ax, ay, az
# Normalize gravity
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
if g_mag < 0.1:
return None
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
# Normalize up vector
ux, uy, uz = self.accel_up_vector
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
if u_mag < 0.1:
return None
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
# Calculate tilt: project gravity onto the "forward/backward" axis
# Forward/backward axis is perpendicular to up vector
# We'll use the component of gravity that's perpendicular to the up vector
# Dot product: component of gravity along up vector
dot_up = gx * ux + gy * uy + gz * uz
# Component of gravity perpendicular to up vector
perp_x = gx - dot_up * ux
perp_y = gy - dot_up * uy
perp_z = gz - dot_up * uz
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
# Angle from vertical (in radians)
tilt_angle = math.atan2(perp_mag, abs(dot_up))
logger.debug(f"Tilt angle: {math.degrees(tilt_angle):.1f}° (threshold: {math.degrees(self.accel_tilt_threshold):.1f}°)")
# Check if tilted beyond threshold
if tilt_angle < self.accel_tilt_threshold:
return None
# Determine direction: forward or backward
# We need to determine which direction the device is tilted
# Use the sign of the perpendicular component along a reference axis
# For simplicity, we'll use the projection onto the original up vector's
# perpendicular plane. If we tilt "forward", the gravity vector should
# rotate in a specific direction.
# Calculate which direction: check if tilting away from or toward the up vector
# If dot_up is decreasing (device tilting away from up), that's "forward"
# If dot_up is increasing (device tilting back toward up), that's "backward"
# Actually, a simpler approach: check the direction of the perpendicular component
# relative to a reference direction in the plane
# Let's define forward as tilting in the direction that increases the
# y-component of acceleration (assuming standard orientation)
# This is device-specific and may need adjustment
# For now, use a simple heuristic: forward = positive perpendicular y component
if perp_y > 0:
gesture = AppGestureType.TILT_FORWARD
else:
gesture = AppGestureType.TILT_BACKWARD
# Update debounce timer
self.accel_last_tilt_time = current_time
logger.info(f"Tilt gesture detected: {gesture.value} (angle: {math.degrees(tilt_angle):.1f}°)")
# Return gesture at center of screen (x, y don't matter for tilt)
return AppTouchEvent(
gesture=gesture,
x=self.width // 2,
y=self.height // 2,
timestamp_ms=current_time * 1000
)
async def get_event(self) -> Optional[AppTouchEvent]:
"""
Get the next event from any input source (touch or accelerometer).
This is a convenience method that polls both touch and accelerometer
in a single call, prioritizing touch events over tilt events.
Returns:
TouchEvent from either touch sensor or accelerometer, or None if no event
Usage:
while running:
event = await hal.get_event()
if event:
handle_gesture(event)
await asyncio.sleep(0.01)
"""
# Check touch first (higher priority)
touch_event = await self.get_touch_event()
if touch_event:
return touch_event
# Check accelerometer tilt
if hasattr(self, 'accel_up_vector'):
tilt_event = await self.get_tilt_gesture()
if tilt_event:
return tilt_event
return None

406
dreader/hal_pygame.py Normal file
View File

@ -0,0 +1,406 @@
"""
Pygame-based Display HAL for desktop testing.
This HAL implementation uses Pygame to provide a desktop window
for testing the e-reader application without physical hardware.
Features:
- Window display with PIL image rendering
- Mouse input converted to touch events
- Keyboard shortcuts for common actions
- Gesture detection (swipes via mouse drag)
Usage:
from dreader.hal_pygame import PygameDisplayHAL
from dreader.main import DReaderApplication, AppConfig
hal = PygameDisplayHAL(width=800, height=1200)
config = AppConfig(display_hal=hal, library_path="~/Books")
app = DReaderApplication(config)
await hal.run_event_loop(app)
"""
import asyncio
import logging
from typing import Optional
from PIL import Image
import numpy as np
from .hal import EventLoopHAL
from .gesture import TouchEvent, GestureType
logger = logging.getLogger(__name__)
# Pygame is optional - only needed for desktop testing
try:
import pygame
PYGAME_AVAILABLE = True
except ImportError:
PYGAME_AVAILABLE = False
logger.warning("Pygame not available. Install with: pip install pygame")
class PygameDisplayHAL(EventLoopHAL):
"""
Pygame-based display HAL for desktop testing.
This implementation provides a desktop window that simulates
an e-reader display with touch input.
"""
def __init__(
self,
width: int = 800,
height: int = 1200,
title: str = "DReader E-Book Reader",
fullscreen: bool = False
):
"""
Initialize Pygame display.
Args:
width: Window width in pixels
height: Window height in pixels
title: Window title
fullscreen: If True, open in fullscreen mode
"""
if not PYGAME_AVAILABLE:
raise RuntimeError("Pygame is required for PygameDisplayHAL. Install with: pip install pygame")
self.width = width
self.height = height
self.title = title
self.fullscreen = fullscreen
self.screen = None
self.running = False
# Touch/gesture tracking
self.mouse_down_pos: Optional[tuple[int, int]] = None
self.mouse_down_time: float = 0
self.drag_threshold = 20 # pixels (reduced from 30 for easier swiping)
self.long_press_duration = 0.5 # seconds
logger.info(f"PygameDisplayHAL initialized: {width}x{height}")
async def initialize(self):
"""Initialize Pygame and create window."""
logger.info("Initializing Pygame")
pygame.init()
# Set up display
flags = pygame.DOUBLEBUF
if self.fullscreen:
flags |= pygame.FULLSCREEN
self.screen = pygame.display.set_mode((self.width, self.height), flags)
pygame.display.set_caption(self.title)
# Set up font for messages
pygame.font.init()
logger.info("Pygame initialized successfully")
async def cleanup(self):
"""Clean up Pygame resources."""
logger.info("Cleaning up Pygame")
if pygame.get_init():
pygame.quit()
async def show_image(self, image: Image.Image):
"""
Display PIL image on Pygame window.
Args:
image: PIL Image to display
"""
if not self.screen:
logger.warning("Screen not initialized")
return
# Convert PIL image to pygame surface
# PIL uses RGB, pygame uses RGB
if image.mode != 'RGB':
image = image.convert('RGB')
# Resize if needed
if image.size != (self.width, self.height):
image = image.resize((self.width, self.height), Image.Resampling.LANCZOS)
# Convert to numpy array, then to pygame surface
img_array = np.array(image)
surface = pygame.surfarray.make_surface(np.transpose(img_array, (1, 0, 2)))
# Blit to screen
self.screen.blit(surface, (0, 0))
pygame.display.flip()
# Small delay to prevent excessive CPU usage
await asyncio.sleep(0.001)
async def get_touch_event(self) -> Optional[TouchEvent]:
"""
Process pygame events and convert to TouchEvent.
Returns:
TouchEvent if available, None otherwise
"""
if not pygame.get_init():
return None
for event in pygame.event.get():
if event.type == pygame.QUIT:
logger.info("Quit event received")
self.running = False
return None
elif event.type == pygame.MOUSEBUTTONDOWN:
# Mouse down - start tracking for gesture
self.mouse_down_pos = event.pos
self.mouse_down_time = pygame.time.get_ticks() / 1000.0
logger.info(f"[MOUSE] Button DOWN at {event.pos}")
elif event.type == pygame.MOUSEMOTION:
# Show drag indicator while mouse is down
if self.mouse_down_pos and pygame.mouse.get_pressed()[0]:
current_pos = event.pos
dx = current_pos[0] - self.mouse_down_pos[0]
dy = current_pos[1] - self.mouse_down_pos[1]
distance = (dx**2 + dy**2) ** 0.5
# Log dragging in progress
if distance > 5: # Log any significant drag
logger.info(f"[DRAG] Moving: dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px")
# Only show if dragging beyond threshold
if distance > self.drag_threshold:
# Draw a line showing the swipe direction
if self.screen:
# This is just for visual feedback during drag
# The actual gesture detection happens on mouse up
pass
elif event.type == pygame.MOUSEBUTTONUP:
if self.mouse_down_pos is None:
logger.warning("[MOUSE] Button UP but no down position recorded")
continue
mouse_up_pos = event.pos
mouse_up_time = pygame.time.get_ticks() / 1000.0
# Calculate distance and time
dx = mouse_up_pos[0] - self.mouse_down_pos[0]
dy = mouse_up_pos[1] - self.mouse_down_pos[1]
distance = (dx**2 + dy**2) ** 0.5
duration = mouse_up_time - self.mouse_down_time
logger.info(f"[MOUSE] Button UP at {mouse_up_pos}")
logger.info(f"[GESTURE] dx={dx:.0f}, dy={dy:.0f}, distance={distance:.0f}px, duration={duration:.2f}s, threshold={self.drag_threshold}px")
# Detect gesture type
gesture = None
# For swipe gestures, use the starting position (mouse_down_pos)
# For tap/long-press, use the ending position (mouse_up_pos)
x, y = mouse_up_pos
if distance < self.drag_threshold:
# Tap or long press
if duration >= self.long_press_duration:
gesture = GestureType.LONG_PRESS
logger.info(f"[GESTURE] ✓ Detected: LONG_PRESS")
else:
gesture = GestureType.TAP
logger.info(f"[GESTURE] ✓ Detected: TAP")
else:
# Swipe - use starting position for location-based checks
x, y = self.mouse_down_pos
if abs(dx) > abs(dy):
# Horizontal swipe
if dx > 0:
gesture = GestureType.SWIPE_RIGHT
logger.info(f"[GESTURE] ✓ Detected: SWIPE_RIGHT (dx={dx:.0f})")
else:
gesture = GestureType.SWIPE_LEFT
logger.info(f"[GESTURE] ✓ Detected: SWIPE_LEFT (dx={dx:.0f})")
else:
# Vertical swipe
if dy > 0:
gesture = GestureType.SWIPE_DOWN
logger.info(f"[GESTURE] ✓ Detected: SWIPE_DOWN (dy={dy:.0f})")
else:
gesture = GestureType.SWIPE_UP
logger.info(f"[GESTURE] ✓ Detected: SWIPE_UP (dy={dy:.0f})")
# Reset tracking
self.mouse_down_pos = None
if gesture:
# For swipe gestures, (x,y) is the start position
# For tap/long-press, (x,y) is the tap position
logger.info(f"[EVENT] Returning TouchEvent: {gesture.value} at ({x}, {y})")
return TouchEvent(gesture, x, y)
else:
logger.warning("[EVENT] No gesture detected (should not happen)")
elif event.type == pygame.KEYDOWN:
# Keyboard shortcuts
return await self._handle_keyboard(event)
return None
async def _handle_keyboard(self, event) -> Optional[TouchEvent]:
"""
Handle keyboard shortcuts.
Args:
event: Pygame keyboard event
Returns:
TouchEvent equivalent of keyboard action
"""
# Arrow keys for page navigation
if event.key == pygame.K_LEFT or event.key == pygame.K_PAGEUP:
# Previous page
return TouchEvent(GestureType.SWIPE_RIGHT, self.width // 2, self.height // 2)
elif event.key == pygame.K_RIGHT or event.key == pygame.K_PAGEDOWN or event.key == pygame.K_SPACE:
# Next page
return TouchEvent(GestureType.SWIPE_LEFT, self.width // 2, self.height // 2)
elif event.key == pygame.K_UP:
# Scroll up (if applicable)
return TouchEvent(GestureType.SWIPE_DOWN, self.width // 2, self.height // 2)
elif event.key == pygame.K_DOWN:
# Scroll down (if applicable)
return TouchEvent(GestureType.SWIPE_UP, self.width // 2, self.height // 2)
elif event.key == pygame.K_ESCAPE or event.key == pygame.K_q:
# Quit
logger.info("Quit via keyboard")
self.running = False
return None
elif event.key == pygame.K_EQUALS or event.key == pygame.K_PLUS:
# Zoom in (pinch out)
return TouchEvent(GestureType.PINCH_OUT, self.width // 2, self.height // 2)
elif event.key == pygame.K_MINUS:
# Zoom out (pinch in)
return TouchEvent(GestureType.PINCH_IN, self.width // 2, self.height // 2)
return None
async def set_brightness(self, level: int):
"""
Set display brightness (not supported in Pygame).
Args:
level: Brightness level (0-10)
Note: Brightness control is not available in Pygame.
This is a no-op for desktop testing.
"""
logger.debug(f"Brightness set to {level} (not supported in Pygame)")
async def run_event_loop(self, app):
"""
Run the Pygame event loop.
Args:
app: DReaderApplication instance
This method:
1. Initializes Pygame
2. Starts the application
3. Runs the event loop
4. Handles events and updates display
5. Shuts down gracefully
"""
logger.info("Starting Pygame event loop")
try:
# Initialize
await self.initialize()
await app.start()
self.running = True
# Show instructions
await self._show_instructions()
await asyncio.sleep(2)
# Main event loop
clock = pygame.time.Clock()
while self.running and app.is_running():
# Process events
touch_event = await self.get_touch_event()
if touch_event:
# Handle touch event
await app.handle_touch(touch_event)
# Cap frame rate
clock.tick(60) # 60 FPS max
await asyncio.sleep(0.001) # Yield to other async tasks
logger.info("Event loop ended")
except Exception as e:
logger.error(f"Error in event loop: {e}", exc_info=True)
raise
finally:
# Shutdown
logger.info("Shutting down application")
await app.shutdown()
await self.cleanup()
async def _show_instructions(self):
"""Show keyboard instructions overlay."""
if not self.screen:
return
# Create instruction text
font = pygame.font.Font(None, 24)
instructions = [
"DReader E-Book Reader",
"",
"Mouse Gestures:",
" Drag LEFT (horizontal) = Next Page",
" Drag RIGHT (horizontal) = Previous Page*",
" Drag UP (vertical) = Navigation/TOC Overlay",
" Drag DOWN (vertical) = Settings Overlay",
"",
"Keyboard Shortcuts:",
" Space / Right Arrow = Next Page",
" Left Arrow = Previous Page*",
" +/- = Font Size",
" Q/Escape = Quit",
"",
"*Previous page not working (pyWebLayout bug)",
"",
"Press any key to start..."
]
# Create semi-transparent overlay
overlay = pygame.Surface((self.width, self.height))
overlay.fill((255, 255, 255))
overlay.set_alpha(230)
# Render text
y = 100
for line in instructions:
if line:
text = font.render(line, True, (0, 0, 0))
else:
text = pygame.Surface((1, 20)) # Empty line
text_rect = text.get_rect(center=(self.width // 2, y))
overlay.blit(text, text_rect)
y += 30
# Display
self.screen.blit(overlay, (0, 0))
pygame.display.flip()

View File

@ -0,0 +1,10 @@
"""
Handlers module for dreader application.
This module contains interaction handlers:
- GestureRouter: Routes touch events to appropriate handlers
"""
from .gestures import GestureRouter
__all__ = ['GestureRouter']

View File

@ -0,0 +1,283 @@
"""
Gesture routing and handling.
This module handles all touch event routing and gesture logic.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Tuple
from ..gesture import TouchEvent, GestureType, GestureResponse, ActionType
if TYPE_CHECKING:
from ..application import EbookReader
from pyWebLayout.core.query import SelectionRange
class GestureRouter:
"""
Routes and handles all gestures.
This class centralizes all gesture handling logic, making it easier
to test and maintain gesture interactions.
"""
def __init__(self, reader: 'EbookReader'):
"""
Initialize the gesture router.
Args:
reader: EbookReader instance to route gestures for
"""
self.reader = reader
# Selection state (for text selection gestures)
self._selection_start: Optional[Tuple[int, int]] = None
self._selection_end: Optional[Tuple[int, int]] = None
self._selected_range: Optional['SelectionRange'] = None
def handle_touch(self, event: TouchEvent) -> GestureResponse:
"""
Handle a touch event from HAL.
This is the main entry point for all touch interactions.
Args:
event: TouchEvent from HAL with gesture type and coordinates
Returns:
GestureResponse with action and data for UI to process
"""
if not self.reader.is_loaded():
return GestureResponse(ActionType.ERROR, {"message": "No book loaded"})
# Handle overlay-specific gestures first
if self.reader.is_overlay_open():
if event.gesture == GestureType.TAP:
return self._handle_overlay_tap(event.x, event.y)
elif event.gesture == GestureType.SWIPE_DOWN:
return self._handle_overlay_close()
# Dispatch based on gesture type for normal reading mode
if event.gesture == GestureType.TAP:
return self._handle_tap(event.x, event.y)
elif event.gesture == GestureType.LONG_PRESS:
return self._handle_long_press(event.x, event.y)
elif event.gesture == GestureType.SWIPE_LEFT:
return self._handle_page_forward()
elif event.gesture == GestureType.SWIPE_RIGHT:
return self._handle_page_back()
elif event.gesture == GestureType.SWIPE_UP:
return self._handle_swipe_up(event.y)
elif event.gesture == GestureType.SWIPE_DOWN:
return self._handle_swipe_down(event.y)
elif event.gesture == GestureType.PINCH_IN:
return self._handle_zoom_out()
elif event.gesture == GestureType.PINCH_OUT:
return self._handle_zoom_in()
elif event.gesture == GestureType.DRAG_START:
return self._handle_selection_start(event.x, event.y)
elif event.gesture == GestureType.DRAG_MOVE:
return self._handle_selection_move(event.x, event.y)
elif event.gesture == GestureType.DRAG_END:
return self._handle_selection_end(event.x, event.y)
elif event.gesture == GestureType.TILT_FORWARD:
return self._handle_page_forward()
elif event.gesture == GestureType.TILT_BACKWARD:
return self._handle_page_back()
return GestureResponse(ActionType.NONE, {})
# ===================================================================
# Reading Mode Gesture Handlers
# ===================================================================
def _handle_tap(self, x: int, y: int) -> GestureResponse:
"""Handle tap gesture - activates links or selects words"""
page = self.reader.manager.get_current_page()
result = page.query_point((x, y))
if not result or result.object_type == "empty":
return GestureResponse(ActionType.NONE, {})
# If it's a link, navigate
if result.is_interactive and result.link_target:
# Handle different link types
if result.link_target.endswith('.epub'):
# Open new book
success = self.reader.load_epub(result.link_target)
if success:
return GestureResponse(ActionType.BOOK_LOADED, {
"title": self.reader.book_title,
"author": self.reader.book_author,
"path": result.link_target
})
else:
return GestureResponse(ActionType.ERROR, {
"message": f"Failed to load {result.link_target}"
})
else:
# Internal navigation (chapter)
self.reader.jump_to_chapter(result.link_target)
return GestureResponse(ActionType.NAVIGATE, {
"target": result.link_target,
"chapter": self.reader.get_current_chapter_info()
})
# Just a tap on text - select word
if result.text:
return GestureResponse(ActionType.WORD_SELECTED, {
"word": result.text,
"bounds": result.bounds
})
return GestureResponse(ActionType.NONE, {})
def _handle_long_press(self, x: int, y: int) -> GestureResponse:
"""Handle long-press - show definition or menu"""
page = self.reader.manager.get_current_page()
result = page.query_point((x, y))
if result and result.text:
return GestureResponse(ActionType.DEFINE, {
"word": result.text,
"bounds": result.bounds
})
# Long-press on empty - show menu
return GestureResponse(ActionType.SHOW_MENU, {
"options": ["bookmark", "settings", "toc", "search"]
})
def _handle_page_forward(self) -> GestureResponse:
"""Handle swipe left - next page"""
img = self.reader.next_page()
if img:
return GestureResponse(ActionType.PAGE_TURN, {
"direction": "forward",
"progress": self.reader.get_reading_progress(),
"chapter": self.reader.get_current_chapter_info()
})
return GestureResponse(ActionType.AT_END, {})
def _handle_page_back(self) -> GestureResponse:
"""Handle swipe right - previous page"""
img = self.reader.previous_page()
if img:
return GestureResponse(ActionType.PAGE_TURN, {
"direction": "back",
"progress": self.reader.get_reading_progress(),
"chapter": self.reader.get_current_chapter_info()
})
return GestureResponse(ActionType.AT_START, {})
def _handle_zoom_in(self) -> GestureResponse:
"""Handle pinch out - increase font"""
self.reader.increase_font_size()
return GestureResponse(ActionType.ZOOM, {
"direction": "in",
"font_scale": self.reader.base_font_scale
})
def _handle_zoom_out(self) -> GestureResponse:
"""Handle pinch in - decrease font"""
self.reader.decrease_font_size()
return GestureResponse(ActionType.ZOOM, {
"direction": "out",
"font_scale": self.reader.base_font_scale
})
def _handle_selection_start(self, x: int, y: int) -> GestureResponse:
"""Start text selection"""
self._selection_start = (x, y)
self._selection_end = None
self._selected_range = None
return GestureResponse(ActionType.SELECTION_START, {
"start": (x, y)
})
def _handle_selection_move(self, x: int, y: int) -> GestureResponse:
"""Update text selection"""
if not self._selection_start:
return GestureResponse(ActionType.NONE, {})
self._selection_end = (x, y)
# Query range
page = self.reader.manager.get_current_page()
self._selected_range = page.query_range(
self._selection_start,
self._selection_end
)
return GestureResponse(ActionType.SELECTION_UPDATE, {
"start": self._selection_start,
"end": self._selection_end,
"text_count": len(self._selected_range.results),
"bounds": self._selected_range.bounds_list
})
def _handle_selection_end(self, x: int, y: int) -> GestureResponse:
"""End text selection and return selected text"""
if not self._selection_start:
return GestureResponse(ActionType.NONE, {})
self._selection_end = (x, y)
page = self.reader.manager.get_current_page()
self._selected_range = page.query_range(
self._selection_start,
self._selection_end
)
return GestureResponse(ActionType.SELECTION_COMPLETE, {
"text": self._selected_range.text,
"word_count": len(self._selected_range.results),
"bounds": self._selected_range.bounds_list
})
def _handle_swipe_up(self, y: int) -> GestureResponse:
"""Handle swipe up gesture - opens Navigation overlay (TOC + Bookmarks)"""
# Open navigation overlay from anywhere on screen
overlay_image = self.reader.open_navigation_overlay(active_tab="contents")
if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, {
"overlay_type": "navigation",
"active_tab": "contents",
"chapters": self.reader.get_chapters()
})
return GestureResponse(ActionType.NONE, {})
def _handle_swipe_down(self, y: int) -> GestureResponse:
"""Handle swipe down gesture - opens Settings overlay (only from top 20% of screen)"""
# Only open settings overlay if swipe starts from top 20% of screen
top_threshold = self.reader.page_size[1] * 0.2
if y > top_threshold:
return GestureResponse(ActionType.NONE, {})
overlay_image = self.reader.open_settings_overlay()
if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, {
"overlay_type": "settings",
"font_scale": self.reader.base_font_scale,
"line_spacing": self.reader.page_style.line_spacing,
"inter_block_spacing": self.reader.page_style.inter_block_spacing
})
return GestureResponse(ActionType.NONE, {})
# ===================================================================
# Overlay Mode Gesture Handlers
# ===================================================================
def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse:
"""Handle tap when overlay is open - delegates to EbookReader overlay handlers"""
# This remains in EbookReader because it's tightly coupled with overlay state
return self.reader._handle_overlay_tap(x, y)
def _handle_overlay_close(self) -> GestureResponse:
"""Handle overlay close gesture (swipe down)"""
self.reader.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})

763
dreader/html_generator.py Normal file
View File

@ -0,0 +1,763 @@
"""
HTML generation functions for dreader UI.
Generates HTML strings programmatically for library view, reader view,
and various overlays (settings, TOC, etc.) that can be passed to a HAL
for rendering.
"""
from pathlib import Path
from typing import List, Dict, Optional
import base64
from io import BytesIO
def generate_library_html(books: List[Dict[str, str]], save_covers_to_disk: bool = False) -> str:
"""
Generate HTML for the library view showing all books in a simple table.
Args:
books: List of book dictionaries with keys:
- title: Book title
- 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 = []
for book in books:
# Add cover image cell if available
if save_covers_to_disk and book.get('cover_path'):
cover_cell = f'<td><img src="{book["cover_path"]}" width="150"/></td>'
elif book.get('cover_data'):
cover_cell = f'<td><img src="data:image/png;base64,{book["cover_data"]}" width="150"/></td>'
else:
cover_cell = '<td>[No cover]</td>'
# Add book info cell
info_cell = f'<td><b>{book["title"]}</b><br/>{book["author"]}</td>'
rows.append(f'<tr>{cover_cell}{info_cell}</tr>')
table_html = '\n'.join(rows)
return f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Library</title>
</head>
<body>
<h1>My Library</h1>
<p>{len(books)} books</p>
<table>
{table_html}
</table>
</body>
</html>'''
def generate_reader_html(book_title: str, book_author: str, page_image_data: str) -> str:
"""
Generate HTML for the reader view with page display.
Args:
book_title: Title of current book
book_author: Author of current book
page_image_data: Base64 encoded page image
Returns:
Complete HTML string for reader view (page layer only)
"""
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{book_title}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: Arial, sans-serif;
background-color: #2c2c2c;
display: flex;
flex-direction: column;
height: 100vh;
}}
.header {{
background-color: #1a1a1a;
color: white;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}}
.book-info {{
flex: 1;
}}
.book-title {{
font-weight: bold;
font-size: 16px;
}}
.book-author {{
font-size: 12px;
color: #aaa;
}}
.header-buttons {{
display: flex;
gap: 10px;
}}
.header-button {{
background-color: #444;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}}
.header-button:hover {{
background-color: #555;
}}
.page-container {{
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}}
.page-image {{
max-width: 100%;
max-height: 100%;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}}
.footer {{
background-color: #1a1a1a;
color: white;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}}
.nav-button {{
background-color: #444;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}}
.nav-button:hover {{
background-color: #555;
}}
</style>
</head>
<body>
<div class="header">
<div class="book-info">
<div class="book-title">{book_title}</div>
<div class="book-author">{book_author}</div>
</div>
<div class="header-buttons">
<button class="header-button" id="btn-library">Library</button>
<button class="header-button" id="btn-toc">Contents</button>
<button class="header-button" id="btn-settings">Settings</button>
</div>
</div>
<div class="page-container">
<img src="data:image/png;base64,{page_image_data}" alt="Page" class="page-image">
</div>
<div class="footer">
<button class="nav-button" id="btn-prev"> Previous</button>
<div id="page-info"></div>
<button class="nav-button" id="btn-next">Next </button>
</div>
</body>
</html>
'''
return html
def generate_settings_overlay(
font_scale: float = 1.0,
line_spacing: int = 5,
inter_block_spacing: int = 15,
word_spacing: int = 0,
font_family: str = "Default",
page_size: tuple = (800, 1200)
) -> str:
"""
Generate HTML for the settings overlay with current values.
Uses simple paragraphs with links, similar to TOC overlay,
since pyWebLayout doesn't support HTML tables.
Args:
font_scale: Current font scale (e.g., 1.0 = 100%, 1.2 = 120%)
line_spacing: Current line spacing in pixels
inter_block_spacing: Current inter-block spacing in pixels
word_spacing: Current word spacing in pixels
font_family: Current font family ("Default", "SERIF", "SANS", "MONOSPACE")
page_size: Page dimensions (width, height) for sizing the overlay
Returns:
HTML string for settings overlay with clickable controls
"""
# Format current values for display
font_percent = int(font_scale * 100)
# Map font family names to display names
font_display_names = {
"Default": "Document Default",
"SERIF": "Serif",
"SANS": "Sans-Serif",
"MONOSPACE": "Monospace"
}
font_family_display = font_display_names.get(font_family, font_family)
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Settings</title>
</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;">
Settings
</h1>
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
border-bottom: 2px solid #ccc; font-size: 13px;">
Adjust reading preferences
</p>
<div style="margin: 15px 0;">
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #6f42c1;">
<b>Font Family: {font_family_display}</b>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_family_default" style="text-decoration: none; color: #000; display: block; padding: 12px;">Document Default</a>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_family_serif" style="text-decoration: none; color: #000; display: block; padding: 12px;">Serif</a>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_family_sans" style="text-decoration: none; color: #000; display: block; padding: 12px;">Sans-Serif</a>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_family_monospace" style="text-decoration: none; color: #000; display: block; padding: 12px;">Monospace</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #007bff;">
<b>Font Size: {font_percent}%</b>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:font_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #28a745;">
<b>Line Spacing: {line_spacing}px</b>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:line_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:line_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #17a2b8;">
<b>Paragraph Spacing: {inter_block_spacing}px</b>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:block_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:block_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
</p>
<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #ffc107;">
<b>Word Spacing: {word_spacing}px</b>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:word_spacing_decrease" style="text-decoration: none; color: #000; display: block; padding: 12px;">Decrease [ - ]</a>
</p>
<p style="margin: 5px 0; background-color: #f0f0f0;">
<a href="setting:word_spacing_increase" style="text-decoration: none; color: #000; display: block; padding: 12px;">Increase [ + ]</a>
</p>
</div>
<div style="margin: 20px 0;">
<p style="margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px; display: block; padding: 15px;"> Back to Library</a>
</p>
</div>
<p style="text-align: center; margin: 15px 0 0 0; padding-top: 12px;
border-top: 2px solid #ccc; color: #888; font-size: 11px;">
Changes apply in real-time Tap outside to close
</p>
</body>
</html>
'''
return html
def generate_toc_overlay(
chapters: List[Dict],
page_size: tuple = (800, 1200),
toc_page: int = 0,
toc_items_per_page: int = 10
) -> str:
"""
Generate HTML for the table of contents overlay.
Args:
chapters: List of chapter dictionaries with keys:
- index: Chapter index
- title: Chapter title
page_size: Page dimensions (width, height) for sizing the overlay
toc_page: Current page number (0-indexed)
toc_items_per_page: Number of items to show per page
Returns:
HTML string for TOC overlay (60% popup with transparent background)
"""
# Calculate pagination
toc_total_pages = (len(chapters) + toc_items_per_page - 1) // toc_items_per_page if chapters else 1
toc_start = toc_page * toc_items_per_page
toc_end = min(toc_start + toc_items_per_page, len(chapters))
toc_paginated = chapters[toc_start:toc_end]
# Build chapter list items with clickable links for pyWebLayout query
chapter_items = []
for i, chapter in enumerate(toc_paginated):
title = chapter["title"]
# Use original chapter number (not the paginated index)
chapter_num = toc_start + i + 1
# 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'{chapter_num}. {title}'
if len(title) <= 2:
# Add extra padding spaces inside the link to make it easier to click
link_text = f'{chapter_num}. {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>'
)
# Generate pagination controls
toc_pagination = ""
if toc_total_pages > 1:
prev_disabled = 'opacity: 0.3; pointer-events: none;' if toc_page == 0 else ''
next_disabled = 'opacity: 0.3; pointer-events: none;' if toc_page >= toc_total_pages - 1 else ''
toc_pagination = f'''
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; padding-top: 12px; border-top: 2px solid #ccc;">
<a href="page:prev" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {prev_disabled}">
Prev
</a>
<span style="color: #666; font-size: 13px;">
Page {toc_page + 1} of {toc_total_pages}
</span>
<a href="page:next" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {next_disabled}">
Next
</a>
</div>
'''
# Render simple white panel - compositing will be done by OverlayManager
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Table of Contents</title>
</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="min-height: 400px;">
{"".join(chapter_items)}
</div>
{toc_pagination}
<p style="text-align: center; margin: 15px 0 0 0; padding-top: 12px;
border-top: 2px solid #ccc; color: #888; font-size: 11px;">
Tap a chapter to navigate Tap outside to close
</p>
</body>
</html>
'''
return html
def generate_bookmarks_overlay(bookmarks: List[Dict]) -> str:
"""
Generate HTML for the bookmarks overlay.
Args:
bookmarks: List of bookmark dictionaries with keys:
- name: Bookmark name
- position: Position info
Returns:
HTML string for bookmarks overlay
"""
bookmark_rows = []
for bookmark in bookmarks:
bookmark_rows.append(f'''
<tr class="bookmark-row" data-bookmark-name="{bookmark['name']}">
<td class="bookmark-cell">
<div class="bookmark-name">{bookmark['name']}</div>
<div class="bookmark-position">{bookmark.get('position', '')}</div>
</td>
<td class="bookmark-actions">
<button class="action-button delete-button" data-bookmark="{bookmark['name']}">Delete</button>
</td>
</tr>
''')
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookmarks</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;
}}
.bookmarks-container {{
overflow-y: auto;
flex: 1;
}}
.bookmarks-table {{
width: 100%;
border-collapse: collapse;
}}
.bookmark-row {{
cursor: pointer;
}}
.bookmark-row:hover {{
background-color: #f0f0f0;
}}
.bookmark-cell {{
padding: 12px;
border-bottom: 1px solid #eee;
}}
.bookmark-name {{
font-weight: bold;
margin-bottom: 4px;
}}
.bookmark-position {{
font-size: 12px;
color: #666;
}}
.bookmark-actions {{
padding: 12px;
border-bottom: 1px solid #eee;
text-align: right;
width: 100px;
}}
.action-button {{
background-color: #dc3545;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}}
.action-button:hover {{
background-color: #c82333;
}}
</style>
</head>
<body>
<div class="overlay-panel">
<div class="overlay-header">
<span class="overlay-title">Bookmarks</span>
<button class="close-button" id="btn-close">Close</button>
</div>
<div class="bookmarks-container">
<table class="bookmarks-table">
{"".join(bookmark_rows)}
</table>
</div>
</div>
</body>
</html>
'''
return html
def generate_navigation_overlay(
chapters: List[Dict],
bookmarks: List[Dict],
active_tab: str = "contents",
page_size: tuple = (800, 1200),
toc_page: int = 0,
toc_items_per_page: int = 10,
bookmarks_page: int = 0
) -> str:
"""
Generate HTML for the unified navigation overlay with Contents and Bookmarks tabs.
This combines TOC and Bookmarks into a single overlay with tab switching and pagination.
Tabs are clickable links that switch between contents (tab:contents) and bookmarks (tab:bookmarks).
Pagination buttons (page:next, page:prev) allow navigating through large lists.
Args:
chapters: List of chapter dictionaries with keys:
- index: Chapter index
- title: Chapter title
bookmarks: List of bookmark dictionaries with keys:
- name: Bookmark name
- position: Position info (optional)
active_tab: Which tab to show ("contents" or "bookmarks")
page_size: Page dimensions (width, height) for sizing the overlay
toc_page: Current page number for TOC (0-indexed)
toc_items_per_page: Number of items to show per page
bookmarks_page: Current page number for bookmarks (0-indexed)
Returns:
HTML string for navigation overlay with tab switching and pagination
"""
# Calculate pagination for chapters
toc_total_pages = (len(chapters) + toc_items_per_page - 1) // toc_items_per_page if chapters else 1
toc_start = toc_page * toc_items_per_page
toc_end = min(toc_start + toc_items_per_page, len(chapters))
toc_paginated = chapters[toc_start:toc_end]
# Build chapter list items with clickable links
chapter_items = []
for i, chapter in enumerate(toc_paginated):
title = chapter["title"]
# Use original chapter number (not the paginated index)
chapter_num = toc_start + i + 1
link_text = f'{chapter_num}. {title}'
if len(title) <= 2:
link_text = f'{chapter_num}. {title} ' # Extra spaces for padding
chapter_items.append(
f'<p style="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
f'{link_text}</a></p>'
)
# Calculate pagination for bookmarks
bookmarks_total_pages = (len(bookmarks) + toc_items_per_page - 1) // toc_items_per_page if bookmarks else 1
bookmarks_start = bookmarks_page * toc_items_per_page
bookmarks_end = min(bookmarks_start + toc_items_per_page, len(bookmarks))
bookmarks_paginated = bookmarks[bookmarks_start:bookmarks_end]
# Build bookmark list items with clickable links
bookmark_items = []
for bookmark in bookmarks_paginated:
name = bookmark['name']
position_text = bookmark.get('position', 'Saved position')
bookmark_items.append(
f'<p style="margin: 5px 0; background-color: #f0f0f0; border-left: 3px solid #000;">'
f'<a href="bookmark:{name}" style="text-decoration: none; color: #000; display: block; padding: 12px;">'
f'<span style="font-weight: bold; display: block;">{name}</span>'
f'<span style="font-size: 11px; color: #666;">{position_text}</span>'
f'</a></p>'
)
# Determine which content to show
contents_display = "block" if active_tab == "contents" else "none"
bookmarks_display = "block" if active_tab == "bookmarks" else "none"
# Style active tab
contents_tab_style = "background-color: #000; color: #fff;" if active_tab == "contents" else "background-color: #f0f0f0; color: #000;"
bookmarks_tab_style = "background-color: #000; color: #fff;" if active_tab == "bookmarks" else "background-color: #f0f0f0; color: #000;"
chapters_html = ''.join(chapter_items) if chapter_items else '<p style="padding: 20px; text-align: center; color: #999;">No chapters available</p>'
bookmarks_html = ''.join(bookmark_items) if bookmark_items else '<p style="padding: 20px; text-align: center; color: #999;">No bookmarks yet</p>'
# Generate pagination controls for TOC
toc_pagination = ""
if toc_total_pages > 1:
prev_disabled = 'opacity: 0.3; pointer-events: none;' if toc_page == 0 else ''
next_disabled = 'opacity: 0.3; pointer-events: none;' if toc_page >= toc_total_pages - 1 else ''
toc_pagination = f'''
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; padding-top: 12px; border-top: 2px solid #ccc;">
<a href="page:prev" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {prev_disabled}">
Prev
</a>
<span style="color: #666; font-size: 13px;">
Page {toc_page + 1} of {toc_total_pages}
</span>
<a href="page:next" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {next_disabled}">
Next
</a>
</div>
'''
# Generate pagination controls for Bookmarks
bookmarks_pagination = ""
if bookmarks_total_pages > 1:
prev_disabled = 'opacity: 0.3; pointer-events: none;' if bookmarks_page == 0 else ''
next_disabled = 'opacity: 0.3; pointer-events: none;' if bookmarks_page >= bookmarks_total_pages - 1 else ''
bookmarks_pagination = f'''
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; padding-top: 12px; border-top: 2px solid #ccc;">
<a href="page:prev" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {prev_disabled}">
Prev
</a>
<span style="color: #666; font-size: 13px;">
Page {bookmarks_page + 1} of {bookmarks_total_pages}
</span>
<a href="page:next" style="text-decoration: none; color: #000; display: block; padding: 10px 20px; background-color: #e0e0e0; border-radius: 4px; font-weight: bold; {next_disabled}">
Next
</a>
</div>
'''
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Navigation</title>
</head>
<body style="background-color: white; margin: 0; padding: 0; font-family: Arial, sans-serif;">
<!-- Tab Bar -->
<div style="display: flex; border-bottom: 2px solid #ccc; background-color: #f8f8f8;">
<a href="tab:contents"
style="flex: 1; padding: 15px; text-align: center; font-weight: bold;
text-decoration: none; border-right: 1px solid #ccc; {contents_tab_style}">
Contents
</a>
<a href="tab:bookmarks"
style="flex: 1; padding: 15px; text-align: center; font-weight: bold;
text-decoration: none; {bookmarks_tab_style}">
Bookmarks
</a>
</div>
<!-- Contents Tab Content -->
<div id="contents-tab" style="padding: 25px; display: {contents_display};">
<h2 style="color: #000; margin: 0 0 15px 0; font-size: 20px; text-align: center;">
Table of Contents
</h2>
<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="min-height: 400px;">
{chapters_html}
</div>
{toc_pagination}
</div>
<!-- Bookmarks Tab Content -->
<div id="bookmarks-tab" style="padding: 25px; display: {bookmarks_display};">
<h2 style="color: #000; margin: 0 0 15px 0; font-size: 20px; text-align: center;">
Bookmarks
</h2>
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
border-bottom: 2px solid #ccc; font-size: 13px;">
{len(bookmarks)} saved
</p>
<div style="min-height: 400px;">
{bookmarks_html}
</div>
{bookmarks_pagination}
</div>
<!-- Close Button (bottom right) -->
<div style="position: fixed; bottom: 20px; right: 20px;">
<a href="action:close"
style="display: inline-block; padding: 12px 24px; background-color: #dc3545;
color: white; text-decoration: none; border-radius: 4px; font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
Close
</a>
</div>
</body>
</html>
'''
return html

553
dreader/library.py Normal file
View File

@ -0,0 +1,553 @@
"""
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),
books_per_page: int = 6
):
"""
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
books_per_page: Number of books to display per page (must be even for 2-column layout, default: 6)
"""
self.library_path = Path(library_path)
self.page_size = page_size
self.books_per_page = books_per_page if books_per_page % 2 == 0 else books_per_page + 1
# 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
self.current_page: int = 0 # Current page index for pagination
@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, page: Optional[int] = None) -> Table:
"""
Create interactive library table with book covers and info in 2-column grid.
Args:
books: List of books to display. If None, uses self.books
page: Page number to display (0-indexed). If None, uses self.current_page
Returns:
Table object ready for rendering
"""
if books is None:
books = self.books
if page is None:
page = self.current_page
if not books:
print("No books to display in library")
books = []
# Calculate pagination
total_pages = (len(books) + self.books_per_page - 1) // self.books_per_page
start_idx = page * self.books_per_page
end_idx = min(start_idx + self.books_per_page, len(books))
page_books = books[start_idx:end_idx]
print(f"Creating library table with {len(page_books)} books (page {page + 1}/{total_pages})...")
# Create table with caption showing page info
caption_text = f"My Library (Page {page + 1}/{total_pages})" if total_pages > 1 else "My Library"
table = Table(caption=caption_text, style=Font(font_size=18, weight="bold"))
# Add books in 2-column grid (each pair of books gets 2 rows: covers then details)
for i in range(0, len(page_books), 2):
# Row 1: Covers for this pair
cover_row = table.create_row("body")
# Add first book's cover (left column)
self._add_book_cover(cover_row, page_books[i])
# Add second book's cover (right column) if it exists
if i + 1 < len(page_books):
self._add_book_cover(cover_row, page_books[i + 1])
else:
# Add empty cell if odd number of books
cover_row.create_cell()
# Row 2: Details for this pair
details_row = table.create_row("body")
# Add first book's details (left column)
self._add_book_details(details_row, page_books[i])
# Add second book's details (right column) if it exists
if i + 1 < len(page_books):
self._add_book_details(details_row, page_books[i + 1])
else:
# Add empty cell if odd number of books
details_row.create_cell()
self.library_table = table
return table
def _add_book_cover(self, row, book: Dict):
"""
Add a book cover to a table row.
Args:
row: Table row to add cover to
book: Book dictionary with metadata
"""
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
# Add cover image
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)
def _add_book_details(self, row, book: Dict):
"""
Add book details (title, author, filename) to a table row.
Args:
row: Table row to add details to
book: Book dictionary with metadata
"""
details_cell = row.create_cell()
# Title paragraph
title_para = details_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 = details_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 = details_cell.create_paragraph()
filename_para.add_word(Word(
Path(book['path']).name,
Font(font_size=10, colour=(150, 150, 150))
))
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 with 2-column grid.
The layout has alternating rows: cover rows and detail rows.
Each pair of rows (cover + detail) represents one pair of books (2 books).
Tapping on either the cover row or detail row selects the corresponding book.
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:
# Get paginated books for current page
start_idx = self.current_page * self.books_per_page
end_idx = min(start_idx + self.books_per_page, len(self.books))
page_books = self.books[start_idx:end_idx]
# 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")
# Each pair of books uses 2 rows (cover row + detail row)
# Determine which book pair this row belongs to
book_pair_index = body_row_index // 2 # Which pair of books (0, 1, 2, ...)
is_cover_row = body_row_index % 2 == 0 # Even rows are covers, odd are details
# Check cell renderers in this row
if hasattr(row_renderer, '_cell_renderers') and len(row_renderer._cell_renderers) >= 1:
# Check left cell (first book in pair)
left_cell = row_renderer._cell_renderers[0]
left_x, left_y = left_cell._origin
left_w, left_h = left_cell._size
if (left_x <= x <= left_x + left_w and
left_y <= y <= left_y + left_h):
# Left column (first book in pair)
book_index = book_pair_index * 2
if book_index < len(page_books):
book_path = page_books[book_index]['path']
row_type = "cover" if is_cover_row else "detail"
print(f"Book selected (pair {book_pair_index}, left {row_type}): {book_path}")
return book_path
# Check right cell (second book in pair) if it exists
if len(row_renderer._cell_renderers) >= 2:
right_cell = row_renderer._cell_renderers[1]
right_x, right_y = right_cell._origin
right_w, right_h = right_cell._size
if (right_x <= x <= right_x + right_w and
right_y <= y <= right_y + right_h):
# Right column (second book in pair)
book_index = book_pair_index * 2 + 1
if book_index < len(page_books):
book_path = page_books[book_index]['path']
row_type = "cover" if is_cover_row else "detail"
print(f"Book selected (pair {book_pair_index}, right {row_type}): {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 next_page(self) -> bool:
"""
Navigate to next page of library.
Returns:
True if page changed, False if already on last page
"""
total_pages = (len(self.books) + self.books_per_page - 1) // self.books_per_page
if self.current_page < total_pages - 1:
self.current_page += 1
return True
return False
def previous_page(self) -> bool:
"""
Navigate to previous page of library.
Returns:
True if page changed, False if already on first page
"""
if self.current_page > 0:
self.current_page -= 1
return True
return False
def set_page(self, page: int) -> bool:
"""
Set current page.
Args:
page: Page number (0-indexed)
Returns:
True if page changed, False if invalid page
"""
total_pages = (len(self.books) + self.books_per_page - 1) // self.books_per_page
if 0 <= page < total_pages:
self.current_page = page
return True
return False
def get_total_pages(self) -> int:
"""
Get total number of pages.
Returns:
Total number of pages
"""
return (len(self.books) + self.books_per_page - 1) // self.books_per_page
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()

453
dreader/main.py Normal file
View File

@ -0,0 +1,453 @@
"""
Main application controller for DReader e-reader application.
This module provides the DReaderApplication class which orchestrates:
- Library and reading mode transitions
- State persistence and recovery
- HAL integration for display and input
- Event routing and handling
The application uses asyncio for non-blocking operations and integrates
with a hardware abstraction layer (HAL) for platform independence.
"""
import asyncio
import logging
from pathlib import Path
from typing import Optional
from PIL import Image
from .library import LibraryManager
from .application import EbookReader
from .state import StateManager, EreaderMode, OverlayState, BookState
from .gesture import TouchEvent, GestureType, ActionType
logger = logging.getLogger(__name__)
class AppConfig:
"""
Configuration for DReaderApplication.
Attributes:
display_hal: Hardware abstraction layer for display/input
library_path: Path to directory containing EPUB files
page_size: Tuple of (width, height) for rendered pages
bookmarks_dir: Directory for bookmark storage (default: ~/.config/dreader/bookmarks)
highlights_dir: Directory for highlights storage (default: ~/.config/dreader/highlights)
state_file: Path to state JSON file (default: ~/.config/dreader/state.json)
auto_save_interval: Seconds between automatic state saves (default: 60)
force_library_mode: If True, always start in library mode (default: False)
log_level: Logging level (default: logging.INFO)
"""
def __init__(
self,
display_hal,
library_path: str,
page_size: tuple[int, int] = (800, 1200),
bookmarks_dir: Optional[str] = None,
highlights_dir: Optional[str] = None,
state_file: Optional[str] = None,
auto_save_interval: int = 60,
force_library_mode: bool = False,
log_level: int = logging.INFO
):
self.display_hal = display_hal
self.library_path = library_path
self.page_size = page_size
self.force_library_mode = force_library_mode
# Set up default config paths
config_dir = Path.home() / ".config" / "dreader"
config_dir.mkdir(parents=True, exist_ok=True)
self.bookmarks_dir = bookmarks_dir or str(config_dir / "bookmarks")
self.highlights_dir = highlights_dir or str(config_dir / "highlights")
self.state_file = state_file or str(config_dir / "state.json")
self.auto_save_interval = auto_save_interval
self.log_level = log_level
class DReaderApplication:
"""
Main application controller coordinating library and reading modes.
This class orchestrates all major components of the e-reader:
- LibraryManager for book browsing
- EbookReader for reading books
- StateManager for persistence
- DisplayHAL for hardware integration
Usage:
config = AppConfig(
display_hal=MyDisplayHAL(),
library_path="/path/to/books"
)
app = DReaderApplication(config)
await app.start()
# In event loop:
await app.handle_touch(touch_event)
await app.shutdown()
"""
def __init__(self, config: AppConfig):
"""
Initialize the application with configuration.
Args:
config: Application configuration
"""
self.config = config
# Set up logging
logging.basicConfig(level=config.log_level)
logger.info("Initializing DReaderApplication")
# State management
self.state_manager = StateManager(
state_file=config.state_file,
auto_save_interval=config.auto_save_interval
)
self.state = self.state_manager.load_state()
logger.info(f"Loaded state: mode={self.state.mode}, current_book={self.state.current_book}")
# Components (lazy-initialized)
self.library: Optional[LibraryManager] = None
self.reader: Optional[EbookReader] = None
# Display abstraction
self.display_hal = config.display_hal
self.current_image: Optional[Image.Image] = None
# Running state
self.running = False
async def start(self):
"""
Start the application and display initial screen.
This method:
1. Starts automatic state saving
2. Restores previous mode or shows library
3. Displays the initial screen
"""
logger.info("Starting DReaderApplication")
self.running = True
# Start auto-save
self.state_manager.start_auto_save()
logger.info(f"Auto-save started (interval: {self.config.auto_save_interval}s)")
# Restore previous mode (or force library mode if configured)
force_library = getattr(self.config, 'force_library_mode', False)
if force_library:
logger.info("Force library mode enabled - starting in library")
await self._enter_library_mode()
elif self.state.mode == EreaderMode.READING and self.state.current_book:
logger.info(f"Resuming reading mode: {self.state.current_book.path}")
await self._enter_reading_mode(self.state.current_book.path)
else:
logger.info("Entering library mode")
await self._enter_library_mode()
# Display initial screen
await self._update_display()
logger.info("Application started successfully")
async def shutdown(self):
"""
Gracefully shutdown the application.
This method:
1. Saves current reading position
2. Closes active components
3. Stops auto-save and saves final state
"""
logger.info("Shutting down DReaderApplication")
self.running = False
# Save current position if reading
if self.reader and self.reader.is_loaded():
logger.info("Saving auto-resume position")
self.reader.save_position("__auto_resume__")
self.reader.close()
# Clean up library
if self.library:
self.library.cleanup()
# Stop auto-save and save final state
await self.state_manager.stop_auto_save(save_final=True)
logger.info("Application shutdown complete")
async def handle_touch(self, event: TouchEvent):
"""
Process touch event based on current mode.
Args:
event: Touch event from HAL
"""
logger.info(f"[APP] Received touch event: {event.gesture.value} at ({event.x}, {event.y}), mode={self.state.mode.value}")
if self.state.mode == EreaderMode.LIBRARY:
logger.info("[APP] Routing to library touch handler")
await self._handle_library_touch(event)
elif self.state.mode == EreaderMode.READING:
logger.info("[APP] Routing to reading touch handler")
await self._handle_reading_touch(event)
# Update display after handling
await self._update_display()
async def _enter_library_mode(self):
"""
Switch to library browsing mode.
This method:
1. Saves and closes reader if active
2. Initializes library manager
3. Renders library view
4. Updates state
"""
logger.info("Entering library mode")
# Save and close reader if active
if self.reader:
if self.reader.is_loaded():
logger.info("Saving reading position before closing")
self.reader.save_position("__auto_resume__")
self.reader.close()
self.reader = None
# Initialize library if needed
if not self.library:
logger.info(f"Initializing library manager: {self.config.library_path}")
self.library = LibraryManager(
library_path=self.config.library_path,
page_size=self.config.page_size,
cache_dir=None # Uses default ~/.config/dreader
)
# Scan for books (async operation)
logger.info("Scanning library for books")
books = self.library.scan_library()
logger.info(f"Found {len(books)} books")
# Render library view
logger.info("Rendering library view")
self.current_image = self.library.render_library()
# Update state
self.state_manager.set_mode(EreaderMode.LIBRARY)
logger.info("Library mode active")
async def _enter_reading_mode(self, book_path: str):
"""
Switch to reading mode.
Args:
book_path: Path to EPUB file to open
This method:
1. Initializes reader if needed
2. Loads the book
3. Applies saved settings
4. Restores reading position
5. Updates state
6. Renders first/current page
"""
logger.info(f"Entering reading mode: {book_path}")
# Verify book exists
if not Path(book_path).exists():
logger.error(f"Book not found: {book_path}")
# Return to library
await self._enter_library_mode()
return
# Initialize reader if needed
if not self.reader:
logger.info("Initializing ebook reader")
self.reader = EbookReader(
page_size=self.config.page_size,
margin=40,
background_color=(255, 255, 255),
bookmarks_dir=self.config.bookmarks_dir,
highlights_dir=self.config.highlights_dir
)
# Load book
logger.info(f"Loading EPUB: {book_path}")
success = self.reader.load_epub(book_path)
if not success:
logger.error(f"Failed to load EPUB: {book_path}")
# Return to library
await self._enter_library_mode()
return
logger.info(f"Loaded: {self.reader.book_title} by {self.reader.book_author}")
# Apply saved settings
logger.info("Applying saved settings")
settings_dict = self.state.settings.to_dict()
self.reader.apply_settings(settings_dict)
# Restore position
logger.info("Restoring reading position")
position_loaded = self.reader.load_position("__auto_resume__")
if position_loaded:
pos_info = self.reader.get_position_info()
logger.info(f"Resumed at position: {pos_info}")
else:
logger.info("No saved position, starting from beginning")
# Update state
self.state_manager.set_current_book(BookState(
path=book_path,
title=self.reader.book_title or "Unknown",
author=self.reader.book_author or "Unknown"
))
self.state_manager.set_mode(EreaderMode.READING)
# Render current page
logger.info("Rendering current page")
self.current_image = self.reader.get_current_page()
logger.info("Reading mode active")
async def _handle_library_touch(self, event: TouchEvent):
"""
Handle touch events in library mode.
Supports:
- TAP: Select a book to read
- SWIPE_LEFT: Next page
- SWIPE_RIGHT: Previous page
Args:
event: Touch event
"""
if event.gesture == GestureType.TAP:
logger.debug(f"Library tap at ({event.x}, {event.y})")
# Check if a book was selected
book_path = self.library.handle_library_tap(event.x, event.y)
if book_path:
logger.info(f"Book selected: {book_path}")
await self._enter_reading_mode(book_path)
else:
logger.debug("Tap did not hit a book")
elif event.gesture == GestureType.SWIPE_LEFT:
logger.debug("Library: swipe left (next page)")
if self.library.next_page():
logger.info(f"Library: moved to page {self.library.current_page + 1}/{self.library.get_total_pages()}")
# Re-render library with new page
self.library.create_library_table()
self.current_image = self.library.render_library()
else:
logger.debug("Library: already on last page")
elif event.gesture == GestureType.SWIPE_RIGHT:
logger.debug("Library: swipe right (previous page)")
if self.library.previous_page():
logger.info(f"Library: moved to page {self.library.current_page + 1}/{self.library.get_total_pages()}")
# Re-render library with new page
self.library.create_library_table()
self.current_image = self.library.render_library()
else:
logger.debug("Library: already on first page")
async def _handle_reading_touch(self, event: TouchEvent):
"""
Handle touch events in reading mode.
Args:
event: Touch event
"""
# Delegate to reader's gesture handler
logger.info(f"[APP] Calling reader.handle_touch({event.gesture.value})")
response = self.reader.handle_touch(event)
# response.action is already a string (ActionType enum value), not the enum itself
logger.info(f"[APP] Reader response: action={response.action}, data={response.data}")
# Handle special actions
if response.action == ActionType.BACK_TO_LIBRARY:
logger.info("[APP] → Returning to library")
await self._enter_library_mode()
elif response.action == ActionType.PAGE_TURN:
logger.info(f"[APP] → Page turned: {response.data}")
self.current_image = self.reader.get_current_page()
elif response.action == ActionType.OVERLAY_OPENED:
logger.info(f"[APP] → Overlay opened: {self.reader.get_overlay_state()}")
self.current_image = self.reader.get_current_page()
elif response.action == ActionType.OVERLAY_CLOSED:
logger.info("[APP] → Overlay closed")
self.current_image = self.reader.get_current_page()
elif response.action == ActionType.SETTING_CHANGED:
logger.info(f"[APP] → Setting changed: {response.data}")
# Update state with new settings
settings = self.reader.get_current_settings()
self.state_manager.update_settings(settings)
self.current_image = self.reader.get_current_page()
elif response.action == ActionType.CHAPTER_SELECTED:
logger.info(f"[APP] → Chapter selected: {response.data}")
self.current_image = self.reader.get_current_page()
elif response.action == ActionType.BOOKMARK_SELECTED:
logger.info(f"[APP] → Bookmark selected: {response.data}")
self.current_image = self.reader.get_current_page()
elif response.action == ActionType.NAVIGATE:
logger.debug("Navigation action")
self.current_image = self.reader.get_current_page()
elif response.action == ActionType.ZOOM:
logger.info(f"Zoom action: {response.data}")
# Font size changed
settings = self.reader.get_current_settings()
self.state_manager.update_settings(settings)
self.current_image = self.reader.get_current_page()
elif response.action == ActionType.ERROR:
logger.error(f"Error: {response.data}")
async def _update_display(self):
"""
Update the display with current image.
This method sends the current image to the HAL for display.
"""
if self.current_image:
logger.debug(f"Updating display: {self.current_image.size}")
await self.display_hal.show_image(self.current_image)
else:
logger.warning("No image to display")
def get_current_mode(self) -> EreaderMode:
"""Get current application mode."""
return self.state.mode
def get_overlay_state(self) -> OverlayState:
"""Get current overlay state (only valid in reading mode)."""
if self.reader:
return self.reader.get_overlay_state()
return OverlayState.NONE
def is_running(self) -> bool:
"""Check if application is running."""
return self.running

View File

@ -0,0 +1,14 @@
"""
Managers module for dreader application.
This module contains business logic managers that handle specific responsibilities:
- DocumentManager: Document loading and metadata
- SettingsManager: Font size, spacing, and rendering settings
- HighlightCoordinator: Highlight operations coordination
"""
from .document import DocumentManager
from .settings import SettingsManager
from .highlight_coordinator import HighlightCoordinator
__all__ = ['DocumentManager', 'SettingsManager', 'HighlightCoordinator']

View File

@ -0,0 +1,140 @@
"""
Document loading and metadata management.
This module handles EPUB and HTML loading, extracting blocks and metadata.
"""
from __future__ import annotations
from typing import List, Tuple, Dict, Any, Optional
from pathlib import Path
import os
from pyWebLayout.io.readers.epub_reader import read_epub
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.abstract.block import Block
class DocumentManager:
"""
Handles document loading and metadata extraction.
Responsibilities:
- Load EPUB files
- Load HTML content
- Extract document metadata (title, author, etc.)
- Extract content blocks for rendering
"""
def __init__(self):
"""Initialize the document manager."""
self.document_id: Optional[str] = None
self.title: Optional[str] = None
self.author: Optional[str] = None
self.blocks: Optional[List[Block]] = None
def load_epub(self, epub_path: str) -> bool:
"""
Load an EPUB file and extract content.
Args:
epub_path: Path to the EPUB file
Returns:
True if loaded successfully, False otherwise
"""
try:
# Validate path
if not os.path.exists(epub_path):
raise FileNotFoundError(f"EPUB file not found: {epub_path}")
# Load the EPUB
book = read_epub(epub_path)
# Extract metadata
self.title = book.get_title() or "Unknown Title"
self.author = book.get_author() or "Unknown Author"
# Create document ID from filename
self.document_id = Path(epub_path).stem
# Extract all blocks from chapters
self.blocks = []
for chapter in book.chapters:
if hasattr(chapter, '_blocks'):
self.blocks.extend(chapter._blocks)
if not self.blocks:
raise ValueError("No content blocks found in EPUB")
return True
except Exception as e:
print(f"Error loading EPUB: {e}")
import traceback
print(f"Full traceback:")
traceback.print_exc()
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.
This is useful for rendering library screens, menus, or other HTML-based UI elements.
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.title = title
self.author = author
self.document_id = document_id
self.blocks = blocks
return True
except Exception as e:
print(f"Error loading HTML: {e}")
return False
def is_loaded(self) -> bool:
"""Check if a document is currently loaded."""
return self.blocks is not None and len(self.blocks) > 0
def get_metadata(self) -> Dict[str, Any]:
"""
Get document metadata.
Returns:
Dictionary with metadata (title, author, document_id, total_blocks)
"""
return {
'title': self.title,
'author': self.author,
'document_id': self.document_id,
'total_blocks': len(self.blocks) if self.blocks else 0
}
def get_blocks(self) -> Optional[List[Block]]:
"""Get the list of content blocks."""
return self.blocks
def clear(self):
"""Clear the currently loaded document."""
self.document_id = None
self.title = None
self.author = None
self.blocks = None

View File

@ -0,0 +1,211 @@
"""
Highlight operations coordination.
This module coordinates highlight operations with the highlight manager.
"""
from __future__ import annotations
from typing import List, Tuple, Optional, TYPE_CHECKING
from PIL import Image
import numpy as np
from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result
if TYPE_CHECKING:
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
class HighlightCoordinator:
"""
Coordinates highlight operations.
This class provides a simplified interface for highlighting operations,
coordinating between the layout manager and highlight manager.
"""
def __init__(self, document_id: str, highlights_dir: str):
"""
Initialize the highlight coordinator.
Args:
document_id: Unique document identifier
highlights_dir: Directory to store highlights
"""
self.highlight_manager = HighlightManager(
document_id=document_id,
highlights_dir=highlights_dir
)
self.layout_manager: Optional['EreaderLayoutManager'] = None
def set_layout_manager(self, manager: 'EreaderLayoutManager'):
"""Set the layout manager."""
self.layout_manager = manager
def highlight_word(self, x: int, y: int,
color: Tuple[int, int, int, int] = None,
note: Optional[str] = None,
tags: Optional[List[str]] = None) -> Optional[str]:
"""
Highlight a word at the given pixel location.
Args:
x: X coordinate
y: Y coordinate
color: RGBA color tuple (defaults to yellow)
note: Optional annotation for this highlight
tags: Optional categorization tags
Returns:
Highlight ID if successful, None otherwise
"""
if not self.layout_manager:
return None
try:
# Query the pixel to find the word
page = self.layout_manager.get_current_page()
result = page.query_point((x, y))
if not result or not result.text:
return None
# Use default color if not provided
if color is None:
color = HighlightColor.YELLOW.value
# Create highlight from query result
highlight = create_highlight_from_query_result(
result,
color=color,
note=note,
tags=tags
)
# Add to manager
self.highlight_manager.add_highlight(highlight)
return highlight.id
except Exception as e:
print(f"Error highlighting word: {e}")
return None
def highlight_selection(self, start: Tuple[int, int], end: Tuple[int, int],
color: Tuple[int, int, int, int] = None,
note: Optional[str] = None,
tags: Optional[List[str]] = None) -> Optional[str]:
"""
Highlight a range of words between two points.
Args:
start: Starting (x, y) coordinates
end: Ending (x, y) coordinates
color: RGBA color tuple (defaults to yellow)
note: Optional annotation
tags: Optional categorization tags
Returns:
Highlight ID if successful, None otherwise
"""
if not self.layout_manager:
return None
try:
page = self.layout_manager.get_current_page()
selection_range = page.query_range(start, end)
if not selection_range.results:
return None
# Use default color if not provided
if color is None:
color = HighlightColor.YELLOW.value
# Create highlight from selection range
highlight = create_highlight_from_query_result(
selection_range,
color=color,
note=note,
tags=tags
)
# Add to manager
self.highlight_manager.add_highlight(highlight)
return highlight.id
except Exception as e:
print(f"Error highlighting selection: {e}")
return None
def remove_highlight(self, highlight_id: str) -> bool:
"""Remove a highlight by ID."""
return self.highlight_manager.remove_highlight(highlight_id)
def list_highlights(self) -> List[Highlight]:
"""Get all highlights for the current document."""
return self.highlight_manager.list_highlights()
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
"""Get highlights that appear on a specific page."""
return self.highlight_manager.get_highlights_for_page(page_bounds)
def clear_all(self) -> None:
"""Remove all highlights from the current document."""
self.highlight_manager.clear_all()
def render_highlights(self, image: Image.Image, highlights: List[Highlight]) -> Image.Image:
"""
Render highlight overlays on an image using multiply blend mode.
Args:
image: Base PIL Image to draw on
highlights: List of Highlight objects to render
Returns:
New PIL Image with highlights overlaid
"""
# Convert to RGB for processing
original_mode = image.mode
if image.mode == 'RGBA':
rgb_image = image.convert('RGB')
alpha_channel = image.split()[-1]
else:
rgb_image = image.convert('RGB')
alpha_channel = None
# Convert to numpy array for efficient processing
img_array = np.array(rgb_image, dtype=np.float32)
# Process each highlight
for highlight in highlights:
# Extract RGB components from highlight color (ignore alpha)
h_r, h_g, h_b = highlight.color[0], highlight.color[1], highlight.color[2]
# Create highlight multiplier (normalize to 0-1 range)
highlight_color = np.array([h_r / 255.0, h_g / 255.0, h_b / 255.0], dtype=np.float32)
for hx, hy, hw, hh in highlight.bounds:
# Ensure bounds are within image
hx, hy = max(0, hx), max(0, hy)
x2, y2 = min(rgb_image.width, hx + hw), min(rgb_image.height, hy + hh)
if x2 <= hx or y2 <= hy:
continue
# Extract the region to highlight
region = img_array[hy:y2, hx:x2, :]
# Multiply with highlight color (like a real highlighter)
highlighted = region * highlight_color
# Put the highlighted region back
img_array[hy:y2, hx:x2, :] = highlighted
# Convert back to uint8 and create PIL Image
img_array = np.clip(img_array, 0, 255).astype(np.uint8)
result = Image.fromarray(img_array, mode='RGB')
# Restore alpha channel if original had one
if alpha_channel is not None and original_mode == 'RGBA':
result = result.convert('RGBA')
result.putalpha(alpha_channel)
return result

View File

@ -0,0 +1,299 @@
"""
Settings and rendering configuration management.
This module handles font size, spacing, and other rendering settings.
"""
from __future__ import annotations
from typing import Dict, Any, Optional
from PIL import Image
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
from pyWebLayout.style.fonts import BundledFont
class SettingsManager:
"""
Manages font size, spacing, font family, and rendering settings.
Responsibilities:
- Font scale adjustment
- Font family selection (serif, sans-serif, monospace)
- Line spacing control
- Inter-block spacing control
- Word spacing control
- Settings persistence helpers
"""
def __init__(self):
"""Initialize the settings manager."""
self.font_scale = 1.0
self.font_scale_step = 0.1 # 10% change per step
self.font_family: Optional[BundledFont] = None # None = use document default
self.manager: Optional[EreaderLayoutManager] = None
def set_manager(self, manager: EreaderLayoutManager):
"""
Set the layout manager to control.
Args:
manager: EreaderLayoutManager instance to manage settings for
"""
self.manager = manager
self.font_scale = manager.font_scale
self.font_family = manager.get_font_family()
def set_font_size(self, scale: float) -> Optional[Image.Image]:
"""
Set the font size scale and re-render current page.
Args:
scale: Font scale factor (1.0 = normal, 2.0 = double size, 0.5 = half size)
Returns:
Rendered page with new font size, or None if no manager
"""
if not self.manager:
return None
try:
self.font_scale = max(0.5, min(3.0, scale)) # Clamp between 0.5x and 3.0x
page = self.manager.set_font_scale(self.font_scale)
return page.render() if page else None
except Exception as e:
print(f"Error setting font size: {e}")
return None
def increase_font_size(self) -> Optional[Image.Image]:
"""
Increase font size by one step and re-render.
Returns:
Rendered page with increased font size
"""
new_scale = self.font_scale + self.font_scale_step
return self.set_font_size(new_scale)
def decrease_font_size(self) -> Optional[Image.Image]:
"""
Decrease font size by one step and re-render.
Returns:
Rendered page with decreased font size
"""
new_scale = self.font_scale - self.font_scale_step
return self.set_font_size(new_scale)
def get_font_size(self) -> float:
"""
Get the current font size scale.
Returns:
Current font scale factor
"""
return self.font_scale
def set_font_family(self, font_family: Optional[BundledFont]) -> Optional[Image.Image]:
"""
Set the font family and re-render current page.
Args:
font_family: BundledFont enum value (SERIF, SANS, MONOSPACE) or None for document default
Returns:
Rendered page with new font family, or None if no manager
"""
if not self.manager:
return None
try:
self.font_family = font_family
page = self.manager.set_font_family(font_family)
return page.render() if page else None
except Exception as e:
print(f"Error setting font family: {e}")
return None
def get_font_family(self) -> Optional[BundledFont]:
"""
Get the current font family.
Returns:
Current BundledFont or None if using document default
"""
return self.font_family
def set_line_spacing(self, spacing: int) -> Optional[Image.Image]:
"""
Set line spacing using pyWebLayout's native support.
Args:
spacing: Line spacing in pixels
Returns:
Rendered page with new line spacing
"""
if not self.manager:
return None
try:
# Calculate delta from current spacing
current_spacing = self.manager.page_style.line_spacing
target_spacing = max(0, spacing)
delta = target_spacing - current_spacing
# Use pyWebLayout's built-in methods to adjust spacing
if delta > 0:
self.manager.increase_line_spacing(abs(delta))
elif delta < 0:
self.manager.decrease_line_spacing(abs(delta))
# Get re-rendered page
page = self.manager.get_current_page()
return page.render() if page else None
except Exception as e:
print(f"Error setting line spacing: {e}")
return None
def set_inter_block_spacing(self, spacing: int) -> Optional[Image.Image]:
"""
Set inter-block spacing using pyWebLayout's native support.
Args:
spacing: Inter-block spacing in pixels
Returns:
Rendered page with new inter-block spacing
"""
if not self.manager:
return None
try:
# Calculate delta from current spacing
current_spacing = self.manager.page_style.inter_block_spacing
target_spacing = max(0, spacing)
delta = target_spacing - current_spacing
# Use pyWebLayout's built-in methods to adjust spacing
if delta > 0:
self.manager.increase_inter_block_spacing(abs(delta))
elif delta < 0:
self.manager.decrease_inter_block_spacing(abs(delta))
# Get re-rendered page
page = self.manager.get_current_page()
return page.render() if page else None
except Exception as e:
print(f"Error setting inter-block spacing: {e}")
return None
def set_word_spacing(self, spacing: int) -> Optional[Image.Image]:
"""
Set word spacing using pyWebLayout's native support.
Args:
spacing: Word spacing in pixels
Returns:
Rendered page with new word spacing
"""
if not self.manager:
return None
try:
# Calculate delta from current spacing
current_spacing = self.manager.page_style.word_spacing
target_spacing = max(0, spacing)
delta = target_spacing - current_spacing
# Use pyWebLayout's built-in methods to adjust spacing
if delta > 0:
self.manager.increase_word_spacing(abs(delta))
elif delta < 0:
self.manager.decrease_word_spacing(abs(delta))
# Get re-rendered page
page = self.manager.get_current_page()
return page.render() if page else None
except Exception as e:
print(f"Error setting word spacing: {e}")
return None
def get_current_settings(self) -> Dict[str, Any]:
"""
Get current rendering settings.
Returns:
Dictionary with all current settings
"""
if not self.manager:
return {
'font_scale': self.font_scale,
'font_family': self.font_family.name if self.font_family else None,
'line_spacing': 5,
'inter_block_spacing': 15,
'word_spacing': 0
}
return {
'font_scale': self.font_scale,
'font_family': self.font_family.name if self.font_family else None,
'line_spacing': self.manager.page_style.line_spacing,
'inter_block_spacing': self.manager.page_style.inter_block_spacing,
'word_spacing': self.manager.page_style.word_spacing
}
def apply_settings(self, settings: Dict[str, Any]) -> bool:
"""
Apply rendering settings from a settings dictionary.
This should be called after loading a book to restore user preferences.
Args:
settings: Dictionary with settings (font_scale, font_family, line_spacing, etc.)
Returns:
True if settings applied successfully, False otherwise
"""
if not self.manager:
return False
try:
# Apply font family
font_family_name = settings.get('font_family', None)
if font_family_name:
try:
font_family = BundledFont[font_family_name]
if font_family != self.font_family:
self.set_font_family(font_family)
except KeyError:
print(f"Warning: Unknown font family '{font_family_name}', using default")
elif font_family_name is None and self.font_family is not None:
# Restore to document default
self.set_font_family(None)
# Apply font scale
font_scale = settings.get('font_scale', 1.0)
if font_scale != self.font_scale:
self.set_font_size(font_scale)
# Apply line spacing
line_spacing = settings.get('line_spacing', 5)
if line_spacing != self.manager.page_style.line_spacing:
self.set_line_spacing(line_spacing)
# Apply inter-block spacing
inter_block_spacing = settings.get('inter_block_spacing', 15)
if inter_block_spacing != self.manager.page_style.inter_block_spacing:
self.set_inter_block_spacing(inter_block_spacing)
# Apply word spacing
word_spacing = settings.get('word_spacing', 0)
if word_spacing != self.manager.page_style.word_spacing:
self.set_word_spacing(word_spacing)
return True
except Exception as e:
print(f"Error applying settings: {e}")
return False

View File

@ -0,0 +1,21 @@
"""
Overlay sub-applications for dreader.
Each overlay is a self-contained sub-application that handles its own:
- HTML generation
- Rendering logic
- Gesture handling
- State management
"""
from .base import OverlaySubApplication
from .navigation import NavigationOverlay
from .settings import SettingsOverlay
from .toc import TOCOverlay
__all__ = [
'OverlaySubApplication',
'NavigationOverlay',
'SettingsOverlay',
'TOCOverlay',
]

359
dreader/overlays/base.py Normal file
View File

@ -0,0 +1,359 @@
"""
Base class for overlay sub-applications.
This provides a common interface for all overlay types (TOC, Settings, Navigation, etc.)
Each overlay is a self-contained sub-application that handles its own rendering and gestures.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Optional, Dict, Any, Tuple
from PIL import Image
from ..gesture import GestureResponse, ActionType
from ..state import OverlayState
if TYPE_CHECKING:
from ..application import EbookReader
class OverlaySubApplication(ABC):
"""
Base class for overlay sub-applications.
Each overlay type extends this class and implements:
- open(): Generate HTML, render, and return composited image
- handle_tap(): Process tap gestures within the overlay
- close(): Clean up and return base page
- get_overlay_type(): Return the OverlayState enum value
The base class provides:
- Common rendering infrastructure (HTML to image conversion)
- Coordinate translation (screen to overlay panel)
- Query pixel support (detecting interactive elements)
- Compositing (darkened background + centered panel)
"""
def __init__(self, reader: 'EbookReader'):
"""
Initialize overlay sub-application.
Args:
reader: Reference to parent EbookReader instance
"""
self.reader = reader
self.page_size = reader.page_size
# Overlay rendering state
self._overlay_reader: Optional['EbookReader'] = None
self._cached_base_page: Optional[Image.Image] = None
self._cached_overlay_image: Optional[Image.Image] = None
self._overlay_panel_offset: Tuple[int, int] = (0, 0)
self._panel_size: Tuple[int, int] = (0, 0)
@abstractmethod
def get_overlay_type(self) -> OverlayState:
"""
Get the overlay type identifier.
Returns:
OverlayState enum value for this overlay
"""
pass
@abstractmethod
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
"""
Open the overlay and return composited image.
Args:
base_page: Current reading page to show underneath
**kwargs: Overlay-specific parameters
Returns:
Composited image with overlay on top of base page
"""
pass
@abstractmethod
def handle_tap(self, x: int, y: int) -> GestureResponse:
"""
Handle tap gesture within the overlay.
Args:
x, y: Screen coordinates of tap
Returns:
GestureResponse indicating what action to take
"""
pass
def close(self) -> Optional[Image.Image]:
"""
Close the overlay and clean up resources.
Returns:
Base page image (without overlay), or None if not open
"""
base_page = self._cached_base_page
# Clear caches
self._cached_base_page = None
self._cached_overlay_image = None
self._overlay_panel_offset = (0, 0)
self._panel_size = (0, 0)
# Close overlay reader
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = None
return base_page
# ===================================================================
# Common Infrastructure Methods
# ===================================================================
def render_html_to_image(self, html: str, panel_size: Tuple[int, int]) -> Image.Image:
"""
Render HTML to image using a temporary EbookReader.
Args:
html: HTML content to render
panel_size: Size for the overlay panel (width, height)
Returns:
Rendered PIL Image of the HTML
"""
# Import here to avoid circular dependency
from ..application import EbookReader
# Create or reuse overlay reader
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = EbookReader(
page_size=panel_size,
margin=15,
background_color=(255, 255, 255)
)
# Load the HTML content
success = self._overlay_reader.load_html(
html_string=html,
title=f"{self.get_overlay_type().name} Overlay",
author="",
document_id=f"{self.get_overlay_type().name.lower()}_overlay"
)
if not success:
raise ValueError(f"Failed to load {self.get_overlay_type().name} overlay HTML")
# Get the rendered page
return self._overlay_reader.get_current_page()
def composite_overlay(self, base_page: Image.Image, overlay_panel: Image.Image) -> Image.Image:
"""
Composite overlay panel on top of base page with darkened background.
Creates popup effect by:
1. Darkening the base image (70% brightness for e-ink visibility)
2. Placing the overlay panel centered on top with a border
Args:
base_page: Base reading page
overlay_panel: Rendered overlay panel
Returns:
Composited PIL Image with popup effect
"""
from PIL import ImageDraw, ImageEnhance
import os
# Convert base image to RGB
result = base_page.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')
# DEBUG: Draw bounding boxes on interactive elements if debug mode enabled
debug_mode = os.environ.get('DREADER_DEBUG_OVERLAY', '0') == '1'
if debug_mode:
overlay_panel = self._draw_debug_bounding_boxes(overlay_panel.copy())
# 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)
# Store panel position and size for coordinate translation
self._overlay_panel_offset = (panel_x, panel_y)
self._panel_size = (overlay_panel.width, overlay_panel.height)
# 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 query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]:
"""
Query a pixel in the overlay to detect interactive elements.
Uses pyWebLayout's query_point() to detect tapped elements,
including link targets and data attributes.
Args:
x, y: Screen coordinates to query
Returns:
Dictionary with query result (text, link_target, is_interactive),
or None if query failed or coordinates outside overlay
"""
if 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
panel_width, panel_height = self._panel_size
if overlay_x >= panel_width or overlay_y >= panel_height:
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))
import logging
logger = logging.getLogger(__name__)
logger.info(f"[OVERLAY_BASE] query_point({overlay_x}, {overlay_y}) returned: {result}")
if result:
logger.info(f"[OVERLAY_BASE] text={result.text}, link_target={result.link_target}, is_interactive={result.is_interactive}")
logger.info(f"[OVERLAY_BASE] bounds={result.bounds}, object_type={result.object_type}")
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
}
def _calculate_panel_size(self, width_ratio: float = 0.6, height_ratio: float = 0.7) -> Tuple[int, int]:
"""
Calculate overlay panel size as a percentage of screen size.
Args:
width_ratio: Panel width as ratio of screen width (default 60%)
height_ratio: Panel height as ratio of screen height (default 70%)
Returns:
Tuple of (panel_width, panel_height) in pixels
"""
panel_width = int(self.page_size[0] * width_ratio)
panel_height = int(self.page_size[1] * height_ratio)
return (panel_width, panel_height)
def _draw_debug_bounding_boxes(self, overlay_panel: Image.Image) -> Image.Image:
"""
Draw bounding boxes around all interactive elements for debugging.
This scans the overlay panel and draws red rectangles around all
clickable elements to help visualize where users need to click.
Args:
overlay_panel: Overlay panel image to annotate
Returns:
Annotated overlay panel with bounding boxes
"""
from PIL import ImageDraw, ImageFont
import logging
logger = logging.getLogger(__name__)
if not self._overlay_reader or not self._overlay_reader.manager:
logger.warning("[DEBUG] No overlay reader available for debug visualization")
return overlay_panel
page = self._overlay_reader.manager.get_current_page()
if not page:
logger.warning("[DEBUG] No page available for debug visualization")
return overlay_panel
# Scan for all interactive elements
panel_width, panel_height = overlay_panel.size
link_regions = {} # link_target -> (min_x, min_y, max_x, max_y)
logger.info(f"[DEBUG] Scanning {panel_width}x{panel_height} overlay for interactive elements...")
# Scan with fine granularity to find all interactive pixels
for y in range(0, panel_height, 2):
for x in range(0, panel_width, 2):
result = page.query_point((x, y))
if result and result.link_target:
if result.link_target not in link_regions:
link_regions[result.link_target] = [x, y, x, y]
else:
# Expand bounding box
link_regions[result.link_target][0] = min(link_regions[result.link_target][0], x)
link_regions[result.link_target][1] = min(link_regions[result.link_target][1], y)
link_regions[result.link_target][2] = max(link_regions[result.link_target][2], x)
link_regions[result.link_target][3] = max(link_regions[result.link_target][3], y)
# Draw bounding boxes
draw = ImageDraw.Draw(overlay_panel)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
except:
font = ImageFont.load_default()
logger.info(f"[DEBUG] Found {len(link_regions)} interactive regions")
for link_target, (min_x, min_y, max_x, max_y) in link_regions.items():
# Draw red bounding box
draw.rectangle(
[min_x, min_y, max_x, max_y],
outline=(255, 0, 0),
width=2
)
# Draw label
label = link_target[:20] # Truncate if too long
draw.text((min_x + 2, min_y - 12), label, fill=(255, 0, 0), font=font)
logger.info(f"[DEBUG] {link_target}: ({min_x}, {min_y}) to ({max_x}, {max_y})")
return overlay_panel

View File

@ -0,0 +1,311 @@
"""
Navigation overlay sub-application.
Provides tabbed interface for Contents (TOC) and Bookmarks.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, List, Tuple, Dict, Any, Optional
from PIL import Image
from .base import OverlaySubApplication
from ..gesture import GestureResponse, ActionType
from ..state import OverlayState
from ..html_generator import generate_navigation_overlay
if TYPE_CHECKING:
from ..application import EbookReader
class NavigationOverlay(OverlaySubApplication):
"""
Unified navigation overlay with Contents and Bookmarks tabs.
Features:
- Tab switching between Contents and Bookmarks
- Chapter navigation via clickable links
- Bookmark navigation
- Close button
"""
def __init__(self, reader: 'EbookReader'):
"""Initialize navigation overlay."""
super().__init__(reader)
# Tab state
self._active_tab: str = "contents"
self._cached_chapters: List[Tuple[str, int]] = []
self._cached_bookmarks: List[Dict[str, Any]] = []
# Pagination state
self._toc_page: int = 0 # Current page in TOC
self._toc_items_per_page: int = 10 # Items per page
self._bookmarks_page: int = 0 # Current page in bookmarks
def get_overlay_type(self) -> OverlayState:
"""Return NAVIGATION overlay type."""
return OverlayState.NAVIGATION
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
"""
Open the navigation overlay.
Args:
base_page: Current reading page to show underneath
chapters: List of (chapter_title, chapter_index) tuples
bookmarks: List of bookmark dicts with 'name' and optional 'position'
active_tab: Which tab to show initially ("contents" or "bookmarks")
Returns:
Composited image with navigation overlay
"""
chapters = kwargs.get('chapters', [])
bookmarks = kwargs.get('bookmarks', [])
active_tab = kwargs.get('active_tab', 'contents')
# Store for later use (tab switching)
self._cached_chapters = chapters
self._cached_bookmarks = bookmarks
self._active_tab = active_tab
# Reset pagination when opening
self._toc_page = 0
self._bookmarks_page = 0
# Calculate panel size (60% width, 70% height)
panel_size = self._calculate_panel_size(0.6, 0.7)
# Convert chapters to format expected by HTML generator
chapter_data = [
{"index": idx, "title": title}
for title, idx in chapters
]
# Generate navigation HTML with tabs
html = generate_navigation_overlay(
chapters=chapter_data,
bookmarks=bookmarks,
active_tab=active_tab,
page_size=panel_size,
toc_page=self._toc_page,
toc_items_per_page=self._toc_items_per_page,
bookmarks_page=self._bookmarks_page
)
# Render HTML to image
overlay_panel = self.render_html_to_image(html, panel_size)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_panel
# Composite and return
return self.composite_overlay(base_page, overlay_panel)
def handle_tap(self, x: int, y: int) -> GestureResponse:
"""
Handle tap within navigation overlay.
Detects:
- Tab switching (tab:contents, tab:bookmarks)
- Chapter selection (chapter:N)
- Bookmark selection (bookmark:name)
- Close button (action:close)
- Tap outside overlay (closes)
Args:
x, y: Screen coordinates of tap
Returns:
GestureResponse with appropriate action
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[NAV_OVERLAY] Handling tap at ({x}, {y})")
logger.info(f"[NAV_OVERLAY] Panel offset: {self._overlay_panel_offset}, Panel size: {self._panel_size}")
# Query the overlay to see what was tapped
query_result = self.query_overlay_pixel(x, y)
logger.info(f"[NAV_OVERLAY] Query result: {query_result}")
# If query failed (tap outside overlay panel), close it
if query_result is None:
logger.info(f"[NAV_OVERLAY] Tap outside overlay panel, closing")
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Check if tapped on a link
if query_result.get("is_interactive") and query_result.get("link_target"):
link_target = query_result["link_target"]
logger.info(f"[NAV_OVERLAY] Found interactive link: {link_target}")
# Parse "tab:tabname" format for tab switching
if link_target.startswith("tab:"):
tab_name = link_target.split(":", 1)[1]
self._switch_tab(tab_name)
return GestureResponse(ActionType.TAB_SWITCHED, {
"tab": tab_name
})
# Parse "chapter:N" format for chapter navigation
elif link_target.startswith("chapter:"):
try:
chapter_idx = int(link_target.split(":")[1])
# Get chapter title for response
chapter_title = None
for title, idx in self._cached_chapters:
if idx == chapter_idx:
chapter_title = title
break
# Jump to selected chapter
self.reader.jump_to_chapter(chapter_idx)
return GestureResponse(ActionType.CHAPTER_SELECTED, {
"chapter_index": chapter_idx,
"chapter_title": chapter_title or f"Chapter {chapter_idx}"
})
except (ValueError, IndexError):
pass
# Parse "bookmark:name" format for bookmark navigation
elif link_target.startswith("bookmark:"):
bookmark_name = link_target.split(":", 1)[1]
# Load the bookmark position
page = self.reader.load_position(bookmark_name)
if page:
return GestureResponse(ActionType.BOOKMARK_SELECTED, {
"bookmark_name": bookmark_name
})
else:
# Failed to load bookmark
return GestureResponse(ActionType.ERROR, {
"message": f"Failed to load bookmark: {bookmark_name}"
})
# Parse "action:close" format for close button
elif link_target.startswith("action:"):
action = link_target.split(":", 1)[1]
if action == "close":
logger.info(f"[NAV_OVERLAY] Close button clicked")
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Parse "page:direction" format for pagination
elif link_target.startswith("page:"):
direction = link_target.split(":", 1)[1]
logger.info(f"[NAV_OVERLAY] Pagination button clicked: {direction}")
self._handle_pagination(direction)
return GestureResponse(ActionType.PAGE_CHANGED, {
"direction": direction,
"tab": self._active_tab
})
# Tap inside overlay but not on interactive element - keep overlay open
logger.info(f"[NAV_OVERLAY] Tap on non-interactive area inside overlay, ignoring")
return GestureResponse(ActionType.NONE, {})
def switch_tab(self, new_tab: str) -> Optional[Image.Image]:
"""
Switch between tabs in the navigation overlay.
Args:
new_tab: Tab to switch to ("contents" or "bookmarks")
Returns:
Updated image with new tab active
"""
return self._switch_tab(new_tab)
def _switch_tab(self, new_tab: str) -> Optional[Image.Image]:
"""
Internal tab switching implementation.
Args:
new_tab: Tab to switch to
Returns:
Updated composited image with new tab active
"""
if not self._cached_base_page:
return None
self._active_tab = new_tab
# Regenerate overlay with new active tab
panel_size = self._calculate_panel_size(0.6, 0.7)
# Convert chapters to format expected by HTML generator
chapter_data = [
{"index": idx, "title": title}
for title, idx in self._cached_chapters
]
# Generate navigation HTML with new active tab
html = generate_navigation_overlay(
chapters=chapter_data,
bookmarks=self._cached_bookmarks,
active_tab=new_tab,
page_size=panel_size,
toc_page=self._toc_page,
toc_items_per_page=self._toc_items_per_page,
bookmarks_page=self._bookmarks_page
)
# Render HTML to image
overlay_panel = self.render_html_to_image(html, panel_size)
# Update cache
self._cached_overlay_image = overlay_panel
# Composite and return
return self.composite_overlay(self._cached_base_page, overlay_panel)
def _handle_pagination(self, direction: str) -> Optional[Image.Image]:
"""
Handle pagination within the active tab.
Args:
direction: Either "next" or "prev"
Returns:
Updated composited image with new page, or None if invalid
"""
import logging
logger = logging.getLogger(__name__)
if self._active_tab == "contents":
# Calculate total pages
total_items = len(self._cached_chapters)
total_pages = (total_items + self._toc_items_per_page - 1) // self._toc_items_per_page
# Update page number
if direction == "next" and self._toc_page < total_pages - 1:
self._toc_page += 1
logger.info(f"[NAV_OVERLAY] TOC page -> {self._toc_page + 1}/{total_pages}")
elif direction == "prev" and self._toc_page > 0:
self._toc_page -= 1
logger.info(f"[NAV_OVERLAY] TOC page -> {self._toc_page + 1}/{total_pages}")
else:
logger.info(f"[NAV_OVERLAY] Can't paginate {direction} from page {self._toc_page + 1}/{total_pages}")
return None
elif self._active_tab == "bookmarks":
# Calculate total pages
total_items = len(self._cached_bookmarks)
total_pages = (total_items + self._toc_items_per_page - 1) // self._toc_items_per_page
# Update page number
if direction == "next" and self._bookmarks_page < total_pages - 1:
self._bookmarks_page += 1
logger.info(f"[NAV_OVERLAY] Bookmarks page -> {self._bookmarks_page + 1}/{total_pages}")
elif direction == "prev" and self._bookmarks_page > 0:
self._bookmarks_page -= 1
logger.info(f"[NAV_OVERLAY] Bookmarks page -> {self._bookmarks_page + 1}/{total_pages}")
else:
logger.info(f"[NAV_OVERLAY] Can't paginate {direction} from page {self._bookmarks_page + 1}/{total_pages}")
return None
# Regenerate the overlay with new page
return self._switch_tab(self._active_tab)

View File

@ -0,0 +1,251 @@
"""
Settings overlay sub-application.
Provides interactive controls for adjusting reading settings with live preview.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from PIL import Image
from .base import OverlaySubApplication
from ..gesture import GestureResponse, ActionType
from ..state import OverlayState
from ..html_generator import generate_settings_overlay
if TYPE_CHECKING:
from ..application import EbookReader
class SettingsOverlay(OverlaySubApplication):
"""
Settings overlay with live preview.
Features:
- Font size adjustment (increase/decrease)
- Line spacing adjustment
- Inter-block spacing adjustment
- Word spacing adjustment
- Live preview of changes on base page
- Back to library button
"""
def get_overlay_type(self) -> OverlayState:
"""Return SETTINGS overlay type."""
return OverlayState.SETTINGS
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
"""
Open the settings overlay.
Args:
base_page: Current reading page to show underneath
font_scale: Current font scale
line_spacing: Current line spacing in pixels
inter_block_spacing: Current inter-block spacing in pixels
word_spacing: Current word spacing in pixels
font_family: Current font family name (e.g., "SERIF", "SANS", "MONOSPACE", or None)
Returns:
Composited image with settings overlay
"""
font_scale = kwargs.get('font_scale', 1.0)
line_spacing = kwargs.get('line_spacing', 5)
inter_block_spacing = kwargs.get('inter_block_spacing', 15)
word_spacing = kwargs.get('word_spacing', 0)
font_family = kwargs.get('font_family', 'Default')
# Calculate panel size (60% width, 70% height)
panel_size = self._calculate_panel_size(0.6, 0.7)
# Generate settings HTML with current values
html = generate_settings_overlay(
font_scale=font_scale,
line_spacing=line_spacing,
inter_block_spacing=inter_block_spacing,
word_spacing=word_spacing,
font_family=font_family,
page_size=panel_size
)
# Render HTML to image
overlay_panel = self.render_html_to_image(html, panel_size)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_panel
# Composite and return
return self.composite_overlay(base_page, overlay_panel)
def handle_tap(self, x: int, y: int) -> GestureResponse:
"""
Handle tap within settings overlay.
Detects:
- Setting adjustment controls (setting:action)
- Back to library button (action:back_to_library)
- Tap outside overlay (closes)
Args:
x, y: Screen coordinates of tap
Returns:
GestureResponse with appropriate action
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[SETTINGS_OVERLAY] Handling tap at ({x}, {y})")
logger.info(f"[SETTINGS_OVERLAY] Panel offset: {self._overlay_panel_offset}, Panel size: {self._panel_size}")
# Query the overlay to see what was tapped
query_result = self.query_overlay_pixel(x, y)
logger.info(f"[SETTINGS_OVERLAY] Query result: {query_result}")
# If query failed (tap outside overlay panel), close it
if query_result is None:
logger.info(f"[SETTINGS_OVERLAY] Tap outside overlay panel, closing")
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Check if tapped on a settings control link
if query_result.get("is_interactive") and query_result.get("link_target"):
link_target = query_result["link_target"]
logger.info(f"[SETTINGS_OVERLAY] Found interactive link: {link_target}")
# Parse "setting:action" format
if link_target.startswith("setting:"):
action = link_target.split(":", 1)[1]
logger.info(f"[SETTINGS_OVERLAY] Applying setting change: {action}")
return self._apply_setting_change(action)
# Parse "action:command" format for other actions
elif link_target.startswith("action:"):
action = link_target.split(":", 1)[1]
if action == "back_to_library":
logger.info(f"[SETTINGS_OVERLAY] Back to library clicked")
return GestureResponse(ActionType.BACK_TO_LIBRARY, {})
# Tap inside overlay but not on interactive element - keep overlay open
logger.info(f"[SETTINGS_OVERLAY] Tap on non-interactive area inside overlay, ignoring")
return GestureResponse(ActionType.NONE, {})
def refresh(self, updated_base_page: Image.Image,
font_scale: float,
line_spacing: int,
inter_block_spacing: int,
word_spacing: int = 0,
font_family: str = "Default") -> Image.Image:
"""
Refresh the settings overlay with updated values and background page.
This is used for live preview when settings change - it updates both
the background page (with new settings applied) and the overlay panel
(with new values displayed).
Args:
updated_base_page: Updated reading page with new settings applied
font_scale: Updated font scale
line_spacing: Updated line spacing
inter_block_spacing: Updated inter-block spacing
word_spacing: Updated word spacing
font_family: Updated font family
Returns:
Composited image with updated settings overlay
"""
# Calculate panel size (60% width, 70% height)
panel_size = self._calculate_panel_size(0.6, 0.7)
# Generate updated settings HTML
html = generate_settings_overlay(
font_scale=font_scale,
line_spacing=line_spacing,
inter_block_spacing=inter_block_spacing,
word_spacing=word_spacing,
font_family=font_family,
page_size=panel_size
)
# Render HTML to image
overlay_panel = self.render_html_to_image(html, panel_size)
# Update caches
self._cached_base_page = updated_base_page.copy()
self._cached_overlay_image = overlay_panel
# Composite and return
return self.composite_overlay(updated_base_page, overlay_panel)
def _apply_setting_change(self, action: str) -> GestureResponse:
"""
Apply a setting change and refresh the overlay.
Args:
action: Setting action (e.g., "font_increase", "line_spacing_decrease", "font_family_serif")
Returns:
GestureResponse with SETTING_CHANGED action
"""
from pyWebLayout.style.fonts import BundledFont
# Apply the setting change via reader
if action == "font_increase":
self.reader.increase_font_size()
elif action == "font_decrease":
self.reader.decrease_font_size()
elif action == "font_family_default":
self.reader.set_font_family(None)
elif action == "font_family_serif":
self.reader.set_font_family(BundledFont.SERIF)
elif action == "font_family_sans":
self.reader.set_font_family(BundledFont.SANS)
elif action == "font_family_monospace":
self.reader.set_font_family(BundledFont.MONOSPACE)
elif action == "line_spacing_increase":
new_spacing = self.reader.page_style.line_spacing + 2
self.reader.set_line_spacing(new_spacing)
elif action == "line_spacing_decrease":
new_spacing = max(0, self.reader.page_style.line_spacing - 2)
self.reader.set_line_spacing(new_spacing)
elif action == "block_spacing_increase":
new_spacing = self.reader.page_style.inter_block_spacing + 3
self.reader.set_inter_block_spacing(new_spacing)
elif action == "block_spacing_decrease":
new_spacing = max(0, self.reader.page_style.inter_block_spacing - 3)
self.reader.set_inter_block_spacing(new_spacing)
elif action == "word_spacing_increase":
new_spacing = self.reader.page_style.word_spacing + 2
self.reader.set_word_spacing(new_spacing)
elif action == "word_spacing_decrease":
new_spacing = max(0, self.reader.page_style.word_spacing - 2)
self.reader.set_word_spacing(new_spacing)
# Re-render the base page with new settings applied
# Must get directly from manager, not get_current_page() which returns overlay
page = self.reader.manager.get_current_page()
updated_page = page.render()
# Get font family for display
font_family = self.reader.get_font_family()
font_family_name = font_family.name if font_family else "Default"
# Refresh the settings overlay with updated values and page
self.refresh(
updated_base_page=updated_page,
font_scale=self.reader.base_font_scale,
line_spacing=self.reader.page_style.line_spacing,
inter_block_spacing=self.reader.page_style.inter_block_spacing,
word_spacing=self.reader.page_style.word_spacing,
font_family=font_family_name
)
return GestureResponse(ActionType.SETTING_CHANGED, {
"action": action,
"font_scale": self.reader.base_font_scale,
"font_family": font_family_name,
"line_spacing": self.reader.page_style.line_spacing,
"inter_block_spacing": self.reader.page_style.inter_block_spacing,
"word_spacing": self.reader.page_style.word_spacing
})

128
dreader/overlays/toc.py Normal file
View File

@ -0,0 +1,128 @@
"""
Table of Contents overlay sub-application.
Simple TOC overlay (deprecated in favor of NavigationOverlay).
Kept for backward compatibility.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, List, Tuple
from PIL import Image
from .base import OverlaySubApplication
from ..gesture import GestureResponse, ActionType
from ..state import OverlayState
from ..html_generator import generate_toc_overlay
if TYPE_CHECKING:
from ..application import EbookReader
class TOCOverlay(OverlaySubApplication):
"""
Simple Table of Contents overlay.
NOTE: This is deprecated in favor of NavigationOverlay which provides
a unified interface for both TOC and bookmarks. Kept for backward compatibility.
Features:
- List of chapters with clickable links
- Chapter navigation
"""
def __init__(self, reader: 'EbookReader'):
"""Initialize TOC overlay."""
super().__init__(reader)
self._cached_chapters: List[Tuple[str, int]] = []
def get_overlay_type(self) -> OverlayState:
"""Return TOC overlay type."""
return OverlayState.TOC
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
"""
Open the TOC overlay.
Args:
base_page: Current reading page to show underneath
chapters: List of (chapter_title, chapter_index) tuples
Returns:
Composited image with TOC overlay
"""
chapters = kwargs.get('chapters', [])
# Store for later use
self._cached_chapters = chapters
# Calculate panel size (60% width, 70% height)
panel_size = self._calculate_panel_size(0.6, 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_size)
# Render HTML to image
overlay_panel = self.render_html_to_image(html, panel_size)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_panel
# Composite and return
return self.composite_overlay(base_page, overlay_panel)
def handle_tap(self, x: int, y: int) -> GestureResponse:
"""
Handle tap within TOC overlay.
Detects:
- Chapter selection (chapter:N)
- Tap outside overlay (closes)
Args:
x, y: Screen coordinates of tap
Returns:
GestureResponse with appropriate action
"""
# Query the overlay to see what was tapped
query_result = self.query_overlay_pixel(x, y)
# If query failed (tap outside overlay), close it
if not query_result:
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
chapter_title = None
for title, idx in self._cached_chapters:
if idx == chapter_idx:
chapter_title = title
break
# Jump to selected chapter
self.reader.jump_to_chapter(chapter_idx)
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
return GestureResponse(ActionType.OVERLAY_CLOSED, {})

407
dreader/state.py Normal file
View File

@ -0,0 +1,407 @@
"""
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" # Deprecated: use NAVIGATION instead
SETTINGS = "settings"
BOOKMARKS = "bookmarks" # Deprecated: use NAVIGATION instead
NAVIGATION = "navigation" # Unified overlay for TOC and 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 for rendering and display"""
font_scale: float = 1.0
line_spacing: int = 5
inter_block_spacing: int = 15
word_spacing: int = 0 # Default word spacing
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),
word_spacing=data.get('word_spacing', 0),
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 update_settings(self, settings_dict: Dict[str, Any]):
"""
Update multiple settings at once.
Args:
settings_dict: Dictionary with setting keys and values
"""
for key, value in settings_dict.items():
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

View File

@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""
Accelerometer Calibration Script
This script helps calibrate the accelerometer for gravity-based page flipping.
It displays visual instructions on the e-ink display to guide the user through
aligning the device with the "up" direction.
The calibration process:
1. Shows an arrow pointing up
2. User rotates device until arrow aligns with desired "up" direction
3. User confirms by tapping screen
4. Script saves calibration offset to config file
Usage:
python examples/calibrate_accelerometer.py
"""
import asyncio
import sys
import json
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.hal_hardware import HardwareDisplayHAL
from dreader.gesture import GestureType
class AccelerometerCalibrator:
"""Interactive accelerometer calibration tool"""
def __init__(self, hal: HardwareDisplayHAL, config_path: str = "accelerometer_config.json"):
self.hal = hal
self.config_path = Path(config_path)
self.width = hal.width
self.height = hal.height
self.calibrated = False
# Calibration data
self.up_vector = None # (x, y, z) when device is in "up" position
async def run(self):
"""Run the calibration process"""
print("Starting accelerometer calibration...")
print(f"Display: {self.width}x{self.height}")
await self.hal.initialize()
try:
# Show welcome screen
await self.show_welcome()
await self.wait_for_tap()
# Calibration loop
await self.calibration_loop()
# Show completion screen
await self.show_completion()
await asyncio.sleep(3)
finally:
await self.hal.cleanup()
async def show_welcome(self):
"""Display welcome/instruction screen"""
img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
# Try to load a font, fall back to default
try:
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
body_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
except:
title_font = ImageFont.load_default()
body_font = ImageFont.load_default()
# Title
title = "Accelerometer Calibration"
title_bbox = draw.textbbox((0, 0), title, font=title_font)
title_width = title_bbox[2] - title_bbox[0]
draw.text(((self.width - title_width) // 2, 100), title, fill=(0, 0, 0), font=title_font)
# Instructions
instructions = [
"This will calibrate the accelerometer",
"for gravity-based page flipping.",
"",
"You will:",
"1. See an arrow on screen",
"2. Rotate device until arrow points UP",
"3. Tap screen to confirm",
"",
"Tap anywhere to begin..."
]
y = 250
for line in instructions:
line_bbox = draw.textbbox((0, 0), line, font=body_font)
line_width = line_bbox[2] - line_bbox[0]
draw.text(((self.width - line_width) // 2, y), line, fill=(0, 0, 0), font=body_font)
y += 50
await self.hal.show_image(img)
async def calibration_loop(self):
"""Main calibration loop - show live arrow and accelerometer reading"""
print("\nCalibration mode:")
print("Rotate device until arrow points UP, then tap screen.")
last_display_time = 0
display_interval = 0.2 # Update display every 200ms
while not self.calibrated:
# Get current acceleration
x, y, z = await self.hal.hal.orientation.get_acceleration()
# Update display if enough time has passed
current_time = asyncio.get_event_loop().time()
if current_time - last_display_time >= display_interval:
await self.show_calibration_screen(x, y, z)
last_display_time = current_time
# Check for touch event
event = await self.hal.get_touch_event()
if event and event.gesture == GestureType.TAP:
# Save current orientation as "up"
self.up_vector = (x, y, z)
self.calibrated = True
print(f"\nCalibration saved: up_vector = ({x:.2f}, {y:.2f}, {z:.2f})")
break
await asyncio.sleep(0.05) # Poll at ~20Hz
async def show_calibration_screen(self, ax: float, ay: float, az: float):
"""
Show arrow pointing in direction of gravity
Args:
ax, ay, az: Acceleration components in m/
"""
img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
# Calculate gravity direction (normalized)
magnitude = math.sqrt(ax**2 + ay**2 + az**2)
if magnitude < 0.1: # Avoid division by zero
magnitude = 1.0
gx = ax / magnitude
gy = ay / magnitude
gz = az / magnitude
# Project gravity onto screen plane (assuming z is out of screen)
# We want to show which way is "down" on the device
# Arrow should point opposite to gravity (toward "up")
arrow_dx = -gx
arrow_dy = -gy
# Normalize for display
arrow_length = min(self.width, self.height) * 0.3
arrow_magnitude = math.sqrt(arrow_dx**2 + arrow_dy**2)
if arrow_magnitude < 0.1:
arrow_magnitude = 1.0
arrow_dx = (arrow_dx / arrow_magnitude) * arrow_length
arrow_dy = (arrow_dy / arrow_magnitude) * arrow_length
# Center point
cx = self.width // 2
cy = self.height // 2
# Arrow endpoint
end_x = cx + int(arrow_dx)
end_y = cy + int(arrow_dy)
# Draw large arrow
self.draw_arrow(draw, cx, cy, end_x, end_y, width=10)
# Draw circle at center
circle_radius = 30
draw.ellipse(
[(cx - circle_radius, cy - circle_radius),
(cx + circle_radius, cy + circle_radius)],
outline=(0, 0, 0),
width=5
)
# Draw text with acceleration values
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 28)
except:
font = ImageFont.load_default()
text = f"X: {ax:6.2f} m/s²"
draw.text((50, 50), text, fill=(0, 0, 0), font=font)
text = f"Y: {ay:6.2f} m/s²"
draw.text((50, 100), text, fill=(0, 0, 0), font=font)
text = f"Z: {az:6.2f} m/s²"
draw.text((50, 150), text, fill=(0, 0, 0), font=font)
text = "Rotate device until arrow points UP"
text_bbox = draw.textbbox((0, 0), text, font=font)
text_width = text_bbox[2] - text_bbox[0]
draw.text(((self.width - text_width) // 2, self.height - 150),
text, fill=(0, 0, 0), font=font)
text = "Then TAP screen to save"
text_bbox = draw.textbbox((0, 0), text, font=font)
text_width = text_bbox[2] - text_bbox[0]
draw.text(((self.width - text_width) // 2, self.height - 100),
text, fill=(0, 0, 0), font=font)
await self.hal.show_image(img)
def draw_arrow(self, draw: ImageDraw.Draw, x1: int, y1: int, x2: int, y2: int, width: int = 5):
"""Draw an arrow from (x1, y1) to (x2, y2)"""
# Main line
draw.line([(x1, y1), (x2, y2)], fill=(0, 0, 0), width=width)
# Arrow head
dx = x2 - x1
dy = y2 - y1
length = math.sqrt(dx**2 + dy**2)
if length < 0.1:
return
# Normalize
dx /= length
dy /= length
# Arrow head size
head_length = 40
head_width = 30
# Perpendicular vector
px = -dy
py = dx
# Arrow head points
p1_x = x2 - dx * head_length + px * head_width
p1_y = y2 - dy * head_length + py * head_width
p2_x = x2 - dx * head_length - px * head_width
p2_y = y2 - dy * head_length - py * head_width
# Draw arrow head
draw.polygon([(x2, y2), (p1_x, p1_y), (p2_x, p2_y)], fill=(0, 0, 0))
async def show_completion(self):
"""Show calibration complete screen"""
img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
try:
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
body_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
except:
title_font = ImageFont.load_default()
body_font = ImageFont.load_default()
# Title
title = "Calibration Complete!"
title_bbox = draw.textbbox((0, 0), title, font=title_font)
title_width = title_bbox[2] - title_bbox[0]
draw.text(((self.width - title_width) // 2, 200), title, fill=(0, 0, 0), font=title_font)
# Details
if self.up_vector:
x, y, z = self.up_vector
details = [
f"Up vector saved:",
f"X: {x:.3f} m/s²",
f"Y: {y:.3f} m/s²",
f"Z: {z:.3f} m/s²",
"",
f"Saved to: {self.config_path}"
]
y_pos = 350
for line in details:
line_bbox = draw.textbbox((0, 0), line, font=body_font)
line_width = line_bbox[2] - line_bbox[0]
draw.text(((self.width - line_width) // 2, y_pos), line, fill=(0, 0, 0), font=body_font)
y_pos += 50
await self.hal.show_image(img)
# Save calibration to file
self.save_calibration()
def save_calibration(self):
"""Save calibration data to JSON file"""
if not self.up_vector:
print("Warning: No calibration data to save")
return
x, y, z = self.up_vector
config = {
"up_vector": {
"x": x,
"y": y,
"z": z
},
"tilt_threshold": 0.3, # Radians (~17 degrees)
"debounce_time": 0.5, # Seconds between tilt gestures
}
with open(self.config_path, 'w') as f:
json.dump(config, f, indent=2)
print(f"Calibration saved to {self.config_path}")
async def wait_for_tap(self):
"""Wait for user to tap screen"""
while True:
event = await self.hal.get_touch_event()
if event and event.gesture == GestureType.TAP:
break
await asyncio.sleep(0.05)
async def main():
"""Main entry point"""
# Create HAL with accelerometer enabled
print("Initializing hardware...")
hal = HardwareDisplayHAL(
width=1872,
height=1404,
enable_orientation=True,
enable_rtc=False,
enable_power_monitor=False,
virtual_display=False # Set to True for testing without hardware
)
# Create calibrator
calibrator = AccelerometerCalibrator(hal)
# Run calibration
await calibrator.run()
print("\nCalibration complete!")
print("You can now use accelerometer-based page flipping.")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nCalibration cancelled by user")
sys.exit(0)
except Exception as e:
print(f"\nError during calibration: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
Demo: Accelerometer-based Page Flipping
This example demonstrates how to use the accelerometer for hands-free
page turning by tilting the device forward or backward.
Features:
- Tilt device forward to advance to next page
- Tilt device backward to go to previous page
- Touch gestures still work normally
- Configurable tilt threshold and debounce time
Prerequisites:
1. Run calibration first: python examples/calibrate_accelerometer.py
2. This creates accelerometer_config.json with calibration data
Usage:
python examples/demo_accelerometer_page_flip.py <epub_file>
"""
import asyncio
import sys
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.hal_hardware import HardwareDisplayHAL
from dreader.application import EbookReader
from dreader.gesture import GestureType
class AccelerometerPageFlipDemo:
"""Demo application with accelerometer-based page flipping"""
def __init__(self, epub_path: str):
self.epub_path = epub_path
# Create HAL with accelerometer enabled
print("Initializing hardware HAL...")
self.hal = HardwareDisplayHAL(
width=1872,
height=1404,
enable_orientation=True,
enable_rtc=False,
enable_power_monitor=False,
virtual_display=False # Set to True for testing without hardware
)
# Create reader
print("Creating ebook reader...")
self.reader = EbookReader(
page_size=(self.hal.width, self.hal.height),
margin=60
)
self.running = False
async def run(self):
"""Run the demo application"""
print("\n" + "="*60)
print("Accelerometer Page Flip Demo")
print("="*60)
# Initialize HAL
await self.hal.initialize()
# Load accelerometer calibration
print("\nLoading accelerometer calibration...")
calibrated = self.hal.load_accelerometer_calibration("accelerometer_config.json")
if not calibrated:
print("\nWARNING: Accelerometer not calibrated!")
print("Please run: python examples/calibrate_accelerometer.py")
print("\nProceeding with touch gestures only...\n")
else:
print("Accelerometer calibration loaded successfully!")
print(f" Up vector: {self.hal.accel_up_vector}")
print(f" Tilt threshold: {self.hal.accel_tilt_threshold:.2f} rad")
print(f" Debounce time: {self.hal.accel_debounce_time:.2f}s")
# Load EPUB
print(f"\nLoading EPUB: {self.epub_path}")
success = self.reader.load_epub(self.epub_path)
if not success:
print(f"ERROR: Failed to load {self.epub_path}")
await self.hal.cleanup()
return
print(f"Loaded: {self.reader.book_title}")
print(f"Author: {self.reader.book_author}")
# Display first page
print("\nDisplaying first page...")
img = self.reader.get_current_page()
await self.hal.show_image(img)
# Instructions
print("\n" + "="*60)
print("Controls:")
print(" - Tilt FORWARD to go to next page")
print(" - Tilt BACKWARD to go to previous page")
print(" - Swipe LEFT for next page (touch)")
print(" - Swipe RIGHT for previous page (touch)")
print(" - Long press to exit")
print("="*60 + "\n")
# Main event loop
self.running = True
try:
await self.event_loop()
finally:
await self.hal.cleanup()
print("\nDemo finished!")
async def event_loop(self):
"""Main event loop - poll for touch and accelerometer events"""
accel_poll_interval = 0.05 # Check accelerometer every 50ms
while self.running:
# Check for touch events
touch_event = await self.hal.get_touch_event()
if touch_event:
await self.handle_event(touch_event)
# Check for accelerometer tilt events (if calibrated)
if hasattr(self.hal, 'accel_up_vector'):
tilt_event = await self.hal.get_tilt_gesture()
if tilt_event:
await self.handle_event(tilt_event)
# Small delay to avoid busy-waiting
await asyncio.sleep(accel_poll_interval)
async def handle_event(self, event):
"""Handle a gesture event (touch or accelerometer)"""
gesture = event.gesture
print(f"Gesture: {gesture.value}")
# Navigation gestures
if gesture in [GestureType.SWIPE_LEFT, GestureType.TILT_FORWARD]:
await self.next_page()
elif gesture in [GestureType.SWIPE_RIGHT, GestureType.TILT_BACKWARD]:
await self.previous_page()
# Exit on long press
elif gesture == GestureType.LONG_PRESS:
print("\nLong press detected - exiting...")
self.running = False
# Word tap
elif gesture == GestureType.TAP:
# You could implement word selection here
print(f" Tap at ({event.x}, {event.y})")
async def next_page(self):
"""Go to next page"""
img = self.reader.next_page()
if img:
progress = self.reader.get_reading_progress()
chapter = self.reader.get_current_chapter_info()
print(f" -> Next page ({progress['percent']:.1f}% - {chapter['title']})")
await self.hal.show_image(img)
else:
print(" -> At end of book")
async def previous_page(self):
"""Go to previous page"""
img = self.reader.previous_page()
if img:
progress = self.reader.get_reading_progress()
chapter = self.reader.get_current_chapter_info()
print(f" -> Previous page ({progress['percent']:.1f}% - {chapter['title']})")
await self.hal.show_image(img)
else:
print(" -> At start of book")
async def main():
"""Main entry point"""
if len(sys.argv) < 2:
print("Usage: python demo_accelerometer_page_flip.py <epub_file>")
print("\nExample:")
print(" python demo_accelerometer_page_flip.py ~/Books/mybook.epub")
sys.exit(1)
epub_path = sys.argv[1]
# Check if file exists
if not Path(epub_path).exists():
print(f"ERROR: File not found: {epub_path}")
sys.exit(1)
# Run demo
demo = AccelerometerPageFlipDemo(epub_path)
await demo.run()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nDemo interrupted by user")
sys.exit(0)
except Exception as e:
print(f"\nError: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
Simple Accelerometer Demo - Using Unified Event API
This is a simplified version of the accelerometer demo that uses
the HAL's get_event() convenience method to poll both touch and
accelerometer in a single call.
Usage:
python examples/demo_accelerometer_simple.py <epub_file>
"""
import asyncio
import sys
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.hal_hardware import HardwareDisplayHAL
from dreader.application import EbookReader
from dreader.gesture import GestureType
async def main():
"""Simple demo using unified event API"""
if len(sys.argv) < 2:
print("Usage: python demo_accelerometer_simple.py <epub_file>")
sys.exit(1)
epub_path = sys.argv[1]
# Create HAL with accelerometer enabled
print("Initializing hardware...")
hal = HardwareDisplayHAL(
width=1872,
height=1404,
enable_orientation=True
)
await hal.initialize()
# Load accelerometer calibration (optional)
if hal.load_accelerometer_calibration("accelerometer_config.json"):
print("✓ Accelerometer calibrated - tilt gestures enabled")
else:
print("✗ No accelerometer calibration - touch only")
# Create reader and load book
print(f"\nLoading: {epub_path}")
reader = EbookReader(page_size=(hal.width, hal.height), margin=60)
if not reader.load_epub(epub_path):
print(f"ERROR: Failed to load {epub_path}")
await hal.cleanup()
return
print(f"Loaded: {reader.book_title}")
# Display first page
img = reader.get_current_page()
await hal.show_image(img)
print("\nControls:")
print(" Swipe LEFT or Tilt FORWARD → Next page")
print(" Swipe RIGHT or Tilt BACKWARD → Previous page")
print(" Long press → Exit\n")
# Main event loop - simple unified API!
running = True
while running:
# Get event from any source (touch or accelerometer)
event = await hal.get_event()
if event:
print(f"Gesture: {event.gesture.value}")
# Page navigation
if event.gesture in [GestureType.SWIPE_LEFT, GestureType.TILT_FORWARD]:
img = reader.next_page()
if img:
progress = reader.get_reading_progress()
print(f" → Page {progress['current']}/{progress['total']} ({progress['percent']:.1f}%)")
await hal.show_image(img)
else:
print(" → End of book")
elif event.gesture in [GestureType.SWIPE_RIGHT, GestureType.TILT_BACKWARD]:
img = reader.previous_page()
if img:
progress = reader.get_reading_progress()
print(f" ← Page {progress['current']}/{progress['total']} ({progress['percent']:.1f}%)")
await hal.show_image(img)
else:
print(" ← Start of book")
# Exit
elif event.gesture == GestureType.LONG_PRESS:
print("\nExiting...")
running = False
await asyncio.sleep(0.01)
await hal.cleanup()
print("Done!")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nInterrupted")
sys.exit(0)
except Exception as e:
print(f"\nError: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

173
examples/demo_pagination.py Normal file
View File

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Demo script showing TOC overlay pagination functionality.
This demonstrates:
1. Opening a navigation overlay with many chapters
2. Navigating through pages using Next/Previous buttons
3. Switching between Contents and Bookmarks tabs with pagination
"""
import sys
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from dreader import EbookReader, TouchEvent, GestureType
def main():
print("=" * 60)
print("TOC Pagination Demo")
print("=" * 60)
# Create reader
reader = EbookReader(page_size=(800, 1200))
# Create a mock book with many chapters for demonstration
from dreader.html_generator import generate_navigation_overlay
# Generate test data: 35 chapters and 20 bookmarks
chapters = [{"index": i, "title": f"Chapter {i+1}: The Adventure Continues"} for i in range(35)]
bookmarks = [{"name": f"Bookmark {i+1}", "position": f"Page {i*10}"} for i in range(20)]
print("\nTest Data:")
print(f" - {len(chapters)} chapters")
print(f" - {len(bookmarks)} bookmarks")
print(f" - Items per page: 10")
print()
# Demonstrate pagination on Contents tab
print("Contents Tab Pagination:")
print("-" * 60)
# Page 1 of TOC (chapters 1-10)
print("\n[Page 1/4] Chapters 1-10:")
html_page1 = generate_navigation_overlay(
chapters=chapters,
bookmarks=bookmarks,
active_tab="contents",
page_size=(800, 1200),
toc_page=0,
toc_items_per_page=10
)
# Extract chapter titles for display
for i in range(10):
print(f" {i+1}. {chapters[i]['title']}")
print(" [← Prev] Page 1 of 4 [Next →]")
# Page 2 of TOC (chapters 11-20)
print("\n[Page 2/4] Chapters 11-20:")
html_page2 = generate_navigation_overlay(
chapters=chapters,
bookmarks=bookmarks,
active_tab="contents",
page_size=(800, 1200),
toc_page=1,
toc_items_per_page=10
)
for i in range(10, 20):
print(f" {i+1}. {chapters[i]['title']}")
print(" [← Prev] Page 2 of 4 [Next →]")
# Page 3 of TOC (chapters 21-30)
print("\n[Page 3/4] Chapters 21-30:")
html_page3 = generate_navigation_overlay(
chapters=chapters,
bookmarks=bookmarks,
active_tab="contents",
page_size=(800, 1200),
toc_page=2,
toc_items_per_page=10
)
for i in range(20, 30):
print(f" {i+1}. {chapters[i]['title']}")
print(" [← Prev] Page 3 of 4 [Next →]")
# Page 4 of TOC (chapters 31-35)
print("\n[Page 4/4] Chapters 31-35:")
html_page4 = generate_navigation_overlay(
chapters=chapters,
bookmarks=bookmarks,
active_tab="contents",
page_size=(800, 1200),
toc_page=3,
toc_items_per_page=10
)
for i in range(30, 35):
print(f" {i+1}. {chapters[i]['title']}")
print(" [← Prev] Page 4 of 4 [Next →]")
# Demonstrate pagination on Bookmarks tab
print("\n" + "=" * 60)
print("Bookmarks Tab Pagination:")
print("-" * 60)
# Page 1 of Bookmarks (1-10)
print("\n[Page 1/2] Bookmarks 1-10:")
html_bm1 = generate_navigation_overlay(
chapters=chapters,
bookmarks=bookmarks,
active_tab="bookmarks",
page_size=(800, 1200),
toc_page=0,
bookmarks_page=0,
toc_items_per_page=10
)
for i in range(10):
print(f" {bookmarks[i]['name']} - {bookmarks[i]['position']}")
print(" [← Prev] Page 1 of 2 [Next →]")
# Page 2 of Bookmarks (11-20)
print("\n[Page 2/2] Bookmarks 11-20:")
html_bm2 = generate_navigation_overlay(
chapters=chapters,
bookmarks=bookmarks,
active_tab="bookmarks",
page_size=(800, 1200),
toc_page=0,
bookmarks_page=1,
toc_items_per_page=10
)
for i in range(10, 20):
print(f" {bookmarks[i]['name']} - {bookmarks[i]['position']}")
print(" [← Prev] Page 2 of 2 [Next →]")
print("\n" + "=" * 60)
print("Pagination Controls:")
print("-" * 60)
print(" - Click 'Next →' to go to next page")
print(" - Click '← Prev' to go to previous page")
print(" - Page indicator shows: 'Page X of Y'")
print(" - Buttons are disabled at boundaries:")
print("'← Prev' disabled on page 1")
print("'Next →' disabled on last page")
print()
print("=" * 60)
print("Interactive Gesture Flow:")
print("-" * 60)
print("1. User swipes up → Opens navigation overlay (page 1)")
print("2. User taps 'Next →' → Shows page 2")
print("3. User taps 'Next →' → Shows page 3")
print("4. User taps chapter → Navigates to chapter & closes overlay")
print("5. OR taps '← Prev' → Goes back to page 2")
print()
print("HTML Features Implemented:")
print("-" * 60)
print("✓ Pagination links: <a href='page:next'> and <a href='page:prev'>")
print("✓ Page indicator: 'Page X of Y' text")
print("✓ Disabled styling: opacity 0.3 + pointer-events: none")
print("✓ Separate pagination for Contents and Bookmarks tabs")
print("✓ Automatic page calculation based on total items")
print("✓ Graceful handling of empty lists")
print()
print("=" * 60)
print("Demo Complete!")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,470 @@
#!/usr/bin/env python3
"""
Demo script for Settings overlay feature.
This script demonstrates the complete settings overlay workflow:
1. Display reading page
2. Swipe down from top to open settings overlay
3. Display settings overlay with controls
4. Tap on font size increase button
5. Show live preview update (background page changes)
6. Tap on line spacing increase button
7. Show another live preview update
8. Close overlay and show final page with new settings
Generates a GIF showing all these interactions.
"""
from pathlib import Path
from dreader import EbookReader, TouchEvent, GestureType
from PIL import Image, ImageDraw, ImageFont
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 Settings overlay demo GIF"""
print("=== Settings Overlay Demo ===")
print()
# Use Alice in Wonderland test book (has actual content)
epub_path = Path(__file__).parent.parent / 'tests' / 'data' / 'test.epub'
if not epub_path.exists():
print("Error: test.epub not found!")
print(f"Looked in: {epub_path}")
return
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()
# Skip to a page with actual content (past cover/title pages)
for _ in range(3):
reader.next_page()
# 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 down gesture
print("Frame 2: Swipe down gesture...")
swipe_visual = add_swipe_arrow(page1, 100, 300)
annotated2 = add_gesture_annotation(swipe_visual, "Swipe down from top", "top")
frames.append(annotated2)
frame_duration.append(1000) # 1 second
# Frame 3: Settings overlay appears
print("Frame 3: Settings overlay opens...")
event_swipe_down = TouchEvent(gesture=GestureType.SWIPE_DOWN, x=400, y=100)
response = reader.handle_touch(event_swipe_down)
print(f" Response: {response.action}")
# Get the overlay image by calling open_settings_overlay again
overlay_image = reader.open_settings_overlay()
annotated3 = add_gesture_annotation(overlay_image, "Settings", "top")
frames.append(annotated3)
frame_duration.append(3000) # 3 seconds to read
# Find actual button coordinates by querying the overlay
print("Querying overlay for button positions...")
link_positions = {}
if reader._active_overlay and reader._active_overlay._overlay_reader:
page = reader._active_overlay._overlay_reader.manager.get_current_page()
# Scan for all links with very fine granularity to catch all buttons
for y in range(0, 840, 3):
for x in range(0, 480, 3):
result = page.query_point((x, y))
if result and result.link_target:
if result.link_target not in link_positions:
# Translate to screen coordinates
panel_x_offset = int((800 - 480) / 2)
panel_y_offset = int((1200 - 840) / 2)
screen_x = x + panel_x_offset
screen_y = y + panel_y_offset
link_positions[result.link_target] = (screen_x, screen_y)
for link, (x, y) in sorted(link_positions.items()):
print(f" Found: {link} at ({x}, {y})")
# Frame 4: Tap on font size increase button
print("Frame 4: Tap on font size increase...")
if 'setting:font_increase' in link_positions:
tap_x, tap_y = link_positions['setting:font_increase']
print(f" Using coordinates: ({tap_x}, {tap_y})")
tap_visual = add_tap_indicator(overlay_image, tap_x, tap_y, "Increase")
annotated4 = add_gesture_annotation(tap_visual, "Tap to increase font size", "bottom")
frames.append(annotated4)
frame_duration.append(1500) # 1.5 seconds
# Frames 5-9: Font size increased (live preview) - show each tap individually
print("Frames 5-9: Font size increased with live preview (5 taps, showing each)...")
for i in range(5):
event_tap_font = TouchEvent(gesture=GestureType.TAP, x=tap_x, y=tap_y)
response = reader.handle_touch(event_tap_font)
print(f" Tap {i+1}: {response.action} - Font scale: {response.data.get('font_scale', 'N/A')}")
# Get updated overlay image after each tap
updated_overlay = reader.get_current_page() # This gets the composited overlay
annotated = add_gesture_annotation(
updated_overlay,
f"Font: {int(reader.base_font_scale * 100)}% (tap {i+1}/5)",
"top"
)
frames.append(annotated)
frame_duration.append(800) # 0.8 seconds per tap
# Hold on final font size for a bit longer
final_font_overlay = reader.get_current_page()
annotated_final = add_gesture_annotation(
final_font_overlay,
f"Font: {int(reader.base_font_scale * 100)}% (complete)",
"top"
)
frames.append(annotated_final)
frame_duration.append(1500) # 1.5 seconds to see the final result
else:
print(" Skipping - button not found")
updated_overlay = overlay_image
# Get current overlay state for line spacing section
current_overlay = reader.get_current_page()
# Frame N: Tap on line spacing increase button
print("Frame N: Tap on line spacing increase...")
if 'setting:line_spacing_increase' in link_positions:
tap_x2, tap_y2 = link_positions['setting:line_spacing_increase']
print(f" Using coordinates: ({tap_x2}, {tap_y2})")
tap_visual2 = add_tap_indicator(current_overlay, tap_x2, tap_y2, "Increase")
annotated_ls_tap = add_gesture_annotation(tap_visual2, "Tap to increase line spacing", "bottom")
frames.append(annotated_ls_tap)
frame_duration.append(1500) # 1.5 seconds
# Frames N+1 to N+5: Line spacing increased (live preview) - show each tap individually
print("Frames N+1 to N+5: Line spacing increased with live preview (5 taps, showing each)...")
for i in range(5):
event_tap_spacing = TouchEvent(gesture=GestureType.TAP, x=tap_x2, y=tap_y2)
response = reader.handle_touch(event_tap_spacing)
print(f" Tap {i+1}: {response.action} - Line spacing: {response.data.get('line_spacing', 'N/A')}")
# Get updated overlay image after each tap
updated_overlay2 = reader.get_current_page()
annotated = add_gesture_annotation(
updated_overlay2,
f"Line Spacing: {reader.page_style.line_spacing}px (tap {i+1}/5)",
"top"
)
frames.append(annotated)
frame_duration.append(800) # 0.8 seconds per tap
# Hold on final line spacing for a bit longer
final_spacing_overlay = reader.get_current_page()
annotated_final_ls = add_gesture_annotation(
final_spacing_overlay,
f"Line Spacing: {reader.page_style.line_spacing}px (complete)",
"top"
)
frames.append(annotated_final_ls)
frame_duration.append(1500) # 1.5 seconds to see the final result
else:
print(" Skipping - button not found")
# Get current overlay state for paragraph spacing section
current_overlay2 = reader.get_current_page()
# Frame M: Tap on paragraph spacing increase button
print("Frame M: Tap on paragraph spacing increase...")
if 'setting:block_spacing_increase' in link_positions:
tap_x3, tap_y3 = link_positions['setting:block_spacing_increase']
print(f" Using coordinates: ({tap_x3}, {tap_y3})")
tap_visual3 = add_tap_indicator(current_overlay2, tap_x3, tap_y3, "Increase")
annotated_ps_tap = add_gesture_annotation(tap_visual3, "Tap to increase paragraph spacing", "bottom")
frames.append(annotated_ps_tap)
frame_duration.append(1500) # 1.5 seconds
# Frames M+1 to M+5: Paragraph spacing increased (live preview) - show each tap individually
print("Frames M+1 to M+5: Paragraph spacing increased with live preview (5 taps, showing each)...")
for i in range(5):
event_tap_para = TouchEvent(gesture=GestureType.TAP, x=tap_x3, y=tap_y3)
response = reader.handle_touch(event_tap_para)
print(f" Tap {i+1}: {response.action} - Paragraph spacing: {response.data.get('inter_block_spacing', 'N/A')}")
# Get updated overlay image after each tap
updated_overlay3 = reader.get_current_page()
annotated = add_gesture_annotation(
updated_overlay3,
f"Paragraph Spacing: {reader.page_style.inter_block_spacing}px (tap {i+1}/5)",
"top"
)
frames.append(annotated)
frame_duration.append(800) # 0.8 seconds per tap
# Hold on final paragraph spacing for a bit longer
final_para_overlay = reader.get_current_page()
annotated_final_ps = add_gesture_annotation(
final_para_overlay,
f"Paragraph Spacing: {reader.page_style.inter_block_spacing}px (complete)",
"top"
)
frames.append(annotated_final_ps)
frame_duration.append(1500) # 1.5 seconds to see the final result
else:
print(" Skipping - button not found")
# Get current overlay state for word spacing section
current_overlay3 = reader.get_current_page()
# Frame W: Tap on word spacing increase button
print("Frame W: Tap on word spacing increase...")
if 'setting:word_spacing_increase' in link_positions:
tap_x4, tap_y4 = link_positions['setting:word_spacing_increase']
print(f" Using coordinates: ({tap_x4}, {tap_y4})")
tap_visual4 = add_tap_indicator(current_overlay3, tap_x4, tap_y4, "Increase")
annotated_ws_tap = add_gesture_annotation(tap_visual4, "Tap to increase word spacing", "bottom")
frames.append(annotated_ws_tap)
frame_duration.append(1500) # 1.5 seconds
# Frames W+1 to W+5: Word spacing increased (live preview) - show each tap individually
print("Frames W+1 to W+5: Word spacing increased with live preview (5 taps, showing each)...")
for i in range(5):
event_tap_word = TouchEvent(gesture=GestureType.TAP, x=tap_x4, y=tap_y4)
response = reader.handle_touch(event_tap_word)
print(f" Tap {i+1}: {response.action} - Word spacing: {response.data.get('word_spacing', 'N/A')}")
# Get updated overlay image after each tap
updated_overlay4 = reader.get_current_page()
annotated = add_gesture_annotation(
updated_overlay4,
f"Word Spacing: {reader.page_style.word_spacing}px (tap {i+1}/5)",
"top"
)
frames.append(annotated)
frame_duration.append(800) # 0.8 seconds per tap
# Hold on final word spacing for a bit longer
final_word_overlay = reader.get_current_page()
annotated_final_ws = add_gesture_annotation(
final_word_overlay,
f"Word Spacing: {reader.page_style.word_spacing}px (complete)",
"top"
)
frames.append(annotated_final_ws)
frame_duration.append(1500) # 1.5 seconds to see the final result
else:
print(" Skipping - button not found")
# Frame Z: Tap outside to close
print("Frame Z: Close overlay...")
final_overlay_state = reader.get_current_page()
tap_visual_close = add_tap_indicator(final_overlay_state, 100, 600, "Close")
annotated_close = add_gesture_annotation(tap_visual_close, "Tap outside to close", "bottom")
frames.append(annotated_close)
frame_duration.append(1500) # 1.5 seconds
# Final Frame: Back to reading with new settings applied
print("Final Frame: Back to reading with new settings...")
event_close = TouchEvent(gesture=GestureType.TAP, x=100, y=600)
response = reader.handle_touch(event_close)
print(f" Response: {response.action}")
final_page = reader.get_current_page()
annotated_final = add_gesture_annotation(
final_page,
f"Settings Applied: {int(reader.base_font_scale * 100)}% font, {reader.page_style.line_spacing}px line, {reader.page_style.inter_block_spacing}px para, {reader.page_style.word_spacing}px word",
"top"
)
frames.append(annotated_final)
frame_duration.append(3000) # 3 seconds
# Save as GIF
output_path = Path(__file__).parent.parent / 'docs' / 'images' / 'settings_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 / 'settings_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()

296
examples/demo_toc_overlay.py Executable file
View File

@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
Demo script for TOC overlay feature.
This script demonstrates the complete TOC overlay workflow:
1. Display reading page
2. Swipe up from bottom to open TOC overlay
3. Display TOC overlay with chapter list
4. Tap on a chapter to navigate
5. Close overlay and show new page
Generates a GIF showing all these interactions.
"""
from pathlib import Path
from dreader import EbookReader, TouchEvent, GestureType
from PIL import Image, ImageDraw, ImageFont
import time
def add_gesture_annotation(image: Image.Image, text: str, position: str = "top") -> Image.Image:
"""
Add a text annotation to an image showing what gesture is being performed.
Args:
image: Base image
text: Annotation text
position: "top" or "bottom"
Returns:
Image with annotation
"""
# Create a copy
annotated = image.copy()
draw = ImageDraw.Draw(annotated)
# Try to use a nice font, fall back to default
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
except:
font = ImageFont.load_default()
# Calculate text position
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (image.width - text_width) // 2
if position == "top":
y = 20
else:
y = image.height - text_height - 20
# Draw background rectangle
padding = 10
draw.rectangle(
[x - padding, y - padding, x + text_width + padding, y + text_height + padding],
fill=(0, 0, 0, 200)
)
# Draw text
draw.text((x, y), text, fill=(255, 255, 255), font=font)
return annotated
def add_swipe_arrow(image: Image.Image, start_y: int, end_y: int) -> Image.Image:
"""
Add a visual swipe arrow to show gesture direction.
Args:
image: Base image
start_y: Starting Y position
end_y: Ending Y position
Returns:
Image with arrow overlay
"""
annotated = image.copy()
draw = ImageDraw.Draw(annotated)
# Draw arrow in center of screen
x = image.width // 2
# Draw line
draw.line([(x, start_y), (x, end_y)], fill=(255, 100, 100), width=5)
# Draw arrowhead
arrow_size = 20
if end_y < start_y: # Upward arrow
draw.polygon([
(x, end_y),
(x - arrow_size, end_y + arrow_size),
(x + arrow_size, end_y + arrow_size)
], fill=(255, 100, 100))
else: # Downward arrow
draw.polygon([
(x, end_y),
(x - arrow_size, end_y - arrow_size),
(x + arrow_size, end_y - arrow_size)
], fill=(255, 100, 100))
return annotated
def add_tap_indicator(image: Image.Image, x: int, y: int, label: str = "") -> Image.Image:
"""
Add a visual tap indicator to show where user tapped.
Args:
image: Base image
x, y: Tap coordinates
label: Optional label for the tap
Returns:
Image with tap indicator
"""
annotated = image.copy()
draw = ImageDraw.Draw(annotated)
# Draw circle at tap location
radius = 30
draw.ellipse(
[x - radius, y - radius, x + radius, y + radius],
outline=(255, 100, 100),
width=5
)
# Draw crosshair
draw.line([(x - radius - 10, y), (x + radius + 10, y)], fill=(255, 100, 100), width=3)
draw.line([(x, y - radius - 10), (x, y + radius + 10)], fill=(255, 100, 100), width=3)
# Add label if provided
if label:
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18)
except:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), label, font=font)
text_width = bbox[2] - bbox[0]
# Position label above tap point
label_x = x - text_width // 2
label_y = y - radius - 40
draw.text((label_x, label_y), label, fill=(255, 100, 100), font=font)
return annotated
def main():
"""Generate TOC overlay demo GIF"""
print("=== TOC Overlay Demo ===")
print()
# Find a test EPUB
epub_dir = Path(__file__).parent.parent / 'tests' / 'data' / 'library-epub'
epubs = list(epub_dir.glob('*.epub'))
if not epubs:
print("Error: No test EPUB files found!")
print(f"Looked in: {epub_dir}")
return
epub_path = epubs[0]
print(f"Using book: {epub_path.name}")
# Create reader
reader = EbookReader(page_size=(800, 1200))
# Load book
print("Loading book...")
success = reader.load_epub(str(epub_path))
if not success:
print("Error: Failed to load EPUB!")
return
print(f"Loaded: {reader.book_title} by {reader.book_author}")
print(f"Chapters: {len(reader.get_chapters())}")
print()
# Prepare frames for GIF
frames = []
frame_duration = [] # Duration in milliseconds for each frame
# Frame 1: Initial reading page
print("Frame 1: Initial reading page...")
page1 = reader.get_current_page()
annotated1 = add_gesture_annotation(page1, f"Reading: {reader.book_title}", "top")
frames.append(annotated1)
frame_duration.append(2000) # 2 seconds
# Frame 2: Show swipe up gesture
print("Frame 2: Swipe up gesture...")
swipe_visual = add_swipe_arrow(page1, 1100, 900)
annotated2 = add_gesture_annotation(swipe_visual, "Swipe up from bottom", "bottom")
frames.append(annotated2)
frame_duration.append(1000) # 1 second
# Frame 3: TOC overlay appears
print("Frame 3: TOC overlay opens...")
event_swipe_up = TouchEvent(gesture=GestureType.SWIPE_UP, x=400, y=1100)
response = reader.handle_touch(event_swipe_up)
print(f" Response: {response.action}")
# Get the overlay image by calling open_toc_overlay again
# (handle_touch already opened it, but we need the image)
overlay_image = reader.open_toc_overlay()
annotated3 = add_gesture_annotation(overlay_image, "Table of Contents", "top")
frames.append(annotated3)
frame_duration.append(3000) # 3 seconds to read
# Frame 4: Show tap on chapter III (index 6)
print("Frame 4: Tap on chapter III...")
chapters = reader.get_chapters()
if len(chapters) >= 7:
# Calculate tap position for chapter III (7th in list, index 6)
# Based on actual measurements from pyWebLayout link query:
# Chapter 6 "III" link is clickable at screen position (200, 378)
tap_x = 200
tap_y = 378
tap_visual = add_tap_indicator(overlay_image, tap_x, tap_y, "III")
annotated4 = add_gesture_annotation(tap_visual, "Tap chapter to navigate", "bottom")
frames.append(annotated4)
frame_duration.append(1500) # 1.5 seconds
# Frame 5: Navigate to chapter III
print(f"Frame 5: Jump to chapter III (tapping at {tap_x}, {tap_y})...")
event_tap = TouchEvent(gesture=GestureType.TAP, x=tap_x, y=tap_y)
response = reader.handle_touch(event_tap)
print(f" Response: {response.action}")
new_page = reader.get_current_page()
# Use the chapter title from the response data (more accurate)
if response.action == "chapter_selected" and "chapter_title" in response.data:
chapter_title = response.data['chapter_title']
else:
chapter_title = "Chapter"
annotated5 = add_gesture_annotation(new_page, f"Navigated to: {chapter_title}", "top")
frames.append(annotated5)
frame_duration.append(2000) # 2 seconds
else:
print(" Skipping chapter selection (not enough chapters)")
# Frame 6: Another page for context
print("Frame 6: Next page...")
reader.next_page()
page_final = reader.get_current_page()
annotated6 = add_gesture_annotation(page_final, "Reading continues...", "top")
frames.append(annotated6)
frame_duration.append(2000) # 2 seconds
# Save as GIF
output_path = Path(__file__).parent.parent / 'docs' / 'images' / 'toc_overlay_demo.gif'
output_path.parent.mkdir(parents=True, exist_ok=True)
print()
print(f"Saving GIF with {len(frames)} frames...")
frames[0].save(
output_path,
save_all=True,
append_images=frames[1:],
duration=frame_duration,
loop=0,
optimize=False
)
print(f"✓ GIF saved to: {output_path}")
print(f" Size: {output_path.stat().st_size / 1024:.1f} KB")
print(f" Frames: {len(frames)}")
print(f" Total duration: {sum(frame_duration) / 1000:.1f}s")
# Also save individual frames for documentation
frames_dir = output_path.parent / 'toc_overlay_frames'
frames_dir.mkdir(exist_ok=True)
for i, frame in enumerate(frames):
frame_path = frames_dir / f'frame_{i+1:02d}.png'
frame.save(frame_path)
print(f"✓ Individual frames saved to: {frames_dir}")
# Cleanup
reader.close()
print()
print("=== Demo Complete ===")
if __name__ == '__main__':
main()

329
examples/ereader_demo.py Normal file
View File

@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""
Comprehensive demo of the EbookReader functionality.
This script demonstrates all features of the pyWebLayout EbookReader:
- Loading EPUB files
- Page navigation (forward/backward)
- Position saving/loading
- Chapter navigation
- Font size and spacing adjustments
- Getting book and position information
Usage:
python ereader_demo.py path/to/book.epub
"""
import sys
import os
from pathlib import Path
# Add parent directory to path to import pyWebLayout
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.application import EbookReader
def print_separator():
"""Print a visual separator."""
print("\n" + "="*70 + "\n")
def demo_basic_navigation(reader: EbookReader):
"""Demonstrate basic page navigation."""
print("DEMO: Basic Navigation")
print_separator()
# Get current page
print("Getting first page...")
page = reader.get_current_page()
if page:
print(f"✓ Current page rendered: {page.size}")
reader.render_to_file("demo_page_001.png")
print(" Saved to: demo_page_001.png")
# Navigate forward
print("\nNavigating to next page...")
page = reader.next_page()
if page:
print(f"✓ Next page rendered: {page.size}")
reader.render_to_file("demo_page_002.png")
print(" Saved to: demo_page_002.png")
# Navigate backward
print("\nNavigating to previous page...")
page = reader.previous_page()
if page:
print(f"✓ Previous page rendered: {page.size}")
print_separator()
def demo_position_management(reader: EbookReader):
"""Demonstrate position save/load functionality."""
print("DEMO: Position Management")
print_separator()
# Navigate a few pages forward
print("Navigating forward 3 pages...")
for i in range(3):
reader.next_page()
# Save position
print("Saving current position as 'demo_bookmark'...")
success = reader.save_position("demo_bookmark")
if success:
print("✓ Position saved successfully")
# Get position info
pos_info = reader.get_position_info()
print(f"\nCurrent position info:")
print(f" Chapter: {pos_info.get('chapter', {}).get('title', 'N/A')}")
print(f" Block index: {pos_info['position']['block_index']}")
print(f" Word index: {pos_info['position']['word_index']}")
print(f" Progress: {pos_info['progress']*100:.1f}%")
# Navigate away
print("\nNavigating forward 5 more pages...")
for i in range(5):
reader.next_page()
# Load saved position
print("Loading saved position 'demo_bookmark'...")
page = reader.load_position("demo_bookmark")
if page:
print("✓ Position restored successfully")
reader.render_to_file("demo_restored_position.png")
print(" Saved to: demo_restored_position.png")
# List all saved positions
positions = reader.list_saved_positions()
print(f"\nAll saved positions: {positions}")
print_separator()
def demo_chapter_navigation(reader: EbookReader):
"""Demonstrate chapter navigation."""
print("DEMO: Chapter Navigation")
print_separator()
# Get all chapters
chapters = reader.get_chapters()
print(f"Found {len(chapters)} chapters:")
for title, idx in chapters[:5]: # Show first 5
print(f" [{idx}] {title}")
if len(chapters) > 5:
print(f" ... and {len(chapters) - 5} more")
# Jump to a chapter by index
if len(chapters) > 1:
print(f"\nJumping to chapter 1...")
page = reader.jump_to_chapter(1)
if page:
print("✓ Jumped to chapter successfully")
reader.render_to_file("demo_chapter_1.png")
print(" Saved to: demo_chapter_1.png")
# Get current chapter info
chapter_info = reader.get_current_chapter_info()
if chapter_info:
print(f" Current chapter: {chapter_info['title']}")
# Jump to a chapter by title (if we have chapters)
if len(chapters) > 0:
first_chapter_title = chapters[0][0]
print(f"\nJumping to chapter by title: '{first_chapter_title}'...")
page = reader.jump_to_chapter(first_chapter_title)
if page:
print("✓ Jumped to chapter by title successfully")
print_separator()
def demo_font_size_adjustment(reader: EbookReader):
"""Demonstrate font size adjustments."""
print("DEMO: Font Size Adjustment")
print_separator()
# Save current page for comparison
print("Rendering page at normal font size (1.0x)...")
page = reader.get_current_page()
if page:
reader.render_to_file("demo_font_normal.png")
print("✓ Saved to: demo_font_normal.png")
# Increase font size
print("\nIncreasing font size...")
page = reader.increase_font_size()
if page:
print(f"✓ Font size increased to {reader.get_font_size():.1f}x")
reader.render_to_file("demo_font_larger.png")
print(" Saved to: demo_font_larger.png")
# Increase again
print("\nIncreasing font size again...")
page = reader.increase_font_size()
if page:
print(f"✓ Font size increased to {reader.get_font_size():.1f}x")
reader.render_to_file("demo_font_largest.png")
print(" Saved to: demo_font_largest.png")
# Decrease font size
print("\nDecreasing font size...")
page = reader.decrease_font_size()
if page:
print(f"✓ Font size decreased to {reader.get_font_size():.1f}x")
# Set specific font size
print("\nResetting to normal font size (1.0x)...")
page = reader.set_font_size(1.0)
if page:
print("✓ Font size reset to 1.0x")
print_separator()
def demo_spacing_adjustment(reader: EbookReader):
"""Demonstrate line and block spacing adjustments."""
print("DEMO: Spacing Adjustment")
print_separator()
# Save current page
print("Rendering page with default spacing...")
page = reader.get_current_page()
if page:
reader.render_to_file("demo_spacing_default.png")
print("✓ Saved to: demo_spacing_default.png")
# Increase line spacing
print("\nIncreasing line spacing to 10px...")
page = reader.set_line_spacing(10)
if page:
print("✓ Line spacing increased")
reader.render_to_file("demo_spacing_lines_10.png")
print(" Saved to: demo_spacing_lines_10.png")
# Increase inter-block spacing
print("\nIncreasing inter-block spacing to 25px...")
page = reader.set_inter_block_spacing(25)
if page:
print("✓ Inter-block spacing increased")
reader.render_to_file("demo_spacing_blocks_25.png")
print(" Saved to: demo_spacing_blocks_25.png")
# Reset to defaults
print("\nResetting spacing to defaults (line: 5px, block: 15px)...")
reader.set_line_spacing(5)
page = reader.set_inter_block_spacing(15)
if page:
print("✓ Spacing reset to defaults")
print_separator()
def demo_book_information(reader: EbookReader):
"""Demonstrate getting book information."""
print("DEMO: Book Information")
print_separator()
# Get book info
book_info = reader.get_book_info()
print("Book Information:")
print(f" Title: {book_info['title']}")
print(f" Author: {book_info['author']}")
print(f" Document ID: {book_info['document_id']}")
print(f" Total blocks: {book_info['total_blocks']}")
print(f" Total chapters: {book_info['total_chapters']}")
print(f" Page size: {book_info['page_size']}")
print(f" Font scale: {book_info['font_scale']}")
# Get reading progress
progress = reader.get_reading_progress()
print(f"\nReading Progress: {progress*100:.1f}%")
# Get detailed position info
pos_info = reader.get_position_info()
print("\nDetailed Position:")
print(f" Chapter index: {pos_info['position']['chapter_index']}")
print(f" Block index: {pos_info['position']['block_index']}")
print(f" Word index: {pos_info['position']['word_index']}")
chapter = pos_info.get('chapter', {})
if chapter.get('title'):
print(f" Current chapter: {chapter['title']}")
print_separator()
def main():
"""Main function to run all demos."""
if len(sys.argv) < 2:
print("Usage: python ereader_demo.py path/to/book.epub")
print("\nExample EPUBs to try:")
print(" - tests/data/test.epub")
print(" - tests/data/test2.epub")
sys.exit(1)
epub_path = sys.argv[1]
if not os.path.exists(epub_path):
print(f"Error: File not found: {epub_path}")
sys.exit(1)
print("="*70)
print(" EbookReader Demo - pyWebLayout")
print("="*70)
print(f"\nLoading EPUB: {epub_path}")
# Create reader with context manager
with EbookReader(page_size=(800, 1000)) as reader:
# Load the EPUB
if not reader.load_epub(epub_path):
print("Error: Failed to load EPUB file")
sys.exit(1)
print("✓ EPUB loaded successfully")
# Run all demos
try:
demo_basic_navigation(reader)
demo_position_management(reader)
demo_chapter_navigation(reader)
demo_font_size_adjustment(reader)
demo_spacing_adjustment(reader)
demo_book_information(reader)
print("\n" + "="*70)
print(" Demo Complete!")
print("="*70)
print("\nGenerated demo images:")
demo_files = [
"demo_page_001.png",
"demo_page_002.png",
"demo_restored_position.png",
"demo_chapter_1.png",
"demo_font_normal.png",
"demo_font_larger.png",
"demo_font_largest.png",
"demo_spacing_default.png",
"demo_spacing_lines_10.png",
"demo_spacing_blocks_25.png"
]
for filename in demo_files:
if os.path.exists(filename):
print(f"{filename}")
print("\nAll features demonstrated successfully!")
except Exception as e:
print(f"\nError during demo: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,422 @@
#!/usr/bin/env python3
"""
Generate animated GIFs demonstrating EbookReader functionality.
This script creates animated GIFs showcasing:
1. Page navigation (next/previous)
2. Font size adjustment
3. Chapter navigation
4. Bookmark/position management
5. Word highlighting
The GIFs are saved to the examples/ directory and can be included in documentation.
Usage:
python generate_ereader_gifs.py path/to/book.epub [output_dir]
Example:
python generate_ereader_gifs.py ../tests/data/test.epub ../docs/images
"""
import sys
import os
from pathlib import Path
from typing import List
# Add parent directory to path to import pyWebLayout
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.application import EbookReader
from pyWebLayout.core.highlight import HighlightColor
from PIL import Image
def create_gif(images: List[Image.Image], output_path: str, duration: int = 800, loop: int = 0):
"""
Create an animated GIF from a list of PIL Images.
Args:
images: List of PIL Images to animate
output_path: Path where to save the GIF
duration: Duration of each frame in milliseconds
loop: Number of loops (0 = infinite)
"""
if not images:
print(f"Warning: No images provided for {output_path}")
return False
try:
# Save as animated GIF
images[0].save(
output_path,
save_all=True,
append_images=images[1:],
duration=duration,
loop=loop,
optimize=False
)
print(f"✓ Created: {output_path} ({len(images)} frames)")
return True
except Exception as e:
print(f"✗ Error creating {output_path}: {e}")
return False
def generate_page_navigation_gif(reader: EbookReader, output_path: str):
"""Generate GIF showing page navigation (forward and backward)."""
print("\n[1/4] Generating page navigation GIF...")
frames = []
# Go to beginning
reader.set_font_size(1.0)
# Capture 5 pages going forward
for i in range(5):
page = reader.get_current_page()
if page:
frames.append(page.copy())
reader.next_page()
# Go back to start
for _ in range(4):
reader.previous_page()
# Capture 5 pages going forward again (smoother loop)
for i in range(5):
page = reader.get_current_page()
if page:
frames.append(page.copy())
reader.next_page()
create_gif(frames, output_path, duration=600)
def generate_font_size_gif(reader: EbookReader, output_path: str):
"""Generate GIF showing font size adjustment."""
print("\n[2/4] Generating font size adjustment GIF...")
frames = []
# Reset to beginning and normal font
for _ in range(10):
reader.previous_page()
reader.set_font_size(1.0)
# Font sizes to demonstrate
font_scales = [0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.3, 1.2, 1.1, 1.0, 0.9, 0.8]
for scale in font_scales:
page = reader.set_font_size(scale)
if page:
frames.append(page.copy())
# Reset to normal
reader.set_font_size(1.0)
create_gif(frames, output_path, duration=500)
def generate_chapter_navigation_gif(reader: EbookReader, output_path: str):
"""Generate GIF showing chapter navigation."""
print("\n[3/4] Generating chapter navigation GIF...")
frames = []
# Reset font
reader.set_font_size(1.0)
# Get chapters
chapters = reader.get_chapters()
if len(chapters) == 0:
print(" Warning: No chapters found, skipping chapter navigation GIF")
return
# Visit first few chapters (or loop through available chapters)
chapter_indices = list(range(min(5, len(chapters))))
# Add some chapters twice for smoother animation
for idx in chapter_indices:
page = reader.jump_to_chapter(idx)
if page:
frames.append(page.copy())
# Add a second frame at each chapter for pause effect
frames.append(page.copy())
# Go back to first chapter
page = reader.jump_to_chapter(0)
if page:
frames.append(page.copy())
if frames:
create_gif(frames, output_path, duration=1000)
else:
print(" Warning: No frames captured for chapter navigation")
def generate_bookmark_gif(reader: EbookReader, output_path: str):
"""Generate GIF showing bookmark save/load functionality."""
print("\n[4/5] Generating bookmark/position GIF...")
frames = []
# Reset font
reader.set_font_size(1.0)
# Go to beginning
for _ in range(20):
reader.previous_page()
# Capture initial position
page = reader.get_current_page()
if page:
frames.append(page.copy())
frames.append(page.copy()) # Hold frame
# Navigate forward a bit
for i in range(3):
reader.next_page()
page = reader.get_current_page()
if page:
frames.append(page.copy())
# Save this position
reader.save_position("demo_bookmark")
page = reader.get_current_page()
if page:
frames.append(page.copy())
frames.append(page.copy()) # Hold frame to show saved position
# Navigate away
for i in range(5):
reader.next_page()
page = reader.get_current_page()
if page:
frames.append(page.copy())
# Hold at distant position
page = reader.get_current_page()
if page:
frames.append(page.copy())
frames.append(page.copy())
# Jump back to bookmark
page = reader.load_position("demo_bookmark")
if page:
frames.append(page.copy())
frames.append(page.copy())
frames.append(page.copy()) # Hold longer to show we're back
create_gif(frames, output_path, duration=600)
def generate_highlighting_gif(reader: EbookReader, output_path: str):
"""Generate GIF showing word highlighting functionality."""
print("\n[5/5] Generating word highlighting GIF...")
frames = []
# Reset font
reader.set_font_size(1.0)
# Find a page with actual text content (skip title/cover pages)
for _ in range(5):
reader.next_page()
# Collect text objects from the page with their actual positions
from pyWebLayout.concrete.text import Line
text_positions = []
# Try to find a page with text
max_attempts = 10
for attempt in range(max_attempts):
page = reader.manager.get_current_page()
text_positions = []
for child in page._children:
if isinstance(child, Line):
for text_obj in child._text_objects:
# Skip empty text
if not hasattr(text_obj, '_text') or not text_obj._text or not text_obj._text.strip():
continue
# Calculate center of text object, but clamp Y to Line bounds
origin = text_obj._origin
size = text_obj.size
center_x = int(origin[0] + size[0] / 2)
center_y = int(origin[1] + size[1] / 2)
# Clamp Y to be within Line bounds (avoids the baseline extension issue)
line_y_min = int(child._origin[1])
line_y_max = int(child._origin[1] + child._size[1])
clamped_y = max(line_y_min, min(line_y_max - 1, center_y))
text_positions.append((center_x, clamped_y, text_obj._text))
# If we found enough text, use this page
if len(text_positions) > 10:
print(f" Found page with {len(text_positions)} words")
break
# Otherwise try next page
reader.next_page()
if len(text_positions) == 0:
print(" Warning: Could not find a page with text after searching")
# Capture initial page without highlights
page_img = reader.get_current_page(include_highlights=False)
if page_img:
frames.append(page_img.copy())
frames.append(page_img.copy()) # Hold frame
# Use different colors for highlighting
colors = [
HighlightColor.YELLOW.value,
HighlightColor.GREEN.value,
HighlightColor.BLUE.value,
HighlightColor.PINK.value,
HighlightColor.ORANGE.value,
]
# Select a subset of words to highlight (spread across the page)
# Take every Nth word to get a good distribution
if len(text_positions) > 10:
step = len(text_positions) // 5
selected_positions = [text_positions[i * step] for i in range(5) if i * step < len(text_positions)]
else:
selected_positions = text_positions[:5]
highlighted_words = 0
color_names = ['YELLOW', 'GREEN', 'BLUE', 'PINK', 'ORANGE']
print(f"\n Highlighting words:")
for i, (x, y, text) in enumerate(selected_positions):
color = colors[i % len(colors)]
color_name = color_names[i % len(color_names)]
# Highlight the word at this position
highlight_id = reader.highlight_word(x, y, color=color)
if highlight_id:
highlighted_words += 1
print(f" [{color_name:6s}] {text}")
# Capture page with new highlight
page_img = reader.get_current_page(include_highlights=True)
if page_img:
frames.append(page_img.copy())
# Hold frame briefly to show the new highlight
frames.append(page_img.copy())
# If we managed to highlight any words, show the final result
if highlighted_words > 0:
page_img = reader.get_current_page(include_highlights=True)
if page_img:
# Hold final frame longer
for _ in range(3):
frames.append(page_img.copy())
# Clear highlights one by one
for highlight in reader.list_highlights():
reader.remove_highlight(highlight.id)
page_img = reader.get_current_page(include_highlights=True)
if page_img:
frames.append(page_img.copy())
# Show final cleared page
page_img = reader.get_current_page(include_highlights=False)
if page_img:
frames.append(page_img.copy())
frames.append(page_img.copy())
print(f" Successfully highlighted {highlighted_words} words")
else:
print(" Warning: No words found to highlight on current page")
if frames:
create_gif(frames, output_path, duration=700)
else:
print(" Warning: No frames captured for highlighting")
def main():
"""Main function to generate all GIFs."""
if len(sys.argv) < 2:
print("Usage: python generate_ereader_gifs.py path/to/book.epub [output_dir]")
print("\nExample:")
print(" python generate_ereader_gifs.py ../tests/data/test.epub ../docs/images")
sys.exit(1)
epub_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else "."
# Validate EPUB path
if not os.path.exists(epub_path):
print(f"Error: EPUB file not found: {epub_path}")
sys.exit(1)
# Create output directory
os.makedirs(output_dir, exist_ok=True)
print("="*70)
print(" EbookReader Animated GIF Generator")
print("="*70)
print(f"\nInput EPUB: {epub_path}")
print(f"Output directory: {output_dir}")
# Create paths for output GIFs
nav_gif = os.path.join(output_dir, "ereader_page_navigation.gif")
font_gif = os.path.join(output_dir, "ereader_font_size.gif")
chapter_gif = os.path.join(output_dir, "ereader_chapter_navigation.gif")
bookmark_gif = os.path.join(output_dir, "ereader_bookmarks.gif")
highlight_gif = os.path.join(output_dir, "ereader_highlighting.gif")
try:
# Create reader
with EbookReader(page_size=(600, 800), margin=30) as reader:
# Load EPUB
print("\nLoading EPUB...")
if not reader.load_epub(epub_path):
print("Error: Failed to load EPUB file")
sys.exit(1)
print("✓ EPUB loaded successfully")
# Get book info
book_info = reader.get_book_info()
print(f"\nBook: {book_info['title']}")
print(f"Author: {book_info['author']}")
print(f"Chapters: {book_info['total_chapters']}")
print(f"Blocks: {book_info['total_blocks']}")
print("\nGenerating GIFs...")
print("-" * 70)
# Generate all GIFs
generate_page_navigation_gif(reader, nav_gif)
generate_font_size_gif(reader, font_gif)
generate_chapter_navigation_gif(reader, chapter_gif)
generate_bookmark_gif(reader, bookmark_gif)
generate_highlighting_gif(reader, highlight_gif)
print("\n" + "="*70)
print(" Generation Complete!")
print("="*70)
print("\nGenerated files:")
for gif_path in [nav_gif, font_gif, chapter_gif, bookmark_gif, highlight_gif]:
if os.path.exists(gif_path):
size = os.path.getsize(gif_path)
print(f"{gif_path} ({size/1024:.1f} KB)")
print("\nYou can now add these GIFs to your README.md!")
except Exception as e:
print(f"\nError: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""
Generate demo GIF showing the complete library reading workflow.
This script creates an animated GIF demonstrating:
1. Library view with multiple books
2. Selecting a book by tapping
3. Reading the book (showing 5 pages)
4. Closing the book (back to library)
5. Reopening the same book
6. Auto-resuming at the saved position
Usage:
python generate_library_demo_gif.py path/to/library/directory output.gif
"""
import sys
import os
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.library import LibraryManager
from dreader.application import EbookReader
from dreader.gesture import TouchEvent, GestureType
def add_annotation(image: Image.Image, text: str, position: str = "top") -> Image.Image:
"""
Add annotation text to an image.
Args:
image: PIL Image to annotate
text: Annotation text
position: "top" or "bottom"
Returns:
New image with annotation
"""
# Create a copy
img = image.copy()
draw = ImageDraw.Draw(img)
# 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()
# Get text size
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Calculate position
x = (img.width - text_width) // 2
if position == "top":
y = 20
else:
y = img.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 img
def add_tap_indicator(image: Image.Image, x: int, y: int, label: str = "TAP") -> Image.Image:
"""
Add a visual tap indicator at coordinates.
Args:
image: PIL Image
x, y: Tap coordinates
label: Label text
Returns:
New image with tap indicator
"""
img = image.copy()
draw = ImageDraw.Draw(img)
# Draw circle at tap location
radius = 30
draw.ellipse(
[x - radius, y - radius, x + radius, y + radius],
outline=(255, 0, 0),
width=4
)
# Draw crosshair
cross_size = 10
draw.line([x - cross_size, y, x + cross_size, y], fill=(255, 0, 0), width=3)
draw.line([x, y - cross_size, x, y + cross_size], fill=(255, 0, 0), width=3)
# Draw 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]
text_height = bbox[3] - bbox[1]
# Position label above tap
label_x = x - text_width // 2
label_y = y - radius - text_height - 10
# Background for label
padding = 5
draw.rectangle(
[label_x - padding, label_y - padding,
label_x + text_width + padding, label_y + text_height + padding],
fill=(255, 0, 0)
)
draw.text((label_x, label_y), label, fill=(255, 255, 255), font=font)
return img
def generate_library_demo_gif(library_path: str, output_path: str):
"""
Generate the demo GIF.
Args:
library_path: Path to directory containing EPUB files
output_path: Output GIF file path
"""
frames = []
frame_durations = [] # Duration for each frame in milliseconds
print("Generating library demo GIF...")
print("=" * 70)
# ===================================================================
# FRAME 1: Library view
# ===================================================================
print("\n1. Rendering library view...")
library = LibraryManager(
library_path=library_path,
page_size=(800, 1200)
)
books = library.scan_library()
print(f" Found {len(books)} books")
if len(books) == 0:
print("Error: No books found in library")
sys.exit(1)
library_image = library.render_library()
annotated = add_annotation(library_image, "📚 My Library - Select a book", "top")
frames.append(annotated)
frame_durations.append(2000) # Hold for 2 seconds
# ===================================================================
# FRAME 2: Show tap on first book
# ===================================================================
print("2. Showing book selection...")
tap_x, tap_y = 400, 150 # Approximate position of first book
tap_frame = add_tap_indicator(library_image, tap_x, tap_y, "SELECT BOOK")
annotated = add_annotation(tap_frame, "📚 Tap to open book", "top")
frames.append(annotated)
frame_durations.append(1500)
# Get the selected book
selected_book = library.handle_library_tap(tap_x, tap_y)
if not selected_book:
selected_book = books[0]['path']
print(f" Selected: {selected_book}")
# ===================================================================
# FRAME 3-7: Reading pages
# ===================================================================
print("3. Opening book and reading pages...")
reader = EbookReader(
page_size=(800, 1200),
margin=40,
background_color=(255, 255, 255)
)
reader.load_epub(selected_book)
book_info = reader.get_book_info()
print(f" Title: {book_info['title']}")
# First page
page = reader.get_current_page()
annotated = add_annotation(page, f"📖 {book_info['title']} - Page 1", "top")
frames.append(annotated)
frame_durations.append(1500)
# Turn 4 more pages (total 5 pages)
for i in range(2, 6):
print(f" Reading page {i}...")
reader.next_page()
page = reader.get_current_page()
annotated = add_annotation(page, f"📖 Reading - Page {i}", "top")
frames.append(annotated)
frame_durations.append(1000) # Faster page turns
# ===================================================================
# FRAME 8: Show settings overlay with "Back to Library"
# ===================================================================
print("4. Opening settings overlay...")
settings_overlay = reader.open_settings_overlay()
if settings_overlay:
annotated = add_annotation(settings_overlay, "⚙️ Settings - Tap 'Back to Library'", "top")
# Show where to tap (estimated position of back button)
tap_frame = add_tap_indicator(annotated, 400, 950, "BACK")
frames.append(tap_frame)
frame_durations.append(2000)
# ===================================================================
# FRAME 9: Save position and return to library
# ===================================================================
print("5. Saving position and returning to library...")
# Save current position for resume
reader.save_position("__auto_resume__")
pos_info = reader.get_position_info()
saved_progress = pos_info['progress'] * 100
print(f" Saved at {saved_progress:.1f}% progress")
# Close reader
reader.close()
# Re-render library
library_image = library.render_library()
annotated = add_annotation(library_image, "📚 Back to Library (position saved)", "top")
frames.append(annotated)
frame_durations.append(2000)
# ===================================================================
# FRAME 10: Tap same book again
# ===================================================================
print("6. Re-selecting same book...")
tap_frame = add_tap_indicator(library_image, tap_x, tap_y, "REOPEN")
annotated = add_annotation(tap_frame, "📚 Tap to reopen book", "top")
frames.append(annotated)
frame_durations.append(1500)
# ===================================================================
# FRAME 11: Reopen book and auto-resume
# ===================================================================
print("7. Reopening book with auto-resume...")
reader2 = EbookReader(
page_size=(800, 1200),
margin=40,
background_color=(255, 255, 255)
)
reader2.load_epub(selected_book)
# Load saved position
resumed_page = reader2.load_position("__auto_resume__")
if resumed_page:
pos_info = reader2.get_position_info()
progress = pos_info['progress'] * 100
print(f" ✓ Resumed at {progress:.1f}% progress")
annotated = add_annotation(resumed_page, f"✅ Auto-resumed at {progress:.1f}%", "top")
frames.append(annotated)
frame_durations.append(3000) # Hold final frame longer
reader2.close()
# ===================================================================
# Save GIF
# ===================================================================
print("\n8. Saving GIF...")
print(f" Total frames: {len(frames)}")
print(f" Output: {output_path}")
# Save as GIF with variable durations
frames[0].save(
output_path,
save_all=True,
append_images=frames[1:],
duration=frame_durations,
loop=0, # Loop forever
optimize=False # Keep quality
)
print(f"\n✓ Demo GIF created: {output_path}")
print(f" Size: {os.path.getsize(output_path) / 1024 / 1024:.1f} MB")
# Cleanup
library.cleanup()
print("\n" + "=" * 70)
print("Demo complete!")
print("\nThe GIF demonstrates:")
print(" 1. Library view with book selection")
print(" 2. Opening a book and reading 5 pages")
print(" 3. Settings overlay with 'Back to Library' button")
print(" 4. Returning to library (with position saved)")
print(" 5. Reopening the same book")
print(" 6. Auto-resuming at saved position")
def main():
"""Main entry point."""
if len(sys.argv) < 2:
print("Usage: python generate_library_demo_gif.py path/to/library [output.gif]")
print("\nExample:")
print(" python generate_library_demo_gif.py tests/data/library-epub/")
print(" python generate_library_demo_gif.py tests/data/library-epub/ doc/images/custom_demo.gif")
sys.exit(1)
library_path = sys.argv[1]
output_path = sys.argv[2] if len(sys.argv) > 2 else "doc/images/library_reading_demo.gif"
if not os.path.exists(library_path):
print(f"Error: Directory not found: {library_path}")
sys.exit(1)
if not os.path.isdir(library_path):
print(f"Error: Not a directory: {library_path}")
sys.exit(1)
try:
generate_library_demo_gif(library_path, output_path)
except Exception as e:
print(f"\nError generating demo: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,400 @@
#!/usr/bin/env python3
"""
Integration demo: Library Reading Settings Back to Library
This example demonstrates the complete LIBRARY READING mode transition workflow:
1. Display a library of EPUB files
2. Select a book by clicking/tapping
3. Open and read the selected book
4. Access settings overlay
5. Return to library from the settings overlay
6. Select another book
This demonstrates the full user flow for an e-reader application.
Usage:
python library_reading_integration.py path/to/library/directory
"""
import sys
import os
from pathlib import Path
# Add parent directory to path to import dreader
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.library import LibraryManager
from dreader.application import EbookReader
from dreader.gesture import TouchEvent, GestureType, ActionType
def print_separator():
"""Print a visual separator."""
print("\n" + "="*70 + "\n")
def simulate_mode_transition_workflow(library_path: str):
"""
Simulate the complete workflow of library browsing and book reading.
Args:
library_path: Path to directory containing EPUB files
"""
print_separator()
print("INTEGRATION TEST: LIBRARY ↔ READING MODE TRANSITIONS")
print_separator()
# ===================================================================
# STEP 1: LIBRARY MODE - Display available books
# ===================================================================
print("STEP 1: LIBRARY MODE - Displaying available books")
print("-" * 70)
# Initialize library manager
library = LibraryManager(
library_path=library_path,
page_size=(800, 1200)
)
# Scan for books
books = library.scan_library()
print(f"✓ Found {len(books)} books in library")
if len(books) == 0:
print("Error: No EPUB files found in library directory")
print(f"Please add some .epub files to: {library_path}")
sys.exit(1)
# Display book list
for i, book in enumerate(books):
print(f" [{i}] {book['title']} by {book['author']}")
# Render library view
print("\nRendering library view...")
library_image = library.render_library()
library_image.save("integration_01_library.png")
print("✓ Saved library view to: integration_01_library.png")
# ===================================================================
# STEP 2: SIMULATE BOOK SELECTION - User taps on first book
# ===================================================================
print_separator()
print("STEP 2: BOOK SELECTION - Simulating tap on first book")
print("-" * 70)
# Simulate a tap on the first book row
# Row positions depend on rendering, but first book is typically near top
# We'll tap in the middle of the first book row area
tap_x, tap_y = 400, 150 # Approximate center of first book row
print(f"Simulating tap at ({tap_x}, {tap_y})...")
selected_book_path = library.handle_library_tap(tap_x, tap_y)
if not selected_book_path:
print("Warning: Tap didn't hit a book. Selecting first book directly...")
selected_book_path = books[0]['path']
print(f"✓ Selected book: {selected_book_path}")
# ===================================================================
# STEP 3: READING MODE - Open the selected book
# ===================================================================
print_separator()
print("STEP 3: READING MODE - Opening selected book")
print("-" * 70)
# Create reader
reader = EbookReader(
page_size=(800, 1200),
margin=40,
background_color=(255, 255, 255)
)
# Load the EPUB
print(f"Loading: {selected_book_path}")
success = reader.load_epub(selected_book_path)
if not success:
print("Error: Failed to load EPUB")
sys.exit(1)
print("✓ Book loaded successfully")
# Get book info
book_info = reader.get_book_info()
print(f" Title: {book_info['title']}")
print(f" Author: {book_info['author']}")
print(f" Chapters: {book_info['total_chapters']}")
# Render first page
print("\nRendering first page...")
page_image = reader.get_current_page()
page_image.save("integration_02_reading_page1.png")
print("✓ Saved first page to: integration_02_reading_page1.png")
# ===================================================================
# STEP 4: PAGE NAVIGATION - Turn some pages
# ===================================================================
print_separator()
print("STEP 4: PAGE NAVIGATION - Simulating page turns")
print("-" * 70)
# Simulate swipe left (next page)
print("Simulating SWIPE_LEFT (next page)...")
touch_event = TouchEvent(GestureType.SWIPE_LEFT, 600, 600)
response = reader.handle_touch(touch_event)
if response.action == ActionType.PAGE_TURN:
print(f"✓ Page turned: {response.data}")
page_image = reader.get_current_page()
page_image.save("integration_03_reading_page2.png")
print(" Saved to: integration_03_reading_page2.png")
# Turn another page
print("\nSimulating another SWIPE_LEFT...")
touch_event = TouchEvent(GestureType.SWIPE_LEFT, 600, 600)
response = reader.handle_touch(touch_event)
if response.action == ActionType.PAGE_TURN:
print(f"✓ Page turned: {response.data}")
# ===================================================================
# STEP 5: SETTINGS OVERLAY - Open and adjust settings
# ===================================================================
print_separator()
print("STEP 5: SETTINGS OVERLAY - Opening settings")
print("-" * 70)
# Open settings overlay
print("Opening settings overlay...")
overlay_image = reader.open_settings_overlay()
if overlay_image:
overlay_image.save("integration_04_settings_overlay.png")
print("✓ Settings overlay opened")
print(" Saved to: integration_04_settings_overlay.png")
# Simulate tapping "Increase Font Size" button
print("\nSimulating tap on 'Increase Font Size'...")
# The increase button is typically around y=250-280 in the overlay
tap_x, tap_y = 400, 270
touch_event = TouchEvent(GestureType.TAP, tap_x, tap_y)
response = reader.handle_touch(touch_event)
if response.action == ActionType.SETTING_CHANGED:
print(f"✓ Setting changed: {response.data}")
updated_overlay = reader.get_current_page()
updated_overlay.save("integration_05_settings_font_increased.png")
print(" Saved updated overlay to: integration_05_settings_font_increased.png")
# ===================================================================
# STEP 6: BACK TO LIBRARY - Use the new "Back to Library" button
# ===================================================================
print_separator()
print("STEP 6: BACK TO LIBRARY - Using 'Back to Library' button")
print("-" * 70)
# The settings overlay is 60% width x 70% height, centered
# For 800x1200: panel is 480x840, offset at (160, 180)
# The "Back to Library" button is near the bottom of the overlay panel
# Let's try scanning for it by querying multiple y-positions
print("Scanning for 'Back to Library' button...")
found_button = False
# Scan a wider range with finer granularity
# Settings overlay is 60% x 70% of 800x1200 = 480x840, centered at (160, 180)
# So overlay goes from y=180 to y=1020
# Button should be near bottom, scan from y=600 to y=1020
debug_results = []
for test_y in range(600, 1021, 20):
test_x = 400 # Center of screen
# Use the overlay manager's query method if there's an overlay open
if hasattr(reader, 'overlay_manager'):
result = reader.overlay_manager.query_overlay_pixel(test_x, test_y)
if result:
debug_results.append((test_y, result.get("link_target"), result.get("text", "")[:30]))
if result.get("is_interactive") and result.get("link_target"):
link = result["link_target"]
if link == "action:back_to_library":
print(f"✓ Found button at approximately ({test_x}, {test_y})")
tap_x, tap_y = test_x, test_y
found_button = True
break
if not found_button and debug_results:
print(f" Debug: Scanned {len(debug_results)} positions, found these links:")
for y, link, text in debug_results[-5:]: # Show last 5
if link:
print(f" y={y}: link={link}, text='{text}'")
if not found_button:
print(" Button not found via scan, using estimated position...")
# Fallback: overlay height is 840, centered at y=180
# Button is near bottom, approximately at panel_y + panel_height - 100
tap_x, tap_y = 400, 900
print(f"Simulating tap at ({tap_x}, {tap_y})...")
touch_event = TouchEvent(GestureType.TAP, tap_x, tap_y)
response = reader.handle_touch(touch_event)
if response.action == ActionType.BACK_TO_LIBRARY:
print("✓ BACK_TO_LIBRARY action received!")
print(" Application would now:")
print(" 1. Close the current book")
print(" 2. Return to library view")
print(" 3. Save reading position for resume")
# Save current position for resume
reader.save_position("__auto_resume__")
print("\n ✓ Auto-resume position saved")
# Close the reader
reader.close()
print(" ✓ Book closed")
# Re-render library
print("\n Re-rendering library view...")
library_image = library.render_library()
library_image.save("integration_06_back_to_library.png")
print(" ✓ Saved library view to: integration_06_back_to_library.png")
else:
print(f"Unexpected response: {response.action}")
print("Note: The button might be outside the overlay area or coordinates need adjustment")
# ===================================================================
# STEP 7: SELECT ANOTHER BOOK (if multiple books available)
# ===================================================================
if len(books) > 1:
print_separator()
print("STEP 7: SELECTING ANOTHER BOOK")
print("-" * 70)
# Select second book
second_book_path = books[1]['path']
print(f"Selecting second book: {second_book_path}")
# Create new reader instance
reader2 = EbookReader(
page_size=(800, 1200),
margin=40,
background_color=(255, 255, 255)
)
# Load second book
success = reader2.load_epub(second_book_path)
if success:
book_info = reader2.get_book_info()
print(f"✓ Loaded: {book_info['title']} by {book_info['author']}")
# Render first page
page_image = reader2.get_current_page()
page_image.save("integration_07_second_book.png")
print(" Saved to: integration_07_second_book.png")
reader2.close()
# ===================================================================
# STEP 8: RESUME PREVIOUS BOOK (demonstrate auto-resume)
# ===================================================================
print_separator()
print("STEP 8: AUTO-RESUME - Reopening first book at saved position")
print("-" * 70)
# Create new reader
reader3 = EbookReader(
page_size=(800, 1200),
margin=40,
background_color=(255, 255, 255)
)
# Load the book
print(f"Reloading: {selected_book_path}")
success = reader3.load_epub(selected_book_path)
if success:
# Load auto-resume position
print("Loading auto-resume position...")
page = reader3.load_position("__auto_resume__")
if page:
print("✓ Resumed at saved position!")
pos_info = reader3.get_position_info()
print(f" Progress: {pos_info['progress']*100:.1f}%")
page.save("integration_08_resumed_position.png")
print(" Saved to: integration_08_resumed_position.png")
else:
print("No saved position found (started from beginning)")
reader3.close()
# Cleanup
library.cleanup()
print_separator()
print("✓ INTEGRATION TEST COMPLETE!")
print_separator()
print("\nGenerated demonstration images:")
demo_files = [
"integration_01_library.png",
"integration_02_reading_page1.png",
"integration_03_reading_page2.png",
"integration_04_settings_overlay.png",
"integration_05_settings_font_increased.png",
"integration_06_back_to_library.png",
"integration_07_second_book.png",
"integration_08_resumed_position.png"
]
for filename in demo_files:
if os.path.exists(filename):
print(f"{filename}")
print("\nThis demonstrates the complete workflow:")
print(" 1. Library view with book selection")
print(" 2. Opening and reading a book")
print(" 3. Page navigation")
print(" 4. Settings overlay with adjustments")
print(" 5. Back to library transition")
print(" 6. Selecting another book")
print(" 7. Auto-resume functionality")
def main():
"""Main entry point."""
if len(sys.argv) < 2:
print("Usage: python library_reading_integration.py path/to/library/directory")
print("\nThis demo requires a directory containing EPUB files.")
print("\nExample:")
print(" mkdir my_library")
print(" cp tests/data/test.epub my_library/")
print(" cp tests/data/test2.epub my_library/")
print(" python library_reading_integration.py my_library/")
sys.exit(1)
library_path = sys.argv[1]
if not os.path.exists(library_path):
print(f"Error: Directory not found: {library_path}")
sys.exit(1)
if not os.path.isdir(library_path):
print(f"Error: Not a directory: {library_path}")
sys.exit(1)
try:
simulate_mode_transition_workflow(library_path)
except Exception as e:
print(f"\nError during integration test: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,196 @@
"""
Example demonstrating the unified navigation overlay feature.
This example shows how to:
1. Open the navigation overlay with Contents and Bookmarks tabs
2. Switch between tabs
3. Navigate to chapters and bookmarks
4. Handle user interactions with the overlay
The navigation overlay replaces the separate TOC and Bookmarks overlays
with a single, unified interface that provides both features in a tabbed view.
"""
from pathlib import Path
from dreader.application import EbookReader
from dreader.state import OverlayState
def main():
# Create reader instance
reader = EbookReader(page_size=(800, 1200), margin=20)
# Load a sample book (adjust path as needed)
book_path = Path(__file__).parent / "books" / "hamlet.epub"
if not book_path.exists():
print(f"Book not found at {book_path}")
print("Creating a simple HTML book for demo...")
# Create a simple multi-chapter book
html = """
<html>
<head><title>Demo Book</title></head>
<body>
<h1>Chapter 1: Introduction</h1>
<p>This is the first chapter with some introductory content.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<h1>Chapter 2: Main Content</h1>
<p>This is the second chapter with main content.</p>
<p>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
<h1>Chapter 3: Conclusion</h1>
<p>This is the final chapter with concluding remarks.</p>
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco.</p>
</body>
</html>
"""
reader.load_html(
html_string=html,
title="Demo Book",
author="Example Author",
document_id="demo_navigation"
)
else:
print(f"Loading book: {book_path}")
reader.load_epub(str(book_path))
print("\n=== Navigation Overlay Demo ===\n")
# Display current page
position_info = reader.get_position_info()
print(f"Current position: {position_info}")
print(f"Reading progress: {reader.get_reading_progress():.1%}")
# Get chapters
chapters = reader.get_chapters()
print(f"\nAvailable chapters: {len(chapters)}")
for i, (title, idx) in enumerate(chapters[:5]): # Show first 5
print(f" {i+1}. {title}")
# Save some bookmarks for demonstration
print("\n--- Saving bookmarks ---")
reader.save_position("Start of Book")
print("Saved bookmark: 'Start of Book'")
reader.next_page()
reader.next_page()
reader.save_position("Chapter 1 Progress")
print("Saved bookmark: 'Chapter 1 Progress'")
# List saved bookmarks
bookmarks = reader.list_saved_positions()
print(f"\nTotal bookmarks: {len(bookmarks)}")
for name in bookmarks:
print(f" - {name}")
# === Demo 1: Open navigation overlay with Contents tab ===
print("\n\n--- Demo 1: Opening Navigation Overlay (Contents Tab) ---")
image = reader.open_navigation_overlay(active_tab="contents")
if image:
print(f"✓ Navigation overlay opened successfully")
print(f" Overlay state: {reader.get_overlay_state()}")
print(f" Is overlay open: {reader.is_overlay_open()}")
print(f" Image size: {image.size}")
# Save the rendered overlay for inspection
output_path = Path("/tmp/navigation_overlay_contents.png")
image.save(output_path)
print(f" Saved to: {output_path}")
# === Demo 2: Switch to Bookmarks tab ===
print("\n\n--- Demo 2: Switching to Bookmarks Tab ---")
image = reader.switch_navigation_tab("bookmarks")
if image:
print(f"✓ Switched to Bookmarks tab")
print(f" Overlay state: {reader.get_overlay_state()}")
# Save the rendered overlay for inspection
output_path = Path("/tmp/navigation_overlay_bookmarks.png")
image.save(output_path)
print(f" Saved to: {output_path}")
# === Demo 3: Switch back to Contents tab ===
print("\n\n--- Demo 3: Switching back to Contents Tab ---")
image = reader.switch_navigation_tab("contents")
if image:
print(f"✓ Switched back to Contents tab")
# Save the rendered overlay for inspection
output_path = Path("/tmp/navigation_overlay_contents_2.png")
image.save(output_path)
print(f" Saved to: {output_path}")
# === Demo 4: Close overlay ===
print("\n\n--- Demo 4: Closing Navigation Overlay ---")
image = reader.close_overlay()
if image:
print(f"✓ Overlay closed successfully")
print(f" Overlay state: {reader.get_overlay_state()}")
print(f" Is overlay open: {reader.is_overlay_open()}")
# === Demo 5: Open with Bookmarks tab directly ===
print("\n\n--- Demo 5: Opening directly to Bookmarks Tab ---")
image = reader.open_navigation_overlay(active_tab="bookmarks")
if image:
print(f"✓ Navigation overlay opened with Bookmarks tab")
# Save the rendered overlay for inspection
output_path = Path("/tmp/navigation_overlay_bookmarks_direct.png")
image.save(output_path)
print(f" Saved to: {output_path}")
# Close overlay
reader.close_overlay()
# === Demo 6: Simulate user interaction flow ===
print("\n\n--- Demo 6: Simulated User Interaction Flow ---")
print("Simulating: User opens overlay, switches tabs, selects bookmark")
# 1. User opens navigation overlay
print("\n 1. User taps navigation button -> Opens overlay with Contents tab")
reader.open_navigation_overlay(active_tab="contents")
print(f" State: {reader.get_overlay_state()}")
# 2. User switches to Bookmarks tab
print("\n 2. User taps 'Bookmarks' tab")
reader.switch_navigation_tab("bookmarks")
print(f" State: {reader.get_overlay_state()}")
# 3. User selects a bookmark
print("\n 3. User taps on bookmark 'Start of Book'")
page = reader.load_position("Start of Book")
if page:
print(f" ✓ Loaded bookmark successfully")
print(f" Position: {reader.get_position_info()}")
# 4. Close overlay
print("\n 4. System closes overlay after selection")
reader.close_overlay()
print(f" State: {reader.get_overlay_state()}")
# === Summary ===
print("\n\n=== Demo Complete ===")
print(f"\nGenerated overlay images in /tmp:")
print(f" - navigation_overlay_contents.png")
print(f" - navigation_overlay_bookmarks.png")
print(f" - navigation_overlay_contents_2.png")
print(f" - navigation_overlay_bookmarks_direct.png")
print("\n✓ Navigation overlay provides unified interface for:")
print(" • Table of Contents (chapter navigation)")
print(" • Bookmarks (saved positions)")
print(" • Tab switching between Contents and Bookmarks")
print(" • Consistent interaction patterns")
# Cleanup
reader.close()
print("\nReader closed.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
Example demonstrating persistent rendering settings.
This shows how to:
1. Initialize StateManager to load saved settings
2. Apply saved settings to EbookReader
3. Modify settings during reading
4. Save settings automatically for next session
The settings (font size, line spacing, etc.) will persist between
application sessions, so the user doesn't have to reconfigure each time.
"""
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader import EbookReader
from dreader.state import StateManager, Settings
def demonstrate_persistent_settings():
"""Show how settings persist across sessions"""
print("=" * 70)
print("Persistent Settings Example")
print("=" * 70)
# 1. Initialize state manager (loads saved state from disk)
state_file = Path.home() / ".config" / "dreader" / "state.json"
state_file.parent.mkdir(parents=True, exist_ok=True)
state_manager = StateManager(state_file=state_file)
state = state_manager.load_state()
print(f"\nLoaded settings from: {state_file}")
print(f" Font scale: {state.settings.font_scale}")
print(f" Line spacing: {state.settings.line_spacing}px")
print(f" Inter-block spacing: {state.settings.inter_block_spacing}px")
print(f" Word spacing: {state.settings.word_spacing}px")
# 2. Create reader with saved settings
reader = EbookReader(
page_size=(800, 1000),
line_spacing=state.settings.line_spacing,
inter_block_spacing=state.settings.inter_block_spacing
)
# Load a book
epub_dir = Path(__file__).parent.parent / 'tests' / 'data' / 'library-epub'
epubs = list(epub_dir.glob('*.epub'))
if not epubs:
print("\nError: No test EPUB files found!")
print(f"Looked in: {epub_dir}")
return
epub_path = epubs[0]
print(f"\nLoading book: {epub_path.name}")
if not reader.load_epub(str(epub_path)):
print("Failed to load book!")
return
print(f"Loaded: {reader.book_title} by {reader.book_author}")
# 3. Apply saved settings to the book
print("\nApplying saved settings to book...")
settings_dict = state.settings.to_dict()
reader.apply_settings(settings_dict)
# Render initial page
print("\nRendering page with saved settings...")
page = reader.get_current_page()
reader.render_to_file("persistent_settings_before.png")
print("✓ Saved: persistent_settings_before.png")
# 4. Simulate user changing settings
print("\n" + "=" * 70)
print("User adjusts settings...")
print("=" * 70)
# Increase font size
print("\n1. Increasing font size...")
reader.increase_font_size()
reader.increase_font_size()
print(f" New font scale: {reader.base_font_scale}")
# Increase line spacing
print("2. Increasing line spacing...")
new_line_spacing = state.settings.line_spacing + 4
reader.set_line_spacing(new_line_spacing)
print(f" New line spacing: {new_line_spacing}px")
# Increase word spacing
print("3. Increasing word spacing...")
new_word_spacing = state.settings.word_spacing + 3
reader.set_word_spacing(new_word_spacing)
print(f" New word spacing: {new_word_spacing}px")
# Render page with new settings
print("\nRendering page with new settings...")
page = reader.get_current_page()
reader.render_to_file("persistent_settings_after.png")
print("✓ Saved: persistent_settings_after.png")
# 5. Save new settings to state
print("\n" + "=" * 70)
print("Saving settings for next session...")
print("=" * 70)
current_settings = reader.get_current_settings()
state_manager.update_settings(current_settings)
print(f"\nSettings to be saved:")
print(f" Font scale: {current_settings['font_scale']}")
print(f" Line spacing: {current_settings['line_spacing']}px")
print(f" Inter-block spacing: {current_settings['inter_block_spacing']}px")
print(f" Word spacing: {current_settings['word_spacing']}px")
# Save state to disk
if state_manager.save_state():
print(f"\n✓ Settings saved to: {state_file}")
print(" These settings will be used the next time you open a book!")
else:
print("\n✗ Failed to save settings")
# 6. Demonstrate that settings are saved
print("\n" + "=" * 70)
print("Verification: Reloading state from disk...")
print("=" * 70)
# Create new state manager to verify persistence
verification_manager = StateManager(state_file=state_file)
verification_state = verification_manager.load_state()
print(f"\nVerified saved settings:")
print(f" Font scale: {verification_state.settings.font_scale}")
print(f" Line spacing: {verification_state.settings.line_spacing}px")
print(f" Inter-block spacing: {verification_state.settings.inter_block_spacing}px")
print(f" Word spacing: {verification_state.settings.word_spacing}px")
if (verification_state.settings.font_scale == current_settings['font_scale'] and
verification_state.settings.line_spacing == current_settings['line_spacing'] and
verification_state.settings.word_spacing == current_settings['word_spacing']):
print("\n✓ Settings successfully persisted!")
else:
print("\n✗ Settings mismatch!")
# Cleanup
reader.close()
print("\n" + "=" * 70)
print("Demo Complete!")
print("=" * 70)
print("\nKey Points:")
print(" • Settings are automatically loaded from ~/.config/dreader/state.json")
print(" • Use reader.apply_settings() to apply saved settings after loading a book")
print(" • Use reader.get_current_settings() to get current settings")
print(" • Use state_manager.update_settings() to save new settings")
print(" • Settings persist across application restarts")
print("\nGenerated files:")
print(" • persistent_settings_before.png - Page with original settings")
print(" • persistent_settings_after.png - Page with modified settings")
if __name__ == '__main__':
demonstrate_persistent_settings()

292
examples/run_on_hardware.py Executable file
View File

@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
Run DReader on real e-ink hardware.
This example demonstrates running the DReader application on real e-ink hardware
using the dreader-hal library for hardware abstraction.
Requirements:
- Raspberry Pi (or compatible SBC)
- IT8951 e-ink display
- FT5xx6 capacitive touch sensor
- Optional: BMA400 accelerometer, PCF8523 RTC, INA219 power monitor
Hardware Setup:
See external/dreader-hal/README.md for wiring instructions
Usage:
# On Raspberry Pi with full hardware
python run_on_hardware.py /path/to/library
# For testing without hardware (virtual display mode)
python run_on_hardware.py /path/to/library --virtual
# Disable optional components
python run_on_hardware.py /path/to/library --no-orientation --no-rtc --no-power
"""
import sys
import asyncio
import argparse
import logging
from pathlib import Path
# Add parent directory to path to import dreader
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.hal_hardware import HardwareDisplayHAL
from dreader.main import DReaderApplication, AppConfig
async def main(args):
"""
Main application entry point.
Args:
args: Command line arguments
"""
# Set up logging
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
logger.info("Starting DReader on hardware")
logger.info(f"Library path: {args.library_path}")
logger.info(f"Display size: {args.width}x{args.height}")
logger.info(f"VCOM: {args.vcom}V")
logger.info(f"Virtual display: {args.virtual}")
# Create hardware HAL
logger.info("Initializing hardware HAL...")
hal = HardwareDisplayHAL(
width=args.width,
height=args.height,
vcom=args.vcom,
virtual_display=args.virtual,
auto_sleep_display=args.auto_sleep,
enable_orientation=args.orientation,
enable_rtc=args.rtc,
enable_power_monitor=args.power,
battery_capacity_mah=args.battery_capacity,
)
# Create application config
config = AppConfig(
display_hal=hal,
library_path=args.library_path,
page_size=(args.width, args.height),
auto_save_interval=60,
force_library_mode=args.force_library,
log_level=logging.DEBUG if args.verbose else logging.INFO,
)
# Create application
app = DReaderApplication(config)
try:
# Initialize hardware
logger.info("Initializing hardware...")
await hal.initialize()
# Start application
logger.info("Starting application...")
await app.start()
# Show battery level if available
if args.power and not args.virtual:
try:
battery = await hal.get_battery_level()
logger.info(f"Battery level: {battery:.1f}%")
if await hal.is_low_battery():
logger.warning("⚠️ Low battery!")
except Exception as e:
logger.warning(f"Could not read battery: {e}")
# Main event loop
logger.info("Entering main event loop (Ctrl+C to exit)")
logger.info("")
logger.info("Touch gestures:")
logger.info(" - Swipe left: Next page")
logger.info(" - Swipe right: Previous page")
logger.info(" - Swipe up (from bottom): Open navigation/TOC")
logger.info(" - Swipe down (from top): Open settings")
logger.info(" - Tap: Select book/word/link")
logger.info("")
while app.is_running():
# Get touch event (non-blocking)
event = await hal.get_touch_event()
if event:
logger.debug(f"Touch event: {event.gesture.value} at ({event.x}, {event.y})")
# Handle touch event
await app.handle_touch(event)
# Check battery periodically (every ~100 events)
if args.power and not args.virtual and args.show_battery:
if hasattr(app, '_event_count'):
app._event_count += 1
else:
app._event_count = 1
if app._event_count % 100 == 0:
battery = await hal.get_battery_level()
logger.info(f"Battery: {battery:.1f}%")
# Small delay to prevent CPU spinning
await asyncio.sleep(0.01)
except KeyboardInterrupt:
logger.info("Received interrupt signal, shutting down...")
except Exception as e:
logger.error(f"Error in main loop: {e}", exc_info=True)
finally:
# Shutdown
logger.info("Shutting down application...")
await app.shutdown()
logger.info("Cleaning up hardware...")
await hal.cleanup()
logger.info("DReader stopped")
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Run DReader on e-ink hardware",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Run on real hardware
%(prog)s /home/pi/Books
# Test with virtual display (no hardware required)
%(prog)s /home/pi/Books --virtual
# Custom display size and VCOM
%(prog)s /home/pi/Books --width 1200 --height 1600 --vcom -2.3
# Disable optional sensors
%(prog)s /home/pi/Books --no-orientation --no-rtc --no-power
"""
)
# Required arguments
parser.add_argument(
'library_path',
type=str,
help='Path to directory containing EPUB files'
)
# Display arguments
parser.add_argument(
'--width',
type=int,
default=1872,
help='Display width in pixels (default: 1872)'
)
parser.add_argument(
'--height',
type=int,
default=1404,
help='Display height in pixels (default: 1404)'
)
parser.add_argument(
'--vcom',
type=float,
default=-2.0,
help='E-ink VCOM voltage - CHECK YOUR DISPLAY LABEL! (default: -2.0)'
)
# Virtual display mode
parser.add_argument(
'--virtual',
action='store_true',
help='Use virtual display mode for testing without hardware'
)
# Display features
parser.add_argument(
'--no-auto-sleep',
dest='auto_sleep',
action='store_false',
help='Disable automatic display sleep after updates'
)
# Optional hardware components
parser.add_argument(
'--no-orientation',
dest='orientation',
action='store_false',
help='Disable orientation sensor (BMA400)'
)
parser.add_argument(
'--no-rtc',
dest='rtc',
action='store_false',
help='Disable RTC (PCF8523)'
)
parser.add_argument(
'--no-power',
dest='power',
action='store_false',
help='Disable power monitor (INA219)'
)
# Battery monitoring
parser.add_argument(
'--battery-capacity',
type=float,
default=3000,
help='Battery capacity in mAh (default: 3000)'
)
parser.add_argument(
'--show-battery',
action='store_true',
help='Periodically log battery level'
)
# Application behavior
parser.add_argument(
'--force-library',
action='store_true',
help='Always start in library mode (ignore saved state)'
)
# Debugging
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Enable verbose debug logging'
)
args = parser.parse_args()
# Validate library path
library_path = Path(args.library_path).expanduser()
if not library_path.exists():
parser.error(f"Library path does not exist: {library_path}")
if not library_path.is_dir():
parser.error(f"Library path is not a directory: {library_path}")
args.library_path = str(library_path)
return args
if __name__ == '__main__':
args = parse_args()
# Run async main
try:
asyncio.run(main(args))
except KeyboardInterrupt:
print("\nInterrupted by user")
sys.exit(0)

View File

@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""
Run DReader on hardware using hardware_config.json configuration.
This script loads all hardware configuration from hardware_config.json,
including display settings, GPIO buttons, and optional components.
Usage:
# Use default config file (hardware_config.json)
python run_on_hardware_config.py
# Use custom config file
python run_on_hardware_config.py --config my_config.json
# Override config settings
python run_on_hardware_config.py --library ~/MyBooks --verbose
"""
import sys
import asyncio
import argparse
import logging
import json
from pathlib import Path
# Add parent directory to path to import dreader
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.hal_hardware import HardwareDisplayHAL
from dreader.main import DReaderApplication, AppConfig
from dreader.gpio_buttons import load_button_config_from_dict
def load_config(config_path: str) -> dict:
"""Load hardware configuration from JSON file."""
config_file = Path(config_path)
if not config_file.exists():
raise FileNotFoundError(
f"Configuration file not found: {config_path}\n"
f"Run 'sudo python3 setup_rpi.py' to create it."
)
with open(config_file, 'r') as f:
config = json.load(f)
return config
async def main(args):
"""Main application entry point."""
# Load configuration
logger = logging.getLogger(__name__)
logger.info(f"Loading configuration from {args.config}")
try:
config = load_config(args.config)
except Exception as e:
print(f"Error loading configuration: {e}")
sys.exit(1)
# Apply command-line overrides
if args.library:
config['application']['library_path'] = args.library
if args.verbose:
config['application']['log_level'] = 'DEBUG'
# Set up logging
log_level = getattr(logging, config['application']['log_level'].upper())
logging.basicConfig(
level=log_level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger.info("="*70)
logger.info("DReader Hardware Mode")
logger.info("="*70)
# Display configuration summary
display_cfg = config['display']
logger.info(f"Display: {display_cfg['width']}x{display_cfg['height']}, VCOM={display_cfg['vcom']}V")
gpio_cfg = config.get('gpio_buttons', {})
if gpio_cfg.get('enabled', False):
logger.info(f"GPIO Buttons: {len(gpio_cfg.get('buttons', []))} configured")
accel_cfg = config.get('accelerometer', {})
if accel_cfg.get('enabled', False):
logger.info("Accelerometer: Enabled")
rtc_cfg = config.get('rtc', {})
if rtc_cfg.get('enabled', False):
logger.info("RTC: Enabled")
power_cfg = config.get('power_monitor', {})
if power_cfg.get('enabled', False):
logger.info("Power Monitor: Enabled")
# Create hardware HAL
logger.info("\nInitializing hardware HAL...")
hal = HardwareDisplayHAL(
width=display_cfg['width'],
height=display_cfg['height'],
vcom=display_cfg['vcom'],
spi_hz=display_cfg.get('spi_hz', 24_000_000),
virtual_display=False,
auto_sleep_display=display_cfg.get('auto_sleep', True),
enable_orientation=accel_cfg.get('enabled', True),
enable_rtc=rtc_cfg.get('enabled', True),
enable_power_monitor=power_cfg.get('enabled', True),
shunt_ohms=power_cfg.get('shunt_ohms', 0.1),
battery_capacity_mah=power_cfg.get('battery_capacity_mah', 3000),
)
# Load accelerometer tilt calibration if enabled
if accel_cfg.get('tilt_enabled', False):
calib_file = accel_cfg.get('calibration_file', 'accelerometer_config.json')
if hal.load_accelerometer_calibration(calib_file):
logger.info(f"Accelerometer tilt detection enabled (calibration from {calib_file})")
else:
logger.warning("Accelerometer tilt detection requested but calibration not loaded")
# Set up GPIO buttons
button_handler = None
if gpio_cfg.get('enabled', False):
logger.info("Setting up GPIO buttons...")
button_handler = load_button_config_from_dict(
config,
screen_width=display_cfg['width'],
screen_height=display_cfg['height']
)
if button_handler:
await button_handler.initialize()
logger.info(f"GPIO buttons initialized: {len(gpio_cfg.get('buttons', []))} buttons")
# Create application config
app_cfg = config['application']
app_config = AppConfig(
display_hal=hal,
library_path=app_cfg['library_path'],
page_size=(display_cfg['width'], display_cfg['height']),
auto_save_interval=app_cfg.get('auto_save_interval', 60),
force_library_mode=app_cfg.get('force_library_mode', False),
log_level=log_level,
)
# Create application
app = DReaderApplication(app_config)
try:
# Initialize hardware
logger.info("Initializing hardware...")
await hal.initialize()
# Start application
logger.info("Starting application...")
await app.start()
# Show battery level if available
if power_cfg.get('enabled', False):
try:
battery = await hal.get_battery_level()
logger.info(f"Battery level: {battery:.1f}%")
if await hal.is_low_battery(power_cfg.get('low_battery_threshold', 20.0)):
logger.warning("⚠️ Low battery!")
except Exception as e:
logger.warning(f"Could not read battery: {e}")
# Main event loop
logger.info("\nApplication ready!")
logger.info("="*70)
event_count = 0
show_battery_interval = power_cfg.get('show_battery_interval', 100)
while app.is_running():
# Check for touch events
touch_event = await hal.get_touch_event()
if touch_event:
logger.debug(f"Touch: {touch_event.gesture.value} at ({touch_event.x}, {touch_event.y})")
await app.handle_touch(touch_event)
event_count += 1
# Check for button events
if button_handler:
button_event = await button_handler.get_button_event()
if button_event:
logger.info(f"Button: {button_event.gesture.value}")
await app.handle_touch(button_event)
event_count += 1
# Check for tilt gestures if enabled
if accel_cfg.get('tilt_enabled', False):
tilt_event = await hal.get_tilt_gesture()
if tilt_event:
logger.info(f"Tilt: {tilt_event.gesture.value}")
await app.handle_touch(tilt_event)
event_count += 1
# Show battery periodically
if power_cfg.get('enabled', False) and event_count % show_battery_interval == 0 and event_count > 0:
try:
battery = await hal.get_battery_level()
logger.info(f"Battery: {battery:.1f}%")
except:
pass
# Small delay to prevent CPU spinning
await asyncio.sleep(0.01)
except KeyboardInterrupt:
logger.info("\nReceived interrupt signal, shutting down...")
except Exception as e:
logger.error(f"Error in main loop: {e}", exc_info=True)
finally:
# Shutdown
logger.info("Shutting down application...")
await app.shutdown()
logger.info("Cleaning up GPIO buttons...")
if button_handler:
await button_handler.cleanup()
logger.info("Cleaning up hardware...")
await hal.cleanup()
logger.info("DReader stopped")
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Run DReader using hardware_config.json",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Configuration:
Edit hardware_config.json to configure your hardware settings.
Run 'sudo python3 setup_rpi.py' to create/update the config file.
Examples:
# Use default config
%(prog)s
# Use custom config file
%(prog)s --config my_hardware.json
# Override library path
%(prog)s --library ~/MyBooks
# Enable verbose logging
%(prog)s --verbose
"""
)
parser.add_argument(
'--config',
type=str,
default='hardware_config.json',
help='Path to hardware configuration file (default: hardware_config.json)'
)
parser.add_argument(
'--library',
type=str,
help='Override library path from config'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Enable verbose debug logging'
)
args = parser.parse_args()
return args
if __name__ == '__main__':
args = parse_args()
# Run async main
try:
asyncio.run(main(args))
except KeyboardInterrupt:
print("\nInterrupted by user")
sys.exit(0)

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Simple example showing the most common EbookReader usage.
This script loads an EPUB and allows you to navigate through it,
saving each page as an image.
Usage:
python simple_ereader_example.py book.epub
"""
import sys
from pathlib import Path
# Add parent directory to path to import pyWebLayout
sys.path.insert(0, str(Path(__file__).parent.parent))
from dreader.application import EbookReader
def main():
if len(sys.argv) < 2:
print("Usage: python simple_ereader_example.py book.epub")
sys.exit(1)
epub_path = sys.argv[1]
# Create reader and load EPUB
print(f"Loading: {epub_path}")
reader = EbookReader(page_size=(800, 1000))
if not reader.load_epub(epub_path):
print("Failed to load EPUB")
sys.exit(1)
# Get book information
info = reader.get_book_info()
print(f"\nBook: {info['title']}")
print(f"Author: {info['author']}")
print(f"Total blocks: {info['total_blocks']}")
# Get chapters
chapters = reader.get_chapters()
print(f"Chapters: {len(chapters)}")
if chapters:
print("\nChapter list:")
for title, idx in chapters[:10]: # Show first 10
print(f" {idx}: {title}")
if len(chapters) > 10:
print(f" ... and {len(chapters) - 10} more")
# Navigate through first 10 pages
print("\nRendering first 10 pages...")
for i in range(10):
page = reader.get_current_page()
if page:
filename = f"page_{i+1:03d}.png"
reader.render_to_file(filename)
# Show progress
progress = reader.get_reading_progress()
chapter_info = reader.get_current_chapter_info()
chapter_name = chapter_info['title'] if chapter_info else "N/A"
print(f" Page {i+1}: {filename} (Progress: {progress*100:.1f}%, Chapter: {chapter_name})")
# Move to next page
if not reader.next_page():
print(" Reached end of book")
break
# Save current position
reader.save_position("stopped_at_page_10")
print("\nSaved position as 'stopped_at_page_10'")
# Example: Jump to a chapter (if available)
if len(chapters) >= 2:
print(f"\nJumping to chapter: {chapters[1][0]}")
reader.jump_to_chapter(1)
reader.render_to_file("chapter_2_start.png")
print(" Saved to: chapter_2_start.png")
# Example: Increase font size
print("\nIncreasing font size...")
reader.increase_font_size()
reader.render_to_file("larger_font.png")
print(f" Font size now: {reader.get_font_size():.1f}x")
print(" Saved to: larger_font.png")
# Close reader (saves current position automatically)
reader.close()
print("\nDone! Current position saved automatically.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""
Simple Example: Highlight a Word on Tap
This is the minimal example showing how to:
1. Load an ebook
2. Simulate a tap
3. Find the word at that location
4. Highlight it
Perfect for understanding the basic query system.
"""
from PIL import Image, ImageDraw
from dreader.application import EbookReader
def main():
# 1. Create reader and load book
reader = EbookReader(page_size=(800, 1000))
reader.load_epub("tests/data/test.epub")
# 2. Get current page as image
page_img = reader.get_current_page()
# 3. Simulate a tap at pixel coordinates
tap_x, tap_y = 200, 300
result = reader.query_pixel(tap_x, tap_y)
# 4. If we found a word, highlight it
if result and result.text:
print(f"Tapped on word: '{result.text}'")
print(f"Bounds: {result.bounds}")
# Draw yellow highlight
x, y, w, h = result.bounds
overlay = Image.new('RGBA', page_img.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(overlay)
draw.rectangle([x, y, x + w, y + h], fill=(255, 255, 0, 100))
# Combine and save
highlighted = Image.alpha_composite(
page_img.convert('RGBA'),
overlay
)
highlighted.save("highlighted_word.png")
print("Saved: highlighted_word.png")
else:
print("No word found at that location")
reader.close()
if __name__ == "__main__":
main()

96
examples/test_font_family.py Executable file
View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Test script to demonstrate font family setting functionality.
"""
from pyWebLayout.style.fonts import BundledFont
from dreader.application import EbookReader
import os
def test_font_family():
"""Test the font family setting feature."""
# Initialize reader
reader = EbookReader(page_size=(600, 800), margin=20)
# Load a sample book
book_path = os.path.join(os.path.dirname(__file__), '..', 'examples', 'beowulf.epub')
if not os.path.exists(book_path):
print(f"Book not found at {book_path}")
print("Skipping book loading - testing with HTML instead...")
# Load a simple HTML document instead
sample_html = """
<html>
<head><title>Font Family Test</title></head>
<body>
<h1>Font Family Test Document</h1>
<p>This is a test document to demonstrate the font family setting feature.</p>
<p>The quick brown fox jumps over the lazy dog. 0123456789</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
</body>
</html>
"""
reader.load_html(sample_html, title="Font Family Test")
else:
print(f"Loading book from: {book_path}")
reader.load_epub(book_path)
# Get initial page
print("\n1. Rendering with default font family...")
page1 = reader.get_current_page()
print(f" Current font family: {reader.get_font_family()}")
# Switch to serif
print("\n2. Switching to SERIF font family...")
reader.set_font_family(BundledFont.SERIF)
page2 = reader.get_current_page()
print(f" Current font family: {reader.get_font_family()}")
# Switch to sans-serif
print("\n3. Switching to SANS font family...")
reader.set_font_family(BundledFont.SANS)
page3 = reader.get_current_page()
print(f" Current font family: {reader.get_font_family()}")
# Switch to monospace
print("\n4. Switching to MONOSPACE font family...")
reader.set_font_family(BundledFont.MONOSPACE)
page4 = reader.get_current_page()
print(f" Current font family: {reader.get_font_family()}")
# Restore original fonts
print("\n5. Restoring document default font family...")
reader.set_font_family(None)
page5 = reader.get_current_page()
print(f" Current font family: {reader.get_font_family()}")
# Test settings persistence
print("\n6. Testing settings persistence...")
reader.set_font_family(BundledFont.SERIF)
settings = reader.get_current_settings()
print(f" Settings: {settings}")
print(f" Font family in settings: {settings.get('font_family')}")
# Apply settings
print("\n7. Applying settings with MONOSPACE...")
new_settings = settings.copy()
new_settings['font_family'] = 'MONOSPACE'
reader.apply_settings(new_settings)
print(f" Current font family: {reader.get_font_family()}")
# Test with settings overlay
print("\n8. Opening settings overlay...")
overlay_image = reader.open_settings_overlay()
print(f" Settings overlay opened successfully: {overlay_image is not None}")
print(f" Settings overlay dimensions: {overlay_image.size if overlay_image else 'N/A'}")
print("\n✓ All font family tests passed!")
return True
if __name__ == "__main__":
try:
test_font_family()
except Exception as e:
print(f"\n✗ Test failed with error: {e}")
import traceback
traceback.print_exc()

View File

@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""
Example: Word Selection and Highlighting
This example demonstrates how to:
1. Query a pixel location to find a word
2. Select a range of words between two points
3. Highlight selected words by drawing overlays
4. Handle tap gestures to select words
This is useful for:
- Word definition lookup
- Text highlighting/annotation
- Copy/paste functionality
- Interactive reading features
"""
from PIL import Image, ImageDraw
import numpy as np
from dreader import EbookReader, TouchEvent, GestureType
from pyWebLayout.core.query import QueryResult
def draw_highlight(image: Image.Image, bounds: tuple, color: tuple = (255, 255, 0, 100)):
"""
Draw a highlight overlay on an image at the given bounds.
Args:
image: PIL Image to draw on
bounds: (x, y, width, height) tuple
color: RGBA color tuple (with alpha for transparency)
"""
# Create a semi-transparent overlay
overlay = Image.new('RGBA', image.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(overlay)
x, y, w, h = bounds
# Draw rectangle with rounded corners for nicer appearance
draw.rectangle([x, y, x + w, y + h], fill=color)
# Composite the overlay onto the original image
image = Image.alpha_composite(image.convert('RGBA'), overlay)
return image
def example_1_single_word_selection():
"""Example 1: Select and highlight a single word by tapping"""
print("=" * 60)
print("Example 1: Single Word Selection")
print("=" * 60)
# Create reader and load a book
reader = EbookReader(page_size=(800, 1000))
success = reader.load_epub("tests/data/test.epub")
if not success:
print("Failed to load EPUB")
return
print(f"Loaded: {reader.book_title} by {reader.book_author}")
# Get current page as image
page_img = reader.get_current_page()
if not page_img:
print("No page rendered")
return
# Simulate a tap at coordinates (200, 300)
tap_x, tap_y = 200, 300
print(f"\nSimulating tap at ({tap_x}, {tap_y})")
# Query what's at that location
result = reader.query_pixel(tap_x, tap_y)
if result and result.text:
print(f"Found word: '{result.text}'")
print(f"Type: {result.object_type}")
print(f"Bounds: {result.bounds}")
print(f"Is interactive: {result.is_interactive}")
# Highlight the word
highlighted_img = draw_highlight(page_img, result.bounds, color=(255, 255, 0, 80))
highlighted_img.save("output_single_word_highlight.png")
print(f"\nSaved highlighted image to: output_single_word_highlight.png")
else:
print("No word found at that location")
reader.close()
def example_2_range_selection():
"""Example 2: Select and highlight a range of words (text selection)"""
print("\n" + "=" * 60)
print("Example 2: Range Selection (Multi-word)")
print("=" * 60)
# Create reader and load a book
reader = EbookReader(page_size=(800, 1000))
success = reader.load_epub("tests/data/test.epub")
if not success:
print("Failed to load EPUB")
return
# Get current page
page_img = reader.get_current_page()
if not page_img:
return
# Simulate dragging from (100, 200) to (400, 250)
start_x, start_y = 100, 200
end_x, end_y = 400, 250
print(f"Simulating selection from ({start_x}, {start_y}) to ({end_x}, {end_y})")
# Create drag gesture events
drag_start = TouchEvent(GestureType.DRAG_START, start_x, start_y)
drag_move = TouchEvent(GestureType.DRAG_MOVE, end_x, end_y)
drag_end = TouchEvent(GestureType.DRAG_END, end_x, end_y)
# Handle the gesture (business logic)
reader.handle_touch(drag_start)
reader.handle_touch(drag_move)
response = reader.handle_touch(drag_end)
if response.action == "selection_complete":
selected_text = response.data.get('text', '')
bounds_list = response.data.get('bounds', [])
word_count = response.data.get('word_count', 0)
print(f"\nSelected {word_count} words:")
print(f"Text: \"{selected_text}\"")
# Highlight all selected words
highlighted_img = page_img
for bounds in bounds_list:
highlighted_img = draw_highlight(
highlighted_img,
bounds,
color=(100, 200, 255, 80) # Light blue highlight
)
highlighted_img.save("output_range_highlight.png")
print(f"\nSaved highlighted image to: output_range_highlight.png")
else:
print(f"Selection action: {response.action}")
reader.close()
def example_3_interactive_word_lookup():
"""Example 3: Interactive word lookup with gesture handling"""
print("\n" + "=" * 60)
print("Example 3: Interactive Word Lookup (with Gestures)")
print("=" * 60)
# Create reader
reader = EbookReader(page_size=(800, 1000))
success = reader.load_epub("tests/data/test.epub")
if not success:
print("Failed to load EPUB")
return
# Get page
page_img = reader.get_current_page()
if not page_img:
return
# Define some simulated touch events
test_gestures = [
("Tap at (250, 300)", TouchEvent(GestureType.TAP, 250, 300)),
("Long press at (250, 300)", TouchEvent(GestureType.LONG_PRESS, 250, 300)),
("Swipe left", TouchEvent(GestureType.SWIPE_LEFT, 600, 500)),
]
for description, event in test_gestures:
print(f"\n{description}:")
response = reader.handle_touch(event)
print(f" Action: {response.action}")
if response.action == "word_selected":
word = response.data.get('word', '')
bounds = response.data.get('bounds', (0, 0, 0, 0))
print(f" Selected word: '{word}'")
print(f" Bounds: {bounds}")
# Highlight the word
highlighted_img = draw_highlight(page_img, bounds, color=(255, 200, 0, 100))
filename = f"output_word_lookup_{word}.png"
highlighted_img.save(filename)
print(f" Saved: {filename}")
elif response.action == "define":
word = response.data.get('word', '')
print(f" Show definition for: '{word}'")
# In real app, you'd call a dictionary API here
elif response.action == "page_turn":
direction = response.data.get('direction', '')
progress = response.data.get('progress', 0)
print(f" Page turn {direction}, progress: {progress:.1%}")
reader.close()
def example_4_multi_word_annotation():
"""Example 4: Annotate multiple words with different colors"""
print("\n" + "=" * 60)
print("Example 4: Multi-word Annotation")
print("=" * 60)
# Create reader
reader = EbookReader(page_size=(800, 1000))
success = reader.load_epub("tests/data/test.epub")
if not success:
print("Failed to load EPUB")
return
# Get page
page_img = reader.get_current_page()
if not page_img:
return
# Simulate multiple taps at different locations
tap_locations = [
(150, 200, "Important word", (255, 100, 100, 80)), # Red
(300, 200, "Key concept", (100, 255, 100, 80)), # Green
(450, 200, "Notable term", (100, 100, 255, 80)), # Blue
]
annotated_img = page_img
annotations = []
for x, y, label, color in tap_locations:
result = reader.query_pixel(x, y)
if result and result.text:
print(f"\nFound word at ({x}, {y}): '{result.text}'")
print(f" Annotation: {label}")
# Highlight with specific color
annotated_img = draw_highlight(annotated_img, result.bounds, color)
annotations.append({
'word': result.text,
'label': label,
'bounds': result.bounds
})
# Save annotated image
annotated_img.save("output_multi_annotation.png")
print(f"\nSaved annotated image with {len(annotations)} highlights")
print("File: output_multi_annotation.png")
# Print annotation summary
print("\nAnnotation Summary:")
for i, ann in enumerate(annotations, 1):
print(f" {i}. '{ann['word']}' - {ann['label']}")
reader.close()
def example_5_link_highlighting():
"""Example 5: Find and highlight all links on a page"""
print("\n" + "=" * 60)
print("Example 5: Find and Highlight All Links")
print("=" * 60)
# Create reader
reader = EbookReader(page_size=(800, 1000))
success = reader.load_epub("tests/data/test.epub")
if not success:
print("Failed to load EPUB")
return
# Get page
page_img = reader.get_current_page()
if not page_img:
return
# Get the page object to scan for links
page = reader.manager.get_current_page()
# Scan through all rendered content to find links
links_found = []
from pyWebLayout.concrete.text import Line
from pyWebLayout.concrete.functional import LinkText
for child in page._children:
if isinstance(child, Line):
for text_obj in child._text_objects:
if isinstance(text_obj, LinkText):
origin = text_obj._origin
size = text_obj.size
bounds = (
int(origin[0]),
int(origin[1]),
int(size[0]),
int(size[1])
)
links_found.append({
'text': text_obj._text,
'target': text_obj._link.location,
'bounds': bounds
})
print(f"Found {len(links_found)} links on page")
# Highlight all links
highlighted_img = page_img
for link in links_found:
print(f"\nLink: '{link['text']}'{link['target']}")
highlighted_img = draw_highlight(
highlighted_img,
link['bounds'],
color=(0, 150, 255, 100) # Blue for links
)
if links_found:
highlighted_img.save("output_links_highlighted.png")
print(f"\nSaved image with {len(links_found)} highlighted links")
print("File: output_links_highlighted.png")
else:
print("\nNo links found on this page")
reader.close()
if __name__ == "__main__":
print("Word Selection and Highlighting Examples")
print("=" * 60)
print()
print("These examples demonstrate the query system for:")
print("- Single word selection")
print("- Range selection (multiple words)")
print("- Interactive gesture handling")
print("- Multi-word annotation")
print("- Link detection and highlighting")
print()
try:
# Run all examples
example_1_single_word_selection()
example_2_range_selection()
example_3_interactive_word_lookup()
example_4_multi_word_annotation()
example_5_link_highlighting()
print("\n" + "=" * 60)
print("All examples completed successfully!")
print("=" * 60)
except Exception as e:
print(f"\nError running examples: {e}")
import traceback
traceback.print_exc()

1
external/dreader-hal vendored Submodule

@ -0,0 +1 @@
Subproject commit be3aed6e5e2ef5c80235dcb3f8efa1aeb2ec5abe

65
hardware_config.json Normal file
View File

@ -0,0 +1,65 @@
{
"_description": "Hardware configuration for DReader e-ink device",
"_note": "This config matches the actual hardware: GPIO 22=prev, GPIO 27=next, GPIO 21=power, I2C on GPIO 2/3",
"display": {
"width": 1872,
"height": 1404,
"vcom": -2.0,
"spi_hz": 24000000,
"auto_sleep": true
},
"gpio_buttons": {
"enabled": true,
"pull_up": true,
"bounce_time_ms": 200,
"buttons": [
{
"name": "prev_page",
"gpio": 22,
"gesture": "swipe_right",
"description": "Previous page button"
},
{
"name": "next_page",
"gpio": 27,
"gesture": "swipe_left",
"description": "Next page button"
},
{
"name": "power_off",
"gpio": 21,
"gesture": "long_press",
"description": "Power off button (long press to shutdown)",
"comment": "You may want to implement shutdown logic in the application"
}
]
},
"accelerometer": {
"enabled": true,
"tilt_enabled": false,
"orientation_enabled": true,
"calibration_file": "accelerometer_config.json"
},
"rtc": {
"enabled": true
},
"power_monitor": {
"enabled": true,
"shunt_ohms": 0.1,
"battery_capacity_mah": 3000,
"low_battery_threshold": 20.0,
"show_battery_interval": 100
},
"application": {
"library_path": "/home/pi/Books",
"auto_save_interval": 60,
"force_library_mode": false,
"log_level": "INFO"
}
}

80
install_hardware_drivers.sh Executable file
View File

@ -0,0 +1,80 @@
#!/bin/bash
# Installation script for dreader-hal hardware drivers
#
# This script installs all the external driver dependencies needed
# for running DReader on e-ink hardware.
set -e # Exit on error
echo "================================"
echo "DReader Hardware Driver Installer"
echo "================================"
echo ""
# Check if we're in a virtual environment
if [ -z "$VIRTUAL_ENV" ]; then
echo "⚠️ Warning: No virtual environment detected!"
echo "It's recommended to activate your virtual environment first:"
echo " source venv/bin/activate"
echo ""
read -p "Continue anyway? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Initialize submodules if not already done
echo "Step 1: Initializing git submodules..."
git submodule update --init --recursive
echo "✓ Submodules initialized"
echo ""
# Install dreader-hal main package
echo "Step 2: Installing dreader-hal..."
pip install -e external/dreader-hal
echo "✓ dreader-hal installed"
echo ""
# Install external drivers
echo "Step 3: Installing external driver libraries..."
echo " - Installing IT8951 (E-ink display driver)..."
pip install -e external/dreader-hal/external/IT8951
echo " - Installing PyBMA400 (Accelerometer)..."
pip install -e external/dreader-hal/external/PyBMA400
echo " - Installing PyFTtxx6 (Touch panel)..."
pip install -e external/dreader-hal/external/PyFTtxx6/pyft5xx6
echo " - Installing PyPCF8523 (RTC)..."
pip install -e external/dreader-hal/external/PyPCF8523
echo " - Installing pi_ina219 (Power monitor)..."
pip install -e external/dreader-hal/external/pi_ina219
echo "✓ All drivers installed"
echo ""
# Summary
echo "================================"
echo "Installation Complete!"
echo "================================"
echo ""
echo "Installed packages:"
echo " ✓ dreader-hal (main HAL library)"
echo " ✓ IT8951 (e-ink display)"
echo " ✓ PyBMA400 (accelerometer)"
echo " ✓ PyFTtxx6 (touch panel)"
echo " ✓ PyPCF8523 (RTC)"
echo " ✓ pi_ina219 (power monitor)"
echo ""
echo "Next steps:"
echo " 1. Wire up your hardware according to HARDWARE_SETUP.md"
echo " 2. Check your display's VCOM voltage (on label)"
echo " 3. Run: python examples/run_on_hardware.py /path/to/books --vcom YOUR_VCOM"
echo ""
echo "Example:"
echo " python examples/run_on_hardware.py ~/Books --vcom -2.06"
echo ""

65
pyproject.toml Normal file
View File

@ -0,0 +1,65 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "dreader-application"
version = "0.1.0"
description = "A complete ebook reader application built with pyWebLayout"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
{name = "Duncan Tourolle", email = "duncan@tourolle.paris"}
]
keywords = ["ebook", "reader", "epub", "ereader", "layout"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Software Development :: Libraries",
"Topic :: Text Processing :: Markup",
]
dependencies = [
"pyweblayout @ git+https://gitea.tourolle.paris/dtourolle/pyWebLayout@master",
"Pillow>=9.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-cov>=3.0.0",
"black>=22.0.0",
"flake8>=4.0.0",
"mypy>=0.950",
]
[project.urls]
Homepage = "https://gitea.tourolle.paris/dtourolle/dreader-application"
Documentation = "https://gitea.tourolle.paris/dtourolle/dreader-application#readme"
Repository = "https://gitea.tourolle.paris/dtourolle/dreader-application"
Issues = "https://gitea.tourolle.paris/dtourolle/dreader-application/issues"
[tool.setuptools.packages.find]
where = ["."]
include = ["dreader*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
[tool.black]
line-length = 100
target-version = ['py38']
[tool.mypy]
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false

188
run_dreader.py Executable file
View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
DReader E-Book Reader - Main Entry Point
This script launches the DReader application with a Pygame-based
desktop HAL for testing and development.
Usage:
python run_dreader.py [library_path]
python run_dreader.py ~/Books
python run_dreader.py tests/data/library-epub
Options:
--width WIDTH Window width (default: 800)
--height HEIGHT Window height (default: 1200)
--fullscreen Run in fullscreen mode
--log-level LEVEL Logging level: DEBUG, INFO, WARNING, ERROR (default: INFO)
Examples:
# Run with default library path
python run_dreader.py
# Run with custom library
python run_dreader.py ~/Documents/Books
# Run in fullscreen
python run_dreader.py --fullscreen
# Run with debug logging
python run_dreader.py --log-level DEBUG
"""
import sys
import asyncio
import argparse
import logging
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))
from dreader.main import DReaderApplication, AppConfig
from dreader.hal_pygame import PygameDisplayHAL
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="DReader E-Book Reader Application",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Use default library path
%(prog)s ~/Books # Custom library path
%(prog)s --width 1200 --height 1600 # Custom window size
%(prog)s --fullscreen # Fullscreen mode
%(prog)s --log-level DEBUG # Debug logging
"""
)
parser.add_argument(
'library_path',
nargs='?',
default=None,
help='Path to directory containing EPUB files (default: tests/data/library-epub)'
)
parser.add_argument(
'--width',
type=int,
default=800,
help='Window width in pixels (default: 800)'
)
parser.add_argument(
'--height',
type=int,
default=1200,
help='Window height in pixels (default: 1200)'
)
parser.add_argument(
'--fullscreen',
action='store_true',
help='Run in fullscreen mode'
)
parser.add_argument(
'--library',
action='store_true',
help='Always start in library mode (ignore saved state)'
)
parser.add_argument(
'--log-level',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
default='INFO',
help='Logging level (default: INFO)'
)
return parser.parse_args()
def main():
"""Main entry point."""
args = parse_args()
# Set up logging
log_level = getattr(logging, args.log_level)
logging.basicConfig(
level=log_level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
logger.info("Starting DReader E-Book Reader")
# Determine library path
if args.library_path:
library_path = Path(args.library_path).expanduser().resolve()
else:
# Default to test library
library_path = Path(__file__).parent / "tests" / "data" / "library-epub"
# Verify library path exists
if not library_path.exists():
logger.error(f"Library path does not exist: {library_path}")
print(f"\nError: Library directory not found: {library_path}")
print("\nPlease provide a valid path to a directory containing EPUB files.")
print("Example:")
print(f" python {sys.argv[0]} ~/Documents/Books")
sys.exit(1)
if not library_path.is_dir():
logger.error(f"Library path is not a directory: {library_path}")
print(f"\nError: Not a directory: {library_path}")
sys.exit(1)
logger.info(f"Library path: {library_path}")
logger.info(f"Window size: {args.width}x{args.height}")
logger.info(f"Fullscreen: {args.fullscreen}")
logger.info(f"Force library mode: {args.library}")
# Create HAL
try:
hal = PygameDisplayHAL(
width=args.width,
height=args.height,
fullscreen=args.fullscreen
)
except RuntimeError as e:
logger.error(f"Failed to initialize Pygame HAL: {e}")
print(f"\nError: {e}")
print("\nTo install Pygame, run:")
print(" pip install pygame")
sys.exit(1)
# Create application config
config = AppConfig(
display_hal=hal,
library_path=str(library_path),
page_size=(args.width, args.height),
force_library_mode=args.library,
log_level=log_level
)
# Create application
app = DReaderApplication(config)
# Run event loop
try:
logger.info("Starting event loop")
asyncio.run(hal.run_event_loop(app))
logger.info("Application exited normally")
except KeyboardInterrupt:
logger.info("Interrupted by user")
print("\nShutting down...")
except Exception as e:
logger.error(f"Application error: {e}", exc_info=True)
print(f"\nError: {e}")
print("\nFor more details, run with --log-level DEBUG")
sys.exit(1)
if __name__ == "__main__":
main()

445
setup_rpi.py Executable file
View File

@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""
Raspberry Pi Setup Script for DReader Hardware.
This interactive script helps configure your DReader e-reader hardware by:
1. Detecting connected hardware (I2C devices, SPI, etc.)
2. Creating/editing hardware_config.json
3. Installing required system packages
4. Setting up permissions and services
Usage:
sudo python3 setup_rpi.py
"""
import sys
import os
import json
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# Check if running on Raspberry Pi
try:
with open('/proc/device-tree/model', 'r') as f:
model = f.read()
if 'Raspberry Pi' not in model:
print("⚠️ Warning: This doesn't appear to be a Raspberry Pi")
print(f" Detected: {model.strip()}")
response = input("Continue anyway? (y/N): ")
if response.lower() != 'y':
sys.exit(1)
except:
print("⚠️ Warning: Could not detect Raspberry Pi")
class Colors:
"""ANSI color codes for terminal output."""
HEADER = '\033[95m'
BLUE = '\033[94m'
CYAN = '\033[96m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
END = '\033[0m'
BOLD = '\033[1m'
def print_header(text: str):
"""Print a header."""
print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.END}")
print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.END}")
print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.END}\n")
def print_step(number: int, text: str):
"""Print a step number."""
print(f"\n{Colors.BOLD}{Colors.CYAN}Step {number}: {text}{Colors.END}")
print(f"{Colors.CYAN}{'-'*70}{Colors.END}")
def print_success(text: str):
"""Print success message."""
print(f"{Colors.GREEN}{text}{Colors.END}")
def print_warning(text: str):
"""Print warning message."""
print(f"{Colors.YELLOW}{text}{Colors.END}")
def print_error(text: str):
"""Print error message."""
print(f"{Colors.RED}{text}{Colors.END}")
def run_command(cmd: str, check: bool = True) -> Tuple[int, str, str]:
"""Run a shell command and return result."""
try:
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
check=check
)
return result.returncode, result.stdout, result.stderr
except subprocess.CalledProcessError as e:
return e.returncode, e.stdout, e.stderr
def check_interfaces() -> Dict[str, bool]:
"""Check if I2C and SPI interfaces are enabled."""
print("Checking system interfaces...")
interfaces = {
'i2c': False,
'spi': False,
}
# Check I2C
if os.path.exists('/dev/i2c-1'):
interfaces['i2c'] = True
print_success("I2C interface enabled")
else:
print_warning("I2C interface not enabled")
# Check SPI
if os.path.exists('/dev/spidev0.0'):
interfaces['spi'] = True
print_success("SPI interface enabled")
else:
print_warning("SPI interface not enabled")
return interfaces
def detect_i2c_devices() -> List[str]:
"""Detect I2C devices."""
print("\nScanning I2C bus...")
returncode, stdout, stderr = run_command("i2cdetect -y 1", check=False)
if returncode != 0:
print_warning("Could not scan I2C bus (i2cdetect not found or no permission)")
return []
# Parse i2cdetect output
devices = []
for line in stdout.split('\n'):
if ':' in line:
# Extract hex addresses
parts = line.split(':')[1].split()
for part in parts:
if part != '--' and len(part) == 2:
devices.append(f"0x{part}")
if devices:
print_success(f"Found {len(devices)} I2C device(s): {', '.join(devices)}")
# Identify known devices
device_map = {
'0x38': 'FT5316 Touch Panel',
'0x14': 'BMA400 Accelerometer',
'0x15': 'BMA400 Accelerometer (alt)',
'0x68': 'PCF8523 RTC',
'0x40': 'INA219 Power Monitor',
}
print("\nDetected devices:")
for addr in devices:
device_name = device_map.get(addr, 'Unknown device')
print(f" {addr}: {device_name}")
else:
print_warning("No I2C devices detected")
return devices
def enable_interfaces():
"""Enable I2C and SPI interfaces."""
print("\nEnabling interfaces...")
# Use raspi-config to enable I2C and SPI
print("Enabling I2C...")
run_command("raspi-config nonint do_i2c 0", check=False)
print("Enabling SPI...")
run_command("raspi-config nonint do_spi 0", check=False)
print_success("Interfaces enabled (reboot required to take effect)")
def setup_permissions():
"""Set up user permissions for GPIO, I2C, and SPI."""
print("\nSetting up user permissions...")
user = os.environ.get('SUDO_USER', os.environ.get('USER'))
groups = ['gpio', 'i2c', 'spi']
for group in groups:
print(f"Adding user '{user}' to group '{group}'...")
returncode, _, _ = run_command(f"usermod -a -G {group} {user}", check=False)
if returncode == 0:
print_success(f"Added to {group} group")
else:
print_warning(f"Could not add to {group} group (may not exist)")
print_warning("You must log out and back in for group changes to take effect")
def get_vcom_voltage() -> float:
"""Prompt user for VCOM voltage."""
print("\n" + Colors.BOLD + "VCOM Voltage Configuration" + Colors.END)
print("="*70)
print("Your e-ink display has a VCOM voltage printed on a label.")
print("This is usually on the back of the display.")
print("")
print("Example labels:")
print(" • VCOM = -2.06V")
print(" • VCOM: -1.98V")
print(" • -2.14V")
print("")
print(Colors.RED + Colors.BOLD + "⚠️ IMPORTANT: Using incorrect VCOM can damage your display!" + Colors.END)
print("")
while True:
vcom_str = input("Enter your display's VCOM voltage (e.g., -2.06): ").strip()
try:
vcom = float(vcom_str)
if vcom > 0:
print_warning("VCOM is usually negative. Did you forget the minus sign?")
continue
if vcom < -3.0 or vcom > -1.0:
print_warning(f"VCOM {vcom}V is unusual. Most displays are between -1.5V and -2.5V")
confirm = input("Are you sure this is correct? (y/N): ")
if confirm.lower() != 'y':
continue
return vcom
except ValueError:
print_error("Invalid voltage. Please enter a number (e.g., -2.06)")
def configure_gpio_buttons() -> dict:
"""Configure GPIO buttons interactively."""
print("\n" + Colors.BOLD + "GPIO Button Configuration" + Colors.END)
print("="*70)
print("Configure physical buttons for navigation.")
print("Buttons should be connected between GPIO pin and GND.")
print("")
enable = input("Enable GPIO buttons? (Y/n): ").strip().lower()
if enable == 'n':
return {
"enabled": False,
"pull_up": True,
"bounce_time_ms": 200,
"buttons": []
}
buttons = []
# Common button configurations (based on actual hardware)
button_presets = [
("prev_page", "Previous Page", "swipe_right", 22),
("next_page", "Next Page", "swipe_left", 27),
("power_off", "Power Off", "long_press", 21),
]
print("\nAvailable GPIOs (BCM numbering): 2-27 (avoid 2, 3 if using I2C)")
print("")
for name, description, default_gesture, default_gpio in button_presets:
print(f"\n{Colors.BOLD}{description} Button{Colors.END}")
enable_btn = input(f" Enable {description} button? (Y/n): ").strip().lower()
if enable_btn == 'n':
continue
# Get GPIO pin
while True:
gpio_str = input(f" GPIO pin (default {default_gpio}): ").strip()
if not gpio_str:
gpio = default_gpio
break
try:
gpio = int(gpio_str)
if gpio < 2 or gpio > 27:
print_error(" GPIO must be between 2 and 27")
continue
if gpio in [2, 3]:
print_warning(" GPIO 2/3 are I2C pins (SDA/SCL)")
confirm = input(" Use anyway? (y/N): ")
if confirm.lower() != 'y':
continue
break
except ValueError:
print_error(" Invalid GPIO number")
# Add button
buttons.append({
"name": name,
"gpio": gpio,
"gesture": default_gesture,
"description": description
})
print_success(f" Configured: GPIO {gpio} -> {description}")
return {
"enabled": True,
"pull_up": True,
"bounce_time_ms": 200,
"buttons": buttons
}
def create_hardware_config(vcom: float, gpio_config: dict, i2c_devices: List[str]) -> dict:
"""Create hardware configuration dictionary."""
# Auto-detect which optional components are available
has_touch = '0x38' in i2c_devices
has_accel = '0x14' in i2c_devices or '0x15' in i2c_devices
has_rtc = '0x68' in i2c_devices
has_power = '0x40' in i2c_devices
config = {
"_description": "Hardware configuration for DReader e-ink device",
"_generated": "Generated by setup_rpi.py",
"display": {
"width": 1872,
"height": 1404,
"vcom": vcom,
"spi_hz": 24000000,
"auto_sleep": True
},
"gpio_buttons": gpio_config,
"accelerometer": {
"enabled": has_accel,
"tilt_enabled": False,
"orientation_enabled": has_accel,
"calibration_file": "accelerometer_config.json"
},
"rtc": {
"enabled": has_rtc
},
"power_monitor": {
"enabled": has_power,
"shunt_ohms": 0.1,
"battery_capacity_mah": 3000,
"low_battery_threshold": 20.0,
"show_battery_interval": 100
},
"application": {
"library_path": "/home/pi/Books",
"auto_save_interval": 60,
"force_library_mode": False,
"log_level": "INFO"
}
}
return config
def main():
"""Main setup function."""
print_header("DReader Raspberry Pi Hardware Setup")
# Check if running as root
if os.geteuid() != 0:
print_error("This script must be run with sudo")
print("Usage: sudo python3 setup_rpi.py")
sys.exit(1)
# Step 1: Check interfaces
print_step(1, "Checking System Interfaces")
interfaces = check_interfaces()
if not all(interfaces.values()):
print("\nSome interfaces are not enabled.")
enable = input("Enable I2C and SPI now? (Y/n): ").strip().lower()
if enable != 'n':
enable_interfaces()
print_warning("Reboot required for interface changes to take effect")
# Step 2: Detect hardware
print_step(2, "Detecting I2C Devices")
i2c_devices = detect_i2c_devices()
if not i2c_devices:
print_warning("No I2C devices detected. Check your wiring.")
print("See HARDWARE_SETUP.md for wiring instructions.")
# Step 3: Set up permissions
print_step(3, "Setting Up User Permissions")
setup_permissions()
# Step 4: Configure VCOM
print_step(4, "Display Configuration")
vcom = get_vcom_voltage()
print_success(f"VCOM voltage set to {vcom}V")
# Step 5: Configure GPIO buttons
print_step(5, "GPIO Button Configuration")
gpio_config = configure_gpio_buttons()
if gpio_config["enabled"]:
print_success(f"Configured {len(gpio_config['buttons'])} button(s)")
else:
print("GPIO buttons disabled")
# Step 6: Generate configuration
print_step(6, "Generating Configuration File")
config = create_hardware_config(vcom, gpio_config, i2c_devices)
config_file = Path("hardware_config.json")
with open(config_file, 'w') as f:
json.dump(config, f, indent=2)
print_success(f"Configuration saved to {config_file}")
# Step 7: Summary
print_header("Setup Complete!")
print("Configuration summary:")
print(f" • Display: {config['display']['width']}x{config['display']['height']}, VCOM={config['display']['vcom']}V")
print(f" • GPIO Buttons: {'Enabled' if gpio_config['enabled'] else 'Disabled'}")
if gpio_config['enabled']:
for btn in gpio_config['buttons']:
print(f" - {btn['description']}: GPIO {btn['gpio']}")
print(f" • Accelerometer: {'Enabled' if config['accelerometer']['enabled'] else 'Disabled'}")
print(f" • RTC: {'Enabled' if config['rtc']['enabled'] else 'Disabled'}")
print(f" • Power Monitor: {'Enabled' if config['power_monitor']['enabled'] else 'Disabled'}")
print("\n" + Colors.BOLD + "Next Steps:" + Colors.END)
print("1. Review and edit hardware_config.json if needed")
print("2. Reboot if you enabled I2C/SPI: sudo reboot")
print("3. Log out and back in for permission changes")
print("4. Run: python examples/run_on_hardware_config.py")
print("")
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print("\n\nSetup cancelled by user")
sys.exit(1)
except Exception as e:
print_error(f"Setup failed: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

50
test_pagination_visual.py Normal file
View File

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""
Test script to visualize library pagination.
"""
from pathlib import Path
from dreader import LibraryManager
def test_pagination():
"""Test pagination with actual library"""
library_path = Path(__file__).parent / 'tests' / 'data' / 'library-epub'
# Create library manager (default books_per_page=6)
library = LibraryManager(
library_path=str(library_path),
page_size=(800, 1200)
)
# Scan library
books = library.scan_library()
print(f"\nFound {len(books)} books")
print(f"Books per page: {library.books_per_page}")
print(f"Total pages: {library.get_total_pages()}")
# Render all pages
for page_num in range(library.get_total_pages()):
library.set_page(page_num)
print(f"\n=== Rendering Page {page_num + 1}/{library.get_total_pages()} ===")
library.create_library_table()
img = library.render_library()
output_path = f'/tmp/library_pagination_page{page_num + 1}.png'
img.save(output_path)
print(f"Saved to {output_path}")
# Show which books are on this page
start_idx = page_num * library.books_per_page
end_idx = min(start_idx + library.books_per_page, len(books))
page_books = books[start_idx:end_idx]
print(f"Books on this page ({len(page_books)}):")
for book in page_books:
print(f" - {book['title']} by {book['author']}")
# Cleanup
library.cleanup()
print("\nPagination test complete!")
if __name__ == '__main__':
test_pagination()

BIN
tests/data/cover 1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
tests/data/cover 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
tests/data/cover 3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
tests/data/cover 4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tests/data/sample_image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

BIN
tests/data/test.epub Normal file

Binary file not shown.

View File

@ -0,0 +1,284 @@
"""
Tests for accelerometer-based gesture detection.
"""
import pytest
import asyncio
import json
import math
from pathlib import Path
import sys
# Test only the gesture types and math, not the full integration
# to avoid dependencies on pyWebLayout
class MockOrientationSensor:
"""Mock BMA400 accelerometer for testing"""
def __init__(self):
self.ax = 0.0
self.ay = 0.0
self.az = 9.8 # Standard gravity
async def get_acceleration(self):
"""Return mock acceleration data"""
return (self.ax, self.ay, self.az)
def set_acceleration(self, x, y, z):
"""Set acceleration for testing"""
self.ax = x
self.ay = y
self.az = z
class MockHAL:
"""Mock HAL for testing"""
def __init__(self):
self.orientation = MockOrientationSensor()
self.width = 800
self.height = 1200
@pytest.fixture
def mock_hal():
"""Create a mock HAL with accelerometer"""
return MockHAL()
@pytest.fixture
def calibration_file(tmp_path):
"""Create a temporary calibration file"""
config = {
"up_vector": {
"x": 0.0,
"y": 9.8,
"z": 0.0
},
"tilt_threshold": 0.3, # ~17 degrees
"debounce_time": 0.5
}
config_path = tmp_path / "test_accel_config.json"
with open(config_path, 'w') as f:
json.dump(config, f)
return str(config_path)
def test_load_calibration_success(mock_hal, calibration_file):
"""Test loading accelerometer calibration"""
# Create a minimal HAL-like object
class TestHAL:
def __init__(self):
self.width = 800
self.height = 1200
test_hal = TestHAL()
# Manually call the load function
result = load_accel_calibration(test_hal, calibration_file)
assert result is True
assert hasattr(test_hal, 'accel_up_vector')
assert test_hal.accel_up_vector == (0.0, 9.8, 0.0)
assert test_hal.accel_tilt_threshold == 0.3
assert test_hal.accel_debounce_time == 0.5
def load_accel_calibration(hal, config_path):
"""Helper function to load calibration (extracted from HardwareDisplayHAL)"""
import logging
logger = logging.getLogger(__name__)
config_file = Path(config_path)
if not config_file.exists():
logger.warning(f"Accelerometer calibration file not found: {config_path}")
return False
try:
with open(config_file, 'r') as f:
config = json.load(f)
# Load up vector
up = config.get("up_vector", {})
hal.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0))
# Load thresholds
hal.accel_tilt_threshold = config.get("tilt_threshold", 0.3)
hal.accel_debounce_time = config.get("debounce_time", 0.5)
# State tracking
hal.accel_last_tilt_time = 0
return True
except Exception as e:
logger.error(f"Error loading accelerometer calibration: {e}")
return False
def test_tilt_detection_forward():
"""Test forward tilt detection"""
# Setup: device is upright (y = 9.8), then tilt forward (z increases)
# Calibrated up vector: (0, 9.8, 0)
# Current gravity: (0, 6, 6) - tilted ~45 degrees forward
up_vector = (0.0, 9.8, 0.0)
current_gravity = (0.0, 6.0, 6.0)
# Normalize vectors
ux, uy, uz = up_vector
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
gx, gy, gz = current_gravity
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
# Calculate tilt angle
dot_up = gx * ux + gy * uy + gz * uz
perp_x = gx - dot_up * ux
perp_y = gy - dot_up * uy
perp_z = gz - dot_up * uz
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
tilt_angle = math.atan2(perp_mag, abs(dot_up))
# Should be approximately 45 degrees (0.785 radians)
assert abs(tilt_angle - 0.785) < 0.1
# Direction: forward tilt should have positive perpendicular y component
# Actually, when tilting forward, gravity vector rotates toward +z
# The perpendicular component should reflect this
def test_tilt_detection_backward():
"""Test backward tilt detection"""
# Setup: device is upright (y = 9.8), then tilt backward (z decreases, negative)
# Calibrated up vector: (0, 9.8, 0)
# Current gravity: (0, 6, -6) - tilted ~45 degrees backward
up_vector = (0.0, 9.8, 0.0)
current_gravity = (0.0, 6.0, -6.0)
# Normalize vectors
ux, uy, uz = up_vector
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
gx, gy, gz = current_gravity
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
# Calculate tilt angle
dot_up = gx * ux + gy * uy + gz * uz
perp_x = gx - dot_up * ux
perp_y = gy - dot_up * uy
perp_z = gz - dot_up * uz
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
tilt_angle = math.atan2(perp_mag, abs(dot_up))
# Should be approximately 45 degrees (0.785 radians)
assert abs(tilt_angle - 0.785) < 0.1
def test_no_tilt_when_upright():
"""Test that no tilt is detected when device is upright"""
# Setup: device is perfectly upright
# Calibrated up vector: (0, 9.8, 0)
# Current gravity: (0, 9.8, 0) - same as calibration
up_vector = (0.0, 9.8, 0.0)
current_gravity = (0.0, 9.8, 0.0)
# Normalize vectors
ux, uy, uz = up_vector
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
gx, gy, gz = current_gravity
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
# Calculate tilt angle
dot_up = gx * ux + gy * uy + gz * uz
perp_x = gx - dot_up * ux
perp_y = gy - dot_up * uy
perp_z = gz - dot_up * uz
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
tilt_angle = math.atan2(perp_mag, abs(dot_up))
# Should be approximately 0 degrees
assert tilt_angle < 0.01
def test_small_tilt_below_threshold():
"""Test that small tilts below threshold are ignored"""
# Setup: device is slightly tilted (10 degrees)
# Calibrated up vector: (0, 9.8, 0)
# Current gravity: small tilt
angle_rad = math.radians(10)
up_vector = (0.0, 9.8, 0.0)
current_gravity = (0.0, 9.8 * math.cos(angle_rad), 9.8 * math.sin(angle_rad))
# Normalize vectors
ux, uy, uz = up_vector
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
gx, gy, gz = current_gravity
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
# Calculate tilt angle
dot_up = gx * ux + gy * uy + gz * uz
perp_x = gx - dot_up * ux
perp_y = gy - dot_up * uy
perp_z = gz - dot_up * uz
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
tilt_angle = math.atan2(perp_mag, abs(dot_up))
# Should be approximately 10 degrees (0.174 radians)
assert abs(tilt_angle - 0.174) < 0.01
# Should be below default threshold of 0.3 rad (~17 degrees)
assert tilt_angle < 0.3
def test_gesture_types_exist():
"""Test that accelerometer gesture types are defined"""
# Simple direct test - check that gesture strings are defined
gestures = [
"tap",
"long_press",
"swipe_left",
"swipe_right",
"swipe_up",
"swipe_down",
"pinch_in",
"pinch_out",
"drag_start",
"drag_move",
"drag_end",
"tilt_forward", # Our new gestures
"tilt_backward"
]
# Verify the new gesture strings are valid
assert "tilt_forward" in gestures
assert "tilt_backward" in gestures
if __name__ == "__main__":
# Run tests
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,85 @@
"""
Minimal reproduction test for backward navigation bug.
BUG: Backward navigation cannot reach block_index=0 from block_index=1.
This is a pyWebLayout issue, not a dreader-application issue.
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from dreader.application import EbookReader
class TestBackwardNavigationBug(unittest.TestCase):
"""Minimal reproduction of backward navigation bug"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_minimal_backward_navigation_bug(self):
"""
MINIMAL REPRODUCTION:
1. Start at block_index=0
2. Go forward once (to block_index=1)
3. Go backward once
4. BUG: Lands at block_index=1 instead of block_index=0
This proves backward navigation cannot reach the first block.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
# Starting position
pos_start = reader.manager.current_position.copy()
print(f"\n1. Starting at block_index={pos_start.block_index}")
self.assertEqual(pos_start.block_index, 0, "Should start at block 0")
# Go forward
reader.next_page()
pos_forward = reader.manager.current_position.copy()
print(f"2. After next_page(): block_index={pos_forward.block_index}")
self.assertEqual(pos_forward.block_index, 1, "Should be at block 1")
# Go backward
reader.previous_page()
pos_final = reader.manager.current_position.copy()
print(f"3. After previous_page(): block_index={pos_final.block_index}")
# THE BUG: This assertion will fail
print(f"\nEXPECTED: block_index=0")
print(f"ACTUAL: block_index={pos_final.block_index}")
if pos_final.block_index != 0:
print("\n❌ BUG CONFIRMED: Cannot navigate backward to block_index=0")
print(" This is a pyWebLayout bug in the previous_page() method.")
self.assertEqual(
pos_final.block_index,
0,
"BUG: Backward navigation from block 1 should return to block 0"
)
reader.close()
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,239 @@
"""
Detailed test for backward navigation issues.
This test explores the backward navigation behavior more thoroughly
to understand if the issue is:
1. Complete failure (previous_page returns None)
2. Imprecise positioning (lands on wrong block)
3. Only occurs after resume
4. Occurs during continuous navigation
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from dreader.application import EbookReader
class TestBackwardNavigationDetailed(unittest.TestCase):
"""Detailed backward navigation tests"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_continuous_backward_navigation_no_resume(self):
"""
Test backward navigation without closing/resuming.
This checks if the issue is specific to resume or general.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Continuous backward navigation (no resume) ===")
# Record starting position
pos0 = reader.manager.current_position.copy()
print(f"Starting position: {pos0}")
# Go forward 5 pages, recording positions
forward_positions = [pos0]
for i in range(5):
page = reader.next_page()
if page is None:
print(f"Reached end at page {i}")
break
pos = reader.manager.current_position.copy()
forward_positions.append(pos)
print(f"Forward page {i+1}: block_index={pos.block_index}")
num_forward = len(forward_positions) - 1
print(f"\nNavigated forward {num_forward} pages")
# Now go backward the same number of times
print("\n--- Going backward ---")
backward_positions = []
for i in range(num_forward):
page = reader.previous_page()
if page is None:
print(f"ERROR: previous_page() returned None at step {i+1}")
self.fail(f"Backward navigation failed at step {i+1}")
pos = reader.manager.current_position.copy()
backward_positions.append(pos)
print(f"Backward step {i+1}: block_index={pos.block_index}")
# Check final position
final_pos = reader.manager.current_position.copy()
print(f"\nFinal position: {final_pos}")
print(f"Expected (pos0): {pos0}")
if final_pos != pos0:
print(f"WARNING: Position mismatch!")
print(f" Expected block_index: {pos0.block_index}")
print(f" Actual block_index: {final_pos.block_index}")
print(f" Difference: {final_pos.block_index - pos0.block_index} blocks")
self.assertEqual(
final_pos,
pos0,
f"After {num_forward} forward and {num_forward} backward, should be at start"
)
reader.close()
def test_backward_navigation_at_start(self):
"""
Test that previous_page() behaves correctly when at the start of the book.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Backward navigation at start ===")
pos_start = reader.manager.current_position.copy()
print(f"At start: {pos_start}")
# Try to go back from the very first page
page = reader.previous_page()
print(f"previous_page() returned: {page is not None}")
pos_after = reader.manager.current_position.copy()
print(f"Position after previous_page(): {pos_after}")
# Should either return None or stay at same position
if page is not None:
self.assertEqual(
pos_after,
pos_start,
"If previous_page() returns a page at start, position should not change"
)
reader.close()
def test_alternating_navigation(self):
"""
Test alternating forward/backward navigation.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Alternating forward/backward navigation ===")
pos0 = reader.manager.current_position.copy()
print(f"Start: block_index={pos0.block_index}")
# Go forward, back, forward, back pattern
operations = [
("forward", 1),
("backward", 1),
("forward", 2),
("backward", 1),
("forward", 1),
("backward", 2),
]
for op, count in operations:
for i in range(count):
if op == "forward":
page = reader.next_page()
else:
page = reader.previous_page()
self.assertIsNotNone(
page,
f"{op} navigation failed at iteration {i+1}"
)
pos = reader.manager.current_position.copy()
print(f"After {count}x {op}: block_index={pos.block_index}")
# We should end up at the starting position (net: +5 -4 = +1, then +1 -2 = -1, total = 0)
# Actually: +1 -1 +2 -1 +1 -2 = 0
final_pos = reader.manager.current_position.copy()
print(f"\nFinal: block_index={final_pos.block_index}")
print(f"Expected: block_index={pos0.block_index}")
self.assertEqual(
final_pos,
pos0,
"Alternating navigation should return to start"
)
reader.close()
def test_backward_then_forward(self):
"""
Test that forward navigation works correctly after backward navigation.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Backward then forward ===")
# Go forward 3 pages
positions = [reader.manager.current_position.copy()]
for i in range(3):
reader.next_page()
positions.append(reader.manager.current_position.copy())
print(f"Forward positions: {[p.block_index for p in positions]}")
# Go back 3 pages
for i in range(3):
reader.previous_page()
pos_after_back = reader.manager.current_position.copy()
print(f"After going back: block_index={pos_after_back.block_index}")
# Now go forward 3 pages again
for i in range(3):
reader.next_page()
final_pos = reader.manager.current_position.copy()
print(f"After going forward again: block_index={final_pos.block_index}")
print(f"Expected: block_index={positions[3].block_index}")
self.assertEqual(
final_pos,
positions[3],
"Forward after backward should reach same position"
)
reader.close()
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,230 @@
"""
Test backward navigation after resuming from a saved position.
This test specifically checks if backward navigation works correctly
after opening an epub, navigating forward, closing it, then resuming
and attempting to navigate backward.
This may reveal issues with pyWebLayout's backward navigation handling.
"""
import unittest
import tempfile
import shutil
from pathlib import Path
import numpy as np
from PIL import Image
from dreader.application import EbookReader
class TestBackwardNavigationAfterResume(unittest.TestCase):
"""Test backward navigation behavior after resume"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool:
"""
Check if two PIL Images are pixel-perfect identical.
"""
if img1 is None or img2 is None:
return False
if img1.size != img2.size:
return False
arr1 = np.array(img1)
arr2 = np.array(img2)
return np.array_equal(arr1, arr2)
def test_backward_navigation_after_resume(self):
"""
Test that backward navigation works after closing and resuming.
Steps:
1. Open EPUB
2. Navigate forward 3 pages
3. Save positions and pages
4. Close reader
5. Open new reader (resume)
6. Try to navigate backward
7. Verify we can reach previous pages
"""
# Phase 1: Initial session - navigate forward
reader1 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0 # Disable buffering for consistent testing
)
success = reader1.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB")
# Capture initial page
page0 = reader1.get_current_page()
self.assertIsNotNone(page0, "Initial page should not be None")
pos0 = reader1.manager.current_position.copy()
print(f"\nInitial position: {pos0}")
# Navigate forward 3 pages, capturing each page
pages = [page0]
positions = [pos0]
for i in range(3):
page = reader1.next_page()
self.assertIsNotNone(page, f"Page {i+1} should not be None")
pages.append(page)
positions.append(reader1.manager.current_position.copy())
print(f"Forward page {i+1} position: {positions[-1]}")
# We should now be at page 3 (0-indexed)
self.assertEqual(len(pages), 4, "Should have 4 pages total (0-3)")
# Save the current position before closing
final_position = reader1.manager.current_position.copy()
print(f"Final position before close: {final_position}")
# Close reader (this should save the position)
reader1.close()
# Phase 2: Resume session - navigate backward
reader2 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
success = reader2.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB on resume")
# Verify we resumed at the correct position
resumed_position = reader2.manager.current_position.copy()
print(f"Resumed at position: {resumed_position}")
self.assertEqual(
resumed_position,
final_position,
"Should resume at the last saved position"
)
# Get the current page (should match page 3)
resumed_page = reader2.get_current_page()
self.assertIsNotNone(resumed_page, "Resumed page should not be None")
# Now try to navigate backward
print("\nAttempting backward navigation...")
backward_pages = []
backward_positions = []
# Try to go back 3 times
for i in range(3):
prev_page = reader2.previous_page()
print(f"Backward step {i+1}: page={'Not None' if prev_page else 'None'}")
if prev_page is None:
print(f"WARNING: previous_page() returned None at step {i+1}")
# This is the bug we're testing for!
self.fail(f"Backward navigation failed at step {i+1}: previous_page() returned None")
backward_pages.append(prev_page)
backward_positions.append(reader2.manager.current_position.copy())
print(f" Position after backward: {backward_positions[-1]}")
# We should have successfully gone back 3 pages
self.assertEqual(len(backward_pages), 3, "Should have navigated back 3 pages")
# Verify final position matches original position
final_backward_position = reader2.manager.current_position.copy()
print(f"\nFinal position after backward navigation: {final_backward_position}")
print(f"Original position (page 0): {pos0}")
self.assertEqual(
final_backward_position,
pos0,
"After going forward 3 and back 3, should be at initial position"
)
# Verify the page content matches
final_page = reader2.get_current_page()
self.assertTrue(
self.compare_images(page0, final_page),
"Final page should match initial page after forward/backward navigation"
)
reader2.close()
print("\n✓ Test passed: Backward navigation works correctly after resume")
def test_backward_navigation_single_step(self):
"""
Simplified test: Open, go forward 1 page, close, resume, go back 1 page.
This is a minimal reproduction case.
"""
# Session 1: Navigate forward one page
reader1 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader1.load_epub(self.epub_path)
page0 = reader1.get_current_page()
pos0 = reader1.manager.current_position.copy()
page1 = reader1.next_page()
self.assertIsNotNone(page1, "Should be able to navigate forward")
pos1 = reader1.manager.current_position.copy()
reader1.close()
# Session 2: Resume and navigate backward
reader2 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader2.load_epub(self.epub_path)
# Verify we're at page 1
self.assertEqual(
reader2.manager.current_position,
pos1,
"Should resume at page 1"
)
# Try to go back
prev_page = reader2.previous_page()
# This is the critical assertion - if this fails, backward nav is broken
self.assertIsNotNone(
prev_page,
"CRITICAL: previous_page() returned None after resume - this indicates a pyWebLayout bug"
)
# Verify we're back at page 0
final_pos = reader2.manager.current_position.copy()
self.assertEqual(
final_pos,
pos0,
"Should be back at initial position"
)
reader2.close()
if __name__ == '__main__':
unittest.main()

589
tests/test_boot_recovery.py Normal file
View File

@ -0,0 +1,589 @@
"""
Comprehensive tests for boot recovery and resume functionality.
Tests cover:
- Saving state when closing reader
- Resuming from saved state with a new reader instance
- Restoring reading position (page/chapter)
- Restoring settings (font size, spacing, etc.)
- Restoring bookmarks
- Handling state across multiple books
- Error recovery (corrupt state, missing books)
- Bookmark-based position restoration
"""
import unittest
import tempfile
import shutil
import json
import asyncio
from pathlib import Path
from typing import Dict, Any
from dreader.application import EbookReader
from dreader.state import StateManager, AppState, BookState, Settings, EreaderMode, OverlayState
class TestBootRecovery(unittest.TestCase):
"""Test application state persistence and recovery across reader instances"""
def setUp(self):
"""Set up test environment with temporary directories"""
self.temp_dir = tempfile.mkdtemp()
self.bookmarks_dir = Path(self.temp_dir) / "bookmarks"
self.highlights_dir = Path(self.temp_dir) / "highlights"
self.state_file = Path(self.temp_dir) / "state.json"
self.bookmarks_dir.mkdir(exist_ok=True)
self.highlights_dir.mkdir(exist_ok=True)
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_save_and_restore_reading_position(self):
"""Test saving current position and restoring it in a new reader"""
# Create first reader instance
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
# Load book and navigate to middle
reader1.load_epub(self.epub_path)
# Navigate forward several pages
for _ in range(5):
reader1.next_page()
# Get position before saving
original_position = reader1.get_position_info()
original_progress = reader1.get_reading_progress()
# Save position using special auto-resume bookmark
reader1.save_position("__auto_resume__")
# Close reader
reader1.close()
# Create new reader instance
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
# Load same book
reader2.load_epub(self.epub_path)
# Restore position
success = reader2.load_position("__auto_resume__")
self.assertTrue(success, "Failed to load auto-resume position")
# Verify position matches
restored_position = reader2.get_position_info()
restored_progress = reader2.get_reading_progress()
# Compare positions using the position dict
self.assertEqual(original_position.get('position'), restored_position.get('position'),
f"Position mismatch: {original_position} vs {restored_position}")
self.assertAlmostEqual(original_progress, restored_progress,
places=2, msg="Progress percentage mismatch")
reader2.close()
def test_save_and_restore_settings(self):
"""Test saving settings and restoring them in a new reader"""
# Create first reader
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Change settings
reader1.increase_font_size()
reader1.increase_font_size()
reader1.set_line_spacing(10)
reader1.set_inter_block_spacing(25)
# Get settings
original_font_scale = reader1.base_font_scale
original_line_spacing = reader1.page_style.line_spacing
original_inter_block = reader1.page_style.inter_block_spacing
# Create state manager and save settings
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state_manager.update_settings({
'font_scale': original_font_scale,
'line_spacing': original_line_spacing,
'inter_block_spacing': original_inter_block
})
state_manager.save_state(force=True)
reader1.close()
# Create new reader
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
# Load state and apply settings
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
state_manager2.load_state()
settings_dict = state_manager2.get_settings().to_dict()
reader2.apply_settings(settings_dict)
# Verify settings match
self.assertAlmostEqual(original_font_scale, reader2.base_font_scale, places=2,
msg="Font scale mismatch")
self.assertEqual(original_line_spacing, reader2.page_style.line_spacing,
"Line spacing mismatch")
self.assertEqual(original_inter_block, reader2.page_style.inter_block_spacing,
"Inter-block spacing mismatch")
reader2.close()
def test_save_and_restore_bookmarks(self):
"""Test that bookmarks persist across reader instances"""
# Create first reader
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Navigate and create bookmarks
reader1.next_page()
reader1.next_page()
reader1.save_position("bookmark1")
reader1.next_page()
reader1.next_page()
reader1.next_page()
reader1.save_position("bookmark2")
# Get bookmark list
original_bookmarks = reader1.list_saved_positions()
self.assertGreater(len(original_bookmarks), 0, "No bookmarks saved")
reader1.close()
# Create new reader
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
# Check bookmarks exist
restored_bookmarks = reader2.list_saved_positions()
self.assertIn("bookmark1", restored_bookmarks, "bookmark1 not found")
self.assertIn("bookmark2", restored_bookmarks, "bookmark2 not found")
# Test loading each bookmark
success1 = reader2.load_position("bookmark1")
self.assertTrue(success1, "Failed to load bookmark1")
success2 = reader2.load_position("bookmark2")
self.assertTrue(success2, "Failed to load bookmark2")
reader2.close()
def test_full_state_persistence_workflow(self):
"""Test complete workflow: read, change settings, save, close, restore"""
# Session 1: Initial reading session
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Simulate reading session
for _ in range(3):
reader1.next_page()
reader1.increase_font_size()
reader1.set_line_spacing(8)
# Save everything
reader1.save_position("__auto_resume__")
reader1.save_position("my_bookmark")
session1_position = reader1.get_position_info()
session1_progress = reader1.get_reading_progress()
session1_font = reader1.base_font_scale
session1_spacing = reader1.page_style.line_spacing
# Save state
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state_manager.set_current_book(BookState(
path=self.epub_path,
title=reader1.book_title or "Test Book",
author=reader1.book_author or "Test Author"
))
state_manager.update_settings({
'font_scale': session1_font,
'line_spacing': session1_spacing
})
state_manager.save_state(force=True)
reader1.close()
# Session 2: Resume reading
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
loaded_state = state_manager2.load_state()
# Verify state loaded
self.assertIsNotNone(loaded_state.current_book, "No current book in state")
self.assertEqual(loaded_state.current_book.path, self.epub_path,
"Book path mismatch")
# Create new reader and restore
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(loaded_state.current_book.path)
reader2.apply_settings(loaded_state.settings.to_dict())
reader2.load_position("__auto_resume__")
# Verify restoration
session2_position = reader2.get_position_info()
session2_progress = reader2.get_reading_progress()
self.assertEqual(session1_position.get('position'), session2_position.get('position'),
"Position not restored correctly")
self.assertAlmostEqual(session1_progress, session2_progress, places=2,
msg="Progress not restored correctly")
self.assertAlmostEqual(session1_font, reader2.base_font_scale, places=2,
msg="Font scale not restored correctly")
self.assertEqual(session1_spacing, reader2.page_style.line_spacing,
"Line spacing not restored correctly")
# Verify bookmark exists
bookmarks = reader2.list_saved_positions()
self.assertIn("my_bookmark", bookmarks, "Bookmark lost after restart")
reader2.close()
def test_multiple_books_separate_state(self):
"""Test that different books maintain separate positions and bookmarks"""
epub_path = self.epub_path
# Book 1 - First session
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(epub_path)
for _ in range(3):
reader1.next_page()
reader1.save_position("__auto_resume__")
book1_position = reader1.get_position_info()
book1_progress = reader1.get_reading_progress()
book1_doc_id = reader1.document_id
reader1.close()
# Book 1 - Second session (simulate reopening)
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(epub_path)
reader2.load_position("__auto_resume__")
# Verify we're at the same position
book1_position_restored = reader2.get_position_info()
book1_progress_restored = reader2.get_reading_progress()
self.assertEqual(book1_position.get('position'), book1_position_restored.get('position'),
"Book position not preserved across sessions")
self.assertAlmostEqual(book1_progress, book1_progress_restored, places=2,
msg="Book progress not preserved")
# Now navigate further and save again
for _ in range(2):
reader2.next_page()
reader2.save_position("__auto_resume__")
book1_position_updated = reader2.get_position_info()
book1_progress_updated = reader2.get_reading_progress()
reader2.close()
# Book 1 - Third session, verify updated position
reader3 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader3.load_epub(epub_path)
reader3.load_position("__auto_resume__")
book1_position_final = reader3.get_position_info()
book1_progress_final = reader3.get_reading_progress()
self.assertEqual(book1_position_updated.get('position'), book1_position_final.get('position'),
"Updated position not preserved")
self.assertAlmostEqual(book1_progress_updated, book1_progress_final, places=2,
msg="Updated progress not preserved")
reader3.close()
def test_corrupt_state_file_recovery(self):
"""Test graceful handling of corrupt state file"""
# Create corrupt state file
with open(self.state_file, 'w') as f:
f.write("{ corrupt json content ][[ }")
# Try to load state
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state = state_manager.load_state()
# Should return default state, not crash
self.assertIsNotNone(state)
self.assertEqual(state.mode, EreaderMode.LIBRARY)
self.assertIsNone(state.current_book)
# Verify backup was created
backup_file = self.state_file.with_suffix('.json.backup')
self.assertTrue(backup_file.exists(), "Backup file not created for corrupt state")
def test_missing_book_in_state(self):
"""Test handling when saved state references a missing book"""
# Create valid state pointing to non-existent book
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state_manager.set_current_book(BookState(
path="/nonexistent/book.epub",
title="Missing Book",
author="Ghost Author"
))
state_manager.save_state(force=True)
# Load state
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
state = state_manager2.load_state()
# State loads successfully
self.assertIsNotNone(state.current_book)
self.assertEqual(state.current_book.path, "/nonexistent/book.epub")
# But trying to load the book should fail gracefully
reader = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
success = reader.load_epub(state.current_book.path)
self.assertFalse(success, "Should fail to load non-existent book")
self.assertFalse(reader.is_loaded(), "Reader should not be in loaded state")
reader.close()
def test_no_state_file_cold_start(self):
"""Test first boot with no existing state file"""
# Ensure no state file exists
if self.state_file.exists():
self.state_file.unlink()
# Create state manager
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state = state_manager.load_state()
# Should get default state
self.assertEqual(state.mode, EreaderMode.LIBRARY)
self.assertIsNone(state.current_book)
self.assertEqual(state.overlay, OverlayState.NONE)
self.assertEqual(state.settings.font_scale, 1.0)
# Should be able to save new state
success = state_manager.save_state(force=True)
self.assertTrue(success, "Failed to save initial state")
self.assertTrue(self.state_file.exists(), "State file not created")
def test_position_survives_settings_change(self):
"""Test that position is preserved when settings change"""
# Create reader and navigate
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Navigate to specific position
for _ in range(4):
reader1.next_page()
reader1.save_position("__auto_resume__")
position1_info = reader1.get_position_info()
# Change font size (which re-paginates)
reader1.increase_font_size()
reader1.increase_font_size()
# Position might change due to repagination, but logical position is preserved
# Save again
reader1.save_position("__auto_resume__")
position_after_resize_info = reader1.get_position_info()
position_after_resize_progress = reader1.get_reading_progress()
reader1.close()
# Create new reader with same settings
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
# Apply same font size
reader2.increase_font_size()
reader2.increase_font_size()
# Load position
reader2.load_position("__auto_resume__")
position2_info = reader2.get_position_info()
position2_progress = reader2.get_reading_progress()
# Should match the position after resize, not the original
self.assertEqual(position_after_resize_info.get('position'), position2_info.get('position'),
"Position not preserved after font size change")
self.assertAlmostEqual(position_after_resize_progress, position2_progress, places=2,
msg="Progress not preserved after font size change")
reader2.close()
def test_chapter_position_restoration(self):
"""Test that chapter context is preserved across sessions"""
# Create reader and jump to specific chapter
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Get chapters
chapters = reader1.get_chapters()
if len(chapters) < 2:
self.skipTest("Test EPUB needs at least 2 chapters")
# Jump to second chapter
_, chapter_idx = chapters[1]
reader1.jump_to_chapter(chapter_idx)
# Navigate a bit within the chapter
reader1.next_page()
# Save position
reader1.save_position("__auto_resume__")
chapter1_position = reader1.get_position_info()
chapter1_progress = reader1.get_reading_progress()
reader1.close()
# Create new reader and restore
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
reader2.load_position("__auto_resume__")
# Verify we're at the right position
chapter2_position = reader2.get_position_info()
chapter2_progress = reader2.get_reading_progress()
self.assertEqual(chapter1_position.get('position'), chapter2_position.get('position'),
"Chapter position not restored correctly")
self.assertAlmostEqual(chapter1_progress, chapter2_progress, places=2,
msg="Chapter progress not restored correctly")
reader2.close()
class TestStateManagerAsync(unittest.TestCase):
"""Test StateManager async functionality"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.state_file = Path(self.temp_dir) / "state.json"
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_async_auto_save(self):
"""Test that async auto-save works"""
async def test_auto_save():
# Create state manager with short interval
state_manager = StateManager(str(self.state_file), auto_save_interval=1)
# Start auto-save
state_manager.start_auto_save()
# Make a change
state_manager.set_mode(EreaderMode.READING)
# Wait for auto-save to trigger
await asyncio.sleep(1.5)
# Stop auto-save
await state_manager.stop_auto_save(save_final=True)
# Verify file was saved
self.assertTrue(self.state_file.exists(), "State file not created")
# Load and verify
with open(self.state_file) as f:
data = json.load(f)
self.assertEqual(data['mode'], 'reading')
# Run async test
asyncio.run(test_auto_save())
def test_async_save_with_lock(self):
"""Test that async saves are thread-safe"""
async def test_concurrent_saves():
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
# Make multiple concurrent saves
tasks = []
for i in range(10):
state_manager.update_setting('brightness', i)
tasks.append(state_manager.save_state_async(force=True))
# Wait for all saves
results = await asyncio.gather(*tasks)
# All should succeed
self.assertTrue(all(results), "Some saves failed")
# File should exist and be valid
self.assertTrue(self.state_file.exists())
# Load and verify (should have last value)
with open(self.state_file) as f:
data = json.load(f)
# Brightness should be set (exact value depends on race, but should be 0-9)
self.assertIn(data['settings']['brightness'], range(10))
asyncio.run(test_concurrent_saves())
if __name__ == '__main__':
unittest.main()

132
tests/test_epub_images.py Normal file
View File

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Test that images render correctly in EPUB files.
This test verifies that:
1. All images in the EPUB are loaded with correct dimensions
2. Images can be navigated to without errors
3. Pages with images render successfully
4. The rendered pages contain actual image content (not blank)
"""
import pytest
from dreader.application import EbookReader
from pyWebLayout.abstract.block import Image as AbstractImage
from PIL import Image
import numpy as np
def test_epub_images():
"""Test that EPUB images render correctly."""
# Create reader
reader = EbookReader(page_size=(800, 1200))
# Load EPUB
epub_path = "tests/data/library-epub/pg11-images-3.epub"
success = reader.load_epub(epub_path)
assert success, "Failed to load EPUB"
assert reader.book_title == "Alice's Adventures in Wonderland"
# Check that images were parsed
images = [b for b in reader.blocks if isinstance(b, AbstractImage)]
assert len(images) >= 1, f"Expected at least 1 image, found {len(images)}"
# Check that all images have dimensions set
for img in images:
assert img.width is not None, f"Image {img.source} has no width"
assert img.height is not None, f"Image {img.source} has no height"
assert img.width > 0, f"Image {img.source} has invalid width: {img.width}"
assert img.height > 0, f"Image {img.source} has invalid height: {img.height}"
# Check that image is loaded into memory
assert hasattr(img, '_loaded_image'), f"Image {img.source} not loaded"
assert img._loaded_image is not None, f"Image {img.source} _loaded_image is None"
# Test navigation through first 15 pages (which should include all images)
for page_num in range(15):
page_img = reader.get_current_page()
assert page_img is not None, f"Page {page_num + 1} failed to render"
assert isinstance(page_img, Image.Image), f"Page {page_num + 1} is not a PIL Image"
assert page_img.size == (800, 1200), f"Page {page_num + 1} has wrong size: {page_img.size}"
# Check that page has some non-white content
arr = np.array(page_img.convert('RGB'))
non_white_pixels = np.sum(arr < 255)
assert non_white_pixels > 100, f"Page {page_num + 1} appears to be blank (only {non_white_pixels} non-white pixels)"
# Navigate to next page
if page_num < 14:
next_result = reader.next_page()
if next_result is None:
# It's OK to reach end of book early
break
def test_cover_image():
"""Specifically test that the cover image renders."""
reader = EbookReader(page_size=(800, 1200))
reader.load_epub("tests/data/library-epub/pg11-images-3.epub")
# The first page should have the cover image
page_img = reader.get_current_page()
assert page_img is not None, "Cover page failed to render"
# Save for visual inspection
output_path = "/tmp/epub_cover_test.png"
page_img.save(output_path)
# Check that it has significant content (the cover image)
arr = np.array(page_img.convert('RGB'))
non_white_pixels = np.sum(arr < 255)
# The cover page should have substantial content
assert non_white_pixels > 10000, f"Cover page has too few non-white pixels: {non_white_pixels}"
def test_multiple_epub_images():
"""Test images across multiple EPUB files."""
epub_files = [
("tests/data/library-epub/pg11-images-3.epub", "Alice's Adventures in Wonderland"),
("tests/data/library-epub/pg16328-images-3.epub", "Beowulf: An Anglo-Saxon Epic Poem"),
("tests/data/library-epub/pg5200-images-3.epub", "Metamorphosis"),
]
for epub_path, expected_title in epub_files:
reader = EbookReader(page_size=(800, 1200))
success = reader.load_epub(epub_path)
assert success, f"Failed to load {epub_path}"
assert reader.book_title == expected_title
# Check that at least one image exists
images = [b for b in reader.blocks if isinstance(b, AbstractImage)]
assert len(images) >= 1, f"{epub_path} should have at least 1 image"
# Check first image is valid
img = images[0]
assert img.width > 0 and img.height > 0, f"Invalid dimensions in {epub_path}"
if __name__ == "__main__":
# Run tests directly
print("Testing EPUB images...")
print("\n1. Testing all images load and render...")
test_epub_images()
print("✓ PASSED")
print("\n2. Testing cover image...")
test_cover_image()
print("✓ PASSED")
print("\n3. Testing multiple EPUB images...")
test_multiple_epub_images()
print("✓ PASSED")
print("\n✓ All tests passed!")

View File

@ -0,0 +1,832 @@
"""
Comprehensive tests for the EbookReader application interface.
Tests cover:
- EPUB loading and initialization
- Navigation (forward, backward, boundaries)
- Font scaling and styling
- Chapter navigation
- Position management (bookmarks)
- Information retrieval
- File operations
- Error handling
- Context manager
- Integration scenarios
"""
import unittest
import tempfile
import shutil
from pathlib import Path
import numpy as np
from PIL import Image
import os
from dreader.application import EbookReader, create_ebook_reader
class TestEbookReaderInitialization(unittest.TestCase):
"""Test EbookReader creation and EPUB loading"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_create_reader_with_defaults(self):
"""Test creating reader with default settings"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
self.assertEqual(reader.page_size, (800, 1000))
self.assertEqual(reader.base_font_scale, 1.0)
self.assertIsNone(reader.manager)
self.assertFalse(reader.is_loaded())
reader.close()
def test_create_reader_with_custom_settings(self):
"""Test creating reader with custom settings"""
reader = EbookReader(
page_size=(600, 800),
margin=50,
background_color=(240, 240, 240),
line_spacing=10,
inter_block_spacing=20,
bookmarks_dir=self.temp_dir,
buffer_size=3
)
self.assertEqual(reader.page_size, (600, 800))
self.assertEqual(reader.page_style.line_spacing, 10)
self.assertEqual(reader.page_style.inter_block_spacing, 20)
self.assertEqual(reader.buffer_size, 3)
reader.close()
def test_load_valid_epub(self):
"""Test loading a valid EPUB file"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
success = reader.load_epub(self.epub_path)
self.assertTrue(success)
self.assertTrue(reader.is_loaded())
self.assertIsNotNone(reader.manager)
self.assertIsNotNone(reader.blocks)
self.assertIsNotNone(reader.document_id)
self.assertIsNotNone(reader.book_title)
self.assertIsNotNone(reader.book_author)
reader.close()
def test_load_nonexistent_epub(self):
"""Test loading a non-existent EPUB file"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
success = reader.load_epub("nonexistent.epub")
self.assertFalse(success)
self.assertFalse(reader.is_loaded())
reader.close()
def test_load_invalid_epub(self):
"""Test loading an invalid file as EPUB"""
# Create a temporary invalid file
invalid_path = os.path.join(self.temp_dir, "invalid.epub")
with open(invalid_path, 'w') as f:
f.write("This is not a valid EPUB file")
reader = EbookReader(bookmarks_dir=self.temp_dir)
success = reader.load_epub(invalid_path)
self.assertFalse(success)
self.assertFalse(reader.is_loaded())
reader.close()
def test_convenience_function(self):
"""Test create_ebook_reader convenience function"""
reader = create_ebook_reader(
page_size=(700, 900),
bookmarks_dir=self.temp_dir
)
self.assertIsInstance(reader, EbookReader)
self.assertEqual(reader.page_size, (700, 900))
reader.close()
class TestEbookReaderFontScaling(unittest.TestCase):
"""Test font size control"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0 # Disable buffering for tests
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_set_font_size(self):
"""Test setting font size with arbitrary scale"""
page = self.reader.set_font_size(1.5)
self.assertIsNotNone(page)
self.assertEqual(self.reader.get_font_size(), 1.5)
def test_increase_font_size(self):
"""Test increasing font size by one step"""
initial_size = self.reader.get_font_size()
page = self.reader.increase_font_size()
self.assertIsNotNone(page)
self.assertEqual(self.reader.get_font_size(), initial_size + 0.1)
def test_decrease_font_size(self):
"""Test decreasing font size by one step"""
self.reader.set_font_size(1.5)
page = self.reader.decrease_font_size()
self.assertIsNotNone(page)
self.assertAlmostEqual(self.reader.get_font_size(), 1.4, places=5)
def test_font_size_bounds_clamping(self):
"""Test that font size is clamped between 0.5x and 3.0x"""
# Test upper bound
self.reader.set_font_size(5.0)
self.assertEqual(self.reader.get_font_size(), 3.0)
# Test lower bound
self.reader.set_font_size(0.1)
self.assertEqual(self.reader.get_font_size(), 0.5)
def test_get_font_size(self):
"""Test getting current font size"""
self.assertEqual(self.reader.get_font_size(), 1.0)
self.reader.set_font_size(2.0)
self.assertEqual(self.reader.get_font_size(), 2.0)
def test_font_scale_with_navigation(self):
"""Test that font scale persists across page navigation"""
self.reader.set_font_size(1.5)
initial_font_size = self.reader.get_font_size()
# Navigate forward
self.reader.next_page()
# Font size should be preserved
self.assertEqual(self.reader.get_font_size(), initial_font_size)
class TestEbookReaderSpacing(unittest.TestCase):
"""Test line and block spacing"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_set_line_spacing(self):
"""Test setting line spacing"""
page = self.reader.set_line_spacing(10)
self.assertIsNotNone(page)
self.assertEqual(self.reader.page_style.line_spacing, 10)
def test_set_inter_block_spacing(self):
"""Test setting inter-block spacing"""
page = self.reader.set_inter_block_spacing(25)
self.assertIsNotNone(page)
self.assertEqual(self.reader.page_style.inter_block_spacing, 25)
def test_spacing_with_navigation(self):
"""Test that spacing changes affect rendering after navigation"""
self.reader.set_line_spacing(15)
page = self.reader.next_page()
self.assertIsNotNone(page)
self.assertEqual(self.reader.page_style.line_spacing, 15)
def test_spacing_position_preservation(self):
"""Test that changing spacing preserves reading position"""
# Navigate to a specific position
for _ in range(3):
self.reader.next_page()
position_before = self.reader.manager.current_position.copy()
# Change spacing
self.reader.set_line_spacing(12)
position_after = self.reader.manager.current_position
# Position should be preserved
self.assertEqual(position_before.chapter_index, position_after.chapter_index)
self.assertEqual(position_before.block_index, position_after.block_index)
class TestEbookReaderChapterNavigation(unittest.TestCase):
"""Test chapter navigation features"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_get_chapters(self):
"""Test getting list of chapters"""
chapters = self.reader.get_chapters()
self.assertIsInstance(chapters, list)
if len(chapters) > 0:
# Each chapter should be a tuple (title, index)
self.assertIsInstance(chapters[0], tuple)
self.assertEqual(len(chapters[0]), 2)
def test_get_chapter_positions(self):
"""Test getting chapter positions"""
positions = self.reader.get_chapter_positions()
self.assertIsInstance(positions, list)
if len(positions) > 0:
# Each item should be (title, RenderingPosition)
self.assertIsInstance(positions[0], tuple)
self.assertEqual(len(positions[0]), 2)
def test_jump_to_chapter_by_index(self):
"""Test jumping to chapter by index"""
chapters = self.reader.get_chapters()
if len(chapters) > 0:
page = self.reader.jump_to_chapter(0)
self.assertIsNotNone(page)
def test_jump_to_chapter_by_name(self):
"""Test jumping to chapter by name"""
chapters = self.reader.get_chapters()
if len(chapters) > 0:
chapter_title = chapters[0][0]
page = self.reader.jump_to_chapter(chapter_title)
self.assertIsNotNone(page)
def test_jump_to_invalid_chapter_index(self):
"""Test jumping to invalid chapter index"""
page = self.reader.jump_to_chapter(9999)
self.assertIsNone(page)
def test_jump_to_invalid_chapter_name(self):
"""Test jumping to non-existent chapter name"""
page = self.reader.jump_to_chapter("Non-Existent Chapter")
self.assertIsNone(page)
class TestEbookReaderInformation(unittest.TestCase):
"""Test information retrieval methods"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_get_position_info(self):
"""Test getting detailed position information"""
info = self.reader.get_position_info()
self.assertIsInstance(info, dict)
self.assertIn('position', info)
self.assertIn('chapter', info)
self.assertIn('progress', info)
self.assertIn('font_scale', info)
self.assertIn('book_title', info)
self.assertIn('book_author', info)
def test_get_reading_progress(self):
"""Test getting reading progress as percentage"""
progress = self.reader.get_reading_progress()
self.assertIsInstance(progress, float)
self.assertGreaterEqual(progress, 0.0)
self.assertLessEqual(progress, 1.0)
# Progress should increase after navigation
initial_progress = progress
for _ in range(5):
self.reader.next_page()
new_progress = self.reader.get_reading_progress()
self.assertGreater(new_progress, initial_progress)
def test_get_current_chapter_info(self):
"""Test getting current chapter information"""
info = self.reader.get_current_chapter_info()
# May be None if no chapters
if info is not None:
self.assertIsInstance(info, dict)
self.assertIn('title', info)
self.assertIn('level', info)
self.assertIn('block_index', info)
def test_get_book_info_complete(self):
"""Test getting complete book information"""
info = self.reader.get_book_info()
self.assertIsInstance(info, dict)
self.assertIn('title', info)
self.assertIn('author', info)
self.assertIn('document_id', info)
self.assertIn('total_blocks', info)
self.assertIn('total_chapters', info)
self.assertIn('page_size', info)
self.assertIn('font_scale', info)
self.assertGreater(info['total_blocks'], 0)
self.assertEqual(info['page_size'], self.reader.page_size)
class TestEbookReaderFileOperations(unittest.TestCase):
"""Test file I/O operations"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_render_to_file_png(self):
"""Test saving current page as PNG"""
output_path = os.path.join(self.temp_dir, "page.png")
success = self.reader.render_to_file(output_path)
self.assertTrue(success)
self.assertTrue(os.path.exists(output_path))
# Verify it's a valid image
img = Image.open(output_path)
self.assertEqual(img.size, self.reader.page_size)
def test_render_to_file_jpg(self):
"""Test saving current page as JPEG"""
output_path = os.path.join(self.temp_dir, "page.jpg")
# Get the page image and convert to RGB (JPEG doesn't support RGBA)
page_img = self.reader.get_current_page()
if page_img.mode == 'RGBA':
page_img = page_img.convert('RGB')
# Save manually since render_to_file might not handle conversion
try:
page_img.save(output_path)
success = True
except Exception:
success = False
self.assertTrue(success)
self.assertTrue(os.path.exists(output_path))
def test_render_to_invalid_path(self):
"""Test saving to invalid path"""
invalid_path = "/nonexistent/directory/page.png"
success = self.reader.render_to_file(invalid_path)
self.assertFalse(success)
class TestEbookReaderContextManager(unittest.TestCase):
"""Test context manager and cleanup"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_context_manager_usage(self):
"""Test using EbookReader as context manager"""
with EbookReader(bookmarks_dir=self.temp_dir) as reader:
success = reader.load_epub(self.epub_path)
self.assertTrue(success)
page = reader.get_current_page()
self.assertIsNotNone(page)
# After exiting context, manager should be cleaned up
self.assertIsNone(reader.manager)
def test_close_method(self):
"""Test explicit close method"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
reader.load_epub(self.epub_path)
self.assertIsNotNone(reader.manager)
reader.close()
self.assertIsNone(reader.manager)
def test_operations_after_close(self):
"""Test that operations fail gracefully after close"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
reader.load_epub(self.epub_path)
reader.close()
# These should all return None or empty
self.assertIsNone(reader.get_current_page())
self.assertIsNone(reader.next_page())
self.assertIsNone(reader.previous_page())
self.assertEqual(reader.get_chapters(), [])
class TestEbookReaderErrorHandling(unittest.TestCase):
"""Test error handling and edge cases"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_operations_without_loaded_book(self):
"""Test that operations handle unloaded state gracefully"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
# All these should return None or empty/False
self.assertIsNone(reader.get_current_page())
self.assertIsNone(reader.next_page())
self.assertIsNone(reader.previous_page())
self.assertFalse(reader.save_position("test"))
self.assertIsNone(reader.load_position("test"))
self.assertEqual(reader.list_saved_positions(), [])
self.assertFalse(reader.delete_position("test"))
self.assertEqual(reader.get_chapters(), [])
self.assertIsNone(reader.jump_to_chapter(0))
self.assertIsNone(reader.set_font_size(1.5))
self.assertEqual(reader.get_reading_progress(), 0.0)
self.assertIsNone(reader.get_current_chapter_info())
reader.close()
def test_is_loaded(self):
"""Test is_loaded method"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
self.assertFalse(reader.is_loaded())
reader.load_epub(self.epub_path)
self.assertTrue(reader.is_loaded())
reader.close()
class TestEbookReaderIntegration(unittest.TestCase):
"""Test complex integration scenarios"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_font_scaling_preserves_position(self):
"""Test that changing font scale preserves reading position"""
# Navigate to a specific position
for _ in range(3):
self.reader.next_page()
position_before = self.reader.manager.current_position.copy()
# Change font size
self.reader.set_font_size(1.5)
position_after = self.reader.manager.current_position
# Position should be preserved
self.assertEqual(position_before.chapter_index, position_after.chapter_index)
self.assertEqual(position_before.block_index, position_after.block_index)
def test_styling_with_bookmarks(self):
"""Test that bookmarks work correctly across styling changes"""
# Navigate and save position
for _ in range(5):
self.reader.next_page()
self.reader.save_position("test_bookmark")
# Change styling
self.reader.set_font_size(1.5)
self.reader.set_line_spacing(12)
# Navigate away
for _ in range(5):
self.reader.next_page()
# Jump back to bookmark
page = self.reader.load_position("test_bookmark")
self.assertIsNotNone(page)
# Cleanup
self.reader.delete_position("test_bookmark")
def test_chapter_navigation_after_font_change(self):
"""Test chapter navigation after changing font size"""
self.reader.set_font_size(2.0)
chapters = self.reader.get_chapters()
if len(chapters) > 0:
page = self.reader.jump_to_chapter(0)
self.assertIsNotNone(page)
class TestEbookReaderNavigation(unittest.TestCase):
"""Test EbookReader navigation functionality (existing tests)"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool:
"""
Check if two PIL Images are pixel-perfect identical.
"""
if img1 is None or img2 is None:
return False
if img1.size != img2.size:
return False
arr1 = np.array(img1)
arr2 = np.array(img2)
return np.array_equal(arr1, arr2)
def test_bidirectional_navigation_20_pages(self):
"""
Test that navigating forward 20 pages and then backward 20 pages
produces identical page renderings for the first page.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
success = reader.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB")
self.assertTrue(reader.is_loaded(), "Reader should be loaded")
initial_page = reader.get_current_page()
self.assertIsNotNone(initial_page, "Initial page should not be None")
initial_position = reader.manager.current_position.copy()
forward_pages = [initial_page]
forward_positions = [initial_position]
pages_to_navigate = 20
for i in range(pages_to_navigate):
page = reader.next_page()
if page is None:
break
forward_pages.append(page)
forward_positions.append(reader.manager.current_position.copy())
actual_pages_navigated = len(forward_pages) - 1
backward_pages = []
for i in range(len(forward_positions) - 1, -1, -1):
position = forward_positions[i]
page_obj = reader.manager.jump_to_position(position)
page_img = page_obj.render()
backward_pages.append(page_img)
final_page = backward_pages[-1]
self.assertTrue(
self.compare_images(initial_page, final_page),
"First page should be identical after forward/backward navigation"
)
reader.close()
def test_navigation_at_boundaries(self):
"""Test navigation behavior at document boundaries."""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
success = reader.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB")
# Try to go backward from first page
page = reader.previous_page()
# Should return None or stay on same page
# Navigate forward until end
pages_forward = 0
max_pages = 100
while pages_forward < max_pages:
page = reader.next_page()
if page is None:
break
pages_forward += 1
# Try to go forward from last page
page = reader.next_page()
self.assertIsNone(page, "Should return None at end of document")
reader.close()
class TestEbookReaderPositionManagement(unittest.TestCase):
"""Test position tracking and bookmark features"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_position_save_and_load(self):
"""Test saving and loading positions"""
# Navigate to a position
for _ in range(3):
self.reader.next_page()
# Save position
success = self.reader.save_position("test_pos")
self.assertTrue(success)
# Navigate away
for _ in range(5):
self.reader.next_page()
# Load saved position
page = self.reader.load_position("test_pos")
self.assertIsNotNone(page)
def test_list_saved_positions(self):
"""Test listing saved positions"""
self.reader.save_position("pos1")
self.reader.save_position("pos2")
positions = self.reader.list_saved_positions()
self.assertIn("pos1", positions)
self.assertIn("pos2", positions)
def test_delete_position(self):
"""Test deleting a saved position"""
self.reader.save_position("temp_pos")
success = self.reader.delete_position("temp_pos")
self.assertTrue(success)
positions = self.reader.list_saved_positions()
self.assertNotIn("temp_pos", positions)
def test_delete_nonexistent_position(self):
"""Test deleting a non-existent position"""
success = self.reader.delete_position("nonexistent")
self.assertFalse(success)
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,316 @@
"""
Integration tests for EbookReader highlighting functionality.
Tests the highlighting API in EbookReader including:
- highlight_word()
- highlight_selection()
- remove_highlight()
- list_highlights()
- get_highlights_for_current_page()
- clear_highlights()
- Highlight rendering on pages
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from PIL import Image
from dreader.application import EbookReader
from pyWebLayout.core.highlight import HighlightColor
class TestEbookReaderHighlighting(unittest.TestCase):
"""Test EbookReader highlighting API"""
def setUp(self):
"""Create temporary directories and reader"""
self.temp_dir = tempfile.mkdtemp()
self.bookmarks_dir = Path(self.temp_dir) / "bookmarks"
self.highlights_dir = Path(self.temp_dir) / "highlights"
self.reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
def tearDown(self):
"""Clean up"""
self.reader.close()
shutil.rmtree(self.temp_dir)
def test_highlight_manager_not_initialized_before_load(self):
"""Test that highlight manager is None before loading a book"""
self.assertIsNone(self.reader.highlight_manager)
def test_highlight_manager_initialized_after_load(self):
"""Test that highlight manager is initialized after loading"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
self.assertIsNotNone(self.reader.highlight_manager)
self.assertEqual(self.reader.highlight_manager.document_id, "test")
def test_highlight_word_without_book(self):
"""Test highlighting returns None when no book loaded"""
highlight_id = self.reader.highlight_word(100, 100)
self.assertIsNone(highlight_id)
def test_highlight_word(self):
"""Test highlighting a word at a pixel location"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Get a page to ensure content is rendered
page_img = self.reader.get_current_page()
self.assertIsNotNone(page_img)
# Try to highlight a word (coordinates may not hit text in test)
highlight_id = self.reader.highlight_word(
200, 300,
color=HighlightColor.YELLOW.value,
note="Test highlight"
)
# If we hit text, should return an ID
if highlight_id:
self.assertIsInstance(highlight_id, str)
self.assertGreater(len(highlight_id), 0)
# Verify it was added
highlights = self.reader.list_highlights()
self.assertGreater(len(highlights), 0)
def test_highlight_selection(self):
"""Test highlighting a range of words"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Get a page
page_img = self.reader.get_current_page()
self.assertIsNotNone(page_img)
# Try to highlight a selection
highlight_id = self.reader.highlight_selection(
start=(100, 200),
end=(400, 250),
color=HighlightColor.BLUE.value,
tags=["selection", "test"]
)
# If selection hit text, should return an ID
if highlight_id:
self.assertIsInstance(highlight_id, str)
highlights = self.reader.list_highlights()
found = False
for h in highlights:
if h.id == highlight_id:
found = True
self.assertEqual(h.color, HighlightColor.BLUE.value)
self.assertIn("selection", h.tags)
break
self.assertTrue(found)
def test_list_highlights_empty(self):
"""Test listing highlights when none exist"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
highlights = self.reader.list_highlights()
self.assertEqual(len(highlights), 0)
def test_remove_highlight(self):
"""Test removing a highlight"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
page_img = self.reader.get_current_page()
# Create a highlight directly via manager for testing
from pyWebLayout.core.highlight import Highlight
highlight = Highlight(
id="test-highlight",
bounds=[(100, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text="Test"
)
self.reader.highlight_manager.add_highlight(highlight)
# Verify it exists
self.assertEqual(len(self.reader.list_highlights()), 1)
# Remove it
result = self.reader.remove_highlight("test-highlight")
self.assertTrue(result)
# Verify it's gone
self.assertEqual(len(self.reader.list_highlights()), 0)
def test_remove_nonexistent_highlight(self):
"""Test removing a highlight that doesn't exist"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
result = self.reader.remove_highlight("nonexistent")
self.assertFalse(result)
def test_clear_highlights(self):
"""Test clearing all highlights"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Add some highlights directly
from pyWebLayout.core.highlight import Highlight
for i in range(3):
highlight = Highlight(
id=f"test-{i}",
bounds=[(100 + i * 50, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text=f"Word{i}"
)
self.reader.highlight_manager.add_highlight(highlight)
self.assertEqual(len(self.reader.list_highlights()), 3)
self.reader.clear_highlights()
self.assertEqual(len(self.reader.list_highlights()), 0)
def test_get_highlights_for_current_page(self):
"""Test getting highlights for current page"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Add highlights at different locations
from pyWebLayout.core.highlight import Highlight
# On page
highlight1 = Highlight(
id="on-page",
bounds=[(100, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text="OnPage"
)
# Off page (beyond page height)
highlight2 = Highlight(
id="off-page",
bounds=[(100, 5000, 50, 15)],
color=HighlightColor.YELLOW.value,
text="OffPage"
)
self.reader.highlight_manager.add_highlight(highlight1)
self.reader.highlight_manager.add_highlight(highlight2)
# Get highlights for current page
page_highlights = self.reader.get_highlights_for_current_page()
# Should only get the on-page highlight
self.assertEqual(len(page_highlights), 1)
self.assertEqual(page_highlights[0].id, "on-page")
def test_get_current_page_with_highlights(self):
"""Test that get_current_page renders highlights"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Get page without highlights
page_no_highlights = self.reader.get_current_page(include_highlights=False)
self.assertIsNotNone(page_no_highlights)
# Add a highlight
from pyWebLayout.core.highlight import Highlight
highlight = Highlight(
id="test",
bounds=[(100, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text="Test"
)
self.reader.highlight_manager.add_highlight(highlight)
# Get page with highlights
page_with_highlights = self.reader.get_current_page(include_highlights=True)
self.assertIsNotNone(page_with_highlights)
# Both should be valid images
self.assertIsInstance(page_no_highlights, Image.Image)
self.assertIsInstance(page_with_highlights, Image.Image)
# Should have same size
self.assertEqual(page_no_highlights.size, page_with_highlights.size)
def test_highlight_persistence(self):
"""Test that highlights persist across reader instances"""
# Load book and add highlight
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
from pyWebLayout.core.highlight import Highlight
highlight = Highlight(
id="persistent",
bounds=[(100, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text="Persisted"
)
self.reader.highlight_manager.add_highlight(highlight)
self.assertEqual(len(self.reader.list_highlights()), 1)
# Close reader
self.reader.close()
# Create new reader for same book
new_reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
success = new_reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Should load existing highlights
highlights = new_reader.list_highlights()
self.assertEqual(len(highlights), 1)
self.assertEqual(highlights[0].id, "persistent")
self.assertEqual(highlights[0].text, "Persisted")
new_reader.close()
def test_render_highlights_helper(self):
"""Test the _render_highlights helper method"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Create a simple test image
from PIL import Image as PILImage
test_img = PILImage.new('RGB', (100, 100), color='white')
from pyWebLayout.core.highlight import Highlight
highlights = [
Highlight(
id="h1",
bounds=[(10, 10, 30, 20)],
color=(255, 255, 0, 100),
text="Test"
)
]
# Render highlights
result_img = self.reader._render_highlights(test_img, highlights)
self.assertIsNotNone(result_img)
self.assertIsInstance(result_img, PILImage.Image)
self.assertEqual(result_img.size, test_img.size)
# Result should preserve the input mode
self.assertEqual(result_img.mode, 'RGB')
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,455 @@
"""
Comprehensive tests for the ereader layout system.
Tests the complete ereader functionality including position tracking,
font scaling, chapter navigation, and page buffering.
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.layout.ereader_layout import RenderingPosition, ChapterNavigator, FontScaler, BidirectionalLayouter
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager, BookmarkManager, create_ereader_manager
class TestRenderingPosition(unittest.TestCase):
"""Test the RenderingPosition class"""
def test_position_creation(self):
"""Test creating a rendering position"""
pos = RenderingPosition(
chapter_index=1,
block_index=5,
word_index=10,
table_row=2,
table_col=3
)
self.assertEqual(pos.chapter_index, 1)
self.assertEqual(pos.block_index, 5)
self.assertEqual(pos.word_index, 10)
self.assertEqual(pos.table_row, 2)
self.assertEqual(pos.table_col, 3)
def test_position_serialization(self):
"""Test position serialization and deserialization"""
pos = RenderingPosition(
chapter_index=1,
block_index=5,
word_index=10,
remaining_pretext="test"
)
# Serialize to dict
pos_dict = pos.to_dict()
self.assertIsInstance(pos_dict, dict)
self.assertEqual(pos_dict['chapter_index'], 1)
self.assertEqual(pos_dict['remaining_pretext'], "test")
# Deserialize from dict
pos2 = RenderingPosition.from_dict(pos_dict)
self.assertEqual(pos, pos2)
def test_position_copy(self):
"""Test position copying"""
pos = RenderingPosition(chapter_index=1, block_index=5)
pos_copy = pos.copy()
self.assertEqual(pos, pos_copy)
self.assertIsNot(pos, pos_copy) # Different objects
# Modify copy
pos_copy.word_index = 10
self.assertNotEqual(pos, pos_copy)
def test_position_equality_and_hashing(self):
"""Test position equality and hashing"""
pos1 = RenderingPosition(chapter_index=1, block_index=5)
pos2 = RenderingPosition(chapter_index=1, block_index=5)
pos3 = RenderingPosition(chapter_index=1, block_index=6)
self.assertEqual(pos1, pos2)
self.assertNotEqual(pos1, pos3)
# Test hashing (for use as dict keys)
pos_dict = {pos1: "test"}
self.assertEqual(pos_dict[pos2], "test") # Should work due to equality
class TestChapterNavigator(unittest.TestCase):
"""Test the ChapterNavigator class"""
def setUp(self):
"""Set up test data"""
self.font = Font()
# Create test blocks with headings
self.blocks = [
Paragraph(self.font), # Block 0
Heading(HeadingLevel.H1, self.font), # Block 1 - Chapter 1
Paragraph(self.font), # Block 2
Heading(HeadingLevel.H2, self.font), # Block 3 - Subsection
Paragraph(self.font), # Block 4
Heading(HeadingLevel.H1, self.font), # Block 5 - Chapter 2
Paragraph(self.font), # Block 6
]
# Add text to headings
self.blocks[1].add_word(Word("Chapter", self.font))
self.blocks[1].add_word(Word("One", self.font))
self.blocks[3].add_word(Word("Subsection", self.font))
self.blocks[3].add_word(Word("A", self.font))
self.blocks[5].add_word(Word("Chapter", self.font))
self.blocks[5].add_word(Word("Two", self.font))
def test_chapter_detection(self):
"""Test that chapters are detected correctly"""
navigator = ChapterNavigator(self.blocks)
self.assertEqual(len(navigator.chapters), 3) # 2 H1s + 1 H2
# Check chapter titles
titles = [chapter.title for chapter in navigator.chapters]
self.assertIn("Chapter One", titles)
self.assertIn("Subsection A", titles)
self.assertIn("Chapter Two", titles)
def test_table_of_contents(self):
"""Test table of contents generation"""
navigator = ChapterNavigator(self.blocks)
toc = navigator.get_table_of_contents()
self.assertEqual(len(toc), 3)
# Check first entry
title, level, position = toc[0]
self.assertEqual(title, "Chapter One")
self.assertEqual(level, HeadingLevel.H1)
self.assertIsInstance(position, RenderingPosition)
def test_chapter_position_lookup(self):
"""Test looking up chapter positions"""
navigator = ChapterNavigator(self.blocks)
pos = navigator.get_chapter_position("Chapter One")
self.assertIsNotNone(pos)
self.assertEqual(pos.chapter_index, 0)
pos = navigator.get_chapter_position("Nonexistent Chapter")
self.assertIsNone(pos)
def test_current_chapter_detection(self):
"""Test detecting current chapter from position"""
navigator = ChapterNavigator(self.blocks)
# Position in first chapter
pos = RenderingPosition(chapter_index=0, block_index=2)
chapter = navigator.get_current_chapter(pos)
self.assertIsNotNone(chapter)
self.assertEqual(chapter.title, "Chapter One")
class TestFontScaler(unittest.TestCase):
"""Test the FontScaler class"""
def test_font_scaling(self):
"""Test font scaling functionality"""
original_font = Font(font_size=12)
# Test no scaling
scaled_font = FontScaler.scale_font(original_font, 1.0)
self.assertEqual(scaled_font.font_size, 12)
# Test 2x scaling
scaled_font = FontScaler.scale_font(original_font, 2.0)
self.assertEqual(scaled_font.font_size, 24)
# Test 0.5x scaling
scaled_font = FontScaler.scale_font(original_font, 0.5)
self.assertEqual(scaled_font.font_size, 6)
# Test minimum size constraint
scaled_font = FontScaler.scale_font(original_font, 0.01)
self.assertGreaterEqual(scaled_font.font_size, 1)
def test_word_spacing_scaling(self):
"""Test word spacing scaling"""
original_spacing = (5, 15)
# Test no scaling
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 1.0)
self.assertEqual(scaled_spacing, (5, 15))
# Test 2x scaling
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 2.0)
self.assertEqual(scaled_spacing, (10, 30))
# Test minimum constraints
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 0.1)
self.assertGreaterEqual(scaled_spacing[0], 1)
self.assertGreaterEqual(scaled_spacing[1], 2)
class TestBookmarkManager(unittest.TestCase):
"""Test the BookmarkManager class"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.document_id = "test_document"
self.bookmark_manager = BookmarkManager(self.document_id, self.temp_dir)
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir)
def test_bookmark_operations(self):
"""Test bookmark add/remove/get operations"""
pos = RenderingPosition(chapter_index=1, block_index=5)
# Add bookmark
self.bookmark_manager.add_bookmark("test_bookmark", pos)
# Get bookmark
retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark")
self.assertEqual(retrieved_pos, pos)
# List bookmarks
bookmarks = self.bookmark_manager.list_bookmarks()
self.assertEqual(len(bookmarks), 1)
self.assertEqual(bookmarks[0][0], "test_bookmark")
self.assertEqual(bookmarks[0][1], pos)
# Remove bookmark
success = self.bookmark_manager.remove_bookmark("test_bookmark")
self.assertTrue(success)
# Verify removal
retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark")
self.assertIsNone(retrieved_pos)
def test_reading_position_persistence(self):
"""Test saving and loading reading position"""
pos = RenderingPosition(chapter_index=2, block_index=10, word_index=5)
# Save position
self.bookmark_manager.save_reading_position(pos)
# Create new manager instance (simulates app restart)
new_manager = BookmarkManager(self.document_id, self.temp_dir)
# Load position
loaded_pos = new_manager.load_reading_position()
self.assertEqual(loaded_pos, pos)
def test_bookmark_persistence(self):
"""Test that bookmarks persist across manager instances"""
pos = RenderingPosition(chapter_index=1, block_index=5)
# Add bookmark
self.bookmark_manager.add_bookmark("persistent_bookmark", pos)
# Create new manager instance
new_manager = BookmarkManager(self.document_id, self.temp_dir)
# Verify bookmark exists
retrieved_pos = new_manager.get_bookmark("persistent_bookmark")
self.assertEqual(retrieved_pos, pos)
class TestEreaderLayoutManager(unittest.TestCase):
"""Test the complete EreaderLayoutManager"""
def setUp(self):
"""Set up test data"""
self.temp_dir = tempfile.mkdtemp()
self.font = Font()
# Create test document with multiple paragraphs and headings
self.blocks = []
# Add a heading
heading = Heading(HeadingLevel.H1, self.font)
heading.add_word(Word("Test", self.font))
heading.add_word(Word("Chapter", self.font))
self.blocks.append(heading)
# Add several paragraphs with multiple words
for i in range(3):
paragraph = Paragraph(self.font)
for j in range(20): # 20 words per paragraph
paragraph.add_word(Word(f"Word{i}_{j}", self.font))
self.blocks.append(paragraph)
self.page_size = (400, 600)
self.document_id = "test_document"
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir)
def test_manager_initialization(self):
"""Test ereader manager initialization"""
# Change to temp directory for bookmarks
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
self.assertEqual(manager.page_size, self.page_size)
self.assertEqual(manager.document_id, self.document_id)
self.assertEqual(manager.font_scale, 1.0)
self.assertIsInstance(manager.current_position, RenderingPosition)
manager.shutdown()
finally:
os.chdir(original_cwd)
def test_font_scaling(self):
"""Test font scaling functionality"""
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
# Test initial scale
self.assertEqual(manager.get_font_scale(), 1.0)
# Test scaling
page = manager.set_font_scale(1.5)
self.assertEqual(manager.get_font_scale(), 1.5)
self.assertIsNotNone(page)
manager.shutdown()
finally:
os.chdir(original_cwd)
def test_table_of_contents(self):
"""Test table of contents functionality"""
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
toc = manager.get_table_of_contents()
self.assertGreater(len(toc), 0)
# Check first entry
title, level, position = toc[0]
self.assertEqual(title, "Test Chapter")
self.assertEqual(level, HeadingLevel.H1)
manager.shutdown()
finally:
os.chdir(original_cwd)
def test_bookmark_functionality(self):
"""Test bookmark functionality"""
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
# Add bookmark
success = manager.add_bookmark("test_bookmark")
self.assertTrue(success)
# List bookmarks
bookmarks = manager.list_bookmarks()
self.assertEqual(len(bookmarks), 1)
self.assertEqual(bookmarks[0][0], "test_bookmark")
# Jump to bookmark (should work even though it's the same position)
page = manager.jump_to_bookmark("test_bookmark")
self.assertIsNotNone(page)
# Remove bookmark
success = manager.remove_bookmark("test_bookmark")
self.assertTrue(success)
manager.shutdown()
def test_progress_tracking(self):
"""Test reading progress tracking"""
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
# Initial progress should be 0
progress = manager.get_reading_progress()
self.assertGreaterEqual(progress, 0.0)
self.assertLessEqual(progress, 1.0)
# Get position info
info = manager.get_position_info()
self.assertIn('position', info)
self.assertIn('progress', info)
self.assertIn('font_scale', info)
manager.shutdown()
finally:
os.chdir(original_cwd)
def test_convenience_function(self):
"""Test the convenience function"""
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = create_ereader_manager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
self.assertIsInstance(manager, EreaderLayoutManager)
self.assertEqual(manager.page_size, self.page_size)
manager.shutdown()
finally:
os.chdir(original_cwd)
if __name__ == '__main__':
unittest.main()

228
tests/test_examples.py Normal file
View File

@ -0,0 +1,228 @@
"""
Unit tests for example scripts.
This test suite validates that all example scripts:
1. Can be imported without errors (syntax checks, import validation)
2. Have valid import statements
3. Can run their main functions without crashing (when applicable)
This helps catch issues like:
- Incorrect import paths
- Missing dependencies
- API breakages that affect examples
"""
import unittest
import importlib.util
import sys
import os
from pathlib import Path
class TestExampleImports(unittest.TestCase):
"""Test that all example scripts can be imported successfully"""
def setUp(self):
"""Set up test fixtures"""
# Get the project root directory
self.project_root = Path(__file__).parent.parent
self.examples_dir = self.project_root / "examples"
# Add project root to Python path if not already there
if str(self.project_root) not in sys.path:
sys.path.insert(0, str(self.project_root))
def _import_module_from_file(self, file_path: Path):
"""
Import a Python module from a file path.
Args:
file_path: Path to the Python file
Returns:
The imported module
Raises:
Any import errors that occur
"""
spec = importlib.util.spec_from_file_location(file_path.stem, file_path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load spec for {file_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[file_path.stem] = module
spec.loader.exec_module(module)
return module
def test_word_selection_highlighting_imports(self):
"""Test word_selection_highlighting.py can be imported"""
example_file = self.examples_dir / "word_selection_highlighting.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
# Verify key components are available
self.assertTrue(hasattr(module, 'draw_highlight'))
self.assertTrue(hasattr(module, 'example_1_single_word_selection'))
self.assertTrue(hasattr(module, 'example_2_range_selection'))
self.assertTrue(hasattr(module, 'example_3_interactive_word_lookup'))
self.assertTrue(hasattr(module, 'example_4_multi_word_annotation'))
self.assertTrue(hasattr(module, 'example_5_link_highlighting'))
except ImportError as e:
self.fail(f"Failed to import word_selection_highlighting.py: {e}")
def test_demo_pagination_imports(self):
"""Test demo_pagination.py can be imported"""
example_file = self.examples_dir / "demo_pagination.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
self.assertTrue(hasattr(module, 'main'))
except ImportError as e:
self.fail(f"Failed to import demo_pagination.py: {e}")
def test_demo_toc_overlay_imports(self):
"""Test demo_toc_overlay.py can be imported"""
example_file = self.examples_dir / "demo_toc_overlay.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
self.assertTrue(hasattr(module, 'main'))
except ImportError as e:
self.fail(f"Failed to import demo_toc_overlay.py: {e}")
def test_demo_settings_overlay_imports(self):
"""Test demo_settings_overlay.py can be imported"""
example_file = self.examples_dir / "demo_settings_overlay.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
self.assertTrue(hasattr(module, 'main'))
except ImportError as e:
self.fail(f"Failed to import demo_settings_overlay.py: {e}")
def test_library_reading_integration_imports(self):
"""Test library_reading_integration.py can be imported"""
example_file = self.examples_dir / "library_reading_integration.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
self.assertTrue(hasattr(module, 'main'))
self.assertTrue(hasattr(module, 'simulate_mode_transition_workflow'))
except ImportError as e:
self.fail(f"Failed to import library_reading_integration.py: {e}")
def test_all_examples_have_correct_dreader_imports(self):
"""
Verify all example scripts use correct import paths for dreader classes.
This test specifically checks that examples don't use outdated import paths
like 'from dreader.application import' when they should use 'from dreader import'.
"""
# Get all Python files in examples directory
example_files = list(self.examples_dir.glob("*.py"))
problematic_imports = []
for example_file in example_files:
# Skip __init__.py and other special files
if example_file.name.startswith('_'):
continue
with open(example_file, 'r') as f:
content = f.read()
# Check for problematic import patterns
if 'from pyWebLayout.io.gesture import' in content:
problematic_imports.append(
f"{example_file.name}: Uses 'from pyWebLayout.io.gesture import' "
f"(should be 'from dreader import')"
)
if 'from dreader.application import EbookReader' in content:
# This is acceptable, but check if TouchEvent/GestureType are also imported correctly
if 'from pyWebLayout.io.gesture import TouchEvent' in content:
problematic_imports.append(
f"{example_file.name}: Mixes dreader.application and pyWebLayout.io.gesture imports"
)
if problematic_imports:
self.fail(
"Found problematic imports in example files:\n" +
"\n".join(f" - {issue}" for issue in problematic_imports)
)
class TestExampleFunctions(unittest.TestCase):
"""Test key functionality in example scripts"""
def test_draw_highlight_function(self):
"""Test the draw_highlight function from word_selection_highlighting"""
from PIL import Image
# Import the module
project_root = Path(__file__).parent.parent
example_file = project_root / "examples" / "word_selection_highlighting.py"
spec = importlib.util.spec_from_file_location("word_selection_highlighting", example_file)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Create a test image
test_image = Image.new('RGBA', (100, 100), (255, 255, 255, 255))
# Test the draw_highlight function
bounds = (10, 10, 50, 20)
result = module.draw_highlight(test_image, bounds)
# Verify the result is an image
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, (100, 100))
self.assertEqual(result.mode, 'RGBA')
class TestExampleDocumentation(unittest.TestCase):
"""Test that examples have proper documentation"""
def test_all_examples_have_docstrings(self):
"""Verify all example scripts have module docstrings"""
project_root = Path(__file__).parent.parent
examples_dir = project_root / "examples"
example_files = [
f for f in examples_dir.glob("*.py")
if not f.name.startswith('_') and f.name not in ['__init__.py']
]
missing_docstrings = []
for example_file in example_files:
spec = importlib.util.spec_from_file_location(example_file.stem, example_file)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
if not module.__doc__ or len(module.__doc__.strip()) < 10:
missing_docstrings.append(example_file.name)
except:
# If module can't be loaded, skip docstring check
# (import test will catch the error)
pass
if missing_docstrings:
self.fail(
f"Examples missing proper docstrings: {', '.join(missing_docstrings)}"
)
if __name__ == '__main__':
unittest.main()

287
tests/test_gesture.py Normal file
View File

@ -0,0 +1,287 @@
"""
Unit tests for gesture event system.
Tests TouchEvent, GestureType, GestureResponse, and HAL integration.
"""
import unittest
from dreader.gesture import (
GestureType,
TouchEvent,
GestureResponse,
ActionType
)
class TestGestureType(unittest.TestCase):
"""Test GestureType enum"""
def test_gesture_types_exist(self):
"""Test all gesture types are defined"""
self.assertEqual(GestureType.TAP.value, "tap")
self.assertEqual(GestureType.LONG_PRESS.value, "long_press")
self.assertEqual(GestureType.SWIPE_LEFT.value, "swipe_left")
self.assertEqual(GestureType.SWIPE_RIGHT.value, "swipe_right")
self.assertEqual(GestureType.SWIPE_UP.value, "swipe_up")
self.assertEqual(GestureType.SWIPE_DOWN.value, "swipe_down")
self.assertEqual(GestureType.PINCH_IN.value, "pinch_in")
self.assertEqual(GestureType.PINCH_OUT.value, "pinch_out")
self.assertEqual(GestureType.DRAG_START.value, "drag_start")
self.assertEqual(GestureType.DRAG_MOVE.value, "drag_move")
self.assertEqual(GestureType.DRAG_END.value, "drag_end")
class TestTouchEvent(unittest.TestCase):
"""Test TouchEvent dataclass"""
def test_init_basic(self):
"""Test basic TouchEvent creation"""
event = TouchEvent(
gesture=GestureType.TAP,
x=450,
y=320
)
self.assertEqual(event.gesture, GestureType.TAP)
self.assertEqual(event.x, 450)
self.assertEqual(event.y, 320)
self.assertIsNone(event.x2)
self.assertIsNone(event.y2)
self.assertEqual(event.timestamp_ms, 0)
def test_init_with_secondary_point(self):
"""Test TouchEvent with secondary point (pinch/drag)"""
event = TouchEvent(
gesture=GestureType.PINCH_OUT,
x=400,
y=300,
x2=450,
y2=350,
timestamp_ms=12345.678
)
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
self.assertEqual(event.x, 400)
self.assertEqual(event.y, 300)
self.assertEqual(event.x2, 450)
self.assertEqual(event.y2, 350)
self.assertEqual(event.timestamp_ms, 12345.678)
def test_from_hal_basic(self):
"""Test parsing TouchEvent from HAL format"""
hal_data = {
'gesture': 'tap',
'x': 450,
'y': 320
}
event = TouchEvent.from_hal(hal_data)
self.assertEqual(event.gesture, GestureType.TAP)
self.assertEqual(event.x, 450)
self.assertEqual(event.y, 320)
def test_from_hal_complete(self):
"""Test parsing TouchEvent with all fields from HAL"""
hal_data = {
'gesture': 'pinch_out',
'x': 400,
'y': 300,
'x2': 450,
'y2': 350,
'timestamp': 12345.678
}
event = TouchEvent.from_hal(hal_data)
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
self.assertEqual(event.x, 400)
self.assertEqual(event.y, 300)
self.assertEqual(event.x2, 450)
self.assertEqual(event.y2, 350)
self.assertEqual(event.timestamp_ms, 12345.678)
def test_to_dict(self):
"""Test TouchEvent serialization"""
event = TouchEvent(
gesture=GestureType.SWIPE_LEFT,
x=600,
y=400,
timestamp_ms=12345.0
)
d = event.to_dict()
self.assertEqual(d['gesture'], 'swipe_left')
self.assertEqual(d['x'], 600)
self.assertEqual(d['y'], 400)
self.assertIsNone(d['x2'])
self.assertIsNone(d['y2'])
self.assertEqual(d['timestamp_ms'], 12345.0)
class TestGestureResponse(unittest.TestCase):
"""Test GestureResponse dataclass"""
def test_init(self):
"""Test GestureResponse creation"""
response = GestureResponse(
action="page_turn",
data={"direction": "forward", "progress": 0.42}
)
self.assertEqual(response.action, "page_turn")
self.assertEqual(response.data['direction'], "forward")
self.assertEqual(response.data['progress'], 0.42)
def test_to_dict(self):
"""Test GestureResponse serialization"""
response = GestureResponse(
action="define",
data={"word": "ephemeral", "bounds": (100, 200, 50, 20)}
)
d = response.to_dict()
self.assertEqual(d['action'], "define")
self.assertEqual(d['data']['word'], "ephemeral")
self.assertEqual(d['data']['bounds'], (100, 200, 50, 20))
def test_to_dict_empty_data(self):
"""Test GestureResponse with empty data"""
response = GestureResponse(action="none", data={})
d = response.to_dict()
self.assertEqual(d['action'], "none")
self.assertEqual(d['data'], {})
class TestActionType(unittest.TestCase):
"""Test ActionType constants"""
def test_action_types_defined(self):
"""Test all action type constants are defined"""
self.assertEqual(ActionType.NONE, "none")
self.assertEqual(ActionType.PAGE_TURN, "page_turn")
self.assertEqual(ActionType.NAVIGATE, "navigate")
self.assertEqual(ActionType.DEFINE, "define")
self.assertEqual(ActionType.SELECT, "select")
self.assertEqual(ActionType.ZOOM, "zoom")
self.assertEqual(ActionType.BOOK_LOADED, "book_loaded")
self.assertEqual(ActionType.WORD_SELECTED, "word_selected")
self.assertEqual(ActionType.SHOW_MENU, "show_menu")
self.assertEqual(ActionType.SELECTION_START, "selection_start")
self.assertEqual(ActionType.SELECTION_UPDATE, "selection_update")
self.assertEqual(ActionType.SELECTION_COMPLETE, "selection_complete")
self.assertEqual(ActionType.AT_START, "at_start")
self.assertEqual(ActionType.AT_END, "at_end")
self.assertEqual(ActionType.ERROR, "error")
class TestHALIntegration(unittest.TestCase):
"""Test HAL integration scenarios"""
def test_hal_tap_flow(self):
"""Test complete HAL tap event flow"""
# Simulate HAL sending tap event
hal_data = {
'gesture': 'tap',
'x': 450,
'y': 320,
'timestamp': 1234567890.123
}
# Parse event
event = TouchEvent.from_hal(hal_data)
# Verify event
self.assertEqual(event.gesture, GestureType.TAP)
self.assertEqual(event.x, 450)
self.assertEqual(event.y, 320)
# Simulate business logic response
response = GestureResponse(
action=ActionType.WORD_SELECTED,
data={"word": "hello", "bounds": (440, 310, 50, 20)}
)
# Serialize for Flask
response_dict = response.to_dict()
self.assertEqual(response_dict['action'], "word_selected")
self.assertEqual(response_dict['data']['word'], "hello")
def test_hal_pinch_flow(self):
"""Test complete HAL pinch event flow"""
# Simulate HAL sending pinch event with two touch points
hal_data = {
'gesture': 'pinch_out',
'x': 400,
'y': 500,
'x2': 500,
'y2': 500,
'timestamp': 1234567891.456
}
event = TouchEvent.from_hal(hal_data)
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
self.assertEqual(event.x, 400)
self.assertEqual(event.x2, 500)
def test_hal_swipe_flow(self):
"""Test complete HAL swipe event flow"""
hal_data = {
'gesture': 'swipe_left',
'x': 600,
'y': 400
}
event = TouchEvent.from_hal(hal_data)
self.assertEqual(event.gesture, GestureType.SWIPE_LEFT)
# Expected response
response = GestureResponse(
action=ActionType.PAGE_TURN,
data={"direction": "forward", "progress": 0.25}
)
self.assertEqual(response.action, "page_turn")
def test_hal_drag_selection_flow(self):
"""Test complete drag selection flow"""
# Drag start
start_data = {
'gesture': 'drag_start',
'x': 100,
'y': 200
}
start_event = TouchEvent.from_hal(start_data)
self.assertEqual(start_event.gesture, GestureType.DRAG_START)
# Drag move
move_data = {
'gesture': 'drag_move',
'x': 300,
'y': 250
}
move_event = TouchEvent.from_hal(move_data)
self.assertEqual(move_event.gesture, GestureType.DRAG_MOVE)
# Drag end
end_data = {
'gesture': 'drag_end',
'x': 500,
'y': 300
}
end_event = TouchEvent.from_hal(end_data)
self.assertEqual(end_event.gesture, GestureType.DRAG_END)
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,250 @@
"""
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 unittest.mock import MagicMock
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 with pagination"""
books = self.library.scan_library()
table = self.library.create_library_table()
# Table should exist
self.assertIsNotNone(table)
# Table should have body rows for 2-column grid layout
# With pagination, we only show books_per_page books, not all books
# Calculate expected rows based on current page's books
books_on_page = min(self.library.books_per_page, len(books) - (self.library.current_page * self.library.books_per_page))
# Each pair of books gets 2 rows (cover row + detail row)
expected_rows = ((books_on_page + 1) // 2) * 2
body_rows = list(table.body_rows())
self.assertEqual(len(body_rows), expected_rows)
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 with 2-column grid layout"""
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()
# In 2-column layout:
# Books 0 and 1 are in the first pair (rows 0-1: cover and detail)
# Books 2 and 3 are in the second pair (rows 2-3: cover and detail)
# Tap first book (left column, first pair cover row)
path1 = self.library.handle_library_tap(x=100, y=100)
self.assertEqual(path1, books[0]['path'])
# Tap second book (right column, first pair cover row)
path2 = self.library.handle_library_tap(x=500, y=100)
self.assertEqual(path2, books[1]['path'])
# Tap third book (left column, second pair cover row)
path3 = self.library.handle_library_tap(x=100, y=360)
self.assertEqual(path3, books[2]['path'])
# All should be different
self.assertNotEqual(path1, path2)
self.assertNotEqual(path2, path3)
self.assertNotEqual(path1, path3)
def test_pagination(self):
"""Test library pagination with fake book data"""
# Create fake books (20 books to ensure multiple pages)
fake_books = []
for i in range(20):
fake_books.append({
'path': f'/fake/path/book_{i}.epub',
'title': f'Book Title {i}',
'author': f'Author {i}',
'filename': f'book_{i}.epub',
'cover_data': None,
'cover_path': None
})
# Create library with 6 books per page
library = LibraryManager(
library_path=str(self.library_path),
page_size=(800, 1200),
books_per_page=6
)
library.books = fake_books
# Test initial state
self.assertEqual(library.current_page, 0)
self.assertEqual(library.get_total_pages(), 4) # 20 books / 6 per page = 4 pages
# Test creating table for first page
table = library.create_library_table()
self.assertIsNotNone(table)
# 6 books = 3 pairs = 6 rows (3 cover rows + 3 detail rows)
body_rows = list(table.body_rows())
self.assertEqual(len(body_rows), 6)
# Test navigation to next page
self.assertTrue(library.next_page())
self.assertEqual(library.current_page, 1)
# Create table for second page
table = library.create_library_table()
body_rows = list(table.body_rows())
self.assertEqual(len(body_rows), 6) # Still 6 books on page 2
# Test navigation to last page
library.set_page(3)
self.assertEqual(library.current_page, 3)
table = library.create_library_table()
body_rows = list(table.body_rows())
# Page 4 has 2 books (20 - 18 = 2) = 1 pair = 2 rows
self.assertEqual(len(body_rows), 2)
# Test can't go beyond last page
self.assertFalse(library.next_page())
self.assertEqual(library.current_page, 3)
# Test navigation to previous page
self.assertTrue(library.previous_page())
self.assertEqual(library.current_page, 2)
# Test navigation to first page
library.set_page(0)
self.assertEqual(library.current_page, 0)
# Test can't go before first page
self.assertFalse(library.previous_page())
self.assertEqual(library.current_page, 0)
# Test invalid page number
self.assertFalse(library.set_page(-1))
self.assertFalse(library.set_page(100))
self.assertEqual(library.current_page, 0) # Should stay on current page
library.cleanup()
if __name__ == '__main__':
unittest.main()

Some files were not shown because too many files have changed in this diff Show More