564 lines
18 KiB
Python

"""
Overlay management for dreader application.
Handles rendering and compositing of overlay screens (TOC, Settings, Bookmarks)
on top of the base reading page.
"""
from __future__ import annotations
from typing import Optional, List, Dict, Any, Tuple
from pathlib import Path
from PIL import Image
from .state import OverlayState
from .html_generator import (
generate_toc_overlay,
generate_settings_overlay,
generate_bookmarks_overlay,
generate_navigation_overlay
)
class OverlayManager:
"""
Manages overlay rendering and interaction.
Handles:
- Generating overlay HTML
- Rendering HTML to images using pyWebLayout
- Compositing overlays on top of base pages
- Tracking current overlay state
"""
def __init__(self, page_size: Tuple[int, int] = (800, 1200)):
"""
Initialize overlay manager.
Args:
page_size: Size of the page/overlay (width, height)
"""
self.page_size = page_size
self.current_overlay = OverlayState.NONE
self._cached_base_page: Optional[Image.Image] = None
self._cached_overlay_image: Optional[Image.Image] = None
self._overlay_reader = None # Will be EbookReader instance for rendering overlays
self._overlay_panel_offset: Tuple[int, int] = (0, 0) # Panel position on screen
def render_html_to_image(self, html: str, size: Optional[Tuple[int, int]] = None) -> Image.Image:
"""
Render HTML content to a PIL Image using pyWebLayout.
This creates a temporary EbookReader instance to render the HTML,
then extracts the rendered page as an image.
Args:
html: HTML string to render
size: Optional (width, height) for rendering size. Defaults to self.page_size
Returns:
PIL Image of the rendered HTML
"""
# Import here to avoid circular dependency
from .application import EbookReader
render_size = size if size else self.page_size
# Create a temporary reader for rendering this HTML
temp_reader = EbookReader(
page_size=render_size,
margin=15,
background_color=(255, 255, 255)
)
# Load the HTML content
success = temp_reader.load_html(
html_string=html,
title="Overlay",
author="",
document_id="temp_overlay"
)
if not success:
raise ValueError("Failed to load HTML for overlay rendering")
# Get the rendered page
image = temp_reader.get_current_page()
# Clean up
temp_reader.close()
return image
def composite_overlay(self, base_image: Image.Image, overlay_panel: Image.Image) -> Image.Image:
"""
Composite overlay panel on top of base image with darkened background.
Creates a popup effect by:
1. Darkening the base image (multiply by 0.5)
2. Placing the overlay panel (60% size) centered on top
Args:
base_image: Base page image (reading page)
overlay_panel: Rendered overlay panel (TOC, settings, etc.)
Returns:
Composited PIL Image with popup overlay effect
"""
from PIL import ImageDraw, ImageEnhance
import numpy as np
# Convert base image to RGB
result = base_image.convert('RGB').copy()
# Lighten the background slightly (70% brightness for e-ink visibility)
enhancer = ImageEnhance.Brightness(result)
result = enhancer.enhance(0.7)
# Convert overlay panel to RGB
if overlay_panel.mode != 'RGB':
overlay_panel = overlay_panel.convert('RGB')
# Calculate centered position for the panel
panel_x = int((self.page_size[0] - overlay_panel.width) / 2)
panel_y = int((self.page_size[1] - overlay_panel.height) / 2)
# Add a thick black border around the panel for e-ink clarity
draw = ImageDraw.Draw(result)
border_width = 3
draw.rectangle(
[panel_x - border_width, panel_y - border_width,
panel_x + overlay_panel.width + border_width, panel_y + overlay_panel.height + border_width],
outline=(0, 0, 0),
width=border_width
)
# Paste the panel onto the dimmed background
result.paste(overlay_panel, (panel_x, panel_y))
return result
def open_toc_overlay(self, chapters: List[Tuple[str, int]], base_page: Image.Image) -> Image.Image:
"""
Open the table of contents overlay.
Args:
chapters: List of (chapter_title, chapter_index) tuples
base_page: Current reading page to show underneath
Returns:
Composited image with TOC overlay on top
"""
# Import here to avoid circular dependency
from .application import EbookReader
# Calculate panel size (60% of screen)
panel_width = int(self.page_size[0] * 0.6)
panel_height = int(self.page_size[1] * 0.7)
# Convert chapters to format expected by HTML generator
chapter_data = [
{"index": idx, "title": title}
for title, idx in chapters
]
# Generate TOC HTML with clickable links
html = generate_toc_overlay(chapter_data, page_size=(panel_width, panel_height))
# Create reader for overlay and keep it alive for querying
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = EbookReader(
page_size=(panel_width, panel_height),
margin=15,
background_color=(255, 255, 255)
)
# Load the HTML content
success = self._overlay_reader.load_html(
html_string=html,
title="Table of Contents",
author="",
document_id="toc_overlay"
)
if not success:
raise ValueError("Failed to load TOC overlay HTML")
# Get the rendered page
overlay_panel = self._overlay_reader.get_current_page()
# Calculate and store panel position for coordinate translation
panel_x = int((self.page_size[0] - panel_width) / 2)
panel_y = int((self.page_size[1] - panel_height) / 2)
self._overlay_panel_offset = (panel_x, panel_y)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_panel
self.current_overlay = OverlayState.TOC
# Composite and return
return self.composite_overlay(base_page, overlay_panel)
def open_settings_overlay(
self,
base_page: Image.Image,
font_scale: float = 1.0,
line_spacing: int = 5,
inter_block_spacing: int = 15,
word_spacing: int = 0
) -> Image.Image:
"""
Open the settings overlay with current settings values.
Args:
base_page: Current reading page to show underneath
font_scale: Current font scale
line_spacing: Current line spacing
inter_block_spacing: Current inter-block spacing
word_spacing: Current word spacing
Returns:
Composited image with settings overlay on top
"""
# Import here to avoid circular dependency
from .application import EbookReader
# Calculate panel size (60% of screen)
panel_width = int(self.page_size[0] * 0.6)
panel_height = int(self.page_size[1] * 0.7)
# Generate settings HTML with current values
html = generate_settings_overlay(
font_scale=font_scale,
line_spacing=line_spacing,
inter_block_spacing=inter_block_spacing,
word_spacing=word_spacing,
page_size=(panel_width, panel_height)
)
# Create reader for overlay and keep it alive for querying
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = EbookReader(
page_size=(panel_width, panel_height),
margin=15,
background_color=(255, 255, 255)
)
# Load the HTML content
success = self._overlay_reader.load_html(
html_string=html,
title="Settings",
author="",
document_id="settings_overlay"
)
if not success:
raise ValueError("Failed to load settings overlay HTML")
# Get the rendered page
overlay_panel = self._overlay_reader.get_current_page()
# Calculate and store panel position for coordinate translation
panel_x = int((self.page_size[0] - panel_width) / 2)
panel_y = int((self.page_size[1] - panel_height) / 2)
self._overlay_panel_offset = (panel_x, panel_y)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_panel
self.current_overlay = OverlayState.SETTINGS
# Composite and return
return self.composite_overlay(base_page, overlay_panel)
def refresh_settings_overlay(
self,
updated_base_page: Image.Image,
font_scale: float,
line_spacing: int,
inter_block_spacing: int,
word_spacing: int = 0
) -> Image.Image:
"""
Refresh the settings overlay with updated values and background page.
This is used for live preview when settings change - it updates both
the background page (with new settings applied) and the overlay panel
(with new values displayed).
Args:
updated_base_page: Updated reading page with new settings applied
font_scale: Updated font scale
line_spacing: Updated line spacing
inter_block_spacing: Updated inter-block spacing
word_spacing: Updated word spacing
Returns:
Composited image with updated settings overlay
"""
# Import here to avoid circular dependency
from .application import EbookReader
# Calculate panel size (60% of screen)
panel_width = int(self.page_size[0] * 0.6)
panel_height = int(self.page_size[1] * 0.7)
# Generate updated settings HTML
html = generate_settings_overlay(
font_scale=font_scale,
line_spacing=line_spacing,
inter_block_spacing=inter_block_spacing,
word_spacing=word_spacing,
page_size=(panel_width, panel_height)
)
# Recreate overlay reader with updated HTML
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = EbookReader(
page_size=(panel_width, panel_height),
margin=15,
background_color=(255, 255, 255)
)
success = self._overlay_reader.load_html(
html_string=html,
title="Settings",
author="",
document_id="settings_overlay"
)
if not success:
raise ValueError("Failed to load updated settings overlay HTML")
# Get the updated rendered panel
overlay_panel = self._overlay_reader.get_current_page()
# Update caches
self._cached_base_page = updated_base_page.copy()
self._cached_overlay_image = overlay_panel
# Composite and return
return self.composite_overlay(updated_base_page, overlay_panel)
def open_bookmarks_overlay(self, bookmarks: List[Dict[str, Any]], base_page: Image.Image) -> Image.Image:
"""
Open the bookmarks overlay.
Args:
bookmarks: List of bookmark dictionaries with 'name' and 'position' keys
base_page: Current reading page to show underneath
Returns:
Composited image with bookmarks overlay on top
"""
# Generate bookmarks HTML
html = generate_bookmarks_overlay(bookmarks)
# Render HTML to image
overlay_image = self.render_html_to_image(html)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_image
self.current_overlay = OverlayState.BOOKMARKS
# Composite and return
return self.composite_overlay(base_page, overlay_image)
def open_navigation_overlay(
self,
chapters: List[Tuple[str, int]],
bookmarks: List[Dict],
base_page: Image.Image,
active_tab: str = "contents"
) -> Image.Image:
"""
Open the unified navigation overlay with Contents and Bookmarks tabs.
This replaces the separate TOC and Bookmarks overlays with a single
overlay that has tabs for switching between contents and bookmarks.
Args:
chapters: List of (chapter_title, chapter_index) tuples
bookmarks: List of bookmark dictionaries with 'name' and optional 'position'
base_page: Current reading page to show underneath
active_tab: Which tab to show ("contents" or "bookmarks")
Returns:
Composited image with navigation overlay on top
"""
# Import here to avoid circular dependency
from .application import EbookReader
# Calculate panel size (60% of screen width, 70% height)
panel_width = int(self.page_size[0] * 0.6)
panel_height = int(self.page_size[1] * 0.7)
# Convert chapters to format expected by HTML generator
chapter_data = [
{"index": idx, "title": title}
for title, idx in chapters
]
# Generate navigation HTML with tabs
html = generate_navigation_overlay(
chapters=chapter_data,
bookmarks=bookmarks,
active_tab=active_tab,
page_size=(panel_width, panel_height)
)
# Create reader for overlay and keep it alive for querying
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = EbookReader(
page_size=(panel_width, panel_height),
margin=15,
background_color=(255, 255, 255)
)
# Load the HTML content
success = self._overlay_reader.load_html(
html_string=html,
title="Navigation",
author="",
document_id="navigation_overlay"
)
if not success:
raise ValueError("Failed to load navigation overlay HTML")
# Get the rendered page
overlay_panel = self._overlay_reader.get_current_page()
# Calculate and store panel position for coordinate translation
panel_x = int((self.page_size[0] - panel_width) / 2)
panel_y = int((self.page_size[1] - panel_height) / 2)
self._overlay_panel_offset = (panel_x, panel_y)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_panel
self.current_overlay = OverlayState.NAVIGATION
# Store active tab for tab switching
self._active_nav_tab = active_tab
self._cached_chapters = chapters
self._cached_bookmarks = bookmarks
# Composite and return
return self.composite_overlay(base_page, overlay_panel)
def switch_navigation_tab(self, new_tab: str) -> Optional[Image.Image]:
"""
Switch between tabs in the navigation overlay.
Args:
new_tab: Tab to switch to ("contents" or "bookmarks")
Returns:
Updated composited image with new tab active, or None if not in navigation overlay
"""
if self.current_overlay != OverlayState.NAVIGATION:
return None
# Re-open navigation overlay with new active tab
if hasattr(self, '_cached_chapters') and hasattr(self, '_cached_bookmarks'):
return self.open_navigation_overlay(
chapters=self._cached_chapters,
bookmarks=self._cached_bookmarks,
base_page=self._cached_base_page,
active_tab=new_tab
)
return None
def close_overlay(self) -> Optional[Image.Image]:
"""
Close the current overlay and return to base page.
Returns:
Base page image (without overlay), or None if no overlay was open
"""
if self.current_overlay == OverlayState.NONE:
return None
self.current_overlay = OverlayState.NONE
base_page = self._cached_base_page
# Clear caches
self._cached_base_page = None
self._cached_overlay_image = None
self._overlay_panel_offset = (0, 0)
# Close overlay reader
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = None
return base_page
def is_overlay_open(self) -> bool:
"""Check if an overlay is currently open."""
return self.current_overlay != OverlayState.NONE
def get_current_overlay_type(self) -> OverlayState:
"""Get the type of currently open overlay."""
return self.current_overlay
def query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]:
"""
Query a pixel in the current overlay to detect interactions.
Uses pyWebLayout's query_point() to detect which element was tapped,
including link targets and data attributes.
Args:
x, y: Pixel coordinates to query (in screen space)
Returns:
Dictionary with query result data (text, link_target, is_interactive),
or None if no overlay open or query failed
"""
if not self.is_overlay_open() or not self._overlay_reader:
return None
# Translate screen coordinates to overlay panel coordinates
panel_x, panel_y = self._overlay_panel_offset
overlay_x = x - panel_x
overlay_y = y - panel_y
# Check if coordinates are within the overlay panel
if overlay_x < 0 or overlay_y < 0:
return None
# Get the current page from the overlay reader
if not self._overlay_reader.manager:
return None
current_page = self._overlay_reader.manager.get_current_page()
if not current_page:
return None
# Query the point
result = current_page.query_point((overlay_x, overlay_y))
if not result:
return None
# Extract relevant data from QueryResult
return {
"text": result.text,
"link_target": result.link_target,
"is_interactive": result.is_interactive,
"bounds": result.bounds,
"object_type": result.object_type
}