Update coverage badges [skip ci]
31
.coveragerc
Normal 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
@ -0,0 +1,2 @@
|
|||||||
|
# Mark EPUB files as binary to prevent any text transformations
|
||||||
|
*.epub binary
|
||||||
183
.gitea/workflows/ci.yml
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
230
HARDWARE_PINOUT.md
Executable 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
@ -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
@ -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
@ -0,0 +1,571 @@
|
|||||||
|
# DReader Application
|
||||||
|
|
||||||
|
## Project Status
|
||||||
|
|
||||||
|
| Badge | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
|  | **Test Coverage** - Percentage of code covered by unit tests |
|
||||||
|
|  | **Documentation Coverage** - Percentage of code with docstrings |
|
||||||
|
|  | **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
@ -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
|
||||||
58
cov_info/coverage-docs.svg
Normal 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
@ -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
@ -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
@ -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()
|
||||||
BIN
docs/images/ereader_bookmarks.gif
Normal file
|
After Width: | Height: | Size: 471 KiB |
BIN
docs/images/ereader_chapter_navigation.gif
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
docs/images/ereader_font_size.gif
Normal file
|
After Width: | Height: | Size: 579 KiB |
BIN
docs/images/ereader_highlighting.gif
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/images/ereader_page_navigation.gif
Normal file
|
After Width: | Height: | Size: 416 KiB |
BIN
docs/images/library_reading_demo.gif
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
docs/images/settings_overlay_demo.gif
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
docs/images/toc_overlay_demo.gif
Normal file
|
After Width: | Height: | Size: 28 KiB |
66
dreader/__init__.py
Normal 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
253
dreader/book_utils.py
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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()
|
||||||
10
dreader/handlers/__init__.py
Normal 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']
|
||||||
283
dreader/handlers/gestures.py
Normal 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
@ -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
@ -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
@ -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
|
||||||
14
dreader/managers/__init__.py
Normal 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']
|
||||||
140
dreader/managers/document.py
Normal 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
|
||||||
211
dreader/managers/highlight_coordinator.py
Normal 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
|
||||||
299
dreader/managers/settings.py
Normal 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
|
||||||
21
dreader/overlays/__init__.py
Normal 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
@ -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
|
||||||
311
dreader/overlays/navigation.py
Normal 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)
|
||||||
251
dreader/overlays/settings.py
Normal 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
@ -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
@ -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
|
||||||
363
examples/calibrate_accelerometer.py
Executable 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/s²
|
||||||
|
"""
|
||||||
|
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)
|
||||||
212
examples/demo_accelerometer_page_flip.py
Executable 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)
|
||||||
118
examples/demo_accelerometer_simple.py
Executable 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
@ -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()
|
||||||
470
examples/demo_settings_overlay.py
Normal 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
@ -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
@ -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()
|
||||||
422
examples/generate_ereader_gifs.py
Normal 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()
|
||||||
341
examples/generate_library_demo_gif.py
Executable 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()
|
||||||
400
examples/library_reading_integration.py
Executable 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()
|
||||||
196
examples/navigation_overlay_example.py
Normal 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()
|
||||||
171
examples/persistent_settings_example.py
Executable 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
@ -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)
|
||||||
293
examples/run_on_hardware_config.py
Executable 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)
|
||||||
96
examples/simple_ereader_example.py
Normal 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()
|
||||||
55
examples/simple_word_highlight.py
Normal 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
@ -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()
|
||||||
360
examples/word_selection_highlighting.py
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
After Width: | Height: | Size: 121 KiB |
BIN
tests/data/cover 2.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tests/data/cover 3.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
tests/data/cover 4.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
tests/data/library-epub/minimal-test.epub
Normal file
BIN
tests/data/library-epub/pg11-images-3.epub
Normal file
BIN
tests/data/library-epub/pg16328-images-3.epub
Normal file
BIN
tests/data/library-epub/pg2554-images-3.epub
Normal file
BIN
tests/data/library-epub/pg34901-images-3.epub
Normal file
BIN
tests/data/library-epub/pg43-images-3.epub
Normal file
BIN
tests/data/library-epub/pg5200-images-3.epub
Normal file
BIN
tests/data/library-epub/pg64317-images-3.epub
Normal file
BIN
tests/data/sample_image.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
tests/data/test.epub
Normal file
284
tests/test_accelerometer_gestures.py
Normal 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"])
|
||||||
85
tests/test_backward_nav_minimal.py
Normal 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()
|
||||||
239
tests/test_backward_navigation_detailed.py
Normal 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()
|
||||||
230
tests/test_backward_navigation_resume.py
Normal 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
@ -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
@ -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!")
|
||||||
832
tests/test_ereader_application.py
Normal 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()
|
||||||
316
tests/test_ereader_highlighting.py
Normal 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()
|
||||||
455
tests/test_ereader_system.py
Normal 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
@ -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
@ -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()
|
||||||
250
tests/test_library_interaction.py
Normal 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()
|
||||||