remove application from library
Some checks failed
Python CI / test (push) Failing after 6m29s

This commit is contained in:
Duncan Tourolle 2025-11-07 18:48:36 +01:00
parent 6bb43db8d5
commit 33e2cbc363
21 changed files with 1747 additions and 2656 deletions

View File

@ -1,6 +1,6 @@
{
"chapter_index": 0,
"block_index": 27,
"block_index": 54,
"word_index": 0,
"table_row": 0,
"table_col": 0,

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
{
"document_id": "test",
"highlights": []
}

View File

@ -77,12 +77,16 @@ class LinkText(Text, Interactable, Queriable):
return None
return self._callback() # Don't pass the point to the callback
def render(self):
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Render the link text with optional hover effects.
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
"""
# Call the parent Text render method
super().render()
# Call the parent Text render method with parameters
super().render(next_text, spacing)
# Add hover effect if needed
if self._hovered:

View File

@ -3,6 +3,7 @@ import numpy as np
from PIL import Image, ImageDraw
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Alignment
from .box import Box
@ -264,24 +265,41 @@ class Page(Renderable, Queriable):
# Default to origin
return (0, 0)
def query_point(self, point: Tuple[int, int]) -> Optional[Renderable]:
def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]:
"""
Query a point to determine which child it belongs to.
Query a point to find the deepest object at that location.
Traverses children and uses Queriable.in_object() for hit-testing.
Args:
point: The (x, y) coordinates to query
Returns:
The child object that contains the point, or None if no child contains it
QueryResult with metadata about what was found, or None if nothing hit
"""
point_array = np.array(point)
# Check each child (in reverse order so topmost child is found first)
for child in reversed(self._children):
if self._point_in_child(point_array, child):
return child
# Use Queriable mixin's in_object() for hit-testing
if isinstance(child, Queriable) and child.in_object(point_array):
# If child can also query (has children of its own), recurse
if hasattr(child, 'query_point'):
result = child.query_point(point)
if result:
result.parent_page = self
return result
# If child's query returned None, continue to next child
continue
return None
# Otherwise, package this child as the result
return self._make_query_result(child, point)
# Nothing hit - return empty result
return QueryResult(
object=self,
object_type="empty",
bounds=(int(point[0]), int(point[1]), 0, 0)
)
def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
"""
@ -337,6 +355,104 @@ class Page(Renderable, Queriable):
return None
def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult:
"""
Package an object into a QueryResult with metadata.
Args:
obj: The object to package
point: The query point
Returns:
QueryResult with extracted metadata
"""
from .text import Text
from .functional import LinkText, ButtonText
# Extract bounds
origin = getattr(obj, '_origin', np.array([0, 0]))
size = getattr(obj, 'size', np.array([0, 0]))
bounds = (
int(origin[0]),
int(origin[1]),
int(size[0]) if hasattr(size, '__getitem__') else 0,
int(size[1]) if hasattr(size, '__getitem__') else 0
)
# Determine type and extract metadata
if isinstance(obj, LinkText):
return QueryResult(
object=obj,
object_type="link",
bounds=bounds,
text=obj._text,
is_interactive=True,
link_target=obj._link.location if hasattr(obj, '_link') else None
)
elif isinstance(obj, ButtonText):
return QueryResult(
object=obj,
object_type="button",
bounds=bounds,
text=obj._text,
is_interactive=True,
callback=obj._callback if hasattr(obj, '_callback') else None
)
elif isinstance(obj, Text):
return QueryResult(
object=obj,
object_type="text",
bounds=bounds,
text=obj._text if hasattr(obj, '_text') else None
)
else:
return QueryResult(
object=obj,
object_type="unknown",
bounds=bounds
)
def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange:
"""
Query all text objects between two points (for text selection).
Uses Queriable.in_object() to determine which objects are in range.
Args:
start: Starting (x, y) point
end: Ending (x, y) point
Returns:
SelectionRange with all text objects between the points
"""
results = []
in_selection = False
start_result = self.query_point(start)
end_result = self.query_point(end)
if not start_result or not end_result:
return SelectionRange(start, end, [])
# Walk through all children (Lines) and their text objects
from .text import Line, Text
for child in self._children:
if isinstance(child, Line) and hasattr(child, '_text_objects'):
for text_obj in child._text_objects:
# Check if this text is the start or is between start and end
if text_obj == start_result.object:
in_selection = True
if in_selection and isinstance(text_obj, Text):
result = self._make_query_result(text_obj, start)
results.append(result)
if text_obj == end_result.object:
in_selection = False
break
return SelectionRange(start, end, results)
def in_object(self, point: Tuple[int, int]) -> bool:
"""
Check if a point is within this page's bounds.

View File

@ -560,3 +560,68 @@ class Line(Box):
# Render with next text information for continuous underline/strikethrough
text.render(next_text, self._spacing_render)
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
"""
Find which Text object contains the given point.
Uses Queriable.in_object() mixin for hit-testing.
Args:
point: (x, y) coordinates to query
Returns:
QueryResult from the text object at that point, or None
"""
from pyWebLayout.core.query import QueryResult
from .functional import LinkText, ButtonText
point_array = np.array(point)
# Check each text object in this line
for text_obj in self._text_objects:
# Use Queriable mixin's in_object() for hit-testing
if isinstance(text_obj, Queriable) and text_obj.in_object(point_array):
# Extract metadata based on text type
origin = text_obj._origin
size = text_obj.size
# Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
# Bounds should be (x, visual_top, width, height) for proper highlighting
visual_top = int(origin[1] - text_obj._ascent)
bounds = (
int(origin[0]),
visual_top,
int(size[0]) if hasattr(size, '__getitem__') else 0,
int(size[1]) if hasattr(size, '__getitem__') else 0
)
if isinstance(text_obj, LinkText):
result = QueryResult(
object=text_obj,
object_type="link",
bounds=bounds,
text=text_obj._text,
is_interactive=True,
link_target=text_obj._link.location if hasattr(text_obj, '_link') else None
)
elif isinstance(text_obj, ButtonText):
result = QueryResult(
object=text_obj,
object_type="button",
bounds=bounds,
text=text_obj._text,
is_interactive=True,
callback=text_obj._callback if hasattr(text_obj, '_callback') else None
)
else:
result = QueryResult(
object=text_obj,
object_type="text",
bounds=bounds,
text=text_obj._text if hasattr(text_obj, '_text') else None
)
result.parent_line = self
return result
return None

View File

@ -1,8 +1,12 @@
from abc import ABC
from typing import Optional, Tuple, List, TYPE_CHECKING
import numpy as np
from pyWebLayout.style.alignment import Alignment
if TYPE_CHECKING:
from pyWebLayout.core.query import QueryResult
class Renderable(ABC):
"""
@ -17,6 +21,9 @@ class Renderable(ABC):
PIL.Image: The rendered image
"""
pass
@property
def origin(self):
return self._origin
class Interactable(ABC):
"""

View 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
View 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
View 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"

View File

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

View File

@ -22,7 +22,7 @@ class SimpleTestRenderable(Renderable, Queriable):
def __init__(self, text: str, size: tuple = (100, 50)):
self._text = text
self.size = size
self.origin = np.array([0, 0])
self._origin = np.array([0, 0])
def render(self):
"""Render returns None - drawing is done via the page's draw object"""
@ -154,16 +154,19 @@ class TestPageImplementation(unittest.TestCase):
# Query points
# Point within first child
found_child = page.query_point((90, 30))
self.assertEqual(found_child, child1)
result = page.query_point((90, 30))
self.assertIsNotNone(result)
self.assertEqual(result.object, child1)
# Point within second child
found_child = page.query_point((30, 30))
self.assertEqual(found_child, child2)
result = page.query_point((30, 30))
self.assertIsNotNone(result)
self.assertEqual(result.object, child2)
# Point outside any child
found_child = page.query_point((300, 250))
self.assertIsNone(found_child)
# Point outside any child - returns QueryResult with object_type "empty"
result = page.query_point((300, 250))
self.assertIsNotNone(result)
self.assertEqual(result.object_type, "empty")
def test_page_in_object(self):
"""Test that page correctly implements in_object"""

1
tests/core/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Tests for core pyWebLayout functionality."""

View 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()

View 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()

View File

@ -0,0 +1 @@
"""Tests for I/O functionality."""

View 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()

View File

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

View File

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