""" 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 import os # 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') # DEBUG: Draw bounding boxes on interactive elements if debug mode enabled debug_mode = os.environ.get('DREADER_DEBUG_OVERLAY', '0') == '1' if debug_mode: overlay_panel = self._draw_debug_bounding_boxes(overlay_panel.copy()) # 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)) import logging logger = logging.getLogger(__name__) logger.info(f"[OVERLAY_BASE] query_point({overlay_x}, {overlay_y}) returned: {result}") if result: logger.info(f"[OVERLAY_BASE] text={result.text}, link_target={result.link_target}, is_interactive={result.is_interactive}") logger.info(f"[OVERLAY_BASE] bounds={result.bounds}, object_type={result.object_type}") 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) def _draw_debug_bounding_boxes(self, overlay_panel: Image.Image) -> Image.Image: """ Draw bounding boxes around all interactive elements for debugging. This scans the overlay panel and draws red rectangles around all clickable elements to help visualize where users need to click. Args: overlay_panel: Overlay panel image to annotate Returns: Annotated overlay panel with bounding boxes """ from PIL import ImageDraw, ImageFont import logging logger = logging.getLogger(__name__) if not self._overlay_reader or not self._overlay_reader.manager: logger.warning("[DEBUG] No overlay reader available for debug visualization") return overlay_panel page = self._overlay_reader.manager.get_current_page() if not page: logger.warning("[DEBUG] No page available for debug visualization") return overlay_panel # Scan for all interactive elements panel_width, panel_height = overlay_panel.size link_regions = {} # link_target -> (min_x, min_y, max_x, max_y) logger.info(f"[DEBUG] Scanning {panel_width}x{panel_height} overlay for interactive elements...") # Scan with fine granularity to find all interactive pixels for y in range(0, panel_height, 2): for x in range(0, panel_width, 2): result = page.query_point((x, y)) if result and result.link_target: if result.link_target not in link_regions: link_regions[result.link_target] = [x, y, x, y] else: # Expand bounding box link_regions[result.link_target][0] = min(link_regions[result.link_target][0], x) link_regions[result.link_target][1] = min(link_regions[result.link_target][1], y) link_regions[result.link_target][2] = max(link_regions[result.link_target][2], x) link_regions[result.link_target][3] = max(link_regions[result.link_target][3], y) # Draw bounding boxes draw = ImageDraw.Draw(overlay_panel) try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10) except: font = ImageFont.load_default() logger.info(f"[DEBUG] Found {len(link_regions)} interactive regions") for link_target, (min_x, min_y, max_x, max_y) in link_regions.items(): # Draw red bounding box draw.rectangle( [min_x, min_y, max_x, max_y], outline=(255, 0, 0), width=2 ) # Draw label label = link_target[:20] # Truncate if too long draw.text((min_x + 2, min_y - 12), label, fill=(255, 0, 0), font=font) logger.info(f"[DEBUG] {link_target}: ({min_x}, {min_y}) to ({max_x}, {max_y})") return overlay_panel