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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 16:35 +0000
1"""Color management service for RTF documents.
3This service provides comprehensive color validation, lookup, and RTF generation
4capabilities using the full 657-color table from r2rtf.
5"""
7from typing import Any
9from rtflite.dictionary.color_table import (
10 color_table,
11 name_to_rgb,
12 name_to_rtf,
13 name_to_type,
14)
17class ColorValidationError(ValueError):
18 """Raised when a color validation fails."""
20 pass
23class ColorService:
24 """Service for color validation, lookup, and RTF generation operations."""
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 )
36 def validate_color(self, color: str) -> bool:
37 """Validate if a color name exists in the color table.
39 Args:
40 color: Color name to validate
42 Returns:
43 True if color exists, False otherwise
44 """
45 return color in self._name_to_type
47 def get_color_index(self, color: str) -> int:
48 """Get the RTF color table index for a color name.
50 Args:
51 color: Color name to look up
53 Returns:
54 Color index (1-based) for RTF color table
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 )
68 return self._name_to_type[color]
70 def get_color_rgb(self, color: str) -> tuple[int, int, int]:
71 """Get RGB values for a color name.
73 Args:
74 color: Color name to look up
76 Returns:
77 RGB tuple (red, green, blue) with values 0-255
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}'")
85 return self._name_to_rgb[color]
87 def get_color_rtf_code(self, color: str) -> str:
88 """Get RTF color definition code for a color name.
90 Args:
91 color: Color name to look up
93 Returns:
94 RTF color definition (e.g., "\\red255\\green0\\blue0;")
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}'")
102 return self._name_to_rtf[color]
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.
109 Args:
110 partial_color: Partial or misspelled color name
111 max_suggestions: Maximum number of suggestions to return
113 Returns:
114 List of suggested color names
115 """
116 partial_lower = partial_color.lower()
118 # Exact match first
119 if partial_lower in self._name_to_type:
120 return [partial_lower]
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
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
139 return suggestions
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.
146 Args:
147 colors: Single color name or list/tuple of color names
149 Returns:
150 Validated list of color names
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 )
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 )
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 )
182 validated_colors.append(color)
184 return validated_colors
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.
189 Args:
190 used_colors: List of color names used in document
192 Returns:
193 True if color table is needed, False otherwise
194 """
195 if not used_colors:
196 return False
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
204 def generate_rtf_color_table(self, used_colors: list[str] | None = None) -> str:
205 """Generate RTF color table definition for used colors.
207 Args:
208 used_colors: List of color names used in document. If None, includes all 657 colors.
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 ""
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])
222 # Generate full R2RTF color table
223 rtf_parts = ["{\\colortbl\n;"] # Start with empty color (index 0)
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}")
229 rtf_parts.append("\n}")
230 return "".join(rtf_parts)
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 ""
240 validated_colors = self.validate_color_list(filtered_colors)
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 )
247 # Create dense color table with only used colors (R2RTF format)
248 rtf_parts = ["{\\colortbl;"] # Start with empty color (index 0)
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}")
255 rtf_parts.append("\n}")
256 return "".join(rtf_parts)
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.
263 Args:
264 color: Color name to look up
265 used_colors: List of colors used in the document (determines table structure)
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
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
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)
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
286 validated_colors = self.validate_color_list(filtered_colors)
287 sorted_colors = sorted(validated_colors, key=lambda x: self._name_to_type[x])
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
295 def get_all_color_names(self) -> list[str]:
296 """Get list of all available color names.
298 Returns:
299 Sorted list of all 657 color names
300 """
301 return sorted(self._name_to_type.keys())
303 def get_color_count(self) -> int:
304 """Get total number of available colors.
306 Returns:
307 Total count of available colors (657)
308 """
309 return len(self._name_to_type)
311 def collect_document_colors(self, document) -> list[str]:
312 """Collect all colors used in a document.
314 Args:
315 document: RTF document object
317 Returns:
318 List of unique color names used in the document
319 """
320 used_colors = set()
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)
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)
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 ]
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 )
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 )
392 return list(used_colors)
394 def set_document_context(self, document=None, used_colors: list[str] | None = None):
395 """Set the document context for color index resolution.
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
405 def clear_document_context(self):
406 """Clear the document context."""
407 self._current_document_colors = None
409 def get_color_info(self, color: str) -> dict[str, Any]:
410 """Get comprehensive information about a color.
412 Args:
413 color: Color name to look up
415 Returns:
416 Dictionary with color information (name, index, rgb, rtf_code)
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}'")
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 }
432# Global color service instance
433color_service = ColorService()
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)
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)
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)