Skip to main content

ooxml_drawingml/
style.rs

1//! `a:fmtScheme` style lists and the `a:fillRef`/`a:lnRef` references into them.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{ColorValue, ShapeFill, ShapeOutline, Theme, resolve_color_value_to_hex_with_theme};
6
7/// Line width applied to a style reference the theme does not define.
8const DEFAULT_STYLE_LINE_WIDTH_EMU: f64 = 9_525.0;
9
10/// First `a:fillRef` index naming `a:bgFillStyleLst` instead of `a:fillStyleLst`.
11const BACKGROUND_FILL_INDEX_BASE: u32 = 1_001;
12
13/// Theme style lists with stable, one-based slots.
14#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct ThemeFormatScheme {
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub fills: Vec<Option<ShapeFill>>,
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub lines: Vec<Option<ShapeOutline>>,
21    /// `a:bgFillStyleLst`, which `a:fillRef` indices from 1001 name.
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub background_fills: Vec<Option<ShapeFill>>,
24}
25
26impl ThemeFormatScheme {
27    pub fn is_empty(&self) -> bool {
28        self.fills.is_empty() && self.lines.is_empty() && self.background_fills.is_empty()
29    }
30
31    fn fill(&self, index: u32) -> Option<&ShapeFill> {
32        let (list, first) = if index >= BACKGROUND_FILL_INDEX_BASE {
33            (&self.background_fills, BACKGROUND_FILL_INDEX_BASE)
34        } else {
35            (&self.fills, 1)
36        };
37        list.get(index.checked_sub(first)? as usize)?.as_ref()
38    }
39
40    fn line(&self, index: u32) -> Option<&ShapeOutline> {
41        self.lines.get(index.checked_sub(1)? as usize)?.as_ref()
42    }
43}
44
45/// A style index and its placeholder colour.
46#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct StyleReference {
49    pub index: u32,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub color: Option<ColorValue>,
52}
53
54/// Theme references and unsupported explicit fills.
55#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct ShapeStyle {
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub font_color: Option<ColorValue>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub fill: Option<StyleReference>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub line: Option<StyleReference>,
64    #[serde(default, skip_serializing_if = "is_false")]
65    pub fill_disabled: bool,
66    #[serde(default, skip_serializing_if = "is_false")]
67    pub line_disabled: bool,
68}
69
70fn is_false(value: &bool) -> bool {
71    !value
72}
73
74impl ShapeStyle {
75    pub fn is_empty(&self) -> bool {
76        self.font_color.is_none()
77            && self.fill.is_none()
78            && self.line.is_none()
79            && !self.fill_disabled
80            && !self.line_disabled
81    }
82}
83
84/// Resolves a fill reference; 0 and 1000 mean no fill.
85pub fn style_fill(
86    scheme: &ThemeFormatScheme,
87    reference: &StyleReference,
88    theme: &Theme,
89) -> ShapeFill {
90    if matches!(reference.index, 0 | 1000) {
91        return ShapeFill::named("none");
92    }
93    let placeholder = placeholder_color(reference, theme);
94    let Some(fill) = scheme.fill(reference.index) else {
95        return ShapeFill {
96            fill_type: "solid".to_owned(),
97            color: placeholder,
98            gradient: None,
99        };
100    };
101    let mut resolved = fill.clone();
102    resolved.color = substitute_color(fill.color.as_ref(), placeholder.as_ref());
103    if let Some(gradient) = &mut resolved.gradient {
104        for stop in &mut gradient.stops {
105            if let Some(color) = substitute_color(Some(&stop.color), placeholder.as_ref()) {
106                stop.color = color;
107            }
108        }
109    }
110    resolved
111}
112
113/// Resolves `a:lnRef` against the theme. Index 0 means no outline.
114pub fn style_outline(
115    scheme: &ThemeFormatScheme,
116    reference: &StyleReference,
117    theme: &Theme,
118) -> Option<ShapeOutline> {
119    if reference.index == 0 {
120        return None;
121    }
122    let placeholder = placeholder_color(reference, theme);
123    let Some(outline) = scheme.line(reference.index) else {
124        return Some(ShapeOutline {
125            width: Some(DEFAULT_STYLE_LINE_WIDTH_EMU),
126            color: placeholder,
127            ..ShapeOutline::default()
128        });
129    };
130    let mut resolved = ShapeOutline {
131        width: outline.width.or(Some(DEFAULT_STYLE_LINE_WIDTH_EMU)),
132        color: substitute_color(outline.color.as_ref(), placeholder.as_ref()),
133        ..outline.clone()
134    };
135    if let Some(gradient) = &mut resolved.gradient {
136        for stop in &mut gradient.stops {
137            if let Some(color) = substitute_color(Some(&stop.color), placeholder.as_ref()) {
138                stop.color = color;
139            }
140        }
141    }
142    Some(resolved)
143}
144
145/// Applies reference modifiers before style modifiers.
146fn placeholder_color(reference: &StyleReference, theme: &Theme) -> Option<ColorValue> {
147    let color = reference.color.as_ref()?;
148    let rgb = resolve_color_value_to_hex_with_theme(Some(color), Some(theme))?;
149    Some(ColorValue {
150        rgb: Some(rgb.trim_start_matches('#').to_owned()),
151        alpha: color.alpha,
152        ..ColorValue::default()
153    })
154}
155
156/// Replaces the base colour while retaining style modifiers.
157fn substitute_color(
158    value: Option<&ColorValue>,
159    placeholder: Option<&ColorValue>,
160) -> Option<ColorValue> {
161    let value = value?;
162    if value.theme_color.as_deref() != Some("phClr") {
163        return Some(value.clone());
164    }
165    let Some(placeholder) = placeholder else {
166        return Some(value.clone());
167    };
168    Some(ColorValue {
169        rgb: placeholder.rgb.clone(),
170        theme_color: None,
171        auto: None,
172        alpha: value.alpha.or(placeholder.alpha),
173        ..value.clone()
174    })
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::{GradientFill, GradientStop};
181
182    fn rgb(value: &str) -> ColorValue {
183        ColorValue {
184            rgb: Some(value.to_owned()),
185            ..Default::default()
186        }
187    }
188
189    #[test]
190    fn gradient_outline_substitutes_placeholder_colour_and_preserves_modifiers() {
191        let scheme = ThemeFormatScheme {
192            lines: vec![Some(ShapeOutline {
193                gradient: Some(GradientFill {
194                    gradient_type: "linear".into(),
195                    angle: Some(45.0),
196                    stops: vec![GradientStop {
197                        position: 0.0,
198                        color: ColorValue {
199                            theme_color: Some("phClr".into()),
200                            luminance_modulation: Some(0.5),
201                            ..Default::default()
202                        },
203                    }],
204                }),
205                ..Default::default()
206            })],
207            ..Default::default()
208        };
209        let reference = StyleReference {
210            index: 1,
211            color: Some(ColorValue {
212                alpha: Some(0.25),
213                ..rgb("FF0000")
214            }),
215        };
216        let resolved = style_outline(&scheme, &reference, &Theme::default()).unwrap();
217        let gradient = resolved.gradient.unwrap();
218        assert_eq!(gradient.angle, Some(45.0));
219        assert_eq!(gradient.stops[0].color.rgb.as_deref(), Some("FF0000"));
220        assert_eq!(gradient.stops[0].color.theme_color, None);
221        assert_eq!(gradient.stops[0].color.luminance_modulation, Some(0.5));
222        assert_eq!(gradient.stops[0].color.alpha, Some(0.25));
223    }
224
225    fn solid(color: ColorValue) -> ShapeFill {
226        ShapeFill {
227            fill_type: "solid".to_owned(),
228            color: Some(color),
229            gradient: None,
230        }
231    }
232
233    fn reference(index: u32) -> StyleReference {
234        StyleReference {
235            index,
236            color: Some(rgb("808080")),
237        }
238    }
239
240    #[test]
241    fn fill_indices_keep_holes_and_select_background_styles() {
242        let scheme = ThemeFormatScheme {
243            fills: vec![Some(solid(rgb("112233"))), None, Some(solid(rgb("445566")))],
244            background_fills: vec![Some(solid(rgb("778899"))), Some(solid(rgb("AABBCC")))],
245            ..Default::default()
246        };
247        let theme = Theme::default();
248        for (index, expected) in [
249            (1, "112233"),
250            (2, "808080"),
251            (3, "445566"),
252            (999, "808080"),
253            (1001, "778899"),
254            (1002, "AABBCC"),
255            (1003, "808080"),
256        ] {
257            assert_eq!(
258                style_fill(&scheme, &reference(index), &theme)
259                    .color
260                    .unwrap()
261                    .rgb
262                    .as_deref(),
263                Some(expected),
264                "index {index}"
265            );
266        }
267        for index in [0, 1000] {
268            assert_eq!(
269                style_fill(&scheme, &reference(index), &theme),
270                ShapeFill::named("none")
271            );
272        }
273    }
274
275    #[test]
276    fn placeholder_modifiers_compose_in_solids_lines_and_every_gradient_stop() {
277        let first = ColorValue {
278            theme_color: Some("phClr".to_owned()),
279            luminance_modulation: Some(0.5),
280            luminance_offset: Some(0.1),
281            ..Default::default()
282        };
283        let last = ColorValue {
284            luminance_modulation: Some(0.25),
285            luminance_offset: Some(0.2),
286            ..first.clone()
287        };
288        let scheme = ThemeFormatScheme {
289            fills: vec![
290                Some(solid(first.clone())),
291                Some(ShapeFill {
292                    fill_type: "gradient".to_owned(),
293                    color: None,
294                    gradient: Some(GradientFill {
295                        gradient_type: "linear".to_owned(),
296                        angle: Some(90.0),
297                        stops: vec![
298                            GradientStop {
299                                position: 0.0,
300                                color: first.clone(),
301                            },
302                            GradientStop {
303                                position: 50000.0,
304                                color: rgb("123456"),
305                            },
306                            GradientStop {
307                                position: 100000.0,
308                                color: last,
309                            },
310                        ],
311                    }),
312                }),
313            ],
314            lines: vec![Some(ShapeOutline {
315                color: Some(first),
316                ..Default::default()
317            })],
318            ..Default::default()
319        };
320        let mut theme = Theme::default();
321        theme.color_scheme.accent1 = "808080".to_owned();
322        let reference = |index| StyleReference {
323            index,
324            color: Some(ColorValue {
325                theme_color: Some("accent1".to_owned()),
326                luminance_modulation: Some(0.5),
327                luminance_offset: Some(0.1),
328                ..Default::default()
329            }),
330        };
331        let hex = |color: &ColorValue| {
332            resolve_color_value_to_hex_with_theme(Some(color), Some(&theme)).unwrap()
333        };
334        assert_eq!(
335            hex(style_fill(&scheme, &reference(1), &theme)
336                .color
337                .as_ref()
338                .unwrap()),
339            "#474747"
340        );
341        assert_eq!(
342            hex(style_outline(&scheme, &reference(1), &theme)
343                .unwrap()
344                .color
345                .as_ref()
346                .unwrap()),
347            "#474747"
348        );
349        let gradient = style_fill(&scheme, &reference(2), &theme).gradient.unwrap();
350        assert_eq!(gradient.angle, Some(90.0));
351        assert_eq!(
352            gradient
353                .stops
354                .iter()
355                .map(|s| (s.position, hex(&s.color)))
356                .collect::<Vec<_>>(),
357            vec![
358                (0.0, "#474747".to_owned()),
359                (50000.0, "#123456".to_owned()),
360                (100000.0, "#4A4A4A".to_owned())
361            ]
362        );
363    }
364
365    #[test]
366    fn line_indices_preserve_no_fill_slots_and_explicit_style_properties() {
367        let line = ShapeOutline {
368            width: Some(28575.0),
369            color: Some(rgb("123456")),
370            style: Some("dash".to_owned()),
371            cap: Some("rnd".to_owned()),
372            ..Default::default()
373        };
374        let scheme = ThemeFormatScheme {
375            lines: vec![Some(ShapeOutline::default()), None, Some(line.clone())],
376            ..Default::default()
377        };
378        let theme = Theme::default();
379        assert_eq!(style_outline(&scheme, &reference(0), &theme), None);
380        assert!(
381            style_outline(&scheme, &reference(1), &theme)
382                .unwrap()
383                .color
384                .is_none()
385        );
386        assert_eq!(style_outline(&scheme, &reference(3), &theme), Some(line));
387        for index in [2, 9] {
388            let outline = style_outline(&scheme, &reference(index), &theme).unwrap();
389            assert_eq!(outline.width, Some(9525.0));
390            assert_eq!(outline.color.unwrap().rgb.as_deref(), Some("808080"));
391        }
392    }
393}