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}