Coverage for pyWebLayout/core/highlight.py: 97%
87 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
1"""
2Text highlighting system for ebook reader.
4Provides data structures and utilities for highlighting text regions,
5managing highlight collections, and rendering highlights on pages.
6"""
8from __future__ import annotations
9import logging
10from dataclasses import dataclass
11from typing import List, Tuple, Optional, Dict, Any
12from enum import Enum
13from pathlib import Path
15from pyWebLayout.core.persistence import ensure_dir, read_json, write_json
17logger = logging.getLogger(__name__)
20class HighlightColor(Enum):
21 """Predefined highlight colors with RGBA values"""
22 YELLOW = (255, 255, 0, 100) # Classic highlight yellow
23 GREEN = (100, 255, 100, 100) # Green for verified/correct
24 BLUE = (100, 200, 255, 100) # Blue for important
25 PINK = (255, 150, 200, 100) # Pink for questions
26 ORANGE = (255, 180, 100, 100) # Orange for warnings
27 PURPLE = (200, 150, 255, 100) # Purple for definitions
28 RED = (255, 100, 100, 100) # Red for errors/concerns
31@dataclass
32class Highlight:
33 """
34 Represents a highlighted text region.
36 Highlights are stored with both pixel bounds (for rendering) and
37 semantic bounds (text content, for persistence across font changes).
38 """
39 # Identification
40 id: str # Unique identifier
42 # Visual properties
43 bounds: List[Tuple[int, int, int, int]] # List of (x, y, w, h) rectangles
44 color: Tuple[int, int, int, int] # RGBA color
46 # Semantic properties (for persistence)
47 text: str # The highlighted text
48 start_word_index: Optional[int] = None # Word index in document (if available)
49 end_word_index: Optional[int] = None
51 # Where in the document this highlight lives, as a serialized
52 # RenderingPosition. `bounds` are pixel coordinates on one particular
53 # rendering, so they stop matching as soon as the font scale or page size
54 # changes; this survives repagination and is what page association uses.
55 position: Optional[Dict[str, Any]] = None
57 # Metadata
58 note: Optional[str] = None # Optional annotation
59 tags: List[str] = None # Optional categorization tags
60 timestamp: Optional[float] = None # When created
62 def __post_init__(self):
63 """Initialize default values"""
64 if self.tags is None:
65 self.tags = []
67 def to_dict(self) -> Dict[str, Any]:
68 """Serialize to dictionary"""
69 return {
70 'id': self.id,
71 'bounds': self.bounds,
72 'color': self.color,
73 'text': self.text,
74 'start_word_index': self.start_word_index,
75 'end_word_index': self.end_word_index,
76 'position': self.position,
77 'note': self.note,
78 'tags': self.tags,
79 'timestamp': self.timestamp
80 }
82 @classmethod
83 def from_dict(cls, data: Dict[str, Any]) -> 'Highlight':
84 """Deserialize from dictionary"""
85 return cls(
86 id=data['id'],
87 bounds=[tuple(b) for b in data['bounds']],
88 color=tuple(data['color']),
89 text=data['text'],
90 start_word_index=data.get('start_word_index'),
91 end_word_index=data.get('end_word_index'),
92 position=data.get('position'),
93 note=data.get('note'),
94 tags=data.get('tags', []),
95 timestamp=data.get('timestamp')
96 )
99class HighlightManager:
100 """
101 Manages highlights for a document.
103 Handles adding, removing, listing, and persisting highlights.
104 """
106 def __init__(self, document_id: str, highlights_dir: str = "highlights"):
107 """
108 Initialize highlight manager.
110 Args:
111 document_id: Unique identifier for the document
112 highlights_dir: Directory to store highlight data
113 """
114 self.document_id = document_id
115 self.highlights_dir = ensure_dir(highlights_dir)
116 self.highlights: Dict[str, Highlight] = {} # id -> Highlight
118 # Load existing highlights
119 self._load_highlights()
121 def add_highlight(self, highlight: Highlight) -> None:
122 """
123 Add a highlight.
125 Args:
126 highlight: Highlight to add
127 """
128 self.highlights[highlight.id] = highlight
129 self._save_highlights()
131 def remove_highlight(self, highlight_id: str) -> bool:
132 """
133 Remove a highlight by ID.
135 Args:
136 highlight_id: ID of highlight to remove
138 Returns:
139 True if removed, False if not found
140 """
141 if highlight_id in self.highlights:
142 del self.highlights[highlight_id]
143 self._save_highlights()
144 return True
145 return False
147 def get_highlight(self, highlight_id: str) -> Optional[Highlight]:
148 """Get a highlight by ID"""
149 return self.highlights.get(highlight_id)
151 def list_highlights(self) -> List[Highlight]:
152 """Get all highlights"""
153 return list(self.highlights.values())
155 def clear_all(self) -> None:
156 """Remove all highlights"""
157 self.highlights.clear()
158 self._save_highlights()
160 def get_highlights_for_page(
161 self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
162 """
163 Get highlights that appear on a specific page.
165 Args:
166 page_bounds: Page bounds (x, y, width, height)
168 Returns:
169 List of highlights on this page
170 """
171 page_x, page_y, page_w, page_h = page_bounds
172 page_highlights = []
174 for highlight in self.highlights.values():
175 # Check if any highlight bounds overlap with page
176 for hx, hy, hw, hh in highlight.bounds:
177 if (hx < page_x + page_w and hx + hw > page_x and
178 hy < page_y + page_h and hy + hh > page_y):
179 page_highlights.append(highlight)
180 break
182 return page_highlights
184 def _get_filepath(self) -> Path:
185 """Get filepath for this document's highlights"""
186 return self.highlights_dir / f"{self.document_id}_highlights.json"
188 def _save_highlights(self) -> None:
189 """Persist highlights to disk"""
190 write_json(self._get_filepath(), {
191 'document_id': self.document_id,
192 'highlights': [h.to_dict() for h in self.highlights.values()]
193 })
195 def _load_highlights(self) -> None:
196 """Load highlights from disk"""
197 data = read_json(self._get_filepath(), {})
198 try:
199 self.highlights = {
200 h['id']: Highlight.from_dict(h)
201 for h in data.get('highlights', [])
202 }
203 except (AttributeError, TypeError, KeyError):
204 logger.warning("Highlight file %s is not in the expected shape; ignoring it",
205 self._get_filepath(), exc_info=True)
206 self.highlights = {}
209def create_highlight_from_query_result(
210 result,
211 color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
212 note: Optional[str] = None,
213 tags: Optional[List[str]] = None,
214 position: Optional[Dict[str, Any]] = None
215) -> Highlight:
216 """
217 Create a highlight from a QueryResult.
219 Args:
220 result: QueryResult from query_point or query_range
221 color: RGBA color tuple
222 note: Optional annotation
223 tags: Optional categorization tags
224 position: Serialized RenderingPosition of the page the result came from
226 Returns:
227 Highlight instance
228 """
229 from time import time
230 import uuid
232 # Handle single result or SelectionRange
233 if hasattr(result, 'results'): # SelectionRange
234 bounds = result.bounds_list
235 text = result.text
236 else: # Single QueryResult
237 bounds = [result.bounds]
238 text = result.text or ""
240 return Highlight(
241 id=str(uuid.uuid4()),
242 bounds=bounds,
243 color=color,
244 text=text,
245 position=position,
246 note=note,
247 tags=tags or [],
248 timestamp=time()
249 )