From 25e7f1423eb6578aeaa36c5aaa5013bd98a7ce1d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 4 Nov 2025 18:39:38 +0100 Subject: [PATCH] ereader module --- .../epub_page_renderer_documentlayouter.py | 380 +++++++++++ examples/ereader_demo.py | 329 +++++++++ examples/simple_ereader_example.py | 96 +++ pyWebLayout/concrete/page.py | 5 + pyWebLayout/layout/README_EREADER_API.md | 207 ++++++ pyWebLayout/layout/ereader_application.py | 632 ++++++++++++++++++ pyWebLayout/layout/ereader_layout.py | 12 +- 7 files changed, 1658 insertions(+), 3 deletions(-) create mode 100644 examples/epub_page_renderer_documentlayouter.py create mode 100644 examples/ereader_demo.py create mode 100644 examples/simple_ereader_example.py create mode 100644 pyWebLayout/layout/README_EREADER_API.md create mode 100644 pyWebLayout/layout/ereader_application.py diff --git a/examples/epub_page_renderer_documentlayouter.py b/examples/epub_page_renderer_documentlayouter.py new file mode 100644 index 0000000..217f9eb --- /dev/null +++ b/examples/epub_page_renderer_documentlayouter.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +""" +EPUB page renderer using DocumentLayouter. + +This tool uses pyWebLayout's DocumentLayouter to render EPUB content: +1. Load an EPUB file +2. Extract all blocks (paragraphs, images, etc.) +3. Use DocumentLayouter to layout blocks on pages +4. Save the pages as PNG images + +Usage: + python epub_page_renderer_documentlayouter.py book.epub --pages 5 --output-dir rendered_pages +""" + +import os +import sys +import argparse +from pathlib import Path +from typing import Optional, List, Tuple + +# Add the parent directory to sys.path to import pyWebLayout +sys.path.insert(0, str(Path(__file__).parent.parent)) + +try: + from pyWebLayout.io.readers.epub_reader import read_epub + from pyWebLayout.layout.document_layouter import DocumentLayouter, paragraph_layouter, image_layouter + from pyWebLayout.concrete.page import Page + from pyWebLayout.style.page_style import PageStyle + from pyWebLayout.style.fonts import Font + from pyWebLayout.style.alignment import Alignment + from pyWebLayout.abstract.block import Block, Paragraph, Heading, HList, Table, Image as AbstractImage + from pyWebLayout.style.concrete_style import RenderingContext, StyleResolver + from PIL import Image, ImageDraw +except ImportError as e: + print(f"Error importing required modules: {e}") + print("Make sure pyWebLayout is properly installed and PIL is available") + sys.exit(1) + + +def get_all_blocks_from_book(book) -> List[Block]: + """ + Extract all blocks from all chapters in the book. + + Args: + book: The Book object from epub_reader + + Returns: + List of all Block objects + """ + all_blocks = [] + + # Iterate through all chapters + for chapter in book.chapters: + # Get blocks from the chapter + if hasattr(chapter, '_blocks'): + all_blocks.extend(chapter._blocks) + + return all_blocks + + +def create_page(page_style: PageStyle, page_size: Tuple[int, int]) -> Page: + """ + Create a new Page with the given style and size. + + Args: + page_style: Style configuration for the page + page_size: (width, height) tuple + + Returns: + A new Page object + """ + page = Page( + size=page_size, + style=page_style + ) + + return page + + +def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle, + page_size: Tuple[int, int], max_pages: int, + alignment_override: Optional[Alignment] = None) -> List[Page]: + """ + Layout blocks across multiple pages using DocumentLayouter. + + Args: + blocks: List of abstract blocks to layout + page_style: Style configuration for pages + page_size: (width, height) tuple for pages + max_pages: Maximum number of pages to generate + alignment_override: Optional alignment to override paragraph alignment + + Returns: + List of rendered Page objects + """ + pages = [] + current_block_index = 0 + continuation_word_index = 0 + continuation_pretext = None + + # Create rendering context + rendering_context = RenderingContext(base_font_size=16) + + while current_block_index < len(blocks) and len(pages) < max_pages: + # Create a new page + page = create_page(page_style, page_size) + page_has_content = False + + # Try to layout blocks on this page + while current_block_index < len(blocks): + block = blocks[current_block_index] + + if isinstance(block, (Paragraph, Heading)): + # Layout paragraph/heading + success, failed_word_index, remaining_pretext = paragraph_layouter( + block, + page, + start_word=continuation_word_index, + pretext=continuation_pretext, + alignment_override=alignment_override + ) + + if success: + # Block fully laid out, move to next block + page_has_content = True + current_block_index += 1 + continuation_word_index = 0 + continuation_pretext = None + else: + # Block partially laid out or page is full + if failed_word_index is not None: + # Partial layout - continue on next page + page_has_content = True + continuation_word_index = failed_word_index + continuation_pretext = remaining_pretext + # Break to create a new page + break + + elif isinstance(block, AbstractImage): + # Layout image + success = image_layouter(block, page) + + if success: + page_has_content = True + current_block_index += 1 + continuation_word_index = 0 + continuation_pretext = None + else: + # Image doesn't fit, try on next page + break + + elif isinstance(block, HList): + # Layout list items as paragraphs + try: + list_items = list(block.items()) + for item in list_items: + if isinstance(item, Paragraph): + success, failed_word_index, remaining_pretext = paragraph_layouter( + item, + page, + start_word=continuation_word_index, + pretext=continuation_pretext, + alignment_override=alignment_override + ) + + if not success: + # Can't fit more on this page + page_has_content = True + break + + continuation_word_index = 0 + continuation_pretext = None + + # Move to next block after processing list + page_has_content = True + current_block_index += 1 + + except Exception as e: + print(f"Warning: Error processing list: {e}") + current_block_index += 1 + + elif isinstance(block, Table): + # Skip tables for now (not implemented) + print(f"Warning: Skipping table (not yet implemented)") + current_block_index += 1 + + else: + # Unknown block type, skip + print(f"Warning: Skipping unknown block type: {type(block).__name__}") + current_block_index += 1 + + # Add page if it has content + if page_has_content: + pages.append(page) + else: + # No content could be added to this page, stop + break + + return pages + + +def main(): + """Main function to handle command line arguments and process the EPUB.""" + parser = argparse.ArgumentParser( + description='Render EPUB pages using DocumentLayouter', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python epub_page_renderer_documentlayouter.py book.epub --pages 5 + python epub_page_renderer_documentlayouter.py book.epub --pages 10 --output-dir my_output --width 600 --height 800 + """ + ) + + parser.add_argument( + 'epub_file', + help='Path to the EPUB file to render' + ) + + parser.add_argument( + '--pages', '-p', + type=int, + default=5, + help='Number of pages to render (default: 5)' + ) + + parser.add_argument( + '--output-dir', '-o', + default='rendered_pages', + help='Output directory for rendered images (default: rendered_pages)' + ) + + parser.add_argument( + '--width', '-w', + type=int, + default=800, + help='Page width in pixels (default: 800)' + ) + + parser.add_argument( + '--height', '-t', + type=int, + default=1000, + help='Page height in pixels (default: 1000)' + ) + + parser.add_argument( + '--margin', '-m', + type=int, + default=40, + help='Page margin in pixels (default: 40)' + ) + + parser.add_argument( + '--align', '-a', + choices=['left', 'justify'], + default='left', + help='Text alignment: left or justify (default: left)' + ) + + args = parser.parse_args() + + # Validate arguments + if not os.path.exists(args.epub_file): + print(f"Error: EPUB file '{args.epub_file}' not found") + return 1 + + if args.pages <= 0: + print("Error: Number of pages must be positive") + return 1 + + # Create output directory + try: + os.makedirs(args.output_dir, exist_ok=True) + except OSError as e: + print(f"Error creating output directory: {e}") + return 1 + + print(f"Loading EPUB file: {args.epub_file}") + + # Load the EPUB file + try: + book = read_epub(args.epub_file) + print(f"Successfully loaded EPUB: {book.get_title() or 'Unknown Title'}") + + # Print book information + author = book.get_metadata('AUTHOR') + if author: + print(f"Author: {author}") + + print(f"Chapters: {len(book.chapters) if hasattr(book, 'chapters') else 'Unknown'}") + + except Exception as e: + print(f"Error loading EPUB file: {e}") + import traceback + traceback.print_exc() + return 1 + + # Extract all blocks from the book + print("Extracting content blocks...") + try: + all_blocks = get_all_blocks_from_book(book) + print(f"Extracted {len(all_blocks)} content blocks") + + if not all_blocks: + print("No content blocks found in EPUB. The book might be empty.") + return 1 + + except Exception as e: + print(f"Error extracting blocks: {e}") + import traceback + traceback.print_exc() + return 1 + + # Set up page style + page_size = (args.width, args.height) + page_style = PageStyle( + background_color=(255, 255, 255), + border_width=args.margin, + border_color=(200, 200, 200), + padding=(10, 10, 10, 10), # top, right, bottom, left + line_spacing=5, + inter_block_spacing=15 + ) + + # Set alignment + alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT + print(f"Setting up layouter with page size {page_size} and {args.align} alignment") + + # Layout blocks on pages + print(f"Rendering up to {args.pages} pages using DocumentLayouter...") + + try: + pages = layout_blocks_on_pages( + all_blocks, + page_style, + page_size, + args.pages, + alignment_override=alignment + ) + + if not pages: + print("No pages were generated.") + return 1 + + print(f"Generated {len(pages)} pages") + + # Save each page to an image + for i, page in enumerate(pages): + print(f"Saving page {i + 1}/{len(pages)}...") + + try: + # Render the page + image = page.render() + + # Save the image + output_filename = f"page_{i + 1:03d}.png" + output_path = os.path.join(args.output_dir, output_filename) + image.save(output_path, 'PNG') + + print(f"Saved: {output_path}") + + except Exception as e: + print(f"Error saving page {i + 1}: {e}") + import traceback + traceback.print_exc() + continue + + print(f"\nCompleted! Rendered {len(pages)} pages to {args.output_dir}") + + except Exception as e: + print(f"Error during pagination/rendering: {e}") + import traceback + traceback.print_exc() + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/ereader_demo.py b/examples/ereader_demo.py new file mode 100644 index 0000000..4afdc8e --- /dev/null +++ b/examples/ereader_demo.py @@ -0,0 +1,329 @@ +#!/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(" - test.epub (if available in project root)") + print(" - test2.epub (if available in project root)") + sys.exit(1) + + epub_path = sys.argv[1] + + if not os.path.exists(epub_path): + print(f"Error: File not found: {epub_path}") + sys.exit(1) + + print("="*70) + print(" EbookReader Demo - pyWebLayout") + print("="*70) + print(f"\nLoading EPUB: {epub_path}") + + # Create reader with context manager + with EbookReader(page_size=(800, 1000)) as reader: + # Load the EPUB + if not reader.load_epub(epub_path): + print("Error: Failed to load EPUB file") + sys.exit(1) + + print("✓ EPUB loaded successfully") + + # Run all demos + try: + demo_basic_navigation(reader) + demo_position_management(reader) + demo_chapter_navigation(reader) + demo_font_size_adjustment(reader) + demo_spacing_adjustment(reader) + demo_book_information(reader) + + print("\n" + "="*70) + print(" Demo Complete!") + print("="*70) + print("\nGenerated demo images:") + demo_files = [ + "demo_page_001.png", + "demo_page_002.png", + "demo_restored_position.png", + "demo_chapter_1.png", + "demo_font_normal.png", + "demo_font_larger.png", + "demo_font_largest.png", + "demo_spacing_default.png", + "demo_spacing_lines_10.png", + "demo_spacing_blocks_25.png" + ] + + for filename in demo_files: + if os.path.exists(filename): + print(f" ✓ {filename}") + + print("\nAll features demonstrated successfully!") + + except Exception as e: + print(f"\nError during demo: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/simple_ereader_example.py b/examples/simple_ereader_example.py new file mode 100644 index 0000000..8ea1a16 --- /dev/null +++ b/examples/simple_ereader_example.py @@ -0,0 +1,96 @@ +#!/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() diff --git a/pyWebLayout/concrete/page.py b/pyWebLayout/concrete/page.py index 1fc56b8..c75d704 100644 --- a/pyWebLayout/concrete/page.py +++ b/pyWebLayout/concrete/page.py @@ -91,6 +91,11 @@ class Page(Renderable, Queriable): """Get the border width""" return self._style.border_width + @property + def available_width(self) -> int: + """Get the available width for content (content area width)""" + return self.content_size[0] + @property def style(self) -> PageStyle: """Get the page style""" diff --git a/pyWebLayout/layout/README_EREADER_API.md b/pyWebLayout/layout/README_EREADER_API.md new file mode 100644 index 0000000..9583dc2 --- /dev/null +++ b/pyWebLayout/layout/README_EREADER_API.md @@ -0,0 +1,207 @@ +# EbookReader API Quick Reference + +This is a quick reference for the `EbookReader` class API. + +## Import + +```python +from pyWebLayout.layout.ereader_application import EbookReader +``` + +## Constructor + +```python +EbookReader( + page_size=(800, 1000), # Page dimensions (width, height) + margin=40, # Page margin in pixels + background_color=(255, 255, 255), # Background RGB color + line_spacing=5, # Line spacing in pixels + inter_block_spacing=15, # Block spacing in pixels + bookmarks_dir="ereader_bookmarks", # Bookmark directory + buffer_size=5 # Number of pages to cache +) +``` + +## Loading & Book Info + +| Method | Returns | Description | +|--------|---------|-------------| +| `load_epub(path)` | `bool` | Load an EPUB file | +| `is_loaded()` | `bool` | Check if book is loaded | +| `get_book_info()` | `dict` | Get book metadata and stats | + +## Navigation + +| Method | Returns | Description | +|--------|---------|-------------| +| `get_current_page()` | `PIL.Image` | Get current page as image | +| `next_page()` | `PIL.Image` or `None` | Navigate forward | +| `previous_page()` | `PIL.Image` or `None` | Navigate backward | +| `render_to_file(path)` | `bool` | Save current page to file | + +## Position Management + +| Method | Returns | Description | +|--------|---------|-------------| +| `save_position(name)` | `bool` | Save current position | +| `load_position(name)` | `PIL.Image` or `None` | Load saved position | +| `list_saved_positions()` | `List[str]` | List all saved positions | +| `delete_position(name)` | `bool` | Delete a saved position | +| `get_position_info()` | `dict` | Get detailed position info | +| `get_reading_progress()` | `float` | Get progress (0.0-1.0) | + +## Chapter Navigation + +| Method | Returns | Description | +|--------|---------|-------------| +| `get_chapters()` | `List[Tuple[str, int]]` | Get list of chapters | +| `get_chapter_positions()` | `List[Tuple[str, RenderingPosition]]` | Get chapters with positions | +| `jump_to_chapter(chapter)` | `PIL.Image` or `None` | Jump to chapter by title or index | +| `get_current_chapter_info()` | `dict` or `None` | Get current chapter info | + +## Font & Styling + +| Method | Returns | Description | +|--------|---------|-------------| +| `get_font_size()` | `float` | Get current font scale | +| `set_font_size(scale)` | `PIL.Image` | Set font scale (0.5-3.0) | +| `increase_font_size()` | `PIL.Image` | Increase font by 10% | +| `decrease_font_size()` | `PIL.Image` | Decrease font by 10% | +| `set_line_spacing(pixels)` | `PIL.Image` | Set line spacing | +| `set_inter_block_spacing(pixels)` | `PIL.Image` | Set block spacing | + +## Cleanup + +| Method | Returns | Description | +|--------|---------|-------------| +| `close()` | `None` | Close reader and save position | + +## Context Manager + +```python +with EbookReader() as reader: + reader.load_epub("book.epub") + # ... use reader +# Automatically closes and saves position +``` + +## Minimal Example + +```python +from pyWebLayout.layout.ereader_application import EbookReader + +# Create and load +reader = EbookReader(page_size=(800, 1000)) +reader.load_epub("book.epub") + +# Navigate +page = reader.get_current_page() +page.save("page1.png") +reader.next_page() + +# Save position +reader.save_position("bookmark1") + +# Close +reader.close() +``` + +## Common Patterns + +### Reading through a book + +```python +with EbookReader() as reader: + reader.load_epub("book.epub") + + page_num = 1 + while True: + page = reader.get_current_page() + if not page: + break + + page.save(f"page_{page_num:03d}.png") + + if not reader.next_page(): + break # End of book + + page_num += 1 +``` + +### Chapter-based navigation + +```python +reader = EbookReader() +reader.load_epub("book.epub") + +# List chapters +chapters = reader.get_chapters() +for title, idx in chapters: + print(f"Chapter {idx}: {title}") + +# Jump to chapter +reader.jump_to_chapter(2) # By index +reader.jump_to_chapter("Chapter 3") # By title +``` + +### Font size adjustment + +```python +reader = EbookReader() +reader.load_epub("book.epub") + +# Simple increase/decrease +reader.increase_font_size() +reader.decrease_font_size() + +# Or set specific scale +reader.set_font_size(1.5) # 150% size +``` + +### Position persistence + +```python +# Save multiple bookmarks +reader.save_position("chapter_1_start") +reader.next_page() +reader.next_page() +reader.save_position("interesting_quote") + +# List all bookmarks +bookmarks = reader.list_saved_positions() + +# Load a bookmark +reader.load_position("chapter_1_start") + +# Delete a bookmark +reader.delete_position("old_bookmark") +``` + +## Position Format + +Positions are stored as JSON in `{bookmarks_dir}/{document_id}_bookmarks.json`: + +```json +{ + "chapter_index": 0, + "block_index": 42, + "word_index": 15, + "table_row": 0, + "table_col": 0, + "list_item_index": 0, + "remaining_pretext": null, + "page_y_offset": 0 +} +``` + +Positions remain valid across: +- Font size changes +- Line spacing changes +- Page size changes +- Block spacing changes + +## See Also + +- `examples/README_EREADER.md` - Full documentation with examples +- `examples/simple_ereader_example.py` - Simple usage example +- `examples/ereader_demo.py` - Comprehensive feature demo diff --git a/pyWebLayout/layout/ereader_application.py b/pyWebLayout/layout/ereader_application.py new file mode 100644 index 0000000..5f30095 --- /dev/null +++ b/pyWebLayout/layout/ereader_application.py @@ -0,0 +1,632 @@ +#!/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) diff --git a/pyWebLayout/layout/ereader_layout.py b/pyWebLayout/layout/ereader_layout.py index dd32b85..90247a9 100644 --- a/pyWebLayout/layout/ereader_layout.py +++ b/pyWebLayout/layout/ereader_layout.py @@ -298,9 +298,15 @@ class BidirectionalLayouter: # This is a simplified implementation # In practice, we'd need to handle each block type appropriately - if isinstance(block, Paragraph): - scaled_block = Paragraph(FontScaler.scale_font(block.style, font_scale)) - for word in block.words_iter(): + if isinstance(block, (Paragraph, Heading)): + scaled_block_style = FontScaler.scale_font(block.style, font_scale) + if isinstance(block, Heading): + scaled_block = Heading(block.level, scaled_block_style) + else: + scaled_block = Paragraph(scaled_block_style) + + # words_iter() returns tuples of (position, word) + for position, word in block.words_iter(): if isinstance(word, Word): scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale)) scaled_block.add_word(scaled_word)