333 lines
11 KiB
Python
333 lines
11 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
|
|
)
|
|
|
|
|
|
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) -> Image.Image:
|
|
"""
|
|
Open the settings overlay.
|
|
|
|
Args:
|
|
base_page: Current reading page to show underneath
|
|
|
|
Returns:
|
|
Composited image with settings overlay on top
|
|
"""
|
|
# Generate settings HTML
|
|
html = generate_settings_overlay()
|
|
|
|
# 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.SETTINGS
|
|
|
|
# Composite and return
|
|
return self.composite_overlay(base_page, overlay_image)
|
|
|
|
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 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
|
|
}
|