pyWebLayout/epub_reader_tk.py

531 lines
21 KiB
Python

#!/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('<<ComboboxSelected>>', 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('<Key-Left>', lambda e: self.previous_page())
self.root.bind('<Key-Right>', lambda e: self.next_page())
self.root.bind('<Key-space>', 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('<Configure>', 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()