1use serde::{Deserialize, Serialize};
2
3use crate::Theme;
4
5#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
7#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
8pub enum ColorTransform {
9 Tint(f64),
10 Shade(f64),
11 Hue(f64),
12 HueMod(f64),
13 HueOff(f64),
14 Sat(f64),
15 SatMod(f64),
16 SatOff(f64),
17 Lum(f64),
18 LumMod(f64),
19 LumOff(f64),
20 Red(f64),
21 RedMod(f64),
22 RedOff(f64),
23 Green(f64),
24 GreenMod(f64),
25 GreenOff(f64),
26 Blue(f64),
27 BlueMod(f64),
28 BlueOff(f64),
29 Alpha(f64),
30 AlphaMod(f64),
31 AlphaOff(f64),
32 Comp,
33 Inv,
34 Gray,
35 Gamma,
36 InvGamma,
37}
38
39impl ColorTransform {
40 pub fn from_xml(name: &str, value: Option<&str>) -> Option<Self> {
41 match name {
42 "comp" => return Some(Self::Comp),
43 "inv" => return Some(Self::Inv),
44 "gray" => return Some(Self::Gray),
45 "gamma" => return Some(Self::Gamma),
46 "invGamma" => return Some(Self::InvGamma),
47 _ => {}
48 }
49 let raw = value?.trim();
50 let value = if matches!(name, "hue" | "hueOff") {
51 raw.parse::<f64>().ok()? / 21_600_000.0
52 } else if let Some(percent) = raw.strip_suffix('%') {
53 percent.parse::<f64>().ok()? / 100.0
54 } else {
55 raw.parse::<f64>().ok()? / 100_000.0
56 };
57 if !value.is_finite() {
58 return None;
59 }
60 let in_range = match name {
61 "tint" | "shade" => (0.0..=1.0).contains(&value),
62 "lumMod" | "lumOff" | "satMod" | "alpha" => (0.0..=10.0).contains(&value),
63 _ => true,
64 };
65 if !in_range {
66 return None;
67 }
68 Some(match name {
69 "tint" => Self::Tint(value),
70 "shade" => Self::Shade(value),
71 "hue" => Self::Hue(value),
72 "hueMod" => Self::HueMod(value),
73 "hueOff" => Self::HueOff(value),
74 "sat" => Self::Sat(value),
75 "satMod" => Self::SatMod(value),
76 "satOff" => Self::SatOff(value),
77 "lum" => Self::Lum(value),
78 "lumMod" => Self::LumMod(value),
79 "lumOff" => Self::LumOff(value),
80 "red" => Self::Red(value),
81 "redMod" => Self::RedMod(value),
82 "redOff" => Self::RedOff(value),
83 "green" => Self::Green(value),
84 "greenMod" => Self::GreenMod(value),
85 "greenOff" => Self::GreenOff(value),
86 "blue" => Self::Blue(value),
87 "blueMod" => Self::BlueMod(value),
88 "blueOff" => Self::BlueOff(value),
89 "alpha" => Self::Alpha(value),
90 "alphaMod" => Self::AlphaMod(value),
91 "alphaOff" => Self::AlphaOff(value),
92 _ => return None,
93 })
94 }
95
96 pub fn xml_name(&self) -> &'static str {
97 match self {
98 Self::Tint(_) => "tint",
99 Self::Shade(_) => "shade",
100 Self::Hue(_) => "hue",
101 Self::HueMod(_) => "hueMod",
102 Self::HueOff(_) => "hueOff",
103 Self::Sat(_) => "sat",
104 Self::SatMod(_) => "satMod",
105 Self::SatOff(_) => "satOff",
106 Self::Lum(_) => "lum",
107 Self::LumMod(_) => "lumMod",
108 Self::LumOff(_) => "lumOff",
109 Self::Red(_) => "red",
110 Self::RedMod(_) => "redMod",
111 Self::RedOff(_) => "redOff",
112 Self::Green(_) => "green",
113 Self::GreenMod(_) => "greenMod",
114 Self::GreenOff(_) => "greenOff",
115 Self::Blue(_) => "blue",
116 Self::BlueMod(_) => "blueMod",
117 Self::BlueOff(_) => "blueOff",
118 Self::Alpha(_) => "alpha",
119 Self::AlphaMod(_) => "alphaMod",
120 Self::AlphaOff(_) => "alphaOff",
121 Self::Comp => "comp",
122 Self::Inv => "inv",
123 Self::Gray => "gray",
124 Self::Gamma => "gamma",
125 Self::InvGamma => "invGamma",
126 }
127 }
128
129 pub fn xml_value(&self) -> Option<String> {
130 let value = match *self {
131 Self::Hue(value)
132 | Self::HueOff(value)
133 | Self::Tint(value)
134 | Self::Shade(value)
135 | Self::HueMod(value)
136 | Self::Sat(value)
137 | Self::SatMod(value)
138 | Self::SatOff(value)
139 | Self::Lum(value)
140 | Self::LumMod(value)
141 | Self::LumOff(value)
142 | Self::Red(value)
143 | Self::RedMod(value)
144 | Self::RedOff(value)
145 | Self::Green(value)
146 | Self::GreenMod(value)
147 | Self::GreenOff(value)
148 | Self::Blue(value)
149 | Self::BlueMod(value)
150 | Self::BlueOff(value)
151 | Self::Alpha(value)
152 | Self::AlphaMod(value)
153 | Self::AlphaOff(value) => value,
154 _ => return None,
155 };
156 let angle = matches!(self, Self::Hue(_) | Self::HueOff(_));
157 let scaled = value * if angle { 21_600_000.0 } else { 100_000.0 };
158 if !scaled.is_finite() {
159 return None;
160 }
161 if angle || (scaled - scaled.round()).abs() < 1e-8 {
162 Some(scaled.round().to_string())
163 } else {
164 Some(format!("{}%", value * 100.0))
165 }
166 }
167}
168
169#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct ColorValue {
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub rgb: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub theme_color: Option<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub theme_tint: Option<String>,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub theme_shade: Option<String>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub auto: Option<bool>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub transforms: Vec<ColorTransform>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub luminance_modulation: Option<f64>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub luminance_offset: Option<f64>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub saturation_modulation: Option<f64>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub alpha: Option<f64>,
198}
199
200impl ColorValue {
201 pub fn drawingml_transforms(&self) -> impl Iterator<Item = ColorTransform> + '_ {
203 let alpha = (!self.transforms.is_empty()
204 && !self.transforms.iter().any(|transform| {
205 matches!(
206 transform,
207 ColorTransform::Alpha(_)
208 | ColorTransform::AlphaMod(_)
209 | ColorTransform::AlphaOff(_)
210 )
211 }))
212 .then_some(self.alpha)
213 .flatten()
214 .filter(|alpha| alpha.is_finite())
215 .map(|alpha| ColorTransform::Alpha(alpha.clamp(0.0, 1.0)));
216 self.transforms.iter().copied().chain(alpha)
217 }
218
219 pub fn from_attribute(value: &str) -> Self {
221 parse_color_value(Some(value), None, None, None)
222 }
223}
224
225pub fn parse_color_value(
226 rgb: Option<&str>,
227 theme_color: Option<&str>,
228 theme_tint: Option<&str>,
229 theme_shade: Option<&str>,
230) -> ColorValue {
231 ColorValue {
232 rgb: rgb
233 .filter(|value| !value.is_empty() && *value != "auto")
234 .map(str::to_owned),
235 auto: (rgb == Some("auto")).then_some(true),
236 theme_color: theme_color
237 .filter(|value| !value.is_empty())
238 .map(str::to_owned),
239 theme_tint: theme_tint
240 .filter(|value| !value.is_empty())
241 .map(str::to_owned),
242 theme_shade: theme_shade
243 .filter(|value| !value.is_empty())
244 .map(str::to_owned),
245 ..ColorValue::default()
246 }
247}
248
249pub fn resolve_color_value_to_hex(color: Option<&ColorValue>) -> Option<String> {
250 resolve_color_value_to_hex_with_theme(color, None)
251}
252
253pub fn resolve_color_value_to_hex_with_theme(
254 color: Option<&ColorValue>,
255 theme: Option<&Theme>,
256) -> Option<String> {
257 let color = color?;
258 let rgb = color.rgb.as_deref().or_else(|| {
259 color.theme_color.as_deref().map(|slot| {
260 theme
261 .and_then(|theme| theme.color_scheme.get(theme.color_map.resolve(slot)))
262 .unwrap_or_else(|| default_theme_color(slot))
263 })
264 })?;
265 let mut channels = parse_rgb(rgb)?;
266 if !color.transforms.is_empty() {
267 let channels = apply_transforms(
268 channels.map(|channel| f64::from(channel) / 255.0),
269 &color.transforms,
270 )
271 .map(|channel| (channel * 255.0).round() as u8);
272 return Some(format!(
273 "#{:02X}{:02X}{:02X}",
274 channels[0], channels[1], channels[2]
275 ));
276 }
277 if let Some(shade) = color.theme_shade.as_deref().and_then(parse_modifier) {
278 channels = channels.map(|channel| (f64::from(channel) * shade).round() as u8);
279 }
280 if let Some(tint) = color.theme_tint.as_deref().and_then(parse_modifier) {
281 channels = channels
282 .map(|channel| (f64::from(channel) * tint + 255.0 * (1.0 - tint)).round() as u8);
283 }
284 channels = apply_hsl_modifiers(channels, color);
285 Some(format!(
286 "#{:02X}{:02X}{:02X}",
287 channels[0], channels[1], channels[2]
288 ))
289}
290
291pub fn resolve_color_value_to_rgba_hex(
294 color: Option<&ColorValue>,
295 theme: Option<&Theme>,
296) -> Option<String> {
297 let hex = resolve_color_value_to_hex_with_theme(color, theme)?;
298 let alpha = color.map_or(1.0, resolve_color_value_alpha);
299 Some(format!("{hex}{:02X}", (alpha * 255.0).round() as u8))
300}
301
302pub fn resolve_color_value_alpha(color: &ColorValue) -> f64 {
303 if color.transforms.is_empty() {
304 return color
305 .alpha
306 .filter(|alpha| alpha.is_finite() && *alpha < 1.0)
307 .unwrap_or(1.0)
308 .clamp(0.0, 1.0);
309 }
310 let mut alpha = 1.0;
311 for transform in color.drawingml_transforms() {
312 alpha = match transform {
313 ColorTransform::Alpha(value) if value.is_finite() => value,
314 ColorTransform::AlphaMod(value) if value.is_finite() => alpha * value,
315 ColorTransform::AlphaOff(value) if value.is_finite() => alpha + value,
316 _ => alpha,
317 }
318 .clamp(0.0, 1.0);
319 }
320 alpha
321}
322
323fn srgb_to_linear(value: f64) -> f64 {
324 if value <= 0.04045 {
325 value / 12.92
326 } else {
327 ((value + 0.055) / 1.055).powf(2.4)
328 }
329}
330
331fn linear_to_srgb(value: f64) -> f64 {
332 if value <= 0.0031308 {
333 value * 12.92
334 } else {
335 1.055 * value.powf(1.0 / 2.4) - 0.055
336 }
337}
338
339fn apply_transforms(mut channels: [f64; 3], transforms: &[ColorTransform]) -> [f64; 3] {
340 use ColorTransform::*;
341
342 for transform in transforms {
343 match *transform {
344 Tint(value) | Shade(value) if value.is_finite() => {
345 channels = channels.map(|channel| {
346 let linear = srgb_to_linear(channel) * value;
347 let linear = if matches!(transform, Tint(_)) {
348 linear + (1.0 - value)
349 } else {
350 linear
351 };
352 linear_to_srgb(linear.clamp(0.0, 1.0))
353 });
354 }
355 Hue(value) | HueMod(value) | HueOff(value) | Sat(value) | SatMod(value)
356 | SatOff(value) | Lum(value) | LumMod(value) | LumOff(value)
357 if value.is_finite() =>
358 {
359 let (mut hue, mut sat, mut lum) = rgb_to_hsl_f64(channels);
360 match *transform {
361 Hue(_) => hue = value * 360.0,
362 HueMod(_) => hue *= value,
363 HueOff(_) => hue += value * 360.0,
364 Sat(_) => sat = value,
365 SatMod(_) => sat *= value,
366 SatOff(_) => sat += value,
367 Lum(_) => lum = value,
368 LumMod(_) => lum *= value,
369 LumOff(_) => lum += value,
370 _ => unreachable!(),
371 }
372 channels = hsl_to_rgb_f64(
373 hue.rem_euclid(360.0),
374 sat.clamp(0.0, 1.0),
375 lum.clamp(0.0, 1.0),
376 );
377 }
378 Comp => {
379 let (hue, sat, lum) = rgb_to_hsl_f64(channels);
380 channels = hsl_to_rgb_f64(hue + 180.0, sat, lum);
381 }
382 Red(value) | RedMod(value) | RedOff(value) | Green(value) | GreenMod(value)
383 | GreenOff(value) | Blue(value) | BlueMod(value) | BlueOff(value)
384 if value.is_finite() =>
385 {
386 let index = match transform {
387 Red(_) | RedMod(_) | RedOff(_) => 0,
388 Green(_) | GreenMod(_) | GreenOff(_) => 1,
389 _ => 2,
390 };
391 let linear = srgb_to_linear(channels[index]);
392 let linear = match transform {
393 RedMod(_) | GreenMod(_) | BlueMod(_) => linear * value,
394 RedOff(_) | GreenOff(_) | BlueOff(_) => linear + value,
395 _ => value,
396 };
397 channels[index] = linear_to_srgb(linear.clamp(0.0, 1.0));
398 }
399 Inv => channels = channels.map(|channel| linear_to_srgb(1.0 - srgb_to_linear(channel))),
400 Gray => {
401 let [red, green, blue] = channels.map(srgb_to_linear);
402 channels = [linear_to_srgb(0.2126 * red + 0.7152 * green + 0.0722 * blue); 3];
403 }
404 Gamma => channels = channels.map(linear_to_srgb),
405 InvGamma => channels = channels.map(srgb_to_linear),
406 _ => {}
407 }
408 channels = channels.map(|channel| channel.clamp(0.0, 1.0));
409 }
410 channels
411}
412
413fn apply_hsl_modifiers(channels: [u8; 3], color: &ColorValue) -> [u8; 3] {
416 let modulation = |value: Option<f64>| value.filter(|value| value.is_finite() && *value >= 0.0);
417 let saturation = modulation(color.saturation_modulation);
418 let luminance = modulation(color.luminance_modulation);
419 let offset = color
420 .luminance_offset
421 .filter(|value| value.is_finite() && *value != 0.0);
422 if saturation.is_none() && luminance.is_none() && offset.is_none() {
423 return channels;
424 }
425 let (hue, mut sat, mut lum) = rgb_to_hsl(channels);
426 if let Some(saturation) = saturation {
427 sat = (sat * saturation).clamp(0.0, 1.0);
428 }
429 if let Some(luminance) = luminance {
430 lum = (lum * luminance).clamp(0.0, 1.0);
431 }
432 if let Some(offset) = offset {
433 lum = (lum + offset).clamp(0.0, 1.0);
434 }
435 hsl_to_rgb(hue, sat, lum)
436}
437
438fn rgb_to_hsl(channels: [u8; 3]) -> (f64, f64, f64) {
439 rgb_to_hsl_f64(channels.map(|channel| f64::from(channel) / 255.0))
440}
441
442fn rgb_to_hsl_f64([red, green, blue]: [f64; 3]) -> (f64, f64, f64) {
443 let max = red.max(green).max(blue);
444 let min = red.min(green).min(blue);
445 let lum = (max + min) / 2.0;
446 let span = max - min;
447 if span <= f64::EPSILON {
448 return (0.0, 0.0, lum);
449 }
450 let sat = span / (1.0 - (2.0 * lum - 1.0).abs());
451 let hue = if max == red {
452 ((green - blue) / span).rem_euclid(6.0)
453 } else if max == green {
454 (blue - red) / span + 2.0
455 } else {
456 (red - green) / span + 4.0
457 };
458 (hue * 60.0, sat.clamp(0.0, 1.0), lum)
459}
460
461fn hsl_to_rgb(hue: f64, sat: f64, lum: f64) -> [u8; 3] {
462 hsl_to_rgb_f64(hue, sat, lum).map(|channel| (channel * 255.0).round() as u8)
463}
464
465fn hsl_to_rgb_f64(hue: f64, sat: f64, lum: f64) -> [f64; 3] {
466 let chroma = (1.0 - (2.0 * lum - 1.0).abs()) * sat;
467 let sector = hue.rem_euclid(360.0) / 60.0;
468 let second = chroma * (1.0 - (sector.rem_euclid(2.0) - 1.0).abs());
469 let (red, green, blue) = match sector as u8 {
470 0 => (chroma, second, 0.0),
471 1 => (second, chroma, 0.0),
472 2 => (0.0, chroma, second),
473 3 => (0.0, second, chroma),
474 4 => (second, 0.0, chroma),
475 _ => (chroma, 0.0, second),
476 };
477 let base = lum - chroma / 2.0;
478 [red, green, blue].map(|channel| (channel + base).clamp(0.0, 1.0))
479}
480
481fn parse_rgb(value: &str) -> Option<[u8; 3]> {
482 let value = value.strip_prefix('#').unwrap_or(value);
483 if value.len() != 6 {
484 return None;
485 }
486 let packed = u32::from_str_radix(value, 16).ok()?;
487 Some([
488 ((packed >> 16) & 0xff) as u8,
489 ((packed >> 8) & 0xff) as u8,
490 (packed & 0xff) as u8,
491 ])
492}
493
494fn parse_modifier(value: &str) -> Option<f64> {
495 let byte = u8::from_str_radix(value, 16).ok()?;
496 Some(f64::from(byte) / 255.0)
497}
498
499pub fn default_theme_color(slot: &str) -> &str {
500 match slot {
501 "dk1" | "text1" => "000000",
502 "lt1" | "background1" => "FFFFFF",
503 "dk2" | "text2" => "44546A",
504 "lt2" | "background2" => "E7E6E6",
505 "accent1" => "4472C4",
506 "accent2" => "ED7D31",
507 "accent3" => "A5A5A5",
508 "accent4" => "FFC000",
509 "accent5" => "5B9BD5",
510 "accent6" => "70AD47",
511 "hlink" => "0563C1",
512 "folHlink" => "954F72",
513 _ => "000000",
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn transform_xml_values_reject_non_finite_numbers() {
523 use ColorTransform::*;
524
525 let constructors: [fn(f64) -> ColorTransform; 23] = [
526 Tint, Shade, Hue, HueMod, HueOff, Sat, SatMod, SatOff, Lum, LumMod, LumOff, Red,
527 RedMod, RedOff, Green, GreenMod, GreenOff, Blue, BlueMod, BlueOff, Alpha, AlphaMod,
528 AlphaOff,
529 ];
530 for constructor in constructors {
531 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX] {
532 assert_eq!(constructor(value).xml_value(), None);
533 }
534 assert!(constructor(0.5).xml_value().is_some());
535 }
536 }
537
538 #[test]
539 fn transform_parsing_retains_legacy_ranges() {
540 for (name, maximum) in [
541 ("tint", 1.0),
542 ("shade", 1.0),
543 ("lumMod", 10.0),
544 ("lumOff", 10.0),
545 ("satMod", 10.0),
546 ("alpha", 10.0),
547 ] {
548 for value in [-0.00001, maximum + 0.00001] {
549 let raw = (value * 100_000.0).to_string();
550 assert_eq!(ColorTransform::from_xml(name, Some(&raw)), None, "{name}");
551 }
552 for value in [0.0, maximum] {
553 for raw in [
554 (value * 100_000.0).to_string(),
555 format!("{}%", value * 100.0),
556 ] {
557 assert!(
558 ColorTransform::from_xml(name, Some(&raw)).is_some(),
559 "{name}"
560 );
561 }
562 }
563 for raw in ["NaN", "inf", "-inf"] {
564 assert_eq!(ColorTransform::from_xml(name, Some(raw)), None);
565 }
566 }
567 assert_eq!(
568 ColorTransform::from_xml("satOff", Some("-50%")),
569 Some(ColorTransform::SatOff(-0.5))
570 );
571 assert_eq!(
572 ColorTransform::from_xml("hueOff", Some("-5400000")),
573 Some(ColorTransform::HueOff(-0.25))
574 );
575 assert_eq!(
576 ColorTransform::from_xml("alphaMod", Some("200000")),
577 Some(ColorTransform::AlphaMod(2.0))
578 );
579 }
580
581 #[test]
582 fn mixed_color_keeps_legacy_alpha_without_an_alpha_step() {
583 let mut color = ColorValue {
584 rgb: Some("112233".to_owned()),
585 theme_tint: Some("00".to_owned()),
586 luminance_modulation: Some(0.0),
587 alpha: Some(0.25),
588 transforms: vec![ColorTransform::Tint(1.0)],
589 ..Default::default()
590 };
591 assert_eq!(
592 resolve_color_value_to_rgba_hex(Some(&color), None).as_deref(),
593 Some("#11223340")
594 );
595 for (step, expected) in [
596 (ColorTransform::Alpha(0.5), 0.5),
597 (ColorTransform::AlphaMod(0.5), 0.5),
598 (ColorTransform::AlphaOff(-0.25), 0.75),
599 ] {
600 color.transforms = vec![step];
601 assert_eq!(resolve_color_value_alpha(&color), expected);
602 }
603 }
604
605 #[test]
606 fn transforms_use_their_own_color_spaces() {
607 use ColorTransform::*;
608
609 for (rgb, transforms, expected) in [
610 ("000000", vec![Red(0.5)], "#BC0000"),
611 ("000000", vec![Green(0.5)], "#00BC00"),
612 ("000000", vec![Blue(0.5)], "#0000BC"),
613 ("FFFFFF", vec![RedMod(0.5)], "#BCFFFF"),
614 ("FFFFFF", vec![GreenMod(0.5)], "#FFBCFF"),
615 ("FFFFFF", vec![BlueMod(0.5)], "#FFFFBC"),
616 ("000000", vec![RedOff(0.5)], "#BC0000"),
617 ("000000", vec![GreenOff(0.5)], "#00BC00"),
618 ("000000", vec![BlueOff(0.5)], "#0000BC"),
619 ("808080", vec![Inv], "#E5E5E5"),
620 ("FF0000", vec![Gray], "#7F7F7F"),
621 ("808080", vec![Gamma], "#BCBCBC"),
622 ("808080", vec![InvGamma], "#373737"),
623 ("808080", vec![Gamma, InvGamma], "#808080"),
624 ("FF0000", vec![Hue(1.0 / 3.0)], "#00FF00"),
625 ("00FF00", vec![HueMod(2.0)], "#0000FF"),
626 ("FF0000", vec![HueOff(-1.0 / 3.0)], "#0000FF"),
627 ("FF0000", vec![Comp], "#00FFFF"),
628 ("FF0000", vec![Sat(0.0)], "#808080"),
629 ("FF0000", vec![SatMod(0.5)], "#BF4040"),
630 ("FF0000", vec![SatOff(-0.5)], "#BF4040"),
631 ("FF0000", vec![Lum(0.25)], "#800000"),
632 ("FF0000", vec![LumOff(0.25)], "#FF8080"),
633 ("FF0000", vec![LumOff(1.0), LumMod(0.5)], "#808080"),
634 ("000000", vec![RedOff(-0.5), RedOff(0.5)], "#BC0000"),
635 ] {
636 let color = ColorValue {
637 rgb: Some(rgb.to_owned()),
638 transforms,
639 ..Default::default()
640 };
641 assert_eq!(
642 resolve_color_value_to_hex(Some(&color)).as_deref(),
643 Some(expected),
644 "{color:?}"
645 );
646 }
647 }
648
649 #[test]
650 fn ordered_alpha_is_separate_and_overrides_legacy_fields() {
651 let mut color = ColorValue {
652 rgb: Some("112233".to_owned()),
653 theme_tint: Some("00".to_owned()),
654 luminance_modulation: Some(0.0),
655 alpha: Some(0.0),
656 transforms: vec![
657 ColorTransform::Alpha(0.5),
658 ColorTransform::AlphaMod(0.5),
659 ColorTransform::AlphaOff(0.25),
660 ],
661 ..Default::default()
662 };
663 assert_eq!(
664 resolve_color_value_to_rgba_hex(Some(&color), None).as_deref(),
665 Some("#11223380")
666 );
667 assert_eq!(
668 resolve_color_value_to_hex(Some(&color)).as_deref(),
669 Some("#112233")
670 );
671 color.transforms = vec![
672 ColorTransform::AlphaOff(-2.0),
673 ColorTransform::AlphaOff(0.5),
674 ];
675 assert_eq!(resolve_color_value_alpha(&color), 0.5);
676 color.transforms = vec![ColorTransform::Alpha(0.25), ColorTransform::Alpha(1.0)];
677 assert_eq!(resolve_color_value_alpha(&color), 1.0);
678 }
679
680 #[test]
681 fn luminance_and_saturation_modifiers_resolve_in_hsl() {
682 let modified = |luminance: Option<f64>, offset: Option<f64>, saturation: Option<f64>| {
683 resolve_color_value_to_hex(Some(&ColorValue {
684 rgb: Some("4472C4".to_owned()),
685 luminance_modulation: luminance,
686 luminance_offset: offset,
687 saturation_modulation: saturation,
688 ..ColorValue::default()
689 }))
690 .expect("resolves")
691 };
692 assert_eq!(modified(None, None, None), "#4472C4");
693 assert_eq!(modified(Some(1.0), None, None), "#4472C4");
694 assert_eq!(modified(Some(0.6), Some(0.4), None), "#8FAADC");
696 assert_eq!(modified(Some(0.75), None, None), "#2F5597");
697 assert_eq!(modified(None, None, Some(0.0)), "#848484");
698 assert_eq!(
699 resolve_color_value_to_hex(Some(&ColorValue {
700 rgb: Some("808080".to_owned()),
701 saturation_modulation: Some(4.0),
702 ..ColorValue::default()
703 }))
704 .as_deref(),
705 Some("#808080"),
706 "a grey has no saturation to modulate"
707 );
708 }
709
710 #[test]
711 fn alpha_reaches_the_rgba_resolver_and_never_the_opaque_one() {
712 let translucent = ColorValue {
713 rgb: Some("112233".to_owned()),
714 alpha: Some(0.5),
715 ..ColorValue::default()
716 };
717 assert_eq!(
718 resolve_color_value_to_hex(Some(&translucent)).as_deref(),
719 Some("#112233")
720 );
721 assert_eq!(
722 resolve_color_value_to_rgba_hex(Some(&translucent), None).as_deref(),
723 Some("#11223380")
724 );
725 assert_eq!(
726 resolve_color_value_to_rgba_hex(
727 Some(&ColorValue {
728 alpha: None,
729 ..translucent
730 }),
731 None
732 )
733 .as_deref(),
734 Some("#112233FF")
735 );
736 }
737
738 #[test]
739 fn legacy_tint_and_shade_keep_their_byte_math() {
740 let modified = |tint: Option<&str>, shade: Option<&str>| {
741 resolve_color_value_to_hex(Some(&ColorValue {
742 rgb: Some("204060".to_owned()),
743 theme_tint: tint.map(str::to_owned),
744 theme_shade: shade.map(str::to_owned),
745 ..ColorValue::default()
746 }))
747 .expect("resolves")
748 };
749
750 assert_eq!(modified(Some("FF"), None), "#204060");
751 assert_eq!(modified(Some("CC"), None), "#4D6680");
752 assert_eq!(modified(Some("80"), None), "#8F9FAF");
753 assert_eq!(modified(Some("33"), None), "#D2D9DF");
754 assert_eq!(modified(Some("00"), None), "#FFFFFF");
755
756 assert_eq!(modified(None, Some("FF")), "#204060");
757 assert_eq!(modified(None, Some("80")), "#102030");
758 assert_eq!(modified(None, Some("33")), "#060D13");
759 assert_eq!(modified(None, Some("00")), "#000000");
760
761 const LEGACY_ACCENT1_TINT_66: &str = "#B4C7E7";
762 assert_eq!(
763 resolve_color_value_to_hex(Some(&ColorValue {
764 theme_color: Some("accent1".to_owned()),
765 theme_tint: Some("66".to_owned()),
766 ..ColorValue::default()
767 }))
768 .as_deref(),
769 Some(LEGACY_ACCENT1_TINT_66)
770 );
771 }
772
773 #[test]
774 fn parses_and_resolves_direct_and_theme_colors() {
775 let direct = parse_color_value(Some("AABBCC"), None, None, None);
776 assert_eq!(
777 resolve_color_value_to_hex(Some(&direct)).as_deref(),
778 Some("#AABBCC")
779 );
780
781 let themed = parse_color_value(None, Some("accent1"), None, None);
782 assert_eq!(
783 resolve_color_value_to_hex(Some(&themed)).as_deref(),
784 Some("#4472C4")
785 );
786
787 let mut theme = Theme::default();
788 theme.color_scheme.accent1 = "204060".to_owned();
789 let tinted = ColorValue {
790 theme_color: Some("accent1".to_owned()),
791 theme_tint: Some("80".to_owned()),
792 ..ColorValue::default()
793 };
794 assert_eq!(
795 resolve_color_value_to_hex_with_theme(Some(&tinted), Some(&theme)).as_deref(),
796 Some("#8F9FAF")
797 );
798
799 let malformed = ColorValue {
800 rgb: Some("aƩabc".to_owned()),
801 ..ColorValue::default()
802 };
803 assert_eq!(resolve_color_value_to_hex(Some(&malformed)), None);
804 }
805}