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/// Resolves a `phClr` colour against the reference's colour, as a style fill does.
146pub fn style_reference_color(
147    value: &ColorValue,
148    reference: &StyleReference,
149    theme: &Theme,
150) -> Option<ColorValue> {
151    substitute_color(Some(value), placeholder_color(reference, theme).as_ref())
152}
153
154/// Applies reference modifiers before style modifiers.
155fn placeholder_color(reference: &StyleReference, theme: &Theme) -> Option<ColorValue> {
156    let color = reference.color.as_ref()?;
157    if !color.transforms.is_empty() {
158        let base = ColorValue {
159            rgb: color.rgb.clone(),
160            theme_color: color.theme_color.clone(),
161            ..ColorValue::default()
162        };
163        let rgb = resolve_color_value_to_hex_with_theme(Some(&base), Some(theme))?;
164        return Some(ColorValue {
165            rgb: Some(rgb.trim_start_matches('#').to_owned()),
166            theme_color: None,
167            ..color.clone()
168        });
169    }
170    let rgb = resolve_color_value_to_hex_with_theme(Some(color), Some(theme))?;
171    Some(ColorValue {
172        rgb: Some(rgb.trim_start_matches('#').to_owned()),
173        alpha: color.alpha,
174        ..ColorValue::default()
175    })
176}
177
178/// Replaces the base colour while retaining style modifiers.
179fn substitute_color(
180    value: Option<&ColorValue>,
181    placeholder: Option<&ColorValue>,
182) -> Option<ColorValue> {
183    let value = value?;
184    if value.theme_color.as_deref() != Some("phClr") {
185        return Some(value.clone());
186    }
187    let Some(placeholder) = placeholder else {
188        return Some(value.clone());
189    };
190    let legacy = value.theme_tint.is_some()
191        || value.theme_shade.is_some()
192        || value.luminance_modulation.is_some()
193        || value.luminance_offset.is_some()
194        || value.saturation_modulation.is_some()
195        || value.alpha.is_some();
196    let placeholder = if !placeholder.transforms.is_empty() && value.transforms.is_empty() && legacy
197    {
198        ColorValue {
199            rgb: resolve_color_value_to_hex_with_theme(Some(placeholder), None)
200                .map(|hex| hex.trim_start_matches('#').to_owned()),
201            alpha: Some(crate::resolve_color_value_alpha(placeholder)),
202            ..ColorValue::default()
203        }
204    } else {
205        placeholder.clone()
206    };
207    let mut transforms: Vec<_> = placeholder.drawingml_transforms().collect();
208    if transforms.is_empty()
209        && !value.transforms.is_empty()
210        && let Some(alpha) = placeholder.alpha
211    {
212        transforms.push(crate::ColorTransform::Alpha(alpha));
213    }
214    transforms.extend(value.drawingml_transforms());
215    Some(ColorValue {
216        rgb: placeholder.rgb.clone(),
217        theme_color: None,
218        auto: None,
219        alpha: value.alpha.or(placeholder.alpha),
220        transforms,
221        ..value.clone()
222    })
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::{GradientFill, GradientStop};
229
230    fn rgb(value: &str) -> ColorValue {
231        ColorValue {
232            rgb: Some(value.to_owned()),
233            ..Default::default()
234        }
235    }
236
237    #[test]
238    fn gradient_outline_substitutes_placeholder_colour_and_preserves_modifiers() {
239        let scheme = ThemeFormatScheme {
240            lines: vec![Some(ShapeOutline {
241                gradient: Some(GradientFill {
242                    gradient_type: "linear".into(),
243                    angle: Some(45.0),
244                    stops: vec![GradientStop {
245                        position: 0.0,
246                        color: ColorValue {
247                            theme_color: Some("phClr".into()),
248                            luminance_modulation: Some(0.5),
249                            ..Default::default()
250                        },
251                    }],
252                }),
253                ..Default::default()
254            })],
255            ..Default::default()
256        };
257        let reference = StyleReference {
258            index: 1,
259            color: Some(ColorValue {
260                alpha: Some(0.25),
261                ..rgb("FF0000")
262            }),
263        };
264        let resolved = style_outline(&scheme, &reference, &Theme::default()).unwrap();
265        let gradient = resolved.gradient.unwrap();
266        assert_eq!(gradient.angle, Some(45.0));
267        assert_eq!(gradient.stops[0].color.rgb.as_deref(), Some("FF0000"));
268        assert_eq!(gradient.stops[0].color.theme_color, None);
269        assert_eq!(gradient.stops[0].color.luminance_modulation, Some(0.5));
270        assert_eq!(gradient.stops[0].color.alpha, Some(0.25));
271    }
272
273    fn solid(color: ColorValue) -> ShapeFill {
274        ShapeFill {
275            fill_type: "solid".to_owned(),
276            color: Some(color),
277            gradient: None,
278        }
279    }
280
281    fn reference(index: u32) -> StyleReference {
282        StyleReference {
283            index,
284            color: Some(rgb("808080")),
285        }
286    }
287
288    #[test]
289    fn fill_indices_keep_holes_and_select_background_styles() {
290        let scheme = ThemeFormatScheme {
291            fills: vec![Some(solid(rgb("112233"))), None, Some(solid(rgb("445566")))],
292            background_fills: vec![Some(solid(rgb("778899"))), Some(solid(rgb("AABBCC")))],
293            ..Default::default()
294        };
295        let theme = Theme::default();
296        for (index, expected) in [
297            (1, "112233"),
298            (2, "808080"),
299            (3, "445566"),
300            (999, "808080"),
301            (1001, "778899"),
302            (1002, "AABBCC"),
303            (1003, "808080"),
304        ] {
305            assert_eq!(
306                style_fill(&scheme, &reference(index), &theme)
307                    .color
308                    .unwrap()
309                    .rgb
310                    .as_deref(),
311                Some(expected),
312                "index {index}"
313            );
314        }
315        for index in [0, 1000] {
316            assert_eq!(
317                style_fill(&scheme, &reference(index), &theme),
318                ShapeFill::named("none")
319            );
320        }
321    }
322
323    #[test]
324    fn ordered_placeholder_transforms_compose_without_intermediate_rounding() {
325        use crate::{ColorTransform, resolve_color_value_to_rgba_hex};
326
327        let color = ColorValue {
328            theme_color: Some("phClr".to_owned()),
329            transforms: vec![ColorTransform::LumMod(0.6), ColorTransform::AlphaMod(0.5)],
330            ..Default::default()
331        };
332        let scheme = ThemeFormatScheme {
333            fills: vec![
334                Some(solid(color.clone())),
335                Some(ShapeFill {
336                    fill_type: "gradient".to_owned(),
337                    color: None,
338                    gradient: Some(GradientFill {
339                        gradient_type: "linear".to_owned(),
340                        angle: None,
341                        stops: vec![GradientStop {
342                            position: 0.0,
343                            color: color.clone(),
344                        }],
345                    }),
346                }),
347            ],
348            lines: vec![Some(ShapeOutline {
349                color: Some(color),
350                ..Default::default()
351            })],
352            ..Default::default()
353        };
354        let mut reference = StyleReference {
355            index: 1,
356            color: Some(ColorValue {
357                theme_color: Some("accent1".to_owned()),
358                transforms: vec![ColorTransform::Tint(0.5), ColorTransform::Alpha(0.5)],
359                ..Default::default()
360            }),
361        };
362        let theme = Theme::default();
363        let solid = style_fill(&scheme, &reference, &theme).color.unwrap();
364        let line = style_outline(&scheme, &reference, &theme)
365            .unwrap()
366            .color
367            .unwrap();
368        reference.index = 2;
369        let gradient = style_fill(&scheme, &reference, &theme).gradient.unwrap();
370        for color in [&solid, &line, &gradient.stops[0].color] {
371            assert_eq!(
372                color.transforms,
373                vec![
374                    ColorTransform::Tint(0.5),
375                    ColorTransform::Alpha(0.5),
376                    ColorTransform::LumMod(0.6),
377                    ColorTransform::AlphaMod(0.5),
378                ]
379            );
380            assert_eq!(
381                resolve_color_value_to_rgba_hex(Some(color), Some(&theme)).as_deref(),
382                Some("#4C64B040")
383            );
384        }
385        reference.index = 1;
386        let color = reference.color.as_mut().unwrap();
387        color.transforms = vec![ColorTransform::Tint(0.5)];
388        color.alpha = Some(0.5);
389        let mixed = style_fill(&scheme, &reference, &theme).color.unwrap();
390        assert_eq!(mixed.transforms, solid.transforms);
391        assert_eq!(
392            resolve_color_value_to_rgba_hex(Some(&mixed), Some(&theme)).as_deref(),
393            Some("#4C64B040")
394        );
395    }
396
397    #[test]
398    fn placeholder_modifiers_compose_in_solids_lines_and_every_gradient_stop() {
399        let first = ColorValue {
400            theme_color: Some("phClr".to_owned()),
401            luminance_modulation: Some(0.5),
402            luminance_offset: Some(0.1),
403            ..Default::default()
404        };
405        let last = ColorValue {
406            luminance_modulation: Some(0.25),
407            luminance_offset: Some(0.2),
408            ..first.clone()
409        };
410        let scheme = ThemeFormatScheme {
411            fills: vec![
412                Some(solid(first.clone())),
413                Some(ShapeFill {
414                    fill_type: "gradient".to_owned(),
415                    color: None,
416                    gradient: Some(GradientFill {
417                        gradient_type: "linear".to_owned(),
418                        angle: Some(90.0),
419                        stops: vec![
420                            GradientStop {
421                                position: 0.0,
422                                color: first.clone(),
423                            },
424                            GradientStop {
425                                position: 50000.0,
426                                color: rgb("123456"),
427                            },
428                            GradientStop {
429                                position: 100000.0,
430                                color: last,
431                            },
432                        ],
433                    }),
434                }),
435            ],
436            lines: vec![Some(ShapeOutline {
437                color: Some(first),
438                ..Default::default()
439            })],
440            ..Default::default()
441        };
442        let mut theme = Theme::default();
443        theme.color_scheme.accent1 = "808080".to_owned();
444        let reference = |index| StyleReference {
445            index,
446            color: Some(ColorValue {
447                theme_color: Some("accent1".to_owned()),
448                luminance_modulation: Some(0.5),
449                luminance_offset: Some(0.1),
450                ..Default::default()
451            }),
452        };
453        let hex = |color: &ColorValue| {
454            resolve_color_value_to_hex_with_theme(Some(color), Some(&theme)).unwrap()
455        };
456        assert_eq!(
457            hex(style_fill(&scheme, &reference(1), &theme)
458                .color
459                .as_ref()
460                .unwrap()),
461            "#474747"
462        );
463        assert_eq!(
464            hex(style_outline(&scheme, &reference(1), &theme)
465                .unwrap()
466                .color
467                .as_ref()
468                .unwrap()),
469            "#474747"
470        );
471        let gradient = style_fill(&scheme, &reference(2), &theme).gradient.unwrap();
472        assert_eq!(gradient.angle, Some(90.0));
473        assert_eq!(
474            gradient
475                .stops
476                .iter()
477                .map(|s| (s.position, hex(&s.color)))
478                .collect::<Vec<_>>(),
479            vec![
480                (0.0, "#474747".to_owned()),
481                (50000.0, "#123456".to_owned()),
482                (100000.0, "#4A4A4A".to_owned())
483            ]
484        );
485    }
486
487    #[test]
488    fn line_indices_preserve_no_fill_slots_and_explicit_style_properties() {
489        let line = ShapeOutline {
490            width: Some(28575.0),
491            color: Some(rgb("123456")),
492            style: Some("dash".to_owned()),
493            cap: Some("rnd".to_owned()),
494            ..Default::default()
495        };
496        let scheme = ThemeFormatScheme {
497            lines: vec![Some(ShapeOutline::default()), None, Some(line.clone())],
498            ..Default::default()
499        };
500        let theme = Theme::default();
501        assert_eq!(style_outline(&scheme, &reference(0), &theme), None);
502        assert!(
503            style_outline(&scheme, &reference(1), &theme)
504                .unwrap()
505                .color
506                .is_none()
507        );
508        assert_eq!(style_outline(&scheme, &reference(3), &theme), Some(line));
509        for index in [2, 9] {
510            let outline = style_outline(&scheme, &reference(index), &theme).unwrap();
511            assert_eq!(outline.width, Some(9525.0));
512            assert_eq!(outline.color.unwrap().rgb.as_deref(), Some("808080"));
513        }
514    }
515}