This commit is contained in:
parent
6bb43db8d5
commit
33e2cbc363
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"chapter_index": 0,
|
"chapter_index": 0,
|
||||||
"block_index": 27,
|
"block_index": 54,
|
||||||
"word_index": 0,
|
"word_index": 0,
|
||||||
"table_row": 0,
|
"table_row": 0,
|
||||||
"table_col": 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 None
|
||||||
return self._callback() # Don't pass the point to the callback
|
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.
|
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
|
# Call the parent Text render method with parameters
|
||||||
super().render()
|
super().render(next_text, spacing)
|
||||||
|
|
||||||
# Add hover effect if needed
|
# Add hover effect if needed
|
||||||
if self._hovered:
|
if self._hovered:
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import numpy as np
|
|||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
|
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.page_style import PageStyle
|
||||||
from pyWebLayout.style import Alignment
|
from pyWebLayout.style import Alignment
|
||||||
from .box import Box
|
from .box import Box
|
||||||
@ -264,24 +265,41 @@ class Page(Renderable, Queriable):
|
|||||||
# Default to origin
|
# Default to origin
|
||||||
return (0, 0)
|
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:
|
Args:
|
||||||
point: The (x, y) coordinates to query
|
point: The (x, y) coordinates to query
|
||||||
|
|
||||||
Returns:
|
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)
|
point_array = np.array(point)
|
||||||
|
|
||||||
# Check each child (in reverse order so topmost child is found first)
|
# Check each child (in reverse order so topmost child is found first)
|
||||||
for child in reversed(self._children):
|
for child in reversed(self._children):
|
||||||
if self._point_in_child(point_array, child):
|
# Use Queriable mixin's in_object() for hit-testing
|
||||||
return child
|
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:
|
def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
|
||||||
"""
|
"""
|
||||||
@ -337,6 +355,104 @@ class Page(Renderable, Queriable):
|
|||||||
|
|
||||||
return None
|
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:
|
def in_object(self, point: Tuple[int, int]) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a point is within this page's bounds.
|
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
|
# Render with next text information for continuous underline/strikethrough
|
||||||
text.render(next_text, self._spacing_render)
|
text.render(next_text, self._spacing_render)
|
||||||
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
|
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 abc import ABC
|
||||||
|
from typing import Optional, Tuple, List, TYPE_CHECKING
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from pyWebLayout.style.alignment import Alignment
|
from pyWebLayout.style.alignment import Alignment
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyWebLayout.core.query import QueryResult
|
||||||
|
|
||||||
|
|
||||||
class Renderable(ABC):
|
class Renderable(ABC):
|
||||||
"""
|
"""
|
||||||
@ -17,6 +21,9 @@ class Renderable(ABC):
|
|||||||
PIL.Image: The rendered image
|
PIL.Image: The rendered image
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
@property
|
||||||
|
def origin(self):
|
||||||
|
return self._origin
|
||||||
|
|
||||||
class Interactable(ABC):
|
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)
|
|
||||||
@ -18,11 +18,11 @@ from pyWebLayout.core.base import Renderable, Queriable
|
|||||||
|
|
||||||
class SimpleTestRenderable(Renderable, Queriable):
|
class SimpleTestRenderable(Renderable, Queriable):
|
||||||
"""A simple test renderable for testing the page system"""
|
"""A simple test renderable for testing the page system"""
|
||||||
|
|
||||||
def __init__(self, text: str, size: tuple = (100, 50)):
|
def __init__(self, text: str, size: tuple = (100, 50)):
|
||||||
self._text = text
|
self._text = text
|
||||||
self.size = size
|
self.size = size
|
||||||
self.origin = np.array([0, 0])
|
self._origin = np.array([0, 0])
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
"""Render returns None - drawing is done via the page's draw object"""
|
"""Render returns None - drawing is done via the page's draw object"""
|
||||||
@ -145,25 +145,28 @@ class TestPageImplementation(unittest.TestCase):
|
|||||||
def test_page_query_point(self):
|
def test_page_query_point(self):
|
||||||
"""Test querying points to find children"""
|
"""Test querying points to find children"""
|
||||||
page = Page(size=(400, 300))
|
page = Page(size=(400, 300))
|
||||||
|
|
||||||
# Add children with known positions and sizes
|
# Add children with known positions and sizes
|
||||||
child1 = SimpleTestRenderable("Child 1", (100, 50))
|
child1 = SimpleTestRenderable("Child 1", (100, 50))
|
||||||
child2 = SimpleTestRenderable("Child 2", (80, 40))
|
child2 = SimpleTestRenderable("Child 2", (80, 40))
|
||||||
|
|
||||||
page.add_child(child1).add_child(child2)
|
page.add_child(child1).add_child(child2)
|
||||||
|
|
||||||
# Query points
|
# Query points
|
||||||
# Point within first child
|
# Point within first child
|
||||||
found_child = page.query_point((90, 30))
|
result = page.query_point((90, 30))
|
||||||
self.assertEqual(found_child, child1)
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result.object, child1)
|
||||||
|
|
||||||
# Point within second child
|
# Point within second child
|
||||||
found_child = page.query_point((30, 30))
|
result = page.query_point((30, 30))
|
||||||
self.assertEqual(found_child, child2)
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result.object, child2)
|
||||||
# Point outside any child
|
|
||||||
found_child = page.query_point((300, 250))
|
# Point outside any child - returns QueryResult with object_type "empty"
|
||||||
self.assertIsNone(found_child)
|
result = page.query_point((300, 250))
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result.object_type, "empty")
|
||||||
|
|
||||||
def test_page_in_object(self):
|
def test_page_in_object(self):
|
||||||
"""Test that page correctly implements in_object"""
|
"""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