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

167 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 16: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 typing import Any 

8 

9from rtflite.dictionary.color_table import ( 

10 color_table, 

11 name_to_rgb, 

12 name_to_rtf, 

13 name_to_type, 

14) 

15 

16 

17class ColorValidationError(ValueError): 

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

19 

20 pass 

21 

22 

23class ColorService: 

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

25 

26 def __init__(self): 

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

28 self._color_table = color_table 

29 self._name_to_type = name_to_type 

30 self._name_to_rgb = name_to_rgb 

31 self._name_to_rtf = name_to_rtf 

32 self._current_document_colors = ( 

33 None # Context for current document being encoded 

34 ) 

35 

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

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

38 

39 Args: 

40 color: Color name to validate 

41 

42 Returns: 

43 True if color exists, False otherwise 

44 """ 

45 return color in self._name_to_type 

46 

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

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

49 

50 Args: 

51 color: Color name to look up 

52 

53 Returns: 

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

55 

56 Raises: 

57 ColorValidationError: If color name is invalid 

58 """ 

59 if not self.validate_color(color): 

60 suggestions = self.get_color_suggestions(color) 

61 suggestion_text = ( 

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

63 ) 

64 raise ColorValidationError( 

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

66 ) 

67 

68 return self._name_to_type[color] 

69 

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

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

72 

73 Args: 

74 color: Color name to look up 

75 

76 Returns: 

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

78 

79 Raises: 

80 ColorValidationError: If color name is invalid 

81 """ 

82 if not self.validate_color(color): 

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

84 

85 return self._name_to_rgb[color] 

86 

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

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

89 

90 Args: 

91 color: Color name to look up 

92 

93 Returns: 

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

95 

96 Raises: 

97 ColorValidationError: If color name is invalid 

98 """ 

99 if not self.validate_color(color): 

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

101 

102 return self._name_to_rtf[color] 

103 

104 def get_color_suggestions( 

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

106 ) -> list[str]: 

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

108 

109 Args: 

110 partial_color: Partial or misspelled color name 

111 max_suggestions: Maximum number of suggestions to return 

112 

113 Returns: 

114 List of suggested color names 

115 """ 

116 partial_lower = partial_color.lower() 

117 

118 # Exact match first 

119 if partial_lower in self._name_to_type: 

120 return [partial_lower] 

121 

122 # Find colors that contain the partial string 

123 suggestions = [] 

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

125 if partial_lower in color_name.lower(): 

126 suggestions.append(color_name) 

127 if len(suggestions) >= max_suggestions: 

128 break 

129 

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

131 if not suggestions: 

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

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

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

135 suggestions.append(color_name) 

136 if len(suggestions) >= max_suggestions: 

137 break 

138 

139 return suggestions 

140 

141 def validate_color_list( 

142 self, colors: str | list[str] | tuple[str, ...] 

143 ) -> list[str]: 

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

145 

146 Args: 

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

148 

149 Returns: 

150 Validated list of color names 

151 

152 Raises: 

153 ColorValidationError: If any color name is invalid 

154 """ 

155 if isinstance(colors, str): 

156 colors = [colors] 

157 elif isinstance(colors, tuple): 

158 colors = list(colors) 

159 elif not isinstance(colors, list): 

160 raise ColorValidationError( 

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

162 ) 

163 

164 validated_colors = [] 

165 for i, color in enumerate(colors): 

166 if not isinstance(color, str): 

167 raise ColorValidationError( 

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

169 ) 

170 

171 if not self.validate_color(color): 

172 suggestions = self.get_color_suggestions(color) 

173 suggestion_text = ( 

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

175 if suggestions 

176 else "" 

177 ) 

178 raise ColorValidationError( 

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

180 ) 

181 

182 validated_colors.append(color) 

183 

184 return validated_colors 

185 

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

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

188 

189 Args: 

190 used_colors: List of color names used in document 

191 

192 Returns: 

193 True if color table is needed, False otherwise 

194 """ 

195 if not used_colors: 

196 return False 

197 

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

199 significant_colors = [ 

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

201 ] 

202 return len(significant_colors) > 0 

203 

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

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

206 

207 Args: 

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

209 

210 Returns: 

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

212 """ 

213 # Check if color table is actually needed 

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

215 return "" 

216 

217 if used_colors is None: 

218 # Include all colors in full r2rtf format 

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

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

221 

222 # Generate full R2RTF color table 

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

224 

225 for color_name in colors_to_include: 

226 rtf_code = self._name_to_rtf[color_name] 

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

228 

229 rtf_parts.append("\n}") 

230 return "".join(rtf_parts) 

231 

232 else: 

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

234 filtered_colors = [ 

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

236 ] 

237 if not filtered_colors: 

238 return "" 

239 

240 validated_colors = self.validate_color_list(filtered_colors) 

241 

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

243 sorted_colors = sorted( 

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

245 ) 

246 

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

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

249 

250 # Add each used color sequentially 

251 for color_name in sorted_colors: 

252 rtf_code = self._name_to_rtf[color_name] 

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

254 

255 rtf_parts.append("\n}") 

256 return "".join(rtf_parts) 

257 

258 def get_rtf_color_index( 

259 self, color: str, used_colors: list[str] | None = None 

260 ) -> int: 

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

262 

263 Args: 

264 color: Color name to look up 

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

266 

267 Returns: 

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

269 """ 

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

271 return 0 # Default/black color 

272 

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

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

275 used_colors = self._current_document_colors 

276 

277 if used_colors is None: 

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

279 return self.get_color_index(color) 

280 

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

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

283 if not filtered_colors: 

284 return 0 

285 

286 validated_colors = self.validate_color_list(filtered_colors) 

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

288 

289 try: 

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

291 return sorted_colors.index(color) + 1 

292 except ValueError: 

293 return 0 

294 

295 def get_all_color_names(self) -> list[str]: 

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

297 

298 Returns: 

299 Sorted list of all 657 color names 

300 """ 

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

302 

303 def get_color_count(self) -> int: 

304 """Get total number of available colors. 

305 

306 Returns: 

307 Total count of available colors (657) 

308 """ 

309 return len(self._name_to_type) 

310 

311 def collect_document_colors(self, document) -> list[str]: 

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

313 

314 Args: 

315 document: RTF document object 

316 

317 Returns: 

318 List of unique color names used in the document 

319 """ 

320 used_colors = set() 

321 

322 # Helper function to extract colors from nested lists 

323 def extract_colors_from_attribute(attr): 

324 if attr is None: 

325 return 

326 if isinstance(attr, str): 

327 if attr: # Skip empty strings 

328 used_colors.add(attr) 

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

330 for item in attr: 

331 extract_colors_from_attribute(item) 

332 

333 # Collect colors from RTF body 

334 if document.rtf_body: 

335 bodies = ( 

336 [document.rtf_body] 

337 if not isinstance(document.rtf_body, list) 

338 else document.rtf_body 

339 ) 

340 for body in bodies: 

341 extract_colors_from_attribute(body.text_color) 

342 extract_colors_from_attribute(body.text_background_color) 

343 extract_colors_from_attribute(body.border_color_left) 

344 extract_colors_from_attribute(body.border_color_right) 

345 extract_colors_from_attribute(body.border_color_top) 

346 extract_colors_from_attribute(body.border_color_bottom) 

347 extract_colors_from_attribute(body.border_color_first) 

348 extract_colors_from_attribute(body.border_color_last) 

349 

350 # Collect colors from other components 

351 components = [ 

352 document.rtf_title, 

353 document.rtf_subline, 

354 document.rtf_footnote, 

355 document.rtf_source, 

356 document.rtf_page_header, 

357 document.rtf_page_footer, 

358 ] 

359 

360 for component in components: 

361 if component: 

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

363 extract_colors_from_attribute( 

364 getattr(component, "text_background_color", None) 

365 ) 

366 

367 # Collect colors from column headers 

368 if document.rtf_column_header: 

369 headers = document.rtf_column_header 

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

371 # Nested format 

372 for header_section in headers: 

373 for header in header_section: 

374 if header: 

375 extract_colors_from_attribute( 

376 getattr(header, "text_color", None) 

377 ) 

378 extract_colors_from_attribute( 

379 getattr(header, "text_background_color", None) 

380 ) 

381 else: 

382 # Flat format 

383 for header in headers: 

384 if header: 

385 extract_colors_from_attribute( 

386 getattr(header, "text_color", None) 

387 ) 

388 extract_colors_from_attribute( 

389 getattr(header, "text_background_color", None) 

390 ) 

391 

392 return list(used_colors) 

393 

394 def set_document_context(self, document=None, used_colors: list[str] | None = None): 

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

396 

397 Args: 

398 document: RTF document to analyze for colors 

399 used_colors: Explicit list of colors used in document 

400 """ 

401 if document is not None and used_colors is None: 

402 used_colors = self.collect_document_colors(document) 

403 self._current_document_colors = used_colors 

404 

405 def clear_document_context(self): 

406 """Clear the document context.""" 

407 self._current_document_colors = None 

408 

409 def get_color_info(self, color: str) -> dict[str, Any]: 

410 """Get comprehensive information about a color. 

411 

412 Args: 

413 color: Color name to look up 

414 

415 Returns: 

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

417 

418 Raises: 

419 ColorValidationError: If color name is invalid 

420 """ 

421 if not self.validate_color(color): 

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

423 

424 return { 

425 "name": color, 

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

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

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

429 } 

430 

431 

432# Global color service instance 

433color_service = ColorService() 

434 

435 

436# Convenience functions for backward compatibility 

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

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

439 return color_service.validate_color(color) 

440 

441 

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

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

444 return color_service.get_color_index(color) 

445 

446 

447def get_color_suggestions(partial_color: str, max_suggestions: int = 5) -> list[str]: 

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

449 return color_service.get_color_suggestions(partial_color, max_suggestions)