Source code for chromo_map.accessibility.contrast

"""Accessibility functions for chromo_map package."""

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from chromo_map.core.color import Color
else:
    from chromo_map.core.color import Color


[docs] def contrast_ratio(color1: "Color", color2: "Color") -> float: """Calculate the contrast ratio between two colors. Uses the WCAG 2.1 formula for contrast ratio. Parameters ---------- color1 : Color The first color. color2 : Color The second color. Returns ------- float The contrast ratio (1:1 to 21:1). Examples -------- Calculate contrast ratio between black and white: .. testcode:: from chromo_map import contrast_ratio ratio = contrast_ratio('black', 'white') print(f"Contrast ratio: {ratio:.1f}:1") .. testoutput:: Contrast ratio: 21.0:1 """ c1 = Color(color1) if not isinstance(color1, Color) else color1 c2 = Color(color2) if not isinstance(color2, Color) else color2 return c1.contrast_ratio(c2)
[docs] def is_accessible(color1: "Color", color2: "Color", level: str = "AA") -> bool: """Check if two colors have sufficient contrast for accessibility. Parameters ---------- color1 : Color The first color (typically text). color2 : Color The second color (typically background). level : str, default 'AA' The WCAG level to check against ('AA' or 'AAA'). Returns ------- bool True if the contrast ratio meets the specified level. Examples -------- Check if black text on white background is accessible: .. testcode:: from chromo_map import is_accessible is_accessible('black', 'white') .. testoutput:: True """ c1 = Color(color1) if not isinstance(color1, Color) else color1 c2 = Color(color2) if not isinstance(color2, Color) else color2 return c1.is_accessible(c2, level)
[docs] def find_accessible_color( base_color: "Color", target_color: "Color", level: str = "AA", adjust_lightness: bool = True, ) -> "Color": """Find an accessible version of a color by adjusting it. Parameters ---------- base_color : Color The color to adjust. target_color : Color The color to ensure accessibility against. level : str, default 'AA' The WCAG level to achieve ('AA' or 'AAA'). adjust_lightness : bool, default True Whether to adjust lightness (True) or brightness/value (False). Returns ------- Color An accessible version of the base color. Examples -------- Find an accessible version of gray against white: .. testcode:: from chromo_map import find_accessible_color accessible = find_accessible_color('#888888', 'white') print(f"Accessible color: {accessible.hex}") .. testoutput:: Accessible color: #595959 """ base = Color(base_color) if not isinstance(base_color, Color) else base_color target = ( Color(target_color) if not isinstance(target_color, Color) else target_color ) required_ratio = 7.0 if level == "AAA" else 4.5 current_ratio = base.contrast_ratio(target) if current_ratio >= required_ratio: return base # Determine if we need to make the color lighter or darker base_luminance = base.luminance target_luminance = target.luminance if base_luminance > target_luminance: # Make base color lighter factor = 1.1 max_factor = 2.0 else: # Make base color darker factor = 0.9 max_factor = 0.1 current_color = base attempts = 0 max_attempts = 50 while ( current_color.contrast_ratio(target) < required_ratio and attempts < max_attempts ): if adjust_lightness: current_color = current_color.adjust_lightness(factor) else: current_color = current_color.adjust_brightness(factor) attempts += 1 # Prevent infinite loops if factor > 1 and factor >= max_factor: break elif factor < 1 and factor <= max_factor: break return current_color
[docs] def find_maximal_contrast_iterative( base_color: "Color", target_color: "Color", level: str = "AA", adjust_lightness: bool = True, step_size: float = 0.1, max_attempts: int = 50, ) -> "Color": """Find maximal contrast using simple iterative approach. This approach incrementally adjusts the color until it meets the contrast requirement. Simple but may not find the optimal solution. Parameters ---------- base_color : Color The color to adjust. target_color : Color The color to ensure accessibility against. level : str, default 'AA' The WCAG level to achieve ('AA' or 'AAA'). adjust_lightness : bool, default True Whether to adjust lightness (True) or brightness/value (False). step_size : float, default 0.1 The step size for adjustments. max_attempts : int, default 50 Maximum number of adjustment attempts. Returns ------- Color The adjusted color with maximal contrast found. Examples -------- Find maximal contrast iteratively: .. testcode:: from chromo_map import find_maximal_contrast_iterative result = find_maximal_contrast_iterative('#888888', 'white') print(f"Iterative result: {result.hex}") .. testoutput:: Iterative result: #595959 """ base = Color(base_color) if not isinstance(base_color, Color) else base_color target = ( Color(target_color) if not isinstance(target_color, Color) else target_color ) required_ratio = 7.0 if level == "AAA" else 4.5 # Determine direction based on luminance base_luminance = base.luminance target_luminance = target.luminance current_color = base best_color = base best_contrast = base.contrast_ratio(target) # Try both directions to find maximum contrast for direction in [1, -1]: temp_color = base attempts = 0 while attempts < max_attempts: try: if adjust_lightness: if direction == 1: next_color = temp_color.adjust_lightness(1 + step_size) else: next_color = temp_color.adjust_lightness(1 - step_size) else: if direction == 1: next_color = temp_color.adjust_brightness(1 + step_size) else: next_color = temp_color.adjust_brightness(1 - step_size) next_contrast = next_color.contrast_ratio(target) if next_contrast > best_contrast: best_contrast = next_contrast best_color = next_color temp_color = next_color else: break # No improvement, stop in this direction attempts += 1 except (ZeroDivisionError, ValueError): break # Stop if adjustment fails return best_color
[docs] def find_maximal_contrast_optimization( base_color: "Color", target_color: "Color", level: str = "AA", method: str = "golden_section", ) -> "Color": """Find maximal contrast using mathematical optimization. This approach uses mathematical optimization techniques to find the adjustment that maximizes contrast ratio while meeting accessibility requirements. Parameters ---------- base_color : Color The color to adjust. target_color : Color The color to ensure accessibility against. level : str, default 'AA' The WCAG level to achieve ('AA' or 'AAA'). method : str, default 'golden_section' The optimization method to use ('golden_section' or 'gradient_descent'). Returns ------- Color The adjusted color with maximal contrast found. Examples -------- Find maximal contrast using optimization: .. testcode:: from chromo_map import find_maximal_contrast_optimization result = find_maximal_contrast_optimization('#888888', 'white') print(f"Optimization result: {result.hex}") .. testoutput:: Optimization result: #595959 """ base = Color(base_color) if not isinstance(base_color, Color) else base_color target = ( Color(target_color) if not isinstance(target_color, Color) else target_color ) required_ratio = 7.0 if level == "AAA" else 4.5 def objective_function(factor: float) -> float: """Objective function to maximize contrast ratio.""" try: # Try both lightness and brightness adjustments lightness_color = base.adjust_lightness(factor) brightness_color = base.adjust_brightness(factor) lightness_contrast = lightness_color.contrast_ratio(target) brightness_contrast = brightness_color.contrast_ratio(target) # Return the maximum contrast achievable return max(lightness_contrast, brightness_contrast) except (ZeroDivisionError, ValueError): return 0.0 # Return minimal contrast if adjustment fails if method == "golden_section": # Golden section search for maximum phi = (1 + 5**0.5) / 2 # Golden ratio resphi = 2 - phi # Search bounds a, b = 0.1, 3.0 tol = 1e-5 # Initial points x1 = a + resphi * (b - a) x2 = a + (1 - resphi) * (b - a) f1 = objective_function(x1) f2 = objective_function(x2) best_factor = x1 if f1 > f2 else x2 best_contrast = max(f1, f2) while abs(b - a) > tol: if f1 > f2: b = x2 x2 = x1 f2 = f1 x1 = a + resphi * (b - a) f1 = objective_function(x1) else: a = x1 x1 = x2 f1 = f2 x2 = a + (1 - resphi) * (b - a) f2 = objective_function(x2) current_best = x1 if f1 > f2 else x2 current_contrast = max(f1, f2) if current_contrast > best_contrast: best_contrast = current_contrast best_factor = current_best # Determine which adjustment method gives better contrast lightness_color = base.adjust_lightness(best_factor) brightness_color = base.adjust_brightness(best_factor) lightness_contrast = lightness_color.contrast_ratio(target) brightness_contrast = brightness_color.contrast_ratio(target) return ( lightness_color if lightness_contrast > brightness_contrast else brightness_color ) elif method == "gradient_descent": # Simple gradient descent factor = 1.0 learning_rate = 0.1 max_iterations = 100 best_factor = factor best_contrast = objective_function(factor) for _ in range(max_iterations): # Numerical gradient estimation epsilon = 1e-6 gradient = ( objective_function(factor + epsilon) - objective_function(factor - epsilon) ) / (2 * epsilon) # Update factor in direction of gradient (maximize) new_factor = factor + learning_rate * gradient new_contrast = objective_function(new_factor) if new_contrast > best_contrast: best_contrast = new_contrast best_factor = new_factor factor = new_factor else: learning_rate *= 0.9 # Reduce learning rate # Early stopping if improvement is minimal if abs(new_contrast - best_contrast) < 1e-6: break # Determine which adjustment method gives better contrast lightness_color = base.adjust_lightness(best_factor) brightness_color = base.adjust_brightness(best_factor) lightness_contrast = lightness_color.contrast_ratio(target) brightness_contrast = brightness_color.contrast_ratio(target) return ( lightness_color if lightness_contrast > brightness_contrast else brightness_color ) else: raise ValueError(f"Unknown optimization method: {method}")