@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Base class for overlay sub-applications.
|
||||
|
||||
This provides a common interface for all overlay types (TOC, Settings, Navigation, etc.)
|
||||
Each overlay is a self-contained sub-application that handles its own rendering and gestures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Optional, Dict, Any, Tuple
|
||||
from PIL import Image
|
||||
|
||||
from ..gesture import GestureResponse, ActionType
|
||||
from ..state import OverlayState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..application import EbookReader
|
||||
|
||||
|
||||
class OverlaySubApplication(ABC):
|
||||
"""
|
||||
Base class for overlay sub-applications.
|
||||
|
||||
Each overlay type extends this class and implements:
|
||||
- open(): Generate HTML, render, and return composited image
|
||||
- handle_tap(): Process tap gestures within the overlay
|
||||
- close(): Clean up and return base page
|
||||
- get_overlay_type(): Return the OverlayState enum value
|
||||
|
||||
The base class provides:
|
||||
- Common rendering infrastructure (HTML to image conversion)
|
||||
- Coordinate translation (screen to overlay panel)
|
||||
- Query pixel support (detecting interactive elements)
|
||||
- Compositing (darkened background + centered panel)
|
||||
"""
|
||||
|
||||
def __init__(self, reader: 'EbookReader'):
|
||||
"""
|
||||
Initialize overlay sub-application.
|
||||
|
||||
Args:
|
||||
reader: Reference to parent EbookReader instance
|
||||
"""
|
||||
self.reader = reader
|
||||
self.page_size = reader.page_size
|
||||
|
||||
# Overlay rendering state
|
||||
self._overlay_reader: Optional['EbookReader'] = None
|
||||
self._cached_base_page: Optional[Image.Image] = None
|
||||
self._cached_overlay_image: Optional[Image.Image] = None
|
||||
self._overlay_panel_offset: Tuple[int, int] = (0, 0)
|
||||
self._panel_size: Tuple[int, int] = (0, 0)
|
||||
|
||||
@abstractmethod
|
||||
def get_overlay_type(self) -> OverlayState:
|
||||
"""
|
||||
Get the overlay type identifier.
|
||||
|
||||
Returns:
|
||||
OverlayState enum value for this overlay
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def open(self, base_page: Image.Image, **kwargs) -> Image.Image:
|
||||
"""
|
||||
Open the overlay and return composited image.
|
||||
|
||||
Args:
|
||||
base_page: Current reading page to show underneath
|
||||
**kwargs: Overlay-specific parameters
|
||||
|
||||
Returns:
|
||||
Composited image with overlay on top of base page
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def handle_tap(self, x: int, y: int) -> GestureResponse:
|
||||
"""
|
||||
Handle tap gesture within the overlay.
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates of tap
|
||||
|
||||
Returns:
|
||||
GestureResponse indicating what action to take
|
||||
"""
|
||||
pass
|
||||
|
||||
def close(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Close the overlay and clean up resources.
|
||||
|
||||
Returns:
|
||||
Base page image (without overlay), or None if not open
|
||||
"""
|
||||
base_page = self._cached_base_page
|
||||
|
||||
# Clear caches
|
||||
self._cached_base_page = None
|
||||
self._cached_overlay_image = None
|
||||
self._overlay_panel_offset = (0, 0)
|
||||
self._panel_size = (0, 0)
|
||||
|
||||
# Close overlay reader
|
||||
if self._overlay_reader:
|
||||
self._overlay_reader.close()
|
||||
self._overlay_reader = None
|
||||
|
||||
return base_page
|
||||
|
||||
# ===================================================================
|
||||
# Common Infrastructure Methods
|
||||
# ===================================================================
|
||||
|
||||
def render_html_to_image(self, html: str, panel_size: Tuple[int, int]) -> Image.Image:
|
||||
"""
|
||||
Render HTML to image using a temporary EbookReader.
|
||||
|
||||
Args:
|
||||
html: HTML content to render
|
||||
panel_size: Size for the overlay panel (width, height)
|
||||
|
||||
Returns:
|
||||
Rendered PIL Image of the HTML
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from ..application import EbookReader
|
||||
|
||||
# Create or reuse overlay reader
|
||||
if self._overlay_reader:
|
||||
self._overlay_reader.close()
|
||||
|
||||
self._overlay_reader = EbookReader(
|
||||
page_size=panel_size,
|
||||
margin=15,
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
# Load the HTML content
|
||||
success = self._overlay_reader.load_html(
|
||||
html_string=html,
|
||||
title=f"{self.get_overlay_type().name} Overlay",
|
||||
author="",
|
||||
document_id=f"{self.get_overlay_type().name.lower()}_overlay"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise ValueError(f"Failed to load {self.get_overlay_type().name} overlay HTML")
|
||||
|
||||
# Get the rendered page
|
||||
return self._overlay_reader.get_current_page()
|
||||
|
||||
def composite_overlay(self, base_page: Image.Image, overlay_panel: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Composite overlay panel on top of base page with darkened background.
|
||||
|
||||
Creates popup effect by:
|
||||
1. Darkening the base image (70% brightness for e-ink visibility)
|
||||
2. Placing the overlay panel centered on top with a border
|
||||
|
||||
Args:
|
||||
base_page: Base reading page
|
||||
overlay_panel: Rendered overlay panel
|
||||
|
||||
Returns:
|
||||
Composited PIL Image with popup effect
|
||||
"""
|
||||
from PIL import ImageDraw, ImageEnhance
|
||||
|
||||
# Convert base image to RGB
|
||||
result = base_page.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)
|
||||
|
||||
# Store panel position and size for coordinate translation
|
||||
self._overlay_panel_offset = (panel_x, panel_y)
|
||||
self._panel_size = (overlay_panel.width, overlay_panel.height)
|
||||
|
||||
# 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 query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Query a pixel in the overlay to detect interactive elements.
|
||||
|
||||
Uses pyWebLayout's query_point() to detect tapped elements,
|
||||
including link targets and data attributes.
|
||||
|
||||
Args:
|
||||
x, y: Screen coordinates to query
|
||||
|
||||
Returns:
|
||||
Dictionary with query result (text, link_target, is_interactive),
|
||||
or None if query failed or coordinates outside overlay
|
||||
"""
|
||||
if 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
|
||||
|
||||
panel_width, panel_height = self._panel_size
|
||||
if overlay_x >= panel_width or overlay_y >= panel_height:
|
||||
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
|
||||
}
|
||||
|
||||
def _calculate_panel_size(self, width_ratio: float = 0.6, height_ratio: float = 0.7) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculate overlay panel size as a percentage of screen size.
|
||||
|
||||
Args:
|
||||
width_ratio: Panel width as ratio of screen width (default 60%)
|
||||
height_ratio: Panel height as ratio of screen height (default 70%)
|
||||
|
||||
Returns:
|
||||
Tuple of (panel_width, panel_height) in pixels
|
||||
"""
|
||||
panel_width = int(self.page_size[0] * width_ratio)
|
||||
panel_height = int(self.page_size[1] * height_ratio)
|
||||
return (panel_width, panel_height)
|
||||
Reference in New Issue
Block a user