#!/usr/bin/env python3 """ Basic EPUB Reader with Pagination using pyWebLayout This reader loads EPUB files and displays them with page-by-page navigation using the pyWebLayout system. It follows the proper architecture where: - EPUBReader loads EPUB files into Document/Chapter objects - Page renders those abstract objects into visual pages - The UI handles pagination and navigation """ import tkinter as tk from tkinter import ttk, filedialog, messagebox import os from typing import List, Optional from PIL import Image, ImageTk from pyWebLayout.io.readers.epub_reader import EPUBReader from pyWebLayout.concrete.page import Page from pyWebLayout.style.fonts import Font from pyWebLayout.abstract.document import Document, Chapter, Book from pyWebLayout.io.readers.html_extraction import parse_html_string class EPUBReaderApp: """Main EPUB reader application using Tkinter""" def __init__(self): self.root = tk.Tk() self.root.title("pyWebLayout EPUB Reader") self.root.geometry("900x700") # Application state self.current_epub: Optional[EPUBReader] = None self.current_document: Optional[Document] = None self.rendered_pages: List[Page] = [] self.current_page_index = 0 # Page settings self.page_width = 700 self.page_height = 550 self.blocks_per_page = 3 # Fewer blocks per page for better readability self.setup_ui() def setup_ui(self): """Setup the user interface""" # Create main frame main_frame = ttk.Frame(self.root) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Top control frame control_frame = ttk.Frame(main_frame) control_frame.pack(fill=tk.X, pady=(0, 10)) # File operations self.open_btn = ttk.Button(control_frame, text="Open EPUB", command=self.open_epub) self.open_btn.pack(side=tk.LEFT, padx=(0, 10)) # Book info self.book_info_label = ttk.Label(control_frame, text="No book loaded") self.book_info_label.pack(side=tk.LEFT, expand=True) # Navigation frame nav_frame = ttk.Frame(main_frame) nav_frame.pack(fill=tk.X, pady=(0, 10)) # Navigation buttons self.prev_btn = ttk.Button(nav_frame, text="◀ Previous", command=self.previous_page, state=tk.DISABLED) self.prev_btn.pack(side=tk.LEFT, padx=(0, 10)) self.next_btn = ttk.Button(nav_frame, text="Next ▶", command=self.next_page, state=tk.DISABLED) self.next_btn.pack(side=tk.LEFT, padx=(0, 10)) # Page info self.page_info_label = ttk.Label(nav_frame, text="Page 0 of 0") self.page_info_label.pack(side=tk.LEFT, padx=(20, 0)) # Chapter selector ttk.Label(nav_frame, text="Chapter:").pack(side=tk.LEFT, padx=(20, 5)) self.chapter_var = tk.StringVar() self.chapter_combo = ttk.Combobox(nav_frame, textvariable=self.chapter_var, state="readonly", width=30) self.chapter_combo.pack(side=tk.LEFT, padx=(0, 10)) self.chapter_combo.bind('<>', self.on_chapter_selected) # Content frame with canvas content_frame = ttk.Frame(main_frame) content_frame.pack(fill=tk.BOTH, expand=True) # Create canvas for page display self.canvas = tk.Canvas(content_frame, bg='white', width=self.page_width, height=self.page_height) self.canvas.pack(expand=True) # Status bar self.status_var = tk.StringVar(value="Ready - Open an EPUB file to begin") status_bar = ttk.Label(main_frame, textvariable=self.status_var, relief=tk.SUNKEN) status_bar.pack(fill=tk.X, pady=(10, 0)) # Bind keyboard shortcuts self.root.bind('', lambda e: self.previous_page()) self.root.bind('', lambda e: self.next_page()) self.root.bind('', lambda e: self.next_page()) self.root.focus_set() # Allow keyboard input def open_epub(self): """Open and load an EPUB file""" file_path = filedialog.askopenfilename( title="Open EPUB File", filetypes=[("EPUB files", "*.epub"), ("All files", "*.*")] ) if file_path: self.load_epub(file_path) def load_epub(self, file_path: str): """Load an EPUB file and prepare for display""" try: self.status_var.set("Loading EPUB file...") self.root.update() # Load the EPUB using the EPUBReader self.current_epub = EPUBReader(file_path) # Get the document structure from the EPUB self.current_document = self.current_epub.read() # Update book info if isinstance(self.current_document, Book): title = self.current_document.get_title() or "Unknown Title" author = self.current_document.get_author() or "Unknown Author" self.book_info_label.config(text=f"{title} by {author}") else: title = getattr(self.current_document, 'title', 'Unknown Title') self.book_info_label.config(text=title) # Populate chapter list self.populate_chapter_list() # Create pages from the document self.create_pages_from_document() # Show first page self.current_page_index = 0 self.display_current_page() self.update_navigation() self.status_var.set(f"Loaded: {os.path.basename(file_path)} - {len(self.rendered_pages)} pages") except Exception as e: self.status_var.set(f"Error loading EPUB: {str(e)}") messagebox.showerror("Error", f"Failed to load EPUB file:\n{str(e)}") print(f"Detailed error: {e}") import traceback traceback.print_exc() def populate_chapter_list(self): """Populate the chapter selection dropdown""" if not self.current_document: return chapters = [] # Check if it's a Book with chapters if isinstance(self.current_document, Book) and self.current_document.chapters: for i, chapter in enumerate(self.current_document.chapters): chapter_title = chapter.title or f"Chapter {i+1}" chapters.append(chapter_title) else: # Fallback: add a single "Document" entry chapters.append("Document") self.chapter_combo['values'] = chapters if chapters: self.chapter_combo.set(chapters[0]) def create_pages_from_document(self): """Create pages using proper fill-until-full pagination logic""" if not self.current_document: return self.rendered_pages.clear() try: # Get all blocks from the document all_blocks = [] if isinstance(self.current_document, Book) and self.current_document.chapters: # Process chapters for chapter in self.current_document.chapters: all_blocks.extend(chapter.blocks) else: # Process document blocks directly all_blocks = self.current_document.blocks # If no blocks found, try to create some from EPUB content if not all_blocks: all_blocks = self.create_blocks_from_epub_content() # Create pages by filling until full (like Line class with words) current_page = Page(size=(self.page_width, self.page_height)) block_index = 0 while block_index < len(all_blocks): block = all_blocks[block_index] # Try to add this block to the current page added_successfully = self.try_add_block_to_page(current_page, block) if added_successfully: # Block fits on current page, move to next block block_index += 1 else: # Block doesn't fit, finalize current page and start new one if current_page._children: # Only add non-empty pages self.rendered_pages.append(current_page) # Start a new page current_page = Page(size=(self.page_width, self.page_height)) # Try to add the block to the new page (with resizing if needed) added_successfully = self.try_add_block_to_page(current_page, block, allow_resize=True) if added_successfully: block_index += 1 else: # Block still doesn't fit even with resizing - skip it with error message print(f"Warning: Block too large to fit on any page, skipping") block_index += 1 # Add the last page if it has content if current_page._children: self.rendered_pages.append(current_page) # If no pages were created, create a default one if not self.rendered_pages: self.create_default_page() except Exception as e: print(f"Error creating pages: {e}") import traceback traceback.print_exc() self.create_default_page() def try_add_block_to_page(self, page: Page, block, allow_resize: bool = False) -> bool: """ Try to add a block to a page. Returns True if successful, False if page is full. This is like trying to add a word to a Line - we actually try to add it and see if it fits. """ try: # Convert block to renderable renderable = page._convert_block_to_renderable(block) if not renderable: return True # Skip blocks that can't be rendered # Handle special cases for oversized content if allow_resize: renderable = self.resize_if_needed(renderable, page) # Store the current state in case we need to rollback children_backup = page._children.copy() # Try adding the renderable to the page page.add_child(renderable) # Now render the page to see the actual height try: # Trigger layout to calculate positions and sizes page.layout() # Calculate the actual content height actual_height = self.calculate_actual_page_height(page) # Get available space (account for padding) available_height = page._size[1] - 40 # 20px top + 20px bottom padding # Check if it fits if actual_height <= available_height: # It fits! Keep the addition return True else: # Doesn't fit - rollback the addition page._children = children_backup return False except Exception as e: # If rendering fails, rollback and skip page._children = children_backup print(f"Error rendering block: {e}") return True # Skip problematic blocks except Exception as e: print(f"Error adding block to page: {e}") return True # Skip problematic blocks def calculate_actual_page_height(self, page: Page) -> int: """Calculate the actual height used by content after layout""" if not page._children: return 0 max_bottom = 0 for child in page._children: if hasattr(child, '_origin') and hasattr(child, '_size'): child_bottom = child._origin[1] + child._size[1] max_bottom = max(max_bottom, child_bottom) return max_bottom def resize_if_needed(self, renderable, page): """Resize oversized content to fit on page""" from pyWebLayout.concrete.image import RenderableImage if isinstance(renderable, RenderableImage): # Resize large images max_width = page._size[0] - 40 # Account for padding max_height = page._size[1] - 60 # Account for padding + some content space # Create a new resized image try: resized_image = RenderableImage( renderable._image, max_width=max_width, max_height=max_height ) return resized_image except Exception: # If resizing fails, return original return renderable # For other types, return as-is for now # TODO: Handle large tables, etc. return renderable def calculate_page_height_usage(self, page: Page) -> int: """Calculate how much height is currently used on the page""" total_height = 20 # Top padding for child in page._children: if hasattr(child, '_size'): total_height += child._size[1] total_height += page._spacing # Add spacing between elements return total_height def get_renderable_height(self, renderable) -> int: """Get the height that a renderable will take""" if hasattr(renderable, '_size'): return renderable._size[1] else: # Estimate height for renderables without size from pyWebLayout.concrete.text import Text from pyWebLayout.concrete.image import RenderableImage if isinstance(renderable, Text): # Estimate text height based on font size font_size = getattr(renderable._font, 'font_size', 16) return font_size + 5 # Font size + some spacing elif isinstance(renderable, RenderableImage): # Images should have size calculated return 200 # Default fallback else: return 30 # Generic fallback def create_blocks_from_epub_content(self): """Create blocks from raw EPUB content when document parsing fails""" blocks = [] try: # Get HTML content from EPUB spine items spine_items = self.current_epub.spine[:3] # Limit to first 3 items for item_id in spine_items: try: # Get the manifest item if item_id in self.current_epub.manifest: item = self.current_epub.manifest[item_id] file_path = item['path'] # Read the HTML content if os.path.exists(file_path): with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # Parse HTML content into blocks html_blocks = parse_html_string(content) blocks.extend(html_blocks[:5]) # Limit blocks per item except Exception as e: print(f"Error processing spine item {item_id}: {e}") continue except Exception as e: print(f"Error getting EPUB content: {e}") return blocks def create_default_page(self): """Create a default page when content loading fails""" page = Page(size=(self.page_width, self.page_height)) # Add some default content from pyWebLayout.concrete.text import Text default_font = Font() if self.current_document: title = getattr(self.current_document, 'title', None) if title: page.add_child(Text(f"Book: {title}", default_font)) page.add_child(Text("Content is loading...", default_font)) else: page.add_child(Text("EPUB content loaded", default_font)) page.add_child(Text("Use arrow keys or buttons to navigate", default_font)) self.rendered_pages = [page] def display_current_page(self): """Display the current page on the canvas""" if not self.rendered_pages or self.current_page_index >= len(self.rendered_pages): return try: # Clear the canvas self.canvas.delete("all") # Get the current page page = self.rendered_pages[self.current_page_index] # Render the page page_image = page.render() # Convert to PhotoImage self.photo = ImageTk.PhotoImage(page_image) # Calculate position to center the page canvas_width = self.canvas.winfo_width() canvas_height = self.canvas.winfo_height() if canvas_width > 1 and canvas_height > 1: # Canvas is properly sized x_pos = max(0, (canvas_width - page_image.width) // 2) y_pos = max(0, (canvas_height - page_image.height) // 2) else: x_pos, y_pos = 0, 0 # Display the page self.canvas.create_image(x_pos, y_pos, anchor=tk.NW, image=self.photo) except Exception as e: # Display error message self.canvas.delete("all") self.canvas.create_text( self.page_width // 2, self.page_height // 2, text=f"Error displaying page: {str(e)}", fill="red", font=("Arial", 12) ) print(f"Display error: {e}") def previous_page(self): """Navigate to the previous page""" if self.current_page_index > 0: self.current_page_index -= 1 self.display_current_page() self.update_navigation() def next_page(self): """Navigate to the next page""" if self.current_page_index < len(self.rendered_pages) - 1: self.current_page_index += 1 self.display_current_page() self.update_navigation() def update_navigation(self): """Update navigation button states and page info""" if not self.rendered_pages: self.prev_btn.config(state=tk.DISABLED) self.next_btn.config(state=tk.DISABLED) self.page_info_label.config(text="Page 0 of 0") return # Update button states self.prev_btn.config(state=tk.NORMAL if self.current_page_index > 0 else tk.DISABLED) self.next_btn.config(state=tk.NORMAL if self.current_page_index < len(self.rendered_pages) - 1 else tk.DISABLED) # Update page info page_num = self.current_page_index + 1 total_pages = len(self.rendered_pages) self.page_info_label.config(text=f"Page {page_num} of {total_pages}") def on_chapter_selected(self, event=None): """Handle chapter selection""" if not self.current_document or not self.rendered_pages: return selected_chapter = self.chapter_var.get() # For now, just go to the first page # In a more sophisticated implementation, we'd track chapter start pages self.current_page_index = 0 self.display_current_page() self.update_navigation() self.status_var.set(f"Viewing: {selected_chapter}") def run(self): """Start the EPUB reader application""" # Make canvas responsive def on_configure(event): # Redisplay current page when canvas is resized if hasattr(self, 'photo'): self.root.after_idle(self.display_current_page) self.canvas.bind('', on_configure) # Start the main loop self.root.mainloop() def main(): """Main function to run the EPUB reader""" print("Starting pyWebLayout EPUB Reader...") try: app = EPUBReaderApp() app.run() except Exception as e: print(f"Error starting EPUB reader: {e}") import traceback traceback.print_exc() if __name__ == "__main__": main()