Skip to main content

apcach_rs/
lib.rs

1use std::fmt;
2
3const SRGB_TO_XYZ: [[f64; 3]; 3] = [
4    [0.41239079926595934, 0.357584339383878, 0.1804807884018343],
5    [0.21263900587151027, 0.715168678767756, 0.07219231536073371],
6    [0.01933081871559182, 0.11919477979462599, 0.9505321522496607],
7];
8const XYZ_TO_SRGB: [[f64; 3]; 3] = [
9    [3.240969941904521, -1.537383177570093, -0.4986107602930034],
10    [-0.9692436362808798, 1.8759675015077206, 0.04155505740717561],
11    [
12        0.05563007969699361,
13        -0.20397695888897652,
14        1.0569715142428786,
15    ],
16];
17const P3_TO_XYZ: [[f64; 3]; 3] = [
18    [0.4865709486482162, 0.26566769316909306, 0.1982172852343625],
19    [0.2289745640697488, 0.6917385218365064, 0.079286914093745],
20    [0.0, 0.04511338185890264, 1.043944368900976],
21];
22const XYZ_TO_P3: [[f64; 3]; 3] = [
23    [2.493496911941425, -0.9313836179191239, -0.40271078445071684],
24    [
25        -0.8294889695615747,
26        1.7626640603183463,
27        0.023624685841943577,
28    ],
29    [
30        0.03584583024378447,
31        -0.07617238926804182,
32        0.9568845240076872,
33    ],
34];
35
36const APCA_MAIN_TRC: f64 = 2.4;
37const APCA_NORM_BG: f64 = 0.56;
38const APCA_NORM_TXT: f64 = 0.57;
39const APCA_REV_TXT: f64 = 0.62;
40const APCA_REV_BG: f64 = 0.65;
41const APCA_BLK_THRS: f64 = 0.022;
42const APCA_BLK_CLMP: f64 = 1.414;
43const APCA_SCALE_BOW: f64 = 1.14;
44const APCA_SCALE_WOB: f64 = 1.14;
45const APCA_LO_BOW_OFFSET: f64 = 0.027;
46const APCA_LO_WOB_OFFSET: f64 = 0.027;
47const APCA_DELTA_Y_MIN: f64 = 0.0005;
48const APCA_LO_CLIP: f64 = 0.1;
49
50const SRGB_APCA_COEFFS: [f64; 3] = [0.2126729, 0.7151522, 0.0721750];
51const P3_APCA_COEFFS: [f64; 3] = [0.228982959480578, 0.691749262585238, 0.0792677779341829];
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ColorSpace {
55    P3,
56    Srgb,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ContrastModel {
61    Apca,
62    Wcag,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum SearchDirection {
67    Auto,
68    Lighter,
69    Darker,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub enum CssFormat {
74    Oklch,
75    Rgb,
76    Hex,
77    P3,
78    FigmaP3,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub enum Chroma {
83    Fixed(f64),
84    Max { cap: f64 },
85}
86
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub enum Color {
89    Oklch { l: f64, c: f64, h: f64, alpha: f64 },
90    Srgb { r: f64, g: f64, b: f64, alpha: f64 },
91    DisplayP3 { r: f64, g: f64, b: f64, alpha: f64 },
92}
93
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct ContrastConfig {
96    pub bg_color: Option<Color>,
97    pub fg_color: Option<Color>,
98    pub cr: f64,
99    pub contrast_model: ContrastModel,
100    pub search_direction: SearchDirection,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq)]
104pub struct ApcachColor {
105    pub alpha: f64,
106    pub chroma: f64,
107    pub color_space: ColorSpace,
108    pub contrast_config: ContrastConfig,
109    pub hue: f64,
110    pub lightness: f64,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub struct ContrastInput(pub ContrastConfig);
115
116#[derive(Debug, Clone, Copy, PartialEq)]
117pub enum Error {
118    InvalidContrast,
119    InvalidChroma,
120    InvalidHue,
121    InvalidAlpha,
122    InvalidRgbComponent,
123}
124
125impl fmt::Display for Error {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            Self::InvalidContrast => f.write_str("invalid contrast value"),
129            Self::InvalidChroma => f.write_str("invalid chroma value"),
130            Self::InvalidHue => f.write_str("invalid hue value"),
131            Self::InvalidAlpha => f.write_str("invalid alpha value"),
132            Self::InvalidRgbComponent => f.write_str("invalid rgb component value"),
133        }
134    }
135}
136
137impl std::error::Error for Error {}
138
139impl From<f64> for ContrastInput {
140    fn from(cr: f64) -> Self {
141        Self(cr_to_bg(
142            Color::white(),
143            cr,
144            ContrastModel::Apca,
145            SearchDirection::Auto,
146        ))
147    }
148}
149
150impl From<ContrastConfig> for ContrastInput {
151    fn from(value: ContrastConfig) -> Self {
152        Self(value)
153    }
154}
155
156impl Color {
157    pub const fn oklch(l: f64, c: f64, h: f64) -> Self {
158        Self::Oklch {
159            l,
160            c,
161            h,
162            alpha: 1.0,
163        }
164    }
165
166    pub const fn oklch_alpha(l: f64, c: f64, h: f64, alpha: f64) -> Self {
167        Self::Oklch { l, c, h, alpha }
168    }
169
170    pub const fn srgb(r: f64, g: f64, b: f64) -> Self {
171        Self::Srgb {
172            r,
173            g,
174            b,
175            alpha: 1.0,
176        }
177    }
178
179    pub const fn srgb_alpha(r: f64, g: f64, b: f64, alpha: f64) -> Self {
180        Self::Srgb { r, g, b, alpha }
181    }
182
183    pub const fn display_p3(r: f64, g: f64, b: f64) -> Self {
184        Self::DisplayP3 {
185            r,
186            g,
187            b,
188            alpha: 1.0,
189        }
190    }
191
192    pub const fn display_p3_alpha(r: f64, g: f64, b: f64, alpha: f64) -> Self {
193        Self::DisplayP3 { r, g, b, alpha }
194    }
195
196    pub const fn white() -> Self {
197        Self::oklch(1.0, 0.0, 0.0)
198    }
199
200    pub const fn black() -> Self {
201        Self::oklch(0.0, 0.0, 0.0)
202    }
203}
204
205pub const fn max_chroma() -> Chroma {
206    Chroma::Max { cap: 0.4 }
207}
208
209pub const fn max_chroma_capped(cap: f64) -> Chroma {
210    Chroma::Max { cap }
211}
212
213pub fn cr_to_bg(
214    bg_color: Color,
215    cr: f64,
216    contrast_model: ContrastModel,
217    search_direction: SearchDirection,
218) -> ContrastConfig {
219    ContrastConfig {
220        bg_color: Some(bg_color),
221        fg_color: None,
222        cr,
223        contrast_model,
224        search_direction,
225    }
226}
227
228pub fn cr_to_bg_white(
229    cr: f64,
230    contrast_model: ContrastModel,
231    search_direction: SearchDirection,
232) -> ContrastConfig {
233    cr_to_bg(Color::white(), cr, contrast_model, search_direction)
234}
235
236pub fn cr_to_bg_black(
237    cr: f64,
238    contrast_model: ContrastModel,
239    search_direction: SearchDirection,
240) -> ContrastConfig {
241    cr_to_bg(Color::black(), cr, contrast_model, search_direction)
242}
243
244pub fn cr_to_fg(
245    fg_color: Color,
246    cr: f64,
247    contrast_model: ContrastModel,
248    search_direction: SearchDirection,
249) -> ContrastConfig {
250    ContrastConfig {
251        bg_color: None,
252        fg_color: Some(fg_color),
253        cr,
254        contrast_model,
255        search_direction,
256    }
257}
258
259pub fn cr_to_fg_white(
260    cr: f64,
261    contrast_model: ContrastModel,
262    search_direction: SearchDirection,
263) -> ContrastConfig {
264    cr_to_fg(Color::white(), cr, contrast_model, search_direction)
265}
266
267pub fn cr_to_fg_black(
268    cr: f64,
269    contrast_model: ContrastModel,
270    search_direction: SearchDirection,
271) -> ContrastConfig {
272    cr_to_fg(Color::black(), cr, contrast_model, search_direction)
273}
274
275pub fn apcach<C: Into<ContrastInput>>(
276    contrast: C,
277    chroma: Chroma,
278    hue: f64,
279    alpha: f64,
280    color_space: ColorSpace,
281) -> Result<ApcachColor, Error> {
282    validate_alpha(alpha)?;
283    validate_hue(hue)?;
284
285    let contrast_config = contrast.into().0;
286
287    let chroma = match chroma {
288        Chroma::Fixed(value) => {
289            validate_chroma(value)?;
290            value
291        }
292        Chroma::Max { cap } => {
293            return max_chroma_search(contrast_config, cap, hue, alpha, color_space)
294        }
295    };
296
297    let lightness = if contrast_is_legal(contrast_config.cr, contrast_config.contrast_model) {
298        calc_lightness(contrast_config, chroma, hue, color_space)?
299    } else {
300        lightness_from_antagonist(contrast_config)?
301    };
302
303    Ok(ApcachColor {
304        alpha,
305        chroma,
306        color_space,
307        contrast_config,
308        hue,
309        lightness,
310    })
311}
312
313pub fn set_contrast(color: ApcachColor, cr: f64) -> Result<ApcachColor, Error> {
314    let mut contrast_config = color.contrast_config;
315    contrast_config.cr = clip_contrast(cr);
316    apcach(
317        contrast_config,
318        Chroma::Fixed(color.chroma),
319        color.hue,
320        color.alpha,
321        color.color_space,
322    )
323}
324
325pub fn map_contrast<F: FnOnce(f64) -> f64>(color: ApcachColor, f: F) -> Result<ApcachColor, Error> {
326    set_contrast(color, f(color.contrast_config.cr))
327}
328
329pub fn set_chroma(color: ApcachColor, chroma: f64) -> Result<ApcachColor, Error> {
330    apcach(
331        color.contrast_config,
332        Chroma::Fixed(clip_chroma(chroma)),
333        color.hue,
334        color.alpha,
335        color.color_space,
336    )
337}
338
339pub fn map_chroma<F: FnOnce(f64) -> f64>(color: ApcachColor, f: F) -> Result<ApcachColor, Error> {
340    set_chroma(color, f(color.chroma))
341}
342
343pub fn set_hue(color: ApcachColor, hue: f64) -> Result<ApcachColor, Error> {
344    apcach(
345        color.contrast_config,
346        Chroma::Fixed(color.chroma),
347        clip_hue(hue),
348        color.alpha,
349        color.color_space,
350    )
351}
352
353pub fn map_hue<F: FnOnce(f64) -> f64>(color: ApcachColor, f: F) -> Result<ApcachColor, Error> {
354    set_hue(color, f(color.hue))
355}
356
357pub fn calc_contrast(
358    fg_color: Color,
359    bg_color: Color,
360    contrast_model: ContrastModel,
361    color_space: ColorSpace,
362) -> Result<f64, Error> {
363    let bg = clamp_color_to_space(bg_color, color_space)?;
364    let mut fg = clamp_color_to_space(fg_color, color_space)?;
365    fg = blend_colors(fg, bg);
366    Ok(calc_contrast_from_prepared_colors(fg, bg, contrast_model, color_space).abs())
367}
368
369pub fn to_css(color: ApcachColor, format: CssFormat) -> Result<String, Error> {
370    match format {
371        CssFormat::Oklch => Ok(format!(
372            "oklch({}% {} {})",
373            color.lightness * 100.0,
374            color.chroma,
375            color.hue
376        )),
377        CssFormat::Rgb => {
378            let rgb = clamped_encoded_from_oklch(
379                color.lightness,
380                color.chroma,
381                color.hue,
382                ColorSpace::Srgb,
383            )?;
384            Ok(format!(
385                "rgb({} {} {})",
386                format_rgb_channel(rgb[0]),
387                format_rgb_channel(rgb[1]),
388                format_rgb_channel(rgb[2])
389            ))
390        }
391        CssFormat::Hex => {
392            let rgb = clamped_encoded_from_oklch(
393                color.lightness,
394                color.chroma,
395                color.hue,
396                ColorSpace::Srgb,
397            )?;
398            Ok(format!(
399                "#{:02x}{:02x}{:02x}",
400                to_u8(rgb[0]),
401                to_u8(rgb[1]),
402                to_u8(rgb[2])
403            ))
404        }
405        CssFormat::P3 => {
406            let p3 = clamped_encoded_from_oklch(
407                color.lightness,
408                color.chroma,
409                color.hue,
410                ColorSpace::P3,
411            )?;
412            Ok(format!("color(display-p3 {} {} {})", p3[0], p3[1], p3[2]))
413        }
414        CssFormat::FigmaP3 => {
415            let p3 = clamped_encoded_from_oklch(
416                color.lightness,
417                color.chroma,
418                color.hue,
419                ColorSpace::P3,
420            )?;
421            Ok(format!(
422                "{:02x}{:02x}{:02x}",
423                to_u8(p3[0]),
424                to_u8(p3[1]),
425                to_u8(p3[2])
426            ))
427        }
428    }
429}
430
431fn max_chroma_search(
432    contrast_config: ContrastConfig,
433    cap: f64,
434    hue: f64,
435    alpha: f64,
436    color_space: ColorSpace,
437) -> Result<ApcachColor, Error> {
438    validate_chroma(cap)?;
439    let mut checking_chroma = cap;
440    let mut search_patch = 0.4;
441    let mut color = apcach(
442        contrast_config,
443        Chroma::Fixed(checking_chroma),
444        hue,
445        alpha,
446        color_space,
447    )?;
448    let mut color_is_valid = false;
449
450    for iteration in 0..30 {
451        let old_chroma = checking_chroma;
452        checking_chroma = (old_chroma + search_patch).clamp(0.0, cap);
453        color = apcach(
454            contrast_config,
455            Chroma::Fixed(checking_chroma),
456            hue,
457            alpha,
458            color_space,
459        )?;
460
461        let new_color_is_valid = in_color_space(color)?;
462        if iteration == 0 && !new_color_is_valid {
463            search_patch *= -1.0;
464        } else if new_color_is_valid != color_is_valid {
465            search_patch /= -2.0;
466        }
467        color_is_valid = new_color_is_valid;
468
469        if checking_chroma <= 0.0 && !color_is_valid {
470            color.chroma = 0.0;
471            return Ok(color);
472        }
473
474        if (search_patch.abs() <= 0.001 || checking_chroma == cap) && color_is_valid {
475            if checking_chroma <= 0.0 {
476                color.chroma = 0.0;
477            }
478            return Ok(color);
479        }
480    }
481
482    Ok(color)
483}
484
485fn calc_lightness(
486    contrast_config: ContrastConfig,
487    chroma: f64,
488    hue: f64,
489    color_space: ColorSpace,
490) -> Result<f64, Error> {
491    let (mut lightness, mut lightness_patch) = lightness_and_patch(contrast_config, color_space)?;
492    let limits = chroma_limits(contrast_config, color_space)?;
493    let mut delta_contrast = 0.0;
494    let mut best_contrast = f64::INFINITY;
495    let mut best_lightness = 0.0;
496    let mut search_window = (0.0, 1.0);
497
498    for iteration in 0..20 {
499        let mut new_lightness = lightness;
500        if iteration > 0 {
501            new_lightness += lightness_patch;
502        }
503        new_lightness = new_lightness.clamp(limits.0, limits.1);
504
505        let checking = clamp_color_to_space(
506            Color::oklch_alpha(new_lightness, chroma, hue, 1.0),
507            color_space,
508        )?;
509
510        let calculated = contrast_from_config(checking, contrast_config, color_space)?;
511        let new_delta_contrast = contrast_config.cr - calculated;
512
513        if iteration == 0
514            && calculated < contrast_config.cr
515            && contrast_config.search_direction != SearchDirection::Auto
516        {
517            best_lightness = lightness;
518            break;
519        }
520
521        if calculated >= contrast_config.cr && calculated < best_contrast {
522            best_contrast = calculated;
523            best_lightness = new_lightness;
524        }
525
526        if delta_contrast != 0.0 && sign_of(new_delta_contrast) != sign_of(delta_contrast) {
527            if lightness_patch > 0.0 {
528                search_window.1 = new_lightness;
529            } else {
530                search_window.0 = new_lightness;
531            }
532            lightness_patch = -lightness_patch / 2.0;
533        } else if (new_lightness + lightness_patch - search_window.0).abs() <= f64::EPSILON
534            || (new_lightness + lightness_patch - search_window.1).abs() <= f64::EPSILON
535        {
536            lightness_patch /= 2.0;
537        }
538
539        if search_window.1 - search_window.0 < 0.001
540            || (iteration > 0 && new_lightness == lightness)
541        {
542            break;
543        }
544
545        delta_contrast = new_delta_contrast;
546        lightness = new_lightness;
547    }
548
549    Ok(best_lightness.clamp(0.0, 1.0))
550}
551
552fn lightness_and_patch(
553    contrast_config: ContrastConfig,
554    color_space: ColorSpace,
555) -> Result<(f64, f64), Error> {
556    let antagonist_lightness = antagonist_color_lightness(contrast_config, color_space)?;
557    let result = match contrast_config.search_direction {
558        SearchDirection::Auto => {
559            if antagonist_lightness < 0.5 {
560                (1.0, (1.0 - antagonist_lightness) / -2.0)
561            } else {
562                (0.0, antagonist_lightness / 2.0)
563            }
564        }
565        SearchDirection::Darker => (0.0, antagonist_lightness / 2.0),
566        SearchDirection::Lighter => (1.0, (antagonist_lightness - 1.0) / 2.0),
567    };
568    Ok(result)
569}
570
571fn chroma_limits(
572    contrast_config: ContrastConfig,
573    color_space: ColorSpace,
574) -> Result<(f64, f64), Error> {
575    if contrast_config.search_direction == SearchDirection::Auto {
576        return Ok((0.0, 1.0));
577    }
578
579    let pair_lightness = antagonist_color_lightness(contrast_config, color_space)?;
580    let bounds = match contrast_config.search_direction {
581        SearchDirection::Auto => (0.0, 1.0),
582        SearchDirection::Lighter => (pair_lightness, 1.0),
583        SearchDirection::Darker => (0.0, pair_lightness),
584    };
585    Ok(bounds)
586}
587
588fn antagonist_color_lightness(
589    contrast_config: ContrastConfig,
590    color_space: ColorSpace,
591) -> Result<f64, Error> {
592    let antagonist = contrast_antagonist(contrast_config)?;
593    let clamped = clamp_color_to_space(antagonist, color_space)?;
594    let oklch = color_to_oklch(clamped)?;
595    Ok(oklch[0])
596}
597
598fn lightness_from_antagonist(contrast_config: ContrastConfig) -> Result<f64, Error> {
599    Ok(color_to_oklch(contrast_antagonist(contrast_config)?)?[0])
600}
601
602fn contrast_antagonist(contrast_config: ContrastConfig) -> Result<Color, Error> {
603    contrast_config
604        .bg_color
605        .or(contrast_config.fg_color)
606        .ok_or(Error::InvalidContrast)
607}
608
609fn contrast_from_config(
610    color: Color,
611    contrast_config: ContrastConfig,
612    color_space: ColorSpace,
613) -> Result<f64, Error> {
614    let (fg, bg) = match (contrast_config.bg_color, contrast_config.fg_color) {
615        (Some(bg), None) => {
616            let bg = clamp_color_to_space(bg, color_space)?;
617            (blend_colors(color, bg), bg)
618        }
619        (None, Some(fg)) => (clamp_color_to_space(fg, color_space)?, color),
620        _ => return Err(Error::InvalidContrast),
621    };
622
623    Ok(
624        calc_contrast_from_prepared_colors(fg, bg, contrast_config.contrast_model, color_space)
625            .abs(),
626    )
627}
628
629fn calc_contrast_from_prepared_colors(
630    fg: Color,
631    bg: Color,
632    contrast_model: ContrastModel,
633    color_space: ColorSpace,
634) -> f64 {
635    match contrast_model {
636        ContrastModel::Apca => match color_space {
637            ColorSpace::P3 => apca_contrast(
638                p3_to_y(encoded_channels(fg, ColorSpace::P3)),
639                p3_to_y(encoded_channels(bg, ColorSpace::P3)),
640            ),
641            ColorSpace::Srgb => apca_contrast(
642                srgb_to_y_u8(encoded_channels(fg, ColorSpace::Srgb)),
643                srgb_to_y_u8(encoded_channels(bg, ColorSpace::Srgb)),
644            ),
645        },
646        ContrastModel::Wcag => wcag_contrast(
647            encoded_channels(fg, ColorSpace::Srgb),
648            encoded_channels(bg, ColorSpace::Srgb),
649        ),
650    }
651}
652
653fn in_color_space(color: ApcachColor) -> Result<bool, Error> {
654    let encoded = color_to_space_encoded(color, color.color_space)?;
655    Ok(encoded
656        .into_iter()
657        .all(|component| (0.0..=1.0).contains(&component)))
658}
659
660fn blend_colors(fg: Color, bg: Color) -> Color {
661    let (fr, fgc, fb, fa) = color_to_rgba_encoded(fg, preferred_space(fg));
662    if fa >= 1.0 {
663        return fg;
664    }
665    let (br, bgc, bb, _) = color_to_rgba_encoded(bg, preferred_space(bg));
666    let alpha = fa.clamp(0.0, 1.0);
667    Color::srgb(
668        br + (fr - br) * alpha,
669        bgc + (fgc - bgc) * alpha,
670        bb + (fb - bb) * alpha,
671    )
672}
673
674fn preferred_space(color: Color) -> ColorSpace {
675    match color {
676        Color::DisplayP3 { .. } => ColorSpace::P3,
677        _ => ColorSpace::Srgb,
678    }
679}
680
681fn color_to_rgba_encoded(color: Color, target: ColorSpace) -> (f64, f64, f64, f64) {
682    match color {
683        Color::Srgb { r, g, b, alpha } if target == ColorSpace::Srgb => (r, g, b, alpha),
684        Color::DisplayP3 { r, g, b, alpha } if target == ColorSpace::P3 => (r, g, b, alpha),
685        _ => {
686            let channels = encoded_channels(color, target);
687            let alpha = match color {
688                Color::Oklch { alpha, .. }
689                | Color::Srgb { alpha, .. }
690                | Color::DisplayP3 { alpha, .. } => alpha,
691            };
692            (channels[0], channels[1], channels[2], alpha)
693        }
694    }
695}
696
697fn clamp_color_to_space(color: Color, color_space: ColorSpace) -> Result<Color, Error> {
698    validate_color(color)?;
699
700    let [l, c, h] = color_to_oklch(color)?;
701    if in_gamut_oklch(l, c, h, color_space) {
702        return Ok(Color::oklch_alpha(l, c, h, color_alpha(color)));
703    }
704
705    let mut low = 0.0;
706    let mut high = c;
707    for _ in 0..30 {
708        let mid = (low + high) / 2.0;
709        if in_gamut_oklch(l, mid, h, color_space) {
710            low = mid;
711        } else {
712            high = mid;
713        }
714    }
715
716    Ok(Color::oklch_alpha(l, low, h, color_alpha(color)))
717}
718
719fn in_gamut_oklch(l: f64, c: f64, h: f64, color_space: ColorSpace) -> bool {
720    let encoded = encoded_from_oklch(l, c, h, color_space);
721    encoded
722        .into_iter()
723        .all(|value| (0.0..=1.0).contains(&value))
724}
725
726fn color_to_space_encoded(color: ApcachColor, color_space: ColorSpace) -> Result<[f64; 3], Error> {
727    Ok(encoded_from_oklch(
728        color.lightness,
729        color.chroma,
730        color.hue,
731        color_space,
732    ))
733}
734
735fn clamped_encoded_from_oklch(
736    l: f64,
737    c: f64,
738    h: f64,
739    color_space: ColorSpace,
740) -> Result<[f64; 3], Error> {
741    let color = clamp_color_to_space(Color::oklch(l, c, h), color_space)?;
742    let Color::Oklch { l, c, h, .. } = color else {
743        unreachable!();
744    };
745    Ok(encoded_from_oklch(l, c, h, color_space))
746}
747
748fn encoded_channels(color: Color, target: ColorSpace) -> [f64; 3] {
749    match color {
750        Color::Oklch { l, c, h, .. } => encoded_from_oklch(l, c, h, target),
751        Color::Srgb { r, g, b, .. } => match target {
752            ColorSpace::Srgb => [r, g, b],
753            ColorSpace::P3 => convert_encoded_rgb([r, g, b], SRGB_TO_XYZ, XYZ_TO_P3),
754        },
755        Color::DisplayP3 { r, g, b, .. } => match target {
756            ColorSpace::P3 => [r, g, b],
757            ColorSpace::Srgb => convert_encoded_rgb([r, g, b], P3_TO_XYZ, XYZ_TO_SRGB),
758        },
759    }
760}
761
762fn convert_encoded_rgb(rgb: [f64; 3], to_xyz: [[f64; 3]; 3], from_xyz: [[f64; 3]; 3]) -> [f64; 3] {
763    let linear = [
764        srgb_decode(rgb[0]),
765        srgb_decode(rgb[1]),
766        srgb_decode(rgb[2]),
767    ];
768    let xyz = mul3(to_xyz, linear);
769    let out_linear = mul3(from_xyz, xyz);
770    [
771        srgb_encode(out_linear[0]),
772        srgb_encode(out_linear[1]),
773        srgb_encode(out_linear[2]),
774    ]
775}
776
777fn encoded_from_oklch(l: f64, c: f64, h: f64, color_space: ColorSpace) -> [f64; 3] {
778    let xyz = oklch_to_xyz(l, c, h);
779    let linear = match color_space {
780        ColorSpace::Srgb => mul3(XYZ_TO_SRGB, xyz),
781        ColorSpace::P3 => mul3(XYZ_TO_P3, xyz),
782    };
783    [
784        srgb_encode(linear[0]),
785        srgb_encode(linear[1]),
786        srgb_encode(linear[2]),
787    ]
788}
789
790fn color_to_oklch(color: Color) -> Result<[f64; 3], Error> {
791    validate_color(color)?;
792    let result = match color {
793        Color::Oklch { l, c, h, .. } => [l, c, h],
794        Color::Srgb { r, g, b, .. } => xyz_to_oklch(mul3(
795            SRGB_TO_XYZ,
796            [srgb_decode(r), srgb_decode(g), srgb_decode(b)],
797        )),
798        Color::DisplayP3 { r, g, b, .. } => xyz_to_oklch(mul3(
799            P3_TO_XYZ,
800            [srgb_decode(r), srgb_decode(g), srgb_decode(b)],
801        )),
802    };
803    Ok(result)
804}
805
806fn validate_color(color: Color) -> Result<(), Error> {
807    match color {
808        Color::Oklch { alpha, .. } => validate_alpha(alpha),
809        Color::Srgb { r, g, b, alpha } | Color::DisplayP3 { r, g, b, alpha } => {
810            validate_alpha(alpha)?;
811            if [r, g, b].into_iter().all(f64::is_finite) {
812                Ok(())
813            } else {
814                Err(Error::InvalidRgbComponent)
815            }
816        }
817    }
818}
819
820fn validate_alpha(alpha: f64) -> Result<(), Error> {
821    if alpha.is_finite() {
822        Ok(())
823    } else {
824        Err(Error::InvalidAlpha)
825    }
826}
827
828fn validate_chroma(chroma: f64) -> Result<(), Error> {
829    if chroma.is_finite() && chroma >= 0.0 {
830        Ok(())
831    } else {
832        Err(Error::InvalidChroma)
833    }
834}
835
836fn validate_hue(hue: f64) -> Result<(), Error> {
837    if hue.is_finite() {
838        Ok(())
839    } else {
840        Err(Error::InvalidHue)
841    }
842}
843
844fn color_alpha(color: Color) -> f64 {
845    match color {
846        Color::Oklch { alpha, .. } | Color::Srgb { alpha, .. } | Color::DisplayP3 { alpha, .. } => {
847            alpha
848        }
849    }
850}
851
852fn contrast_is_legal(cr: f64, contrast_model: ContrastModel) -> bool {
853    match contrast_model {
854        ContrastModel::Apca => cr.abs() >= 8.0,
855        ContrastModel::Wcag => cr.abs() >= 1.0,
856    }
857}
858
859fn clip_contrast(cr: f64) -> f64 {
860    cr.clamp(0.0, 108.0)
861}
862
863fn clip_chroma(chroma: f64) -> f64 {
864    chroma.clamp(0.0, 0.37)
865}
866
867fn clip_hue(hue: f64) -> f64 {
868    hue.clamp(0.0, 360.0)
869}
870
871fn sign_of(number: f64) -> f64 {
872    number / number.abs()
873}
874
875fn oklch_to_xyz(l: f64, c: f64, h: f64) -> [f64; 3] {
876    let hr = h.to_radians();
877    let a = c * hr.cos();
878    let b = c * hr.sin();
879
880    let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
881    let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
882    let s_ = l - 0.089_484_177_5 * a - 1.291_485_548 * b;
883
884    let l3 = l_ * l_ * l_;
885    let m3 = m_ * m_ * m_;
886    let s3 = s_ * s_ * s_;
887
888    [
889        1.227_013_851_1 * l3 - 0.557_799_980_7 * m3 + 0.281_256_149 * s3,
890        -0.040_580_178_4 * l3 + 1.112_256_869_6 * m3 - 0.071_676_678_7 * s3,
891        -0.076_381_284_5 * l3 - 0.421_481_978_4 * m3 + 1.586_163_220_4 * s3,
892    ]
893}
894
895fn xyz_to_oklch(xyz: [f64; 3]) -> [f64; 3] {
896    let l = 0.818_933_010_1 * xyz[0] + 0.361_866_742_4 * xyz[1] - 0.128_859_713_7 * xyz[2];
897    let m = 0.032_984_543_6 * xyz[0] + 0.929_311_871_5 * xyz[1] + 0.036_145_638_7 * xyz[2];
898    let s = 0.048_200_301_8 * xyz[0] + 0.264_366_269_1 * xyz[1] + 0.633_851_707 * xyz[2];
899
900    let l_ = l.cbrt();
901    let m_ = m.cbrt();
902    let s_ = s.cbrt();
903
904    let l_ok = 0.210_454_255_3 * l_ + 0.793_617_785 * m_ - 0.004_072_046_8 * s_;
905    let a = 1.977_998_495_1 * l_ - 2.428_592_205 * m_ + 0.450_593_709_9 * s_;
906    let b = 0.025_904_037_1 * l_ + 0.782_771_766_2 * m_ - 0.808_675_766 * s_;
907    let chroma = (a * a + b * b).sqrt();
908    let hue = b.atan2(a).to_degrees().rem_euclid(360.0);
909
910    [l_ok, chroma, hue]
911}
912
913fn mul3(matrix: [[f64; 3]; 3], vector: [f64; 3]) -> [f64; 3] {
914    [
915        matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
916        matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
917        matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2],
918    ]
919}
920
921fn srgb_decode(value: f64) -> f64 {
922    if value <= 0.04045 {
923        value / 12.92
924    } else {
925        ((value + 0.055) / 1.055).powf(2.4)
926    }
927}
928
929fn srgb_encode(value: f64) -> f64 {
930    if value <= 0.0031308 {
931        12.92 * value
932    } else {
933        1.055 * value.powf(1.0 / 2.4) - 0.055
934    }
935}
936
937fn srgb_to_y_u8(rgb: [f64; 3]) -> f64 {
938    let rounded = [
939        (rgb[0].max(0.0) * 255.0).round(),
940        (rgb[1].max(0.0) * 255.0).round(),
941        (rgb[2].max(0.0) * 255.0).round(),
942    ];
943    SRGB_APCA_COEFFS[0] * (rounded[0] / 255.0).powf(APCA_MAIN_TRC)
944        + SRGB_APCA_COEFFS[1] * (rounded[1] / 255.0).powf(APCA_MAIN_TRC)
945        + SRGB_APCA_COEFFS[2] * (rounded[2] / 255.0).powf(APCA_MAIN_TRC)
946}
947
948fn p3_to_y(rgb: [f64; 3]) -> f64 {
949    P3_APCA_COEFFS[0] * rgb[0].max(0.0).powf(APCA_MAIN_TRC)
950        + P3_APCA_COEFFS[1] * rgb[1].max(0.0).powf(APCA_MAIN_TRC)
951        + P3_APCA_COEFFS[2] * rgb[2].max(0.0).powf(APCA_MAIN_TRC)
952}
953
954fn apca_contrast(txt_y: f64, bg_y: f64) -> f64 {
955    if txt_y.is_nan() || bg_y.is_nan() || txt_y < 0.0 || bg_y < 0.0 || txt_y > 1.1 || bg_y > 1.1 {
956        return 0.0;
957    }
958
959    let txt_y = soft_clamp_black(txt_y);
960    let bg_y = soft_clamp_black(bg_y);
961
962    if (bg_y - txt_y).abs() < APCA_DELTA_Y_MIN {
963        return 0.0;
964    }
965
966    let output = if bg_y > txt_y {
967        let sapc = (bg_y.powf(APCA_NORM_BG) - txt_y.powf(APCA_NORM_TXT)) * APCA_SCALE_BOW;
968        if sapc < APCA_LO_CLIP {
969            0.0
970        } else {
971            sapc - APCA_LO_BOW_OFFSET
972        }
973    } else {
974        let sapc = (bg_y.powf(APCA_REV_BG) - txt_y.powf(APCA_REV_TXT)) * APCA_SCALE_WOB;
975        if sapc > -APCA_LO_CLIP {
976            0.0
977        } else {
978            sapc + APCA_LO_WOB_OFFSET
979        }
980    };
981
982    output * 100.0
983}
984
985fn soft_clamp_black(y: f64) -> f64 {
986    if y > APCA_BLK_THRS {
987        y
988    } else {
989        y + (APCA_BLK_THRS - y).powf(APCA_BLK_CLMP)
990    }
991}
992
993fn wcag_contrast(fg: [f64; 3], bg: [f64; 3]) -> f64 {
994    let fg_l = relative_luminance(fg);
995    let bg_l = relative_luminance(bg);
996    let (light, dark) = if fg_l >= bg_l {
997        (fg_l, bg_l)
998    } else {
999        (bg_l, fg_l)
1000    };
1001    (light + 0.05) / (dark + 0.05)
1002}
1003
1004fn relative_luminance(rgb: [f64; 3]) -> f64 {
1005    let linear = [
1006        srgb_decode(rgb[0]),
1007        srgb_decode(rgb[1]),
1008        srgb_decode(rgb[2]),
1009    ];
1010    0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
1011}
1012
1013fn format_rgb_channel(value: f64) -> String {
1014    to_u8(value).to_string()
1015}
1016
1017fn to_u8(value: f64) -> u8 {
1018    (value.clamp(0.0, 1.0) * 255.0).round() as u8
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    fn assert_close(left: f64, right: f64, epsilon: f64) {
1026        assert!(
1027            (left - right).abs() <= epsilon,
1028            "left={left}, right={right}, epsilon={epsilon}"
1029        );
1030    }
1031
1032    #[test]
1033    fn apca_matches_known_black_white_values() {
1034        let white = Color::white();
1035        let black = Color::black();
1036
1037        assert_close(
1038            calc_contrast(white, black, ContrastModel::Apca, ColorSpace::Srgb).unwrap(),
1039            107.88473318309848,
1040            1e-9,
1041        );
1042        assert_close(
1043            calc_contrast(black, white, ContrastModel::Apca, ColorSpace::Srgb).unwrap(),
1044            106.04067321268862,
1045            1e-9,
1046        );
1047    }
1048
1049    #[test]
1050    fn wcag_matches_known_black_white_values() {
1051        assert_close(
1052            calc_contrast(
1053                Color::white(),
1054                Color::black(),
1055                ContrastModel::Wcag,
1056                ColorSpace::Srgb,
1057            )
1058            .unwrap(),
1059            21.0,
1060            1e-6,
1061        );
1062    }
1063
1064    #[test]
1065    fn creates_known_srgb_color() {
1066        let color = apcach(70.0, Chroma::Fixed(0.15), 150.0, 100.0, ColorSpace::Srgb).unwrap();
1067        assert_close(color.lightness * 100.0, 55.566405559494214, 1e-6);
1068    }
1069
1070    #[test]
1071    fn creates_known_p3_color() {
1072        let color = apcach(70.0, Chroma::Fixed(0.2), 150.0, 100.0, ColorSpace::P3).unwrap();
1073        assert_close(color.lightness * 100.0, 55.17578088989781, 1e-6);
1074    }
1075
1076    #[test]
1077    fn max_chroma_finds_valid_srgb_color() {
1078        let color = apcach(
1079            cr_to_bg_white(70.0, ContrastModel::Apca, SearchDirection::Auto),
1080            max_chroma(),
1081            200.0,
1082            100.0,
1083            ColorSpace::Srgb,
1084        )
1085        .unwrap();
1086
1087        assert!(in_color_space(color).unwrap());
1088        assert!(
1089            calc_contrast(
1090                Color::oklch(color.lightness, color.chroma, color.hue),
1091                Color::white(),
1092                ContrastModel::Apca,
1093                ColorSpace::Srgb,
1094            )
1095            .unwrap()
1096                >= 70.0
1097        );
1098    }
1099
1100    #[test]
1101    fn emits_hex_output() {
1102        let color = apcach(70.0, Chroma::Fixed(0.15), 150.0, 100.0, ColorSpace::Srgb).unwrap();
1103        let css = to_css(color, CssFormat::Hex).unwrap();
1104        assert_eq!(css.len(), 7);
1105        assert!(css.starts_with('#'));
1106    }
1107
1108    #[test]
1109    fn relative_adjustments_work() {
1110        let color = apcach(60.0, Chroma::Fixed(0.15), 145.0, 100.0, ColorSpace::Srgb).unwrap();
1111        let adjusted = map_contrast(color, |cr| cr + 10.0).unwrap();
1112        assert_close(adjusted.contrast_config.cr, 70.0, 1e-12);
1113    }
1114}