1use 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#[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#[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 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}