Skip to main content

ooxml_drawingml/
color.rs

1use serde::{Deserialize, Serialize};
2
3use crate::Theme;
4
5/// DrawingML transforms; hue and hue offsets use turns, other values use fractions.
6#[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    /// Overrides legacy colour modifiers; legacy alpha applies without an alpha step.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub transforms: Vec<ColorTransform>,
185    /// `a:lumMod`, as a fraction of the colour's own luminance.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub luminance_modulation: Option<f64>,
188    /// `a:lumOff`, added to luminance after `a:lumMod`.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub luminance_offset: Option<f64>,
191    /// `a:satMod`, as a fraction of the colour's own saturation.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub saturation_modulation: Option<f64>,
194    /// `a:alpha`, as a fraction. Opaque hex output drops it; use
195    /// [`resolve_color_value_to_rgba_hex`] to keep it.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub alpha: Option<f64>,
198}
199
200impl ColorValue {
201    /// Ordered transforms with legacy alpha as a fallback.
202    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    /// A plain `w:val`/`w:fill` attribute, where `auto` defers to the consumer.
220    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
291/// `#RRGGBBAA`, for hosts that can paint `a:alpha`. Every other resolver
292/// returns opaque `#RRGGBB`, which many consumers require.
293pub 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
413/// `a:satMod`, `a:lumMod` and `a:lumOff`, which scale and shift the colour in
414/// HSL space rather than per channel.
415fn 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        // The pairing Office writes for a 40% lighter accent.
695        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}