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/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..a4943e8
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,176 @@
+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
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.x'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ # Install package in development mode
+ 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
+
+ - 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
index 95fc48b..a8bbf5b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,52 +1,59 @@
-# Python
+# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
-*.so
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
+*/__pycache__
-# Virtual environments
+# Distribution / packaging
+dist/
+build/
+*.egg-info/
+
+# Environment
venv/
env/
-ENV/
-.venv
+.env/
+.venv/
-# IDEs
-.vscode/
+# Tests
+.pytest_cache/
+.coverage
+htmlcov/
+coverage.xml
+coverage.json
+.tox/
+
+# IDE files
.idea/
+.vscode/
*.swp
*.swo
*~
.DS_Store
-# Testing
-.pytest_cache/
-.coverage
-htmlcov/
-.tox/
-
-# Ereader data
-ereader_bookmarks/
-highlights/
+# Project specific - images and outputs
*.png
-*.gif
*.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/
@@ -56,3 +63,13 @@ 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/README.md b/README.md
index f8cb8c4..5493728 100644
--- a/README.md
+++ b/README.md
@@ -1,87 +1,132 @@
-# pyWebLayout-ereader
+# DReader Application
-A complete ebook reader application built with [pyWebLayout](https://github.com/yourusername/pyWebLayout).
+## Project Status
-This project demonstrates how to build a full-featured ebook reader using pyWebLayout's layout engine. It serves as both a reference implementation and a ready-to-use ereader library.
+| Badge | Description |
+|-------|-------------|
+|  | **Test Coverage** - Percentage of code covered by unit tests |
+|  | **Documentation Coverage** - Percentage of code with docstrings |
+|  | **License** - Project licensing information |
-## Features
+> 📋 **Note**: Badges show results from the commit referenced in the URLs. Red "error" badges indicate build failures for that specific step.
-- 📖 **EPUB Support** - Load and read EPUB files
-- 📄 **Page Navigation** - Forward/backward navigation with smooth rendering
-- 🔖 **Bookmarks** - Save and restore reading positions
-- 🎨 **Text Highlighting** - Highlight words and selections with notes
-- 🔍 **Text Selection** - Select and query text via touch/click
-- ⚙️ **Customization** - Font size, line spacing, colors
-- 📑 **Chapter Navigation** - Jump to chapters via table of contents
-- 👆 **Gesture Support** - Tap, swipe, pinch, long-press handling
+## 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 text highlighting, bookmarks, gesture support, 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
+- 📄 **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
+- 📊 **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
## Installation
-### From Source
-
```bash
-# Install pyWebLayout first if not already installed
-cd /path/to/pyWebLayout
+# Clone the repository
+git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
+cd dreader-application
+
+# Install in development mode
pip install -e .
-# Install pyWebLayout-ereader
-cd /path/to/pyWebLayout-ereader
-pip install -e .
+# Or install with dev dependencies
+pip install -e ".[dev]"
```
-### As a Dependency
+The pyWebLayout dependency will be automatically installed from the git repository.
-```bash
-pip install pyweblayout-ereader
-```
+## DReader in Action
+
+Here are animated demonstrations of the key features:
+
+
+
+
+ Page Navigation
+ 
+ Forward and backward navigation through pages
+ |
+
+ Font Size Adjustment
+ 
+ Dynamic font size scaling with position preservation
+ |
+
+
+
+ Chapter Navigation
+ 
+ Jump directly to chapters by title or index
+ |
+
+ Bookmarks & Positions
+ 
+ Save and restore reading positions anywhere in the book
+ |
+
+
+
+ Text Highlighting
+ 
+ Highlight words and selections with custom colors and notes
+ |
+
+
## Quick Start
```python
-from pyweblayout_ereader import EbookReader
+from dreader.application import EbookReader
-# Create reader
-reader = EbookReader(page_size=(800, 1000))
+# Create an ebook reader
+with EbookReader(page_size=(800, 1000)) as reader:
+ # Load an EPUB file
+ reader.load_epub("mybook.epub")
-# Load an EPUB
-reader.load_epub("mybook.epub")
+ # Get current page as PIL Image
+ page = reader.get_current_page()
+ page.save("page_001.png")
-# Get current page as image
-page = reader.get_current_page()
-page.save("current_page.png")
+ # Navigate through pages
+ reader.next_page()
+ reader.previous_page()
-# Navigate
-reader.next_page()
-reader.previous_page()
+ # Save reading position
+ reader.save_position("chapter_3")
-# Save position
-reader.save_position("bookmark1")
+ # Jump to a chapter
+ reader.jump_to_chapter("Chapter 5")
-# Later, restore position
-reader.load_position("bookmark1")
-```
+ # Adjust font size
+ reader.increase_font_size()
-## Examples
+ # Highlight text at coordinates
+ highlight_id = reader.highlight_word(x=200, y=300, note="Important!")
-See the `examples/` directory for complete examples:
-
-- **simple_ereader_example.py** - Basic ereader usage
-- **ereader_demo.py** - Full-featured demo with all capabilities
-- **word_selection_highlighting.py** - Text selection and highlighting
-- **generate_ereader_gifs.py** - Generate animated demos
-
-## Architecture
-
-This project is a **high-level application layer** that combines pyWebLayout components:
-
-```
-pyweblayout_ereader.EbookReader
- ↓
-├── pyWebLayout.layout.EreaderLayoutManager # Layout & pagination
-├── pyWebLayout.core.HighlightManager # Highlighting system
-├── pyWebLayout.io.gesture # Touch/gesture handling
-└── pyWebLayout.io.readers # EPUB parsing
+ # Get progress
+ progress = reader.get_reading_progress()
+ print(f"Progress: {progress*100:.1f}%")
```
## API Overview
@@ -91,7 +136,7 @@ pyweblayout_ereader.EbookReader
```python
reader.load_epub("book.epub")
reader.is_loaded() # Check if book loaded
-reader.get_book_info() # Get metadata
+reader.get_book_info() # Get metadata (title, author, etc.)
```
### Navigation
@@ -99,21 +144,26 @@ reader.get_book_info() # Get metadata
```python
reader.next_page()
reader.previous_page()
-reader.jump_to_chapter("Chapter 1")
-reader.get_reading_progress() # 0.0 to 1.0
+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
```
-### Styling
+### 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
+### Bookmarks & Position Management
```python
reader.save_position("my_bookmark")
@@ -122,23 +172,36 @@ reader.list_saved_positions()
reader.delete_position("my_bookmark")
```
-### Highlighting
+### Text Highlighting
```python
-# Highlight a word at pixel coordinates
-highlight_id = reader.highlight_word(x=100, y=200, note="Important!")
+from pyWebLayout.core.highlight import HighlightColor
-# Highlight a selection
+# 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) # Yellow
+ 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
-reader.list_highlights()
+highlights = reader.list_highlights()
reader.remove_highlight(highlight_id)
reader.clear_highlights()
+reader.get_highlights_for_current_page()
```
### Gesture Handling
@@ -153,32 +216,73 @@ 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']}")
```
-## Use Cases
+### File Operations
-- **Desktop Ereader Applications** - Build native ereader apps
-- **Web-based Readers** - Serve rendered pages via Flask/FastAPI
-- **E-ink Device Firmware** - Optimized for e-ink displays
-- **Reading Analytics** - Track reading patterns and highlights
-- **Educational Tools** - Annotated reading with highlights and notes
+```python
+# Save current page to file
+reader.render_to_file("current_page.png")
-## Relationship to pyWebLayout
+# Context manager (auto-saves position on close)
+with EbookReader(page_size=(800, 1000)) as reader:
+ reader.load_epub("book.epub")
+ # ... use reader ...
+# Position automatically saved on exit
+```
-**pyWebLayout** is a layout engine library providing low-level primitives for:
-- Text rendering and layout
+## Examples
+
+Check out the `examples/` directory for complete working 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
+- **[word_selection_highlighting.py](examples/word_selection_highlighting.py)** - Text selection and highlighting
+- **[simple_word_highlight.py](examples/simple_word_highlight.py)** - Minimal highlighting example
+- **[generate_ereader_gifs.py](examples/generate_ereader_gifs.py)** - Generate animated GIF demonstrations
+
+## 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
+```
+
+### 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
-**pyWebLayout-ereader** is an application framework that:
+**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, etc.)
+- Manages application state (bookmarks, highlights, positions)
- Handles business logic for gestures and interactions
Think of it like this:
-- pyWebLayout = React (library)
-- pyWebLayout-ereader = Next.js (framework)
+- **pyWebLayout** = React (library)
+- **DReader Application** = Next.js (framework)
+
+## 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
@@ -189,22 +293,83 @@ pip install -e ".[dev]"
# Run tests
pytest
+# Run tests with coverage
+pytest --cov=dreader --cov-report=html
+
# Format code
-black pyweblayout_ereader/
+black dreader/ tests/
# Type checking
-mypy pyweblayout_ereader/
+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
+
+# 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
+- **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
```
## 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 file for details
+MIT License - see [LICENSE](LICENSE) file for details
+
+## Author
+
+Duncan Tourolle - duncan@tourolle.paris
## Related Projects
-- [pyWebLayout](https://github.com/yourusername/pyWebLayout) - The underlying layout engine
-- Add your projects here!
+- [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/docs/images/ereader_bookmarks.gif b/docs/images/ereader_bookmarks.gif
new file mode 100644
index 0000000..c6d4e51
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..56b0b5c
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..1e6e65a
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..c4c4774
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..abecfe4
Binary files /dev/null and b/docs/images/ereader_page_navigation.gif differ
diff --git a/pyweblayout_ereader/__init__.py b/dreader/__init__.py
similarity index 100%
rename from pyweblayout_ereader/__init__.py
rename to dreader/__init__.py
diff --git a/pyweblayout_ereader/application.py b/dreader/application.py
similarity index 100%
rename from pyweblayout_ereader/application.py
rename to dreader/application.py
diff --git a/examples/ereader_demo.py b/examples/ereader_demo.py
index 5f3796b..f8abdd5 100644
--- a/examples/ereader_demo.py
+++ b/examples/ereader_demo.py
@@ -18,7 +18,10 @@ import sys
import os
from pathlib import Path
-from pyweblayout_ereader import EbookReader
+# 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():
diff --git a/examples/generate_ereader_gifs.py b/examples/generate_ereader_gifs.py
index 92c3c3f..d9c6aed 100644
--- a/examples/generate_ereader_gifs.py
+++ b/examples/generate_ereader_gifs.py
@@ -23,7 +23,10 @@ import os
from pathlib import Path
from typing import List
-from pyweblayout_ereader import EbookReader
+# 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
diff --git a/examples/simple_ereader_example.py b/examples/simple_ereader_example.py
index ed392b4..590934d 100644
--- a/examples/simple_ereader_example.py
+++ b/examples/simple_ereader_example.py
@@ -12,7 +12,10 @@ Usage:
import sys
from pathlib import Path
-from pyweblayout_ereader import EbookReader
+# Add parent directory to path to import pyWebLayout
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from dreader.application import EbookReader
def main():
diff --git a/examples/simple_word_highlight.py b/examples/simple_word_highlight.py
index e5589ee..9681a35 100644
--- a/examples/simple_word_highlight.py
+++ b/examples/simple_word_highlight.py
@@ -12,7 +12,7 @@ Perfect for understanding the basic query system.
"""
from PIL import Image, ImageDraw
-from pyweblayout_ereader import EbookReader
+from dreader.application import EbookReader
def main():
diff --git a/examples/word_selection_highlighting.py b/examples/word_selection_highlighting.py
index 07226be..f525a4f 100644
--- a/examples/word_selection_highlighting.py
+++ b/examples/word_selection_highlighting.py
@@ -18,7 +18,7 @@ This is useful for:
from PIL import Image, ImageDraw
import numpy as np
-from pyweblayout_ereader import EbookReader
+from dreader.application import EbookReader
from pyWebLayout.io.gesture import TouchEvent, GestureType
from pyWebLayout.core.query import QueryResult
diff --git a/pyproject.toml b/pyproject.toml
index 12c60aa..6a74b2f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,14 +3,14 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
-name = "pyweblayout-ereader"
+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 = "Your Name", email = "your.email@example.com"}
+ {name = "Duncan Tourolle", email = "duncan@tourolle.paris"}
]
keywords = ["ebook", "reader", "epub", "ereader", "layout"]
classifiers = [
@@ -27,7 +27,7 @@ classifiers = [
]
dependencies = [
- "pyweblayout>=0.1.0",
+ "pyweblayout @ git+https://gitea.tourolle.paris/dtourolle/pyWebLayout",
"Pillow>=9.0.0",
]
@@ -41,14 +41,14 @@ dev = [
]
[project.urls]
-Homepage = "https://github.com/yourusername/pyWebLayout-ereader"
-Documentation = "https://github.com/yourusername/pyWebLayout-ereader#readme"
-Repository = "https://github.com/yourusername/pyWebLayout-ereader"
-Issues = "https://github.com/yourusername/pyWebLayout-ereader/issues"
+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 = ["pyweblayout_ereader*"]
+include = ["dreader*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
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..3b65265
--- /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 be RGBA for transparency
+ self.assertEqual(result_img.mode, 'RGBA')
+
+
+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()