Duncan Tourolle 284a6e3393
All checks were successful
Python CI / test (push) Successful in 4m30s
library and toc navigation
2025-11-08 12:20:23 +01:00

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
}