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
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-25 22: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 collections.abc import Mapping, Sequence
8from typing import Any
10from rtflite.dictionary.color_table import (
11 color_table,
12 name_to_rgb,
13 name_to_rtf,
14 name_to_type,
15)
18class ColorValidationError(ValueError):
19 """Raised when a color validation fails."""
21 pass
24class ColorService:
25 """Service for color validation, lookup, and RTF generation operations."""
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 )
37 def validate_color(self, color: str) -> bool:
38 """Validate if a color name exists in the color table.
40 Args:
41 color: Color name to validate
43 Returns:
44 True if color exists, False otherwise
45 """
46 return color in self._name_to_type
48 def get_color_index(self, color: str) -> int:
49 """Get the RTF color table index for a color name.
51 Args:
52 color: Color name to look up
54 Returns:
55 Color index (1-based) for RTF color table
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 )
69 return self._name_to_type[color]
71 def get_color_rgb(self, color: str) -> tuple[int, int, int]:
72 """Get RGB values for a color name.
74 Args:
75 color: Color name to look up
77 Returns:
78 RGB tuple (red, green, blue) with values 0-255
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}'")
86 return self._name_to_rgb[color]
88 def get_color_rtf_code(self, color: str) -> str:
89 """Get RTF color definition code for a color name.
91 Args:
92 color: Color name to look up
94 Returns:
95 RTF color definition (e.g., "\\red255\\green0\\blue0;")
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}'")
103 return self._name_to_rtf[color]
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.
110 Args:
111 partial_color: Partial or misspelled color name
112 max_suggestions: Maximum number of suggestions to return
114 Returns:
115 List of suggested color names
116 """
117 partial_lower = partial_color.lower()
119 # Exact match first
120 if partial_lower in self._name_to_type:
121 return [partial_lower]
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
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
140 return suggestions
142 def validate_color_list(self, colors: str | Sequence[str]) -> Sequence[str]:
143 """Validate a list of colors, converting single color to list.
145 Args:
146 colors: Single color name or list/tuple of color names
148 Returns:
149 Validated list of color names
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 )
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 )
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 )
181 validated_colors.append(color)
183 return validated_colors
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.
188 Args:
189 used_colors: List of color names used in document
191 Returns:
192 True if color table is needed, False otherwise
193 """
194 if not used_colors:
195 return False
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
203 def generate_rtf_color_table(self, used_colors: Sequence[str] | None = None) -> str:
204 """Generate RTF color table definition for used colors.
206 Args:
207 used_colors: List of color names used in document. If None, includes all 657 colors.
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 ""
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])
221 # Generate full R2RTF color table
222 rtf_parts = ["{\\colortbl\n;"] # Start with empty color (index 0)
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}")
228 rtf_parts.append("\n}")
229 return "".join(rtf_parts)
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 ""
239 validated_colors = self.validate_color_list(filtered_colors)
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 )
246 # Create dense color table with only used colors (R2RTF format)
247 rtf_parts = ["{\\colortbl;"] # Start with empty color (index 0)
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}")
254 rtf_parts.append("\n}")
255 return "".join(rtf_parts)
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.
262 Args:
263 color: Color name to look up
264 used_colors: List of colors used in the document (determines table structure)
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
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
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)
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
285 validated_colors = self.validate_color_list(filtered_colors)
286 sorted_colors = sorted(validated_colors, key=lambda x: self._name_to_type[x])
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
294 def get_all_color_names(self) -> Sequence[str]:
295 """Get list of all available color names.
297 Returns:
298 Sorted list of all 657 color names
299 """
300 return sorted(self._name_to_type.keys())
302 def get_color_count(self) -> int:
303 """Get total number of available colors.
305 Returns:
306 Total count of available colors (657)
307 """
308 return len(self._name_to_type)
310 def collect_document_colors(self, document) -> Sequence[str]:
311 """Collect all colors used in a document.
313 Args:
314 document: RTF document object
316 Returns:
317 List of unique color names used in the document
318 """
319 used_colors = set()
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)
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)
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 ]
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 )
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 )
391 return list(used_colors)
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.
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
406 def clear_document_context(self):
407 """Clear the document context."""
408 self._current_document_colors = None
410 def get_color_info(self, color: str) -> Mapping[str, Any]:
411 """Get comprehensive information about a color.
413 Args:
414 color: Color name to look up
416 Returns:
417 Dictionary with color information (name, index, rgb, rtf_code)
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}'")
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 }
433# Global color service instance
434color_service = ColorService()
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)
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)
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)