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
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-28 05:09 +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 "Invalid color name "
67 f"'{color}'. Must be one of 657 supported colors.{suggestion_text}"
68 )
70 return self._name_to_type[color]
72 def get_color_rgb(self, color: str) -> tuple[int, int, int]:
73 """Get RGB values for a color name.
75 Args:
76 color: Color name to look up
78 Returns:
79 RGB tuple (red, green, blue) with values 0-255
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}'")
87 return self._name_to_rgb[color]
89 def get_color_rtf_code(self, color: str) -> str:
90 """Get RTF color definition code for a color name.
92 Args:
93 color: Color name to look up
95 Returns:
96 RTF color definition (e.g., "\\red255\\green0\\blue0;")
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}'")
104 return self._name_to_rtf[color]
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.
111 Args:
112 partial_color: Partial or misspelled color name
113 max_suggestions: Maximum number of suggestions to return
115 Returns:
116 List of suggested color names
117 """
118 partial_lower = partial_color.lower()
120 # Exact match first
121 if partial_lower in self._name_to_type:
122 return [partial_lower]
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
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
141 return suggestions
143 def validate_color_list(self, colors: str | Sequence[str]) -> Sequence[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: Sequence[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: Sequence[str] | None = None) -> str:
205 """Generate RTF color table definition for used colors.
207 Args:
208 used_colors: Color names used in the document.
209 If None, includes all 657 colors.
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 ""
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])
223 # Generate full R2RTF color table
224 rtf_parts = ["{\\colortbl\n;"] # Start with empty color (index 0)
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}")
230 rtf_parts.append("\n}")
231 return "".join(rtf_parts)
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 ""
241 validated_colors = self.validate_color_list(filtered_colors)
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 )
248 # Create dense color table with only used colors (R2RTF format)
249 rtf_parts = ["{\\colortbl;"] # Start with empty color (index 0)
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}")
256 rtf_parts.append("\n}")
257 return "".join(rtf_parts)
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
265 Args:
266 color: Color name to look up
267 used_colors: Colors used in the document
268 (determines table structure)
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
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
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)
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
290 validated_colors = self.validate_color_list(filtered_colors)
291 sorted_colors = sorted(validated_colors, key=lambda x: self._name_to_type[x])
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
299 def get_all_color_names(self) -> Sequence[str]:
300 """Get list of all available color names.
302 Returns:
303 Sorted list of all 657 color names
304 """
305 return sorted(self._name_to_type.keys())
307 def get_color_count(self) -> int:
308 """Get total number of available colors.
310 Returns:
311 Total count of available colors (657)
312 """
313 return len(self._name_to_type)
315 def collect_document_colors(self, document) -> Sequence[str]:
316 """Collect all colors used in a document.
318 Args:
319 document: RTF document object
321 Returns:
322 List of unique color names used in the document
323 """
324 used_colors = set()
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)
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)
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 ]
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 )
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 )
396 return list(used_colors)
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.
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
411 def clear_document_context(self):
412 """Clear the document context."""
413 self._current_document_colors = None
415 def get_color_info(self, color: str) -> Mapping[str, Any]:
416 """Get comprehensive information about a color.
418 Args:
419 color: Color name to look up
421 Returns:
422 Dictionary with color information (name, index, rgb, rtf_code)
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}'")
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 }
438# Global color service instance
439color_service = ColorService()
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)
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)
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)