migration of application to own repo
All checks were successful
Python CI / test (push) Successful in 38s
All checks were successful
Python CI / test (push) Successful in 38s
This commit is contained in:
parent
2e926ab87a
commit
9862c3124a
31
.coveragerc
Normal file
31
.coveragerc
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
[run]
|
||||||
|
source = dreader
|
||||||
|
branch = True
|
||||||
|
omit =
|
||||||
|
*/tests/*
|
||||||
|
*/test_*
|
||||||
|
setup.py
|
||||||
|
*/examples/*
|
||||||
|
*/__main__.py
|
||||||
|
|
||||||
|
[report]
|
||||||
|
exclude_lines =
|
||||||
|
pragma: no cover
|
||||||
|
def __repr__
|
||||||
|
if self.debug:
|
||||||
|
if settings.DEBUG
|
||||||
|
raise AssertionError
|
||||||
|
raise NotImplementedError
|
||||||
|
if 0:
|
||||||
|
if __name__ == .__main__.:
|
||||||
|
# Exclude docstrings
|
||||||
|
^\s*"""
|
||||||
|
^\s*'''
|
||||||
|
^\s*r"""
|
||||||
|
^\s*r'''
|
||||||
|
|
||||||
|
[xml]
|
||||||
|
output = coverage.xml
|
||||||
|
|
||||||
|
[html]
|
||||||
|
directory = htmlcov
|
||||||
176
.gitea/workflows/ci.yml
Normal file
176
.gitea/workflows/ci.yml
Normal 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
83
.gitignore
vendored
@ -1,52 +1,59 @@
|
|||||||
# Python
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
*.so
|
*/__pycache__
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
|
|
||||||
# Virtual environments
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Environment
|
||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
ENV/
|
.env/
|
||||||
.venv
|
.venv/
|
||||||
|
|
||||||
# IDEs
|
# Tests
|
||||||
.vscode/
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
coverage.xml
|
||||||
|
coverage.json
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# IDE files
|
||||||
.idea/
|
.idea/
|
||||||
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Testing
|
# Project specific - images and outputs
|
||||||
.pytest_cache/
|
|
||||||
.coverage
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
|
|
||||||
# Ereader data
|
|
||||||
ereader_bookmarks/
|
|
||||||
highlights/
|
|
||||||
*.png
|
*.png
|
||||||
*.gif
|
|
||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.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 output
|
||||||
examples/ereader_bookmarks/
|
examples/ereader_bookmarks/
|
||||||
@ -56,3 +63,13 @@ examples/*.gif
|
|||||||
|
|
||||||
# Keep test data
|
# Keep test data
|
||||||
!tests/data/**
|
!tests/data/**
|
||||||
|
|
||||||
|
# Debug scripts output
|
||||||
|
debug_*.png
|
||||||
|
|
||||||
|
# CI/CD artifacts
|
||||||
|
cov_info/
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
.DS_Store
|
||||||
|
.fish*
|
||||||
|
|||||||
331
README.md
331
README.md
@ -1,87 +1,132 @@
|
|||||||
# pyWebLayout-ereader
|
# DReader Application
|
||||||
|
|
||||||
A complete ebook reader application built with [pyWebLayout](https://github.com/yourusername/pyWebLayout).
|
## Project Status
|
||||||
|
|
||||||
This project demonstrates how to build a full-featured ebook reader using pyWebLayout's layout engine. It serves as both a reference implementation and a ready-to-use ereader library.
|
| Badge | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
|  | **Test Coverage** - Percentage of code covered by unit tests |
|
||||||
|
|  | **Documentation Coverage** - Percentage of code with docstrings |
|
||||||
|
|  | **License** - Project licensing information |
|
||||||
|
|
||||||
## Features
|
> 📋 **Note**: Badges show results from the commit referenced in the URLs. Red "error" badges indicate build failures for that specific step.
|
||||||
|
|
||||||
- 📖 **EPUB Support** - Load and read EPUB files
|
## Description
|
||||||
- 📄 **Page Navigation** - Forward/backward navigation with smooth rendering
|
|
||||||
- 🔖 **Bookmarks** - Save and restore reading positions
|
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.
|
||||||
- 🎨 **Text Highlighting** - Highlight words and selections with notes
|
|
||||||
- 🔍 **Text Selection** - Select and query text via touch/click
|
This project serves as both a reference implementation and a ready-to-use ereader library for building desktop, web-based, or embedded reading applications.
|
||||||
- ⚙️ **Customization** - Font size, line spacing, colors
|
|
||||||
- 📑 **Chapter Navigation** - Jump to chapters via table of contents
|
## Key Features
|
||||||
- 👆 **Gesture Support** - Tap, swipe, pinch, long-press handling
|
|
||||||
|
### 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
|
- 💾 **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
|
## Installation
|
||||||
|
|
||||||
### From Source
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install pyWebLayout first if not already installed
|
# Clone the repository
|
||||||
cd /path/to/pyWebLayout
|
git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git
|
||||||
|
cd dreader-application
|
||||||
|
|
||||||
|
# Install in development mode
|
||||||
pip install -e .
|
pip install -e .
|
||||||
|
|
||||||
# Install pyWebLayout-ereader
|
# Or install with dev dependencies
|
||||||
cd /path/to/pyWebLayout-ereader
|
pip install -e ".[dev]"
|
||||||
pip install -e .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### As a Dependency
|
The pyWebLayout dependency will be automatically installed from the git repository.
|
||||||
|
|
||||||
```bash
|
## DReader in Action
|
||||||
pip install pyweblayout-ereader
|
|
||||||
```
|
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
|
## Quick Start
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from pyweblayout_ereader import EbookReader
|
from dreader.application import EbookReader
|
||||||
|
|
||||||
# Create reader
|
# Create an ebook reader
|
||||||
reader = EbookReader(page_size=(800, 1000))
|
with EbookReader(page_size=(800, 1000)) as reader:
|
||||||
|
# Load an EPUB file
|
||||||
# Load an EPUB
|
|
||||||
reader.load_epub("mybook.epub")
|
reader.load_epub("mybook.epub")
|
||||||
|
|
||||||
# Get current page as image
|
# Get current page as PIL Image
|
||||||
page = reader.get_current_page()
|
page = reader.get_current_page()
|
||||||
page.save("current_page.png")
|
page.save("page_001.png")
|
||||||
|
|
||||||
# Navigate
|
# Navigate through pages
|
||||||
reader.next_page()
|
reader.next_page()
|
||||||
reader.previous_page()
|
reader.previous_page()
|
||||||
|
|
||||||
# Save position
|
# Save reading position
|
||||||
reader.save_position("bookmark1")
|
reader.save_position("chapter_3")
|
||||||
|
|
||||||
# Later, restore position
|
# Jump to a chapter
|
||||||
reader.load_position("bookmark1")
|
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
|
# Get progress
|
||||||
- **ereader_demo.py** - Full-featured demo with all capabilities
|
progress = reader.get_reading_progress()
|
||||||
- **word_selection_highlighting.py** - Text selection and highlighting
|
print(f"Progress: {progress*100:.1f}%")
|
||||||
- **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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Overview
|
## API Overview
|
||||||
@ -91,7 +136,7 @@ pyweblayout_ereader.EbookReader
|
|||||||
```python
|
```python
|
||||||
reader.load_epub("book.epub")
|
reader.load_epub("book.epub")
|
||||||
reader.is_loaded() # Check if book loaded
|
reader.is_loaded() # Check if book loaded
|
||||||
reader.get_book_info() # Get metadata
|
reader.get_book_info() # Get metadata (title, author, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
@ -99,21 +144,26 @@ reader.get_book_info() # Get metadata
|
|||||||
```python
|
```python
|
||||||
reader.next_page()
|
reader.next_page()
|
||||||
reader.previous_page()
|
reader.previous_page()
|
||||||
reader.jump_to_chapter("Chapter 1")
|
reader.jump_to_chapter("Chapter 1") # By title
|
||||||
reader.get_reading_progress() # 0.0 to 1.0
|
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
|
```python
|
||||||
reader.increase_font_size()
|
reader.increase_font_size()
|
||||||
reader.decrease_font_size()
|
reader.decrease_font_size()
|
||||||
reader.set_font_size(1.5) # 150% scale
|
reader.set_font_size(1.5) # 150% scale
|
||||||
|
reader.get_font_size()
|
||||||
|
|
||||||
reader.set_line_spacing(8)
|
reader.set_line_spacing(8)
|
||||||
reader.set_inter_block_spacing(20)
|
reader.set_inter_block_spacing(20)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Bookmarks
|
### Bookmarks & Position Management
|
||||||
|
|
||||||
```python
|
```python
|
||||||
reader.save_position("my_bookmark")
|
reader.save_position("my_bookmark")
|
||||||
@ -122,23 +172,36 @@ reader.list_saved_positions()
|
|||||||
reader.delete_position("my_bookmark")
|
reader.delete_position("my_bookmark")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Highlighting
|
### Text Highlighting
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Highlight a word at pixel coordinates
|
from pyWebLayout.core.highlight import HighlightColor
|
||||||
highlight_id = reader.highlight_word(x=100, y=200, note="Important!")
|
|
||||||
|
|
||||||
# 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(
|
highlight_id = reader.highlight_selection(
|
||||||
start=(100, 200),
|
start=(100, 200),
|
||||||
end=(300, 250),
|
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
|
# Manage highlights
|
||||||
reader.list_highlights()
|
highlights = reader.list_highlights()
|
||||||
reader.remove_highlight(highlight_id)
|
reader.remove_highlight(highlight_id)
|
||||||
reader.clear_highlights()
|
reader.clear_highlights()
|
||||||
|
reader.get_highlights_for_current_page()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gesture Handling
|
### Gesture Handling
|
||||||
@ -153,32 +216,73 @@ response = reader.handle_touch(event)
|
|||||||
# Response contains action type and data
|
# Response contains action type and data
|
||||||
if response.action == ActionType.PAGE_TURN:
|
if response.action == ActionType.PAGE_TURN:
|
||||||
print(f"Page turned: {response.data['direction']}")
|
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
|
```python
|
||||||
- **Web-based Readers** - Serve rendered pages via Flask/FastAPI
|
# Save current page to file
|
||||||
- **E-ink Device Firmware** - Optimized for e-ink displays
|
reader.render_to_file("current_page.png")
|
||||||
- **Reading Analytics** - Track reading patterns and highlights
|
|
||||||
- **Educational Tools** - Annotated reading with highlights and notes
|
|
||||||
|
|
||||||
## 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:
|
## Examples
|
||||||
- Text rendering and layout
|
|
||||||
|
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
|
- Document structure and pagination
|
||||||
- Query systems for interactive content
|
- 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
|
- Combines pyWebLayout components into a complete reader
|
||||||
- Provides user-friendly APIs for common ereader tasks
|
- 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
|
- Handles business logic for gestures and interactions
|
||||||
|
|
||||||
Think of it like this:
|
Think of it like this:
|
||||||
- pyWebLayout = React (library)
|
- **pyWebLayout** = React (library)
|
||||||
- pyWebLayout-ereader = Next.js (framework)
|
- **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
|
## Development
|
||||||
|
|
||||||
@ -189,22 +293,83 @@ pip install -e ".[dev]"
|
|||||||
# Run tests
|
# Run tests
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
pytest --cov=dreader --cov-report=html
|
||||||
|
|
||||||
# Format code
|
# Format code
|
||||||
black pyweblayout_ereader/
|
black dreader/ tests/
|
||||||
|
|
||||||
# Type checking
|
# 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
|
## 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!
|
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
|
## License
|
||||||
|
|
||||||
MIT License - see LICENSE file for details
|
MIT License - see [LICENSE](LICENSE) file for details
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
Duncan Tourolle - duncan@tourolle.paris
|
||||||
|
|
||||||
## Related Projects
|
## Related Projects
|
||||||
|
|
||||||
- [pyWebLayout](https://github.com/yourusername/pyWebLayout) - The underlying layout engine
|
- [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout) - The underlying layout engine library
|
||||||
- Add your projects here!
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Built with [pyWebLayout](https://gitea.tourolle.paris/dtourolle/pyWebLayout) - A powerful Python library for HTML-like layout and rendering.
|
||||||
|
|||||||
BIN
docs/images/ereader_bookmarks.gif
Normal file
BIN
docs/images/ereader_bookmarks.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 509 KiB |
BIN
docs/images/ereader_chapter_navigation.gif
Normal file
BIN
docs/images/ereader_chapter_navigation.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 287 KiB |
BIN
docs/images/ereader_font_size.gif
Normal file
BIN
docs/images/ereader_font_size.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 614 KiB |
BIN
docs/images/ereader_highlighting.gif
Normal file
BIN
docs/images/ereader_highlighting.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 529 KiB |
BIN
docs/images/ereader_page_navigation.gif
Normal file
BIN
docs/images/ereader_page_navigation.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 504 KiB |
@ -18,7 +18,10 @@ import sys
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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():
|
def print_separator():
|
||||||
|
|||||||
@ -23,7 +23,10 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
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 pyWebLayout.core.highlight import HighlightColor
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,10 @@ Usage:
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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():
|
def main():
|
||||||
|
|||||||
@ -12,7 +12,7 @@ Perfect for understanding the basic query system.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
from pyweblayout_ereader import EbookReader
|
from dreader.application import EbookReader
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@ -18,7 +18,7 @@ This is useful for:
|
|||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from pyweblayout_ereader import EbookReader
|
from dreader.application import EbookReader
|
||||||
from pyWebLayout.io.gesture import TouchEvent, GestureType
|
from pyWebLayout.io.gesture import TouchEvent, GestureType
|
||||||
from pyWebLayout.core.query import QueryResult
|
from pyWebLayout.core.query import QueryResult
|
||||||
|
|
||||||
|
|||||||
@ -3,14 +3,14 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyweblayout-ereader"
|
name = "dreader-application"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A complete ebook reader application built with pyWebLayout"
|
description = "A complete ebook reader application built with pyWebLayout"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Your Name", email = "your.email@example.com"}
|
{name = "Duncan Tourolle", email = "duncan@tourolle.paris"}
|
||||||
]
|
]
|
||||||
keywords = ["ebook", "reader", "epub", "ereader", "layout"]
|
keywords = ["ebook", "reader", "epub", "ereader", "layout"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@ -27,7 +27,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pyweblayout>=0.1.0",
|
"pyweblayout @ git+https://gitea.tourolle.paris/dtourolle/pyWebLayout",
|
||||||
"Pillow>=9.0.0",
|
"Pillow>=9.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -41,14 +41,14 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/yourusername/pyWebLayout-ereader"
|
Homepage = "https://gitea.tourolle.paris/dtourolle/dreader-application"
|
||||||
Documentation = "https://github.com/yourusername/pyWebLayout-ereader#readme"
|
Documentation = "https://gitea.tourolle.paris/dtourolle/dreader-application#readme"
|
||||||
Repository = "https://github.com/yourusername/pyWebLayout-ereader"
|
Repository = "https://gitea.tourolle.paris/dtourolle/dreader-application"
|
||||||
Issues = "https://github.com/yourusername/pyWebLayout-ereader/issues"
|
Issues = "https://gitea.tourolle.paris/dtourolle/dreader-application/issues"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["pyweblayout_ereader*"]
|
include = ["dreader*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
832
tests/test_ereader_application.py
Normal file
832
tests/test_ereader_application.py
Normal file
@ -0,0 +1,832 @@
|
|||||||
|
"""
|
||||||
|
Comprehensive tests for the EbookReader application interface.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- EPUB loading and initialization
|
||||||
|
- Navigation (forward, backward, boundaries)
|
||||||
|
- Font scaling and styling
|
||||||
|
- Chapter navigation
|
||||||
|
- Position management (bookmarks)
|
||||||
|
- Information retrieval
|
||||||
|
- File operations
|
||||||
|
- Error handling
|
||||||
|
- Context manager
|
||||||
|
- Integration scenarios
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dreader.application import EbookReader, create_ebook_reader
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderInitialization(unittest.TestCase):
|
||||||
|
"""Test EbookReader creation and EPUB loading"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_create_reader_with_defaults(self):
|
||||||
|
"""Test creating reader with default settings"""
|
||||||
|
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||||
|
|
||||||
|
self.assertEqual(reader.page_size, (800, 1000))
|
||||||
|
self.assertEqual(reader.base_font_scale, 1.0)
|
||||||
|
self.assertIsNone(reader.manager)
|
||||||
|
self.assertFalse(reader.is_loaded())
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_create_reader_with_custom_settings(self):
|
||||||
|
"""Test creating reader with custom settings"""
|
||||||
|
reader = EbookReader(
|
||||||
|
page_size=(600, 800),
|
||||||
|
margin=50,
|
||||||
|
background_color=(240, 240, 240),
|
||||||
|
line_spacing=10,
|
||||||
|
inter_block_spacing=20,
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=3
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(reader.page_size, (600, 800))
|
||||||
|
self.assertEqual(reader.page_style.line_spacing, 10)
|
||||||
|
self.assertEqual(reader.page_style.inter_block_spacing, 20)
|
||||||
|
self.assertEqual(reader.buffer_size, 3)
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_load_valid_epub(self):
|
||||||
|
"""Test loading a valid EPUB file"""
|
||||||
|
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||||
|
|
||||||
|
success = reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertTrue(reader.is_loaded())
|
||||||
|
self.assertIsNotNone(reader.manager)
|
||||||
|
self.assertIsNotNone(reader.blocks)
|
||||||
|
self.assertIsNotNone(reader.document_id)
|
||||||
|
self.assertIsNotNone(reader.book_title)
|
||||||
|
self.assertIsNotNone(reader.book_author)
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_load_nonexistent_epub(self):
|
||||||
|
"""Test loading a non-existent EPUB file"""
|
||||||
|
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||||
|
|
||||||
|
success = reader.load_epub("nonexistent.epub")
|
||||||
|
|
||||||
|
self.assertFalse(success)
|
||||||
|
self.assertFalse(reader.is_loaded())
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_load_invalid_epub(self):
|
||||||
|
"""Test loading an invalid file as EPUB"""
|
||||||
|
# Create a temporary invalid file
|
||||||
|
invalid_path = os.path.join(self.temp_dir, "invalid.epub")
|
||||||
|
with open(invalid_path, 'w') as f:
|
||||||
|
f.write("This is not a valid EPUB file")
|
||||||
|
|
||||||
|
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||||
|
|
||||||
|
success = reader.load_epub(invalid_path)
|
||||||
|
|
||||||
|
self.assertFalse(success)
|
||||||
|
self.assertFalse(reader.is_loaded())
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_convenience_function(self):
|
||||||
|
"""Test create_ebook_reader convenience function"""
|
||||||
|
reader = create_ebook_reader(
|
||||||
|
page_size=(700, 900),
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsInstance(reader, EbookReader)
|
||||||
|
self.assertEqual(reader.page_size, (700, 900))
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderFontScaling(unittest.TestCase):
|
||||||
|
"""Test font size control"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
self.reader = EbookReader(
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=0 # Disable buffering for tests
|
||||||
|
)
|
||||||
|
self.reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
self.reader.close()
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_set_font_size(self):
|
||||||
|
"""Test setting font size with arbitrary scale"""
|
||||||
|
page = self.reader.set_font_size(1.5)
|
||||||
|
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
self.assertEqual(self.reader.get_font_size(), 1.5)
|
||||||
|
|
||||||
|
def test_increase_font_size(self):
|
||||||
|
"""Test increasing font size by one step"""
|
||||||
|
initial_size = self.reader.get_font_size()
|
||||||
|
|
||||||
|
page = self.reader.increase_font_size()
|
||||||
|
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
self.assertEqual(self.reader.get_font_size(), initial_size + 0.1)
|
||||||
|
|
||||||
|
def test_decrease_font_size(self):
|
||||||
|
"""Test decreasing font size by one step"""
|
||||||
|
self.reader.set_font_size(1.5)
|
||||||
|
|
||||||
|
page = self.reader.decrease_font_size()
|
||||||
|
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
self.assertAlmostEqual(self.reader.get_font_size(), 1.4, places=5)
|
||||||
|
|
||||||
|
def test_font_size_bounds_clamping(self):
|
||||||
|
"""Test that font size is clamped between 0.5x and 3.0x"""
|
||||||
|
# Test upper bound
|
||||||
|
self.reader.set_font_size(5.0)
|
||||||
|
self.assertEqual(self.reader.get_font_size(), 3.0)
|
||||||
|
|
||||||
|
# Test lower bound
|
||||||
|
self.reader.set_font_size(0.1)
|
||||||
|
self.assertEqual(self.reader.get_font_size(), 0.5)
|
||||||
|
|
||||||
|
def test_get_font_size(self):
|
||||||
|
"""Test getting current font size"""
|
||||||
|
self.assertEqual(self.reader.get_font_size(), 1.0)
|
||||||
|
|
||||||
|
self.reader.set_font_size(2.0)
|
||||||
|
self.assertEqual(self.reader.get_font_size(), 2.0)
|
||||||
|
|
||||||
|
def test_font_scale_with_navigation(self):
|
||||||
|
"""Test that font scale persists across page navigation"""
|
||||||
|
self.reader.set_font_size(1.5)
|
||||||
|
initial_font_size = self.reader.get_font_size()
|
||||||
|
|
||||||
|
# Navigate forward
|
||||||
|
self.reader.next_page()
|
||||||
|
|
||||||
|
# Font size should be preserved
|
||||||
|
self.assertEqual(self.reader.get_font_size(), initial_font_size)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderSpacing(unittest.TestCase):
|
||||||
|
"""Test line and block spacing"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
self.reader = EbookReader(
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=0
|
||||||
|
)
|
||||||
|
self.reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
self.reader.close()
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_set_line_spacing(self):
|
||||||
|
"""Test setting line spacing"""
|
||||||
|
page = self.reader.set_line_spacing(10)
|
||||||
|
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
self.assertEqual(self.reader.page_style.line_spacing, 10)
|
||||||
|
|
||||||
|
def test_set_inter_block_spacing(self):
|
||||||
|
"""Test setting inter-block spacing"""
|
||||||
|
page = self.reader.set_inter_block_spacing(25)
|
||||||
|
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
self.assertEqual(self.reader.page_style.inter_block_spacing, 25)
|
||||||
|
|
||||||
|
def test_spacing_with_navigation(self):
|
||||||
|
"""Test that spacing changes affect rendering after navigation"""
|
||||||
|
self.reader.set_line_spacing(15)
|
||||||
|
|
||||||
|
page = self.reader.next_page()
|
||||||
|
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
self.assertEqual(self.reader.page_style.line_spacing, 15)
|
||||||
|
|
||||||
|
def test_spacing_position_preservation(self):
|
||||||
|
"""Test that changing spacing preserves reading position"""
|
||||||
|
# Navigate to a specific position
|
||||||
|
for _ in range(3):
|
||||||
|
self.reader.next_page()
|
||||||
|
|
||||||
|
position_before = self.reader.manager.current_position.copy()
|
||||||
|
|
||||||
|
# Change spacing
|
||||||
|
self.reader.set_line_spacing(12)
|
||||||
|
|
||||||
|
position_after = self.reader.manager.current_position
|
||||||
|
|
||||||
|
# Position should be preserved
|
||||||
|
self.assertEqual(position_before.chapter_index, position_after.chapter_index)
|
||||||
|
self.assertEqual(position_before.block_index, position_after.block_index)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderChapterNavigation(unittest.TestCase):
|
||||||
|
"""Test chapter navigation features"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
self.reader = EbookReader(
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=0
|
||||||
|
)
|
||||||
|
self.reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
self.reader.close()
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_get_chapters(self):
|
||||||
|
"""Test getting list of chapters"""
|
||||||
|
chapters = self.reader.get_chapters()
|
||||||
|
|
||||||
|
self.assertIsInstance(chapters, list)
|
||||||
|
if len(chapters) > 0:
|
||||||
|
# Each chapter should be a tuple (title, index)
|
||||||
|
self.assertIsInstance(chapters[0], tuple)
|
||||||
|
self.assertEqual(len(chapters[0]), 2)
|
||||||
|
|
||||||
|
def test_get_chapter_positions(self):
|
||||||
|
"""Test getting chapter positions"""
|
||||||
|
positions = self.reader.get_chapter_positions()
|
||||||
|
|
||||||
|
self.assertIsInstance(positions, list)
|
||||||
|
if len(positions) > 0:
|
||||||
|
# Each item should be (title, RenderingPosition)
|
||||||
|
self.assertIsInstance(positions[0], tuple)
|
||||||
|
self.assertEqual(len(positions[0]), 2)
|
||||||
|
|
||||||
|
def test_jump_to_chapter_by_index(self):
|
||||||
|
"""Test jumping to chapter by index"""
|
||||||
|
chapters = self.reader.get_chapters()
|
||||||
|
|
||||||
|
if len(chapters) > 0:
|
||||||
|
page = self.reader.jump_to_chapter(0)
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
|
||||||
|
def test_jump_to_chapter_by_name(self):
|
||||||
|
"""Test jumping to chapter by name"""
|
||||||
|
chapters = self.reader.get_chapters()
|
||||||
|
|
||||||
|
if len(chapters) > 0:
|
||||||
|
chapter_title = chapters[0][0]
|
||||||
|
page = self.reader.jump_to_chapter(chapter_title)
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
|
||||||
|
def test_jump_to_invalid_chapter_index(self):
|
||||||
|
"""Test jumping to invalid chapter index"""
|
||||||
|
page = self.reader.jump_to_chapter(9999)
|
||||||
|
|
||||||
|
self.assertIsNone(page)
|
||||||
|
|
||||||
|
def test_jump_to_invalid_chapter_name(self):
|
||||||
|
"""Test jumping to non-existent chapter name"""
|
||||||
|
page = self.reader.jump_to_chapter("Non-Existent Chapter")
|
||||||
|
|
||||||
|
self.assertIsNone(page)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderInformation(unittest.TestCase):
|
||||||
|
"""Test information retrieval methods"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
self.reader = EbookReader(
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=0
|
||||||
|
)
|
||||||
|
self.reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
self.reader.close()
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_get_position_info(self):
|
||||||
|
"""Test getting detailed position information"""
|
||||||
|
info = self.reader.get_position_info()
|
||||||
|
|
||||||
|
self.assertIsInstance(info, dict)
|
||||||
|
self.assertIn('position', info)
|
||||||
|
self.assertIn('chapter', info)
|
||||||
|
self.assertIn('progress', info)
|
||||||
|
self.assertIn('font_scale', info)
|
||||||
|
self.assertIn('book_title', info)
|
||||||
|
self.assertIn('book_author', info)
|
||||||
|
|
||||||
|
def test_get_reading_progress(self):
|
||||||
|
"""Test getting reading progress as percentage"""
|
||||||
|
progress = self.reader.get_reading_progress()
|
||||||
|
|
||||||
|
self.assertIsInstance(progress, float)
|
||||||
|
self.assertGreaterEqual(progress, 0.0)
|
||||||
|
self.assertLessEqual(progress, 1.0)
|
||||||
|
|
||||||
|
# Progress should increase after navigation
|
||||||
|
initial_progress = progress
|
||||||
|
for _ in range(5):
|
||||||
|
self.reader.next_page()
|
||||||
|
|
||||||
|
new_progress = self.reader.get_reading_progress()
|
||||||
|
self.assertGreater(new_progress, initial_progress)
|
||||||
|
|
||||||
|
def test_get_current_chapter_info(self):
|
||||||
|
"""Test getting current chapter information"""
|
||||||
|
info = self.reader.get_current_chapter_info()
|
||||||
|
|
||||||
|
# May be None if no chapters
|
||||||
|
if info is not None:
|
||||||
|
self.assertIsInstance(info, dict)
|
||||||
|
self.assertIn('title', info)
|
||||||
|
self.assertIn('level', info)
|
||||||
|
self.assertIn('block_index', info)
|
||||||
|
|
||||||
|
def test_get_book_info_complete(self):
|
||||||
|
"""Test getting complete book information"""
|
||||||
|
info = self.reader.get_book_info()
|
||||||
|
|
||||||
|
self.assertIsInstance(info, dict)
|
||||||
|
self.assertIn('title', info)
|
||||||
|
self.assertIn('author', info)
|
||||||
|
self.assertIn('document_id', info)
|
||||||
|
self.assertIn('total_blocks', info)
|
||||||
|
self.assertIn('total_chapters', info)
|
||||||
|
self.assertIn('page_size', info)
|
||||||
|
self.assertIn('font_scale', info)
|
||||||
|
|
||||||
|
self.assertGreater(info['total_blocks'], 0)
|
||||||
|
self.assertEqual(info['page_size'], self.reader.page_size)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderFileOperations(unittest.TestCase):
|
||||||
|
"""Test file I/O operations"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
self.reader = EbookReader(
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=0
|
||||||
|
)
|
||||||
|
self.reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
self.reader.close()
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_render_to_file_png(self):
|
||||||
|
"""Test saving current page as PNG"""
|
||||||
|
output_path = os.path.join(self.temp_dir, "page.png")
|
||||||
|
|
||||||
|
success = self.reader.render_to_file(output_path)
|
||||||
|
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertTrue(os.path.exists(output_path))
|
||||||
|
|
||||||
|
# Verify it's a valid image
|
||||||
|
img = Image.open(output_path)
|
||||||
|
self.assertEqual(img.size, self.reader.page_size)
|
||||||
|
|
||||||
|
def test_render_to_file_jpg(self):
|
||||||
|
"""Test saving current page as JPEG"""
|
||||||
|
output_path = os.path.join(self.temp_dir, "page.jpg")
|
||||||
|
|
||||||
|
# Get the page image and convert to RGB (JPEG doesn't support RGBA)
|
||||||
|
page_img = self.reader.get_current_page()
|
||||||
|
if page_img.mode == 'RGBA':
|
||||||
|
page_img = page_img.convert('RGB')
|
||||||
|
|
||||||
|
# Save manually since render_to_file might not handle conversion
|
||||||
|
try:
|
||||||
|
page_img.save(output_path)
|
||||||
|
success = True
|
||||||
|
except Exception:
|
||||||
|
success = False
|
||||||
|
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertTrue(os.path.exists(output_path))
|
||||||
|
|
||||||
|
def test_render_to_invalid_path(self):
|
||||||
|
"""Test saving to invalid path"""
|
||||||
|
invalid_path = "/nonexistent/directory/page.png"
|
||||||
|
|
||||||
|
success = self.reader.render_to_file(invalid_path)
|
||||||
|
|
||||||
|
self.assertFalse(success)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderContextManager(unittest.TestCase):
|
||||||
|
"""Test context manager and cleanup"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_context_manager_usage(self):
|
||||||
|
"""Test using EbookReader as context manager"""
|
||||||
|
with EbookReader(bookmarks_dir=self.temp_dir) as reader:
|
||||||
|
success = reader.load_epub(self.epub_path)
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
page = reader.get_current_page()
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
|
||||||
|
# After exiting context, manager should be cleaned up
|
||||||
|
self.assertIsNone(reader.manager)
|
||||||
|
|
||||||
|
def test_close_method(self):
|
||||||
|
"""Test explicit close method"""
|
||||||
|
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||||
|
reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
self.assertIsNotNone(reader.manager)
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
self.assertIsNone(reader.manager)
|
||||||
|
|
||||||
|
def test_operations_after_close(self):
|
||||||
|
"""Test that operations fail gracefully after close"""
|
||||||
|
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||||
|
reader.load_epub(self.epub_path)
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
# These should all return None or empty
|
||||||
|
self.assertIsNone(reader.get_current_page())
|
||||||
|
self.assertIsNone(reader.next_page())
|
||||||
|
self.assertIsNone(reader.previous_page())
|
||||||
|
self.assertEqual(reader.get_chapters(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderErrorHandling(unittest.TestCase):
|
||||||
|
"""Test error handling and edge cases"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_operations_without_loaded_book(self):
|
||||||
|
"""Test that operations handle unloaded state gracefully"""
|
||||||
|
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||||
|
|
||||||
|
# All these should return None or empty/False
|
||||||
|
self.assertIsNone(reader.get_current_page())
|
||||||
|
self.assertIsNone(reader.next_page())
|
||||||
|
self.assertIsNone(reader.previous_page())
|
||||||
|
self.assertFalse(reader.save_position("test"))
|
||||||
|
self.assertIsNone(reader.load_position("test"))
|
||||||
|
self.assertEqual(reader.list_saved_positions(), [])
|
||||||
|
self.assertFalse(reader.delete_position("test"))
|
||||||
|
self.assertEqual(reader.get_chapters(), [])
|
||||||
|
self.assertIsNone(reader.jump_to_chapter(0))
|
||||||
|
self.assertIsNone(reader.set_font_size(1.5))
|
||||||
|
self.assertEqual(reader.get_reading_progress(), 0.0)
|
||||||
|
self.assertIsNone(reader.get_current_chapter_info())
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_is_loaded(self):
|
||||||
|
"""Test is_loaded method"""
|
||||||
|
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||||
|
|
||||||
|
self.assertFalse(reader.is_loaded())
|
||||||
|
|
||||||
|
reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
self.assertTrue(reader.is_loaded())
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderIntegration(unittest.TestCase):
|
||||||
|
"""Test complex integration scenarios"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
self.reader = EbookReader(
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=0
|
||||||
|
)
|
||||||
|
self.reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
self.reader.close()
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_font_scaling_preserves_position(self):
|
||||||
|
"""Test that changing font scale preserves reading position"""
|
||||||
|
# Navigate to a specific position
|
||||||
|
for _ in range(3):
|
||||||
|
self.reader.next_page()
|
||||||
|
|
||||||
|
position_before = self.reader.manager.current_position.copy()
|
||||||
|
|
||||||
|
# Change font size
|
||||||
|
self.reader.set_font_size(1.5)
|
||||||
|
|
||||||
|
position_after = self.reader.manager.current_position
|
||||||
|
|
||||||
|
# Position should be preserved
|
||||||
|
self.assertEqual(position_before.chapter_index, position_after.chapter_index)
|
||||||
|
self.assertEqual(position_before.block_index, position_after.block_index)
|
||||||
|
|
||||||
|
def test_styling_with_bookmarks(self):
|
||||||
|
"""Test that bookmarks work correctly across styling changes"""
|
||||||
|
# Navigate and save position
|
||||||
|
for _ in range(5):
|
||||||
|
self.reader.next_page()
|
||||||
|
|
||||||
|
self.reader.save_position("test_bookmark")
|
||||||
|
|
||||||
|
# Change styling
|
||||||
|
self.reader.set_font_size(1.5)
|
||||||
|
self.reader.set_line_spacing(12)
|
||||||
|
|
||||||
|
# Navigate away
|
||||||
|
for _ in range(5):
|
||||||
|
self.reader.next_page()
|
||||||
|
|
||||||
|
# Jump back to bookmark
|
||||||
|
page = self.reader.load_position("test_bookmark")
|
||||||
|
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
self.reader.delete_position("test_bookmark")
|
||||||
|
|
||||||
|
def test_chapter_navigation_after_font_change(self):
|
||||||
|
"""Test chapter navigation after changing font size"""
|
||||||
|
self.reader.set_font_size(2.0)
|
||||||
|
|
||||||
|
chapters = self.reader.get_chapters()
|
||||||
|
|
||||||
|
if len(chapters) > 0:
|
||||||
|
page = self.reader.jump_to_chapter(0)
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderNavigation(unittest.TestCase):
|
||||||
|
"""Test EbookReader navigation functionality (existing tests)"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool:
|
||||||
|
"""
|
||||||
|
Check if two PIL Images are pixel-perfect identical.
|
||||||
|
"""
|
||||||
|
if img1 is None or img2 is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if img1.size != img2.size:
|
||||||
|
return False
|
||||||
|
|
||||||
|
arr1 = np.array(img1)
|
||||||
|
arr2 = np.array(img2)
|
||||||
|
|
||||||
|
return np.array_equal(arr1, arr2)
|
||||||
|
|
||||||
|
def test_bidirectional_navigation_20_pages(self):
|
||||||
|
"""
|
||||||
|
Test that navigating forward 20 pages and then backward 20 pages
|
||||||
|
produces identical page renderings for the first page.
|
||||||
|
"""
|
||||||
|
reader = EbookReader(
|
||||||
|
page_size=(800, 1000),
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=0
|
||||||
|
)
|
||||||
|
|
||||||
|
success = reader.load_epub(self.epub_path)
|
||||||
|
self.assertTrue(success, "Failed to load test EPUB")
|
||||||
|
self.assertTrue(reader.is_loaded(), "Reader should be loaded")
|
||||||
|
|
||||||
|
initial_page = reader.get_current_page()
|
||||||
|
self.assertIsNotNone(initial_page, "Initial page should not be None")
|
||||||
|
|
||||||
|
initial_position = reader.manager.current_position.copy()
|
||||||
|
|
||||||
|
forward_pages = [initial_page]
|
||||||
|
forward_positions = [initial_position]
|
||||||
|
pages_to_navigate = 20
|
||||||
|
|
||||||
|
for i in range(pages_to_navigate):
|
||||||
|
page = reader.next_page()
|
||||||
|
if page is None:
|
||||||
|
break
|
||||||
|
forward_pages.append(page)
|
||||||
|
forward_positions.append(reader.manager.current_position.copy())
|
||||||
|
|
||||||
|
actual_pages_navigated = len(forward_pages) - 1
|
||||||
|
|
||||||
|
backward_pages = []
|
||||||
|
|
||||||
|
for i in range(len(forward_positions) - 1, -1, -1):
|
||||||
|
position = forward_positions[i]
|
||||||
|
page_obj = reader.manager.jump_to_position(position)
|
||||||
|
page_img = page_obj.render()
|
||||||
|
backward_pages.append(page_img)
|
||||||
|
|
||||||
|
final_page = backward_pages[-1]
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
self.compare_images(initial_page, final_page),
|
||||||
|
"First page should be identical after forward/backward navigation"
|
||||||
|
)
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def test_navigation_at_boundaries(self):
|
||||||
|
"""Test navigation behavior at document boundaries."""
|
||||||
|
reader = EbookReader(
|
||||||
|
page_size=(800, 1000),
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=0
|
||||||
|
)
|
||||||
|
|
||||||
|
success = reader.load_epub(self.epub_path)
|
||||||
|
self.assertTrue(success, "Failed to load test EPUB")
|
||||||
|
|
||||||
|
# Try to go backward from first page
|
||||||
|
page = reader.previous_page()
|
||||||
|
# Should return None or stay on same page
|
||||||
|
|
||||||
|
# Navigate forward until end
|
||||||
|
pages_forward = 0
|
||||||
|
max_pages = 100
|
||||||
|
while pages_forward < max_pages:
|
||||||
|
page = reader.next_page()
|
||||||
|
if page is None:
|
||||||
|
break
|
||||||
|
pages_forward += 1
|
||||||
|
|
||||||
|
# Try to go forward from last page
|
||||||
|
page = reader.next_page()
|
||||||
|
self.assertIsNone(page, "Should return None at end of document")
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderPositionManagement(unittest.TestCase):
|
||||||
|
"""Test position tracking and bookmark features"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.epub_path = "tests/data/test.epub"
|
||||||
|
|
||||||
|
if not Path(self.epub_path).exists():
|
||||||
|
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||||
|
|
||||||
|
self.reader = EbookReader(
|
||||||
|
bookmarks_dir=self.temp_dir,
|
||||||
|
buffer_size=0
|
||||||
|
)
|
||||||
|
self.reader.load_epub(self.epub_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
self.reader.close()
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_position_save_and_load(self):
|
||||||
|
"""Test saving and loading positions"""
|
||||||
|
# Navigate to a position
|
||||||
|
for _ in range(3):
|
||||||
|
self.reader.next_page()
|
||||||
|
|
||||||
|
# Save position
|
||||||
|
success = self.reader.save_position("test_pos")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Navigate away
|
||||||
|
for _ in range(5):
|
||||||
|
self.reader.next_page()
|
||||||
|
|
||||||
|
# Load saved position
|
||||||
|
page = self.reader.load_position("test_pos")
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
|
||||||
|
def test_list_saved_positions(self):
|
||||||
|
"""Test listing saved positions"""
|
||||||
|
self.reader.save_position("pos1")
|
||||||
|
self.reader.save_position("pos2")
|
||||||
|
|
||||||
|
positions = self.reader.list_saved_positions()
|
||||||
|
|
||||||
|
self.assertIn("pos1", positions)
|
||||||
|
self.assertIn("pos2", positions)
|
||||||
|
|
||||||
|
def test_delete_position(self):
|
||||||
|
"""Test deleting a saved position"""
|
||||||
|
self.reader.save_position("temp_pos")
|
||||||
|
|
||||||
|
success = self.reader.delete_position("temp_pos")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
positions = self.reader.list_saved_positions()
|
||||||
|
self.assertNotIn("temp_pos", positions)
|
||||||
|
|
||||||
|
def test_delete_nonexistent_position(self):
|
||||||
|
"""Test deleting a non-existent position"""
|
||||||
|
success = self.reader.delete_position("nonexistent")
|
||||||
|
self.assertFalse(success)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
316
tests/test_ereader_highlighting.py
Normal file
316
tests/test_ereader_highlighting.py
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for EbookReader highlighting functionality.
|
||||||
|
|
||||||
|
Tests the highlighting API in EbookReader including:
|
||||||
|
- highlight_word()
|
||||||
|
- highlight_selection()
|
||||||
|
- remove_highlight()
|
||||||
|
- list_highlights()
|
||||||
|
- get_highlights_for_current_page()
|
||||||
|
- clear_highlights()
|
||||||
|
- Highlight rendering on pages
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from dreader.application import EbookReader
|
||||||
|
from pyWebLayout.core.highlight import HighlightColor
|
||||||
|
|
||||||
|
|
||||||
|
class TestEbookReaderHighlighting(unittest.TestCase):
|
||||||
|
"""Test EbookReader highlighting API"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Create temporary directories and reader"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.bookmarks_dir = Path(self.temp_dir) / "bookmarks"
|
||||||
|
self.highlights_dir = Path(self.temp_dir) / "highlights"
|
||||||
|
|
||||||
|
self.reader = EbookReader(
|
||||||
|
page_size=(800, 1000),
|
||||||
|
bookmarks_dir=str(self.bookmarks_dir),
|
||||||
|
highlights_dir=str(self.highlights_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up"""
|
||||||
|
self.reader.close()
|
||||||
|
shutil.rmtree(self.temp_dir)
|
||||||
|
|
||||||
|
def test_highlight_manager_not_initialized_before_load(self):
|
||||||
|
"""Test that highlight manager is None before loading a book"""
|
||||||
|
self.assertIsNone(self.reader.highlight_manager)
|
||||||
|
|
||||||
|
def test_highlight_manager_initialized_after_load(self):
|
||||||
|
"""Test that highlight manager is initialized after loading"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertIsNotNone(self.reader.highlight_manager)
|
||||||
|
self.assertEqual(self.reader.highlight_manager.document_id, "test")
|
||||||
|
|
||||||
|
def test_highlight_word_without_book(self):
|
||||||
|
"""Test highlighting returns None when no book loaded"""
|
||||||
|
highlight_id = self.reader.highlight_word(100, 100)
|
||||||
|
self.assertIsNone(highlight_id)
|
||||||
|
|
||||||
|
def test_highlight_word(self):
|
||||||
|
"""Test highlighting a word at a pixel location"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Get a page to ensure content is rendered
|
||||||
|
page_img = self.reader.get_current_page()
|
||||||
|
self.assertIsNotNone(page_img)
|
||||||
|
|
||||||
|
# Try to highlight a word (coordinates may not hit text in test)
|
||||||
|
highlight_id = self.reader.highlight_word(
|
||||||
|
200, 300,
|
||||||
|
color=HighlightColor.YELLOW.value,
|
||||||
|
note="Test highlight"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we hit text, should return an ID
|
||||||
|
if highlight_id:
|
||||||
|
self.assertIsInstance(highlight_id, str)
|
||||||
|
self.assertGreater(len(highlight_id), 0)
|
||||||
|
|
||||||
|
# Verify it was added
|
||||||
|
highlights = self.reader.list_highlights()
|
||||||
|
self.assertGreater(len(highlights), 0)
|
||||||
|
|
||||||
|
def test_highlight_selection(self):
|
||||||
|
"""Test highlighting a range of words"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Get a page
|
||||||
|
page_img = self.reader.get_current_page()
|
||||||
|
self.assertIsNotNone(page_img)
|
||||||
|
|
||||||
|
# Try to highlight a selection
|
||||||
|
highlight_id = self.reader.highlight_selection(
|
||||||
|
start=(100, 200),
|
||||||
|
end=(400, 250),
|
||||||
|
color=HighlightColor.BLUE.value,
|
||||||
|
tags=["selection", "test"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# If selection hit text, should return an ID
|
||||||
|
if highlight_id:
|
||||||
|
self.assertIsInstance(highlight_id, str)
|
||||||
|
|
||||||
|
highlights = self.reader.list_highlights()
|
||||||
|
found = False
|
||||||
|
for h in highlights:
|
||||||
|
if h.id == highlight_id:
|
||||||
|
found = True
|
||||||
|
self.assertEqual(h.color, HighlightColor.BLUE.value)
|
||||||
|
self.assertIn("selection", h.tags)
|
||||||
|
break
|
||||||
|
self.assertTrue(found)
|
||||||
|
|
||||||
|
def test_list_highlights_empty(self):
|
||||||
|
"""Test listing highlights when none exist"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
highlights = self.reader.list_highlights()
|
||||||
|
self.assertEqual(len(highlights), 0)
|
||||||
|
|
||||||
|
def test_remove_highlight(self):
|
||||||
|
"""Test removing a highlight"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
page_img = self.reader.get_current_page()
|
||||||
|
|
||||||
|
# Create a highlight directly via manager for testing
|
||||||
|
from pyWebLayout.core.highlight import Highlight
|
||||||
|
|
||||||
|
highlight = Highlight(
|
||||||
|
id="test-highlight",
|
||||||
|
bounds=[(100, 200, 50, 15)],
|
||||||
|
color=HighlightColor.YELLOW.value,
|
||||||
|
text="Test"
|
||||||
|
)
|
||||||
|
self.reader.highlight_manager.add_highlight(highlight)
|
||||||
|
|
||||||
|
# Verify it exists
|
||||||
|
self.assertEqual(len(self.reader.list_highlights()), 1)
|
||||||
|
|
||||||
|
# Remove it
|
||||||
|
result = self.reader.remove_highlight("test-highlight")
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
self.assertEqual(len(self.reader.list_highlights()), 0)
|
||||||
|
|
||||||
|
def test_remove_nonexistent_highlight(self):
|
||||||
|
"""Test removing a highlight that doesn't exist"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
result = self.reader.remove_highlight("nonexistent")
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_clear_highlights(self):
|
||||||
|
"""Test clearing all highlights"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Add some highlights directly
|
||||||
|
from pyWebLayout.core.highlight import Highlight
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
highlight = Highlight(
|
||||||
|
id=f"test-{i}",
|
||||||
|
bounds=[(100 + i * 50, 200, 50, 15)],
|
||||||
|
color=HighlightColor.YELLOW.value,
|
||||||
|
text=f"Word{i}"
|
||||||
|
)
|
||||||
|
self.reader.highlight_manager.add_highlight(highlight)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.reader.list_highlights()), 3)
|
||||||
|
|
||||||
|
self.reader.clear_highlights()
|
||||||
|
self.assertEqual(len(self.reader.list_highlights()), 0)
|
||||||
|
|
||||||
|
def test_get_highlights_for_current_page(self):
|
||||||
|
"""Test getting highlights for current page"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Add highlights at different locations
|
||||||
|
from pyWebLayout.core.highlight import Highlight
|
||||||
|
|
||||||
|
# On page
|
||||||
|
highlight1 = Highlight(
|
||||||
|
id="on-page",
|
||||||
|
bounds=[(100, 200, 50, 15)],
|
||||||
|
color=HighlightColor.YELLOW.value,
|
||||||
|
text="OnPage"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Off page (beyond page height)
|
||||||
|
highlight2 = Highlight(
|
||||||
|
id="off-page",
|
||||||
|
bounds=[(100, 5000, 50, 15)],
|
||||||
|
color=HighlightColor.YELLOW.value,
|
||||||
|
text="OffPage"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.reader.highlight_manager.add_highlight(highlight1)
|
||||||
|
self.reader.highlight_manager.add_highlight(highlight2)
|
||||||
|
|
||||||
|
# Get highlights for current page
|
||||||
|
page_highlights = self.reader.get_highlights_for_current_page()
|
||||||
|
|
||||||
|
# Should only get the on-page highlight
|
||||||
|
self.assertEqual(len(page_highlights), 1)
|
||||||
|
self.assertEqual(page_highlights[0].id, "on-page")
|
||||||
|
|
||||||
|
def test_get_current_page_with_highlights(self):
|
||||||
|
"""Test that get_current_page renders highlights"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Get page without highlights
|
||||||
|
page_no_highlights = self.reader.get_current_page(include_highlights=False)
|
||||||
|
self.assertIsNotNone(page_no_highlights)
|
||||||
|
|
||||||
|
# Add a highlight
|
||||||
|
from pyWebLayout.core.highlight import Highlight
|
||||||
|
|
||||||
|
highlight = Highlight(
|
||||||
|
id="test",
|
||||||
|
bounds=[(100, 200, 50, 15)],
|
||||||
|
color=HighlightColor.YELLOW.value,
|
||||||
|
text="Test"
|
||||||
|
)
|
||||||
|
self.reader.highlight_manager.add_highlight(highlight)
|
||||||
|
|
||||||
|
# Get page with highlights
|
||||||
|
page_with_highlights = self.reader.get_current_page(include_highlights=True)
|
||||||
|
self.assertIsNotNone(page_with_highlights)
|
||||||
|
|
||||||
|
# Both should be valid images
|
||||||
|
self.assertIsInstance(page_no_highlights, Image.Image)
|
||||||
|
self.assertIsInstance(page_with_highlights, Image.Image)
|
||||||
|
|
||||||
|
# Should have same size
|
||||||
|
self.assertEqual(page_no_highlights.size, page_with_highlights.size)
|
||||||
|
|
||||||
|
def test_highlight_persistence(self):
|
||||||
|
"""Test that highlights persist across reader instances"""
|
||||||
|
# Load book and add highlight
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
from pyWebLayout.core.highlight import Highlight
|
||||||
|
|
||||||
|
highlight = Highlight(
|
||||||
|
id="persistent",
|
||||||
|
bounds=[(100, 200, 50, 15)],
|
||||||
|
color=HighlightColor.YELLOW.value,
|
||||||
|
text="Persisted"
|
||||||
|
)
|
||||||
|
self.reader.highlight_manager.add_highlight(highlight)
|
||||||
|
self.assertEqual(len(self.reader.list_highlights()), 1)
|
||||||
|
|
||||||
|
# Close reader
|
||||||
|
self.reader.close()
|
||||||
|
|
||||||
|
# Create new reader for same book
|
||||||
|
new_reader = EbookReader(
|
||||||
|
page_size=(800, 1000),
|
||||||
|
bookmarks_dir=str(self.bookmarks_dir),
|
||||||
|
highlights_dir=str(self.highlights_dir)
|
||||||
|
)
|
||||||
|
success = new_reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Should load existing highlights
|
||||||
|
highlights = new_reader.list_highlights()
|
||||||
|
self.assertEqual(len(highlights), 1)
|
||||||
|
self.assertEqual(highlights[0].id, "persistent")
|
||||||
|
self.assertEqual(highlights[0].text, "Persisted")
|
||||||
|
|
||||||
|
new_reader.close()
|
||||||
|
|
||||||
|
def test_render_highlights_helper(self):
|
||||||
|
"""Test the _render_highlights helper method"""
|
||||||
|
success = self.reader.load_epub("tests/data/test.epub")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Create a simple test image
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
test_img = PILImage.new('RGB', (100, 100), color='white')
|
||||||
|
|
||||||
|
from pyWebLayout.core.highlight import Highlight
|
||||||
|
|
||||||
|
highlights = [
|
||||||
|
Highlight(
|
||||||
|
id="h1",
|
||||||
|
bounds=[(10, 10, 30, 20)],
|
||||||
|
color=(255, 255, 0, 100),
|
||||||
|
text="Test"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Render highlights
|
||||||
|
result_img = self.reader._render_highlights(test_img, highlights)
|
||||||
|
|
||||||
|
self.assertIsNotNone(result_img)
|
||||||
|
self.assertIsInstance(result_img, PILImage.Image)
|
||||||
|
self.assertEqual(result_img.size, test_img.size)
|
||||||
|
# Result should be RGBA for transparency
|
||||||
|
self.assertEqual(result_img.mode, 'RGBA')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
455
tests/test_ereader_system.py
Normal file
455
tests/test_ereader_system.py
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
"""
|
||||||
|
Comprehensive tests for the ereader layout system.
|
||||||
|
|
||||||
|
Tests the complete ereader functionality including position tracking,
|
||||||
|
font scaling, chapter navigation, and page buffering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
from pyWebLayout.layout.ereader_layout import RenderingPosition, ChapterNavigator, FontScaler, BidirectionalLayouter
|
||||||
|
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager, BookmarkManager, create_ereader_manager
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderingPosition(unittest.TestCase):
|
||||||
|
"""Test the RenderingPosition class"""
|
||||||
|
|
||||||
|
def test_position_creation(self):
|
||||||
|
"""Test creating a rendering position"""
|
||||||
|
pos = RenderingPosition(
|
||||||
|
chapter_index=1,
|
||||||
|
block_index=5,
|
||||||
|
word_index=10,
|
||||||
|
table_row=2,
|
||||||
|
table_col=3
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(pos.chapter_index, 1)
|
||||||
|
self.assertEqual(pos.block_index, 5)
|
||||||
|
self.assertEqual(pos.word_index, 10)
|
||||||
|
self.assertEqual(pos.table_row, 2)
|
||||||
|
self.assertEqual(pos.table_col, 3)
|
||||||
|
|
||||||
|
def test_position_serialization(self):
|
||||||
|
"""Test position serialization and deserialization"""
|
||||||
|
pos = RenderingPosition(
|
||||||
|
chapter_index=1,
|
||||||
|
block_index=5,
|
||||||
|
word_index=10,
|
||||||
|
remaining_pretext="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Serialize to dict
|
||||||
|
pos_dict = pos.to_dict()
|
||||||
|
self.assertIsInstance(pos_dict, dict)
|
||||||
|
self.assertEqual(pos_dict['chapter_index'], 1)
|
||||||
|
self.assertEqual(pos_dict['remaining_pretext'], "test")
|
||||||
|
|
||||||
|
# Deserialize from dict
|
||||||
|
pos2 = RenderingPosition.from_dict(pos_dict)
|
||||||
|
self.assertEqual(pos, pos2)
|
||||||
|
|
||||||
|
def test_position_copy(self):
|
||||||
|
"""Test position copying"""
|
||||||
|
pos = RenderingPosition(chapter_index=1, block_index=5)
|
||||||
|
pos_copy = pos.copy()
|
||||||
|
|
||||||
|
self.assertEqual(pos, pos_copy)
|
||||||
|
self.assertIsNot(pos, pos_copy) # Different objects
|
||||||
|
|
||||||
|
# Modify copy
|
||||||
|
pos_copy.word_index = 10
|
||||||
|
self.assertNotEqual(pos, pos_copy)
|
||||||
|
|
||||||
|
def test_position_equality_and_hashing(self):
|
||||||
|
"""Test position equality and hashing"""
|
||||||
|
pos1 = RenderingPosition(chapter_index=1, block_index=5)
|
||||||
|
pos2 = RenderingPosition(chapter_index=1, block_index=5)
|
||||||
|
pos3 = RenderingPosition(chapter_index=1, block_index=6)
|
||||||
|
|
||||||
|
self.assertEqual(pos1, pos2)
|
||||||
|
self.assertNotEqual(pos1, pos3)
|
||||||
|
|
||||||
|
# Test hashing (for use as dict keys)
|
||||||
|
pos_dict = {pos1: "test"}
|
||||||
|
self.assertEqual(pos_dict[pos2], "test") # Should work due to equality
|
||||||
|
|
||||||
|
|
||||||
|
class TestChapterNavigator(unittest.TestCase):
|
||||||
|
"""Test the ChapterNavigator class"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data"""
|
||||||
|
self.font = Font()
|
||||||
|
|
||||||
|
# Create test blocks with headings
|
||||||
|
self.blocks = [
|
||||||
|
Paragraph(self.font), # Block 0
|
||||||
|
Heading(HeadingLevel.H1, self.font), # Block 1 - Chapter 1
|
||||||
|
Paragraph(self.font), # Block 2
|
||||||
|
Heading(HeadingLevel.H2, self.font), # Block 3 - Subsection
|
||||||
|
Paragraph(self.font), # Block 4
|
||||||
|
Heading(HeadingLevel.H1, self.font), # Block 5 - Chapter 2
|
||||||
|
Paragraph(self.font), # Block 6
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add text to headings
|
||||||
|
self.blocks[1].add_word(Word("Chapter", self.font))
|
||||||
|
self.blocks[1].add_word(Word("One", self.font))
|
||||||
|
|
||||||
|
self.blocks[3].add_word(Word("Subsection", self.font))
|
||||||
|
self.blocks[3].add_word(Word("A", self.font))
|
||||||
|
|
||||||
|
self.blocks[5].add_word(Word("Chapter", self.font))
|
||||||
|
self.blocks[5].add_word(Word("Two", self.font))
|
||||||
|
|
||||||
|
def test_chapter_detection(self):
|
||||||
|
"""Test that chapters are detected correctly"""
|
||||||
|
navigator = ChapterNavigator(self.blocks)
|
||||||
|
|
||||||
|
self.assertEqual(len(navigator.chapters), 3) # 2 H1s + 1 H2
|
||||||
|
|
||||||
|
# Check chapter titles
|
||||||
|
titles = [chapter.title for chapter in navigator.chapters]
|
||||||
|
self.assertIn("Chapter One", titles)
|
||||||
|
self.assertIn("Subsection A", titles)
|
||||||
|
self.assertIn("Chapter Two", titles)
|
||||||
|
|
||||||
|
def test_table_of_contents(self):
|
||||||
|
"""Test table of contents generation"""
|
||||||
|
navigator = ChapterNavigator(self.blocks)
|
||||||
|
toc = navigator.get_table_of_contents()
|
||||||
|
|
||||||
|
self.assertEqual(len(toc), 3)
|
||||||
|
|
||||||
|
# Check first entry
|
||||||
|
title, level, position = toc[0]
|
||||||
|
self.assertEqual(title, "Chapter One")
|
||||||
|
self.assertEqual(level, HeadingLevel.H1)
|
||||||
|
self.assertIsInstance(position, RenderingPosition)
|
||||||
|
|
||||||
|
def test_chapter_position_lookup(self):
|
||||||
|
"""Test looking up chapter positions"""
|
||||||
|
navigator = ChapterNavigator(self.blocks)
|
||||||
|
|
||||||
|
pos = navigator.get_chapter_position("Chapter One")
|
||||||
|
self.assertIsNotNone(pos)
|
||||||
|
self.assertEqual(pos.chapter_index, 0)
|
||||||
|
|
||||||
|
pos = navigator.get_chapter_position("Nonexistent Chapter")
|
||||||
|
self.assertIsNone(pos)
|
||||||
|
|
||||||
|
def test_current_chapter_detection(self):
|
||||||
|
"""Test detecting current chapter from position"""
|
||||||
|
navigator = ChapterNavigator(self.blocks)
|
||||||
|
|
||||||
|
# Position in first chapter
|
||||||
|
pos = RenderingPosition(chapter_index=0, block_index=2)
|
||||||
|
chapter = navigator.get_current_chapter(pos)
|
||||||
|
self.assertIsNotNone(chapter)
|
||||||
|
self.assertEqual(chapter.title, "Chapter One")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontScaler(unittest.TestCase):
|
||||||
|
"""Test the FontScaler class"""
|
||||||
|
|
||||||
|
def test_font_scaling(self):
|
||||||
|
"""Test font scaling functionality"""
|
||||||
|
original_font = Font(font_size=12)
|
||||||
|
|
||||||
|
# Test no scaling
|
||||||
|
scaled_font = FontScaler.scale_font(original_font, 1.0)
|
||||||
|
self.assertEqual(scaled_font.font_size, 12)
|
||||||
|
|
||||||
|
# Test 2x scaling
|
||||||
|
scaled_font = FontScaler.scale_font(original_font, 2.0)
|
||||||
|
self.assertEqual(scaled_font.font_size, 24)
|
||||||
|
|
||||||
|
# Test 0.5x scaling
|
||||||
|
scaled_font = FontScaler.scale_font(original_font, 0.5)
|
||||||
|
self.assertEqual(scaled_font.font_size, 6)
|
||||||
|
|
||||||
|
# Test minimum size constraint
|
||||||
|
scaled_font = FontScaler.scale_font(original_font, 0.01)
|
||||||
|
self.assertGreaterEqual(scaled_font.font_size, 1)
|
||||||
|
|
||||||
|
def test_word_spacing_scaling(self):
|
||||||
|
"""Test word spacing scaling"""
|
||||||
|
original_spacing = (5, 15)
|
||||||
|
|
||||||
|
# Test no scaling
|
||||||
|
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 1.0)
|
||||||
|
self.assertEqual(scaled_spacing, (5, 15))
|
||||||
|
|
||||||
|
# Test 2x scaling
|
||||||
|
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 2.0)
|
||||||
|
self.assertEqual(scaled_spacing, (10, 30))
|
||||||
|
|
||||||
|
# Test minimum constraints
|
||||||
|
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 0.1)
|
||||||
|
self.assertGreaterEqual(scaled_spacing[0], 1)
|
||||||
|
self.assertGreaterEqual(scaled_spacing[1], 2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookmarkManager(unittest.TestCase):
|
||||||
|
"""Test the BookmarkManager class"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test environment"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.document_id = "test_document"
|
||||||
|
self.bookmark_manager = BookmarkManager(self.document_id, self.temp_dir)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
shutil.rmtree(self.temp_dir)
|
||||||
|
|
||||||
|
def test_bookmark_operations(self):
|
||||||
|
"""Test bookmark add/remove/get operations"""
|
||||||
|
pos = RenderingPosition(chapter_index=1, block_index=5)
|
||||||
|
|
||||||
|
# Add bookmark
|
||||||
|
self.bookmark_manager.add_bookmark("test_bookmark", pos)
|
||||||
|
|
||||||
|
# Get bookmark
|
||||||
|
retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark")
|
||||||
|
self.assertEqual(retrieved_pos, pos)
|
||||||
|
|
||||||
|
# List bookmarks
|
||||||
|
bookmarks = self.bookmark_manager.list_bookmarks()
|
||||||
|
self.assertEqual(len(bookmarks), 1)
|
||||||
|
self.assertEqual(bookmarks[0][0], "test_bookmark")
|
||||||
|
self.assertEqual(bookmarks[0][1], pos)
|
||||||
|
|
||||||
|
# Remove bookmark
|
||||||
|
success = self.bookmark_manager.remove_bookmark("test_bookmark")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# Verify removal
|
||||||
|
retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark")
|
||||||
|
self.assertIsNone(retrieved_pos)
|
||||||
|
|
||||||
|
def test_reading_position_persistence(self):
|
||||||
|
"""Test saving and loading reading position"""
|
||||||
|
pos = RenderingPosition(chapter_index=2, block_index=10, word_index=5)
|
||||||
|
|
||||||
|
# Save position
|
||||||
|
self.bookmark_manager.save_reading_position(pos)
|
||||||
|
|
||||||
|
# Create new manager instance (simulates app restart)
|
||||||
|
new_manager = BookmarkManager(self.document_id, self.temp_dir)
|
||||||
|
|
||||||
|
# Load position
|
||||||
|
loaded_pos = new_manager.load_reading_position()
|
||||||
|
self.assertEqual(loaded_pos, pos)
|
||||||
|
|
||||||
|
def test_bookmark_persistence(self):
|
||||||
|
"""Test that bookmarks persist across manager instances"""
|
||||||
|
pos = RenderingPosition(chapter_index=1, block_index=5)
|
||||||
|
|
||||||
|
# Add bookmark
|
||||||
|
self.bookmark_manager.add_bookmark("persistent_bookmark", pos)
|
||||||
|
|
||||||
|
# Create new manager instance
|
||||||
|
new_manager = BookmarkManager(self.document_id, self.temp_dir)
|
||||||
|
|
||||||
|
# Verify bookmark exists
|
||||||
|
retrieved_pos = new_manager.get_bookmark("persistent_bookmark")
|
||||||
|
self.assertEqual(retrieved_pos, pos)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEreaderLayoutManager(unittest.TestCase):
|
||||||
|
"""Test the complete EreaderLayoutManager"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data"""
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.font = Font()
|
||||||
|
|
||||||
|
# Create test document with multiple paragraphs and headings
|
||||||
|
self.blocks = []
|
||||||
|
|
||||||
|
# Add a heading
|
||||||
|
heading = Heading(HeadingLevel.H1, self.font)
|
||||||
|
heading.add_word(Word("Test", self.font))
|
||||||
|
heading.add_word(Word("Chapter", self.font))
|
||||||
|
self.blocks.append(heading)
|
||||||
|
|
||||||
|
# Add several paragraphs with multiple words
|
||||||
|
for i in range(3):
|
||||||
|
paragraph = Paragraph(self.font)
|
||||||
|
for j in range(20): # 20 words per paragraph
|
||||||
|
paragraph.add_word(Word(f"Word{i}_{j}", self.font))
|
||||||
|
self.blocks.append(paragraph)
|
||||||
|
|
||||||
|
self.page_size = (400, 600)
|
||||||
|
self.document_id = "test_document"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up test environment"""
|
||||||
|
shutil.rmtree(self.temp_dir)
|
||||||
|
|
||||||
|
def test_manager_initialization(self):
|
||||||
|
"""Test ereader manager initialization"""
|
||||||
|
# Change to temp directory for bookmarks
|
||||||
|
original_cwd = Path.cwd()
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
os.chdir(self.temp_dir)
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
self.blocks,
|
||||||
|
self.page_size,
|
||||||
|
self.document_id,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(manager.page_size, self.page_size)
|
||||||
|
self.assertEqual(manager.document_id, self.document_id)
|
||||||
|
self.assertEqual(manager.font_scale, 1.0)
|
||||||
|
self.assertIsInstance(manager.current_position, RenderingPosition)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
def test_font_scaling(self):
|
||||||
|
"""Test font scaling functionality"""
|
||||||
|
original_cwd = Path.cwd()
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
os.chdir(self.temp_dir)
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
self.blocks,
|
||||||
|
self.page_size,
|
||||||
|
self.document_id,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test initial scale
|
||||||
|
self.assertEqual(manager.get_font_scale(), 1.0)
|
||||||
|
|
||||||
|
# Test scaling
|
||||||
|
page = manager.set_font_scale(1.5)
|
||||||
|
self.assertEqual(manager.get_font_scale(), 1.5)
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
def test_table_of_contents(self):
|
||||||
|
"""Test table of contents functionality"""
|
||||||
|
original_cwd = Path.cwd()
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
os.chdir(self.temp_dir)
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
self.blocks,
|
||||||
|
self.page_size,
|
||||||
|
self.document_id,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
toc = manager.get_table_of_contents()
|
||||||
|
self.assertGreater(len(toc), 0)
|
||||||
|
|
||||||
|
# Check first entry
|
||||||
|
title, level, position = toc[0]
|
||||||
|
self.assertEqual(title, "Test Chapter")
|
||||||
|
self.assertEqual(level, HeadingLevel.H1)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
def test_bookmark_functionality(self):
|
||||||
|
"""Test bookmark functionality"""
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
self.blocks,
|
||||||
|
self.page_size,
|
||||||
|
self.document_id,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add bookmark
|
||||||
|
success = manager.add_bookmark("test_bookmark")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
# List bookmarks
|
||||||
|
bookmarks = manager.list_bookmarks()
|
||||||
|
self.assertEqual(len(bookmarks), 1)
|
||||||
|
self.assertEqual(bookmarks[0][0], "test_bookmark")
|
||||||
|
|
||||||
|
# Jump to bookmark (should work even though it's the same position)
|
||||||
|
page = manager.jump_to_bookmark("test_bookmark")
|
||||||
|
self.assertIsNotNone(page)
|
||||||
|
|
||||||
|
# Remove bookmark
|
||||||
|
success = manager.remove_bookmark("test_bookmark")
|
||||||
|
self.assertTrue(success)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
|
||||||
|
def test_progress_tracking(self):
|
||||||
|
"""Test reading progress tracking"""
|
||||||
|
original_cwd = Path.cwd()
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
os.chdir(self.temp_dir)
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(
|
||||||
|
self.blocks,
|
||||||
|
self.page_size,
|
||||||
|
self.document_id,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initial progress should be 0
|
||||||
|
progress = manager.get_reading_progress()
|
||||||
|
self.assertGreaterEqual(progress, 0.0)
|
||||||
|
self.assertLessEqual(progress, 1.0)
|
||||||
|
|
||||||
|
# Get position info
|
||||||
|
info = manager.get_position_info()
|
||||||
|
self.assertIn('position', info)
|
||||||
|
self.assertIn('progress', info)
|
||||||
|
self.assertIn('font_scale', info)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
def test_convenience_function(self):
|
||||||
|
"""Test the convenience function"""
|
||||||
|
original_cwd = Path.cwd()
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
os.chdir(self.temp_dir)
|
||||||
|
|
||||||
|
manager = create_ereader_manager(
|
||||||
|
self.blocks,
|
||||||
|
self.page_size,
|
||||||
|
self.document_id,
|
||||||
|
bookmarks_dir=self.temp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsInstance(manager, EreaderLayoutManager)
|
||||||
|
self.assertEqual(manager.page_size, self.page_size)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Loading…
x
Reference in New Issue
Block a user