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](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 | -## 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
+ 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 +
## 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()