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

168 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-28 05:09 +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 "Invalid color name " 

67 f"'{color}'. Must be one of 657 supported colors.{suggestion_text}" 

68 ) 

69 

70 return self._name_to_type[color] 

71 

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

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

74 

75 Args: 

76 color: Color name to look up 

77 

78 Returns: 

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

80 

81 Raises: 

82 ColorValidationError: If color name is invalid 

83 """ 

84 if not self.validate_color(color): 

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

86 

87 return self._name_to_rgb[color] 

88 

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

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

91 

92 Args: 

93 color: Color name to look up 

94 

95 Returns: 

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

97 

98 Raises: 

99 ColorValidationError: If color name is invalid 

100 """ 

101 if not self.validate_color(color): 

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

103 

104 return self._name_to_rtf[color] 

105 

106 def get_color_suggestions( 

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

108 ) -> Sequence[str]: 

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

110 

111 Args: 

112 partial_color: Partial or misspelled color name 

113 max_suggestions: Maximum number of suggestions to return 

114 

115 Returns: 

116 List of suggested color names 

117 """ 

118 partial_lower = partial_color.lower() 

119 

120 # Exact match first 

121 if partial_lower in self._name_to_type: 

122 return [partial_lower] 

123 

124 # Find colors that contain the partial string 

125 suggestions = [] 

126 for color_name in self._name_to_type: 

127 if partial_lower in color_name.lower(): 

128 suggestions.append(color_name) 

129 if len(suggestions) >= max_suggestions: 

130 break 

131 

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

133 if not suggestions: 

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

135 for color_name in self._name_to_type: 

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

137 suggestions.append(color_name) 

138 if len(suggestions) >= max_suggestions: 

139 break 

140 

141 return suggestions 

142 

143 def validate_color_list(self, colors: str | Sequence[str]) -> Sequence[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: Sequence[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: Sequence[str] | None = None) -> str: 

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

206 

207 Args: 

208 used_colors: Color names used in the document. 

209 If None, includes all 657 colors. 

210 

211 Returns: 

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

213 """ 

214 # Check if color table is actually needed 

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

216 return "" 

217 

218 if used_colors is None: 

219 # Include all colors in full r2rtf format 

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

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

222 

223 # Generate full R2RTF color table 

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

225 

226 for color_name in colors_to_include: 

227 rtf_code = self._name_to_rtf[color_name] 

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

229 

230 rtf_parts.append("\n}") 

231 return "".join(rtf_parts) 

232 

233 else: 

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

235 filtered_colors = [ 

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

237 ] 

238 if not filtered_colors: 

239 return "" 

240 

241 validated_colors = self.validate_color_list(filtered_colors) 

242 

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

244 sorted_colors = sorted( 

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

246 ) 

247 

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

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

250 

251 # Add each used color sequentially 

252 for color_name in sorted_colors: 

253 rtf_code = self._name_to_rtf[color_name] 

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

255 

256 rtf_parts.append("\n}") 

257 return "".join(rtf_parts) 

258 

259 def get_rtf_color_index( 

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

261 ) -> int: 

262 """Get the RTF color table index for a color in the context of 

263 a specific document 

264 

265 Args: 

266 color: Color name to look up 

267 used_colors: Colors used in the document 

268 (determines table structure) 

269 

270 Returns: 

271 Sequential color index in the RTF table (1-based for dense tables, 

272 original index for full tables) 

273 """ 

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

275 return 0 # Default/black color 

276 

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

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

279 used_colors = self._current_document_colors 

280 

281 if used_colors is None: 

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

283 return self.get_color_index(color) 

284 

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

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

287 if not filtered_colors: 

288 return 0 

289 

290 validated_colors = self.validate_color_list(filtered_colors) 

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

292 

293 try: 

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

295 return sorted_colors.index(color) + 1 

296 except ValueError: 

297 return 0 

298 

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

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

301 

302 Returns: 

303 Sorted list of all 657 color names 

304 """ 

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

306 

307 def get_color_count(self) -> int: 

308 """Get total number of available colors. 

309 

310 Returns: 

311 Total count of available colors (657) 

312 """ 

313 return len(self._name_to_type) 

314 

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

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

317 

318 Args: 

319 document: RTF document object 

320 

321 Returns: 

322 List of unique color names used in the document 

323 """ 

324 used_colors = set() 

325 

326 # Helper function to extract colors from nested lists 

327 def extract_colors_from_attribute(attr): 

328 if attr is None: 

329 return 

330 if isinstance(attr, str): 

331 if attr: # Skip empty strings 

332 used_colors.add(attr) 

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

334 for item in attr: 

335 extract_colors_from_attribute(item) 

336 

337 # Collect colors from RTF body 

338 if document.rtf_body: 

339 bodies = ( 

340 [document.rtf_body] 

341 if not isinstance(document.rtf_body, list) 

342 else document.rtf_body 

343 ) 

344 for body in bodies: 

345 extract_colors_from_attribute(body.text_color) 

346 extract_colors_from_attribute(body.text_background_color) 

347 extract_colors_from_attribute(body.border_color_left) 

348 extract_colors_from_attribute(body.border_color_right) 

349 extract_colors_from_attribute(body.border_color_top) 

350 extract_colors_from_attribute(body.border_color_bottom) 

351 extract_colors_from_attribute(body.border_color_first) 

352 extract_colors_from_attribute(body.border_color_last) 

353 

354 # Collect colors from other components 

355 components = [ 

356 document.rtf_title, 

357 document.rtf_subline, 

358 document.rtf_footnote, 

359 document.rtf_source, 

360 document.rtf_page_header, 

361 document.rtf_page_footer, 

362 ] 

363 

364 for component in components: 

365 if component: 

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

367 extract_colors_from_attribute( 

368 getattr(component, "text_background_color", None) 

369 ) 

370 

371 # Collect colors from column headers 

372 if document.rtf_column_header: 

373 headers = document.rtf_column_header 

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

375 # Nested format 

376 for header_section in headers: 

377 for header in header_section: 

378 if header: 

379 extract_colors_from_attribute( 

380 getattr(header, "text_color", None) 

381 ) 

382 extract_colors_from_attribute( 

383 getattr(header, "text_background_color", None) 

384 ) 

385 else: 

386 # Flat format 

387 for header in headers: 

388 if header: 

389 extract_colors_from_attribute( 

390 getattr(header, "text_color", None) 

391 ) 

392 extract_colors_from_attribute( 

393 getattr(header, "text_background_color", None) 

394 ) 

395 

396 return list(used_colors) 

397 

398 def set_document_context( 

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

400 ): 

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

402 

403 Args: 

404 document: RTF document to analyze for colors 

405 used_colors: Explicit list of colors used in document 

406 """ 

407 if document is not None and used_colors is None: 

408 used_colors = self.collect_document_colors(document) 

409 self._current_document_colors = used_colors 

410 

411 def clear_document_context(self): 

412 """Clear the document context.""" 

413 self._current_document_colors = None 

414 

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

416 """Get comprehensive information about a color. 

417 

418 Args: 

419 color: Color name to look up 

420 

421 Returns: 

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

423 

424 Raises: 

425 ColorValidationError: If color name is invalid 

426 """ 

427 if not self.validate_color(color): 

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

429 

430 return { 

431 "name": color, 

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

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

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

435 } 

436 

437 

438# Global color service instance 

439color_service = ColorService() 

440 

441 

442# Convenience functions for backward compatibility 

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

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

445 return color_service.validate_color(color) 

446 

447 

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

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

450 return color_service.get_color_index(color) 

451 

452 

453def get_color_suggestions( 

454 partial_color: str, max_suggestions: int = 5 

455) -> Sequence[str]: 

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

457 return color_service.get_color_suggestions(partial_color, max_suggestions)