Coverage for src/rtflite/services/color_service.py: 92%

168 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-25 22:35 +0000

1"""Color management service for RTF documents. 

2 

3This service provides comprehensive color validation, lookup, and RTF generation 

4capabilities using the full 657-color table from r2rtf. 

5""" 

6 

7from collections.abc import Mapping, Sequence 

8from typing import Any 

9 

10from rtflite.dictionary.color_table import ( 

11 color_table, 

12 name_to_rgb, 

13 name_to_rtf, 

14 name_to_type, 

15) 

16 

17 

18class ColorValidationError(ValueError): 

19 """Raised when a color validation fails.""" 

20 

21 pass 

22 

23 

24class ColorService: 

25 """Service for color validation, lookup, and RTF generation operations.""" 

26 

27 def __init__(self): 

28 """Initialize the color service with the comprehensive color table.""" 

29 self._color_table = color_table 

30 self._name_to_type = name_to_type 

31 self._name_to_rgb = name_to_rgb 

32 self._name_to_rtf = name_to_rtf 

33 self._current_document_colors = ( 

34 None # Context for current document being encoded 

35 ) 

36 

37 def validate_color(self, color: str) -> bool: 

38 """Validate if a color name exists in the color table. 

39 

40 Args: 

41 color: Color name to validate 

42 

43 Returns: 

44 True if color exists, False otherwise 

45 """ 

46 return color in self._name_to_type 

47 

48 def get_color_index(self, color: str) -> int: 

49 """Get the RTF color table index for a color name. 

50 

51 Args: 

52 color: Color name to look up 

53 

54 Returns: 

55 Color index (1-based) for RTF color table 

56 

57 Raises: 

58 ColorValidationError: If color name is invalid 

59 """ 

60 if not self.validate_color(color): 

61 suggestions = self.get_color_suggestions(color) 

62 suggestion_text = ( 

63 f" Did you mean: {', '.join(suggestions[:3])}?" if suggestions else "" 

64 ) 

65 raise ColorValidationError( 

66 f"Invalid color name '{color}'. Must be one of 657 supported colors.{suggestion_text}" 

67 ) 

68 

69 return self._name_to_type[color] 

70 

71 def get_color_rgb(self, color: str) -> tuple[int, int, int]: 

72 """Get RGB values for a color name. 

73 

74 Args: 

75 color: Color name to look up 

76 

77 Returns: 

78 RGB tuple (red, green, blue) with values 0-255 

79 

80 Raises: 

81 ColorValidationError: If color name is invalid 

82 """ 

83 if not self.validate_color(color): 

84 raise ColorValidationError(f"Invalid color name '{color}'") 

85 

86 return self._name_to_rgb[color] 

87 

88 def get_color_rtf_code(self, color: str) -> str: 

89 """Get RTF color definition code for a color name. 

90 

91 Args: 

92 color: Color name to look up 

93 

94 Returns: 

95 RTF color definition (e.g., "\\red255\\green0\\blue0;") 

96 

97 Raises: 

98 ColorValidationError: If color name is invalid 

99 """ 

100 if not self.validate_color(color): 

101 raise ColorValidationError(f"Invalid color name '{color}'") 

102 

103 return self._name_to_rtf[color] 

104 

105 def get_color_suggestions( 

106 self, partial_color: str, max_suggestions: int = 5 

107 ) -> Sequence[str]: 

108 """Get color name suggestions for partial matches. 

109 

110 Args: 

111 partial_color: Partial or misspelled color name 

112 max_suggestions: Maximum number of suggestions to return 

113 

114 Returns: 

115 List of suggested color names 

116 """ 

117 partial_lower = partial_color.lower() 

118 

119 # Exact match first 

120 if partial_lower in self._name_to_type: 

121 return [partial_lower] 

122 

123 # Find colors that contain the partial string 

124 suggestions = [] 

125 for color_name in self._name_to_type.keys(): 

126 if partial_lower in color_name.lower(): 

127 suggestions.append(color_name) 

128 if len(suggestions) >= max_suggestions: 

129 break 

130 

131 # If no substring matches, find colors that start with same letter 

132 if not suggestions: 

133 first_char = partial_lower[0] if partial_lower else "" 

134 for color_name in self._name_to_type.keys(): 

135 if color_name.lower().startswith(first_char): 

136 suggestions.append(color_name) 

137 if len(suggestions) >= max_suggestions: 

138 break 

139 

140 return suggestions 

141 

142 def validate_color_list(self, colors: str | Sequence[str]) -> Sequence[str]: 

143 """Validate a list of colors, converting single color to list. 

144 

145 Args: 

146 colors: Single color name or list/tuple of color names 

147 

148 Returns: 

149 Validated list of color names 

150 

151 Raises: 

152 ColorValidationError: If any color name is invalid 

153 """ 

154 if isinstance(colors, str): 

155 colors = [colors] 

156 elif isinstance(colors, tuple): 

157 colors = list(colors) 

158 elif not isinstance(colors, list): 

159 raise ColorValidationError( 

160 f"Colors must be string, list, or tuple, got {type(colors)}" 

161 ) 

162 

163 validated_colors = [] 

164 for i, color in enumerate(colors): 

165 if not isinstance(color, str): 

166 raise ColorValidationError( 

167 f"Color at index {i} must be string, got {type(color)}" 

168 ) 

169 

170 if not self.validate_color(color): 

171 suggestions = self.get_color_suggestions(color) 

172 suggestion_text = ( 

173 f" Did you mean: {', '.join(suggestions[:3])}?" 

174 if suggestions 

175 else "" 

176 ) 

177 raise ColorValidationError( 

178 f"Invalid color name '{color}' at index {i}.{suggestion_text}" 

179 ) 

180 

181 validated_colors.append(color) 

182 

183 return validated_colors 

184 

185 def needs_color_table(self, used_colors: Sequence[str] | None = None) -> bool: 

186 """Check if a color table is needed based on used colors. 

187 

188 Args: 

189 used_colors: List of color names used in document 

190 

191 Returns: 

192 True if color table is needed, False otherwise 

193 """ 

194 if not used_colors: 

195 return False 

196 

197 # Filter out empty strings and check for non-default colors 

198 significant_colors = [ 

199 color for color in used_colors if color and color != "black" 

200 ] 

201 return len(significant_colors) > 0 

202 

203 def generate_rtf_color_table(self, used_colors: Sequence[str] | None = None) -> str: 

204 """Generate RTF color table definition for used colors. 

205 

206 Args: 

207 used_colors: List of color names used in document. If None, includes all 657 colors. 

208 

209 Returns: 

210 RTF color table definition string, or empty string if no color table needed 

211 """ 

212 # Check if color table is actually needed 

213 if used_colors is not None and not self.needs_color_table(used_colors): 

214 return "" 

215 

216 if used_colors is None: 

217 # Include all colors in full r2rtf format 

218 colors_to_include = list(self._name_to_type.keys()) 

219 colors_to_include.sort(key=lambda x: self._name_to_type[x]) 

220 

221 # Generate full R2RTF color table 

222 rtf_parts = ["{\\colortbl\n;"] # Start with empty color (index 0) 

223 

224 for color_name in colors_to_include: 

225 rtf_code = self._name_to_rtf[color_name] 

226 rtf_parts.append(f"\n{rtf_code}") 

227 

228 rtf_parts.append("\n}") 

229 return "".join(rtf_parts) 

230 

231 else: 

232 # Create dense sequential color table for used colors (R2RTF style) 

233 filtered_colors = [ 

234 color for color in used_colors if color and color != "black" 

235 ] 

236 if not filtered_colors: 

237 return "" 

238 

239 validated_colors = self.validate_color_list(filtered_colors) 

240 

241 # Sort colors by their original r2rtf index for consistent ordering 

242 sorted_colors = sorted( 

243 validated_colors, key=lambda x: self._name_to_type[x] 

244 ) 

245 

246 # Create dense color table with only used colors (R2RTF format) 

247 rtf_parts = ["{\\colortbl;"] # Start with empty color (index 0) 

248 

249 # Add each used color sequentially 

250 for color_name in sorted_colors: 

251 rtf_code = self._name_to_rtf[color_name] 

252 rtf_parts.append(f"\n{rtf_code}") 

253 

254 rtf_parts.append("\n}") 

255 return "".join(rtf_parts) 

256 

257 def get_rtf_color_index( 

258 self, color: str, used_colors: Sequence[str] | None = None 

259 ) -> int: 

260 """Get the RTF color table index for a color in the context of a specific document. 

261 

262 Args: 

263 color: Color name to look up 

264 used_colors: List of colors used in the document (determines table structure) 

265 

266 Returns: 

267 Sequential color index in the RTF table (1-based for dense table, original index for full table) 

268 """ 

269 if not color or color == "black": 

270 return 0 # Default/black color 

271 

272 # Use document context if available and no explicit used_colors provided 

273 if used_colors is None and self._current_document_colors is not None: 

274 used_colors = self._current_document_colors 

275 

276 if used_colors is None: 

277 # Use original r2rtf index if no specific color list (full table) 

278 return self.get_color_index(color) 

279 

280 # For document-specific color tables, use sequential indices in dense table 

281 filtered_colors = [c for c in used_colors if c and c != "black"] 

282 if not filtered_colors: 

283 return 0 

284 

285 validated_colors = self.validate_color_list(filtered_colors) 

286 sorted_colors = sorted(validated_colors, key=lambda x: self._name_to_type[x]) 

287 

288 try: 

289 # Return 1-based sequential index in the dense table 

290 return sorted_colors.index(color) + 1 

291 except ValueError: 

292 return 0 

293 

294 def get_all_color_names(self) -> Sequence[str]: 

295 """Get list of all available color names. 

296 

297 Returns: 

298 Sorted list of all 657 color names 

299 """ 

300 return sorted(self._name_to_type.keys()) 

301 

302 def get_color_count(self) -> int: 

303 """Get total number of available colors. 

304 

305 Returns: 

306 Total count of available colors (657) 

307 """ 

308 return len(self._name_to_type) 

309 

310 def collect_document_colors(self, document) -> Sequence[str]: 

311 """Collect all colors used in a document. 

312 

313 Args: 

314 document: RTF document object 

315 

316 Returns: 

317 List of unique color names used in the document 

318 """ 

319 used_colors = set() 

320 

321 # Helper function to extract colors from nested lists 

322 def extract_colors_from_attribute(attr): 

323 if attr is None: 

324 return 

325 if isinstance(attr, str): 

326 if attr: # Skip empty strings 

327 used_colors.add(attr) 

328 elif isinstance(attr, (list, tuple)): 

329 for item in attr: 

330 extract_colors_from_attribute(item) 

331 

332 # Collect colors from RTF body 

333 if document.rtf_body: 

334 bodies = ( 

335 [document.rtf_body] 

336 if not isinstance(document.rtf_body, list) 

337 else document.rtf_body 

338 ) 

339 for body in bodies: 

340 extract_colors_from_attribute(body.text_color) 

341 extract_colors_from_attribute(body.text_background_color) 

342 extract_colors_from_attribute(body.border_color_left) 

343 extract_colors_from_attribute(body.border_color_right) 

344 extract_colors_from_attribute(body.border_color_top) 

345 extract_colors_from_attribute(body.border_color_bottom) 

346 extract_colors_from_attribute(body.border_color_first) 

347 extract_colors_from_attribute(body.border_color_last) 

348 

349 # Collect colors from other components 

350 components = [ 

351 document.rtf_title, 

352 document.rtf_subline, 

353 document.rtf_footnote, 

354 document.rtf_source, 

355 document.rtf_page_header, 

356 document.rtf_page_footer, 

357 ] 

358 

359 for component in components: 

360 if component: 

361 extract_colors_from_attribute(getattr(component, "text_color", None)) 

362 extract_colors_from_attribute( 

363 getattr(component, "text_background_color", None) 

364 ) 

365 

366 # Collect colors from column headers 

367 if document.rtf_column_header: 

368 headers = document.rtf_column_header 

369 if isinstance(headers[0], list): 

370 # Nested format 

371 for header_section in headers: 

372 for header in header_section: 

373 if header: 

374 extract_colors_from_attribute( 

375 getattr(header, "text_color", None) 

376 ) 

377 extract_colors_from_attribute( 

378 getattr(header, "text_background_color", None) 

379 ) 

380 else: 

381 # Flat format 

382 for header in headers: 

383 if header: 

384 extract_colors_from_attribute( 

385 getattr(header, "text_color", None) 

386 ) 

387 extract_colors_from_attribute( 

388 getattr(header, "text_background_color", None) 

389 ) 

390 

391 return list(used_colors) 

392 

393 def set_document_context( 

394 self, document=None, used_colors: Sequence[str] | None = None 

395 ): 

396 """Set the document context for color index resolution. 

397 

398 Args: 

399 document: RTF document to analyze for colors 

400 used_colors: Explicit list of colors used in document 

401 """ 

402 if document is not None and used_colors is None: 

403 used_colors = self.collect_document_colors(document) 

404 self._current_document_colors = used_colors 

405 

406 def clear_document_context(self): 

407 """Clear the document context.""" 

408 self._current_document_colors = None 

409 

410 def get_color_info(self, color: str) -> Mapping[str, Any]: 

411 """Get comprehensive information about a color. 

412 

413 Args: 

414 color: Color name to look up 

415 

416 Returns: 

417 Dictionary with color information (name, index, rgb, rtf_code) 

418 

419 Raises: 

420 ColorValidationError: If color name is invalid 

421 """ 

422 if not self.validate_color(color): 

423 raise ColorValidationError(f"Invalid color name '{color}'") 

424 

425 return { 

426 "name": color, 

427 "index": self._name_to_type[color], 

428 "rgb": self._name_to_rgb[color], 

429 "rtf_code": self._name_to_rtf[color], 

430 } 

431 

432 

433# Global color service instance 

434color_service = ColorService() 

435 

436 

437# Convenience functions for backward compatibility 

438def validate_color(color: str) -> bool: 

439 """Validate if a color name exists in the color table.""" 

440 return color_service.validate_color(color) 

441 

442 

443def get_color_index(color: str) -> int: 

444 """Get the RTF color table index for a color name.""" 

445 return color_service.get_color_index(color) 

446 

447 

448def get_color_suggestions( 

449 partial_color: str, max_suggestions: int = 5 

450) -> Sequence[str]: 

451 """Get color name suggestions for partial matches.""" 

452 return color_service.get_color_suggestions(partial_color, max_suggestions)