Skip to main content

wcag_contrast/
lib.rs

1//! # wcag-contrast — color contrast for accessibility
2//!
3//! Compute [WCAG 2](https://www.w3.org/TR/WCAG21/#contrast-minimum) color
4//! contrast ratios from hex or RGB colors and check whether they pass AA / AAA
5//! for normal or large text. A small, focused, zero-dependency accessibility
6//! helper for linters, design tools, and CI checks.
7//!
8//! ```
9//! use wcag_contrast::{contrast_hex, level, WcagLevel};
10//!
11//! let ratio = contrast_hex("#ffffff", "#000000").unwrap();
12//! assert_eq!(ratio, 21.0); // maximum possible contrast
13//! assert_eq!(level(ratio, false), WcagLevel::AAA);
14//! ```
15
16use std::fmt;
17
18/// An error produced while parsing a hex color.
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum ParseError {
22    /// The hex string was not 3 or 6 digits (ignoring an optional leading `#`).
23    WrongLength(usize),
24    /// The string contained non-hexadecimal characters.
25    InvalidHex(String),
26}
27
28impl fmt::Display for ParseError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            ParseError::WrongLength(n) => {
32                write!(f, "hex color must have 3 or 6 digits, found {n}")
33            }
34            ParseError::InvalidHex(s) => write!(f, "invalid hex color: {s:?}"),
35        }
36    }
37}
38
39impl std::error::Error for ParseError {}
40
41/// An 8-bit-per-channel sRGB color.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct Rgb {
44    /// Red channel (0–255).
45    pub r: u8,
46    /// Green channel (0–255).
47    pub g: u8,
48    /// Blue channel (0–255).
49    pub b: u8,
50}
51
52impl Rgb {
53    /// Create a color from its channels.
54    #[must_use]
55    pub const fn new(r: u8, g: u8, b: u8) -> Self {
56        Self { r, g, b }
57    }
58
59    /// Parse a hex color (`"#rgb"`, `"#rrggbb"`, with or without `#`,
60    /// case-insensitive).
61    ///
62    /// # Errors
63    ///
64    /// Returns [`ParseError`] if the length is not 3 or 6, or the digits are not
65    /// valid hexadecimal.
66    pub fn from_hex(s: &str) -> Result<Self, ParseError> {
67        let digits = s.strip_prefix('#').unwrap_or(s);
68        // Hex is ASCII; rejecting non-ASCII up front keeps byte indexing safe
69        // (so multibyte input returns Err instead of panicking) and makes the
70        // length count characters.
71        if !digits.is_ascii() {
72            return Err(ParseError::InvalidHex(s.to_owned()));
73        }
74        let bytes = digits.as_bytes();
75        let expanded: [u8; 6] = match bytes.len() {
76            3 => [bytes[0], bytes[0], bytes[1], bytes[1], bytes[2], bytes[2]],
77            6 => [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]],
78            n => return Err(ParseError::WrongLength(n)),
79        };
80        let channel = |hi: u8, lo: u8| -> Result<u8, ParseError> {
81            let pair = [hi, lo];
82            let text =
83                core::str::from_utf8(&pair).map_err(|_| ParseError::InvalidHex(s.to_owned()))?;
84            u8::from_str_radix(text, 16).map_err(|_| ParseError::InvalidHex(s.to_owned()))
85        };
86        Ok(Self::new(
87            channel(expanded[0], expanded[1])?,
88            channel(expanded[2], expanded[3])?,
89            channel(expanded[4], expanded[5])?,
90        ))
91    }
92
93    /// The WCAG relative luminance of this color, in `0.0..=1.0`.
94    #[must_use]
95    pub fn relative_luminance(&self) -> f64 {
96        fn linearize(channel: u8) -> f64 {
97            let c = f64::from(channel) / 255.0;
98            if c <= 0.03928 {
99                c / 12.92
100            } else {
101                ((c + 0.055) / 1.055).powf(2.4)
102            }
103        }
104        0.2126 * linearize(self.r) + 0.7152 * linearize(self.g) + 0.0722 * linearize(self.b)
105    }
106}
107
108/// The WCAG 2 contrast ratio between two colors, from `1.0` (identical) to
109/// `21.0` (black vs white). Order-independent.
110#[must_use]
111pub fn contrast_ratio(a: Rgb, b: Rgb) -> f64 {
112    let la = a.relative_luminance();
113    let lb = b.relative_luminance();
114    let (lighter, darker) = if la >= lb { (la, lb) } else { (lb, la) };
115    (lighter + 0.05) / (darker + 0.05)
116}
117
118/// Parse two hex colors and return their contrast ratio.
119///
120/// # Errors
121///
122/// Returns [`ParseError`] if either color fails to parse.
123pub fn contrast_hex(a: &str, b: &str) -> Result<f64, ParseError> {
124    Ok(contrast_ratio(Rgb::from_hex(a)?, Rgb::from_hex(b)?))
125}
126
127/// The highest WCAG conformance level a contrast `ratio` meets for the given
128/// text size.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
130pub enum WcagLevel {
131    /// Does not meet AA.
132    Fail,
133    /// Meets AA (4.5:1 normal, 3:1 large) but not AAA.
134    AA,
135    /// Meets AAA (7:1 normal, 4.5:1 large).
136    AAA,
137}
138
139impl fmt::Display for WcagLevel {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        f.write_str(match self {
142            WcagLevel::Fail => "Fail",
143            WcagLevel::AA => "AA",
144            WcagLevel::AAA => "AAA",
145        })
146    }
147}
148
149/// Classify a contrast `ratio` into the highest [`WcagLevel`] it passes for
150/// normal or `large_text` (≥18pt, or ≥14pt bold).
151#[must_use]
152pub fn level(ratio: f64, large_text: bool) -> WcagLevel {
153    let (aa, aaa) = if large_text { (3.0, 4.5) } else { (4.5, 7.0) };
154    if ratio >= aaa {
155        WcagLevel::AAA
156    } else if ratio >= aa {
157        WcagLevel::AA
158    } else {
159        WcagLevel::Fail
160    }
161}