Skip to main content

rpptx_layout/
style.rs

1//! Shape-style inheritance resolution.
2
3use std::fmt;
4
5use oxml_drawing::color::ColorChoice;
6use oxml_drawing::effect::CT_EffectList;
7use oxml_drawing::fill::Fill;
8use oxml_drawing::line::CT_LineProperties;
9use oxml_drawing::style_ref::FontCollectionIndex;
10use rpptx_oxml::shape_tree::CT_Shape;
11
12use crate::ResolveCtx;
13
14/// The modelled shape-style properties after theme lookup and direct overlays.
15#[derive(Clone, Debug, Default, Eq, PartialEq)]
16pub struct EffectiveShapeStyle {
17    pub fill: Option<Fill>,
18    pub line: Option<CT_LineProperties>,
19    pub effects: Option<CT_EffectList>,
20    pub font_collection: Option<FontCollectionIndex>,
21}
22
23/// Errors produced while resolving a shape against its theme format scheme.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub enum ResolveError {
26    StyleIndexOutOfRange {
27        reference: &'static str,
28        index: u32,
29        available: usize,
30    },
31    UnresolvedPlaceholderColor {
32        reference: &'static str,
33    },
34    ConcreteValue {
35        kind: &'static str,
36        detail: String,
37    },
38}
39
40impl fmt::Display for ResolveError {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::StyleIndexOutOfRange {
44                reference,
45                index,
46                available,
47            } => write!(
48                formatter,
49                "{reference} style index {index} is outside the {available} available entries"
50            ),
51            Self::UnresolvedPlaceholderColor { reference } => write!(
52                formatter,
53                "{reference} style retains an unresolved phClr placeholder colour"
54            ),
55            Self::ConcreteValue { kind, detail } => {
56                write!(
57                    formatter,
58                    "cannot resolve {kind} to a concrete value: {detail}"
59                )
60            }
61        }
62    }
63}
64
65impl std::error::Error for ResolveError {}
66
67impl ResolveCtx<'_> {
68    /// Resolves one ordinary shape's modelled theme and direct style properties.
69    pub fn effective_shape_style(
70        &self,
71        shape: &CT_Shape,
72    ) -> Result<EffectiveShapeStyle, ResolveError> {
73        let matrix = &self.theme.theme_elements.format_scheme;
74        let referenced_effect_style = match shape.style() {
75            Some(style) => matrix_entry(
76                "effect",
77                style.effect_reference.index,
78                &matrix.effect_styles,
79            )?,
80            None => None,
81        };
82        let mut effective = if let Some(style) = shape.style() {
83            EffectiveShapeStyle {
84                fill: referenced_fill(
85                    style.fill_reference.index,
86                    &matrix.fill_styles,
87                    &matrix.background_fill_styles,
88                )?,
89                line: matrix_entry("line", style.line_reference.index, &matrix.line_styles)?,
90                effects: referenced_effect_style
91                    .as_ref()
92                    .filter(|style| !style.has_unmodelled_effect())
93                    .and_then(|style| style.effect_list.clone()),
94                font_collection: Some(style.font_reference.index),
95            }
96        } else {
97            EffectiveShapeStyle::default()
98        };
99
100        if let Some(explicit) = &shape.shape_properties.fill {
101            effective.fill = Some(explicit.clone());
102        }
103        if let Some(explicit) = &shape.shape_properties.line {
104            if let Some(line) = effective.line.as_mut() {
105                overlay_line(line, explicit);
106            } else {
107                effective.line = Some(explicit.clone());
108            }
109        }
110        let has_explicit_unmodelled_effect = shape.shape_properties.has_unmodelled_effect();
111        if has_explicit_unmodelled_effect {
112            effective.effects = None;
113        } else if let Some(explicit) = &shape.shape_properties.effects {
114            effective.effects = Some(explicit.clone());
115        }
116
117        let fill_reference = shape
118            .style()
119            .and_then(|style| style.fill_reference.color.as_ref());
120        let line_reference = shape
121            .style()
122            .and_then(|style| style.line_reference.color.as_ref());
123        let effect_reference = shape
124            .style()
125            .and_then(|style| style.effect_reference.color.as_ref());
126        let unmodelled_effect_has_placeholder = if has_explicit_unmodelled_effect {
127            shape
128                .shape_properties
129                .has_unmodelled_effect_placeholder_color()
130        } else {
131            referenced_effect_style
132                .as_ref()
133                .is_some_and(|style| style.has_unmodelled_effect_placeholder_color())
134        };
135        if unmodelled_effect_has_placeholder {
136            return Err(ResolveError::UnresolvedPlaceholderColor {
137                reference: "effect",
138            });
139        }
140        if let Some(fill) = effective.fill.as_mut() {
141            substitute_fill(fill, fill_reference, "fill")?;
142        }
143        if let Some(line) = effective.line.as_mut()
144            && let Some(fill) = line.fill.as_mut()
145        {
146            substitute_fill(fill, line_reference, "line")?;
147        }
148        if let Some(effects) = effective.effects.as_mut() {
149            if let Some(shadow) = effects.outer_shadow.as_mut()
150                && let Some(color) = shadow.color.as_mut()
151            {
152                substitute_color(color, effect_reference, "effect")?;
153            }
154            if effects.has_unmodelled_placeholder_color() {
155                return Err(ResolveError::UnresolvedPlaceholderColor {
156                    reference: "effect",
157                });
158            }
159        }
160        Ok(effective)
161    }
162}
163
164fn matrix_entry<T: Clone>(
165    reference: &'static str,
166    index: u32,
167    entries: &[T],
168) -> Result<Option<T>, ResolveError> {
169    if index == 0 {
170        return Ok(None);
171    }
172    let offset = usize::try_from(index - 1).ok();
173    offset
174        .and_then(|offset| entries.get(offset))
175        .cloned()
176        .map(Some)
177        .ok_or(ResolveError::StyleIndexOutOfRange {
178            reference,
179            index,
180            available: entries.len(),
181        })
182}
183
184pub(crate) fn referenced_fill(
185    index: u32,
186    normal: &[Fill],
187    background: &[Fill],
188) -> Result<Option<Fill>, ResolveError> {
189    if index > 1000 {
190        matrix_entry("background fill", index - 1000, background)
191    } else {
192        matrix_entry("fill", index, normal)
193    }
194}
195
196fn overlay_line(target: &mut CT_LineProperties, source: &CT_LineProperties) {
197    if source.width.is_some() {
198        target.width = source.width;
199    }
200    if source.cap.is_some() {
201        target.cap = source.cap;
202    }
203    if source.fill.is_some() {
204        target.fill.clone_from(&source.fill);
205    }
206    if source.dash.is_some() {
207        target.dash.clone_from(&source.dash);
208    }
209    if source.join.is_some() {
210        target.join.clone_from(&source.join);
211    }
212    if source.head_end.is_some() {
213        target.head_end.clone_from(&source.head_end);
214    }
215    if source.tail_end.is_some() {
216        target.tail_end.clone_from(&source.tail_end);
217    }
218}
219
220pub(crate) fn substitute_fill(
221    fill: &mut Fill,
222    reference: Option<&ColorChoice>,
223    kind: &'static str,
224) -> Result<(), ResolveError> {
225    match fill {
226        Fill::Solid(fill) => substitute_optional_color(&mut fill.color, reference, kind),
227        Fill::Gradient(fill) => {
228            for stop in &mut fill.stops {
229                substitute_optional_color(&mut stop.color, reference, kind)?;
230            }
231            Ok(())
232        }
233        Fill::Pattern(fill) => {
234            substitute_optional_color(&mut fill.foreground, reference, kind)?;
235            substitute_optional_color(&mut fill.background, reference, kind)
236        }
237        Fill::NoFill(_) | Fill::Blip(_) => Ok(()),
238    }
239}
240
241fn substitute_optional_color(
242    color: &mut Option<ColorChoice>,
243    reference: Option<&ColorChoice>,
244    kind: &'static str,
245) -> Result<(), ResolveError> {
246    if let Some(color) = color {
247        substitute_color(color, reference, kind)?;
248    }
249    Ok(())
250}
251
252fn substitute_color(
253    color: &mut ColorChoice,
254    reference: Option<&ColorChoice>,
255    kind: &'static str,
256) -> Result<(), ResolveError> {
257    if !matches!(color, ColorChoice::Scheme { value, .. } if value == "phClr") {
258        return Ok(());
259    }
260    let placeholder_transforms = color.transforms().to_vec();
261    let Some(mut replacement) = reference.cloned() else {
262        return Err(ResolveError::UnresolvedPlaceholderColor { reference: kind });
263    };
264    if matches!(&replacement, ColorChoice::Scheme { value, .. } if value == "phClr") {
265        return Err(ResolveError::UnresolvedPlaceholderColor { reference: kind });
266    }
267    transforms_mut(&mut replacement).extend(placeholder_transforms);
268    *color = replacement;
269    Ok(())
270}
271
272fn transforms_mut(color: &mut ColorChoice) -> &mut Vec<oxml_drawing::color::ColorTransform> {
273    match color {
274        ColorChoice::Srgb { transforms, .. }
275        | ColorChoice::Scheme { transforms, .. }
276        | ColorChoice::System { transforms, .. }
277        | ColorChoice::Preset { transforms, .. } => transforms,
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use oxml_drawing::color::{ColorChoice, ColorMap, ColorTransform};
284    use oxml_drawing::fill::Fill;
285    use oxml_drawing::line::{LineCap, LineDash, ST_PresetLineDashVal};
286    use oxml_drawing::style_ref::FontCollectionIndex;
287    use oxml_drawing::text::CT_TextListStyle;
288    use oxml_drawing::theme::CT_OfficeStyleSheet;
289    use rpptx_oxml::shape_tree::{CT_Shape, ShapeTreeChild};
290    use rpptx_oxml::slide_parts::{CT_Slide, CT_SlideLayout, CT_SlideMaster};
291
292    use super::{ResolveCtx, ResolveError};
293
294    const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
295    const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
296
297    #[test]
298    fn shape_with_style_resolves_theme_fill_with_reference_colour_substituted() {
299        let fixture = Fixture::new(&shape("", &style_refs(0, 1, 0, "minor", "AABBCC")));
300        let resolved = fixture
301            .context()
302            .effective_shape_style(fixture.shape(0))
303            .unwrap();
304
305        let Some(Fill::Solid(fill)) = resolved.fill else {
306            panic!("expected a resolved solid fill");
307        };
308        let Some(ColorChoice::Srgb {
309            value, transforms, ..
310        }) = fill.color
311        else {
312            panic!("expected the reference sRGB colour");
313        };
314        assert_eq!(value.to_string(), "AABBCC");
315        assert!(transforms.is_empty());
316        assert_eq!(resolved.font_collection, Some(FontCollectionIndex::Minor));
317    }
318
319    #[test]
320    fn fill_ref_1001_selects_first_background_fill() {
321        let fixture = Fixture::new(&shape("", &style_refs(0, 1001, 0, "major", "102030")));
322        let resolved = fixture
323            .context()
324            .effective_shape_style(fixture.shape(0))
325            .unwrap();
326
327        let Some(Fill::Solid(fill)) = resolved.fill else {
328            panic!("expected the first background solid fill");
329        };
330        assert!(matches!(
331            fill.color,
332            Some(ColorChoice::Srgb { value, .. }) if value.to_string() == "102030"
333        ));
334    }
335
336    #[test]
337    fn style_matrix_zero_is_none_and_out_of_range_is_error() {
338        let shapes = [
339            shape("", &style_refs(0, 0, 0, "none", "112233")),
340            shape("", &style_refs(0, 4, 0, "none", "112233")),
341        ]
342        .join("");
343        let fixture = Fixture::new(&shapes);
344
345        let zero = fixture
346            .context()
347            .effective_shape_style(fixture.shape(0))
348            .unwrap();
349        assert!(zero.fill.is_none());
350        assert!(zero.line.is_none());
351        assert!(zero.effects.is_none());
352        assert_eq!(zero.font_collection, Some(FontCollectionIndex::None));
353
354        assert_eq!(
355            fixture
356                .context()
357                .effective_shape_style(fixture.shape(1))
358                .unwrap_err(),
359            ResolveError::StyleIndexOutOfRange {
360                reference: "fill",
361                index: 4,
362                available: 3,
363            }
364        );
365    }
366
367    #[test]
368    fn placeholder_colour_substitution_keeps_transform_order() {
369        let mut fixture = Fixture::new(&shape(
370            "",
371            &style_refs_with_fill_colour(
372                1,
373                r#"<a:schemeClr val="accent2"><a:shade val="80000"/></a:schemeClr>"#,
374            ),
375        ));
376        fixture.theme.theme_elements.format_scheme.fill_styles[0] = Fill::from_xml(
377            br#"<a:solidFill><a:schemeClr val="phClr"><a:tint val="30000"/></a:schemeClr></a:solidFill>"#,
378        )
379        .unwrap();
380
381        let resolved = fixture
382            .context()
383            .effective_shape_style(fixture.shape(0))
384            .unwrap();
385        let Some(Fill::Solid(fill)) = resolved.fill else {
386            panic!("expected a solid fill");
387        };
388        let Some(ColorChoice::Scheme {
389            value, transforms, ..
390        }) = fill.color
391        else {
392            panic!("expected a substituted scheme colour");
393        };
394        assert_eq!(value, "accent2");
395        assert!(matches!(
396            transforms.as_slice(),
397            [ColorTransform::Shade(shade), ColorTransform::Tint(tint)]
398                if shade.0 == 80_000 && tint.0 == 30_000
399        ));
400    }
401
402    #[test]
403    fn explicit_shape_properties_overlay_the_theme_style() {
404        let fixture = Fixture::new(&shape(
405            r#"<a:noFill/><a:ln w="25400"><a:prstDash val="dot"/></a:ln><a:effectLst/>"#,
406            &style_refs(2, 1, 3, "minor", "445566"),
407        ));
408        let resolved = fixture
409            .context()
410            .effective_shape_style(fixture.shape(0))
411            .unwrap();
412
413        assert!(matches!(resolved.fill, Some(Fill::NoFill(_))));
414        let line = resolved.line.unwrap();
415        assert_eq!(line.width, Some(25_400));
416        assert_eq!(line.cap, Some(LineCap::Flat));
417        assert!(matches!(
418            line.dash,
419            Some(LineDash::Preset(ref dash)) if dash.value == ST_PresetLineDashVal::Dot
420        ));
421        assert!(matches!(
422            line.fill,
423            Some(Fill::Solid(fill))
424                if matches!(fill.color, Some(ColorChoice::Srgb { value, .. }) if value.to_string() == "445566")
425        ));
426        assert!(resolved.effects.unwrap().outer_shadow.is_none());
427        assert_eq!(resolved.font_collection, Some(FontCollectionIndex::Minor));
428    }
429
430    #[test]
431    fn modelled_effect_placeholder_colour_is_substituted() {
432        let mut fixture = Fixture::new(&shape("", &style_refs(0, 0, 3, "none", "667788")));
433        let Fill::Solid(fill) = Fill::from_xml(
434            br#"<a:solidFill><a:schemeClr val="phClr"><a:tint val="25000"/></a:schemeClr></a:solidFill>"#,
435        )
436        .unwrap()
437        else {
438            unreachable!();
439        };
440        fixture.theme.theme_elements.format_scheme.effect_styles[2]
441            .effect_list
442            .as_mut()
443            .unwrap()
444            .outer_shadow
445            .as_mut()
446            .unwrap()
447            .color = fill.color;
448
449        let resolved = fixture
450            .context()
451            .effective_shape_style(fixture.shape(0))
452            .unwrap();
453        let color = resolved
454            .effects
455            .unwrap()
456            .outer_shadow
457            .unwrap()
458            .color
459            .unwrap();
460        assert!(matches!(
461            color,
462            ColorChoice::Srgb { value, transforms, .. }
463                if value.to_string() == "667788"
464                    && matches!(transforms.as_slice(), [ColorTransform::Tint(tint)] if tint.0 == 25_000)
465        ));
466    }
467
468    #[test]
469    fn unmodelled_effect_with_placeholder_colour_is_rejected() {
470        let fixture = Fixture::new(&shape(
471            r#"<a:effectLst><a:glow rad="100"><a:schemeClr val="phClr"/></a:glow></a:effectLst>"#,
472            &style_refs(0, 0, 0, "none", "778899"),
473        ));
474
475        assert_eq!(
476            fixture
477                .context()
478                .effective_shape_style(fixture.shape(0))
479                .unwrap_err(),
480            ResolveError::UnresolvedPlaceholderColor {
481                reference: "effect",
482            }
483        );
484    }
485
486    #[test]
487    fn explicit_opaque_effect_dag_replaces_theme_effect() {
488        let fixture = Fixture::new(&shape(
489            r#"<a:effectDag><a:cont/></a:effectDag>"#,
490            &style_refs(0, 0, 3, "none", "778899"),
491        ));
492
493        let resolved = fixture
494            .context()
495            .effective_shape_style(fixture.shape(0))
496            .unwrap();
497        assert!(resolved.effects.is_none());
498    }
499
500    #[test]
501    fn explicit_opaque_effect_dag_with_placeholder_colour_is_rejected() {
502        let fixture = Fixture::new(&shape(
503            r#"<a:effectDag><a:cont><a:effectLst><a:glow rad="100"><a:schemeClr val="phClr"/></a:glow></a:effectLst></a:cont></a:effectDag>"#,
504            &style_refs(0, 0, 3, "none", "778899"),
505        ));
506
507        assert_eq!(
508            fixture
509                .context()
510                .effective_shape_style(fixture.shape(0))
511                .unwrap_err(),
512            ResolveError::UnresolvedPlaceholderColor {
513                reference: "effect",
514            }
515        );
516    }
517
518    #[test]
519    fn theme_opaque_effect_dag_with_placeholder_colour_is_rejected() {
520        let mut fixture = Fixture::new(&shape("", &style_refs(0, 0, 1, "none", "778899")));
521        let xml = String::from_utf8(fixture.theme.to_xml().unwrap()).unwrap();
522        let xml = xml.replacen(
523            "<a:effectLst/>",
524            r#"<a:effectDag><a:cont><a:effectLst><a:glow rad="100"><a:schemeClr val="phClr"/></a:glow></a:effectLst></a:cont></a:effectDag>"#,
525            1,
526        );
527        fixture.theme = CT_OfficeStyleSheet::from_xml(xml.as_bytes()).unwrap();
528
529        assert_eq!(
530            fixture
531                .context()
532                .effective_shape_style(fixture.shape(0))
533                .unwrap_err(),
534            ResolveError::UnresolvedPlaceholderColor {
535                reference: "effect",
536            }
537        );
538    }
539
540    #[test]
541    fn foreign_namespace_effect_dag_does_not_replace_theme_effect() {
542        let fixture = Fixture::new(&shape(
543            r#"<x:effectDag xmlns:x="urn:extension"><x:schemeClr val="phClr"/></x:effectDag>"#,
544            &style_refs(0, 0, 3, "none", "778899"),
545        ));
546
547        let resolved = fixture
548            .context()
549            .effective_shape_style(fixture.shape(0))
550            .unwrap();
551        assert!(resolved.effects.is_some());
552    }
553
554    #[test]
555    fn foreign_namespace_scheme_color_is_not_a_placeholder_color() {
556        let fixture = Fixture::new(&shape(
557            r#"<a:effectDag><a:cont><x:schemeClr xmlns:x="urn:extension" val="phClr"/></a:cont></a:effectDag>"#,
558            &style_refs(0, 0, 3, "none", "778899"),
559        ));
560
561        let resolved = fixture
562            .context()
563            .effective_shape_style(fixture.shape(0))
564            .unwrap();
565        assert!(resolved.effects.is_none());
566    }
567
568    struct Fixture {
569        theme: CT_OfficeStyleSheet,
570        master: CT_SlideMaster,
571        layout: CT_SlideLayout,
572        slide: CT_Slide,
573        default_text_style: CT_TextListStyle,
574    }
575
576    impl Fixture {
577        fn new(shapes: &str) -> Self {
578            Self {
579                theme: CT_OfficeStyleSheet::office_default(),
580                master: CT_SlideMaster::from_xml(master_xml().as_bytes()).unwrap(),
581                layout: CT_SlideLayout::from_xml(layout_xml().as_bytes()).unwrap(),
582                slide: CT_Slide::from_xml(slide_xml(shapes).as_bytes()).unwrap(),
583                default_text_style: CT_TextListStyle::default(),
584            }
585        }
586
587        fn context(&self) -> ResolveCtx<'_> {
588            ResolveCtx::new(
589                &self.theme,
590                ColorMap::default(),
591                &self.master,
592                &self.layout,
593                &self.slide,
594                &self.default_text_style,
595            )
596        }
597
598        fn shape(&self, index: usize) -> &CT_Shape {
599            let ShapeTreeChild::Shape(shape) =
600                &self.slide.common_slide_data.shape_tree.children[index]
601            else {
602                panic!("expected an ordinary shape");
603            };
604            shape
605        }
606    }
607
608    fn style_refs(line: u32, fill: u32, effect: u32, font: &str, color: &str) -> String {
609        format!(
610            r#"<p:style><a:lnRef idx="{line}"><a:srgbClr val="{color}"/></a:lnRef><a:fillRef idx="{fill}"><a:srgbClr val="{color}"/></a:fillRef><a:effectRef idx="{effect}"><a:srgbClr val="{color}"/></a:effectRef><a:fontRef idx="{font}"><a:srgbClr val="{color}"/></a:fontRef></p:style>"#
611        )
612    }
613
614    fn style_refs_with_fill_colour(fill: u32, fill_colour: &str) -> String {
615        format!(
616            r#"<p:style><a:lnRef idx="0"><a:srgbClr val="000000"/></a:lnRef><a:fillRef idx="{fill}">{fill_colour}</a:fillRef><a:effectRef idx="0"><a:srgbClr val="000000"/></a:effectRef><a:fontRef idx="minor"><a:srgbClr val="000000"/></a:fontRef></p:style>"#
617        )
618    }
619
620    fn shape(properties: &str, style: &str) -> String {
621        format!(
622            "<p:sp><p:nvSpPr><p:cNvPr/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>{properties}</p:spPr>{style}</p:sp>"
623        )
624    }
625
626    fn slide_xml(shapes: &str) -> String {
627        format!(
628            "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld></p:sld>",
629            shape_tree(shapes)
630        )
631    }
632
633    fn layout_xml() -> String {
634        format!(
635            "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld></p:sldLayout>",
636            shape_tree("")
637        )
638    }
639
640    fn master_xml() -> String {
641        format!(
642            "<p:sldMaster xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld><p:clrMap bg1=\"lt1\" tx1=\"dk1\" bg2=\"lt2\" tx2=\"dk2\" accent1=\"accent1\" accent2=\"accent2\" accent3=\"accent3\" accent4=\"accent4\" accent5=\"accent5\" accent6=\"accent6\" hlink=\"hlink\" folHlink=\"folHlink\"/></p:sldMaster>",
643            shape_tree("")
644        )
645    }
646
647    fn shape_tree(shapes: &str) -> String {
648        format!("<p:spTree><p:nvGrpSpPr/><p:grpSpPr/>{shapes}</p:spTree>")
649    }
650}