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::{
6    ColorValue, ShapeEffects, ShapeFill, ShapeOutline, Theme, resolve_color_value_to_hex_with_theme,
7};
8
9/// Line width applied to a style reference the theme does not define.
10const DEFAULT_STYLE_LINE_WIDTH_EMU: f64 = 9_525.0;
11
12/// First `a:fillRef` index naming `a:bgFillStyleLst` instead of `a:fillStyleLst`.
13const BACKGROUND_FILL_INDEX_BASE: u32 = 1_001;
14
15/// Theme style lists with stable, one-based slots.
16#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct ThemeFormatScheme {
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub fills: Vec<Option<ShapeFill>>,
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub lines: Vec<Option<ShapeOutline>>,
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub effects: Vec<Option<ShapeEffects>>,
25    /// `a:bgFillStyleLst`, which `a:fillRef` indices from 1001 name.
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub background_fills: Vec<Option<ShapeFill>>,
28}
29
30impl ThemeFormatScheme {
31    pub fn is_empty(&self) -> bool {
32        self.fills.is_empty()
33            && self.lines.is_empty()
34            && self.effects.is_empty()
35            && self.background_fills.is_empty()
36    }
37
38    fn fill(&self, index: u32) -> Option<&ShapeFill> {
39        let (list, first) = if index >= BACKGROUND_FILL_INDEX_BASE {
40            (&self.background_fills, BACKGROUND_FILL_INDEX_BASE)
41        } else {
42            (&self.fills, 1)
43        };
44        list.get(index.checked_sub(first)? as usize)?.as_ref()
45    }
46
47    fn line(&self, index: u32) -> Option<&ShapeOutline> {
48        self.lines.get(index.checked_sub(1)? as usize)?.as_ref()
49    }
50}
51
52/// A style index and its placeholder colour.
53#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct StyleReference {
56    pub index: u32,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub color: Option<ColorValue>,
59}
60
61/// Theme references and unsupported explicit fills.
62#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct ShapeStyle {
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub font_color: Option<ColorValue>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub fill: Option<StyleReference>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub line: Option<StyleReference>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub effect: Option<StyleReference>,
73    #[serde(default, skip_serializing_if = "is_false")]
74    pub fill_disabled: bool,
75    #[serde(default, skip_serializing_if = "is_false")]
76    pub line_disabled: bool,
77}
78
79fn is_false(value: &bool) -> bool {
80    !value
81}
82
83impl ShapeStyle {
84    pub fn is_empty(&self) -> bool {
85        self.font_color.is_none()
86            && self.fill.is_none()
87            && self.line.is_none()
88            && self.effect.is_none()
89            && !self.fill_disabled
90            && !self.line_disabled
91    }
92}
93
94/// Resolves a fill reference; 0 and 1000 mean no fill.
95pub fn style_fill(
96    scheme: &ThemeFormatScheme,
97    reference: &StyleReference,
98    theme: &Theme,
99) -> ShapeFill {
100    if matches!(reference.index, 0 | 1000) {
101        return ShapeFill::named("none");
102    }
103    let placeholder = placeholder_color(reference, theme);
104    let Some(fill) = scheme.fill(reference.index) else {
105        return ShapeFill {
106            fill_type: "solid".to_owned(),
107            color: placeholder,
108            gradient: None,
109        };
110    };
111    let mut resolved = fill.clone();
112    resolved.color = substitute_color(fill.color.as_ref(), placeholder.as_ref());
113    if let Some(gradient) = &mut resolved.gradient {
114        for stop in &mut gradient.stops {
115            if let Some(color) = substitute_color(Some(&stop.color), placeholder.as_ref()) {
116                stop.color = color;
117            }
118        }
119    }
120    resolved
121}
122
123/// Resolves `a:lnRef` against the theme. Index 0 means no outline.
124pub fn style_outline(
125    scheme: &ThemeFormatScheme,
126    reference: &StyleReference,
127    theme: &Theme,
128) -> Option<ShapeOutline> {
129    if reference.index == 0 {
130        return None;
131    }
132    let placeholder = placeholder_color(reference, theme);
133    let Some(outline) = scheme.line(reference.index) else {
134        return Some(ShapeOutline {
135            width: Some(DEFAULT_STYLE_LINE_WIDTH_EMU),
136            color: placeholder,
137            ..ShapeOutline::default()
138        });
139    };
140    let mut resolved = ShapeOutline {
141        width: outline.width.or(Some(DEFAULT_STYLE_LINE_WIDTH_EMU)),
142        color: substitute_color(outline.color.as_ref(), placeholder.as_ref()),
143        ..outline.clone()
144    };
145    if let Some(gradient) = &mut resolved.gradient {
146        for stop in &mut gradient.stops {
147            if let Some(color) = substitute_color(Some(&stop.color), placeholder.as_ref()) {
148                stop.color = color;
149            }
150        }
151    }
152    Some(resolved)
153}
154
155/// Resolves `a:effectRef`; index 0 means no effects.
156pub fn style_effects(
157    scheme: &ThemeFormatScheme,
158    reference: &StyleReference,
159    theme: &Theme,
160) -> Option<ShapeEffects> {
161    let index = reference.index.checked_sub(1)? as usize;
162    let mut effects = scheme.effects.get(index)?.as_ref()?.clone();
163    if let Some(shadow) = &mut effects.outer_shadow {
164        shadow.color = substitute_color(
165            shadow.color.as_ref(),
166            placeholder_color(reference, theme).as_ref(),
167        );
168    }
169    Some(effects)
170}
171
172/// Resolves a `phClr` colour against the reference's colour, as a style fill does.
173pub fn style_reference_color(
174    value: &ColorValue,
175    reference: &StyleReference,
176    theme: &Theme,
177) -> Option<ColorValue> {
178    substitute_color(Some(value), placeholder_color(reference, theme).as_ref())
179}
180
181/// Applies reference modifiers before style modifiers.
182fn placeholder_color(reference: &StyleReference, theme: &Theme) -> Option<ColorValue> {
183    let color = reference.color.as_ref()?;
184    if !color.transforms.is_empty() {
185        let base = ColorValue {
186            rgb: color.rgb.clone(),
187            theme_color: color.theme_color.clone(),
188            ..ColorValue::default()
189        };
190        let rgb = resolve_color_value_to_hex_with_theme(Some(&base), Some(theme))?;
191        return Some(ColorValue {
192            rgb: Some(rgb.trim_start_matches('#').to_owned()),
193            theme_color: None,
194            ..color.clone()
195        });
196    }
197    let rgb = resolve_color_value_to_hex_with_theme(Some(color), Some(theme))?;
198    Some(ColorValue {
199        rgb: Some(rgb.trim_start_matches('#').to_owned()),
200        alpha: color.alpha,
201        ..ColorValue::default()
202    })
203}
204
205/// Replaces the base colour while retaining style modifiers.
206fn substitute_color(
207    value: Option<&ColorValue>,
208    placeholder: Option<&ColorValue>,
209) -> Option<ColorValue> {
210    let value = value?;
211    if value.theme_color.as_deref() != Some("phClr") {
212        return Some(value.clone());
213    }
214    let Some(placeholder) = placeholder else {
215        return Some(value.clone());
216    };
217    let legacy = value.theme_tint.is_some()
218        || value.theme_shade.is_some()
219        || value.luminance_modulation.is_some()
220        || value.luminance_offset.is_some()
221        || value.saturation_modulation.is_some()
222        || value.alpha.is_some();
223    let placeholder = if !placeholder.transforms.is_empty() && value.transforms.is_empty() && legacy
224    {
225        ColorValue {
226            rgb: resolve_color_value_to_hex_with_theme(Some(placeholder), None)
227                .map(|hex| hex.trim_start_matches('#').to_owned()),
228            alpha: Some(crate::resolve_color_value_alpha(placeholder)),
229            ..ColorValue::default()
230        }
231    } else {
232        placeholder.clone()
233    };
234    let mut transforms: Vec<_> = placeholder.drawingml_transforms().collect();
235    if transforms.is_empty()
236        && !value.transforms.is_empty()
237        && let Some(alpha) = placeholder.alpha
238    {
239        transforms.push(crate::ColorTransform::Alpha(alpha));
240    }
241    transforms.extend(value.drawingml_transforms());
242    Some(ColorValue {
243        rgb: placeholder.rgb.clone(),
244        theme_color: None,
245        auto: None,
246        alpha: value.alpha.or(placeholder.alpha),
247        transforms,
248        ..value.clone()
249    })
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::{GradientFill, GradientStop};
256
257    fn rgb(value: &str) -> ColorValue {
258        ColorValue {
259            rgb: Some(value.to_owned()),
260            ..Default::default()
261        }
262    }
263
264    #[test]
265    fn gradient_outline_substitutes_placeholder_colour_and_preserves_modifiers() {
266        let scheme = ThemeFormatScheme {
267            lines: vec![Some(ShapeOutline {
268                gradient: Some(GradientFill {
269                    gradient_type: "linear".into(),
270                    angle: Some(45.0),
271                    stops: vec![GradientStop {
272                        position: 0.0,
273                        color: ColorValue {
274                            theme_color: Some("phClr".into()),
275                            luminance_modulation: Some(0.5),
276                            ..Default::default()
277                        },
278                    }],
279                }),
280                ..Default::default()
281            })],
282            ..Default::default()
283        };
284        let reference = StyleReference {
285            index: 1,
286            color: Some(ColorValue {
287                alpha: Some(0.25),
288                ..rgb("FF0000")
289            }),
290        };
291        let resolved = style_outline(&scheme, &reference, &Theme::default()).unwrap();
292        let gradient = resolved.gradient.unwrap();
293        assert_eq!(gradient.angle, Some(45.0));
294        assert_eq!(gradient.stops[0].color.rgb.as_deref(), Some("FF0000"));
295        assert_eq!(gradient.stops[0].color.theme_color, None);
296        assert_eq!(gradient.stops[0].color.luminance_modulation, Some(0.5));
297        assert_eq!(gradient.stops[0].color.alpha, Some(0.25));
298    }
299
300    fn solid(color: ColorValue) -> ShapeFill {
301        ShapeFill {
302            fill_type: "solid".to_owned(),
303            color: Some(color),
304            gradient: None,
305        }
306    }
307
308    fn reference(index: u32) -> StyleReference {
309        StyleReference {
310            index,
311            color: Some(rgb("808080")),
312        }
313    }
314
315    #[test]
316    fn fill_indices_keep_holes_and_select_background_styles() {
317        let scheme = ThemeFormatScheme {
318            fills: vec![Some(solid(rgb("112233"))), None, Some(solid(rgb("445566")))],
319            background_fills: vec![Some(solid(rgb("778899"))), Some(solid(rgb("AABBCC")))],
320            ..Default::default()
321        };
322        let theme = Theme::default();
323        for (index, expected) in [
324            (1, "112233"),
325            (2, "808080"),
326            (3, "445566"),
327            (999, "808080"),
328            (1001, "778899"),
329            (1002, "AABBCC"),
330            (1003, "808080"),
331        ] {
332            assert_eq!(
333                style_fill(&scheme, &reference(index), &theme)
334                    .color
335                    .unwrap()
336                    .rgb
337                    .as_deref(),
338                Some(expected),
339                "index {index}"
340            );
341        }
342        for index in [0, 1000] {
343            assert_eq!(
344                style_fill(&scheme, &reference(index), &theme),
345                ShapeFill::named("none")
346            );
347        }
348    }
349
350    #[test]
351    fn ordered_placeholder_transforms_compose_without_intermediate_rounding() {
352        use crate::{ColorTransform, resolve_color_value_to_rgba_hex};
353
354        let color = ColorValue {
355            theme_color: Some("phClr".to_owned()),
356            transforms: vec![ColorTransform::LumMod(0.6), ColorTransform::AlphaMod(0.5)],
357            ..Default::default()
358        };
359        let scheme = ThemeFormatScheme {
360            fills: vec![
361                Some(solid(color.clone())),
362                Some(ShapeFill {
363                    fill_type: "gradient".to_owned(),
364                    color: None,
365                    gradient: Some(GradientFill {
366                        gradient_type: "linear".to_owned(),
367                        angle: None,
368                        stops: vec![GradientStop {
369                            position: 0.0,
370                            color: color.clone(),
371                        }],
372                    }),
373                }),
374            ],
375            lines: vec![Some(ShapeOutline {
376                color: Some(color),
377                ..Default::default()
378            })],
379            ..Default::default()
380        };
381        let mut reference = StyleReference {
382            index: 1,
383            color: Some(ColorValue {
384                theme_color: Some("accent1".to_owned()),
385                transforms: vec![ColorTransform::Tint(0.5), ColorTransform::Alpha(0.5)],
386                ..Default::default()
387            }),
388        };
389        let theme = Theme::default();
390        let solid = style_fill(&scheme, &reference, &theme).color.unwrap();
391        let line = style_outline(&scheme, &reference, &theme)
392            .unwrap()
393            .color
394            .unwrap();
395        reference.index = 2;
396        let gradient = style_fill(&scheme, &reference, &theme).gradient.unwrap();
397        for color in [&solid, &line, &gradient.stops[0].color] {
398            assert_eq!(
399                color.transforms,
400                vec![
401                    ColorTransform::Tint(0.5),
402                    ColorTransform::Alpha(0.5),
403                    ColorTransform::LumMod(0.6),
404                    ColorTransform::AlphaMod(0.5),
405                ]
406            );
407            assert_eq!(
408                resolve_color_value_to_rgba_hex(Some(color), Some(&theme)).as_deref(),
409                Some("#4C64B040")
410            );
411        }
412        reference.index = 1;
413        let color = reference.color.as_mut().unwrap();
414        color.transforms = vec![ColorTransform::Tint(0.5)];
415        color.alpha = Some(0.5);
416        let mixed = style_fill(&scheme, &reference, &theme).color.unwrap();
417        assert_eq!(mixed.transforms, solid.transforms);
418        assert_eq!(
419            resolve_color_value_to_rgba_hex(Some(&mixed), Some(&theme)).as_deref(),
420            Some("#4C64B040")
421        );
422    }
423
424    #[test]
425    fn placeholder_modifiers_compose_in_solids_lines_and_every_gradient_stop() {
426        let first = ColorValue {
427            theme_color: Some("phClr".to_owned()),
428            luminance_modulation: Some(0.5),
429            luminance_offset: Some(0.1),
430            ..Default::default()
431        };
432        let last = ColorValue {
433            luminance_modulation: Some(0.25),
434            luminance_offset: Some(0.2),
435            ..first.clone()
436        };
437        let scheme = ThemeFormatScheme {
438            fills: vec![
439                Some(solid(first.clone())),
440                Some(ShapeFill {
441                    fill_type: "gradient".to_owned(),
442                    color: None,
443                    gradient: Some(GradientFill {
444                        gradient_type: "linear".to_owned(),
445                        angle: Some(90.0),
446                        stops: vec![
447                            GradientStop {
448                                position: 0.0,
449                                color: first.clone(),
450                            },
451                            GradientStop {
452                                position: 50000.0,
453                                color: rgb("123456"),
454                            },
455                            GradientStop {
456                                position: 100000.0,
457                                color: last,
458                            },
459                        ],
460                    }),
461                }),
462            ],
463            lines: vec![Some(ShapeOutline {
464                color: Some(first),
465                ..Default::default()
466            })],
467            ..Default::default()
468        };
469        let mut theme = Theme::default();
470        theme.color_scheme.accent1 = "808080".to_owned();
471        let reference = |index| StyleReference {
472            index,
473            color: Some(ColorValue {
474                theme_color: Some("accent1".to_owned()),
475                luminance_modulation: Some(0.5),
476                luminance_offset: Some(0.1),
477                ..Default::default()
478            }),
479        };
480        let hex = |color: &ColorValue| {
481            resolve_color_value_to_hex_with_theme(Some(color), Some(&theme)).unwrap()
482        };
483        assert_eq!(
484            hex(style_fill(&scheme, &reference(1), &theme)
485                .color
486                .as_ref()
487                .unwrap()),
488            "#474747"
489        );
490        assert_eq!(
491            hex(style_outline(&scheme, &reference(1), &theme)
492                .unwrap()
493                .color
494                .as_ref()
495                .unwrap()),
496            "#474747"
497        );
498        let gradient = style_fill(&scheme, &reference(2), &theme).gradient.unwrap();
499        assert_eq!(gradient.angle, Some(90.0));
500        assert_eq!(
501            gradient
502                .stops
503                .iter()
504                .map(|s| (s.position, hex(&s.color)))
505                .collect::<Vec<_>>(),
506            vec![
507                (0.0, "#474747".to_owned()),
508                (50000.0, "#123456".to_owned()),
509                (100000.0, "#4A4A4A".to_owned())
510            ]
511        );
512    }
513
514    #[test]
515    fn line_indices_preserve_no_fill_slots_and_explicit_style_properties() {
516        let line = ShapeOutline {
517            width: Some(28575.0),
518            color: Some(rgb("123456")),
519            style: Some("dash".to_owned()),
520            cap: Some("rnd".to_owned()),
521            ..Default::default()
522        };
523        let scheme = ThemeFormatScheme {
524            lines: vec![Some(ShapeOutline::default()), None, Some(line.clone())],
525            ..Default::default()
526        };
527        let theme = Theme::default();
528        assert_eq!(style_outline(&scheme, &reference(0), &theme), None);
529        assert!(
530            style_outline(&scheme, &reference(1), &theme)
531                .unwrap()
532                .color
533                .is_none()
534        );
535        assert_eq!(style_outline(&scheme, &reference(3), &theme), Some(line));
536        for index in [2, 9] {
537            let outline = style_outline(&scheme, &reference(index), &theme).unwrap();
538            assert_eq!(outline.width, Some(9525.0));
539            assert_eq!(outline.color.unwrap().rgb.as_deref(), Some("808080"));
540        }
541    }
542}