migration of application to own repo
All checks were successful
Python CI / test (push) Successful in 38s

This commit is contained in:
Duncan Tourolle 2025-11-07 18:47:31 +01:00
parent 2e926ab87a
commit 9862c3124a
20 changed files with 2134 additions and 133 deletions

31
.coveragerc Normal file
View File

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

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

@ -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

83
.gitignore vendored
View File

@ -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*

331
README.md
View File

@ -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:
<table>
<tr>
<td align="center">
<b>Page Navigation</b><br>
<img src="docs/images/ereader_page_navigation.gif" width="300" alt="Page Navigation"><br>
<em>Forward and backward navigation through pages</em>
</td>
<td align="center">
<b>Font Size Adjustment</b><br>
<img src="docs/images/ereader_font_size.gif" width="300" alt="Font Size"><br>
<em>Dynamic font size scaling with position preservation</em>
</td>
</tr>
<tr>
<td align="center">
<b>Chapter Navigation</b><br>
<img src="docs/images/ereader_chapter_navigation.gif" width="300" alt="Chapter Navigation"><br>
<em>Jump directly to chapters by title or index</em>
</td>
<td align="center">
<b>Bookmarks & Positions</b><br>
<img src="docs/images/ereader_bookmarks.gif" width="300" alt="Bookmarks"><br>
<em>Save and restore reading positions anywhere in the book</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<b>Text Highlighting</b><br>
<img src="docs/images/ereader_highlighting.gif" width="300" alt="Highlighting"><br>
<em>Highlight words and selections with custom colors and notes</em>
</td>
</tr>
</table>
## Quick Start
```python
from pyweblayout_ereader import EbookReader
from dreader.application import EbookReader
# Create reader
reader = EbookReader(page_size=(800, 1000))
# Load an EPUB
# Create an ebook reader
with EbookReader(page_size=(800, 1000)) as reader:
# Load an EPUB file
reader.load_epub("mybook.epub")
# Get current page as image
# Get current page as PIL Image
page = reader.get_current_page()
page.save("current_page.png")
page.save("page_001.png")
# Navigate
# Navigate through pages
reader.next_page()
reader.previous_page()
# Save position
reader.save_position("bookmark1")
# Save reading position
reader.save_position("chapter_3")
# Later, restore position
reader.load_position("bookmark1")
```
# Jump to a chapter
reader.jump_to_chapter("Chapter 5")
## Examples
# Adjust font size
reader.increase_font_size()
See the `examples/` directory for complete examples:
# Highlight text at coordinates
highlight_id = reader.highlight_word(x=200, y=300, note="Important!")
- **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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

View File

@ -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():

View File

@ -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

View File

@ -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():

View File

@ -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():

View File

@ -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

View File

@ -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"]

View File

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

View File

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

View File

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