commit 4971c0fc595a41ad8c5b0d2622af5908fec8196c Author: Gitea Action Date: Wed Nov 12 18:52:08 2025 +0000 Update coverage badges [skip ci] diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..2c5da47 --- /dev/null +++ b/.coveragerc @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2efe7e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Mark EPUB files as binary to prevent any text transformations +*.epub binary diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..fbde196 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8bbf5b --- /dev/null +++ b/.gitignore @@ -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* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..39d8d48 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/dreader-hal"] + path = external/dreader-hal + url = https://gitea.tourolle.paris/dtourolle/dreader-hal diff --git a/ACCELEROMETER_PAGE_FLIP.md b/ACCELEROMETER_PAGE_FLIP.md new file mode 100644 index 0000000..364ebcd --- /dev/null +++ b/ACCELEROMETER_PAGE_FLIP.md @@ -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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..80343d5 --- /dev/null +++ b/ARCHITECTURE.md @@ -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 diff --git a/GPIO_BUTTONS.md b/GPIO_BUTTONS.md new file mode 100644 index 0000000..2481841 --- /dev/null +++ b/GPIO_BUTTONS.md @@ -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 diff --git a/HAL_IMPLEMENTATION_SPEC.md b/HAL_IMPLEMENTATION_SPEC.md new file mode 100644 index 0000000..f615d2c --- /dev/null +++ b/HAL_IMPLEMENTATION_SPEC.md @@ -0,0 +1,1303 @@ +# DReader Hardware Abstraction Layer (HAL) Implementation Specification + +**Version**: 1.0 +**Date**: 2025-11-09 +**Target**: Hardware driver developers implementing platform-specific HALs + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Architecture](#2-architecture) +3. [HAL Interface Specification](#3-hal-interface-specification) +4. [Touch Event & Gesture System](#4-touch-event--gesture-system) +5. [Implementation Requirements](#5-implementation-requirements) +6. [Testing & Validation](#6-testing--validation) +7. [Reference Implementation](#7-reference-implementation) +8. [Platform-Specific Considerations](#8-platform-specific-considerations) +9. [Example Implementations](#9-example-implementations) + +--- + +## 1. Overview + +### 1.1 Purpose + +The DReader HAL provides a **platform-independent abstraction** for: +- **Display output**: Rendering PIL images to hardware screen +- **Touch input**: Capturing touch/mouse events and converting to gestures +- **Hardware control**: Brightness, power management, etc. + +### 1.2 Design Goals + +- **Portability**: Same application code runs on different hardware platforms +- **Async-first**: All operations are async for non-blocking I/O +- **Testability**: Easy desktop testing before deploying to device +- **Simplicity**: Minimal interface with clear contracts + +### 1.3 Key Responsibilities + +**What the HAL MUST do:** +- Display PIL Images on physical screen +- Detect touch events and classify gestures +- Return standardized `TouchEvent` objects +- Handle platform-specific initialization/cleanup + +**What the HAL does NOT do:** +- Application logic (handled by `EbookReader` and `DReaderApplication`) +- Gesture routing (handled by `GestureRouter`) +- State management (handled by `StateManager`) +- Content rendering (handled by pyWebLayout) + +--- + +## 2. Architecture + +### 2.1 System Layers + +``` +┌─────────────────────────────────────────────────────────┐ +│ DReaderApplication (main.py) │ +│ - State management, mode switching, event routing │ +└───────────────────────┬─────────────────────────────────┘ + │ + │ Uses DisplayHAL interface + ↓ +┌─────────────────────────────────────────────────────────┐ +│ DisplayHAL (Abstract Interface) │ +│ - show_image(PIL.Image) │ +│ - get_touch_event() -> TouchEvent │ +│ - set_brightness(int) │ +│ - initialize() / cleanup() │ +└───────────────────────┬─────────────────────────────────┘ + │ + │ Implemented by + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Platform-Specific HAL Implementation │ +│ │ +│ Examples: │ +│ • PygameDisplayHAL (Desktop/Testing) │ +│ • EinkDisplayHAL (E-ink devices) │ +│ • KoboDisplayHAL (Kobo readers) │ +│ • RemarkableDisplayHAL (reMarkable tablets) │ +│ • [Your Custom HAL] │ +└───────────────────────┬─────────────────────────────────┘ + │ + │ Controls + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Hardware Drivers │ +│ • Framebuffer / Display controller │ +│ • Touch sensor / Digitizer │ +│ • Backlight / Frontlight │ +│ • Power management │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 Event Flow + +``` +Hardware Touch Sensor + ↓ + HAL Driver + ↓ + Touch Detection + ↓ + Gesture Classification ← HAL responsibility + ↓ + TouchEvent + ↓ +DReaderApplication.handle_touch() + ↓ + GestureRouter + ↓ + Gesture Handlers + ↓ + GestureResponse + ↓ + Display Update + ↓ +HAL.show_image() + ↓ + Hardware Display +``` + +--- + +## 3. HAL Interface Specification + +### 3.1 Base Interface: `DisplayHAL` + +**File**: `dreader/hal.py` + +```python +from abc import ABC, abstractmethod +from typing import Optional +from PIL import Image +from dreader.gesture import TouchEvent + +class DisplayHAL(ABC): + """Abstract interface for display and input hardware""" +``` + +### 3.2 Required Methods + +#### 3.2.1 `show_image(image: PIL.Image.Image)` ⭐ CRITICAL + +**Purpose**: Display a PIL Image on the physical screen + +**Contract**: +```python +async def show_image(self, image: Image.Image): + """ + Display a PIL Image on the screen. + + Args: + image: PIL Image to display (typically RGB mode) + + Requirements: + - MUST handle image format conversion if needed + - MUST handle resolution mismatch (scale/crop) + - MUST update physical display hardware + - SHOULD complete within 100ms for responsiveness + - SHOULD support e-ink optimization if applicable + + Image Specifications: + - Format: Usually RGB (may be 'L' for grayscale) + - Size: Typically matches page_size config (e.g., 800x1200) + - Color depth: 8-bit per channel RGB + """ + pass +``` + +**Implementation Notes**: +- **E-ink devices**: Convert to grayscale, apply dithering, use appropriate refresh mode +- **Color displays**: May need RGB↔BGR conversion depending on driver +- **Framebuffer**: May need to write directly to `/dev/fb0` or use platform API +- **Performance**: Cache converted images if format conversion is expensive + +**Example (Pseudocode)**: +```python +async def show_image(self, image: Image.Image): + # Convert to device format + if self.is_eink: + image = image.convert('L') # Grayscale + image = apply_dithering(image) + + # Resize if needed + if image.size != (self.width, self.height): + image = image.resize((self.width, self.height), Image.LANCZOS) + + # Write to framebuffer or display API + self.framebuffer.write(image) + + # Refresh display + self.display_controller.refresh() +``` + +--- + +#### 3.2.2 `get_touch_event() -> Optional[TouchEvent]` ⭐ CRITICAL + +**Purpose**: Wait for and return the next touch event with gesture classification + +**Contract**: +```python +async def get_touch_event(self) -> Optional[TouchEvent]: + """ + Get the next touch event from hardware. + + Returns: + TouchEvent if available, None if no event + + Requirements: + - MUST classify gesture type (tap, swipe, long press, etc.) + - MUST return None immediately if no event (non-blocking) + - SHOULD detect gestures accurately with sensible thresholds + - MUST provide accurate pixel coordinates + - MAY implement timeouts for responsiveness + + Gesture Detection Thresholds (Recommended): + - Tap: < 30px movement, < 300ms duration + - Long press: < 30px movement, >= 500ms duration + - Swipe: >= 30px movement, < 500ms duration + - Drag: >= 30px movement, sustained motion + - Pinch: Two-finger distance change + + Coordinate System: + - Origin (0, 0) = top-left corner + - X increases right + - Y increases down + - Range: (0, 0) to (width-1, height-1) + """ + pass +``` + +**TouchEvent Format**: +```python +@dataclass +class TouchEvent: + gesture: GestureType # Required: gesture classification + x: int # Required: primary touch X + y: int # Required: primary touch Y + x2: Optional[int] = None # Optional: secondary touch X (pinch) + y2: Optional[int] = None # Optional: secondary touch Y (pinch) + timestamp_ms: float = 0 # Optional: timestamp +``` + +**Implementation Pattern**: +```python +async def get_touch_event(self) -> Optional[TouchEvent]: + # Read from touch hardware + touch_data = await self.read_touch_sensor() + + if not touch_data: + return None + + # Track touch state + if touch_data.is_down: + self._touch_start = (touch_data.x, touch_data.y) + self._touch_start_time = time.time() + + elif touch_data.is_up: + # Calculate gesture + dx = touch_data.x - self._touch_start[0] + dy = touch_data.y - self._touch_start[1] + distance = (dx**2 + dy**2) ** 0.5 + duration = time.time() - self._touch_start_time + + # Classify gesture + if distance < 30: + if duration >= 0.5: + gesture = GestureType.LONG_PRESS + else: + gesture = GestureType.TAP + else: + # Swipe direction + if abs(dx) > abs(dy): + gesture = GestureType.SWIPE_LEFT if dx < 0 else GestureType.SWIPE_RIGHT + else: + gesture = GestureType.SWIPE_UP if dy < 0 else GestureType.SWIPE_DOWN + + return TouchEvent(gesture, touch_data.x, touch_data.y) + + return None +``` + +--- + +#### 3.2.3 `set_brightness(level: int)` + +**Purpose**: Control display brightness/frontlight + +**Contract**: +```python +async def set_brightness(self, level: int): + """ + Set display brightness. + + Args: + level: Brightness level (0-10) + 0 = dimmest (may be off) + 10 = brightest + + Requirements: + - MUST accept range 0-10 + - SHOULD map to hardware's native range + - MAY be no-op if brightness control unavailable + - SHOULD persist setting if supported by hardware + """ + pass +``` + +**Implementation Notes**: +- Map 0-10 to hardware range (e.g., 0-255) +- E-ink frontlight: Control LED PWM or similar +- LCD backlight: Control via sysfs or platform API +- Desktop: May be no-op or control window alpha + +--- + +#### 3.2.4 `initialize()` (Optional) + +**Purpose**: One-time hardware initialization + +**Contract**: +```python +async def initialize(self): + """ + Initialize the display hardware. + + Called once before application starts. + + Typical Tasks: + - Open framebuffer device + - Initialize touch sensor + - Configure display controller + - Set default brightness + - Calibrate touch screen + - Clear screen to white/black + """ + pass +``` + +--- + +#### 3.2.5 `cleanup()` (Optional) + +**Purpose**: Hardware cleanup on shutdown + +**Contract**: +```python +async def cleanup(self): + """ + Clean up display hardware resources. + + Called during application shutdown. + + Typical Tasks: + - Close file descriptors + - Restore default display state + - Power down display + - Release hardware locks + """ + pass +``` + +--- + +#### 3.2.6 `show_message(message: str, duration: float)` (Optional) + +**Purpose**: Display simple text messages (loading screens, errors) + +**Contract**: +```python +async def show_message(self, message: str, duration: float = 2.0): + """ + Display a text message. + + Args: + message: Text to display + duration: How long to show (seconds) + + Default implementation provided (creates PIL Image with text). + Override for platform-specific message display. + """ + pass +``` + +--- + +### 3.3 Extended Interface: `EventLoopHAL` + +**Purpose**: For platforms that provide their own event loop (Pygame, Qt, Tkinter) + +**File**: `dreader/hal.py` + +```python +class EventLoopHAL(DisplayHAL): + """Extended HAL that provides its own event loop""" + + @abstractmethod + async def run_event_loop(self, app): + """ + Run the platform's event loop. + + Args: + app: DReaderApplication instance + + Typical Flow: + 1. await self.initialize() + 2. await app.start() + 3. Enter event loop: + a. Get events + b. await app.handle_touch(event) + c. Handle quit/close + 4. await app.shutdown() + 5. await self.cleanup() + """ + pass +``` + +**Use Case**: When the platform requires control of the main loop (e.g., Pygame, Qt) + +--- + +## 4. Touch Event & Gesture System + +### 4.1 GestureType Enumeration + +**File**: `dreader/gesture.py` + +All gestures that HAL must be able to detect: + +```python +class GestureType(Enum): + TAP = "tap" # Single finger tap + LONG_PRESS = "long_press" # Hold for 500ms+ + SWIPE_LEFT = "swipe_left" # Horizontal swipe left + SWIPE_RIGHT = "swipe_right" # Horizontal swipe right + SWIPE_UP = "swipe_up" # Vertical swipe up + SWIPE_DOWN = "swipe_down" # Vertical swipe down + PINCH_IN = "pinch_in" # Two-finger pinch (zoom out) + PINCH_OUT = "pinch_out" # Two-finger spread (zoom in) + DRAG_START = "drag_start" # Start dragging + DRAG_MOVE = "drag_move" # Continue dragging + DRAG_END = "drag_end" # End dragging +``` + +### 4.2 Gesture Priority & Requirements + +#### 4.2.1 Essential Gestures (MUST implement) + +| Gesture | Usage | Detection | +|---------|-------|-----------| +| **TAP** | Link/button activation, word selection | < 30px movement, < 300ms | +| **SWIPE_LEFT** | Next page | Horizontal, dx < -30px | +| **SWIPE_RIGHT** | Previous page | Horizontal, dx > 30px | +| **SWIPE_UP** | Open navigation/TOC | Vertical, dy < -30px | +| **SWIPE_DOWN** | Open settings (from top 20%) | Vertical, dy > 30px | + +#### 4.2.2 Important Gestures (SHOULD implement) + +| Gesture | Usage | Detection | +|---------|-------|-----------| +| **LONG_PRESS** | Word definition, context menu | < 30px movement, >= 500ms | +| **PINCH_OUT** | Increase font size | Two-finger distance increase | +| **PINCH_IN** | Decrease font size | Two-finger distance decrease | + +#### 4.2.3 Advanced Gestures (MAY implement) + +| Gesture | Usage | Detection | +|---------|-------|-----------| +| **DRAG_START/MOVE/END** | Text selection | Sustained motion >= 30px | + +### 4.3 Gesture Detection Algorithm + +#### 4.3.1 State Machine + +``` +IDLE + ↓ touch_down +TOUCHING + ↓ timeout(500ms) → LONG_PRESS + ↓ movement > 30px → MOVING + ↓ touch_up → TAP +MOVING + ↓ touch_up → SWIPE_* + ↓ sustained → DRAG_* +``` + +#### 4.3.2 Recommended Thresholds + +```python +# Distance thresholds +TAP_THRESHOLD = 30 # pixels +SWIPE_MIN_DISTANCE = 30 # pixels +DRAG_THRESHOLD = 30 # pixels + +# Time thresholds +LONG_PRESS_DURATION = 0.5 # seconds (500ms) +TAP_MAX_DURATION = 0.3 # seconds (300ms) +SWIPE_MAX_DURATION = 0.5 # seconds + +# Direction thresholds +SWIPE_ANGLE_THRESHOLD = 45 # degrees (for horizontal vs vertical) +``` + +#### 4.3.3 Pseudocode + +```python +class GestureDetector: + def on_touch_down(self, x, y): + self.start_pos = (x, y) + self.start_time = time.now() + self.state = TOUCHING + + def on_touch_move(self, x, y): + dx = x - self.start_pos[0] + dy = y - self.start_pos[1] + distance = sqrt(dx**2 + dy**2) + + if distance > TAP_THRESHOLD and self.state == TOUCHING: + self.state = MOVING + # Could emit DRAG_START here + + def on_touch_up(self, x, y): + dx = x - self.start_pos[0] + dy = y - self.start_pos[1] + distance = sqrt(dx**2 + dy**2) + duration = time.now() - self.start_time + + if distance < TAP_THRESHOLD: + if duration >= LONG_PRESS_DURATION: + return GestureType.LONG_PRESS + else: + return GestureType.TAP + else: + # Determine swipe direction + if abs(dx) > abs(dy): # Horizontal + if dx > 0: + return GestureType.SWIPE_RIGHT + else: + return GestureType.SWIPE_LEFT + else: # Vertical + if dy > 0: + return GestureType.SWIPE_DOWN + else: + return GestureType.SWIPE_UP +``` + +### 4.4 TouchEvent Construction + +```python +# Example: Creating a TAP event +event = TouchEvent( + gesture=GestureType.TAP, + x=450, + y=320, + timestamp_ms=time.time() * 1000 +) + +# Example: Creating a SWIPE event +event = TouchEvent( + gesture=GestureType.SWIPE_LEFT, + x=600, # End position + y=400, + timestamp_ms=time.time() * 1000 +) + +# Example: Creating a PINCH event (two fingers) +event = TouchEvent( + gesture=GestureType.PINCH_OUT, + x=400, # Finger 1 + y=500, + x2=600, # Finger 2 + y2=700, + timestamp_ms=time.time() * 1000 +) +``` + +--- + +## 5. Implementation Requirements + +### 5.1 Mandatory Features + +✅ **MUST implement**: +- [ ] `show_image()` - Display PIL Images +- [ ] `get_touch_event()` - Return TouchEvents +- [ ] Basic gesture detection (TAP, SWIPE_LEFT/RIGHT/UP/DOWN) +- [ ] Correct coordinate system (0,0 = top-left) +- [ ] Async/await support for all methods + +### 5.2 Recommended Features + +⭐ **SHOULD implement**: +- [ ] `initialize()` and `cleanup()` - Proper lifecycle management +- [ ] `set_brightness()` - Brightness control +- [ ] LONG_PRESS gesture +- [ ] PINCH_IN/OUT gestures (if multitouch available) +- [ ] Efficient image format conversion +- [ ] E-ink optimization (partial refresh, dithering) + +### 5.3 Optional Features + +💡 **MAY implement**: +- [ ] `show_message()` override - Custom loading screens +- [ ] DRAG gestures - Text selection +- [ ] Touch pressure sensitivity +- [ ] Multi-touch tracking +- [ ] Hardware acceleration +- [ ] Custom refresh modes (e-ink A2/DU/GC16) + +### 5.4 Performance Targets + +| Metric | Target | Critical | +|--------|--------|----------| +| Image display latency | < 100ms | < 500ms | +| Touch event latency | < 50ms | < 200ms | +| Frame rate (color) | 30 FPS | 10 FPS | +| E-ink refresh (fast) | < 200ms | < 500ms | +| E-ink refresh (full) | < 1000ms | < 2000ms | +| Gesture detection accuracy | > 95% | > 80% | + +--- + +## 6. Testing & Validation + +### 6.1 Unit Tests + +Test each HAL method in isolation: + +```python +import pytest +from your_hal import YourDisplayHAL + +@pytest.mark.asyncio +async def test_show_image(): + hal = YourDisplayHAL() + await hal.initialize() + + # Create test image + img = Image.new('RGB', (800, 1200), color=(255, 0, 0)) + + # Should not raise + await hal.show_image(img) + + await hal.cleanup() + +@pytest.mark.asyncio +async def test_gesture_detection(): + hal = YourDisplayHAL() + await hal.initialize() + + # Simulate tap + event = await hal.get_touch_event() + assert event.gesture == GestureType.TAP + assert 0 <= event.x < hal.width + assert 0 <= event.y < hal.height +``` + +### 6.2 Integration Tests + +Test with actual DReaderApplication: + +```python +from dreader.main import DReaderApplication, AppConfig + +async def test_integration(): + hal = YourDisplayHAL() + config = AppConfig( + display_hal=hal, + library_path="./test_books", + page_size=(800, 1200) + ) + app = DReaderApplication(config) + + await app.start() + + # Simulate touch + event = TouchEvent(GestureType.TAP, 400, 600) + await app.handle_touch(event) + + await app.shutdown() +``` + +### 6.3 Manual Test Checklist + +- [ ] Display shows full-screen images without distortion +- [ ] Tap on link navigates to chapter +- [ ] Swipe left advances page +- [ ] Swipe right goes back page +- [ ] Swipe up opens TOC overlay +- [ ] Swipe down (from top) opens settings +- [ ] Long press on word shows definition +- [ ] Pinch gestures change font size (if supported) +- [ ] Brightness control works (if supported) +- [ ] No memory leaks after 100+ page turns +- [ ] No display artifacts or tearing + +### 6.4 Validation Tools + +**Debug Logging**: +```python +import logging +logger = logging.getLogger(__name__) + +async def show_image(self, image): + logger.info(f"Displaying image: {image.size}, {image.mode}") + # ... implementation + +async def get_touch_event(self): + event = # ... detect gesture + logger.info(f"Gesture detected: {event.gesture} at ({event.x}, {event.y})") + return event +``` + +**Touch Coordinate Visualization**: +- Draw red dot at touch coordinates +- Display gesture type as text overlay +- Show swipe trajectory lines + +--- + +## 7. Reference Implementation + +### 7.1 PygameDisplayHAL + +**File**: `dreader/hal_pygame.py` + +The Pygame HAL is a **complete reference implementation** for desktop testing. + +**Key features**: +- Mouse → touch event conversion +- Gesture detection algorithm +- Keyboard shortcuts +- Debug logging + +**Study this implementation for**: +- Gesture detection state machine +- Coordinate handling +- Event loop structure +- Error handling patterns + +### 7.2 Minimal HAL Template + +```python +from dreader.hal import DisplayHAL +from dreader.gesture import TouchEvent, GestureType +from PIL import Image +from typing import Optional +import asyncio + +class MinimalHAL(DisplayHAL): + """Minimal HAL implementation template""" + + def __init__(self, width: int = 800, height: int = 1200): + self.width = width + self.height = height + + # Gesture tracking + self._touch_start = None + self._touch_start_time = 0 + + async def initialize(self): + """Initialize hardware""" + # TODO: Open framebuffer, initialize touch sensor + pass + + async def cleanup(self): + """Cleanup hardware""" + # TODO: Close file descriptors, cleanup + pass + + async def show_image(self, image: Image.Image): + """Display image on screen""" + # Convert to device format + 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 + ) + + # TODO: Write to framebuffer or display API + # framebuffer.write(image) + + async def get_touch_event(self) -> Optional[TouchEvent]: + """Get next touch event""" + # TODO: Read from touch hardware + # touch_data = await read_touch_sensor() + + # If no touch, return None + # if not touch_data: + # return None + + # Classify gesture based on touch_down/touch_up + # See gesture detection algorithm in section 4.3 + + pass + + async def set_brightness(self, level: int): + """Set brightness (0-10)""" + # Map to hardware range + hardware_value = int(level * 255 / 10) + + # TODO: Write to brightness control + # with open('/sys/class/backlight/.../brightness', 'w') as f: + # f.write(str(hardware_value)) +``` + +--- + +## 8. Platform-Specific Considerations + +### 8.1 E-Ink Devices (Kobo, Kindle, reMarkable) + +#### Display Characteristics: +- Grayscale only (8-bit per pixel) +- Slow refresh (200ms - 1000ms) +- Multiple refresh modes (A2, DU, GC16, GL16) +- Requires dithering for images + +#### Optimization Strategies: + +**1. Partial Refresh**: +```python +async def show_image(self, image: Image.Image, partial: bool = True): + if partial: + # Use fast A2 mode for text-only updates + self.controller.set_mode('A2') + else: + # Full GC16 refresh every N pages + self.controller.set_mode('GC16') + + self.framebuffer.write(image) + self.controller.refresh() +``` + +**2. Dithering**: +```python +from PIL import ImageDraw + +def apply_dithering(image: Image.Image) -> Image.Image: + # Convert to grayscale + image = image.convert('L') + + # Apply Floyd-Steinberg dithering + image = image.convert('1', dither=Image.FLOYDSTEINBERG) + image = image.convert('L') + + return image +``` + +**3. Refresh Strategy**: +- Text pages: A2 mode (fast, ~200ms) +- Image-heavy pages: GC16 mode (slow, ~1000ms) +- Full refresh every 5-10 pages to clear ghosting + +#### Touch Input: +- Often resistive touch (single-point only) +- Lower resolution than display +- May require calibration + +**Example: Kobo HAL skeleton**: +```python +class KoboDisplayHAL(DisplayHAL): + def __init__(self): + self.fb = open('/dev/fb0', 'wb') + self.touch = open('/dev/input/event0', 'rb') + self.width = 1072 + self.height = 1448 + self.refresh_counter = 0 + + async def show_image(self, image: Image.Image): + # Convert to grayscale + image = image.convert('L') + + # Resize + image = image.resize((self.width, self.height)) + + # Write to framebuffer + self.fb.write(image.tobytes()) + + # Decide refresh mode + self.refresh_counter += 1 + if self.refresh_counter % 10 == 0: + # Full refresh every 10 pages + self._trigger_refresh('GC16') + else: + # Fast refresh + self._trigger_refresh('A2') +``` + +--- + +### 8.2 Linux Framebuffer + +#### Display: +```python +async def show_image(self, image: Image.Image): + # Open framebuffer + with open('/dev/fb0', 'wb') as fb: + # Convert to RGB565 or RGB888 depending on device + if self.color_depth == 16: + # RGB565 + data = image.convert('RGB').tobytes('raw', 'RGB') + # Convert to RGB565 packed format + data = self._convert_rgb888_to_rgb565(data) + else: + # RGB888 + data = image.convert('RGB').tobytes() + + fb.write(data) +``` + +#### Touch Input (evdev): +```python +import evdev + +async def get_touch_event(self) -> Optional[TouchEvent]: + device = evdev.InputDevice('/dev/input/event0') + + async for event in device.async_read_loop(): + if event.type == evdev.ecodes.EV_ABS: + if event.code == evdev.ecodes.ABS_X: + self.current_x = event.value + elif event.code == evdev.ecodes.ABS_Y: + self.current_y = event.value + + elif event.type == evdev.ecodes.EV_KEY: + if event.code == evdev.ecodes.BTN_TOUCH: + if event.value == 1: # Touch down + self._on_touch_down(self.current_x, self.current_y) + else: # Touch up + gesture = self._on_touch_up(self.current_x, self.current_y) + if gesture: + return TouchEvent(gesture, self.current_x, self.current_y) +``` + +--- + +### 8.3 Embedded Systems (Raspberry Pi, etc.) + +#### Recommendations: +- Use hardware-accelerated PIL if available (Pillow-SIMD) +- Minimize memory allocations (reuse image buffers) +- Consider using mmap for framebuffer access +- Implement watchdog for crash recovery + +```python +import mmap + +class EmbeddedHAL(DisplayHAL): + def __init__(self): + self.fb_fd = os.open('/dev/fb0', os.O_RDWR) + self.fb_size = self.width * self.height * 4 # RGB888 + self.fb_mmap = mmap.mmap( + self.fb_fd, + self.fb_size, + mmap.MAP_SHARED, + mmap.PROT_READ | mmap.PROT_WRITE + ) + + async def show_image(self, image: Image.Image): + # Write directly to mmap'd framebuffer + data = image.convert('RGB').tobytes() + self.fb_mmap.seek(0) + self.fb_mmap.write(data) +``` + +--- + +### 8.4 Desktop Testing (Windows/macOS/Linux) + +Use **PygameDisplayHAL** (already provided) or implement similar with: +- **Tkinter**: For simple cross-platform windows +- **Qt (PyQt5/PySide6)**: For advanced GUI features +- **SDL2**: For game-like responsiveness + +--- + +## 9. Example Implementations + +### 9.1 Complete Example: Pygame HAL + +See `dreader/hal_pygame.py` for full implementation. + +**Key sections to study**: +- Lines 158-240: Gesture detection in `get_touch_event()` +- Lines 111-140: Image display in `show_image()` +- Lines 302-354: Event loop in `run_event_loop()` + +--- + +### 9.2 Pseudocode: E-Ink HAL + +```python +from dreader.hal import DisplayHAL +from dreader.gesture import TouchEvent, GestureType +from PIL import Image +import time + +class EInkHAL(DisplayHAL): + """E-Ink device HAL with partial refresh support""" + + def __init__(self, width=1072, height=1448): + self.width = width + self.height = height + self.refresh_count = 0 + + # Hardware interfaces + self.framebuffer = None + self.touch_device = None + self.frontlight = None + + # Gesture tracking + self._touch_down_pos = None + self._touch_down_time = 0 + + async def initialize(self): + """Initialize e-ink display and touch sensor""" + # Open framebuffer + self.framebuffer = open('/dev/fb0', 'r+b') + + # Open touch device + self.touch_device = evdev.InputDevice('/dev/input/event0') + + # Initialize frontlight + self.frontlight = open('/sys/class/backlight/mxc_mst716/brightness', 'w') + + # Clear screen + blank = Image.new('L', (self.width, self.height), 255) + await self.show_image(blank) + + async def cleanup(self): + """Cleanup hardware resources""" + if self.framebuffer: + self.framebuffer.close() + if self.frontlight: + self.frontlight.close() + + async def show_image(self, image: Image.Image): + """Display image with e-ink optimization""" + # Convert to grayscale + if image.mode != 'L': + image = image.convert('L') + + # Resize if needed + if image.size != (self.width, self.height): + image = image.resize((self.width, self.height), Image.LANCZOS) + + # Write to framebuffer + self.framebuffer.seek(0) + self.framebuffer.write(image.tobytes()) + + # Decide refresh mode + self.refresh_count += 1 + if self.refresh_count % 10 == 0: + # Full refresh every 10 pages (remove ghosting) + self._trigger_refresh('FULL') + await asyncio.sleep(1.0) # Wait for full refresh + else: + # Partial refresh (fast) + self._trigger_refresh('PARTIAL') + await asyncio.sleep(0.2) # Wait for partial refresh + + def _trigger_refresh(self, mode: str): + """Trigger display refresh""" + if mode == 'FULL': + # Device-specific: trigger full GC16 refresh + # Example: ioctl(MXCFB_SEND_UPDATE, waveform=GC16) + pass + else: + # Device-specific: trigger partial A2 refresh + # Example: ioctl(MXCFB_SEND_UPDATE, waveform=A2) + pass + + async def get_touch_event(self) -> Optional[TouchEvent]: + """Read touch events from evdev""" + current_x = 0 + current_y = 0 + + # Read events (with timeout) + try: + events = await asyncio.wait_for( + self._read_touch_events(), + timeout=0.1 + ) + except asyncio.TimeoutError: + return None + + for event in events: + if event.type == evdev.ecodes.EV_ABS: + if event.code == evdev.ecodes.ABS_X: + current_x = event.value + elif event.code == evdev.ecodes.ABS_Y: + current_y = event.value + + elif event.type == evdev.ecodes.EV_KEY: + if event.code == evdev.ecodes.BTN_TOUCH: + if event.value == 1: # Touch down + self._touch_down_pos = (current_x, current_y) + self._touch_down_time = time.time() + + else: # Touch up + if not self._touch_down_pos: + continue + + # Calculate gesture + dx = current_x - self._touch_down_pos[0] + dy = current_y - self._touch_down_pos[1] + distance = (dx**2 + dy**2) ** 0.5 + duration = time.time() - self._touch_down_time + + # Classify + if distance < 30: + if duration >= 0.5: + gesture = GestureType.LONG_PRESS + else: + gesture = GestureType.TAP + else: + if abs(dx) > abs(dy): + gesture = GestureType.SWIPE_LEFT if dx < 0 else GestureType.SWIPE_RIGHT + else: + gesture = GestureType.SWIPE_UP if dy < 0 else GestureType.SWIPE_DOWN + + self._touch_down_pos = None + return TouchEvent(gesture, current_x, current_y) + + return None + + async def _read_touch_events(self): + """Helper to read touch events with async""" + events = [] + async for event in self.touch_device.async_read_loop(): + events.append(event) + if event.type == evdev.ecodes.EV_SYN: + break # End of event batch + return events + + async def set_brightness(self, level: int): + """Set frontlight brightness""" + # Map 0-10 to hardware range (e.g., 0-100) + value = level * 10 + self.frontlight.seek(0) + self.frontlight.write(str(value)) + self.frontlight.flush() +``` + +--- + +### 9.3 Usage Example + +```python +import asyncio +from your_hal import YourDisplayHAL +from dreader.main import DReaderApplication, AppConfig + +async def main(): + # Create HAL + hal = YourDisplayHAL(width=800, height=1200) + + # Configure application + config = AppConfig( + display_hal=hal, + library_path="/home/user/Books", + page_size=(800, 1200), + log_level=logging.INFO + ) + + # Create application + app = DReaderApplication(config) + + # If using EventLoopHAL: + await hal.run_event_loop(app) + + # OR if using basic DisplayHAL: + # await app.start() + # 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() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## 10. Troubleshooting + +### Common Issues + +**Issue**: Images not displaying +- Check framebuffer path (`/dev/fb0`) +- Verify color format (RGB vs BGR) +- Check image size matches display +- Verify write permissions + +**Issue**: Touch not detected +- Check evdev device path +- Verify touch coordinates are mapped correctly +- Check coordinate system (origin, scaling) +- Test with `evtest` utility + +**Issue**: Gestures not recognized +- Check thresholds (TAP_THRESHOLD, etc.) +- Add debug logging to gesture detection +- Verify touch up/down events are paired +- Check timestamp accuracy + +**Issue**: Slow performance +- Profile image conversion (use cProfile) +- Cache converted images +- Use partial refresh on e-ink +- Consider hardware acceleration + +--- + +## 11. Resources + +### Documentation +- DReader Architecture: `INTEGRATION_DOCUMENTATION_INDEX.md` +- pyWebLayout Integration: `PYWEBLAYOUT_INTEGRATION_GUIDE.md` +- Async Rendering: `ASYNC_DIRTY_FLAG_RENDERING.md` + +### Reference Code +- Pygame HAL: `dreader/hal_pygame.py` +- HAL Interface: `dreader/hal.py` +- Gesture Types: `dreader/gesture.py` +- Main Application: `dreader/main.py` + +### External References +- Linux Framebuffer: https://www.kernel.org/doc/Documentation/fb/api.txt +- evdev Input: https://python-evdev.readthedocs.io/ +- PIL/Pillow: https://pillow.readthedocs.io/ +- E-Ink Controllers: Device-specific documentation (Kobo, Kindle, reMarkable) + +--- + +## 12. Checklist for HAL Implementers + +Use this checklist to track your implementation: + +### Setup +- [ ] Read this specification completely +- [ ] Study PygameDisplayHAL reference implementation +- [ ] Set up development environment +- [ ] Test DReader with Pygame HAL first + +### Core Implementation +- [ ] Create HAL class inheriting from DisplayHAL +- [ ] Implement `__init__()` with device configuration +- [ ] Implement `initialize()` - hardware setup +- [ ] Implement `cleanup()` - resource cleanup +- [ ] Implement `show_image()` - display rendering +- [ ] Implement `get_touch_event()` - input handling +- [ ] Implement gesture detection algorithm +- [ ] Implement `set_brightness()` (if supported) + +### Testing +- [ ] Unit test each method +- [ ] Test with minimal application +- [ ] Test with full DReader application +- [ ] Test all essential gestures +- [ ] Performance profiling +- [ ] Memory leak testing +- [ ] Long-term stability testing + +### Optimization +- [ ] Profile and optimize hot paths +- [ ] Implement caching where appropriate +- [ ] Add platform-specific optimizations +- [ ] Tune gesture thresholds +- [ ] Optimize display refresh strategy + +### Documentation +- [ ] Document platform-specific requirements +- [ ] Document known limitations +- [ ] Create usage examples +- [ ] Write troubleshooting guide + +--- + +## Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2025-11-09 | Initial specification | + +--- + +**End of HAL Implementation Specification** + +For questions or issues, please refer to the DReader documentation or create an issue in the project repository. diff --git a/HARDWARE_PINOUT.md b/HARDWARE_PINOUT.md new file mode 100755 index 0000000..d35af02 --- /dev/null +++ b/HARDWARE_PINOUT.md @@ -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 diff --git a/HARDWARE_SETUP.md b/HARDWARE_SETUP.md new file mode 100644 index 0000000..b4e98e1 --- /dev/null +++ b/HARDWARE_SETUP.md @@ -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) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b09da01 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8a6570 --- /dev/null +++ b/README.md @@ -0,0 +1,571 @@ +# DReader Application + +## Project Status + +| Badge | Description | +|-------|-------------| +| ![Test Coverage](https://gitea.tourolle.paris/dtourolle/dreader-application/raw/branch/badges/cov_info/coverage.svg) | **Test Coverage** - Percentage of code covered by unit tests | +| ![Documentation Coverage](https://gitea.tourolle.paris/dtourolle/dreader-application/raw/branch/badges/cov_info/coverage-docs.svg) | **Documentation Coverage** - Percentage of code with docstrings | +| ![License](https://img.shields.io/badge/license-MIT-blue.svg) | **License** - Project licensing information | + +## Description + +DReader Application is a complete, production-ready ebook reader built on [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout). It demonstrates how to build a full-featured ebook reader with library browsing, text highlighting, bookmarks, gesture support, overlays, and position persistence. + +This project serves as both a reference implementation and a ready-to-use ereader library for building desktop, web-based, or embedded reading applications. + +## Key Features + +### Core Reading Features +- 📖 **EPUB Support** - Load and render EPUB files with full text extraction +- 📚 **Library Management** - Browse and select books from your collection +- 📄 **Page Rendering** - Render pages as PIL Images optimized for any display +- ⬅️➡️ **Navigation** - Smooth forward and backward page navigation +- 🔖 **Bookmarks** - Save and restore reading positions with persistence +- 📑 **Chapter Navigation** - Jump to chapters by title or index via TOC +- 📋 **Unified Overlays** - Navigation (TOC + Bookmarks) and Settings overlays +- 📊 **Progress Tracking** - Real-time reading progress percentage + +### Text Interaction +- 🎨 **Text Highlighting** - Highlight words and text selections with custom colors +- 💡 **Highlight Notes** - Attach notes and annotations to highlights +- 🔍 **Text Selection** - Select words or ranges via pixel coordinates +- 👆 **Gesture Support** - Handle tap, swipe, pinch, long-press events +- 🎯 **Pixel Queries** - Query text content at any screen position + +### Customization & Display +- 🔤 **Font Control** - Dynamically adjust font size with live preview +- 📏 **Spacing Control** - Customize line and paragraph spacing +- 💾 **Position Persistence** - Stable positions across style changes +- ⚡ **Smart Reflow** - Automatic text reflow on font/spacing changes +- 🎨 **Custom Styling** - Full control over colors, fonts, and layout +- 💾 **Settings Persistence** - Save and restore preferences across sessions + +## Installation + +```bash +# Clone the repository +git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git +cd dreader-application + +# Install in development mode +pip install -e . + +# Or install with dev dependencies +pip install -e ".[dev]" +``` + +The pyWebLayout dependency will be automatically installed from the git repository. + +## DReader in Action + +Here are animated demonstrations of the key features: + + + + + + + + + + + + + + +
+ Page Navigation
+ Page Navigation
+ Forward and backward navigation through pages +
+ Font Size Adjustment
+ Font Size
+ Dynamic font size scaling with position preservation +
+ Chapter Navigation
+ Chapter Navigation
+ Jump directly to chapters by title or index +
+ Bookmarks & Positions
+ Bookmarks
+ Save and restore reading positions anywhere in the book +
+ Text Highlighting
+ Highlighting
+ Highlight words and selections with custom colors and notes +
+ TOC Overlay
+ TOC Overlay
+ Interactive table of contents with gesture-based navigation +
+ +## 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. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..fa338cc --- /dev/null +++ b/REQUIREMENTS.md @@ -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 diff --git a/cov_info/coverage-docs.svg b/cov_info/coverage-docs.svg new file mode 100644 index 0000000..f3f8d64 --- /dev/null +++ b/cov_info/coverage-docs.svg @@ -0,0 +1,58 @@ + + interrogate: 99.7% + + + + + + + + + + + interrogate + interrogate + 99.7% + 99.7% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cov_info/coverage.svg b/cov_info/coverage.svg new file mode 100644 index 0000000..e01303b --- /dev/null +++ b/cov_info/coverage.svg @@ -0,0 +1 @@ +coverage: failedcoveragefailed \ No newline at end of file diff --git a/debug_overlay_links.py b/debug_overlay_links.py new file mode 100644 index 0000000..8cd7a03 --- /dev/null +++ b/debug_overlay_links.py @@ -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) diff --git a/debug_previous_page.py b/debug_previous_page.py new file mode 100644 index 0000000..a985651 --- /dev/null +++ b/debug_previous_page.py @@ -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() diff --git a/docs/images/ereader_bookmarks.gif b/docs/images/ereader_bookmarks.gif new file mode 100644 index 0000000..19ad147 Binary files /dev/null and b/docs/images/ereader_bookmarks.gif differ diff --git a/docs/images/ereader_chapter_navigation.gif b/docs/images/ereader_chapter_navigation.gif new file mode 100644 index 0000000..4e69f03 Binary files /dev/null and b/docs/images/ereader_chapter_navigation.gif differ diff --git a/docs/images/ereader_font_size.gif b/docs/images/ereader_font_size.gif new file mode 100644 index 0000000..0190396 Binary files /dev/null and b/docs/images/ereader_font_size.gif differ diff --git a/docs/images/ereader_highlighting.gif b/docs/images/ereader_highlighting.gif new file mode 100644 index 0000000..d778656 Binary files /dev/null and b/docs/images/ereader_highlighting.gif differ diff --git a/docs/images/ereader_page_navigation.gif b/docs/images/ereader_page_navigation.gif new file mode 100644 index 0000000..dcbd97c Binary files /dev/null and b/docs/images/ereader_page_navigation.gif differ diff --git a/docs/images/library_reading_demo.gif b/docs/images/library_reading_demo.gif new file mode 100644 index 0000000..0f0e259 Binary files /dev/null and b/docs/images/library_reading_demo.gif differ diff --git a/docs/images/settings_overlay_demo.gif b/docs/images/settings_overlay_demo.gif new file mode 100644 index 0000000..9378f98 Binary files /dev/null and b/docs/images/settings_overlay_demo.gif differ diff --git a/docs/images/toc_overlay_demo.gif b/docs/images/toc_overlay_demo.gif new file mode 100644 index 0000000..ac6a4bc Binary files /dev/null and b/docs/images/toc_overlay_demo.gif differ diff --git a/dreader/__init__.py b/dreader/__init__.py new file mode 100644 index 0000000..d32986f --- /dev/null +++ b/dreader/__init__.py @@ -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", +] diff --git a/dreader/application.py b/dreader/application.py new file mode 100644 index 0000000..dfe01ce --- /dev/null +++ b/dreader/application.py @@ -0,0 +1,1173 @@ +#!/usr/bin/env python3 +""" +Simple ereader application interface for pyWebLayout. + +This module provides a user-friendly wrapper around the ereader infrastructure, +making it easy to build ebook reader applications with all essential features. + +Example: + from pyWebLayout.layout.ereader_application import EbookReader + + # Create reader + reader = EbookReader(page_size=(800, 1000)) + + # Load an EPUB + reader.load_epub("mybook.epub") + + # Navigate + reader.next_page() + reader.previous_page() + + # Get current page + page_image = reader.get_current_page() + + # Modify styling + reader.increase_font_size() + reader.set_line_spacing(8) + + # Chapter navigation + chapters = reader.get_chapters() + reader.jump_to_chapter("Chapter 1") + + # Position management + reader.save_position("bookmark1") + reader.load_position("bookmark1") +""" + +from __future__ import annotations +from typing import List, Tuple, Optional, Dict, Any, Union +from pathlib import Path +import os + +from PIL import Image + +from pyWebLayout.abstract.block import Block, HeadingLevel +from pyWebLayout.layout.ereader_manager import EreaderLayoutManager +from pyWebLayout.layout.ereader_layout import RenderingPosition +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.concrete.page import Page +from pyWebLayout.core.query import QueryResult, SelectionRange +from pyWebLayout.core.highlight import Highlight, HighlightColor, create_highlight_from_query_result + +from .gesture import TouchEvent, GestureType, GestureResponse, ActionType +from .state import OverlayState +from .managers import DocumentManager, SettingsManager, HighlightCoordinator +from .handlers import GestureRouter +from .overlays import NavigationOverlay, SettingsOverlay, TOCOverlay + + +class EbookReader: + """ + Simple ereader application with all essential features. + + Features: + - Load EPUB files + - Forward/backward page navigation + - Position save/load (based on abstract document structure) + - Chapter navigation + - Font size and spacing control + - Current page retrieval as PIL Image + + The reader maintains position using abstract document structure (chapter/block/word indices), + ensuring positions remain valid across font size and styling changes. + """ + + def __init__(self, + page_size: Tuple[int, int] = (800, 1000), + margin: int = 40, + background_color: Tuple[int, int, int] = (255, 255, 255), + line_spacing: int = 5, + inter_block_spacing: int = 15, + bookmarks_dir: str = "ereader_bookmarks", + highlights_dir: str = "highlights", + buffer_size: int = 5): + """ + Initialize the ebook reader. + + Args: + page_size: Page dimensions (width, height) in pixels + margin: Page margin in pixels + background_color: Background color as RGB tuple + line_spacing: Spacing between lines in pixels + inter_block_spacing: Spacing between blocks in pixels + bookmarks_dir: Directory to store bookmarks and positions + highlights_dir: Directory to store highlights + buffer_size: Number of pages to cache for performance + """ + self.page_size = page_size + self.bookmarks_dir = bookmarks_dir + self.highlights_dir = highlights_dir + self.buffer_size = buffer_size + + # Create page style + self.page_style = PageStyle( + background_color=background_color, + border_width=margin, + border_color=background_color, + padding=(10, 10, 10, 10), + line_spacing=line_spacing, + inter_block_spacing=inter_block_spacing + ) + + # Core managers (NEW: Refactored into separate modules) + self.doc_manager = DocumentManager() + self.settings_manager = SettingsManager() + self.highlight_coordinator: Optional[HighlightCoordinator] = None + self.gesture_router = GestureRouter(self) + + # Layout manager (initialized after loading) + self.manager: Optional[EreaderLayoutManager] = None + + # Legacy compatibility properties + self.blocks: Optional[List[Block]] = None + self.document_id: Optional[str] = None + self.book_title: Optional[str] = None + self.book_author: Optional[str] = None + self.highlight_manager = None # Will delegate to highlight_coordinator + + # Font scale state (delegated to settings_manager but kept for compatibility) + self.base_font_scale = 1.0 + self.font_scale_step = 0.1 + + # Overlay sub-applications + self._overlay_subapps = { + OverlayState.NAVIGATION: NavigationOverlay(self), + OverlayState.SETTINGS: SettingsOverlay(self), + OverlayState.TOC: TOCOverlay(self), + } + self._active_overlay = None # Current active overlay sub-application + self.current_overlay_state = OverlayState.NONE + + def load_epub(self, epub_path: str) -> bool: + """ + Load an EPUB file into the reader. + + Args: + epub_path: Path to the EPUB file + + Returns: + True if loaded successfully, False otherwise + """ + # Use DocumentManager to load the EPUB + success = self.doc_manager.load_epub(epub_path) + + if not success: + return False + + # Set compatibility properties + self.book_title = self.doc_manager.title + self.book_author = self.doc_manager.author + self.document_id = self.doc_manager.document_id + self.blocks = self.doc_manager.blocks + + # Initialize the ereader manager + self.manager = EreaderLayoutManager( + blocks=self.blocks, + page_size=self.page_size, + document_id=self.document_id, + buffer_size=self.buffer_size, + page_style=self.page_style, + bookmarks_dir=self.bookmarks_dir + ) + + # Initialize managers that depend on layout manager + self.settings_manager.set_manager(self.manager) + + # Initialize highlight coordinator for this document + self.highlight_coordinator = HighlightCoordinator( + document_id=self.document_id, + highlights_dir=self.highlights_dir + ) + self.highlight_coordinator.set_layout_manager(self.manager) + self.highlight_manager = self.highlight_coordinator.highlight_manager # Compatibility + + return True + + def load_html(self, html_string: str, title: str = "HTML Document", author: str = "Unknown", document_id: str = "html_doc") -> bool: + """ + Load HTML content directly into the reader. + + This is useful for rendering library screens, menus, or other HTML-based UI elements + using the same rendering engine as the ebook reader. + + Args: + html_string: HTML content to render + title: Document title (for metadata) + author: Document author (for metadata) + document_id: Unique identifier for this HTML document + + Returns: + True if loaded successfully, False otherwise + """ + # Use DocumentManager to load HTML + success = self.doc_manager.load_html(html_string, title, author, document_id) + + if not success: + return False + + # Set compatibility properties + self.book_title = self.doc_manager.title + self.book_author = self.doc_manager.author + self.document_id = self.doc_manager.document_id + self.blocks = self.doc_manager.blocks + + # Initialize the ereader manager + self.manager = EreaderLayoutManager( + blocks=self.blocks, + page_size=self.page_size, + document_id=self.document_id, + buffer_size=self.buffer_size, + page_style=self.page_style, + bookmarks_dir=self.bookmarks_dir + ) + + # Initialize managers that depend on layout manager + self.settings_manager.set_manager(self.manager) + + # Initialize highlight coordinator for this document + self.highlight_coordinator = HighlightCoordinator( + document_id=self.document_id, + highlights_dir=self.highlights_dir + ) + self.highlight_coordinator.set_layout_manager(self.manager) + self.highlight_manager = self.highlight_coordinator.highlight_manager # Compatibility + + return True + + def is_loaded(self) -> bool: + """Check if a book is currently loaded.""" + return self.manager is not None + + def get_current_page(self, include_highlights: bool = True) -> Optional[Image.Image]: + """ + Get the current page as a PIL Image. + + If an overlay is currently open, returns the composited overlay image. + Otherwise returns the base reading page. + + Args: + include_highlights: Whether to overlay highlights on the page (only applies to base page) + + Returns: + PIL Image of the current page (or overlay), or None if no book is loaded + """ + if not self.manager: + return None + + # If an overlay is open, return the cached composited overlay image + if self.is_overlay_open() and self._active_overlay: + # Return the composited overlay from the sub-application + if self._active_overlay._cached_base_page and self._active_overlay._cached_overlay_image: + return self._active_overlay.composite_overlay( + self._active_overlay._cached_base_page, + self._active_overlay._cached_overlay_image + ) + + try: + page = self.manager.get_current_page() + img = page.render() + + # Overlay highlights if requested and available + if include_highlights and self.highlight_manager: + # Get page bounds + page_bounds = (0, 0, self.page_size[0], self.page_size[1]) + highlights = self.highlight_manager.get_highlights_for_page(page_bounds) + + if highlights: + img = self._render_highlights(img, highlights) + + return img + except Exception as e: + print(f"Error rendering page: {e}") + return None + + def next_page(self) -> Optional[Image.Image]: + """ + Navigate to the next page. + + Returns: + PIL Image of the next page, or None if at end of book + """ + if not self.manager: + return None + + try: + page = self.manager.next_page() + if page: + return page.render() + return None + except Exception as e: + print(f"Error navigating to next page: {e}") + return None + + def previous_page(self) -> Optional[Image.Image]: + """ + Navigate to the previous page. + + Returns: + PIL Image of the previous page, or None if at beginning of book + """ + if not self.manager: + return None + + try: + page = self.manager.previous_page() + if page: + return page.render() + return None + except Exception as e: + print(f"Error navigating to previous page: {e}") + return None + + def save_position(self, name: str = "current_position") -> bool: + """ + Save the current reading position with a name. + + The position is saved based on abstract document structure (chapter, block, word indices), + making it stable across font size and styling changes. + + Args: + name: Name for this saved position + + Returns: + True if saved successfully, False otherwise + """ + if not self.manager: + return False + + try: + self.manager.add_bookmark(name) + return True + except Exception as e: + print(f"Error saving position: {e}") + return False + + def load_position(self, name: str = "current_position") -> Optional[Image.Image]: + """ + Load a previously saved reading position. + + Args: + name: Name of the saved position + + Returns: + PIL Image of the page at the loaded position, or None if not found + """ + if not self.manager: + return None + + try: + page = self.manager.jump_to_bookmark(name) + if page: + return page.render() + return None + except Exception as e: + print(f"Error loading position: {e}") + return None + + def list_saved_positions(self) -> List[str]: + """ + Get a list of all saved position names. + + Returns: + List of position names + """ + if not self.manager: + return [] + + try: + bookmarks = self.manager.list_bookmarks() + return [name for name, _ in bookmarks] + except Exception as e: + print(f"Error listing positions: {e}") + return [] + + def delete_position(self, name: str) -> bool: + """ + Delete a saved position. + + Args: + name: Name of the position to delete + + Returns: + True if deleted, False otherwise + """ + if not self.manager: + return False + + return self.manager.remove_bookmark(name) + + def get_chapters(self) -> List[Tuple[str, int]]: + """ + Get a list of all chapters with their indices. + + Returns: + List of (chapter_title, chapter_index) tuples + """ + if not self.manager: + return [] + + try: + toc = self.manager.get_table_of_contents() + # Convert to simplified format (title, index) + chapters = [] + for i, (title, level, position) in enumerate(toc): + chapters.append((title, i)) + return chapters + except Exception as e: + print(f"Error getting chapters: {e}") + return [] + + def get_chapter_positions(self) -> List[Tuple[str, RenderingPosition]]: + """ + Get chapter titles with their exact rendering positions. + + Returns: + List of (title, position) tuples + """ + if not self.manager: + return [] + + try: + toc = self.manager.get_table_of_contents() + return [(title, position) for title, level, position in toc] + except Exception as e: + print(f"Error getting chapter positions: {e}") + return [] + + def jump_to_chapter(self, chapter: Union[str, int]) -> Optional[Image.Image]: + """ + Navigate to a specific chapter by title or index. + + Args: + chapter: Chapter title (string) or chapter index (integer) + + Returns: + PIL Image of the first page of the chapter, or None if not found + """ + if not self.manager: + return None + + try: + if isinstance(chapter, int): + page = self.manager.jump_to_chapter_index(chapter) + else: + page = self.manager.jump_to_chapter(chapter) + + if page: + return page.render() + return None + except Exception as e: + print(f"Error jumping to chapter: {e}") + return None + + 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: + PIL Image of the re-rendered page with new font size + """ + result = self.settings_manager.set_font_size(scale) + if result: + self.base_font_scale = self.settings_manager.font_scale # Sync compatibility property + return result + + def increase_font_size(self) -> Optional[Image.Image]: + """ + Increase font size by one step and re-render. + + Returns: + PIL Image of the re-rendered page + """ + result = self.settings_manager.increase_font_size() + if result: + self.base_font_scale = self.settings_manager.font_scale + return result + + def decrease_font_size(self) -> Optional[Image.Image]: + """ + Decrease font size by one step and re-render. + + Returns: + PIL Image of the re-rendered page + """ + result = self.settings_manager.decrease_font_size() + if result: + self.base_font_scale = self.settings_manager.font_scale + return result + + def get_font_size(self) -> float: + """ + Get the current font size scale. + + Returns: + Current font scale factor + """ + return self.settings_manager.get_font_size() + + def set_font_family(self, font_family) -> 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: + PIL Image of the re-rendered page + """ + return self.settings_manager.set_font_family(font_family) + + def get_font_family(self): + """ + Get the current font family. + + Returns: + Current BundledFont or None if using document default + """ + return self.settings_manager.get_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: + PIL Image of the re-rendered page + """ + return self.settings_manager.set_line_spacing(spacing) + + 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: + PIL Image of the re-rendered page + """ + return self.settings_manager.set_inter_block_spacing(spacing) + + 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: + PIL Image of the re-rendered page + """ + return self.settings_manager.set_word_spacing(spacing) + + def get_position_info(self) -> Dict[str, Any]: + """ + Get detailed information about the current position. + + Returns: + Dictionary with position details including: + - position: RenderingPosition details (chapter_index, block_index, word_index) + - chapter: Current chapter info (title, level) + - progress: Reading progress (0.0 to 1.0) + - font_scale: Current font scale + - book_title: Book title + - book_author: Book author + """ + if not self.manager: + return {} + + try: + info = self.manager.get_position_info() + info['book_title'] = self.book_title + info['book_author'] = self.book_author + return info + except Exception as e: + print(f"Error getting position info: {e}") + return {} + + def get_reading_progress(self) -> float: + """ + Get reading progress as a percentage. + + Returns: + Progress from 0.0 (beginning) to 1.0 (end) + """ + if not self.manager: + return 0.0 + + return self.manager.get_reading_progress() + + def get_current_chapter_info(self) -> Optional[Dict[str, Any]]: + """ + Get information about the current chapter. + + Returns: + Dictionary with chapter info (title, level) or None + """ + if not self.manager: + return None + + try: + chapter = self.manager.get_current_chapter() + if chapter: + return { + 'title': chapter.title, + 'level': chapter.level, + 'block_index': chapter.block_index + } + return None + except Exception as e: + print(f"Error getting current chapter: {e}") + return None + + def render_to_file(self, output_path: str) -> bool: + """ + Save the current page to an image file. + + Args: + output_path: Path where to save the image (e.g., "page.png") + + Returns: + True if saved successfully, False otherwise + """ + page_image = self.get_current_page() + if page_image: + try: + page_image.save(output_path) + return True + except Exception as e: + print(f"Error saving image: {e}") + return False + return False + + def get_book_info(self) -> Dict[str, Any]: + """ + Get information about the loaded book. + + Returns: + Dictionary with book information + """ + return { + 'title': self.book_title, + 'author': self.book_author, + 'document_id': self.document_id, + 'total_blocks': len(self.blocks) if self.blocks else 0, + 'total_chapters': len(self.get_chapters()), + 'page_size': self.page_size, + 'font_scale': self.base_font_scale + } + + # ===== Settings Persistence ===== + + def get_current_settings(self) -> Dict[str, Any]: + """ + Get current rendering settings. + + Returns: + Dictionary with all current settings + """ + return self.settings_manager.get_current_settings() + + 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, line_spacing, etc.) + + Returns: + True if settings applied successfully, False otherwise + """ + success = self.settings_manager.apply_settings(settings) + if success: + # Sync compatibility property + self.base_font_scale = self.settings_manager.font_scale + return success + + # ===== Gesture Handling ===== + # All business logic for touch input is handled here + + def handle_touch(self, event: TouchEvent) -> GestureResponse: + """ + Handle a touch event from HAL. + + **This is the main business logic entry point for all touch interactions.** + Flask should call this and use the response to generate HTML/JSON. + + Args: + event: TouchEvent from HAL with gesture type and coordinates + + Returns: + GestureResponse with action and data for UI to process + """ + # Delegate to gesture router + return self.gesture_router.handle_touch(event) + + def query_pixel(self, x: int, y: int) -> Optional[QueryResult]: + """ + Direct pixel query for debugging/tools. + + Args: + x, y: Pixel coordinates + + Returns: + QueryResult or None if nothing at that location + """ + if not self.manager: + return None + + page = self.manager.get_current_page() + return page.query_point((x, y)) + + + def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse: + """ + Handle tap when overlay is open. + + Delegates to the active overlay sub-application for handling. + If the response indicates the overlay should be closed, closes it. + """ + if not self._active_overlay: + # No active overlay, close legacy overlay if any + self.close_overlay() + return GestureResponse(ActionType.OVERLAY_CLOSED, {}) + + # Delegate to the active overlay sub-application + response = self._active_overlay.handle_tap(x, y) + + # If the response indicates overlay should be closed, close it + if response.action in (ActionType.OVERLAY_CLOSED, ActionType.CHAPTER_SELECTED, + ActionType.BOOKMARK_SELECTED): + self.close_overlay() + + return response + + + # =================================================================== + # Highlighting API + # =================================================================== + + 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.manager or not self.highlight_manager: + return None + + try: + # Query the pixel to find the word + result = self.query_pixel(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.manager or not self.highlight_manager: + return None + + try: + page = self.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. + + Args: + highlight_id: ID of the highlight to remove + + Returns: + True if removed successfully, False otherwise + """ + if not self.highlight_manager: + return False + + return self.highlight_manager.remove_highlight(highlight_id) + + def list_highlights(self) -> List[Highlight]: + """ + Get all highlights for the current document. + + Returns: + List of Highlight objects + """ + if not self.highlight_manager: + return [] + + return self.highlight_manager.list_highlights() + + def get_highlights_for_current_page(self) -> List[Highlight]: + """ + Get highlights that appear on the current page. + + Returns: + List of Highlight objects on this page + """ + if not self.manager or not self.highlight_manager: + return [] + + page_bounds = (0, 0, self.page_size[0], self.page_size[1]) + return self.highlight_manager.get_highlights_for_page(page_bounds) + + def clear_highlights(self) -> None: + """Remove all highlights from the current document.""" + if self.highlight_manager: + 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. + + This preserves text contrast by multiplying the highlight color with the + underlying pixels, like a real highlighter pen. + + Args: + image: Base PIL Image to draw on + highlights: List of Highlight objects to render + + Returns: + New PIL Image with highlights overlaid + """ + import numpy as np + + # Convert to RGB for processing (we'll add alpha back later if needed) + original_mode = image.mode + if image.mode == 'RGBA': + # Separate alpha channel + 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) + # This darkens the image proportionally to the highlight color + 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 + + # =================================================================== + # Overlay Management API + # =================================================================== + + def open_toc_overlay(self) -> Optional[Image.Image]: + """ + Open the table of contents overlay. + + Returns: + Composited image with TOC overlay on top of current page, or None if no book loaded + """ + if not self.is_loaded(): + return None + + # Get current page as base + base_page = self.get_current_page(include_highlights=False) + if not base_page: + return None + + # Get chapters + chapters = self.get_chapters() + + # Use the TOC sub-application + overlay_subapp = self._overlay_subapps[OverlayState.TOC] + result = overlay_subapp.open(base_page, chapters=chapters) + + # Update state + self._active_overlay = overlay_subapp + self.current_overlay_state = OverlayState.TOC + + return result + + def open_settings_overlay(self) -> Optional[Image.Image]: + """ + Open the settings overlay with current settings values. + + Returns: + Composited image with settings overlay on top of current page, or None if no book loaded + """ + if not self.is_loaded(): + return None + + # Get current page as base + base_page = self.get_current_page(include_highlights=False) + if not base_page: + return None + + # Get current settings + font_scale = self.base_font_scale + line_spacing = self.page_style.line_spacing + inter_block_spacing = self.page_style.inter_block_spacing + word_spacing = self.page_style.word_spacing + font_family = self.get_font_family() + font_family_name = font_family.name if font_family else "Default" + + # Use the Settings sub-application + overlay_subapp = self._overlay_subapps[OverlayState.SETTINGS] + result = overlay_subapp.open( + base_page, + font_scale=font_scale, + line_spacing=line_spacing, + inter_block_spacing=inter_block_spacing, + word_spacing=word_spacing, + font_family=font_family_name + ) + + # Update state + self._active_overlay = overlay_subapp + self.current_overlay_state = OverlayState.SETTINGS + + return result + + def open_bookmarks_overlay(self) -> Optional[Image.Image]: + """ + Open the bookmarks overlay. + + This is a convenience method that opens the navigation overlay with the bookmarks tab active. + + Returns: + Composited image with bookmarks overlay on top of current page, or None if no book loaded + """ + return self.open_navigation_overlay(active_tab="bookmarks") + + def open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]: + """ + Open the unified navigation overlay with Contents and Bookmarks tabs. + + This is the new unified overlay that replaces separate TOC and Bookmarks overlays. + It provides a tabbed interface for switching between table of contents and bookmarks. + + Args: + active_tab: Which tab to show initially ("contents" or "bookmarks") + + Returns: + Composited image with navigation overlay on top of current page, or None if no book loaded + """ + if not self.is_loaded(): + return None + + # Get current page as base + base_page = self.get_current_page(include_highlights=False) + if not base_page: + return None + + # Get chapters for Contents tab + chapters = self.get_chapters() + + # Get bookmarks for Bookmarks tab + bookmark_names = self.list_saved_positions() + bookmarks = [ + {"name": name, "position": f"Saved position"} + for name in bookmark_names + ] + + # Use the Navigation sub-application + overlay_subapp = self._overlay_subapps[OverlayState.NAVIGATION] + result = overlay_subapp.open( + base_page, + chapters=chapters, + bookmarks=bookmarks, + active_tab=active_tab + ) + + # Update state + self._active_overlay = overlay_subapp + self.current_overlay_state = OverlayState.NAVIGATION + + return result + + def switch_navigation_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, or None if navigation overlay is not open + """ + if self.current_overlay_state != OverlayState.NAVIGATION: + return None + + # Delegate to the Navigation sub-application + if isinstance(self._active_overlay, NavigationOverlay): + result = self._active_overlay.switch_tab(new_tab) + return result if result else self.get_current_page() + + return None + + def close_overlay(self) -> Optional[Image.Image]: + """ + Close the current overlay and return to reading view. + + Returns: + Base page image without overlay, or None if no overlay was open + """ + if self.current_overlay_state == OverlayState.NONE: + return None + + # Close the active overlay sub-application + if self._active_overlay: + self._active_overlay.close() + self._active_overlay = None + + # Update state + self.current_overlay_state = OverlayState.NONE + + # Return fresh current page + return self.get_current_page() + + def is_overlay_open(self) -> bool: + """Check if an overlay is currently open.""" + return self.current_overlay_state != OverlayState.NONE + + def get_overlay_state(self) -> OverlayState: + """Get current overlay state.""" + return self.current_overlay_state + + def close(self): + """ + Close the reader and save current position. + Should be called when done with the reader. + """ + if self.manager: + self.manager.shutdown() + self.manager = None + + def __enter__(self): + """Context manager support.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager cleanup.""" + self.close() + + def __del__(self): + """Cleanup on deletion.""" + self.close() + + +# Convenience function +def create_ebook_reader(page_size: Tuple[int, int] = (800, 1000), **kwargs) -> EbookReader: + """ + Create an ebook reader with sensible defaults. + + Args: + page_size: Page dimensions (width, height) in pixels + **kwargs: Additional arguments passed to EbookReader + + Returns: + Configured EbookReader instance + """ + return EbookReader(page_size=page_size, **kwargs) diff --git a/dreader/book_utils.py b/dreader/book_utils.py new file mode 100644 index 0000000..c32bf11 --- /dev/null +++ b/dreader/book_utils.py @@ -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') diff --git a/dreader/gesture.py b/dreader/gesture.py new file mode 100644 index 0000000..03098ae --- /dev/null +++ b/dreader/gesture.py @@ -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" diff --git a/dreader/gpio_buttons.py b/dreader/gpio_buttons.py new file mode 100644 index 0000000..b94e407 --- /dev/null +++ b/dreader/gpio_buttons.py @@ -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 diff --git a/dreader/hal.py b/dreader/hal.py new file mode 100644 index 0000000..532d7e4 --- /dev/null +++ b/dreader/hal.py @@ -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 diff --git a/dreader/hal_hardware.py b/dreader/hal_hardware.py new file mode 100644 index 0000000..884dd74 --- /dev/null +++ b/dreader/hal_hardware.py @@ -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 diff --git a/dreader/hal_pygame.py b/dreader/hal_pygame.py new file mode 100644 index 0000000..a7f3b03 --- /dev/null +++ b/dreader/hal_pygame.py @@ -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() diff --git a/dreader/handlers/__init__.py b/dreader/handlers/__init__.py new file mode 100644 index 0000000..147b058 --- /dev/null +++ b/dreader/handlers/__init__.py @@ -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'] diff --git a/dreader/handlers/gestures.py b/dreader/handlers/gestures.py new file mode 100644 index 0000000..40874ae --- /dev/null +++ b/dreader/handlers/gestures.py @@ -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, {}) diff --git a/dreader/html_generator.py b/dreader/html_generator.py new file mode 100644 index 0000000..0820ba6 --- /dev/null +++ b/dreader/html_generator.py @@ -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'' + elif book.get('cover_data'): + cover_cell = f'' + else: + cover_cell = '[No cover]' + + # Add book info cell + info_cell = f'{book["title"]}
{book["author"]}' + + rows.append(f'{cover_cell}{info_cell}') + + table_html = '\n'.join(rows) + + return f''' + + + + + Library + + +

My Library

+

{len(books)} books

+ +{table_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''' + + + + + + {book_title} + + + +
+
+
{book_title}
+
{book_author}
+
+
+ + + +
+
+ +
+ Page +
+ + + + +''' + 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''' + + + + + Settings + + + +

+ Settings +

+ +

+ Adjust reading preferences +

+ +
+

+ Font Family: {font_family_display} +

+

+ Document Default +

+

+ Serif +

+

+ Sans-Serif +

+

+ Monospace +

+ +

+ Font Size: {font_percent}% +

+

+ Decrease [ - ] +

+

+ Increase [ + ] +

+ +

+ Line Spacing: {line_spacing}px +

+

+ Decrease [ - ] +

+

+ Increase [ + ] +

+ +

+ Paragraph Spacing: {inter_block_spacing}px +

+

+ Decrease [ - ] +

+

+ Increase [ + ] +

+ +

+ Word Spacing: {word_spacing}px +

+

+ Decrease [ - ] +

+

+ Increase [ + ] +

+
+ +
+

+ ◄ Back to Library +

+
+ +

+ Changes apply in real-time • Tap outside to close +

+ + +''' + 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'

' + f'' + f'{link_text}

' + ) + + # 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''' +
+ + ← Prev + + + Page {toc_page + 1} of {toc_total_pages} + + + Next → + +
+ ''' + + # Render simple white panel - compositing will be done by OverlayManager + html = f''' + + + + + Table of Contents + + + +

+ Table of Contents +

+ +

+ {len(chapters)} chapters +

+ +
+ {"".join(chapter_items)} +
+ + {toc_pagination} + +

+ Tap a chapter to navigate • Tap outside to close +

+ + +''' + 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''' + + +
{bookmark['name']}
+
{bookmark.get('position', '')}
+ + + + + + ''') + + html = f''' + + + + + + Bookmarks + + + +
+
+ Bookmarks + +
+ +
+ + {"".join(bookmark_rows)} +
+
+
+ + +''' + 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'

' + f'' + f'{link_text}

' + ) + + # 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'

' + f'' + f'{name}' + f'{position_text}' + f'

' + ) + + # 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 '

No chapters available

' + bookmarks_html = ''.join(bookmark_items) if bookmark_items else '

No bookmarks yet

' + + # 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''' +
+ + ← Prev + + + Page {toc_page + 1} of {toc_total_pages} + + + Next → + +
+ ''' + + # 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''' +
+ + ← Prev + + + Page {bookmarks_page + 1} of {bookmarks_total_pages} + + + Next → + +
+ ''' + + html = f''' + + + + + Navigation + + + + +
+ + Contents + + + Bookmarks + +
+ + +
+

+ Table of Contents +

+

+ {len(chapters)} chapters +

+
+ {chapters_html} +
+ {toc_pagination} +
+ + +
+

+ Bookmarks +

+

+ {len(bookmarks)} saved +

+
+ {bookmarks_html} +
+ {bookmarks_pagination} +
+ + +
+ + Close + +
+ + + +''' + return html diff --git a/dreader/library.py b/dreader/library.py new file mode 100644 index 0000000..be4c951 --- /dev/null +++ b/dreader/library.py @@ -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() diff --git a/dreader/main.py b/dreader/main.py new file mode 100644 index 0000000..84b8e23 --- /dev/null +++ b/dreader/main.py @@ -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 diff --git a/dreader/managers/__init__.py b/dreader/managers/__init__.py new file mode 100644 index 0000000..7444f42 --- /dev/null +++ b/dreader/managers/__init__.py @@ -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'] diff --git a/dreader/managers/document.py b/dreader/managers/document.py new file mode 100644 index 0000000..7570813 --- /dev/null +++ b/dreader/managers/document.py @@ -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 diff --git a/dreader/managers/highlight_coordinator.py b/dreader/managers/highlight_coordinator.py new file mode 100644 index 0000000..7edbdb9 --- /dev/null +++ b/dreader/managers/highlight_coordinator.py @@ -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 diff --git a/dreader/managers/settings.py b/dreader/managers/settings.py new file mode 100644 index 0000000..ac39a43 --- /dev/null +++ b/dreader/managers/settings.py @@ -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 diff --git a/dreader/overlays/__init__.py b/dreader/overlays/__init__.py new file mode 100644 index 0000000..030df3b --- /dev/null +++ b/dreader/overlays/__init__.py @@ -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', +] diff --git a/dreader/overlays/base.py b/dreader/overlays/base.py new file mode 100644 index 0000000..0712b65 --- /dev/null +++ b/dreader/overlays/base.py @@ -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 diff --git a/dreader/overlays/navigation.py b/dreader/overlays/navigation.py new file mode 100644 index 0000000..876646e --- /dev/null +++ b/dreader/overlays/navigation.py @@ -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) diff --git a/dreader/overlays/settings.py b/dreader/overlays/settings.py new file mode 100644 index 0000000..c644a07 --- /dev/null +++ b/dreader/overlays/settings.py @@ -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 + }) diff --git a/dreader/overlays/toc.py b/dreader/overlays/toc.py new file mode 100644 index 0000000..410e0e9 --- /dev/null +++ b/dreader/overlays/toc.py @@ -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, {}) diff --git a/dreader/state.py b/dreader/state.py new file mode 100644 index 0000000..24fb432 --- /dev/null +++ b/dreader/state.py @@ -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 diff --git a/examples/calibrate_accelerometer.py b/examples/calibrate_accelerometer.py new file mode 100755 index 0000000..fbcac57 --- /dev/null +++ b/examples/calibrate_accelerometer.py @@ -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) diff --git a/examples/demo_accelerometer_page_flip.py b/examples/demo_accelerometer_page_flip.py new file mode 100755 index 0000000..86ce0cc --- /dev/null +++ b/examples/demo_accelerometer_page_flip.py @@ -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 +""" + +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 ") + 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) diff --git a/examples/demo_accelerometer_simple.py b/examples/demo_accelerometer_simple.py new file mode 100755 index 0000000..3819078 --- /dev/null +++ b/examples/demo_accelerometer_simple.py @@ -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 +""" + +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 ") + 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) diff --git a/examples/demo_pagination.py b/examples/demo_pagination.py new file mode 100644 index 0000000..a675f26 --- /dev/null +++ b/examples/demo_pagination.py @@ -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: and ") + 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() diff --git a/examples/demo_settings_overlay.py b/examples/demo_settings_overlay.py new file mode 100644 index 0000000..abd911b --- /dev/null +++ b/examples/demo_settings_overlay.py @@ -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() diff --git a/examples/demo_toc_overlay.py b/examples/demo_toc_overlay.py new file mode 100755 index 0000000..9b26794 --- /dev/null +++ b/examples/demo_toc_overlay.py @@ -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() diff --git a/examples/ereader_demo.py b/examples/ereader_demo.py new file mode 100644 index 0000000..f8abdd5 --- /dev/null +++ b/examples/ereader_demo.py @@ -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() diff --git a/examples/generate_ereader_gifs.py b/examples/generate_ereader_gifs.py new file mode 100644 index 0000000..d9c6aed --- /dev/null +++ b/examples/generate_ereader_gifs.py @@ -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() diff --git a/examples/generate_library_demo_gif.py b/examples/generate_library_demo_gif.py new file mode 100755 index 0000000..cea4860 --- /dev/null +++ b/examples/generate_library_demo_gif.py @@ -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() diff --git a/examples/library_reading_integration.py b/examples/library_reading_integration.py new file mode 100755 index 0000000..4759439 --- /dev/null +++ b/examples/library_reading_integration.py @@ -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() diff --git a/examples/navigation_overlay_example.py b/examples/navigation_overlay_example.py new file mode 100644 index 0000000..bfe56c2 --- /dev/null +++ b/examples/navigation_overlay_example.py @@ -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 = """ + + Demo Book + +

Chapter 1: Introduction

+

This is the first chapter with some introductory content.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+ +

Chapter 2: Main Content

+

This is the second chapter with main content.

+

Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+ +

Chapter 3: Conclusion

+

This is the final chapter with concluding remarks.

+

Ut enim ad minim veniam, quis nostrud exercitation ullamco.

+ + + """ + 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() diff --git a/examples/persistent_settings_example.py b/examples/persistent_settings_example.py new file mode 100755 index 0000000..058654f --- /dev/null +++ b/examples/persistent_settings_example.py @@ -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() diff --git a/examples/run_on_hardware.py b/examples/run_on_hardware.py new file mode 100755 index 0000000..ef0a49b --- /dev/null +++ b/examples/run_on_hardware.py @@ -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) diff --git a/examples/run_on_hardware_config.py b/examples/run_on_hardware_config.py new file mode 100755 index 0000000..dfa648e --- /dev/null +++ b/examples/run_on_hardware_config.py @@ -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) diff --git a/examples/simple_ereader_example.py b/examples/simple_ereader_example.py new file mode 100644 index 0000000..590934d --- /dev/null +++ b/examples/simple_ereader_example.py @@ -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() diff --git a/examples/simple_word_highlight.py b/examples/simple_word_highlight.py new file mode 100644 index 0000000..9681a35 --- /dev/null +++ b/examples/simple_word_highlight.py @@ -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() diff --git a/examples/test_font_family.py b/examples/test_font_family.py new file mode 100755 index 0000000..6a69cce --- /dev/null +++ b/examples/test_font_family.py @@ -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 = """ + + Font Family Test + +

Font Family Test Document

+

This is a test document to demonstrate the font family setting feature.

+

The quick brown fox jumps over the lazy dog. 0123456789

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+ + + """ + 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() diff --git a/examples/word_selection_highlighting.py b/examples/word_selection_highlighting.py new file mode 100644 index 0000000..af6f803 --- /dev/null +++ b/examples/word_selection_highlighting.py @@ -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() diff --git a/external/dreader-hal b/external/dreader-hal new file mode 160000 index 0000000..be3aed6 --- /dev/null +++ b/external/dreader-hal @@ -0,0 +1 @@ +Subproject commit be3aed6e5e2ef5c80235dcb3f8efa1aeb2ec5abe diff --git a/hardware_config.json b/hardware_config.json new file mode 100644 index 0000000..7a3ba69 --- /dev/null +++ b/hardware_config.json @@ -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" + } +} diff --git a/install_hardware_drivers.sh b/install_hardware_drivers.sh new file mode 100755 index 0000000..1101215 --- /dev/null +++ b/install_hardware_drivers.sh @@ -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 "" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0d4c5a5 --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/run_dreader.py b/run_dreader.py new file mode 100755 index 0000000..7640268 --- /dev/null +++ b/run_dreader.py @@ -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() diff --git a/setup_rpi.py b/setup_rpi.py new file mode 100755 index 0000000..c42f4ed --- /dev/null +++ b/setup_rpi.py @@ -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) diff --git a/test_pagination_visual.py b/test_pagination_visual.py new file mode 100644 index 0000000..56de105 --- /dev/null +++ b/test_pagination_visual.py @@ -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() diff --git a/tests/data/cover 1.png b/tests/data/cover 1.png new file mode 100644 index 0000000..133fa42 Binary files /dev/null and b/tests/data/cover 1.png differ diff --git a/tests/data/cover 2.png b/tests/data/cover 2.png new file mode 100644 index 0000000..f3c6d4d Binary files /dev/null and b/tests/data/cover 2.png differ diff --git a/tests/data/cover 3.png b/tests/data/cover 3.png new file mode 100644 index 0000000..18a722d Binary files /dev/null and b/tests/data/cover 3.png differ diff --git a/tests/data/cover 4.png b/tests/data/cover 4.png new file mode 100644 index 0000000..3725d31 Binary files /dev/null and b/tests/data/cover 4.png differ diff --git a/tests/data/library-epub/minimal-test.epub b/tests/data/library-epub/minimal-test.epub new file mode 100644 index 0000000..6ba94db Binary files /dev/null and b/tests/data/library-epub/minimal-test.epub differ diff --git a/tests/data/library-epub/pg11-images-3.epub b/tests/data/library-epub/pg11-images-3.epub new file mode 100644 index 0000000..727dfe6 Binary files /dev/null and b/tests/data/library-epub/pg11-images-3.epub differ diff --git a/tests/data/library-epub/pg16328-images-3.epub b/tests/data/library-epub/pg16328-images-3.epub new file mode 100644 index 0000000..893e1ab Binary files /dev/null and b/tests/data/library-epub/pg16328-images-3.epub differ diff --git a/tests/data/library-epub/pg2554-images-3.epub b/tests/data/library-epub/pg2554-images-3.epub new file mode 100644 index 0000000..6249194 Binary files /dev/null and b/tests/data/library-epub/pg2554-images-3.epub differ diff --git a/tests/data/library-epub/pg34901-images-3.epub b/tests/data/library-epub/pg34901-images-3.epub new file mode 100644 index 0000000..b1cbdfa Binary files /dev/null and b/tests/data/library-epub/pg34901-images-3.epub differ diff --git a/tests/data/library-epub/pg43-images-3.epub b/tests/data/library-epub/pg43-images-3.epub new file mode 100644 index 0000000..28c95ac Binary files /dev/null and b/tests/data/library-epub/pg43-images-3.epub differ diff --git a/tests/data/library-epub/pg5200-images-3.epub b/tests/data/library-epub/pg5200-images-3.epub new file mode 100644 index 0000000..8670db9 Binary files /dev/null and b/tests/data/library-epub/pg5200-images-3.epub differ diff --git a/tests/data/library-epub/pg64317-images-3.epub b/tests/data/library-epub/pg64317-images-3.epub new file mode 100644 index 0000000..6348318 Binary files /dev/null and b/tests/data/library-epub/pg64317-images-3.epub differ diff --git a/tests/data/sample_image.jpg b/tests/data/sample_image.jpg new file mode 100644 index 0000000..9584729 Binary files /dev/null and b/tests/data/sample_image.jpg differ diff --git a/tests/data/test.epub b/tests/data/test.epub new file mode 100644 index 0000000..727dfe6 Binary files /dev/null and b/tests/data/test.epub differ diff --git a/tests/test_accelerometer_gestures.py b/tests/test_accelerometer_gestures.py new file mode 100644 index 0000000..98cec19 --- /dev/null +++ b/tests/test_accelerometer_gestures.py @@ -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"]) diff --git a/tests/test_backward_nav_minimal.py b/tests/test_backward_nav_minimal.py new file mode 100644 index 0000000..ae799f6 --- /dev/null +++ b/tests/test_backward_nav_minimal.py @@ -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() diff --git a/tests/test_backward_navigation_detailed.py b/tests/test_backward_navigation_detailed.py new file mode 100644 index 0000000..4fe6707 --- /dev/null +++ b/tests/test_backward_navigation_detailed.py @@ -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() diff --git a/tests/test_backward_navigation_resume.py b/tests/test_backward_navigation_resume.py new file mode 100644 index 0000000..1df5ff8 --- /dev/null +++ b/tests/test_backward_navigation_resume.py @@ -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() diff --git a/tests/test_boot_recovery.py b/tests/test_boot_recovery.py new file mode 100644 index 0000000..ec4de27 --- /dev/null +++ b/tests/test_boot_recovery.py @@ -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() diff --git a/tests/test_epub_images.py b/tests/test_epub_images.py new file mode 100644 index 0000000..12e0126 --- /dev/null +++ b/tests/test_epub_images.py @@ -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!") diff --git a/tests/test_ereader_application.py b/tests/test_ereader_application.py new file mode 100644 index 0000000..907c70d --- /dev/null +++ b/tests/test_ereader_application.py @@ -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() diff --git a/tests/test_ereader_highlighting.py b/tests/test_ereader_highlighting.py new file mode 100644 index 0000000..691bdcb --- /dev/null +++ b/tests/test_ereader_highlighting.py @@ -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() diff --git a/tests/test_ereader_system.py b/tests/test_ereader_system.py new file mode 100644 index 0000000..199d589 --- /dev/null +++ b/tests/test_ereader_system.py @@ -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() diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..fe8e15a --- /dev/null +++ b/tests/test_examples.py @@ -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() diff --git a/tests/test_gesture.py b/tests/test_gesture.py new file mode 100644 index 0000000..a50fbcb --- /dev/null +++ b/tests/test_gesture.py @@ -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() diff --git a/tests/test_library_interaction.py b/tests/test_library_interaction.py new file mode 100644 index 0000000..9ab2154 --- /dev/null +++ b/tests/test_library_interaction.py @@ -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() diff --git a/tests/test_navigation_overlay.py b/tests/test_navigation_overlay.py new file mode 100644 index 0000000..f1e2116 --- /dev/null +++ b/tests/test_navigation_overlay.py @@ -0,0 +1,196 @@ +""" +Tests for the unified navigation overlay (TOC + Bookmarks tabs) +""" +import pytest +from pathlib import Path +from PIL import Image + +from dreader.application import EbookReader +from dreader.state import OverlayState +from dreader.gesture import TouchEvent, GestureType, ActionType + + +@pytest.fixture +def reader_with_book(): + """Create a reader with a test book loaded""" + reader = EbookReader(page_size=(400, 600), margin=10) + + # Load a simple test book + test_book = Path(__file__).parent.parent / "examples" / "books" / "hamlet.epub" + if test_book.exists(): + reader.load_epub(str(test_book)) + else: + # Fallback: create simple HTML for testing + html = """ + + +

Chapter 1

+

This is chapter 1 content

+

Chapter 2

+

This is chapter 2 content

+ + + """ + reader.load_html(html, title="Test Book", author="Test Author", document_id="test") + + yield reader + reader.close() + + +def test_open_navigation_overlay_contents_tab(reader_with_book): + """Test opening navigation overlay with Contents tab active""" + reader = reader_with_book + + # Open navigation overlay with contents tab + image = reader.open_navigation_overlay(active_tab="contents") + + assert image is not None + assert isinstance(image, Image.Image) + assert reader.get_overlay_state() == OverlayState.NAVIGATION + assert reader.is_overlay_open() + + +def test_open_navigation_overlay_bookmarks_tab(reader_with_book): + """Test opening navigation overlay with Bookmarks tab active""" + reader = reader_with_book + + # Save a bookmark first + reader.save_position("Test Bookmark") + + # Open navigation overlay with bookmarks tab + image = reader.open_navigation_overlay(active_tab="bookmarks") + + assert image is not None + assert isinstance(image, Image.Image) + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + +def test_switch_navigation_tabs(reader_with_book): + """Test switching between Contents and Bookmarks tabs""" + reader = reader_with_book + + # Open with contents tab + reader.open_navigation_overlay(active_tab="contents") + + # Switch to bookmarks + image = reader.switch_navigation_tab("bookmarks") + assert image is not None + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + # Switch back to contents + image = reader.switch_navigation_tab("contents") + assert image is not None + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + +def test_close_navigation_overlay(reader_with_book): + """Test closing navigation overlay""" + reader = reader_with_book + + # Open overlay + reader.open_navigation_overlay() + assert reader.is_overlay_open() + + # Close overlay + image = reader.close_overlay() + assert image is not None + assert not reader.is_overlay_open() + assert reader.get_overlay_state() == OverlayState.NONE + + +def test_navigation_overlay_tab_switching_gesture(reader_with_book): + """Test tab switching via gesture/touch handling""" + reader = reader_with_book + + # Open navigation overlay + reader.open_navigation_overlay(active_tab="contents") + + # Query the overlay to find the bookmarks tab button + # This would normally be done by finding the coordinates of the "Bookmarks" tab + # For now, we test that the switch method works + result = reader.switch_navigation_tab("bookmarks") + + assert result is not None + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + +def test_navigation_overlay_with_no_bookmarks(reader_with_book): + """Test navigation overlay when there are no bookmarks""" + reader = reader_with_book + + # Open bookmarks tab (should show "No bookmarks yet") + image = reader.open_navigation_overlay(active_tab="bookmarks") + + assert image is not None + # The overlay should still open successfully + assert reader.get_overlay_state() == OverlayState.NAVIGATION + + +def test_navigation_overlay_preserves_page_position(reader_with_book): + """Test that opening/closing navigation overlay preserves reading position""" + reader = reader_with_book + + # Go to page 2 + reader.next_page() + initial_position = reader.get_position_info() + + # Open and close navigation overlay + reader.open_navigation_overlay() + reader.close_overlay() + + # Verify position hasn't changed + final_position = reader.get_position_info() + assert initial_position == final_position + + +def test_navigation_overlay_chapter_selection(reader_with_book): + """Test selecting a chapter from the navigation overlay""" + reader = reader_with_book + + # Get chapters + chapters = reader.get_chapters() + if len(chapters) < 2: + pytest.skip("Test book doesn't have enough chapters") + + # Open navigation overlay + reader.open_navigation_overlay(active_tab="contents") + + # Get initial position + initial_position = reader.get_position_info() + + # Jump to chapter via the reader method (simulating a tap on chapter) + reader.jump_to_chapter(chapters[1][1]) # chapters[1] = (title, index) + reader.close_overlay() + + # Verify position changed + new_position = reader.get_position_info() + assert new_position != initial_position + + +def test_navigation_overlay_bookmark_selection(reader_with_book): + """Test selecting a bookmark from the navigation overlay""" + reader = reader_with_book + + # Save a bookmark at page 1 + reader.save_position("Bookmark 1") + + # Move to a different page + reader.next_page() + position_before = reader.get_position_info() + + # Open navigation overlay with bookmarks tab + reader.open_navigation_overlay(active_tab="bookmarks") + + # Load the bookmark (simulating a tap on bookmark) + page = reader.load_position("Bookmark 1") + assert page is not None + + reader.close_overlay() + + # Verify position changed back to bookmark + position_after = reader.get_position_info() + assert position_after != position_before + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_settings_overlay.py b/tests/test_settings_overlay.py new file mode 100644 index 0000000..5223df6 --- /dev/null +++ b/tests/test_settings_overlay.py @@ -0,0 +1,396 @@ +""" +Unit tests for Settings overlay functionality. + +Tests the complete workflow of: +1. Opening settings overlay with swipe down gesture +2. Adjusting settings (font size, line spacing, etc.) +3. Live preview updates +4. Closing overlay +""" + +import unittest +from pathlib import Path +from dreader import ( + EbookReader, + TouchEvent, + GestureType, + ActionType, + OverlayState +) + + +class TestSettingsOverlay(unittest.TestCase): + """Test Settings overlay opening, interaction, and closing""" + + def setUp(self): + """Set up test reader with a book""" + import os + import zipfile + + self.reader = EbookReader(page_size=(800, 1200)) + + # Load a test EPUB - use a larger EPUB for spacing tests + epub_dir = Path(__file__).parent / 'data' / 'library-epub' + epubs = list(epub_dir.glob('*.epub')) + if not epubs: + self.skipTest("No test EPUB files available") + + # Prefer larger EPUBs for better testing of spacing changes + # Skip minimal-test.epub as it has too little content + epubs = [e for e in epubs if 'minimal' not in e.name] + if not epubs: + epubs = list(epub_dir.glob('*.epub')) + + test_epub = epubs[0] + + # Debug logging + print(f"\n=== EPUB Loading Debug Info ===") + print(f"Test EPUB path: {test_epub}") + print(f"Absolute path: {test_epub.absolute()}") + print(f"File exists: {test_epub.exists()}") + print(f"File size: {test_epub.stat().st_size if test_epub.exists() else 'N/A'}") + print(f"Is file: {test_epub.is_file() if test_epub.exists() else 'N/A'}") + print(f"Readable: {os.access(test_epub, os.R_OK) if test_epub.exists() else 'N/A'}") + + # Test if it's a valid ZIP + if test_epub.exists(): + # Check file magic bytes + with open(test_epub, 'rb') as f: + first_bytes = f.read(10) + print(f"First 10 bytes (hex): {first_bytes.hex()}") + print(f"First 10 bytes (ascii): {first_bytes[:4]}") + print(f"Is PK header: {first_bytes[:2] == b'PK'}") + + try: + with zipfile.ZipFile(test_epub, 'r') as zf: + print(f"Valid ZIP: True") + print(f"Files in ZIP: {len(zf.namelist())}") + print(f"First 3 files: {zf.namelist()[:3]}") + except Exception as e: + print(f"ZIP validation error: {e}") + + # Try to load + success = self.reader.load_epub(str(test_epub)) + + if not success: + print(f"=== Load failed ===") + # Try loading with pyWebLayout directly for more detailed error + try: + from pyWebLayout.io.readers.epub_reader import read_epub + book = read_epub(str(test_epub)) + print(f"Direct pyWebLayout load: SUCCESS (unexpected!)") + except Exception as e: + print(f"Direct pyWebLayout load error: {e}") + import traceback + traceback.print_exc() + + self.assertTrue(success, f"Failed to load test EPUB: {test_epub}") + + def tearDown(self): + """Clean up""" + self.reader.close() + + def test_open_settings_overlay_directly(self): + """Test opening settings overlay using direct API call""" + # Initially no overlay + self.assertFalse(self.reader.is_overlay_open()) + + # Open settings overlay + overlay_image = self.reader.open_settings_overlay() + + # Should return an image + self.assertIsNotNone(overlay_image) + self.assertEqual(overlay_image.size, (800, 1200)) + + # Overlay should be open + self.assertTrue(self.reader.is_overlay_open()) + self.assertEqual(self.reader.get_overlay_state(), OverlayState.SETTINGS) + + def test_close_settings_overlay_directly(self): + """Test closing settings overlay using direct API call""" + # Open overlay first + self.reader.open_settings_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Close overlay + page_image = self.reader.close_overlay() + + # Should return base page + self.assertIsNotNone(page_image) + + # Overlay should be closed + self.assertFalse(self.reader.is_overlay_open()) + self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE) + + def test_swipe_down_from_top_opens_settings(self): + """Test that swipe down from top of screen opens settings overlay""" + # Create swipe down event from top of screen (y=100, which is < 20% of 1200) + event = TouchEvent( + gesture=GestureType.SWIPE_DOWN, + x=400, + y=100 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should open overlay + self.assertEqual(response.action, ActionType.OVERLAY_OPENED) + self.assertEqual(response.data['overlay_type'], 'settings') + self.assertTrue(self.reader.is_overlay_open()) + + def test_swipe_down_from_middle_does_not_open_settings(self): + """Test that swipe down from middle of screen does NOT open settings""" + # Create swipe down event from middle of screen (y=600, which is > 20% of 1200) + event = TouchEvent( + gesture=GestureType.SWIPE_DOWN, + x=400, + y=600 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should not open overlay + self.assertEqual(response.action, ActionType.NONE) + self.assertFalse(self.reader.is_overlay_open()) + + def test_tap_outside_closes_settings_overlay(self): + """Test that tapping outside the settings panel closes it""" + # Open overlay first + self.reader.open_settings_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Tap in the far left (outside the centered panel) + event = TouchEvent( + gesture=GestureType.TAP, + x=50, # Well outside panel + y=600 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should close overlay + self.assertEqual(response.action, ActionType.OVERLAY_CLOSED) + self.assertFalse(self.reader.is_overlay_open()) + + def test_font_size_increase(self): + """Test increasing font size through settings overlay""" + # Open overlay + self.reader.open_settings_overlay() + initial_font_scale = self.reader.base_font_scale + + # Get overlay reader to query button positions from the active overlay sub-application + overlay_subapp = self.reader._active_overlay + if not overlay_subapp: + self.skipTest("No active overlay sub-application") + + overlay_reader = overlay_subapp._overlay_reader + + if not overlay_reader or not overlay_reader.manager: + self.skipTest("Overlay reader not available for querying") + + # Query the overlay to find the "A+" button link + # We'll search for it by looking for links with "setting:font_increase" + page = overlay_reader.manager.get_current_page() + + # Try multiple Y positions in the font size row to find the button + # Panel is 60% of screen width (480px) centered (x offset = 160) + # First setting row should be around y=100-150 in panel coordinates + found_button = False + tap_x = None + tap_y = None + + for y in range(80, 180, 10): + for x in range(300, 450, 20): # Right side of panel where buttons are + # Translate to panel coordinates + panel_x_offset = int((800 - 480) / 2) + panel_y_offset = int((1200 - 840) / 2) + panel_x = x - panel_x_offset + panel_y = y - panel_y_offset + + if panel_x < 0 or panel_y < 0: + continue + + result = page.query_point((panel_x, panel_y)) + if result and result.link_target == "setting:font_increase": + tap_x = x + tap_y = y + found_button = True + break + if found_button: + break + + if not found_button: + # Fallback: use approximate coordinates + # Based on HTML layout: panel center + right side button + tap_x = 550 + tap_y = 350 + + # Tap the increase button (in screen coordinates) + event = TouchEvent( + gesture=GestureType.TAP, + x=tap_x, + y=tap_y + ) + + response = self.reader.handle_touch(event) + + # Should either change setting or close (depending on whether we hit the button) + if response.action == ActionType.SETTING_CHANGED: + # Font size should have increased + self.assertGreater(self.reader.base_font_scale, initial_font_scale) + # Overlay should still be open + self.assertTrue(self.reader.is_overlay_open()) + else: + # If we missed the button, that's OK for this test + pass + + def test_line_spacing_adjustment(self): + """Test adjusting line spacing through settings overlay""" + # Open overlay + self.reader.open_settings_overlay() + initial_spacing = self.reader.page_style.line_spacing + + # Close overlay for this test (full interaction would require precise coordinates) + self.reader.close_overlay() + + # Verify we can adjust line spacing programmatically + self.reader.set_line_spacing(initial_spacing + 2) + self.assertEqual(self.reader.page_style.line_spacing, initial_spacing + 2) + + def test_settings_values_displayed_in_overlay(self): + """Test that current settings values are shown in the overlay""" + # Set specific values + self.reader.set_font_size(1.5) # 150% + self.reader.set_line_spacing(10) + + # Open overlay + overlay_image = self.reader.open_settings_overlay() + self.assertIsNotNone(overlay_image) + + # Overlay should be open with current values + # (Visual verification would show "150%" and "10px" in the HTML) + self.assertTrue(self.reader.is_overlay_open()) + + def test_multiple_setting_changes(self): + """Test making multiple setting changes in sequence""" + initial_font = self.reader.base_font_scale + initial_spacing = self.reader.page_style.line_spacing + + # Change font size + self.reader.increase_font_size() + self.assertNotEqual(self.reader.base_font_scale, initial_font) + + # Change line spacing + self.reader.set_line_spacing(initial_spacing + 5) + self.assertNotEqual(self.reader.page_style.line_spacing, initial_spacing) + + # Open overlay to verify values + overlay_image = self.reader.open_settings_overlay() + self.assertIsNotNone(overlay_image) + + def test_settings_persist_after_overlay_close(self): + """Test that setting changes persist after closing overlay""" + # Make a change + initial_font = self.reader.base_font_scale + self.reader.increase_font_size() + new_font = self.reader.base_font_scale + + # Open and close overlay + self.reader.open_settings_overlay() + self.reader.close_overlay() + + # Settings should still be changed + self.assertEqual(self.reader.base_font_scale, new_font) + self.assertNotEqual(self.reader.base_font_scale, initial_font) + + def test_overlay_refresh_after_setting_change(self): + """Test that overlay can be refreshed with updated values""" + # Open overlay + self.reader.open_settings_overlay() + + # Access refresh method through active overlay sub-application + overlay_subapp = self.reader._active_overlay + if not overlay_subapp: + self.skipTest("No active overlay sub-application") + + # Change a setting programmatically + self.reader.increase_font_size() + new_page = self.reader.get_current_page(include_highlights=False) + + # Refresh overlay + refreshed_image = overlay_subapp.refresh( + updated_base_page=new_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 + ) + + self.assertIsNotNone(refreshed_image) + self.assertEqual(refreshed_image.size, (800, 1200)) + + def test_line_spacing_actually_changes_rendering(self): + """Verify that line spacing changes produce different rendered images""" + # Close any open overlay first + if self.reader.is_overlay_open(): + self.reader.close_overlay() + + # Set initial line spacing and get page + self.reader.set_line_spacing(5) + page1 = self.reader.get_current_page() + self.assertIsNotNone(page1) + + # Change line spacing significantly + self.reader.set_line_spacing(30) + page2 = self.reader.get_current_page() + self.assertIsNotNone(page2) + + # Images should be different (different line spacing should affect rendering) + self.assertNotEqual(page1.tobytes(), page2.tobytes(), + "Line spacing change should affect rendering") + + def test_inter_block_spacing_actually_changes_rendering(self): + """Verify that inter-block spacing changes produce different rendered images""" + # Close any open overlay first + if self.reader.is_overlay_open(): + self.reader.close_overlay() + + # Set initial inter-block spacing and get page + self.reader.set_inter_block_spacing(15) + page1 = self.reader.get_current_page() + self.assertIsNotNone(page1) + + # Change inter-block spacing significantly + self.reader.set_inter_block_spacing(50) + page2 = self.reader.get_current_page() + self.assertIsNotNone(page2) + + # Images should be different + self.assertNotEqual(page1.tobytes(), page2.tobytes(), + "Inter-block spacing change should affect rendering") + + def test_word_spacing_actually_changes_rendering(self): + """Verify that word spacing changes produce different rendered images""" + # Close any open overlay first + if self.reader.is_overlay_open(): + self.reader.close_overlay() + + # Set initial word spacing and get page + self.reader.set_word_spacing(0) + page1 = self.reader.get_current_page() + self.assertIsNotNone(page1) + + # Change word spacing significantly + self.reader.set_word_spacing(20) + page2 = self.reader.get_current_page() + self.assertIsNotNone(page2) + + # Images should be different + self.assertNotEqual(page1.tobytes(), page2.tobytes(), + "Word spacing change should affect rendering") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_toc_overlay.py b/tests/test_toc_overlay.py new file mode 100644 index 0000000..3a58a41 --- /dev/null +++ b/tests/test_toc_overlay.py @@ -0,0 +1,456 @@ +""" +Unit tests for TOC overlay functionality. + +Tests the complete workflow of: +1. Opening TOC overlay with swipe up gesture +2. Selecting a chapter from the TOC +3. Closing overlay by tapping outside or swiping down +""" + +import unittest +from pathlib import Path +from dreader import ( + EbookReader, + TouchEvent, + GestureType, + ActionType, + OverlayState +) + + +class TestTOCOverlay(unittest.TestCase): + """Test TOC overlay opening, interaction, and closing""" + + def setUp(self): + """Set up test reader with a book""" + self.reader = EbookReader(page_size=(800, 1200)) + + # Load a test EPUB + test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub' + if not test_epub.exists(): + # Try to find any EPUB in test data + epub_dir = Path(__file__).parent / 'data' / 'library-epub' + epubs = list(epub_dir.glob('*.epub')) + if epubs: + test_epub = epubs[0] + else: + self.skipTest("No test EPUB files available") + + success = self.reader.load_epub(str(test_epub)) + self.assertTrue(success, "Failed to load test EPUB") + + def tearDown(self): + """Clean up""" + self.reader.close() + + def test_overlay_manager_initialization(self): + """Test that overlay sub-applications are properly initialized""" + # Check that overlay sub-applications exist + self.assertIsNotNone(self.reader._overlay_subapps) + self.assertIn(OverlayState.TOC, self.reader._overlay_subapps) + self.assertIn(OverlayState.SETTINGS, self.reader._overlay_subapps) + self.assertIn(OverlayState.NAVIGATION, self.reader._overlay_subapps) + + # Initially no overlay should be active + self.assertFalse(self.reader.is_overlay_open()) + self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE) + + def test_open_toc_overlay_directly(self): + """Test opening TOC overlay using direct API call""" + # Initially no overlay + self.assertFalse(self.reader.is_overlay_open()) + + # Open TOC overlay + overlay_image = self.reader.open_toc_overlay() + + # Should return an image + self.assertIsNotNone(overlay_image) + self.assertEqual(overlay_image.size, (800, 1200)) + + # Overlay should be open + self.assertTrue(self.reader.is_overlay_open()) + self.assertEqual(self.reader.get_overlay_state(), OverlayState.TOC) + + def test_close_toc_overlay_directly(self): + """Test closing TOC overlay using direct API call""" + # Open overlay first + self.reader.open_toc_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Close overlay + page_image = self.reader.close_overlay() + + # Should return base page + self.assertIsNotNone(page_image) + + # Overlay should be closed + self.assertFalse(self.reader.is_overlay_open()) + self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE) + + def test_swipe_up_from_bottom_opens_toc(self): + """Test that swipe up from bottom of screen opens TOC overlay""" + # Create swipe up event from bottom of screen (y=1100, which is > 80% of 1200) + event = TouchEvent( + gesture=GestureType.SWIPE_UP, + x=400, + y=1100 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should open overlay (navigation or toc, depending on implementation) + self.assertEqual(response.action, ActionType.OVERLAY_OPENED) + self.assertIn(response.data['overlay_type'], ['toc', 'navigation']) + self.assertTrue(self.reader.is_overlay_open()) + + def test_swipe_up_from_middle_opens_navigation(self): + """Test that swipe up from anywhere opens navigation overlay""" + # Create swipe up event from middle of screen + event = TouchEvent( + gesture=GestureType.SWIPE_UP, + x=400, + y=600 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should open navigation overlay from anywhere + self.assertEqual(response.action, ActionType.OVERLAY_OPENED) + self.assertIn(response.data['overlay_type'], ['toc', 'navigation']) + self.assertTrue(self.reader.is_overlay_open()) + + def test_swipe_down_closes_overlay(self): + """Test that swipe down closes the overlay""" + # Open overlay first + self.reader.open_toc_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Create swipe down event + event = TouchEvent( + gesture=GestureType.SWIPE_DOWN, + x=400, + y=300 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should close overlay + self.assertEqual(response.action, ActionType.OVERLAY_CLOSED) + self.assertFalse(self.reader.is_overlay_open()) + + def test_tap_outside_overlay_closes_it(self): + """Test that tapping outside the overlay panel closes it""" + # Open overlay first + self.reader.open_toc_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Tap in the far left (outside the centered panel) + # Panel is 60% wide centered, so left edge is at 20% + event = TouchEvent( + gesture=GestureType.TAP, + x=50, # Well outside panel + y=600 + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should close overlay + self.assertEqual(response.action, ActionType.OVERLAY_CLOSED) + self.assertFalse(self.reader.is_overlay_open()) + + def test_tap_on_chapter_selects_and_closes(self): + """Test that tapping on a chapter navigates to it and closes overlay""" + # Open overlay first + self.reader.open_toc_overlay() + chapters = self.reader.get_chapters() + + if len(chapters) < 2: + self.skipTest("Need at least 2 chapters for this test") + + # Calculate tap position for second chapter (index 1 - "Metamorphosis") + # Based on actual measurements from pyWebLayout query_point: + # Overlay bounds: (38, 138, 122, 16) -> X=[38,160], Y=[138,154] + # With panel offset (160, 180): Screen X=[198,320], Y=[318,334] + tap_x = 250 # Within the link text bounds + tap_y = 335 # Chapter 1 "Metamorphosis" at overlay Y=155 (138+16=154, screen 180+155=335) + + event = TouchEvent( + gesture=GestureType.TAP, + x=tap_x, + y=tap_y + ) + + # Handle gesture + response = self.reader.handle_touch(event) + + # Should select chapter + self.assertEqual(response.action, ActionType.CHAPTER_SELECTED) + self.assertIn('chapter_index', response.data) + + # Overlay should be closed + self.assertFalse(self.reader.is_overlay_open()) + + def test_multiple_overlay_operations(self): + """Test opening and closing overlay multiple times""" + # Open and close 3 times + for i in range(3): + # Open + self.reader.open_toc_overlay() + self.assertTrue(self.reader.is_overlay_open()) + + # Close + self.reader.close_overlay() + self.assertFalse(self.reader.is_overlay_open()) + + def test_overlay_with_page_navigation(self): + """Test that overlay works correctly after navigating pages""" + # Navigate to page 2 + self.reader.next_page() + + # Open overlay + overlay_image = self.reader.open_toc_overlay() + self.assertIsNotNone(overlay_image) + self.assertTrue(self.reader.is_overlay_open()) + + # Close overlay + self.reader.close_overlay() + self.assertFalse(self.reader.is_overlay_open()) + + def test_toc_overlay_contains_all_chapters(self): + """Test that TOC overlay includes all book chapters""" + chapters = self.reader.get_chapters() + + # Open overlay (this generates the HTML with chapters) + overlay_image = self.reader.open_toc_overlay() + self.assertIsNotNone(overlay_image) + + # Verify overlay manager has correct chapter count + # This is implicit in the rendering - if it renders without error, + # all chapters were included + self.assertTrue(self.reader.is_overlay_open()) + + def test_overlay_state_persistence_ready(self): + """Test that overlay state can be tracked (for future state persistence)""" + # This test verifies the state tracking is ready for StateManager integration + + # Start with no overlay + self.assertEqual(self.reader.current_overlay_state, OverlayState.NONE) + + # Open TOC + self.reader.open_toc_overlay() + self.assertEqual(self.reader.current_overlay_state, OverlayState.TOC) + + # Close overlay + self.reader.close_overlay() + self.assertEqual(self.reader.current_overlay_state, OverlayState.NONE) + + +class TestOverlayRendering(unittest.TestCase): + """Test overlay rendering and compositing""" + + def setUp(self): + """Set up test reader""" + self.reader = EbookReader(page_size=(800, 1200)) + test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub' + + if not test_epub.exists(): + epub_dir = Path(__file__).parent / 'data' / 'library-epub' + epubs = list(epub_dir.glob('*.epub')) + if epubs: + test_epub = epubs[0] + else: + self.skipTest("No test EPUB files available") + + self.reader.load_epub(str(test_epub)) + + def tearDown(self): + """Clean up""" + self.reader.close() + + def test_overlay_image_size(self): + """Test that overlay image matches page size""" + overlay_image = self.reader.open_toc_overlay() + self.assertEqual(overlay_image.size, (800, 1200)) + + def test_overlay_compositing(self): + """Test that overlay is properly composited on base page""" + # Get base page + base_page = self.reader.get_current_page() + + # Open overlay (creates composited image) + overlay_image = self.reader.open_toc_overlay() + + # Composited image should be different from base page + self.assertIsNotNone(overlay_image) + + # Images should have same size but different content + self.assertEqual(base_page.size, overlay_image.size) + + def test_overlay_html_to_image_conversion(self): + """Test that HTML overlay is correctly converted to image""" + from dreader.html_generator import generate_toc_overlay + + # Get chapters + chapters = self.reader.get_chapters() + chapter_data = [{"index": idx, "title": title} for title, idx in chapters] + + # Generate HTML + html = generate_toc_overlay(chapter_data) + self.assertIsNotNone(html) + self.assertIn("Table of Contents", html) + + # Open the TOC overlay which internally renders HTML to image + overlay_image = self.reader.open_toc_overlay() + + # Should produce valid image + self.assertIsNotNone(overlay_image) + self.assertEqual(overlay_image.size, (800, 1200)) + + +class TestTOCPagination(unittest.TestCase): + """Test TOC overlay pagination functionality""" + + def setUp(self): + """Set up test reader with a book""" + self.reader = EbookReader(page_size=(800, 1200)) + + # Load a test EPUB + test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub' + if not test_epub.exists(): + epub_dir = Path(__file__).parent / 'data' / 'library-epub' + epubs = list(epub_dir.glob('*.epub')) + if epubs: + test_epub = epubs[0] + else: + self.skipTest("No test EPUB files available") + + success = self.reader.load_epub(str(test_epub)) + self.assertTrue(success, "Failed to load test EPUB") + + def tearDown(self): + """Clean up""" + self.reader.close() + + def test_pagination_with_many_chapters(self): + """Test pagination when there are more chapters than fit on one page""" + from dreader.html_generator import generate_toc_overlay + + # Create test data with many chapters + chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(25)] + + # Generate HTML for page 1 (chapters 0-9) + html_page1 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10) + self.assertIn("1. Chapter 1", html_page1) + self.assertIn("10. Chapter 10", html_page1) + self.assertNotIn("11. Chapter 11", html_page1) + self.assertIn("Page 1 of 3", html_page1) + + # Generate HTML for page 2 (chapters 10-19) + html_page2 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=1, toc_items_per_page=10) + self.assertNotIn("10. Chapter 10", html_page2) + self.assertIn("11. Chapter 11", html_page2) + self.assertIn("20. Chapter 20", html_page2) + self.assertIn("Page 2 of 3", html_page2) + + # Generate HTML for page 3 (chapters 20-24) + html_page3 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=2, toc_items_per_page=10) + self.assertNotIn("20. Chapter 20", html_page3) + self.assertIn("21. Chapter 21", html_page3) + self.assertIn("25. Chapter 25", html_page3) + self.assertIn("Page 3 of 3", html_page3) + + def test_pagination_buttons_disabled_at_boundaries(self): + """Test that pagination buttons are disabled at first and last pages""" + from dreader.html_generator import generate_toc_overlay + + chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(25)] + + # Page 1: prev button should be disabled + html_page1 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10) + self.assertIn("page:prev", html_page1) + self.assertIn("page:next", html_page1) + # Check that prev button has disabled styling + self.assertIn("opacity: 0.3; pointer-events: none;", html_page1) + + # Last page: next button should be disabled + html_page3 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=2, toc_items_per_page=10) + self.assertIn("page:prev", html_page3) + self.assertIn("page:next", html_page3) + + def test_no_pagination_for_small_list(self): + """Test that pagination is not shown when all chapters fit on one page""" + from dreader.html_generator import generate_toc_overlay + + chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(5)] + + html = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10) + self.assertNotIn("page:prev", html) + self.assertNotIn("page:next", html) + self.assertNotIn("Page", html.split("chapters")[1]) # No "Page X of Y" after "N chapters" + + def test_navigation_overlay_pagination(self): + """Test pagination in the modern navigation overlay""" + from dreader.html_generator import generate_navigation_overlay + + chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(25)] + bookmarks = [{"name": f"Bookmark {i+1}", "position": f"Page {i}"} for i in range(15)] + + # Generate navigation overlay with pagination + html = generate_navigation_overlay( + chapters=chapters, + bookmarks=bookmarks, + active_tab="contents", + page_size=(800, 1200), + toc_page=1, + toc_items_per_page=10, + bookmarks_page=0 + ) + + # Should show chapters 11-20 on page 2 + self.assertIn("11. Chapter 11", html) + self.assertIn("20. Chapter 20", html) + self.assertNotIn("10. Chapter 10", html) + self.assertNotIn("21. Chapter 21", html) + + def test_bookmarks_pagination(self): + """Test pagination works for bookmarks tab too""" + from dreader.html_generator import generate_navigation_overlay + + chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(5)] + bookmarks = [{"name": f"Bookmark {i+1}", "position": f"Page {i}"} for i in range(25)] + + # Generate navigation overlay with bookmarks on page 2 + html = generate_navigation_overlay( + chapters=chapters, + bookmarks=bookmarks, + active_tab="bookmarks", + page_size=(800, 1200), + toc_page=0, + toc_items_per_page=10, + bookmarks_page=1 + ) + + # Should show bookmarks 11-20 on page 2 + self.assertIn("Bookmark 11", html) + self.assertIn("Bookmark 20", html) + self.assertNotIn("Bookmark 10", html) + self.assertNotIn("Bookmark 21", html) + + def test_pagination_handles_empty_list(self): + """Test pagination handles empty chapter list gracefully""" + from dreader.html_generator import generate_toc_overlay + + chapters = [] + html = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10) + + self.assertIn("0 chapters", html) + self.assertNotIn("page:prev", html) + self.assertNotIn("page:next", html) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..7d45aae --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for dreader modules.""" diff --git a/tests/unit/managers/__init__.py b/tests/unit/managers/__init__.py new file mode 100644 index 0000000..496d1d6 --- /dev/null +++ b/tests/unit/managers/__init__.py @@ -0,0 +1 @@ +"""Unit tests for manager modules.""" diff --git a/tests/unit/managers/test_document.py b/tests/unit/managers/test_document.py new file mode 100644 index 0000000..665db4e --- /dev/null +++ b/tests/unit/managers/test_document.py @@ -0,0 +1,164 @@ +""" +Unit tests for DocumentManager. + +Tests document loading in isolation without full EbookReader. +""" + +import unittest +import tempfile +import os +from pathlib import Path + +from dreader.managers.document import DocumentManager + + +class TestDocumentManager(unittest.TestCase): + """Test DocumentManager in isolation""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + self.epub_path = "tests/data/test.epub" + self.manager = DocumentManager() + + def tearDown(self): + """Clean up""" + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_initialization(self): + """Test manager initializes correctly""" + manager = DocumentManager() + + self.assertIsNone(manager.document_id) + self.assertIsNone(manager.title) + self.assertIsNone(manager.author) + self.assertIsNone(manager.blocks) + self.assertFalse(manager.is_loaded()) + + def test_load_valid_epub(self): + """Test loading a valid EPUB file""" + if not Path(self.epub_path).exists(): + self.skipTest(f"Test EPUB not found at {self.epub_path}") + + success = self.manager.load_epub(self.epub_path) + + self.assertTrue(success) + self.assertTrue(self.manager.is_loaded()) + self.assertIsNotNone(self.manager.document_id) + self.assertIsNotNone(self.manager.title) + self.assertIsNotNone(self.manager.author) + self.assertIsNotNone(self.manager.blocks) + self.assertGreater(len(self.manager.blocks), 0) + + def test_load_nonexistent_epub(self): + """Test loading a non-existent EPUB file""" + success = self.manager.load_epub("nonexistent.epub") + + self.assertFalse(success) + self.assertFalse(self.manager.is_loaded()) + + 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") + + success = self.manager.load_epub(invalid_path) + + self.assertFalse(success) + self.assertFalse(self.manager.is_loaded()) + + def test_load_html_success(self): + """Test loading HTML content""" + html = """ + + +

Test Document

+

This is a test paragraph.

+ + + """ + + success = self.manager.load_html( + html, + title="Test HTML", + author="Test Author", + document_id="test_html" + ) + + self.assertTrue(success) + self.assertTrue(self.manager.is_loaded()) + self.assertEqual(self.manager.title, "Test HTML") + self.assertEqual(self.manager.author, "Test Author") + self.assertEqual(self.manager.document_id, "test_html") + self.assertGreater(len(self.manager.blocks), 0) + + def test_load_empty_html(self): + """Test loading empty HTML""" + success = self.manager.load_html("") + + self.assertFalse(success) + self.assertFalse(self.manager.is_loaded()) + + def test_get_metadata(self): + """Test getting document metadata""" + if not Path(self.epub_path).exists(): + self.skipTest(f"Test EPUB not found at {self.epub_path}") + + self.manager.load_epub(self.epub_path) + metadata = self.manager.get_metadata() + + self.assertIsInstance(metadata, dict) + self.assertIn('title', metadata) + self.assertIn('author', metadata) + self.assertIn('document_id', metadata) + self.assertIn('total_blocks', metadata) + self.assertGreater(metadata['total_blocks'], 0) + + def test_get_blocks(self): + """Test getting content blocks""" + if not Path(self.epub_path).exists(): + self.skipTest(f"Test EPUB not found at {self.epub_path}") + + self.manager.load_epub(self.epub_path) + blocks = self.manager.get_blocks() + + self.assertIsInstance(blocks, list) + self.assertGreater(len(blocks), 0) + + def test_clear(self): + """Test clearing loaded document""" + if not Path(self.epub_path).exists(): + self.skipTest(f"Test EPUB not found at {self.epub_path}") + + self.manager.load_epub(self.epub_path) + self.assertTrue(self.manager.is_loaded()) + + self.manager.clear() + + self.assertFalse(self.manager.is_loaded()) + self.assertIsNone(self.manager.document_id) + self.assertIsNone(self.manager.title) + self.assertIsNone(self.manager.author) + self.assertIsNone(self.manager.blocks) + + def test_multiple_loads(self): + """Test loading multiple documents sequentially""" + html1 = "

Document 1

" + html2 = "

Document 2

" + + # Load first document + self.manager.load_html(html1, title="Doc 1", document_id="doc1") + self.assertEqual(self.manager.title, "Doc 1") + self.assertEqual(self.manager.document_id, "doc1") + + # Load second document (should replace first) + self.manager.load_html(html2, title="Doc 2", document_id="doc2") + self.assertEqual(self.manager.title, "Doc 2") + self.assertEqual(self.manager.document_id, "doc2") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/managers/test_settings.py b/tests/unit/managers/test_settings.py new file mode 100644 index 0000000..60efad0 --- /dev/null +++ b/tests/unit/managers/test_settings.py @@ -0,0 +1,220 @@ +""" +Unit tests for SettingsManager. + +Tests settings management in isolation using mocks. +""" + +import unittest +from unittest.mock import Mock, MagicMock + +from dreader.managers.settings import SettingsManager + + +class TestSettingsManager(unittest.TestCase): + """Test SettingsManager in isolation""" + + def setUp(self): + """Set up test environment""" + self.manager = SettingsManager() + + def test_initialization(self): + """Test manager initializes correctly""" + manager = SettingsManager() + + self.assertEqual(manager.font_scale, 1.0) + self.assertEqual(manager.font_scale_step, 0.1) + self.assertIsNone(manager.manager) + + def test_get_font_size(self): + """Test getting current font size""" + self.assertEqual(self.manager.get_font_size(), 1.0) + + self.manager.font_scale = 1.5 + self.assertEqual(self.manager.get_font_size(), 1.5) + + def test_set_font_size_without_manager(self): + """Test setting font size without layout manager returns None""" + result = self.manager.set_font_size(1.5) + self.assertIsNone(result) + + def test_set_font_size_with_manager(self): + """Test setting font size with layout manager""" + # Create mock manager + mock_layout_manager = Mock() + mock_page = Mock() + mock_page.render.return_value = "rendered_page" + mock_layout_manager.set_font_scale.return_value = mock_page + mock_layout_manager.font_scale = 1.0 + + self.manager.set_manager(mock_layout_manager) + + # Set font size + result = self.manager.set_font_size(1.5) + + self.assertIsNotNone(result) + self.assertEqual(result, "rendered_page") + self.assertEqual(self.manager.font_scale, 1.5) + mock_layout_manager.set_font_scale.assert_called_once_with(1.5) + + def test_font_size_clamping(self): + """Test font size is clamped between 0.5x and 3.0x""" + mock_layout_manager = Mock() + mock_page = Mock() + mock_page.render.return_value = "rendered_page" + mock_layout_manager.set_font_scale.return_value = mock_page + mock_layout_manager.font_scale = 1.0 + + self.manager.set_manager(mock_layout_manager) + + # Test upper bound + self.manager.set_font_size(5.0) + self.assertEqual(self.manager.font_scale, 3.0) + + # Test lower bound + self.manager.set_font_size(0.1) + self.assertEqual(self.manager.font_scale, 0.5) + + def test_increase_font_size(self): + """Test increasing font size by one step""" + mock_layout_manager = Mock() + mock_page = Mock() + mock_page.render.return_value = "rendered_page" + mock_layout_manager.set_font_scale.return_value = mock_page + mock_layout_manager.font_scale = 1.0 + + self.manager.set_manager(mock_layout_manager) + + initial_size = self.manager.font_scale + self.manager.increase_font_size() + + self.assertEqual(self.manager.font_scale, initial_size + 0.1) + + def test_decrease_font_size(self): + """Test decreasing font size by one step""" + mock_layout_manager = Mock() + mock_page = Mock() + mock_page.render.return_value = "rendered_page" + mock_layout_manager.set_font_scale.return_value = mock_page + mock_layout_manager.font_scale = 1.5 + + self.manager.set_manager(mock_layout_manager) + self.manager.font_scale = 1.5 + + self.manager.decrease_font_size() + + self.assertAlmostEqual(self.manager.font_scale, 1.4, places=5) + + def test_set_line_spacing(self): + """Test setting line spacing""" + mock_layout_manager = Mock() + mock_page = Mock() + mock_page.render.return_value = "rendered_page" + mock_layout_manager.get_current_page.return_value = mock_page + mock_layout_manager.page_style = Mock() + mock_layout_manager.page_style.line_spacing = 5 + + self.manager.set_manager(mock_layout_manager) + + result = self.manager.set_line_spacing(10) + + self.assertIsNotNone(result) + mock_layout_manager.increase_line_spacing.assert_called_once_with(5) + + def test_set_inter_block_spacing(self): + """Test setting inter-block spacing""" + mock_layout_manager = Mock() + mock_page = Mock() + mock_page.render.return_value = "rendered_page" + mock_layout_manager.get_current_page.return_value = mock_page + mock_layout_manager.page_style = Mock() + mock_layout_manager.page_style.inter_block_spacing = 15 + + self.manager.set_manager(mock_layout_manager) + + result = self.manager.set_inter_block_spacing(25) + + self.assertIsNotNone(result) + mock_layout_manager.increase_inter_block_spacing.assert_called_once_with(10) + + def test_set_word_spacing(self): + """Test setting word spacing""" + mock_layout_manager = Mock() + mock_page = Mock() + mock_page.render.return_value = "rendered_page" + mock_layout_manager.get_current_page.return_value = mock_page + mock_layout_manager.page_style = Mock() + mock_layout_manager.page_style.word_spacing = 0 + + self.manager.set_manager(mock_layout_manager) + + result = self.manager.set_word_spacing(3) + + self.assertIsNotNone(result) + mock_layout_manager.increase_word_spacing.assert_called_once_with(3) + + def test_get_current_settings_without_manager(self): + """Test getting settings without layout manager""" + settings = self.manager.get_current_settings() + + self.assertIsInstance(settings, dict) + self.assertEqual(settings['font_scale'], 1.0) + self.assertEqual(settings['line_spacing'], 5) + self.assertEqual(settings['inter_block_spacing'], 15) + self.assertEqual(settings['word_spacing'], 0) + + def test_get_current_settings_with_manager(self): + """Test getting settings with layout manager""" + mock_layout_manager = Mock() + mock_layout_manager.page_style = Mock() + mock_layout_manager.page_style.line_spacing = 10 + mock_layout_manager.page_style.inter_block_spacing = 20 + mock_layout_manager.page_style.word_spacing = 3 + mock_layout_manager.font_scale = 1.5 + + self.manager.set_manager(mock_layout_manager) + self.manager.font_scale = 1.5 + + settings = self.manager.get_current_settings() + + self.assertEqual(settings['font_scale'], 1.5) + self.assertEqual(settings['line_spacing'], 10) + self.assertEqual(settings['inter_block_spacing'], 20) + self.assertEqual(settings['word_spacing'], 3) + + def test_apply_settings(self): + """Test applying settings from dictionary""" + mock_layout_manager = Mock() + mock_page = Mock() + mock_page.render.return_value = "rendered_page" + mock_layout_manager.set_font_scale.return_value = mock_page + mock_layout_manager.get_current_page.return_value = mock_page + mock_layout_manager.page_style = Mock() + mock_layout_manager.page_style.line_spacing = 5 + mock_layout_manager.page_style.inter_block_spacing = 15 + mock_layout_manager.page_style.word_spacing = 0 + mock_layout_manager.font_scale = 1.0 + + self.manager.set_manager(mock_layout_manager) + + settings = { + 'font_scale': 1.5, + 'line_spacing': 10, + 'inter_block_spacing': 20, + 'word_spacing': 3 + } + + success = self.manager.apply_settings(settings) + + self.assertTrue(success) + self.assertEqual(self.manager.font_scale, 1.5) + + def test_apply_settings_without_manager(self): + """Test applying settings without layout manager returns False""" + settings = {'font_scale': 1.5} + success = self.manager.apply_settings(settings) + + self.assertFalse(success) + + +if __name__ == '__main__': + unittest.main()