commit 2e926ab87a12f0a72f78820fe591e66c576cf54f Author: Duncan Tourolle Date: Fri Nov 7 18:47:10 2025 +0100 Migration of application to own repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95fc48b --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Ereader data +ereader_bookmarks/ +highlights/ +*.png +*.gif +*.jpg +*.jpeg + +# Examples output +examples/ereader_bookmarks/ +examples/highlights/ +examples/*.png +examples/*.gif + +# Keep test data +!tests/data/** diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b09da01 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 pyWebLayout Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8cb8c4 --- /dev/null +++ b/README.md @@ -0,0 +1,210 @@ +# pyWebLayout-ereader + +A complete ebook reader application built with [pyWebLayout](https://github.com/yourusername/pyWebLayout). + +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. + +## Features + +- 📖 **EPUB Support** - Load and read EPUB files +- 📄 **Page Navigation** - Forward/backward navigation with smooth rendering +- 🔖 **Bookmarks** - Save and restore reading positions +- 🎨 **Text Highlighting** - Highlight words and selections with notes +- 🔍 **Text Selection** - Select and query text via touch/click +- ⚙️ **Customization** - Font size, line spacing, colors +- 📑 **Chapter Navigation** - Jump to chapters via table of contents +- 👆 **Gesture Support** - Tap, swipe, pinch, long-press handling +- 💾 **Position Persistence** - Stable positions across style changes + +## Installation + +### From Source + +```bash +# Install pyWebLayout first if not already installed +cd /path/to/pyWebLayout +pip install -e . + +# Install pyWebLayout-ereader +cd /path/to/pyWebLayout-ereader +pip install -e . +``` + +### As a Dependency + +```bash +pip install pyweblayout-ereader +``` + +## Quick Start + +```python +from pyweblayout_ereader import EbookReader + +# Create reader +reader = EbookReader(page_size=(800, 1000)) + +# Load an EPUB +reader.load_epub("mybook.epub") + +# Get current page as image +page = reader.get_current_page() +page.save("current_page.png") + +# Navigate +reader.next_page() +reader.previous_page() + +# Save position +reader.save_position("bookmark1") + +# Later, restore position +reader.load_position("bookmark1") +``` + +## Examples + +See the `examples/` directory for complete examples: + +- **simple_ereader_example.py** - Basic ereader usage +- **ereader_demo.py** - Full-featured demo with all capabilities +- **word_selection_highlighting.py** - Text selection and highlighting +- **generate_ereader_gifs.py** - Generate animated demos + +## Architecture + +This project is a **high-level application layer** that combines pyWebLayout components: + +``` +pyweblayout_ereader.EbookReader + ↓ +├── pyWebLayout.layout.EreaderLayoutManager # Layout & pagination +├── pyWebLayout.core.HighlightManager # Highlighting system +├── pyWebLayout.io.gesture # Touch/gesture handling +└── pyWebLayout.io.readers # EPUB parsing +``` + +## API Overview + +### Loading Content + +```python +reader.load_epub("book.epub") +reader.is_loaded() # Check if book loaded +reader.get_book_info() # Get metadata +``` + +### Navigation + +```python +reader.next_page() +reader.previous_page() +reader.jump_to_chapter("Chapter 1") +reader.get_reading_progress() # 0.0 to 1.0 +``` + +### Styling + +```python +reader.increase_font_size() +reader.decrease_font_size() +reader.set_font_size(1.5) # 150% scale +reader.set_line_spacing(8) +reader.set_inter_block_spacing(20) +``` + +### Bookmarks + +```python +reader.save_position("my_bookmark") +reader.load_position("my_bookmark") +reader.list_saved_positions() +reader.delete_position("my_bookmark") +``` + +### Highlighting + +```python +# Highlight a word at pixel coordinates +highlight_id = reader.highlight_word(x=100, y=200, note="Important!") + +# Highlight a selection +highlight_id = reader.highlight_selection( + start=(100, 200), + end=(300, 250), + color=(255, 255, 0, 128) # Yellow +) + +# Manage highlights +reader.list_highlights() +reader.remove_highlight(highlight_id) +reader.clear_highlights() +``` + +### Gesture Handling + +```python +from pyWebLayout.io.gesture import TouchEvent, GestureType + +# Handle touch input +event = TouchEvent(GestureType.TAP, x=400, y=300) +response = reader.handle_touch(event) + +# Response contains action type and data +if response.action == ActionType.PAGE_TURN: + print(f"Page turned: {response.data['direction']}") +``` + +## Use Cases + +- **Desktop Ereader Applications** - Build native ereader apps +- **Web-based Readers** - Serve rendered pages via Flask/FastAPI +- **E-ink Device Firmware** - Optimized for e-ink displays +- **Reading Analytics** - Track reading patterns and highlights +- **Educational Tools** - Annotated reading with highlights and notes + +## Relationship to pyWebLayout + +**pyWebLayout** is a layout engine library providing low-level primitives for: +- Text rendering and layout +- Document structure and pagination +- Query systems for interactive content + +**pyWebLayout-ereader** is an application framework that: +- Combines pyWebLayout components into a complete reader +- Provides user-friendly APIs for common ereader tasks +- Manages application state (bookmarks, highlights, etc.) +- Handles business logic for gestures and interactions + +Think of it like this: +- pyWebLayout = React (library) +- pyWebLayout-ereader = Next.js (framework) + +## Development + +```bash +# Install in development mode with dev dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Format code +black pyweblayout_ereader/ + +# Type checking +mypy pyweblayout_ereader/ +``` + +## 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! + +## License + +MIT License - see LICENSE file for details + +## Related Projects + +- [pyWebLayout](https://github.com/yourusername/pyWebLayout) - The underlying layout engine +- Add your projects here! diff --git a/examples/README_EREADER.md b/examples/README_EREADER.md new file mode 100644 index 0000000..a031acf --- /dev/null +++ b/examples/README_EREADER.md @@ -0,0 +1,363 @@ +# EbookReader - Simple EPUB Reader Application + +The `EbookReader` class provides a complete, user-friendly interface for building ebook reader applications with pyWebLayout. It wraps all the complex ereader infrastructure into a simple API. + +## Features + +- 📖 **EPUB Loading** - Load EPUB files with automatic content extraction +- ⬅️➡️ **Page Navigation** - Forward and backward page navigation +- 🔖 **Position Management** - Save/load reading positions (stable across font changes) +- 📑 **Chapter Navigation** - Jump to chapters by title or index +- 🔤 **Font Size Control** - Increase/decrease font size with live re-rendering +- 📏 **Spacing Control** - Adjust line and block spacing +- 📊 **Progress Tracking** - Get reading progress and position information +- 💾 **Context Manager Support** - Automatic cleanup with `with` statement + +## Quick Start + +```python +from pyWebLayout.layout.ereader_application import EbookReader + +# Create reader +reader = EbookReader(page_size=(800, 1000)) + +# Load an EPUB +reader.load_epub("mybook.epub") + +# Get current page as PIL Image +page_image = reader.get_current_page() +page_image.save("current_page.png") + +# Navigate +reader.next_page() +reader.previous_page() + +# Close reader +reader.close() +``` + +## API Reference + +### Initialization + +```python +reader = EbookReader( + page_size=(800, 1000), # Page dimensions (width, height) in pixels + margin=40, # Page margin in pixels + background_color=(255, 255, 255), # RGB background color + line_spacing=5, # Line spacing in pixels + inter_block_spacing=15, # Space between blocks in pixels + bookmarks_dir="ereader_bookmarks", # Directory for bookmarks + buffer_size=5 # Number of pages to cache +) +``` + +### Loading EPUB + +```python +# Load EPUB file +success = reader.load_epub("path/to/book.epub") + +# Check if book is loaded +if reader.is_loaded(): + print("Book loaded successfully") + +# Get book information +book_info = reader.get_book_info() +# Returns: { +# 'title': 'Book Title', +# 'author': 'Author Name', +# 'document_id': 'book', +# 'total_blocks': 5000, +# 'total_chapters': 20, +# 'page_size': (800, 1000), +# 'font_scale': 1.0 +# } +``` + +### Page Navigation + +```python +# Get current page as PIL Image +page = reader.get_current_page() + +# Navigate to next page +page = reader.next_page() # Returns None at end of book + +# Navigate to previous page +page = reader.previous_page() # Returns None at beginning + +# Save current page to file +reader.render_to_file("page.png") +``` + +### Position Management + +Positions are saved based on abstract document structure (chapter/block/word indices), making them stable across font size and styling changes. + +```python +# Save current position +reader.save_position("my_bookmark") + +# Load saved position +page = reader.load_position("my_bookmark") + +# List all saved positions +positions = reader.list_saved_positions() +# Returns: ['my_bookmark', 'chapter_2', ...] + +# Delete a position +reader.delete_position("my_bookmark") + +# Get detailed position info +info = reader.get_position_info() +# Returns: { +# 'position': {'chapter_index': 0, 'block_index': 42, 'word_index': 15, ...}, +# 'chapter': {'title': 'Chapter 1', 'level': 'H1', ...}, +# 'progress': 0.15, # 15% through the book +# 'font_scale': 1.0, +# 'book_title': 'Book Title', +# 'book_author': 'Author Name' +# } + +# Get reading progress (0.0 to 1.0) +progress = reader.get_reading_progress() +print(f"You're {progress*100:.1f}% through the book") +``` + +### Chapter Navigation + +```python +# Get all chapters +chapters = reader.get_chapters() +# Returns: [('Chapter 1', 0), ('Chapter 2', 1), ...] + +# Get chapters with positions +chapter_positions = reader.get_chapter_positions() +# Returns: [('Chapter 1', RenderingPosition(...)), ...] + +# Jump to chapter by index +page = reader.jump_to_chapter(1) # Jump to second chapter + +# Jump to chapter by title +page = reader.jump_to_chapter("Chapter 1") + +# Get current chapter info +chapter_info = reader.get_current_chapter_info() +# Returns: {'title': 'Chapter 1', 'level': HeadingLevel.H1, 'block_index': 0} +``` + +### Font Size Control + +```python +# Get current font size scale +scale = reader.get_font_size() # Default: 1.0 + +# Set specific font size scale +page = reader.set_font_size(1.5) # 150% of normal size + +# Increase font size by 10% +page = reader.increase_font_size() + +# Decrease font size by 10% +page = reader.decrease_font_size() +``` + +### Spacing Control + +```python +# Set line spacing (spacing between lines within a paragraph) +page = reader.set_line_spacing(10) # 10 pixels + +# Set inter-block spacing (spacing between paragraphs, headings, etc.) +page = reader.set_inter_block_spacing(20) # 20 pixels +``` + +### Context Manager + +The reader supports Python's context manager protocol for automatic cleanup: + +```python +with EbookReader(page_size=(800, 1000)) as reader: + reader.load_epub("book.epub") + page = reader.get_current_page() + # ... do stuff +# Automatically saves position and cleans up resources +``` + +## Complete Example + +```python +from pyWebLayout.layout.ereader_application import EbookReader + +# Create reader with custom settings +with EbookReader( + page_size=(800, 1000), + margin=50, + line_spacing=8, + inter_block_spacing=20 +) as reader: + # Load EPUB + if not reader.load_epub("my_novel.epub"): + print("Failed to load EPUB") + exit(1) + + # Get book info + info = reader.get_book_info() + print(f"Reading: {info['title']} by {info['author']}") + print(f"Total chapters: {info['total_chapters']}") + + # Navigate through first few pages + for i in range(5): + page = reader.get_current_page() + page.save(f"page_{i+1:03d}.png") + reader.next_page() + + # Save current position + reader.save_position("page_5") + + # Jump to a chapter + chapters = reader.get_chapters() + if len(chapters) > 2: + print(f"Jumping to: {chapters[2][0]}") + reader.jump_to_chapter(2) + reader.render_to_file("chapter_3_start.png") + + # Return to saved position + reader.load_position("page_5") + + # Adjust font size + reader.increase_font_size() + reader.render_to_file("page_5_larger_font.png") + + # Get progress + progress = reader.get_reading_progress() + print(f"Reading progress: {progress*100:.1f}%") +``` + +## Demo Script + +Run the comprehensive demo to see all features in action: + +```bash +python examples/ereader_demo.py path/to/book.epub +``` + +This will demonstrate: +- Basic page navigation +- Position save/load +- Chapter navigation +- Font size adjustments +- Spacing adjustments +- Book information retrieval + +The demo generates multiple PNG files showing different pages and settings. + +## Position Storage Format + +Positions are stored as JSON files in the `bookmarks_dir` (default: `ereader_bookmarks/`): + +```json +{ + "chapter_index": 0, + "block_index": 42, + "word_index": 15, + "table_row": 0, + "table_col": 0, + "list_item_index": 0, + "remaining_pretext": null, + "page_y_offset": 0 +} +``` + +This format is tied to the abstract document structure, making positions stable across: +- Font size changes +- Line spacing changes +- Inter-block spacing changes +- Page size changes + +## Integration Example: Simple GUI + +Here's a minimal example of integrating with Tkinter: + +```python +import tkinter as tk +from tkinter import filedialog +from PIL import ImageTk +from pyWebLayout.layout.ereader_application import EbookReader + +class SimpleEreaderGUI: + def __init__(self, root): + self.root = root + self.reader = EbookReader(page_size=(600, 800)) + + # Create UI + self.image_label = tk.Label(root) + self.image_label.pack() + + btn_frame = tk.Frame(root) + btn_frame.pack() + + tk.Button(btn_frame, text="Open EPUB", command=self.open_epub).pack(side=tk.LEFT) + tk.Button(btn_frame, text="Previous", command=self.prev_page).pack(side=tk.LEFT) + tk.Button(btn_frame, text="Next", command=self.next_page).pack(side=tk.LEFT) + tk.Button(btn_frame, text="Font+", command=self.increase_font).pack(side=tk.LEFT) + tk.Button(btn_frame, text="Font-", command=self.decrease_font).pack(side=tk.LEFT) + + def open_epub(self): + filepath = filedialog.askopenfilename(filetypes=[("EPUB files", "*.epub")]) + if filepath: + self.reader.load_epub(filepath) + self.display_page() + + def display_page(self): + page = self.reader.get_current_page() + if page: + photo = ImageTk.PhotoImage(page) + self.image_label.config(image=photo) + self.image_label.image = photo + + def next_page(self): + if self.reader.next_page(): + self.display_page() + + def prev_page(self): + if self.reader.previous_page(): + self.display_page() + + def increase_font(self): + self.reader.increase_font_size() + self.display_page() + + def decrease_font(self): + self.reader.decrease_font_size() + self.display_page() + +root = tk.Tk() +root.title("Simple Ereader") +app = SimpleEreaderGUI(root) +root.mainloop() +``` + +## Performance Notes + +- The reader uses intelligent page caching for fast navigation +- First page load may take ~1 second, subsequent pages are typically < 0.1 seconds +- Background rendering attempts to pre-cache upcoming pages (you may see pickle warnings, which can be ignored) +- Font size changes invalidate the cache and require re-rendering from the current position +- Position save/load is nearly instantaneous + +## Limitations + +- Currently supports EPUB files only (no PDF, MOBI, etc.) +- Images in EPUBs may not render in some cases +- Tables are skipped in rendering +- Complex HTML layouts may not render perfectly +- No text selection or search functionality (these would need to be added separately) + +## See Also + +- `examples/ereader_demo.py` - Comprehensive feature demonstration +- `pyWebLayout/layout/ereader_manager.py` - Underlying manager class +- `pyWebLayout/layout/ereader_layout.py` - Core layout engine +- `examples/README_EPUB_RENDERERS.md` - Lower-level EPUB rendering diff --git a/examples/ereader_demo.py b/examples/ereader_demo.py new file mode 100644 index 0000000..5f3796b --- /dev/null +++ b/examples/ereader_demo.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +""" +Comprehensive demo of the EbookReader functionality. + +This script demonstrates all features of the pyWebLayout EbookReader: +- Loading EPUB files +- Page navigation (forward/backward) +- Position saving/loading +- Chapter navigation +- Font size and spacing adjustments +- Getting book and position information + +Usage: + python ereader_demo.py path/to/book.epub +""" + +import sys +import os +from pathlib import Path + +from pyweblayout_ereader import EbookReader + + +def print_separator(): + """Print a visual separator.""" + print("\n" + "="*70 + "\n") + + +def demo_basic_navigation(reader: EbookReader): + """Demonstrate basic page navigation.""" + print("DEMO: Basic Navigation") + print_separator() + + # Get current page + print("Getting first page...") + page = reader.get_current_page() + if page: + print(f"✓ Current page rendered: {page.size}") + reader.render_to_file("demo_page_001.png") + print(" Saved to: demo_page_001.png") + + # Navigate forward + print("\nNavigating to next page...") + page = reader.next_page() + if page: + print(f"✓ Next page rendered: {page.size}") + reader.render_to_file("demo_page_002.png") + print(" Saved to: demo_page_002.png") + + # Navigate backward + print("\nNavigating to previous page...") + page = reader.previous_page() + if page: + print(f"✓ Previous page rendered: {page.size}") + + print_separator() + + +def demo_position_management(reader: EbookReader): + """Demonstrate position save/load functionality.""" + print("DEMO: Position Management") + print_separator() + + # Navigate a few pages forward + print("Navigating forward 3 pages...") + for i in range(3): + reader.next_page() + + # Save position + print("Saving current position as 'demo_bookmark'...") + success = reader.save_position("demo_bookmark") + if success: + print("✓ Position saved successfully") + + # Get position info + pos_info = reader.get_position_info() + print(f"\nCurrent position info:") + print(f" Chapter: {pos_info.get('chapter', {}).get('title', 'N/A')}") + print(f" Block index: {pos_info['position']['block_index']}") + print(f" Word index: {pos_info['position']['word_index']}") + print(f" Progress: {pos_info['progress']*100:.1f}%") + + # Navigate away + print("\nNavigating forward 5 more pages...") + for i in range(5): + reader.next_page() + + # Load saved position + print("Loading saved position 'demo_bookmark'...") + page = reader.load_position("demo_bookmark") + if page: + print("✓ Position restored successfully") + reader.render_to_file("demo_restored_position.png") + print(" Saved to: demo_restored_position.png") + + # List all saved positions + positions = reader.list_saved_positions() + print(f"\nAll saved positions: {positions}") + + print_separator() + + +def demo_chapter_navigation(reader: EbookReader): + """Demonstrate chapter navigation.""" + print("DEMO: Chapter Navigation") + print_separator() + + # Get all chapters + chapters = reader.get_chapters() + print(f"Found {len(chapters)} chapters:") + for title, idx in chapters[:5]: # Show first 5 + print(f" [{idx}] {title}") + + if len(chapters) > 5: + print(f" ... and {len(chapters) - 5} more") + + # Jump to a chapter by index + if len(chapters) > 1: + print(f"\nJumping to chapter 1...") + page = reader.jump_to_chapter(1) + if page: + print("✓ Jumped to chapter successfully") + reader.render_to_file("demo_chapter_1.png") + print(" Saved to: demo_chapter_1.png") + + # Get current chapter info + chapter_info = reader.get_current_chapter_info() + if chapter_info: + print(f" Current chapter: {chapter_info['title']}") + + # Jump to a chapter by title (if we have chapters) + if len(chapters) > 0: + first_chapter_title = chapters[0][0] + print(f"\nJumping to chapter by title: '{first_chapter_title}'...") + page = reader.jump_to_chapter(first_chapter_title) + if page: + print("✓ Jumped to chapter by title successfully") + + print_separator() + + +def demo_font_size_adjustment(reader: EbookReader): + """Demonstrate font size adjustments.""" + print("DEMO: Font Size Adjustment") + print_separator() + + # Save current page for comparison + print("Rendering page at normal font size (1.0x)...") + page = reader.get_current_page() + if page: + reader.render_to_file("demo_font_normal.png") + print("✓ Saved to: demo_font_normal.png") + + # Increase font size + print("\nIncreasing font size...") + page = reader.increase_font_size() + if page: + print(f"✓ Font size increased to {reader.get_font_size():.1f}x") + reader.render_to_file("demo_font_larger.png") + print(" Saved to: demo_font_larger.png") + + # Increase again + print("\nIncreasing font size again...") + page = reader.increase_font_size() + if page: + print(f"✓ Font size increased to {reader.get_font_size():.1f}x") + reader.render_to_file("demo_font_largest.png") + print(" Saved to: demo_font_largest.png") + + # Decrease font size + print("\nDecreasing font size...") + page = reader.decrease_font_size() + if page: + print(f"✓ Font size decreased to {reader.get_font_size():.1f}x") + + # Set specific font size + print("\nResetting to normal font size (1.0x)...") + page = reader.set_font_size(1.0) + if page: + print("✓ Font size reset to 1.0x") + + print_separator() + + +def demo_spacing_adjustment(reader: EbookReader): + """Demonstrate line and block spacing adjustments.""" + print("DEMO: Spacing Adjustment") + print_separator() + + # Save current page + print("Rendering page with default spacing...") + page = reader.get_current_page() + if page: + reader.render_to_file("demo_spacing_default.png") + print("✓ Saved to: demo_spacing_default.png") + + # Increase line spacing + print("\nIncreasing line spacing to 10px...") + page = reader.set_line_spacing(10) + if page: + print("✓ Line spacing increased") + reader.render_to_file("demo_spacing_lines_10.png") + print(" Saved to: demo_spacing_lines_10.png") + + # Increase inter-block spacing + print("\nIncreasing inter-block spacing to 25px...") + page = reader.set_inter_block_spacing(25) + if page: + print("✓ Inter-block spacing increased") + reader.render_to_file("demo_spacing_blocks_25.png") + print(" Saved to: demo_spacing_blocks_25.png") + + # Reset to defaults + print("\nResetting spacing to defaults (line: 5px, block: 15px)...") + reader.set_line_spacing(5) + page = reader.set_inter_block_spacing(15) + if page: + print("✓ Spacing reset to defaults") + + print_separator() + + +def demo_book_information(reader: EbookReader): + """Demonstrate getting book information.""" + print("DEMO: Book Information") + print_separator() + + # Get book info + book_info = reader.get_book_info() + print("Book Information:") + print(f" Title: {book_info['title']}") + print(f" Author: {book_info['author']}") + print(f" Document ID: {book_info['document_id']}") + print(f" Total blocks: {book_info['total_blocks']}") + print(f" Total chapters: {book_info['total_chapters']}") + print(f" Page size: {book_info['page_size']}") + print(f" Font scale: {book_info['font_scale']}") + + # Get reading progress + progress = reader.get_reading_progress() + print(f"\nReading Progress: {progress*100:.1f}%") + + # Get detailed position info + pos_info = reader.get_position_info() + print("\nDetailed Position:") + print(f" Chapter index: {pos_info['position']['chapter_index']}") + print(f" Block index: {pos_info['position']['block_index']}") + print(f" Word index: {pos_info['position']['word_index']}") + + chapter = pos_info.get('chapter', {}) + if chapter.get('title'): + print(f" Current chapter: {chapter['title']}") + + print_separator() + + +def main(): + """Main function to run all demos.""" + if len(sys.argv) < 2: + print("Usage: python ereader_demo.py path/to/book.epub") + print("\nExample EPUBs to try:") + print(" - tests/data/test.epub") + print(" - tests/data/test2.epub") + sys.exit(1) + + epub_path = sys.argv[1] + + if not os.path.exists(epub_path): + print(f"Error: File not found: {epub_path}") + sys.exit(1) + + print("="*70) + print(" EbookReader Demo - pyWebLayout") + print("="*70) + print(f"\nLoading EPUB: {epub_path}") + + # Create reader with context manager + with EbookReader(page_size=(800, 1000)) as reader: + # Load the EPUB + if not reader.load_epub(epub_path): + print("Error: Failed to load EPUB file") + sys.exit(1) + + print("✓ EPUB loaded successfully") + + # Run all demos + try: + demo_basic_navigation(reader) + demo_position_management(reader) + demo_chapter_navigation(reader) + demo_font_size_adjustment(reader) + demo_spacing_adjustment(reader) + demo_book_information(reader) + + print("\n" + "="*70) + print(" Demo Complete!") + print("="*70) + print("\nGenerated demo images:") + demo_files = [ + "demo_page_001.png", + "demo_page_002.png", + "demo_restored_position.png", + "demo_chapter_1.png", + "demo_font_normal.png", + "demo_font_larger.png", + "demo_font_largest.png", + "demo_spacing_default.png", + "demo_spacing_lines_10.png", + "demo_spacing_blocks_25.png" + ] + + for filename in demo_files: + if os.path.exists(filename): + print(f" ✓ {filename}") + + print("\nAll features demonstrated successfully!") + + except Exception as e: + print(f"\nError during demo: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/generate_ereader_gifs.py b/examples/generate_ereader_gifs.py new file mode 100644 index 0000000..92c3c3f --- /dev/null +++ b/examples/generate_ereader_gifs.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +""" +Generate animated GIFs demonstrating EbookReader functionality. + +This script creates animated GIFs showcasing: +1. Page navigation (next/previous) +2. Font size adjustment +3. Chapter navigation +4. Bookmark/position management +5. Word highlighting + +The GIFs are saved to the examples/ directory and can be included in documentation. + +Usage: + python generate_ereader_gifs.py path/to/book.epub [output_dir] + +Example: + python generate_ereader_gifs.py ../tests/data/test.epub ../docs/images +""" + +import sys +import os +from pathlib import Path +from typing import List + +from pyweblayout_ereader import EbookReader +from pyWebLayout.core.highlight import HighlightColor +from PIL import Image + + +def create_gif(images: List[Image.Image], output_path: str, duration: int = 800, loop: int = 0): + """ + Create an animated GIF from a list of PIL Images. + + Args: + images: List of PIL Images to animate + output_path: Path where to save the GIF + duration: Duration of each frame in milliseconds + loop: Number of loops (0 = infinite) + """ + if not images: + print(f"Warning: No images provided for {output_path}") + return False + + try: + # Save as animated GIF + images[0].save( + output_path, + save_all=True, + append_images=images[1:], + duration=duration, + loop=loop, + optimize=False + ) + print(f"✓ Created: {output_path} ({len(images)} frames)") + return True + except Exception as e: + print(f"✗ Error creating {output_path}: {e}") + return False + + +def generate_page_navigation_gif(reader: EbookReader, output_path: str): + """Generate GIF showing page navigation (forward and backward).""" + print("\n[1/4] Generating page navigation GIF...") + + frames = [] + + # Go to beginning + reader.set_font_size(1.0) + + # Capture 5 pages going forward + for i in range(5): + page = reader.get_current_page() + if page: + frames.append(page.copy()) + reader.next_page() + + # Go back to start + for _ in range(4): + reader.previous_page() + + # Capture 5 pages going forward again (smoother loop) + for i in range(5): + page = reader.get_current_page() + if page: + frames.append(page.copy()) + reader.next_page() + + create_gif(frames, output_path, duration=600) + + +def generate_font_size_gif(reader: EbookReader, output_path: str): + """Generate GIF showing font size adjustment.""" + print("\n[2/4] Generating font size adjustment GIF...") + + frames = [] + + # Reset to beginning and normal font + for _ in range(10): + reader.previous_page() + reader.set_font_size(1.0) + + # Font sizes to demonstrate + font_scales = [0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.3, 1.2, 1.1, 1.0, 0.9, 0.8] + + for scale in font_scales: + page = reader.set_font_size(scale) + if page: + frames.append(page.copy()) + + # Reset to normal + reader.set_font_size(1.0) + + create_gif(frames, output_path, duration=500) + + +def generate_chapter_navigation_gif(reader: EbookReader, output_path: str): + """Generate GIF showing chapter navigation.""" + print("\n[3/4] Generating chapter navigation GIF...") + + frames = [] + + # Reset font + reader.set_font_size(1.0) + + # Get chapters + chapters = reader.get_chapters() + + if len(chapters) == 0: + print(" Warning: No chapters found, skipping chapter navigation GIF") + return + + # Visit first few chapters (or loop through available chapters) + chapter_indices = list(range(min(5, len(chapters)))) + + # Add some chapters twice for smoother animation + for idx in chapter_indices: + page = reader.jump_to_chapter(idx) + if page: + frames.append(page.copy()) + # Add a second frame at each chapter for pause effect + frames.append(page.copy()) + + # Go back to first chapter + page = reader.jump_to_chapter(0) + if page: + frames.append(page.copy()) + + if frames: + create_gif(frames, output_path, duration=1000) + else: + print(" Warning: No frames captured for chapter navigation") + + +def generate_bookmark_gif(reader: EbookReader, output_path: str): + """Generate GIF showing bookmark save/load functionality.""" + print("\n[4/5] Generating bookmark/position GIF...") + + frames = [] + + # Reset font + reader.set_font_size(1.0) + + # Go to beginning + for _ in range(20): + reader.previous_page() + + # Capture initial position + page = reader.get_current_page() + if page: + frames.append(page.copy()) + frames.append(page.copy()) # Hold frame + + # Navigate forward a bit + for i in range(3): + reader.next_page() + page = reader.get_current_page() + if page: + frames.append(page.copy()) + + # Save this position + reader.save_position("demo_bookmark") + page = reader.get_current_page() + if page: + frames.append(page.copy()) + frames.append(page.copy()) # Hold frame to show saved position + + # Navigate away + for i in range(5): + reader.next_page() + page = reader.get_current_page() + if page: + frames.append(page.copy()) + + # Hold at distant position + page = reader.get_current_page() + if page: + frames.append(page.copy()) + frames.append(page.copy()) + + # Jump back to bookmark + page = reader.load_position("demo_bookmark") + if page: + frames.append(page.copy()) + frames.append(page.copy()) + frames.append(page.copy()) # Hold longer to show we're back + + create_gif(frames, output_path, duration=600) + + +def generate_highlighting_gif(reader: EbookReader, output_path: str): + """Generate GIF showing word highlighting functionality.""" + print("\n[5/5] Generating word highlighting GIF...") + + frames = [] + + # Reset font + reader.set_font_size(1.0) + + # Find a page with actual text content (skip title/cover pages) + for _ in range(5): + reader.next_page() + + # Collect text objects from the page with their actual positions + from pyWebLayout.concrete.text import Line + text_positions = [] + + # Try to find a page with text + max_attempts = 10 + for attempt in range(max_attempts): + page = reader.manager.get_current_page() + text_positions = [] + + for child in page._children: + if isinstance(child, Line): + for text_obj in child._text_objects: + # Skip empty text + if not hasattr(text_obj, '_text') or not text_obj._text or not text_obj._text.strip(): + continue + + # Calculate center of text object, but clamp Y to Line bounds + origin = text_obj._origin + size = text_obj.size + center_x = int(origin[0] + size[0] / 2) + center_y = int(origin[1] + size[1] / 2) + + # Clamp Y to be within Line bounds (avoids the baseline extension issue) + line_y_min = int(child._origin[1]) + line_y_max = int(child._origin[1] + child._size[1]) + clamped_y = max(line_y_min, min(line_y_max - 1, center_y)) + + text_positions.append((center_x, clamped_y, text_obj._text)) + + # If we found enough text, use this page + if len(text_positions) > 10: + print(f" Found page with {len(text_positions)} words") + break + + # Otherwise try next page + reader.next_page() + + if len(text_positions) == 0: + print(" Warning: Could not find a page with text after searching") + + # Capture initial page without highlights + page_img = reader.get_current_page(include_highlights=False) + if page_img: + frames.append(page_img.copy()) + frames.append(page_img.copy()) # Hold frame + + # Use different colors for highlighting + colors = [ + HighlightColor.YELLOW.value, + HighlightColor.GREEN.value, + HighlightColor.BLUE.value, + HighlightColor.PINK.value, + HighlightColor.ORANGE.value, + ] + + # Select a subset of words to highlight (spread across the page) + # Take every Nth word to get a good distribution + if len(text_positions) > 10: + step = len(text_positions) // 5 + selected_positions = [text_positions[i * step] for i in range(5) if i * step < len(text_positions)] + else: + selected_positions = text_positions[:5] + + highlighted_words = 0 + color_names = ['YELLOW', 'GREEN', 'BLUE', 'PINK', 'ORANGE'] + + print(f"\n Highlighting words:") + for i, (x, y, text) in enumerate(selected_positions): + color = colors[i % len(colors)] + color_name = color_names[i % len(color_names)] + + # Highlight the word at this position + highlight_id = reader.highlight_word(x, y, color=color) + + if highlight_id: + highlighted_words += 1 + print(f" [{color_name:6s}] {text}") + # Capture page with new highlight + page_img = reader.get_current_page(include_highlights=True) + if page_img: + frames.append(page_img.copy()) + # Hold frame briefly to show the new highlight + frames.append(page_img.copy()) + + # If we managed to highlight any words, show the final result + if highlighted_words > 0: + page_img = reader.get_current_page(include_highlights=True) + if page_img: + # Hold final frame longer + for _ in range(3): + frames.append(page_img.copy()) + + # Clear highlights one by one + for highlight in reader.list_highlights(): + reader.remove_highlight(highlight.id) + page_img = reader.get_current_page(include_highlights=True) + if page_img: + frames.append(page_img.copy()) + + # Show final cleared page + page_img = reader.get_current_page(include_highlights=False) + if page_img: + frames.append(page_img.copy()) + frames.append(page_img.copy()) + + print(f" Successfully highlighted {highlighted_words} words") + else: + print(" Warning: No words found to highlight on current page") + + if frames: + create_gif(frames, output_path, duration=700) + else: + print(" Warning: No frames captured for highlighting") + + +def main(): + """Main function to generate all GIFs.""" + if len(sys.argv) < 2: + print("Usage: python generate_ereader_gifs.py path/to/book.epub [output_dir]") + print("\nExample:") + print(" python generate_ereader_gifs.py ../tests/data/test.epub ../docs/images") + sys.exit(1) + + epub_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else "." + + # Validate EPUB path + if not os.path.exists(epub_path): + print(f"Error: EPUB file not found: {epub_path}") + sys.exit(1) + + # Create output directory + os.makedirs(output_dir, exist_ok=True) + + print("="*70) + print(" EbookReader Animated GIF Generator") + print("="*70) + print(f"\nInput EPUB: {epub_path}") + print(f"Output directory: {output_dir}") + + # Create paths for output GIFs + nav_gif = os.path.join(output_dir, "ereader_page_navigation.gif") + font_gif = os.path.join(output_dir, "ereader_font_size.gif") + chapter_gif = os.path.join(output_dir, "ereader_chapter_navigation.gif") + bookmark_gif = os.path.join(output_dir, "ereader_bookmarks.gif") + highlight_gif = os.path.join(output_dir, "ereader_highlighting.gif") + + try: + # Create reader + with EbookReader(page_size=(600, 800), margin=30) as reader: + # Load EPUB + print("\nLoading EPUB...") + if not reader.load_epub(epub_path): + print("Error: Failed to load EPUB file") + sys.exit(1) + + print("✓ EPUB loaded successfully") + + # Get book info + book_info = reader.get_book_info() + print(f"\nBook: {book_info['title']}") + print(f"Author: {book_info['author']}") + print(f"Chapters: {book_info['total_chapters']}") + print(f"Blocks: {book_info['total_blocks']}") + + print("\nGenerating GIFs...") + print("-" * 70) + + # Generate all GIFs + generate_page_navigation_gif(reader, nav_gif) + generate_font_size_gif(reader, font_gif) + generate_chapter_navigation_gif(reader, chapter_gif) + generate_bookmark_gif(reader, bookmark_gif) + generate_highlighting_gif(reader, highlight_gif) + + print("\n" + "="*70) + print(" Generation Complete!") + print("="*70) + print("\nGenerated files:") + for gif_path in [nav_gif, font_gif, chapter_gif, bookmark_gif, highlight_gif]: + if os.path.exists(gif_path): + size = os.path.getsize(gif_path) + print(f" ✓ {gif_path} ({size/1024:.1f} KB)") + + print("\nYou can now add these GIFs to your README.md!") + + except Exception as e: + print(f"\nError: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/simple_ereader_example.py b/examples/simple_ereader_example.py new file mode 100644 index 0000000..ed392b4 --- /dev/null +++ b/examples/simple_ereader_example.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Simple example showing the most common EbookReader usage. + +This script loads an EPUB and allows you to navigate through it, +saving each page as an image. + +Usage: + python simple_ereader_example.py book.epub +""" + +import sys +from pathlib import Path + +from pyweblayout_ereader import EbookReader + + +def main(): + if len(sys.argv) < 2: + print("Usage: python simple_ereader_example.py book.epub") + sys.exit(1) + + epub_path = sys.argv[1] + + # Create reader and load EPUB + print(f"Loading: {epub_path}") + reader = EbookReader(page_size=(800, 1000)) + + if not reader.load_epub(epub_path): + print("Failed to load EPUB") + sys.exit(1) + + # Get book information + info = reader.get_book_info() + print(f"\nBook: {info['title']}") + print(f"Author: {info['author']}") + print(f"Total blocks: {info['total_blocks']}") + + # Get chapters + chapters = reader.get_chapters() + print(f"Chapters: {len(chapters)}") + if chapters: + print("\nChapter list:") + for title, idx in chapters[:10]: # Show first 10 + print(f" {idx}: {title}") + if len(chapters) > 10: + print(f" ... and {len(chapters) - 10} more") + + # Navigate through first 10 pages + print("\nRendering first 10 pages...") + for i in range(10): + page = reader.get_current_page() + if page: + filename = f"page_{i+1:03d}.png" + reader.render_to_file(filename) + + # Show progress + progress = reader.get_reading_progress() + chapter_info = reader.get_current_chapter_info() + chapter_name = chapter_info['title'] if chapter_info else "N/A" + + print(f" Page {i+1}: {filename} (Progress: {progress*100:.1f}%, Chapter: {chapter_name})") + + # Move to next page + if not reader.next_page(): + print(" Reached end of book") + break + + # Save current position + reader.save_position("stopped_at_page_10") + print("\nSaved position as 'stopped_at_page_10'") + + # Example: Jump to a chapter (if available) + if len(chapters) >= 2: + print(f"\nJumping to chapter: {chapters[1][0]}") + reader.jump_to_chapter(1) + reader.render_to_file("chapter_2_start.png") + print(" Saved to: chapter_2_start.png") + + # Example: Increase font size + print("\nIncreasing font size...") + reader.increase_font_size() + reader.render_to_file("larger_font.png") + print(f" Font size now: {reader.get_font_size():.1f}x") + print(" Saved to: larger_font.png") + + # Close reader (saves current position automatically) + reader.close() + print("\nDone! Current position saved automatically.") + + +if __name__ == "__main__": + main() diff --git a/examples/simple_word_highlight.py b/examples/simple_word_highlight.py new file mode 100644 index 0000000..e5589ee --- /dev/null +++ b/examples/simple_word_highlight.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Simple Example: Highlight a Word on Tap + +This is the minimal example showing how to: +1. Load an ebook +2. Simulate a tap +3. Find the word at that location +4. Highlight it + +Perfect for understanding the basic query system. +""" + +from PIL import Image, ImageDraw +from pyweblayout_ereader import EbookReader + + +def main(): + # 1. Create reader and load book + reader = EbookReader(page_size=(800, 1000)) + reader.load_epub("tests/data/test.epub") + + # 2. Get current page as image + page_img = reader.get_current_page() + + # 3. Simulate a tap at pixel coordinates + tap_x, tap_y = 200, 300 + result = reader.query_pixel(tap_x, tap_y) + + # 4. If we found a word, highlight it + if result and result.text: + print(f"Tapped on word: '{result.text}'") + print(f"Bounds: {result.bounds}") + + # Draw yellow highlight + x, y, w, h = result.bounds + overlay = Image.new('RGBA', page_img.size, (255, 255, 255, 0)) + draw = ImageDraw.Draw(overlay) + draw.rectangle([x, y, x + w, y + h], fill=(255, 255, 0, 100)) + + # Combine and save + highlighted = Image.alpha_composite( + page_img.convert('RGBA'), + overlay + ) + highlighted.save("highlighted_word.png") + print("Saved: highlighted_word.png") + else: + print("No word found at that location") + + reader.close() + + +if __name__ == "__main__": + main() diff --git a/examples/word_selection_highlighting.py b/examples/word_selection_highlighting.py new file mode 100644 index 0000000..07226be --- /dev/null +++ b/examples/word_selection_highlighting.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +""" +Example: Word Selection and Highlighting + +This example demonstrates how to: +1. Query a pixel location to find a word +2. Select a range of words between two points +3. Highlight selected words by drawing overlays +4. Handle tap gestures to select words + +This is useful for: +- Word definition lookup +- Text highlighting/annotation +- Copy/paste functionality +- Interactive reading features +""" + +from PIL import Image, ImageDraw +import numpy as np + +from pyweblayout_ereader import EbookReader +from pyWebLayout.io.gesture import TouchEvent, GestureType +from pyWebLayout.core.query import QueryResult + + +def draw_highlight(image: Image.Image, bounds: tuple, color: tuple = (255, 255, 0, 100)): + """ + Draw a highlight overlay on an image at the given bounds. + + Args: + image: PIL Image to draw on + bounds: (x, y, width, height) tuple + color: RGBA color tuple (with alpha for transparency) + """ + # Create a semi-transparent overlay + overlay = Image.new('RGBA', image.size, (255, 255, 255, 0)) + draw = ImageDraw.Draw(overlay) + + x, y, w, h = bounds + # Draw rectangle with rounded corners for nicer appearance + draw.rectangle([x, y, x + w, y + h], fill=color) + + # Composite the overlay onto the original image + image = Image.alpha_composite(image.convert('RGBA'), overlay) + return image + + +def example_1_single_word_selection(): + """Example 1: Select and highlight a single word by tapping""" + print("=" * 60) + print("Example 1: Single Word Selection") + print("=" * 60) + + # Create reader and load a book + reader = EbookReader(page_size=(800, 1000)) + success = reader.load_epub("tests/data/test.epub") + + if not success: + print("Failed to load EPUB") + return + + print(f"Loaded: {reader.book_title} by {reader.book_author}") + + # Get current page as image + page_img = reader.get_current_page() + if not page_img: + print("No page rendered") + return + + # Simulate a tap at coordinates (200, 300) + tap_x, tap_y = 200, 300 + print(f"\nSimulating tap at ({tap_x}, {tap_y})") + + # Query what's at that location + result = reader.query_pixel(tap_x, tap_y) + + if result and result.text: + print(f"Found word: '{result.text}'") + print(f"Type: {result.object_type}") + print(f"Bounds: {result.bounds}") + print(f"Is interactive: {result.is_interactive}") + + # Highlight the word + highlighted_img = draw_highlight(page_img, result.bounds, color=(255, 255, 0, 80)) + highlighted_img.save("output_single_word_highlight.png") + print(f"\nSaved highlighted image to: output_single_word_highlight.png") + else: + print("No word found at that location") + + reader.close() + + +def example_2_range_selection(): + """Example 2: Select and highlight a range of words (text selection)""" + print("\n" + "=" * 60) + print("Example 2: Range Selection (Multi-word)") + print("=" * 60) + + # Create reader and load a book + reader = EbookReader(page_size=(800, 1000)) + success = reader.load_epub("tests/data/test.epub") + + if not success: + print("Failed to load EPUB") + return + + # Get current page + page_img = reader.get_current_page() + if not page_img: + return + + # Simulate dragging from (100, 200) to (400, 250) + start_x, start_y = 100, 200 + end_x, end_y = 400, 250 + + print(f"Simulating selection from ({start_x}, {start_y}) to ({end_x}, {end_y})") + + # Create drag gesture events + drag_start = TouchEvent(GestureType.DRAG_START, start_x, start_y) + drag_move = TouchEvent(GestureType.DRAG_MOVE, end_x, end_y) + drag_end = TouchEvent(GestureType.DRAG_END, end_x, end_y) + + # Handle the gesture (business logic) + reader.handle_touch(drag_start) + reader.handle_touch(drag_move) + response = reader.handle_touch(drag_end) + + if response.action == "selection_complete": + selected_text = response.data.get('text', '') + bounds_list = response.data.get('bounds', []) + word_count = response.data.get('word_count', 0) + + print(f"\nSelected {word_count} words:") + print(f"Text: \"{selected_text}\"") + + # Highlight all selected words + highlighted_img = page_img + for bounds in bounds_list: + highlighted_img = draw_highlight( + highlighted_img, + bounds, + color=(100, 200, 255, 80) # Light blue highlight + ) + + highlighted_img.save("output_range_highlight.png") + print(f"\nSaved highlighted image to: output_range_highlight.png") + else: + print(f"Selection action: {response.action}") + + reader.close() + + +def example_3_interactive_word_lookup(): + """Example 3: Interactive word lookup with gesture handling""" + print("\n" + "=" * 60) + print("Example 3: Interactive Word Lookup (with Gestures)") + print("=" * 60) + + # Create reader + reader = EbookReader(page_size=(800, 1000)) + success = reader.load_epub("tests/data/test.epub") + + if not success: + print("Failed to load EPUB") + return + + # Get page + page_img = reader.get_current_page() + if not page_img: + return + + # Define some simulated touch events + test_gestures = [ + ("Tap at (250, 300)", TouchEvent(GestureType.TAP, 250, 300)), + ("Long press at (250, 300)", TouchEvent(GestureType.LONG_PRESS, 250, 300)), + ("Swipe left", TouchEvent(GestureType.SWIPE_LEFT, 600, 500)), + ] + + for description, event in test_gestures: + print(f"\n{description}:") + response = reader.handle_touch(event) + print(f" Action: {response.action}") + + if response.action == "word_selected": + word = response.data.get('word', '') + bounds = response.data.get('bounds', (0, 0, 0, 0)) + print(f" Selected word: '{word}'") + print(f" Bounds: {bounds}") + + # Highlight the word + highlighted_img = draw_highlight(page_img, bounds, color=(255, 200, 0, 100)) + filename = f"output_word_lookup_{word}.png" + highlighted_img.save(filename) + print(f" Saved: {filename}") + + elif response.action == "define": + word = response.data.get('word', '') + print(f" Show definition for: '{word}'") + # In real app, you'd call a dictionary API here + + elif response.action == "page_turn": + direction = response.data.get('direction', '') + progress = response.data.get('progress', 0) + print(f" Page turn {direction}, progress: {progress:.1%}") + + reader.close() + + +def example_4_multi_word_annotation(): + """Example 4: Annotate multiple words with different colors""" + print("\n" + "=" * 60) + print("Example 4: Multi-word Annotation") + print("=" * 60) + + # Create reader + reader = EbookReader(page_size=(800, 1000)) + success = reader.load_epub("tests/data/test.epub") + + if not success: + print("Failed to load EPUB") + return + + # Get page + page_img = reader.get_current_page() + if not page_img: + return + + # Simulate multiple taps at different locations + tap_locations = [ + (150, 200, "Important word", (255, 100, 100, 80)), # Red + (300, 200, "Key concept", (100, 255, 100, 80)), # Green + (450, 200, "Notable term", (100, 100, 255, 80)), # Blue + ] + + annotated_img = page_img + annotations = [] + + for x, y, label, color in tap_locations: + result = reader.query_pixel(x, y) + + if result and result.text: + print(f"\nFound word at ({x}, {y}): '{result.text}'") + print(f" Annotation: {label}") + + # Highlight with specific color + annotated_img = draw_highlight(annotated_img, result.bounds, color) + + annotations.append({ + 'word': result.text, + 'label': label, + 'bounds': result.bounds + }) + + # Save annotated image + annotated_img.save("output_multi_annotation.png") + print(f"\nSaved annotated image with {len(annotations)} highlights") + print("File: output_multi_annotation.png") + + # Print annotation summary + print("\nAnnotation Summary:") + for i, ann in enumerate(annotations, 1): + print(f" {i}. '{ann['word']}' - {ann['label']}") + + reader.close() + + +def example_5_link_highlighting(): + """Example 5: Find and highlight all links on a page""" + print("\n" + "=" * 60) + print("Example 5: Find and Highlight All Links") + print("=" * 60) + + # Create reader + reader = EbookReader(page_size=(800, 1000)) + success = reader.load_epub("tests/data/test.epub") + + if not success: + print("Failed to load EPUB") + return + + # Get page + page_img = reader.get_current_page() + if not page_img: + return + + # Get the page object to scan for links + page = reader.manager.get_current_page() + + # Scan through all rendered content to find links + links_found = [] + from pyWebLayout.concrete.text import Line + from pyWebLayout.concrete.functional import LinkText + + for child in page._children: + if isinstance(child, Line): + for text_obj in child._text_objects: + if isinstance(text_obj, LinkText): + origin = text_obj._origin + size = text_obj.size + bounds = ( + int(origin[0]), + int(origin[1]), + int(size[0]), + int(size[1]) + ) + links_found.append({ + 'text': text_obj._text, + 'target': text_obj._link.location, + 'bounds': bounds + }) + + print(f"Found {len(links_found)} links on page") + + # Highlight all links + highlighted_img = page_img + for link in links_found: + print(f"\nLink: '{link['text']}' → {link['target']}") + highlighted_img = draw_highlight( + highlighted_img, + link['bounds'], + color=(0, 150, 255, 100) # Blue for links + ) + + if links_found: + highlighted_img.save("output_links_highlighted.png") + print(f"\nSaved image with {len(links_found)} highlighted links") + print("File: output_links_highlighted.png") + else: + print("\nNo links found on this page") + + reader.close() + + +if __name__ == "__main__": + print("Word Selection and Highlighting Examples") + print("=" * 60) + print() + print("These examples demonstrate the query system for:") + print("- Single word selection") + print("- Range selection (multiple words)") + print("- Interactive gesture handling") + print("- Multi-word annotation") + print("- Link detection and highlighting") + print() + + try: + # Run all examples + example_1_single_word_selection() + example_2_range_selection() + example_3_interactive_word_lookup() + example_4_multi_word_annotation() + example_5_link_highlighting() + + print("\n" + "=" * 60) + print("All examples completed successfully!") + print("=" * 60) + + except Exception as e: + print(f"\nError running examples: {e}") + import traceback + traceback.print_exc() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..12c60aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyweblayout-ereader" +version = "0.1.0" +description = "A complete ebook reader application built with pyWebLayout" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "Your Name", email = "your.email@example.com"} +] +keywords = ["ebook", "reader", "epub", "ereader", "layout"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Libraries", + "Topic :: Text Processing :: Markup", +] + +dependencies = [ + "pyweblayout>=0.1.0", + "Pillow>=9.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=3.0.0", + "black>=22.0.0", + "flake8>=4.0.0", + "mypy>=0.950", +] + +[project.urls] +Homepage = "https://github.com/yourusername/pyWebLayout-ereader" +Documentation = "https://github.com/yourusername/pyWebLayout-ereader#readme" +Repository = "https://github.com/yourusername/pyWebLayout-ereader" +Issues = "https://github.com/yourusername/pyWebLayout-ereader/issues" + +[tool.setuptools.packages.find] +where = ["."] +include = ["pyweblayout_ereader*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" + +[tool.black] +line-length = 100 +target-version = ['py38'] + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false diff --git a/pyweblayout_ereader/__init__.py b/pyweblayout_ereader/__init__.py new file mode 100644 index 0000000..2b78813 --- /dev/null +++ b/pyweblayout_ereader/__init__.py @@ -0,0 +1,11 @@ +""" +pyWebLayout-ereader: A complete ebook reader application built with pyWebLayout. + +This package provides a high-level, user-friendly ebook reader implementation +with all essential features for building ereader applications. +""" + +from pyweblayout_ereader.application import EbookReader, create_ebook_reader + +__version__ = "0.1.0" +__all__ = ["EbookReader", "create_ebook_reader"] diff --git a/pyweblayout_ereader/application.py b/pyweblayout_ereader/application.py new file mode 100644 index 0000000..f828129 --- /dev/null +++ b/pyweblayout_ereader/application.py @@ -0,0 +1,1074 @@ +#!/usr/bin/env python3 +""" +Simple ereader application interface for pyWebLayout. + +This module provides a user-friendly wrapper around the ereader infrastructure, +making it easy to build ebook reader applications with all essential features. + +Example: + from pyWebLayout.layout.ereader_application import EbookReader + + # Create reader + reader = EbookReader(page_size=(800, 1000)) + + # Load an EPUB + reader.load_epub("mybook.epub") + + # Navigate + reader.next_page() + reader.previous_page() + + # Get current page + page_image = reader.get_current_page() + + # Modify styling + reader.increase_font_size() + reader.set_line_spacing(8) + + # Chapter navigation + chapters = reader.get_chapters() + reader.jump_to_chapter("Chapter 1") + + # Position management + reader.save_position("bookmark1") + reader.load_position("bookmark1") +""" + +from __future__ import annotations +from typing import List, Tuple, Optional, Dict, Any, Union +from pathlib import Path +import os + +from PIL import Image + +from pyWebLayout.io.readers.epub_reader import read_epub +from pyWebLayout.io.gesture import TouchEvent, GestureType, GestureResponse, ActionType +from pyWebLayout.abstract.block import Block, HeadingLevel +from pyWebLayout.layout.ereader_manager import EreaderLayoutManager +from pyWebLayout.layout.ereader_layout import RenderingPosition +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.concrete.page import Page +from pyWebLayout.core.query import QueryResult, SelectionRange +from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result + + +class EbookReader: + """ + Simple ereader application with all essential features. + + Features: + - Load EPUB files + - Forward/backward page navigation + - Position save/load (based on abstract document structure) + - Chapter navigation + - Font size and spacing control + - Current page retrieval as PIL Image + + The reader maintains position using abstract document structure (chapter/block/word indices), + ensuring positions remain valid across font size and styling changes. + """ + + def __init__(self, + page_size: Tuple[int, int] = (800, 1000), + margin: int = 40, + background_color: Tuple[int, int, int] = (255, 255, 255), + line_spacing: int = 5, + inter_block_spacing: int = 15, + bookmarks_dir: str = "ereader_bookmarks", + highlights_dir: str = "highlights", + buffer_size: int = 5): + """ + Initialize the ebook reader. + + Args: + page_size: Page dimensions (width, height) in pixels + margin: Page margin in pixels + background_color: Background color as RGB tuple + line_spacing: Spacing between lines in pixels + inter_block_spacing: Spacing between blocks in pixels + bookmarks_dir: Directory to store bookmarks and positions + highlights_dir: Directory to store highlights + buffer_size: Number of pages to cache for performance + """ + self.page_size = page_size + self.bookmarks_dir = bookmarks_dir + self.highlights_dir = highlights_dir + self.buffer_size = buffer_size + + # Create page style + self.page_style = PageStyle( + background_color=background_color, + border_width=margin, + border_color=(200, 200, 200), + padding=(10, 10, 10, 10), + line_spacing=line_spacing, + inter_block_spacing=inter_block_spacing + ) + + # State + self.manager: Optional[EreaderLayoutManager] = None + self.blocks: Optional[List[Block]] = None + self.document_id: Optional[str] = None + self.book_title: Optional[str] = None + self.book_author: Optional[str] = None + self.highlight_manager: Optional[HighlightManager] = None + + # Font scale state + self.base_font_scale = 1.0 + self.font_scale_step = 0.1 # 10% change per step + + # Selection state (for text selection gestures) + self._selection_start: Optional[Tuple[int, int]] = None + self._selection_end: Optional[Tuple[int, int]] = None + self._selected_range: Optional[SelectionRange] = None + + def load_epub(self, epub_path: str) -> bool: + """ + Load an EPUB file into the reader. + + Args: + epub_path: Path to the EPUB file + + Returns: + True if loaded successfully, False otherwise + """ + try: + # Validate path + if not os.path.exists(epub_path): + raise FileNotFoundError(f"EPUB file not found: {epub_path}") + + # Load the EPUB + book = read_epub(epub_path) + + # Extract metadata + self.book_title = book.get_title() or "Unknown Title" + self.book_author = book.get_metadata('AUTHOR') or "Unknown Author" + + # Create document ID from filename + self.document_id = Path(epub_path).stem + + # Extract all blocks from chapters + self.blocks = [] + for chapter in book.chapters: + if hasattr(chapter, '_blocks'): + self.blocks.extend(chapter._blocks) + + if not self.blocks: + raise ValueError("No content blocks found in EPUB") + + # Initialize the ereader manager + self.manager = EreaderLayoutManager( + blocks=self.blocks, + page_size=self.page_size, + document_id=self.document_id, + buffer_size=self.buffer_size, + page_style=self.page_style, + bookmarks_dir=self.bookmarks_dir + ) + + # Initialize highlight manager for this document + self.highlight_manager = HighlightManager( + document_id=self.document_id, + highlights_dir=self.highlights_dir + ) + + return True + + except Exception as e: + print(f"Error loading EPUB: {e}") + return False + + def is_loaded(self) -> bool: + """Check if a book is currently loaded.""" + return self.manager is not None + + def get_current_page(self, include_highlights: bool = True) -> Optional[Image.Image]: + """ + Get the current page as a PIL Image. + + Args: + include_highlights: Whether to overlay highlights on the page + + Returns: + PIL Image of the current page, or None if no book is loaded + """ + if not self.manager: + return None + + try: + page = self.manager.get_current_page() + img = page.render() + + # Overlay highlights if requested and available + if include_highlights and self.highlight_manager: + # Get page bounds + page_bounds = (0, 0, self.page_size[0], self.page_size[1]) + highlights = self.highlight_manager.get_highlights_for_page(page_bounds) + + if highlights: + img = self._render_highlights(img, highlights) + + return img + except Exception as e: + print(f"Error rendering page: {e}") + return None + + def next_page(self) -> Optional[Image.Image]: + """ + Navigate to the next page. + + Returns: + PIL Image of the next page, or None if at end of book + """ + if not self.manager: + return None + + try: + page = self.manager.next_page() + if page: + return page.render() + return None + except Exception as e: + print(f"Error navigating to next page: {e}") + return None + + def previous_page(self) -> Optional[Image.Image]: + """ + Navigate to the previous page. + + Returns: + PIL Image of the previous page, or None if at beginning of book + """ + if not self.manager: + return None + + try: + page = self.manager.previous_page() + if page: + return page.render() + return None + except Exception as e: + print(f"Error navigating to previous page: {e}") + return None + + def save_position(self, name: str = "current_position") -> bool: + """ + Save the current reading position with a name. + + The position is saved based on abstract document structure (chapter, block, word indices), + making it stable across font size and styling changes. + + Args: + name: Name for this saved position + + Returns: + True if saved successfully, False otherwise + """ + if not self.manager: + return False + + try: + self.manager.add_bookmark(name) + return True + except Exception as e: + print(f"Error saving position: {e}") + return False + + def load_position(self, name: str = "current_position") -> Optional[Image.Image]: + """ + Load a previously saved reading position. + + Args: + name: Name of the saved position + + Returns: + PIL Image of the page at the loaded position, or None if not found + """ + if not self.manager: + return None + + try: + page = self.manager.jump_to_bookmark(name) + if page: + return page.render() + return None + except Exception as e: + print(f"Error loading position: {e}") + return None + + def list_saved_positions(self) -> List[str]: + """ + Get a list of all saved position names. + + Returns: + List of position names + """ + if not self.manager: + return [] + + try: + bookmarks = self.manager.list_bookmarks() + return [name for name, _ in bookmarks] + except Exception as e: + print(f"Error listing positions: {e}") + return [] + + def delete_position(self, name: str) -> bool: + """ + Delete a saved position. + + Args: + name: Name of the position to delete + + Returns: + True if deleted, False otherwise + """ + if not self.manager: + return False + + return self.manager.remove_bookmark(name) + + def get_chapters(self) -> List[Tuple[str, int]]: + """ + Get a list of all chapters with their indices. + + Returns: + List of (chapter_title, chapter_index) tuples + """ + if not self.manager: + return [] + + try: + toc = self.manager.get_table_of_contents() + # Convert to simplified format (title, index) + chapters = [] + for i, (title, level, position) in enumerate(toc): + chapters.append((title, i)) + return chapters + except Exception as e: + print(f"Error getting chapters: {e}") + return [] + + def get_chapter_positions(self) -> List[Tuple[str, RenderingPosition]]: + """ + Get chapter titles with their exact rendering positions. + + Returns: + List of (title, position) tuples + """ + if not self.manager: + return [] + + try: + toc = self.manager.get_table_of_contents() + return [(title, position) for title, level, position in toc] + except Exception as e: + print(f"Error getting chapter positions: {e}") + return [] + + def jump_to_chapter(self, chapter: Union[str, int]) -> Optional[Image.Image]: + """ + Navigate to a specific chapter by title or index. + + Args: + chapter: Chapter title (string) or chapter index (integer) + + Returns: + PIL Image of the first page of the chapter, or None if not found + """ + if not self.manager: + return None + + try: + if isinstance(chapter, int): + page = self.manager.jump_to_chapter_index(chapter) + else: + page = self.manager.jump_to_chapter(chapter) + + if page: + return page.render() + return None + except Exception as e: + print(f"Error jumping to chapter: {e}") + return None + + def set_font_size(self, scale: float) -> Optional[Image.Image]: + """ + Set the font size scale and re-render current page. + + Args: + scale: Font scale factor (1.0 = normal, 2.0 = double size, 0.5 = half size) + + Returns: + PIL Image of the re-rendered page with new font size + """ + if not self.manager: + return None + + try: + self.base_font_scale = max(0.5, min(3.0, scale)) # Clamp between 0.5x and 3.0x + page = self.manager.set_font_scale(self.base_font_scale) + return page.render() + except Exception as e: + print(f"Error setting font size: {e}") + return None + + def increase_font_size(self) -> Optional[Image.Image]: + """ + Increase font size by one step and re-render. + + Returns: + PIL Image of the re-rendered page + """ + new_scale = self.base_font_scale + self.font_scale_step + return self.set_font_size(new_scale) + + def decrease_font_size(self) -> Optional[Image.Image]: + """ + Decrease font size by one step and re-render. + + Returns: + PIL Image of the re-rendered page + """ + new_scale = self.base_font_scale - self.font_scale_step + return self.set_font_size(new_scale) + + def get_font_size(self) -> float: + """ + Get the current font size scale. + + Returns: + Current font scale factor + """ + return self.base_font_scale + + def set_line_spacing(self, spacing: int) -> Optional[Image.Image]: + """ + Set line spacing and re-render current page. + + Args: + spacing: Line spacing in pixels + + Returns: + PIL Image of the re-rendered page + """ + if not self.manager: + return None + + try: + # Update page style + self.page_style.line_spacing = max(0, spacing) + + # Need to recreate the manager with new page style + current_pos = self.manager.current_position + current_font_scale = self.base_font_scale + self.manager.shutdown() + + self.manager = EreaderLayoutManager( + blocks=self.blocks, + page_size=self.page_size, + document_id=self.document_id, + buffer_size=self.buffer_size, + page_style=self.page_style, + bookmarks_dir=self.bookmarks_dir + ) + + # Restore position + self.manager.current_position = current_pos + + # Restore font scale using the method (not direct assignment) + if current_font_scale != 1.0: + self.manager.set_font_scale(current_font_scale) + + page = self.manager.get_current_page() + return page.render() + except Exception as e: + print(f"Error setting line spacing: {e}") + return None + + def set_inter_block_spacing(self, spacing: int) -> Optional[Image.Image]: + """ + Set spacing between blocks (paragraphs, headings, etc.) and re-render. + + Args: + spacing: Inter-block spacing in pixels + + Returns: + PIL Image of the re-rendered page + """ + if not self.manager: + return None + + try: + # Update page style + self.page_style.inter_block_spacing = max(0, spacing) + + # Need to recreate the manager with new page style + current_pos = self.manager.current_position + current_font_scale = self.base_font_scale + self.manager.shutdown() + + self.manager = EreaderLayoutManager( + blocks=self.blocks, + page_size=self.page_size, + document_id=self.document_id, + buffer_size=self.buffer_size, + page_style=self.page_style, + bookmarks_dir=self.bookmarks_dir + ) + + # Restore position + self.manager.current_position = current_pos + + # Restore font scale using the method (not direct assignment) + if current_font_scale != 1.0: + self.manager.set_font_scale(current_font_scale) + + page = self.manager.get_current_page() + return page.render() + except Exception as e: + print(f"Error setting inter-block spacing: {e}") + return None + + def get_position_info(self) -> Dict[str, Any]: + """ + Get detailed information about the current position. + + Returns: + Dictionary with position details including: + - position: RenderingPosition details (chapter_index, block_index, word_index) + - chapter: Current chapter info (title, level) + - progress: Reading progress (0.0 to 1.0) + - font_scale: Current font scale + - book_title: Book title + - book_author: Book author + """ + if not self.manager: + return {} + + try: + info = self.manager.get_position_info() + info['book_title'] = self.book_title + info['book_author'] = self.book_author + return info + except Exception as e: + print(f"Error getting position info: {e}") + return {} + + def get_reading_progress(self) -> float: + """ + Get reading progress as a percentage. + + Returns: + Progress from 0.0 (beginning) to 1.0 (end) + """ + if not self.manager: + return 0.0 + + return self.manager.get_reading_progress() + + def get_current_chapter_info(self) -> Optional[Dict[str, Any]]: + """ + Get information about the current chapter. + + Returns: + Dictionary with chapter info (title, level) or None + """ + if not self.manager: + return None + + try: + chapter = self.manager.get_current_chapter() + if chapter: + return { + 'title': chapter.title, + 'level': chapter.level, + 'block_index': chapter.block_index + } + return None + except Exception as e: + print(f"Error getting current chapter: {e}") + return None + + def render_to_file(self, output_path: str) -> bool: + """ + Save the current page to an image file. + + Args: + output_path: Path where to save the image (e.g., "page.png") + + Returns: + True if saved successfully, False otherwise + """ + page_image = self.get_current_page() + if page_image: + try: + page_image.save(output_path) + return True + except Exception as e: + print(f"Error saving image: {e}") + return False + return False + + def get_book_info(self) -> Dict[str, Any]: + """ + Get information about the loaded book. + + Returns: + Dictionary with book information + """ + return { + 'title': self.book_title, + 'author': self.book_author, + 'document_id': self.document_id, + 'total_blocks': len(self.blocks) if self.blocks else 0, + 'total_chapters': len(self.get_chapters()), + 'page_size': self.page_size, + 'font_scale': self.base_font_scale + } + + # ===== Gesture Handling ===== + # All business logic for touch input is handled here + + def handle_touch(self, event: TouchEvent) -> GestureResponse: + """ + Handle a touch event from HAL. + + **This is the main business logic entry point for all touch interactions.** + Flask should call this and use the response to generate HTML/JSON. + + Args: + event: TouchEvent from HAL with gesture type and coordinates + + Returns: + GestureResponse with action and data for UI to process + """ + if not self.is_loaded(): + return GestureResponse(ActionType.ERROR, {"message": "No book loaded"}) + + # Dispatch based on gesture type + if event.gesture == GestureType.TAP: + return self._handle_tap(event.x, event.y) + elif event.gesture == GestureType.LONG_PRESS: + return self._handle_long_press(event.x, event.y) + elif event.gesture == GestureType.SWIPE_LEFT: + return self._handle_page_forward() + elif event.gesture == GestureType.SWIPE_RIGHT: + return self._handle_page_back() + elif event.gesture == GestureType.PINCH_IN: + return self._handle_zoom_out() + elif event.gesture == GestureType.PINCH_OUT: + return self._handle_zoom_in() + elif event.gesture == GestureType.DRAG_START: + return self._handle_selection_start(event.x, event.y) + elif event.gesture == GestureType.DRAG_MOVE: + return self._handle_selection_move(event.x, event.y) + elif event.gesture == GestureType.DRAG_END: + return self._handle_selection_end(event.x, event.y) + + return GestureResponse(ActionType.NONE, {}) + + def query_pixel(self, x: int, y: int) -> Optional[QueryResult]: + """ + Direct pixel query for debugging/tools. + + Args: + x, y: Pixel coordinates + + Returns: + QueryResult or None if nothing at that location + """ + if not self.manager: + return None + + page = self.manager.get_current_page() + return page.query_point((x, y)) + + def _handle_tap(self, x: int, y: int) -> GestureResponse: + """Handle tap gesture - activates links or selects words""" + page = self.manager.get_current_page() + result = page.query_point((x, y)) + + if not result or result.object_type == "empty": + return GestureResponse(ActionType.NONE, {}) + + # If it's a link, navigate + if result.is_interactive and result.link_target: + # Handle different link types + if result.link_target.endswith('.epub'): + # Open new book + success = self.load_epub(result.link_target) + if success: + return GestureResponse(ActionType.BOOK_LOADED, { + "title": self.book_title, + "author": self.book_author, + "path": result.link_target + }) + else: + return GestureResponse(ActionType.ERROR, { + "message": f"Failed to load {result.link_target}" + }) + else: + # Internal navigation (chapter) + self.jump_to_chapter(result.link_target) + return GestureResponse(ActionType.NAVIGATE, { + "target": result.link_target, + "chapter": self.get_current_chapter_info() + }) + + # Just a tap on text - select word + if result.text: + return GestureResponse(ActionType.WORD_SELECTED, { + "word": result.text, + "bounds": result.bounds + }) + + return GestureResponse(ActionType.NONE, {}) + + def _handle_long_press(self, x: int, y: int) -> GestureResponse: + """Handle long-press - show definition or menu""" + page = self.manager.get_current_page() + result = page.query_point((x, y)) + + if result and result.text: + return GestureResponse(ActionType.DEFINE, { + "word": result.text, + "bounds": result.bounds + }) + + # Long-press on empty - show menu + return GestureResponse(ActionType.SHOW_MENU, { + "options": ["bookmark", "settings", "toc", "search"] + }) + + def _handle_page_forward(self) -> GestureResponse: + """Handle swipe left - next page""" + img = self.next_page() + if img: + return GestureResponse(ActionType.PAGE_TURN, { + "direction": "forward", + "progress": self.get_reading_progress(), + "chapter": self.get_current_chapter_info() + }) + return GestureResponse(ActionType.AT_END, {}) + + def _handle_page_back(self) -> GestureResponse: + """Handle swipe right - previous page""" + img = self.previous_page() + if img: + return GestureResponse(ActionType.PAGE_TURN, { + "direction": "back", + "progress": self.get_reading_progress(), + "chapter": self.get_current_chapter_info() + }) + return GestureResponse(ActionType.AT_START, {}) + + def _handle_zoom_in(self) -> GestureResponse: + """Handle pinch out - increase font""" + self.increase_font_size() + return GestureResponse(ActionType.ZOOM, { + "direction": "in", + "font_scale": self.base_font_scale + }) + + def _handle_zoom_out(self) -> GestureResponse: + """Handle pinch in - decrease font""" + self.decrease_font_size() + return GestureResponse(ActionType.ZOOM, { + "direction": "out", + "font_scale": self.base_font_scale + }) + + def _handle_selection_start(self, x: int, y: int) -> GestureResponse: + """Start text selection""" + self._selection_start = (x, y) + self._selection_end = None + self._selected_range = None + + return GestureResponse(ActionType.SELECTION_START, { + "start": (x, y) + }) + + def _handle_selection_move(self, x: int, y: int) -> GestureResponse: + """Update text selection""" + if not self._selection_start: + return GestureResponse(ActionType.NONE, {}) + + self._selection_end = (x, y) + + # Query range + page = self.manager.get_current_page() + self._selected_range = page.query_range( + self._selection_start, + self._selection_end + ) + + return GestureResponse(ActionType.SELECTION_UPDATE, { + "start": self._selection_start, + "end": self._selection_end, + "text_count": len(self._selected_range.results), + "bounds": self._selected_range.bounds_list + }) + + def _handle_selection_end(self, x: int, y: int) -> GestureResponse: + """End text selection and return selected text""" + if not self._selection_start: + return GestureResponse(ActionType.NONE, {}) + + self._selection_end = (x, y) + + page = self.manager.get_current_page() + self._selected_range = page.query_range( + self._selection_start, + self._selection_end + ) + + return GestureResponse(ActionType.SELECTION_COMPLETE, { + "text": self._selected_range.text, + "word_count": len(self._selected_range.results), + "bounds": self._selected_range.bounds_list + }) + + # =================================================================== + # Highlighting API + # =================================================================== + + def highlight_word(self, x: int, y: int, + color: Tuple[int, int, int, int] = None, + note: Optional[str] = None, + tags: Optional[List[str]] = None) -> Optional[str]: + """ + Highlight a word at the given pixel location. + + Args: + x: X coordinate + y: Y coordinate + color: RGBA color tuple (defaults to yellow) + note: Optional annotation for this highlight + tags: Optional categorization tags + + Returns: + Highlight ID if successful, None otherwise + """ + if not self.manager or not self.highlight_manager: + return None + + try: + # Query the pixel to find the word + result = self.query_pixel(x, y) + if not result or not result.text: + return None + + # Use default color if not provided + if color is None: + color = HighlightColor.YELLOW.value + + # Create highlight from query result + highlight = create_highlight_from_query_result( + result, + color=color, + note=note, + tags=tags + ) + + # Add to manager + self.highlight_manager.add_highlight(highlight) + + return highlight.id + except Exception as e: + print(f"Error highlighting word: {e}") + return None + + def highlight_selection(self, start: Tuple[int, int], end: Tuple[int, int], + color: Tuple[int, int, int, int] = None, + note: Optional[str] = None, + tags: Optional[List[str]] = None) -> Optional[str]: + """ + Highlight a range of words between two points. + + Args: + start: Starting (x, y) coordinates + end: Ending (x, y) coordinates + color: RGBA color tuple (defaults to yellow) + note: Optional annotation + tags: Optional categorization tags + + Returns: + Highlight ID if successful, None otherwise + """ + if not self.manager or not self.highlight_manager: + return None + + try: + page = self.manager.get_current_page() + selection_range = page.query_range(start, end) + + if not selection_range.results: + return None + + # Use default color if not provided + if color is None: + color = HighlightColor.YELLOW.value + + # Create highlight from selection range + highlight = create_highlight_from_query_result( + selection_range, + color=color, + note=note, + tags=tags + ) + + # Add to manager + self.highlight_manager.add_highlight(highlight) + + return highlight.id + except Exception as e: + print(f"Error highlighting selection: {e}") + return None + + def remove_highlight(self, highlight_id: str) -> bool: + """ + Remove a highlight by ID. + + Args: + highlight_id: ID of the highlight to remove + + Returns: + True if removed successfully, False otherwise + """ + if not self.highlight_manager: + return False + + return self.highlight_manager.remove_highlight(highlight_id) + + def list_highlights(self) -> List[Highlight]: + """ + Get all highlights for the current document. + + Returns: + List of Highlight objects + """ + if not self.highlight_manager: + return [] + + return self.highlight_manager.list_highlights() + + def get_highlights_for_current_page(self) -> List[Highlight]: + """ + Get highlights that appear on the current page. + + Returns: + List of Highlight objects on this page + """ + if not self.manager or not self.highlight_manager: + return [] + + page_bounds = (0, 0, self.page_size[0], self.page_size[1]) + return self.highlight_manager.get_highlights_for_page(page_bounds) + + def clear_highlights(self) -> None: + """Remove all highlights from the current document.""" + if self.highlight_manager: + self.highlight_manager.clear_all() + + def _render_highlights(self, image: Image.Image, highlights: List[Highlight]) -> Image.Image: + """ + Render highlight overlays on an image using multiply blend mode. + + This preserves text contrast by multiplying the highlight color with the + underlying pixels, like a real highlighter pen. + + Args: + image: Base PIL Image to draw on + highlights: List of Highlight objects to render + + Returns: + New PIL Image with highlights overlaid + """ + import numpy as np + + # Convert to RGB for processing (we'll add alpha back later if needed) + original_mode = image.mode + if image.mode == 'RGBA': + # Separate alpha channel + rgb_image = image.convert('RGB') + alpha_channel = image.split()[-1] + else: + rgb_image = image.convert('RGB') + alpha_channel = None + + # Convert to numpy array for efficient processing + img_array = np.array(rgb_image, dtype=np.float32) + + # Process each highlight + for highlight in highlights: + # Extract RGB components from highlight color (ignore alpha) + h_r, h_g, h_b = highlight.color[0], highlight.color[1], highlight.color[2] + + # Create highlight multiplier (normalize to 0-1 range) + highlight_color = np.array([h_r / 255.0, h_g / 255.0, h_b / 255.0], dtype=np.float32) + + for hx, hy, hw, hh in highlight.bounds: + # Ensure bounds are within image + hx, hy = max(0, hx), max(0, hy) + x2, y2 = min(rgb_image.width, hx + hw), min(rgb_image.height, hy + hh) + + if x2 <= hx or y2 <= hy: + continue + + # Extract the region to highlight + region = img_array[hy:y2, hx:x2, :] + + # Multiply with highlight color (like a real highlighter) + # This darkens the image proportionally to the highlight color + highlighted = region * highlight_color + + # Put the highlighted region back + img_array[hy:y2, hx:x2, :] = highlighted + + # Convert back to uint8 and create PIL Image + img_array = np.clip(img_array, 0, 255).astype(np.uint8) + result = Image.fromarray(img_array, mode='RGB') + + # Restore alpha channel if original had one + if alpha_channel is not None and original_mode == 'RGBA': + result = result.convert('RGBA') + result.putalpha(alpha_channel) + + return result + + def close(self): + """ + Close the reader and save current position. + Should be called when done with the reader. + """ + if self.manager: + self.manager.shutdown() + self.manager = None + + def __enter__(self): + """Context manager support.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager cleanup.""" + self.close() + + def __del__(self): + """Cleanup on deletion.""" + self.close() + + +# Convenience function +def create_ebook_reader(page_size: Tuple[int, int] = (800, 1000), **kwargs) -> EbookReader: + """ + Create an ebook reader with sensible defaults. + + Args: + page_size: Page dimensions (width, height) in pixels + **kwargs: Additional arguments passed to EbookReader + + Returns: + Configured EbookReader instance + """ + return EbookReader(page_size=page_size, **kwargs)