This commit is contained in:
parent
6bb43db8d5
commit
33e2cbc363
@ -1,6 +1,6 @@
|
||||
{
|
||||
"chapter_index": 0,
|
||||
"block_index": 27,
|
||||
"block_index": 54,
|
||||
"word_index": 0,
|
||||
"table_row": 0,
|
||||
"table_col": 0,
|
||||
|
||||
@ -1,329 +0,0 @@
|
||||
#!/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
|
||||
|
||||
# Add parent directory to path to import pyWebLayout
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from pyWebLayout.layout.ereader_application 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()
|
||||
@ -1,289 +0,0 @@
|
||||
#!/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
|
||||
|
||||
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
|
||||
|
||||
# Add parent directory to path to import pyWebLayout
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from pyWebLayout.layout.ereader_application import EbookReader
|
||||
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/4] 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 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")
|
||||
|
||||
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)
|
||||
|
||||
print("\n" + "="*70)
|
||||
print(" Generation Complete!")
|
||||
print("="*70)
|
||||
print("\nGenerated files:")
|
||||
for gif_path in [nav_gif, font_gif, chapter_gif, bookmark_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()
|
||||
@ -1,96 +0,0 @@
|
||||
#!/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
|
||||
|
||||
# Add parent directory to path to import pyWebLayout
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from pyWebLayout.layout.ereader_application 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()
|
||||
4
highlights/test_highlights.json
Normal file
4
highlights/test_highlights.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"document_id": "test",
|
||||
"highlights": []
|
||||
}
|
||||
@ -77,12 +77,16 @@ class LinkText(Text, Interactable, Queriable):
|
||||
return None
|
||||
return self._callback() # Don't pass the point to the callback
|
||||
|
||||
def render(self):
|
||||
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
||||
"""
|
||||
Render the link text with optional hover effects.
|
||||
|
||||
Args:
|
||||
next_text: The next Text object in the line (if any)
|
||||
spacing: The spacing to the next text object
|
||||
"""
|
||||
# Call the parent Text render method
|
||||
super().render()
|
||||
# Call the parent Text render method with parameters
|
||||
super().render(next_text, spacing)
|
||||
|
||||
# Add hover effect if needed
|
||||
if self._hovered:
|
||||
|
||||
@ -3,6 +3,7 @@ import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Alignment
|
||||
from .box import Box
|
||||
@ -264,24 +265,41 @@ class Page(Renderable, Queriable):
|
||||
# Default to origin
|
||||
return (0, 0)
|
||||
|
||||
def query_point(self, point: Tuple[int, int]) -> Optional[Renderable]:
|
||||
def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]:
|
||||
"""
|
||||
Query a point to determine which child it belongs to.
|
||||
Query a point to find the deepest object at that location.
|
||||
Traverses children and uses Queriable.in_object() for hit-testing.
|
||||
|
||||
Args:
|
||||
point: The (x, y) coordinates to query
|
||||
|
||||
Returns:
|
||||
The child object that contains the point, or None if no child contains it
|
||||
QueryResult with metadata about what was found, or None if nothing hit
|
||||
"""
|
||||
point_array = np.array(point)
|
||||
|
||||
# Check each child (in reverse order so topmost child is found first)
|
||||
for child in reversed(self._children):
|
||||
if self._point_in_child(point_array, child):
|
||||
return child
|
||||
# Use Queriable mixin's in_object() for hit-testing
|
||||
if isinstance(child, Queriable) and child.in_object(point_array):
|
||||
# If child can also query (has children of its own), recurse
|
||||
if hasattr(child, 'query_point'):
|
||||
result = child.query_point(point)
|
||||
if result:
|
||||
result.parent_page = self
|
||||
return result
|
||||
# If child's query returned None, continue to next child
|
||||
continue
|
||||
|
||||
return None
|
||||
# Otherwise, package this child as the result
|
||||
return self._make_query_result(child, point)
|
||||
|
||||
# Nothing hit - return empty result
|
||||
return QueryResult(
|
||||
object=self,
|
||||
object_type="empty",
|
||||
bounds=(int(point[0]), int(point[1]), 0, 0)
|
||||
)
|
||||
|
||||
def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
|
||||
"""
|
||||
@ -337,6 +355,104 @@ class Page(Renderable, Queriable):
|
||||
|
||||
return None
|
||||
|
||||
def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult:
|
||||
"""
|
||||
Package an object into a QueryResult with metadata.
|
||||
|
||||
Args:
|
||||
obj: The object to package
|
||||
point: The query point
|
||||
|
||||
Returns:
|
||||
QueryResult with extracted metadata
|
||||
"""
|
||||
from .text import Text
|
||||
from .functional import LinkText, ButtonText
|
||||
|
||||
# Extract bounds
|
||||
origin = getattr(obj, '_origin', np.array([0, 0]))
|
||||
size = getattr(obj, 'size', np.array([0, 0]))
|
||||
bounds = (
|
||||
int(origin[0]),
|
||||
int(origin[1]),
|
||||
int(size[0]) if hasattr(size, '__getitem__') else 0,
|
||||
int(size[1]) if hasattr(size, '__getitem__') else 0
|
||||
)
|
||||
|
||||
# Determine type and extract metadata
|
||||
if isinstance(obj, LinkText):
|
||||
return QueryResult(
|
||||
object=obj,
|
||||
object_type="link",
|
||||
bounds=bounds,
|
||||
text=obj._text,
|
||||
is_interactive=True,
|
||||
link_target=obj._link.location if hasattr(obj, '_link') else None
|
||||
)
|
||||
elif isinstance(obj, ButtonText):
|
||||
return QueryResult(
|
||||
object=obj,
|
||||
object_type="button",
|
||||
bounds=bounds,
|
||||
text=obj._text,
|
||||
is_interactive=True,
|
||||
callback=obj._callback if hasattr(obj, '_callback') else None
|
||||
)
|
||||
elif isinstance(obj, Text):
|
||||
return QueryResult(
|
||||
object=obj,
|
||||
object_type="text",
|
||||
bounds=bounds,
|
||||
text=obj._text if hasattr(obj, '_text') else None
|
||||
)
|
||||
else:
|
||||
return QueryResult(
|
||||
object=obj,
|
||||
object_type="unknown",
|
||||
bounds=bounds
|
||||
)
|
||||
|
||||
def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange:
|
||||
"""
|
||||
Query all text objects between two points (for text selection).
|
||||
Uses Queriable.in_object() to determine which objects are in range.
|
||||
|
||||
Args:
|
||||
start: Starting (x, y) point
|
||||
end: Ending (x, y) point
|
||||
|
||||
Returns:
|
||||
SelectionRange with all text objects between the points
|
||||
"""
|
||||
results = []
|
||||
in_selection = False
|
||||
|
||||
start_result = self.query_point(start)
|
||||
end_result = self.query_point(end)
|
||||
|
||||
if not start_result or not end_result:
|
||||
return SelectionRange(start, end, [])
|
||||
|
||||
# Walk through all children (Lines) and their text objects
|
||||
from .text import Line, Text
|
||||
|
||||
for child in self._children:
|
||||
if isinstance(child, Line) and hasattr(child, '_text_objects'):
|
||||
for text_obj in child._text_objects:
|
||||
# Check if this text is the start or is between start and end
|
||||
if text_obj == start_result.object:
|
||||
in_selection = True
|
||||
|
||||
if in_selection and isinstance(text_obj, Text):
|
||||
result = self._make_query_result(text_obj, start)
|
||||
results.append(result)
|
||||
|
||||
if text_obj == end_result.object:
|
||||
in_selection = False
|
||||
break
|
||||
|
||||
return SelectionRange(start, end, results)
|
||||
|
||||
def in_object(self, point: Tuple[int, int]) -> bool:
|
||||
"""
|
||||
Check if a point is within this page's bounds.
|
||||
|
||||
@ -560,3 +560,68 @@ class Line(Box):
|
||||
# Render with next text information for continuous underline/strikethrough
|
||||
text.render(next_text, self._spacing_render)
|
||||
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
|
||||
|
||||
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
|
||||
"""
|
||||
Find which Text object contains the given point.
|
||||
Uses Queriable.in_object() mixin for hit-testing.
|
||||
|
||||
Args:
|
||||
point: (x, y) coordinates to query
|
||||
|
||||
Returns:
|
||||
QueryResult from the text object at that point, or None
|
||||
"""
|
||||
from pyWebLayout.core.query import QueryResult
|
||||
from .functional import LinkText, ButtonText
|
||||
|
||||
point_array = np.array(point)
|
||||
|
||||
# Check each text object in this line
|
||||
for text_obj in self._text_objects:
|
||||
# Use Queriable mixin's in_object() for hit-testing
|
||||
if isinstance(text_obj, Queriable) and text_obj.in_object(point_array):
|
||||
# Extract metadata based on text type
|
||||
origin = text_obj._origin
|
||||
size = text_obj.size
|
||||
|
||||
# Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
|
||||
# Bounds should be (x, visual_top, width, height) for proper highlighting
|
||||
visual_top = int(origin[1] - text_obj._ascent)
|
||||
bounds = (
|
||||
int(origin[0]),
|
||||
visual_top,
|
||||
int(size[0]) if hasattr(size, '__getitem__') else 0,
|
||||
int(size[1]) if hasattr(size, '__getitem__') else 0
|
||||
)
|
||||
|
||||
if isinstance(text_obj, LinkText):
|
||||
result = QueryResult(
|
||||
object=text_obj,
|
||||
object_type="link",
|
||||
bounds=bounds,
|
||||
text=text_obj._text,
|
||||
is_interactive=True,
|
||||
link_target=text_obj._link.location if hasattr(text_obj, '_link') else None
|
||||
)
|
||||
elif isinstance(text_obj, ButtonText):
|
||||
result = QueryResult(
|
||||
object=text_obj,
|
||||
object_type="button",
|
||||
bounds=bounds,
|
||||
text=text_obj._text,
|
||||
is_interactive=True,
|
||||
callback=text_obj._callback if hasattr(text_obj, '_callback') else None
|
||||
)
|
||||
else:
|
||||
result = QueryResult(
|
||||
object=text_obj,
|
||||
object_type="text",
|
||||
bounds=bounds,
|
||||
text=text_obj._text if hasattr(text_obj, '_text') else None
|
||||
)
|
||||
|
||||
result.parent_line = self
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
from abc import ABC
|
||||
from typing import Optional, Tuple, List, TYPE_CHECKING
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyWebLayout.core.query import QueryResult
|
||||
|
||||
|
||||
class Renderable(ABC):
|
||||
"""
|
||||
@ -17,6 +21,9 @@ class Renderable(ABC):
|
||||
PIL.Image: The rendered image
|
||||
"""
|
||||
pass
|
||||
@property
|
||||
def origin(self):
|
||||
return self._origin
|
||||
|
||||
class Interactable(ABC):
|
||||
"""
|
||||
|
||||
248
pyWebLayout/core/highlight.py
Normal file
248
pyWebLayout/core/highlight.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""
|
||||
Text highlighting system for ebook reader.
|
||||
|
||||
Provides data structures and utilities for highlighting text regions,
|
||||
managing highlight collections, and rendering highlights on pages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
from enum import Enum
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class HighlightColor(Enum):
|
||||
"""Predefined highlight colors with RGBA values"""
|
||||
YELLOW = (255, 255, 0, 100) # Classic highlight yellow
|
||||
GREEN = (100, 255, 100, 100) # Green for verified/correct
|
||||
BLUE = (100, 200, 255, 100) # Blue for important
|
||||
PINK = (255, 150, 200, 100) # Pink for questions
|
||||
ORANGE = (255, 180, 100, 100) # Orange for warnings
|
||||
PURPLE = (200, 150, 255, 100) # Purple for definitions
|
||||
RED = (255, 100, 100, 100) # Red for errors/concerns
|
||||
|
||||
|
||||
@dataclass
|
||||
class Highlight:
|
||||
"""
|
||||
Represents a highlighted text region.
|
||||
|
||||
Highlights are stored with both pixel bounds (for rendering) and
|
||||
semantic bounds (text content, for persistence across font changes).
|
||||
"""
|
||||
# Identification
|
||||
id: str # Unique identifier
|
||||
|
||||
# Visual properties
|
||||
bounds: List[Tuple[int, int, int, int]] # List of (x, y, w, h) rectangles
|
||||
color: Tuple[int, int, int, int] # RGBA color
|
||||
|
||||
# Semantic properties (for persistence)
|
||||
text: str # The highlighted text
|
||||
start_word_index: Optional[int] = None # Word index in document (if available)
|
||||
end_word_index: Optional[int] = None
|
||||
|
||||
# Metadata
|
||||
note: Optional[str] = None # Optional annotation
|
||||
tags: List[str] = None # Optional categorization tags
|
||||
timestamp: Optional[float] = None # When created
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize default values"""
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'bounds': self.bounds,
|
||||
'color': self.color,
|
||||
'text': self.text,
|
||||
'start_word_index': self.start_word_index,
|
||||
'end_word_index': self.end_word_index,
|
||||
'note': self.note,
|
||||
'tags': self.tags,
|
||||
'timestamp': self.timestamp
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Highlight':
|
||||
"""Deserialize from dictionary"""
|
||||
return cls(
|
||||
id=data['id'],
|
||||
bounds=[tuple(b) for b in data['bounds']],
|
||||
color=tuple(data['color']),
|
||||
text=data['text'],
|
||||
start_word_index=data.get('start_word_index'),
|
||||
end_word_index=data.get('end_word_index'),
|
||||
note=data.get('note'),
|
||||
tags=data.get('tags', []),
|
||||
timestamp=data.get('timestamp')
|
||||
)
|
||||
|
||||
|
||||
class HighlightManager:
|
||||
"""
|
||||
Manages highlights for a document.
|
||||
|
||||
Handles adding, removing, listing, and persisting highlights.
|
||||
"""
|
||||
|
||||
def __init__(self, document_id: str, highlights_dir: str = "highlights"):
|
||||
"""
|
||||
Initialize highlight manager.
|
||||
|
||||
Args:
|
||||
document_id: Unique identifier for the document
|
||||
highlights_dir: Directory to store highlight data
|
||||
"""
|
||||
self.document_id = document_id
|
||||
self.highlights_dir = Path(highlights_dir)
|
||||
self.highlights: Dict[str, Highlight] = {} # id -> Highlight
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
self.highlights_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing highlights
|
||||
self._load_highlights()
|
||||
|
||||
def add_highlight(self, highlight: Highlight) -> None:
|
||||
"""
|
||||
Add a highlight.
|
||||
|
||||
Args:
|
||||
highlight: Highlight to add
|
||||
"""
|
||||
self.highlights[highlight.id] = highlight
|
||||
self._save_highlights()
|
||||
|
||||
def remove_highlight(self, highlight_id: str) -> bool:
|
||||
"""
|
||||
Remove a highlight by ID.
|
||||
|
||||
Args:
|
||||
highlight_id: ID of highlight to remove
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
if highlight_id in self.highlights:
|
||||
del self.highlights[highlight_id]
|
||||
self._save_highlights()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_highlight(self, highlight_id: str) -> Optional[Highlight]:
|
||||
"""Get a highlight by ID"""
|
||||
return self.highlights.get(highlight_id)
|
||||
|
||||
def list_highlights(self) -> List[Highlight]:
|
||||
"""Get all highlights"""
|
||||
return list(self.highlights.values())
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Remove all highlights"""
|
||||
self.highlights.clear()
|
||||
self._save_highlights()
|
||||
|
||||
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
|
||||
"""
|
||||
Get highlights that appear on a specific page.
|
||||
|
||||
Args:
|
||||
page_bounds: Page bounds (x, y, width, height)
|
||||
|
||||
Returns:
|
||||
List of highlights on this page
|
||||
"""
|
||||
page_x, page_y, page_w, page_h = page_bounds
|
||||
page_highlights = []
|
||||
|
||||
for highlight in self.highlights.values():
|
||||
# Check if any highlight bounds overlap with page
|
||||
for hx, hy, hw, hh in highlight.bounds:
|
||||
if (hx < page_x + page_w and hx + hw > page_x and
|
||||
hy < page_y + page_h and hy + hh > page_y):
|
||||
page_highlights.append(highlight)
|
||||
break
|
||||
|
||||
return page_highlights
|
||||
|
||||
def _get_filepath(self) -> Path:
|
||||
"""Get filepath for this document's highlights"""
|
||||
return self.highlights_dir / f"{self.document_id}_highlights.json"
|
||||
|
||||
def _save_highlights(self) -> None:
|
||||
"""Persist highlights to disk"""
|
||||
try:
|
||||
filepath = self._get_filepath()
|
||||
data = {
|
||||
'document_id': self.document_id,
|
||||
'highlights': [h.to_dict() for h in self.highlights.values()]
|
||||
}
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error saving highlights: {e}")
|
||||
|
||||
def _load_highlights(self) -> None:
|
||||
"""Load highlights from disk"""
|
||||
try:
|
||||
filepath = self._get_filepath()
|
||||
if not filepath.exists():
|
||||
return
|
||||
|
||||
with open(filepath, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.highlights = {
|
||||
h['id']: Highlight.from_dict(h)
|
||||
for h in data.get('highlights', [])
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error loading highlights: {e}")
|
||||
self.highlights = {}
|
||||
|
||||
|
||||
def create_highlight_from_query_result(
|
||||
result,
|
||||
color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
|
||||
note: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None
|
||||
) -> Highlight:
|
||||
"""
|
||||
Create a highlight from a QueryResult.
|
||||
|
||||
Args:
|
||||
result: QueryResult from query_pixel or query_range
|
||||
color: RGBA color tuple
|
||||
note: Optional annotation
|
||||
tags: Optional categorization tags
|
||||
|
||||
Returns:
|
||||
Highlight instance
|
||||
"""
|
||||
from time import time
|
||||
import uuid
|
||||
|
||||
# Handle single result or SelectionRange
|
||||
if hasattr(result, 'results'): # SelectionRange
|
||||
bounds = result.bounds_list
|
||||
text = result.text
|
||||
else: # Single QueryResult
|
||||
bounds = [result.bounds]
|
||||
text = result.text or ""
|
||||
|
||||
return Highlight(
|
||||
id=str(uuid.uuid4()),
|
||||
bounds=bounds,
|
||||
color=color,
|
||||
text=text,
|
||||
note=note,
|
||||
tags=tags or [],
|
||||
timestamp=time()
|
||||
)
|
||||
87
pyWebLayout/core/query.py
Normal file
87
pyWebLayout/core/query.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""
|
||||
Query system for pixel-to-content mapping.
|
||||
|
||||
This module provides data structures for querying rendered content,
|
||||
enabling interactive features like link clicking, word definition lookup,
|
||||
and text selection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple, List, Any, TYPE_CHECKING
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyWebLayout.core.base import Queriable
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryResult:
|
||||
"""
|
||||
Result of querying a point on a rendered page.
|
||||
|
||||
This encapsulates all information about what was found at a pixel location,
|
||||
including geometry, content, and interaction capabilities.
|
||||
"""
|
||||
# What was found
|
||||
object: 'Queriable' # The object at this point
|
||||
object_type: str # "link", "text", "image", "button", "word", "empty"
|
||||
|
||||
# Geometry
|
||||
bounds: Tuple[int, int, int, int] # (x, y, width, height) in page coordinates
|
||||
|
||||
# Content (for text/words)
|
||||
text: Optional[str] = None
|
||||
word_index: Optional[int] = None # Index in abstract document structure
|
||||
block_index: Optional[int] = None # Block index in document
|
||||
|
||||
# Interaction (for links/buttons)
|
||||
is_interactive: bool = False
|
||||
link_target: Optional[str] = None # URL or internal reference
|
||||
callback: Optional[Any] = None # Interaction callback
|
||||
|
||||
# Hierarchy (for debugging/traversal)
|
||||
parent_line: Optional[Any] = None
|
||||
parent_page: Optional[Any] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization"""
|
||||
return {
|
||||
'object_type': self.object_type,
|
||||
'bounds': self.bounds,
|
||||
'text': self.text,
|
||||
'is_interactive': self.is_interactive,
|
||||
'link_target': self.link_target,
|
||||
'word_index': self.word_index,
|
||||
'block_index': self.block_index
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectionRange:
|
||||
"""
|
||||
Represents a range of selected text between two points.
|
||||
"""
|
||||
start_point: Tuple[int, int]
|
||||
end_point: Tuple[int, int]
|
||||
results: List[QueryResult] # All query results in the range
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Get concatenated text from all results"""
|
||||
return " ".join(r.text for r in self.results if r.text)
|
||||
|
||||
@property
|
||||
def bounds_list(self) -> List[Tuple[int, int, int, int]]:
|
||||
"""Get list of all bounding boxes for highlighting"""
|
||||
return [r.bounds for r in self.results]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization"""
|
||||
return {
|
||||
'start': self.start_point,
|
||||
'end': self.end_point,
|
||||
'text': self.text,
|
||||
'word_count': len(self.results),
|
||||
'bounds': self.bounds_list
|
||||
}
|
||||
124
pyWebLayout/io/gesture.py
Normal file
124
pyWebLayout/io/gesture.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""
|
||||
Gesture event types for touch input.
|
||||
|
||||
This module defines touch gestures that can be received from a HAL (Hardware Abstraction Layer)
|
||||
or touch input system, and the response format for actions to be performed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class GestureType(Enum):
|
||||
"""Touch gesture types from HAL"""
|
||||
TAP = "tap" # Single finger tap
|
||||
LONG_PRESS = "long_press" # Hold for 500ms+
|
||||
SWIPE_LEFT = "swipe_left" # Swipe left (page forward)
|
||||
SWIPE_RIGHT = "swipe_right" # Swipe right (page back)
|
||||
SWIPE_UP = "swipe_up" # Swipe up (scroll down)
|
||||
SWIPE_DOWN = "swipe_down" # Swipe down (scroll up)
|
||||
PINCH_IN = "pinch_in" # Pinch fingers together (zoom out)
|
||||
PINCH_OUT = "pinch_out" # Spread fingers apart (zoom in)
|
||||
DRAG_START = "drag_start" # Start dragging/selection
|
||||
DRAG_MOVE = "drag_move" # Continue dragging
|
||||
DRAG_END = "drag_end" # End dragging/selection
|
||||
|
||||
|
||||
@dataclass
|
||||
class TouchEvent:
|
||||
"""
|
||||
Touch event from HAL.
|
||||
|
||||
Represents a single touch gesture with its coordinates and metadata.
|
||||
"""
|
||||
gesture: GestureType
|
||||
x: int # Primary touch point X coordinate
|
||||
y: int # Primary touch point Y coordinate
|
||||
x2: Optional[int] = None # Secondary point X (for pinch/drag)
|
||||
y2: Optional[int] = None # Secondary point Y (for pinch/drag)
|
||||
timestamp_ms: float = 0 # Timestamp in milliseconds
|
||||
|
||||
@classmethod
|
||||
def from_hal(cls, hal_data: dict) -> 'TouchEvent':
|
||||
"""
|
||||
Parse a touch event from HAL format.
|
||||
|
||||
Args:
|
||||
hal_data: Dictionary with gesture data from HAL
|
||||
Expected keys: 'gesture', 'x', 'y', optionally 'x2', 'y2', 'timestamp'
|
||||
|
||||
Returns:
|
||||
TouchEvent instance
|
||||
|
||||
Example:
|
||||
>>> event = TouchEvent.from_hal({
|
||||
... 'gesture': 'tap',
|
||||
... 'x': 450,
|
||||
... 'y': 320
|
||||
... })
|
||||
"""
|
||||
return cls(
|
||||
gesture=GestureType(hal_data['gesture']),
|
||||
x=hal_data['x'],
|
||||
y=hal_data['y'],
|
||||
x2=hal_data.get('x2'),
|
||||
y2=hal_data.get('y2'),
|
||||
timestamp_ms=hal_data.get('timestamp', 0)
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization"""
|
||||
return {
|
||||
'gesture': self.gesture.value,
|
||||
'x': self.x,
|
||||
'y': self.y,
|
||||
'x2': self.x2,
|
||||
'y2': self.y2,
|
||||
'timestamp_ms': self.timestamp_ms
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GestureResponse:
|
||||
"""
|
||||
Response from handling a gesture.
|
||||
|
||||
This encapsulates the action that should be performed by the UI
|
||||
in response to a gesture, keeping all business logic in the library.
|
||||
"""
|
||||
action: str # Action type: "navigate", "define", "select", "zoom", "page_turn", "none", etc.
|
||||
data: Dict[str, Any] # Action-specific data
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
Convert to dictionary for Flask JSON response.
|
||||
|
||||
Returns:
|
||||
Dictionary with action and data
|
||||
"""
|
||||
return {
|
||||
'action': self.action,
|
||||
'data': self.data
|
||||
}
|
||||
|
||||
|
||||
# Action type constants for clarity
|
||||
class ActionType:
|
||||
"""Constants for gesture response action types"""
|
||||
NONE = "none"
|
||||
PAGE_TURN = "page_turn"
|
||||
NAVIGATE = "navigate"
|
||||
DEFINE = "define"
|
||||
SELECT = "select"
|
||||
ZOOM = "zoom"
|
||||
BOOK_LOADED = "book_loaded"
|
||||
WORD_SELECTED = "word_selected"
|
||||
SHOW_MENU = "show_menu"
|
||||
SELECTION_START = "selection_start"
|
||||
SELECTION_UPDATE = "selection_update"
|
||||
SELECTION_COMPLETE = "selection_complete"
|
||||
AT_START = "at_start"
|
||||
AT_END = "at_end"
|
||||
ERROR = "error"
|
||||
@ -1,632 +0,0 @@
|
||||
#!/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.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
|
||||
|
||||
|
||||
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",
|
||||
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
|
||||
buffer_size: Number of pages to cache for performance
|
||||
"""
|
||||
self.page_size = page_size
|
||||
self.bookmarks_dir = bookmarks_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
|
||||
|
||||
# Font scale state
|
||||
self.base_font_scale = 1.0
|
||||
self.font_scale_step = 0.1 # 10% change per step
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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) -> Optional[Image.Image]:
|
||||
"""
|
||||
Get the current page as a PIL Image.
|
||||
|
||||
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()
|
||||
return page.render()
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
@ -22,7 +22,7 @@ class SimpleTestRenderable(Renderable, Queriable):
|
||||
def __init__(self, text: str, size: tuple = (100, 50)):
|
||||
self._text = text
|
||||
self.size = size
|
||||
self.origin = np.array([0, 0])
|
||||
self._origin = np.array([0, 0])
|
||||
|
||||
def render(self):
|
||||
"""Render returns None - drawing is done via the page's draw object"""
|
||||
@ -154,16 +154,19 @@ class TestPageImplementation(unittest.TestCase):
|
||||
|
||||
# Query points
|
||||
# Point within first child
|
||||
found_child = page.query_point((90, 30))
|
||||
self.assertEqual(found_child, child1)
|
||||
result = page.query_point((90, 30))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object, child1)
|
||||
|
||||
# Point within second child
|
||||
found_child = page.query_point((30, 30))
|
||||
self.assertEqual(found_child, child2)
|
||||
result = page.query_point((30, 30))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object, child2)
|
||||
|
||||
# Point outside any child
|
||||
found_child = page.query_point((300, 250))
|
||||
self.assertIsNone(found_child)
|
||||
# Point outside any child - returns QueryResult with object_type "empty"
|
||||
result = page.query_point((300, 250))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "empty")
|
||||
|
||||
def test_page_in_object(self):
|
||||
"""Test that page correctly implements in_object"""
|
||||
|
||||
1
tests/core/__init__.py
Normal file
1
tests/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for core pyWebLayout functionality."""
|
||||
353
tests/core/test_highlight.py
Normal file
353
tests/core/test_highlight.py
Normal file
@ -0,0 +1,353 @@
|
||||
"""
|
||||
Unit tests for the highlight system.
|
||||
|
||||
Tests Highlight, HighlightColor, HighlightManager, and integration with query system.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from pyWebLayout.core.highlight import (
|
||||
Highlight,
|
||||
HighlightColor,
|
||||
HighlightManager,
|
||||
create_highlight_from_query_result
|
||||
)
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
|
||||
|
||||
class TestHighlightColor(unittest.TestCase):
|
||||
"""Test HighlightColor enum"""
|
||||
|
||||
def test_colors_defined(self):
|
||||
"""Test all expected colors are defined"""
|
||||
expected_colors = ['YELLOW', 'GREEN', 'BLUE', 'PINK', 'ORANGE', 'PURPLE', 'RED']
|
||||
|
||||
for color_name in expected_colors:
|
||||
self.assertTrue(hasattr(HighlightColor, color_name))
|
||||
color = getattr(HighlightColor, color_name)
|
||||
self.assertIsInstance(color.value, tuple)
|
||||
self.assertEqual(len(color.value), 4) # RGBA
|
||||
|
||||
def test_yellow_is_default(self):
|
||||
"""Test yellow highlight color"""
|
||||
yellow = HighlightColor.YELLOW.value
|
||||
self.assertEqual(yellow, (255, 255, 0, 100))
|
||||
|
||||
|
||||
class TestHighlight(unittest.TestCase):
|
||||
"""Test Highlight dataclass"""
|
||||
|
||||
def test_init_basic(self):
|
||||
"""Test basic Highlight creation"""
|
||||
highlight = Highlight(
|
||||
id="test-id",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Hello"
|
||||
)
|
||||
|
||||
self.assertEqual(highlight.id, "test-id")
|
||||
self.assertEqual(len(highlight.bounds), 1)
|
||||
self.assertEqual(highlight.bounds[0], (10, 20, 50, 15))
|
||||
self.assertEqual(highlight.color, (255, 255, 0, 100))
|
||||
self.assertEqual(highlight.text, "Hello")
|
||||
self.assertIsNone(highlight.note)
|
||||
self.assertEqual(highlight.tags, [])
|
||||
|
||||
def test_init_with_metadata(self):
|
||||
"""Test Highlight with full metadata"""
|
||||
highlight = Highlight(
|
||||
id="test-id",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Hello",
|
||||
note="Important word",
|
||||
tags=["important", "keyword"],
|
||||
timestamp=1234567890.0,
|
||||
start_word_index=5,
|
||||
end_word_index=5
|
||||
)
|
||||
|
||||
self.assertEqual(highlight.note, "Important word")
|
||||
self.assertEqual(highlight.tags, ["important", "keyword"])
|
||||
self.assertEqual(highlight.timestamp, 1234567890.0)
|
||||
self.assertEqual(highlight.start_word_index, 5)
|
||||
self.assertEqual(highlight.end_word_index, 5)
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test Highlight serialization"""
|
||||
highlight = Highlight(
|
||||
id="test-id",
|
||||
bounds=[(10, 20, 50, 15), (70, 20, 40, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Hello world",
|
||||
note="Test note",
|
||||
tags=["test"],
|
||||
timestamp=1234567890.0
|
||||
)
|
||||
|
||||
data = highlight.to_dict()
|
||||
|
||||
self.assertEqual(data['id'], "test-id")
|
||||
self.assertEqual(len(data['bounds']), 2)
|
||||
self.assertEqual(data['bounds'][0], (10, 20, 50, 15))
|
||||
self.assertEqual(data['color'], (255, 255, 0, 100))
|
||||
self.assertEqual(data['text'], "Hello world")
|
||||
self.assertEqual(data['note'], "Test note")
|
||||
self.assertEqual(data['tags'], ["test"])
|
||||
self.assertEqual(data['timestamp'], 1234567890.0)
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test Highlight deserialization"""
|
||||
data = {
|
||||
'id': "test-id",
|
||||
'bounds': [[10, 20, 50, 15], [70, 20, 40, 15]],
|
||||
'color': [255, 255, 0, 100],
|
||||
'text': "Hello world",
|
||||
'note': "Test note",
|
||||
'tags': ["test"],
|
||||
'timestamp': 1234567890.0,
|
||||
'start_word_index': 5,
|
||||
'end_word_index': 6
|
||||
}
|
||||
|
||||
highlight = Highlight.from_dict(data)
|
||||
|
||||
self.assertEqual(highlight.id, "test-id")
|
||||
self.assertEqual(len(highlight.bounds), 2)
|
||||
self.assertEqual(highlight.bounds[0], (10, 20, 50, 15))
|
||||
self.assertEqual(highlight.color, (255, 255, 0, 100))
|
||||
self.assertEqual(highlight.text, "Hello world")
|
||||
self.assertEqual(highlight.note, "Test note")
|
||||
self.assertEqual(highlight.tags, ["test"])
|
||||
self.assertEqual(highlight.start_word_index, 5)
|
||||
self.assertEqual(highlight.end_word_index, 6)
|
||||
|
||||
|
||||
class TestHighlightManager(unittest.TestCase):
|
||||
"""Test HighlightManager class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create temporary directory for highlights"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.manager = HighlightManager(
|
||||
document_id="test-doc",
|
||||
highlights_dir=self.temp_dir
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up temporary directory"""
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_init(self):
|
||||
"""Test HighlightManager initialization"""
|
||||
self.assertEqual(self.manager.document_id, "test-doc")
|
||||
self.assertEqual(self.manager.highlights_dir, Path(self.temp_dir))
|
||||
self.assertEqual(len(self.manager.highlights), 0)
|
||||
|
||||
def test_add_highlight(self):
|
||||
"""Test adding a highlight"""
|
||||
highlight = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Test"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight)
|
||||
|
||||
self.assertEqual(len(self.manager.highlights), 1)
|
||||
self.assertIn("test-1", self.manager.highlights)
|
||||
self.assertEqual(self.manager.highlights["test-1"], highlight)
|
||||
|
||||
def test_remove_highlight(self):
|
||||
"""Test removing a highlight"""
|
||||
highlight = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Test"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight)
|
||||
self.assertEqual(len(self.manager.highlights), 1)
|
||||
|
||||
result = self.manager.remove_highlight("test-1")
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(len(self.manager.highlights), 0)
|
||||
|
||||
def test_remove_nonexistent_highlight(self):
|
||||
"""Test removing a highlight that doesn't exist"""
|
||||
result = self.manager.remove_highlight("nonexistent")
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_get_highlight(self):
|
||||
"""Test getting a highlight by ID"""
|
||||
highlight = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Test"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight)
|
||||
retrieved = self.manager.get_highlight("test-1")
|
||||
|
||||
self.assertIsNotNone(retrieved)
|
||||
self.assertEqual(retrieved.id, "test-1")
|
||||
self.assertEqual(retrieved.text, "Test")
|
||||
|
||||
def test_list_highlights(self):
|
||||
"""Test listing all highlights"""
|
||||
highlight1 = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="First"
|
||||
)
|
||||
highlight2 = Highlight(
|
||||
id="test-2",
|
||||
bounds=[(100, 20, 50, 15)],
|
||||
color=(100, 255, 100, 100),
|
||||
text="Second"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight1)
|
||||
self.manager.add_highlight(highlight2)
|
||||
|
||||
highlights = self.manager.list_highlights()
|
||||
self.assertEqual(len(highlights), 2)
|
||||
self.assertIn(highlight1, highlights)
|
||||
self.assertIn(highlight2, highlights)
|
||||
|
||||
def test_clear_all(self):
|
||||
"""Test clearing all highlights"""
|
||||
highlight1 = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="First"
|
||||
)
|
||||
highlight2 = Highlight(
|
||||
id="test-2",
|
||||
bounds=[(100, 20, 50, 15)],
|
||||
color=(100, 255, 100, 100),
|
||||
text="Second"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight1)
|
||||
self.manager.add_highlight(highlight2)
|
||||
self.assertEqual(len(self.manager.highlights), 2)
|
||||
|
||||
self.manager.clear_all()
|
||||
self.assertEqual(len(self.manager.highlights), 0)
|
||||
|
||||
def test_persistence(self):
|
||||
"""Test that highlights are persisted to disk"""
|
||||
highlight = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Persisted"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight)
|
||||
|
||||
# Create new manager for same document
|
||||
new_manager = HighlightManager(
|
||||
document_id="test-doc",
|
||||
highlights_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Should load existing highlights
|
||||
self.assertEqual(len(new_manager.highlights), 1)
|
||||
self.assertIn("test-1", new_manager.highlights)
|
||||
self.assertEqual(new_manager.highlights["test-1"].text, "Persisted")
|
||||
|
||||
def test_get_highlights_for_page(self):
|
||||
"""Test filtering highlights by page bounds"""
|
||||
# Highlight on page
|
||||
highlight1 = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(100, 100, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="On page"
|
||||
)
|
||||
|
||||
# Highlight off page
|
||||
highlight2 = Highlight(
|
||||
id="test-2",
|
||||
bounds=[(1000, 1000, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Off page"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight1)
|
||||
self.manager.add_highlight(highlight2)
|
||||
|
||||
# Page bounds (0, 0, 800, 1000)
|
||||
page_bounds = (0, 0, 800, 1000)
|
||||
page_highlights = self.manager.get_highlights_for_page(page_bounds)
|
||||
|
||||
self.assertEqual(len(page_highlights), 1)
|
||||
self.assertEqual(page_highlights[0].id, "test-1")
|
||||
|
||||
|
||||
class TestCreateHighlightFromQueryResult(unittest.TestCase):
|
||||
"""Test create_highlight_from_query_result function"""
|
||||
|
||||
def test_create_from_single_result(self):
|
||||
"""Test creating highlight from single QueryResult"""
|
||||
result = QueryResult(
|
||||
object=object(),
|
||||
object_type="text",
|
||||
bounds=(10, 20, 50, 15),
|
||||
text="Hello"
|
||||
)
|
||||
|
||||
highlight = create_highlight_from_query_result(
|
||||
result,
|
||||
color=(255, 255, 0, 100),
|
||||
note="Test note",
|
||||
tags=["test"]
|
||||
)
|
||||
|
||||
self.assertIsNotNone(highlight.id)
|
||||
self.assertEqual(len(highlight.bounds), 1)
|
||||
self.assertEqual(highlight.bounds[0], (10, 20, 50, 15))
|
||||
self.assertEqual(highlight.color, (255, 255, 0, 100))
|
||||
self.assertEqual(highlight.text, "Hello")
|
||||
self.assertEqual(highlight.note, "Test note")
|
||||
self.assertEqual(highlight.tags, ["test"])
|
||||
self.assertIsNotNone(highlight.timestamp)
|
||||
|
||||
def test_create_from_selection_range(self):
|
||||
"""Test creating highlight from SelectionRange"""
|
||||
results = [
|
||||
QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"),
|
||||
QueryResult(object(), "text", (45, 20, 35, 15), text="world")
|
||||
]
|
||||
|
||||
sel_range = SelectionRange((10, 20), (80, 35), results)
|
||||
|
||||
highlight = create_highlight_from_query_result(
|
||||
sel_range,
|
||||
color=(100, 255, 100, 100),
|
||||
note="Multi-word"
|
||||
)
|
||||
|
||||
self.assertIsNotNone(highlight.id)
|
||||
self.assertEqual(len(highlight.bounds), 2)
|
||||
self.assertEqual(highlight.bounds[0], (10, 20, 30, 15))
|
||||
self.assertEqual(highlight.bounds[1], (45, 20, 35, 15))
|
||||
self.assertEqual(highlight.color, (100, 255, 100, 100))
|
||||
self.assertEqual(highlight.text, "Hello world")
|
||||
self.assertEqual(highlight.note, "Multi-word")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
424
tests/core/test_query_system.py
Normal file
424
tests/core/test_query_system.py
Normal file
@ -0,0 +1,424 @@
|
||||
"""
|
||||
Unit tests for the query system (pixel-to-content mapping).
|
||||
|
||||
Tests the QueryResult, SelectionRange, and query_point functionality
|
||||
across Page, Line, and Text classes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.concrete.functional import LinkText
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.abstract.functional import Link, LinkType
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
|
||||
|
||||
|
||||
class TestQueryResult(unittest.TestCase):
|
||||
"""Test QueryResult dataclass"""
|
||||
|
||||
def test_init_basic(self):
|
||||
"""Test basic QueryResult creation"""
|
||||
obj = object()
|
||||
result = QueryResult(
|
||||
object=obj,
|
||||
object_type="text",
|
||||
bounds=(100, 200, 50, 20)
|
||||
)
|
||||
|
||||
self.assertEqual(result.object, obj)
|
||||
self.assertEqual(result.object_type, "text")
|
||||
self.assertEqual(result.bounds, (100, 200, 50, 20))
|
||||
self.assertIsNone(result.text)
|
||||
self.assertFalse(result.is_interactive)
|
||||
|
||||
def test_init_with_metadata(self):
|
||||
"""Test QueryResult with full metadata"""
|
||||
obj = object()
|
||||
result = QueryResult(
|
||||
object=obj,
|
||||
object_type="link",
|
||||
bounds=(100, 200, 50, 20),
|
||||
text="Click here",
|
||||
is_interactive=True,
|
||||
link_target="chapter2"
|
||||
)
|
||||
|
||||
self.assertEqual(result.text, "Click here")
|
||||
self.assertTrue(result.is_interactive)
|
||||
self.assertEqual(result.link_target, "chapter2")
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test QueryResult serialization"""
|
||||
result = QueryResult(
|
||||
object=object(),
|
||||
object_type="link",
|
||||
bounds=(100, 200, 50, 20),
|
||||
text="Click here",
|
||||
is_interactive=True,
|
||||
link_target="chapter2"
|
||||
)
|
||||
|
||||
d = result.to_dict()
|
||||
self.assertEqual(d['object_type'], "link")
|
||||
self.assertEqual(d['bounds'], (100, 200, 50, 20))
|
||||
self.assertEqual(d['text'], "Click here")
|
||||
self.assertTrue(d['is_interactive'])
|
||||
self.assertEqual(d['link_target'], "chapter2")
|
||||
|
||||
|
||||
class TestSelectionRange(unittest.TestCase):
|
||||
"""Test SelectionRange dataclass"""
|
||||
|
||||
def test_init(self):
|
||||
"""Test SelectionRange creation"""
|
||||
results = []
|
||||
sel_range = SelectionRange(
|
||||
start_point=(10, 20),
|
||||
end_point=(100, 30),
|
||||
results=results
|
||||
)
|
||||
|
||||
self.assertEqual(sel_range.start_point, (10, 20))
|
||||
self.assertEqual(sel_range.end_point, (100, 30))
|
||||
self.assertEqual(sel_range.results, results)
|
||||
|
||||
def test_text_property(self):
|
||||
"""Test concatenated text extraction"""
|
||||
results = [
|
||||
QueryResult(object(), "text", (0, 0, 0, 0), text="Hello"),
|
||||
QueryResult(object(), "text", (0, 0, 0, 0), text="world"),
|
||||
QueryResult(object(), "text", (0, 0, 0, 0), text="test")
|
||||
]
|
||||
|
||||
sel_range = SelectionRange((0, 0), (100, 100), results)
|
||||
self.assertEqual(sel_range.text, "Hello world test")
|
||||
|
||||
def test_bounds_list_property(self):
|
||||
"""Test bounds list extraction"""
|
||||
results = [
|
||||
QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"),
|
||||
QueryResult(object(), "text", (45, 20, 35, 15), text="world")
|
||||
]
|
||||
|
||||
sel_range = SelectionRange((0, 0), (100, 100), results)
|
||||
bounds = sel_range.bounds_list
|
||||
|
||||
self.assertEqual(len(bounds), 2)
|
||||
self.assertEqual(bounds[0], (10, 20, 30, 15))
|
||||
self.assertEqual(bounds[1], (45, 20, 35, 15))
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test SelectionRange serialization"""
|
||||
results = [
|
||||
QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"),
|
||||
QueryResult(object(), "text", (45, 20, 35, 15), text="world")
|
||||
]
|
||||
|
||||
sel_range = SelectionRange((10, 20), (80, 35), results)
|
||||
d = sel_range.to_dict()
|
||||
|
||||
self.assertEqual(d['start'], (10, 20))
|
||||
self.assertEqual(d['end'], (80, 35))
|
||||
self.assertEqual(d['text'], "Hello world")
|
||||
self.assertEqual(d['word_count'], 2)
|
||||
self.assertEqual(len(d['bounds']), 2)
|
||||
|
||||
|
||||
class TestTextQueryPoint(unittest.TestCase):
|
||||
"""Test Text class in_object (from Queriable mixin)"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
self.font = create_default_test_font()
|
||||
|
||||
def test_in_object_hit(self):
|
||||
"""Test in_object returns True for point inside text"""
|
||||
text = Text("Hello", self.font, self.draw)
|
||||
text.set_origin(np.array([100, 100]))
|
||||
|
||||
# Point inside text bounds
|
||||
self.assertTrue(text.in_object(np.array([110, 105])))
|
||||
|
||||
def test_in_object_miss(self):
|
||||
"""Test in_object returns False for point outside text"""
|
||||
text = Text("Hello", self.font, self.draw)
|
||||
text.set_origin(np.array([100, 100]))
|
||||
|
||||
# Point outside text bounds
|
||||
self.assertFalse(text.in_object(np.array([50, 50])))
|
||||
self.assertFalse(text.in_object(np.array([200, 200])))
|
||||
|
||||
|
||||
class TestLineQueryPoint(unittest.TestCase):
|
||||
"""Test Line.query_point method"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
self.font = create_default_test_font()
|
||||
|
||||
def test_query_point_finds_text(self):
|
||||
"""Test Line.query_point finds a text object"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
# Add text objects
|
||||
word1 = Word("Hello", self.font)
|
||||
word2 = Word("world", self.font)
|
||||
|
||||
line.add_word(word1)
|
||||
line.add_word(word2)
|
||||
line.render()
|
||||
|
||||
# Query a point that should hit first word
|
||||
# (after rendering, text objects have positions set)
|
||||
if len(line._text_objects) > 0:
|
||||
text_obj = line._text_objects[0]
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5))
|
||||
|
||||
result = line.query_point(point)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "text")
|
||||
self.assertIsNotNone(result.text)
|
||||
|
||||
def test_query_point_miss(self):
|
||||
"""Test Line.query_point returns None for miss"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
word1 = Word("Hello", self.font)
|
||||
line.add_word(word1)
|
||||
line.render()
|
||||
|
||||
# Query far outside line bounds
|
||||
result = line.query_point((10, 10))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_query_point_finds_link(self):
|
||||
"""Test Line.query_point correctly identifies links"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
# Create a linked word
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
linked_word = LinkedWord("Click", self.font, "chapter2", LinkType.INTERNAL)
|
||||
|
||||
line.add_word(linked_word)
|
||||
line.render()
|
||||
|
||||
# Query the link
|
||||
if len(line._text_objects) > 0:
|
||||
text_obj = line._text_objects[0]
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5))
|
||||
|
||||
result = line.query_point(point)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "link")
|
||||
self.assertTrue(result.is_interactive)
|
||||
self.assertEqual(result.link_target, "chapter2")
|
||||
|
||||
|
||||
class TestPageQueryPoint(unittest.TestCase):
|
||||
"""Test Page.query_point method"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.page = Page(size=(800, 1000), style=PageStyle())
|
||||
self.font = create_default_test_font()
|
||||
|
||||
def test_query_point_empty_page(self):
|
||||
"""Test querying empty page returns empty result"""
|
||||
result = self.page.query_point((400, 500))
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "empty")
|
||||
self.assertEqual(result.object, self.page)
|
||||
|
||||
def test_query_point_finds_line(self):
|
||||
"""Test Page.query_point traverses to Line"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.page.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
word = Word("Hello", self.font)
|
||||
line.add_word(word)
|
||||
line.render()
|
||||
|
||||
self.page.add_child(line)
|
||||
|
||||
# Query a point inside the line
|
||||
if len(line._text_objects) > 0:
|
||||
text_obj = line._text_objects[0]
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5))
|
||||
|
||||
result = self.page.query_point(point)
|
||||
|
||||
# Should traverse Page → Line → Text
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "text")
|
||||
self.assertEqual(result.parent_page, self.page)
|
||||
|
||||
def test_query_point_multiple_lines(self):
|
||||
"""Test Page.query_point with multiple lines"""
|
||||
# Add two lines at different Y positions
|
||||
line1 = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.page.draw,
|
||||
font=self.font
|
||||
)
|
||||
line2 = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 150]),
|
||||
size=(700, 30),
|
||||
draw=self.page.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
word1 = Word("First", self.font)
|
||||
word2 = Word("Second", self.font)
|
||||
|
||||
line1.add_word(word1)
|
||||
line2.add_word(word2)
|
||||
|
||||
line1.render()
|
||||
line2.render()
|
||||
|
||||
self.page.add_child(line1)
|
||||
self.page.add_child(line2)
|
||||
|
||||
# Query first line
|
||||
if len(line1._text_objects) > 0:
|
||||
text_obj1 = line1._text_objects[0]
|
||||
point1 = (int(text_obj1._origin[0] + 5), int(text_obj1._origin[1] + 5))
|
||||
result1 = self.page.query_point(point1)
|
||||
|
||||
self.assertIsNotNone(result1)
|
||||
self.assertEqual(result1.text, "First")
|
||||
|
||||
# Query second line
|
||||
if len(line2._text_objects) > 0:
|
||||
text_obj2 = line2._text_objects[0]
|
||||
point2 = (int(text_obj2._origin[0] + 5), int(text_obj2._origin[1] + 5))
|
||||
result2 = self.page.query_point(point2)
|
||||
|
||||
self.assertIsNotNone(result2)
|
||||
self.assertEqual(result2.text, "Second")
|
||||
|
||||
|
||||
class TestPageQueryRange(unittest.TestCase):
|
||||
"""Test Page.query_range method for text selection"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.page = Page(size=(800, 1000), style=PageStyle())
|
||||
self.font = create_default_test_font()
|
||||
|
||||
def test_query_range_single_line(self):
|
||||
"""Test selecting text within a single line"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.page.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
# Add multiple words
|
||||
words = [Word(text, self.font) for text in ["Hello", "world", "test"]]
|
||||
for word in words:
|
||||
line.add_word(word)
|
||||
|
||||
line.render()
|
||||
self.page.add_child(line)
|
||||
|
||||
if len(line._text_objects) >= 2:
|
||||
# Select from first to second word
|
||||
start_text = line._text_objects[0]
|
||||
end_text = line._text_objects[1]
|
||||
|
||||
start_point = (int(start_text._origin[0] + 5), int(start_text._origin[1] + 5))
|
||||
end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1] + 5))
|
||||
|
||||
sel_range = self.page.query_range(start_point, end_point)
|
||||
|
||||
self.assertIsNotNone(sel_range)
|
||||
self.assertGreater(len(sel_range.results), 0)
|
||||
self.assertIn("Hello", sel_range.text)
|
||||
|
||||
def test_query_range_invalid(self):
|
||||
"""Test query_range with invalid points returns empty"""
|
||||
sel_range = self.page.query_range((10, 10), (20, 20))
|
||||
|
||||
self.assertEqual(len(sel_range.results), 0)
|
||||
self.assertEqual(sel_range.text, "")
|
||||
|
||||
|
||||
class TestPageMakeQueryResult(unittest.TestCase):
|
||||
"""Test Page._make_query_result helper"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.page = Page(size=(800, 1000), style=PageStyle())
|
||||
self.font = create_default_test_font()
|
||||
self.draw = self.page.draw
|
||||
|
||||
def test_make_query_result_text(self):
|
||||
"""Test packaging regular Text object"""
|
||||
text = Text("Hello", self.font, self.draw)
|
||||
text.set_origin(np.array([100, 200]))
|
||||
|
||||
result = self.page._make_query_result(text, (105, 205))
|
||||
|
||||
self.assertEqual(result.object_type, "text")
|
||||
self.assertEqual(result.text, "Hello")
|
||||
self.assertFalse(result.is_interactive)
|
||||
|
||||
def test_make_query_result_link(self):
|
||||
"""Test packaging LinkText object"""
|
||||
link = Link(location="chapter2", link_type=LinkType.INTERNAL, callback=None)
|
||||
link_text = LinkText(link, "Click here", self.font, self.draw)
|
||||
link_text.set_origin(np.array([100, 200]))
|
||||
|
||||
result = self.page._make_query_result(link_text, (105, 205))
|
||||
|
||||
self.assertEqual(result.object_type, "link")
|
||||
self.assertEqual(result.text, "Click here")
|
||||
self.assertTrue(result.is_interactive)
|
||||
self.assertEqual(result.link_target, "chapter2")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
1
tests/io_tests/io/__init__.py
Normal file
1
tests/io_tests/io/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for I/O functionality."""
|
||||
287
tests/io_tests/io/test_gesture.py
Normal file
287
tests/io_tests/io/test_gesture.py
Normal file
@ -0,0 +1,287 @@
|
||||
"""
|
||||
Unit tests for gesture event system.
|
||||
|
||||
Tests TouchEvent, GestureType, GestureResponse, and HAL integration.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.io.gesture import (
|
||||
GestureType,
|
||||
TouchEvent,
|
||||
GestureResponse,
|
||||
ActionType
|
||||
)
|
||||
|
||||
|
||||
class TestGestureType(unittest.TestCase):
|
||||
"""Test GestureType enum"""
|
||||
|
||||
def test_gesture_types_exist(self):
|
||||
"""Test all gesture types are defined"""
|
||||
self.assertEqual(GestureType.TAP.value, "tap")
|
||||
self.assertEqual(GestureType.LONG_PRESS.value, "long_press")
|
||||
self.assertEqual(GestureType.SWIPE_LEFT.value, "swipe_left")
|
||||
self.assertEqual(GestureType.SWIPE_RIGHT.value, "swipe_right")
|
||||
self.assertEqual(GestureType.SWIPE_UP.value, "swipe_up")
|
||||
self.assertEqual(GestureType.SWIPE_DOWN.value, "swipe_down")
|
||||
self.assertEqual(GestureType.PINCH_IN.value, "pinch_in")
|
||||
self.assertEqual(GestureType.PINCH_OUT.value, "pinch_out")
|
||||
self.assertEqual(GestureType.DRAG_START.value, "drag_start")
|
||||
self.assertEqual(GestureType.DRAG_MOVE.value, "drag_move")
|
||||
self.assertEqual(GestureType.DRAG_END.value, "drag_end")
|
||||
|
||||
|
||||
class TestTouchEvent(unittest.TestCase):
|
||||
"""Test TouchEvent dataclass"""
|
||||
|
||||
def test_init_basic(self):
|
||||
"""Test basic TouchEvent creation"""
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.TAP,
|
||||
x=450,
|
||||
y=320
|
||||
)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.TAP)
|
||||
self.assertEqual(event.x, 450)
|
||||
self.assertEqual(event.y, 320)
|
||||
self.assertIsNone(event.x2)
|
||||
self.assertIsNone(event.y2)
|
||||
self.assertEqual(event.timestamp_ms, 0)
|
||||
|
||||
def test_init_with_secondary_point(self):
|
||||
"""Test TouchEvent with secondary point (pinch/drag)"""
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.PINCH_OUT,
|
||||
x=400,
|
||||
y=300,
|
||||
x2=450,
|
||||
y2=350,
|
||||
timestamp_ms=12345.678
|
||||
)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
|
||||
self.assertEqual(event.x, 400)
|
||||
self.assertEqual(event.y, 300)
|
||||
self.assertEqual(event.x2, 450)
|
||||
self.assertEqual(event.y2, 350)
|
||||
self.assertEqual(event.timestamp_ms, 12345.678)
|
||||
|
||||
def test_from_hal_basic(self):
|
||||
"""Test parsing TouchEvent from HAL format"""
|
||||
hal_data = {
|
||||
'gesture': 'tap',
|
||||
'x': 450,
|
||||
'y': 320
|
||||
}
|
||||
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.TAP)
|
||||
self.assertEqual(event.x, 450)
|
||||
self.assertEqual(event.y, 320)
|
||||
|
||||
def test_from_hal_complete(self):
|
||||
"""Test parsing TouchEvent with all fields from HAL"""
|
||||
hal_data = {
|
||||
'gesture': 'pinch_out',
|
||||
'x': 400,
|
||||
'y': 300,
|
||||
'x2': 450,
|
||||
'y2': 350,
|
||||
'timestamp': 12345.678
|
||||
}
|
||||
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
|
||||
self.assertEqual(event.x, 400)
|
||||
self.assertEqual(event.y, 300)
|
||||
self.assertEqual(event.x2, 450)
|
||||
self.assertEqual(event.y2, 350)
|
||||
self.assertEqual(event.timestamp_ms, 12345.678)
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test TouchEvent serialization"""
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.SWIPE_LEFT,
|
||||
x=600,
|
||||
y=400,
|
||||
timestamp_ms=12345.0
|
||||
)
|
||||
|
||||
d = event.to_dict()
|
||||
|
||||
self.assertEqual(d['gesture'], 'swipe_left')
|
||||
self.assertEqual(d['x'], 600)
|
||||
self.assertEqual(d['y'], 400)
|
||||
self.assertIsNone(d['x2'])
|
||||
self.assertIsNone(d['y2'])
|
||||
self.assertEqual(d['timestamp_ms'], 12345.0)
|
||||
|
||||
|
||||
class TestGestureResponse(unittest.TestCase):
|
||||
"""Test GestureResponse dataclass"""
|
||||
|
||||
def test_init(self):
|
||||
"""Test GestureResponse creation"""
|
||||
response = GestureResponse(
|
||||
action="page_turn",
|
||||
data={"direction": "forward", "progress": 0.42}
|
||||
)
|
||||
|
||||
self.assertEqual(response.action, "page_turn")
|
||||
self.assertEqual(response.data['direction'], "forward")
|
||||
self.assertEqual(response.data['progress'], 0.42)
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test GestureResponse serialization"""
|
||||
response = GestureResponse(
|
||||
action="define",
|
||||
data={"word": "ephemeral", "bounds": (100, 200, 50, 20)}
|
||||
)
|
||||
|
||||
d = response.to_dict()
|
||||
|
||||
self.assertEqual(d['action'], "define")
|
||||
self.assertEqual(d['data']['word'], "ephemeral")
|
||||
self.assertEqual(d['data']['bounds'], (100, 200, 50, 20))
|
||||
|
||||
def test_to_dict_empty_data(self):
|
||||
"""Test GestureResponse with empty data"""
|
||||
response = GestureResponse(action="none", data={})
|
||||
|
||||
d = response.to_dict()
|
||||
|
||||
self.assertEqual(d['action'], "none")
|
||||
self.assertEqual(d['data'], {})
|
||||
|
||||
|
||||
class TestActionType(unittest.TestCase):
|
||||
"""Test ActionType constants"""
|
||||
|
||||
def test_action_types_defined(self):
|
||||
"""Test all action type constants are defined"""
|
||||
self.assertEqual(ActionType.NONE, "none")
|
||||
self.assertEqual(ActionType.PAGE_TURN, "page_turn")
|
||||
self.assertEqual(ActionType.NAVIGATE, "navigate")
|
||||
self.assertEqual(ActionType.DEFINE, "define")
|
||||
self.assertEqual(ActionType.SELECT, "select")
|
||||
self.assertEqual(ActionType.ZOOM, "zoom")
|
||||
self.assertEqual(ActionType.BOOK_LOADED, "book_loaded")
|
||||
self.assertEqual(ActionType.WORD_SELECTED, "word_selected")
|
||||
self.assertEqual(ActionType.SHOW_MENU, "show_menu")
|
||||
self.assertEqual(ActionType.SELECTION_START, "selection_start")
|
||||
self.assertEqual(ActionType.SELECTION_UPDATE, "selection_update")
|
||||
self.assertEqual(ActionType.SELECTION_COMPLETE, "selection_complete")
|
||||
self.assertEqual(ActionType.AT_START, "at_start")
|
||||
self.assertEqual(ActionType.AT_END, "at_end")
|
||||
self.assertEqual(ActionType.ERROR, "error")
|
||||
|
||||
|
||||
class TestHALIntegration(unittest.TestCase):
|
||||
"""Test HAL integration scenarios"""
|
||||
|
||||
def test_hal_tap_flow(self):
|
||||
"""Test complete HAL tap event flow"""
|
||||
# Simulate HAL sending tap event
|
||||
hal_data = {
|
||||
'gesture': 'tap',
|
||||
'x': 450,
|
||||
'y': 320,
|
||||
'timestamp': 1234567890.123
|
||||
}
|
||||
|
||||
# Parse event
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
# Verify event
|
||||
self.assertEqual(event.gesture, GestureType.TAP)
|
||||
self.assertEqual(event.x, 450)
|
||||
self.assertEqual(event.y, 320)
|
||||
|
||||
# Simulate business logic response
|
||||
response = GestureResponse(
|
||||
action=ActionType.WORD_SELECTED,
|
||||
data={"word": "hello", "bounds": (440, 310, 50, 20)}
|
||||
)
|
||||
|
||||
# Serialize for Flask
|
||||
response_dict = response.to_dict()
|
||||
|
||||
self.assertEqual(response_dict['action'], "word_selected")
|
||||
self.assertEqual(response_dict['data']['word'], "hello")
|
||||
|
||||
def test_hal_pinch_flow(self):
|
||||
"""Test complete HAL pinch event flow"""
|
||||
# Simulate HAL sending pinch event with two touch points
|
||||
hal_data = {
|
||||
'gesture': 'pinch_out',
|
||||
'x': 400,
|
||||
'y': 500,
|
||||
'x2': 500,
|
||||
'y2': 500,
|
||||
'timestamp': 1234567891.456
|
||||
}
|
||||
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
|
||||
self.assertEqual(event.x, 400)
|
||||
self.assertEqual(event.x2, 500)
|
||||
|
||||
def test_hal_swipe_flow(self):
|
||||
"""Test complete HAL swipe event flow"""
|
||||
hal_data = {
|
||||
'gesture': 'swipe_left',
|
||||
'x': 600,
|
||||
'y': 400
|
||||
}
|
||||
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.SWIPE_LEFT)
|
||||
|
||||
# Expected response
|
||||
response = GestureResponse(
|
||||
action=ActionType.PAGE_TURN,
|
||||
data={"direction": "forward", "progress": 0.25}
|
||||
)
|
||||
|
||||
self.assertEqual(response.action, "page_turn")
|
||||
|
||||
def test_hal_drag_selection_flow(self):
|
||||
"""Test complete drag selection flow"""
|
||||
# Drag start
|
||||
start_data = {
|
||||
'gesture': 'drag_start',
|
||||
'x': 100,
|
||||
'y': 200
|
||||
}
|
||||
|
||||
start_event = TouchEvent.from_hal(start_data)
|
||||
self.assertEqual(start_event.gesture, GestureType.DRAG_START)
|
||||
|
||||
# Drag move
|
||||
move_data = {
|
||||
'gesture': 'drag_move',
|
||||
'x': 300,
|
||||
'y': 250
|
||||
}
|
||||
|
||||
move_event = TouchEvent.from_hal(move_data)
|
||||
self.assertEqual(move_event.gesture, GestureType.DRAG_MOVE)
|
||||
|
||||
# Drag end
|
||||
end_data = {
|
||||
'gesture': 'drag_end',
|
||||
'x': 500,
|
||||
'y': 300
|
||||
}
|
||||
|
||||
end_event = TouchEvent.from_hal(end_data)
|
||||
self.assertEqual(end_event.gesture, GestureType.DRAG_END)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -1,832 +0,0 @@
|
||||
"""
|
||||
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 pyWebLayout.layout.ereader_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()
|
||||
@ -1,455 +0,0 @@
|
||||
"""
|
||||
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