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

1""" 

2Text highlighting system for ebook reader. 

3 

4Provides data structures and utilities for highlighting text regions, 

5managing highlight collections, and rendering highlights on pages. 

6""" 

7 

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 

14 

15from pyWebLayout.core.persistence import ensure_dir, read_json, write_json 

16 

17logger = logging.getLogger(__name__) 

18 

19 

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 

29 

30 

31@dataclass 

32class Highlight: 

33 """ 

34 Represents a highlighted text region. 

35 

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 

41 

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 

45 

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 

50 

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 

56 

57 # Metadata 

58 note: Optional[str] = None # Optional annotation 

59 tags: List[str] = None # Optional categorization tags 

60 timestamp: Optional[float] = None # When created 

61 

62 def __post_init__(self): 

63 """Initialize default values""" 

64 if self.tags is None: 

65 self.tags = [] 

66 

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 } 

81 

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 ) 

97 

98 

99class HighlightManager: 

100 """ 

101 Manages highlights for a document. 

102 

103 Handles adding, removing, listing, and persisting highlights. 

104 """ 

105 

106 def __init__(self, document_id: str, highlights_dir: str = "highlights"): 

107 """ 

108 Initialize highlight manager. 

109 

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 

117 

118 # Load existing highlights 

119 self._load_highlights() 

120 

121 def add_highlight(self, highlight: Highlight) -> None: 

122 """ 

123 Add a highlight. 

124 

125 Args: 

126 highlight: Highlight to add 

127 """ 

128 self.highlights[highlight.id] = highlight 

129 self._save_highlights() 

130 

131 def remove_highlight(self, highlight_id: str) -> bool: 

132 """ 

133 Remove a highlight by ID. 

134 

135 Args: 

136 highlight_id: ID of highlight to remove 

137 

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 

146 

147 def get_highlight(self, highlight_id: str) -> Optional[Highlight]: 

148 """Get a highlight by ID""" 

149 return self.highlights.get(highlight_id) 

150 

151 def list_highlights(self) -> List[Highlight]: 

152 """Get all highlights""" 

153 return list(self.highlights.values()) 

154 

155 def clear_all(self) -> None: 

156 """Remove all highlights""" 

157 self.highlights.clear() 

158 self._save_highlights() 

159 

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. 

164 

165 Args: 

166 page_bounds: Page bounds (x, y, width, height) 

167 

168 Returns: 

169 List of highlights on this page 

170 """ 

171 page_x, page_y, page_w, page_h = page_bounds 

172 page_highlights = [] 

173 

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 

181 

182 return page_highlights 

183 

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" 

187 

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 }) 

194 

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 = {} 

207 

208 

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. 

218 

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 

225 

226 Returns: 

227 Highlight instance 

228 """ 

229 from time import time 

230 import uuid 

231 

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 "" 

239 

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 )