1use rpptx_oxml::placeholder::PhType;
4use rpptx_oxml::shape_tree::CT_Shape;
5
6use oxml_drawing::text::{
7 CT_TextCharacterProperties, CT_TextListStyle, CT_TextParagraphProperties,
8};
9
10use crate::ResolveCtx;
11
12#[derive(Clone, Debug, Default, Eq, PartialEq)]
14pub struct EffectiveTextProperties {
15 pub paragraph: CT_TextParagraphProperties,
16 pub run: CT_TextCharacterProperties,
17}
18
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
21pub struct EffectiveListStyle {
22 levels: [EffectiveTextProperties; 9],
23}
24
25impl EffectiveListStyle {
26 pub fn level(&self, level: u8) -> Option<&EffectiveTextProperties> {
28 self.levels.get(usize::from(level))
29 }
30}
31
32impl ResolveCtx<'_> {
33 pub fn effective_list_style(&self, shape: &CT_Shape) -> EffectiveListStyle {
35 let key = shape
36 .placeholder
37 .as_ref()
38 .map(|placeholder| placeholder.key());
39 let mut effective = if let Some(cached) = self.list_style_cache.borrow().get(&key) {
40 cached.clone()
41 } else {
42 let resolved = self.resolve_list_style_prefix(shape);
43 self.list_style_cache
44 .borrow_mut()
45 .insert(key, resolved.clone());
46 resolved
47 };
48 if let Some(list_style) = shape.text_body.as_ref().and_then(|body| body.list_style()) {
49 merge_list_style(&mut effective, list_style);
50 }
51 effective
52 }
53
54 pub fn effective_text_properties(
56 &self,
57 shape: &CT_Shape,
58 paragraph: Option<&CT_TextParagraphProperties>,
59 run: Option<&CT_TextCharacterProperties>,
60 ) -> EffectiveTextProperties {
61 let style = self.effective_list_style(shape);
62 let level = paragraph
63 .and_then(|properties| properties.level)
64 .unwrap_or(0);
65 let mut effective = style
66 .level(level)
67 .cloned()
68 .unwrap_or_else(EffectiveTextProperties::default);
69 if let Some(properties) = paragraph {
70 merge_paragraph(&mut effective, properties);
71 }
72 if let Some(properties) = run {
73 merge_character(&mut effective, properties);
74 }
75 effective
76 }
77
78 pub(crate) fn effective_table_text_properties(
80 &self,
81 table_style: Option<&CT_TextCharacterProperties>,
82 paragraph: Option<&CT_TextParagraphProperties>,
83 run: Option<&CT_TextCharacterProperties>,
84 ) -> EffectiveTextProperties {
85 let mut style = EffectiveListStyle::default();
86 merge_list_style(&mut style, self.default_text_style);
87 if let Some(styles) = &self.master.text_styles {
88 merge_list_style(&mut style, &styles.other_style);
89 }
90 let level = paragraph
91 .and_then(|properties| properties.level)
92 .unwrap_or(0);
93 let mut effective = style
94 .level(level)
95 .cloned()
96 .unwrap_or_else(EffectiveTextProperties::default);
97 if let Some(properties) = table_style {
98 merge_character(&mut effective, properties);
99 }
100 if let Some(properties) = paragraph {
101 merge_paragraph(&mut effective, properties);
102 }
103 if let Some(properties) = run {
104 merge_character(&mut effective, properties);
105 }
106 effective
107 }
108
109 fn resolve_list_style_prefix(&self, shape: &CT_Shape) -> EffectiveListStyle {
110 let mut effective = EffectiveListStyle::default();
111 merge_list_style(&mut effective, self.default_text_style);
112
113 if let Some(styles) = &self.master.text_styles {
114 let master_style = match shape
115 .placeholder
116 .as_ref()
117 .map(|placeholder| placeholder.effective_type())
118 {
119 Some(PhType::Title | PhType::CenteredTitle) => &styles.title_style,
120 Some(PhType::Body | PhType::Subtitle | PhType::Object) => &styles.body_style,
121 _ => &styles.other_style,
122 };
123 merge_list_style(&mut effective, master_style);
124 }
125
126 let (layout_placeholder, master_placeholder) = self.placeholder_chain(shape);
127 if let Some(list_style) = master_placeholder
128 .and_then(|placeholder| placeholder.text_body.as_ref())
129 .and_then(|body| body.list_style())
130 {
131 merge_list_style(&mut effective, list_style);
132 }
133 if let Some(list_style) = layout_placeholder
134 .and_then(|placeholder| placeholder.text_body.as_ref())
135 .and_then(|body| body.list_style())
136 {
137 merge_list_style(&mut effective, list_style);
138 }
139 effective
140 }
141}
142
143fn merge_list_style(effective: &mut EffectiveListStyle, source: &CT_TextListStyle) {
144 for level in 0..9 {
145 if let Some(properties) = &source.default_paragraph_properties {
146 merge_paragraph(&mut effective.levels[level], properties);
147 }
148 if let Some(properties) = source.level(level + 1) {
149 merge_paragraph(&mut effective.levels[level], properties);
150 }
151 }
152}
153
154fn merge_paragraph(effective: &mut EffectiveTextProperties, source: &CT_TextParagraphProperties) {
155 if source.left_margin.is_some() {
156 effective.paragraph.left_margin = source.left_margin;
157 }
158 if source.right_margin.is_some() {
159 effective.paragraph.right_margin = source.right_margin;
160 }
161 if source.level.is_some() {
162 effective.paragraph.level = source.level;
163 }
164 if source.indent.is_some() {
165 effective.paragraph.indent = source.indent;
166 }
167 if source.alignment.is_some() {
168 effective.paragraph.alignment = source.alignment;
169 }
170 if source.right_to_left.is_some() {
171 effective.paragraph.right_to_left = source.right_to_left;
172 }
173 if source.line_spacing.is_some() {
174 effective.paragraph.line_spacing = source.line_spacing.clone();
175 }
176 if source.space_before.is_some() {
177 effective.paragraph.space_before = source.space_before.clone();
178 }
179 if source.space_after.is_some() {
180 effective.paragraph.space_after = source.space_after.clone();
181 }
182 if let Some(source_bullet) = &source.bullet {
183 let bullet = effective.paragraph.bullet.get_or_insert_default();
184 if source_bullet.color.is_some() {
185 bullet.color = source_bullet.color.clone();
186 }
187 if source_bullet.size.is_some() {
188 bullet.size = source_bullet.size.clone();
189 }
190 if source_bullet.font.is_some() {
191 bullet.font = source_bullet.font.clone();
192 }
193 if source_bullet.choice.is_some() {
194 bullet.choice = source_bullet.choice.clone();
195 }
196 }
197 if let Some(properties) = &source.default_run_properties {
198 merge_character_properties(&mut effective.run, properties);
199 }
200}
201
202fn merge_character(effective: &mut EffectiveTextProperties, source: &CT_TextCharacterProperties) {
203 merge_character_properties(&mut effective.run, source);
204}
205
206fn merge_character_properties(
207 effective: &mut CT_TextCharacterProperties,
208 source: &CT_TextCharacterProperties,
209) {
210 if source.font_size.is_some() {
211 effective.font_size = source.font_size;
212 }
213 if source.bold.is_some() {
214 effective.bold = source.bold;
215 }
216 if source.italic.is_some() {
217 effective.italic = source.italic;
218 }
219 if source.all_caps.is_some() {
220 effective.all_caps = source.all_caps;
221 }
222 if source.underline.is_some() {
223 effective.underline = source.underline;
224 }
225 if source.strike.is_some() {
226 effective.strike = source.strike;
227 }
228 if source.spacing.is_some() {
229 effective.spacing = source.spacing.clone();
230 }
231 if source.baseline.is_some() {
232 effective.baseline = source.baseline.clone();
233 }
234 if source.fill.is_some() {
235 effective.fill = source.fill.clone();
236 }
237 if source.latin.is_some() {
238 effective.latin = source.latin.clone();
239 }
240 if source.east_asian.is_some() {
241 effective.east_asian = source.east_asian.clone();
242 }
243 if source.complex_script.is_some() {
244 effective.complex_script = source.complex_script.clone();
245 }
246 if source.symbol.is_some() {
247 effective.symbol = source.symbol.clone();
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use oxml_drawing::text::{
254 CT_TextCharacterProperties, CT_TextListStyle, CT_TextParagraphProperties, TextAlignment,
255 TextBulletChoice, TextBulletSizeValue,
256 };
257 use oxml_drawing::{color::ColorMap, theme::CT_OfficeStyleSheet};
258 use rpptx_oxml::shape_tree::{CT_Shape, ShapeTreeChild};
259 use rpptx_oxml::slide_parts::{CT_Slide, CT_SlideLayout, CT_SlideMaster};
260
261 use super::{EffectiveListStyle, EffectiveTextProperties, merge_list_style, merge_paragraph};
262 use crate::ResolveCtx;
263
264 const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
265
266 #[test]
267 fn seven_source_list_style_merge_resolves_run_size_and_typeface() {
268 let fixture = Fixture::new(
269 "<a:defPPr marL=\"100\"><a:defRPr sz=\"1000\"/></a:defPPr>",
270 "<a:defPPr marR=\"200\"/>",
271 "<a:defPPr indent=\"300\"/>",
272 "<a:defPPr algn=\"ctr\"/>",
273 &["<a:defPPr><a:defRPr b=\"1\"/></a:defPPr>"],
274 );
275 let paragraph = paragraph("<a:pPr><a:defRPr sz=\"2400\"/></a:pPr>");
276 let run = character("<a:rPr><a:latin typeface=\"Run Face\"/></a:rPr>");
277 let resolved = fixture.context().effective_text_properties(
278 fixture.slide_shape(0),
279 Some(¶graph),
280 Some(&run),
281 );
282
283 assert_eq!(resolved.run.font_size, Some(2400));
284 assert_eq!(resolved.run.latin.unwrap().typeface, "Run Face");
285 assert_eq!(resolved.paragraph.left_margin, Some(100));
286 assert_eq!(resolved.paragraph.right_margin, Some(200));
287 assert_eq!(resolved.paragraph.indent, Some(300));
288 assert_eq!(resolved.paragraph.alignment, Some(TextAlignment::Center));
289 assert_eq!(resolved.run.bold, Some(true));
290 }
291
292 #[test]
293 fn default_paragraph_properties_apply_before_each_level() {
294 let source = list_style(
295 "<a:defPPr marL=\"100\"><a:defRPr sz=\"1200\"/></a:defPPr>\
296 <a:lvl1pPr marL=\"200\"/><a:lvl9pPr><a:defRPr b=\"1\"/></a:lvl9pPr>",
297 );
298 let mut effective = EffectiveListStyle::default();
299 merge_list_style(&mut effective, &source);
300
301 assert_eq!(effective.level(0).unwrap().paragraph.left_margin, Some(200));
302 assert_eq!(effective.level(8).unwrap().paragraph.left_margin, Some(100));
303 assert_eq!(effective.level(8).unwrap().run.font_size, Some(1200));
304 assert_eq!(effective.level(8).unwrap().run.bold, Some(true));
305 }
306
307 #[test]
308 fn all_nine_levels_merge_independently() {
309 let levels = (1..=9)
310 .map(|level| format!("<a:lvl{level}pPr marL=\"{}\"/>", level * 100))
311 .collect::<String>();
312 let mut effective = EffectiveListStyle::default();
313 merge_list_style(&mut effective, &list_style(&levels));
314
315 for level in 0..9 {
316 assert_eq!(
317 effective.level(level).unwrap().paragraph.left_margin,
318 Some(i32::from(level + 1) * 100)
319 );
320 }
321 }
322
323 #[test]
324 fn later_sources_win_per_property_without_erasing_other_fields() {
325 let mut effective = EffectiveTextProperties::default();
326 merge_paragraph(
327 &mut effective,
328 ¶graph("<a:pPr marL=\"100\" marR=\"200\"><a:defRPr sz=\"1100\" b=\"1\"/></a:pPr>"),
329 );
330 merge_paragraph(
331 &mut effective,
332 ¶graph("<a:pPr marL=\"300\"><a:defRPr sz=\"2200\"/></a:pPr>"),
333 );
334
335 assert_eq!(effective.paragraph.left_margin, Some(300));
336 assert_eq!(effective.paragraph.right_margin, Some(200));
337 assert_eq!(effective.run.font_size, Some(2200));
338 assert_eq!(effective.run.bold, Some(true));
339 }
340
341 #[test]
342 fn paragraph_direction_inherits_and_direct_ltr_overrides_it() {
343 let mut effective = EffectiveTextProperties::default();
344 merge_paragraph(&mut effective, ¶graph("<a:pPr rtl=\"1\"></a:pPr>"));
345 assert_eq!(effective.paragraph.right_to_left, Some(true));
346
347 merge_paragraph(&mut effective, ¶graph("<a:pPr rtl=\"0\"></a:pPr>"));
348 assert_eq!(effective.paragraph.right_to_left, Some(false));
349 }
350
351 #[test]
352 fn all_caps_inherits_and_a_direct_none_value_overrides_it() {
353 let fixture = Fixture::new(
354 "<a:defPPr><a:defRPr cap=\"all\"/></a:defPPr>",
355 "",
356 "",
357 "",
358 &[""],
359 );
360 let inherited =
361 fixture
362 .context()
363 .effective_text_properties(fixture.slide_shape(0), None, None);
364 assert_eq!(inherited.run.all_caps, Some(true));
365
366 let direct = character("<a:rPr cap=\"none\"></a:rPr>");
367 let overridden = fixture.context().effective_text_properties(
368 fixture.slide_shape(0),
369 None,
370 Some(&direct),
371 );
372 assert_eq!(overridden.run.all_caps, Some(false));
373 }
374
375 #[test]
376 fn fills_and_typeface_slots_merge_as_atomic_properties() {
377 let mut effective = EffectiveTextProperties::default();
378 merge_paragraph(
379 &mut effective,
380 ¶graph(
381 "<a:pPr><a:defRPr><a:solidFill><a:srgbClr val=\"112233\"/></a:solidFill><a:latin typeface=\"Latin One\"/><a:ea typeface=\"East Asian One\"/></a:defRPr></a:pPr>",
382 ),
383 );
384 merge_paragraph(
385 &mut effective,
386 ¶graph(
387 "<a:pPr><a:defRPr><a:solidFill><a:srgbClr val=\"AABBCC\"/></a:solidFill><a:latin typeface=\"Latin Two\"/></a:defRPr></a:pPr>",
388 ),
389 );
390
391 assert_eq!(effective.run.latin.unwrap().typeface, "Latin Two");
392 assert_eq!(effective.run.east_asian.unwrap().typeface, "East Asian One");
393 assert_eq!(
394 effective.run.fill,
395 character("<a:rPr><a:solidFill><a:srgbClr val=\"AABBCC\"/></a:solidFill></a:rPr>").fill
396 );
397 }
398
399 #[test]
400 fn raw_xml_and_hyperlink_actions_are_not_inherited() {
401 let source = paragraph(
402 "<a:pPr producer=\"raw\"><x:opaque xmlns:x=\"urn:x\"/><a:defRPr><a:hlinkClick action=\"ppaction://jump\"/></a:defRPr></a:pPr>",
403 );
404 let mut effective = EffectiveTextProperties::default();
405 merge_paragraph(&mut effective, &source);
406
407 assert!(effective.paragraph.raw_children().is_empty());
408 assert!(effective.run.raw_children().is_empty());
409 assert!(effective.run.hyperlink_click.is_none());
410 assert!(effective.run.hyperlink_mouse_over.is_none());
411 }
412
413 #[test]
414 fn bullet_components_merge_independently() {
415 let mut effective = EffectiveTextProperties::default();
416 merge_paragraph(
417 &mut effective,
418 ¶graph(
419 "<a:pPr><a:buClr><a:srgbClr val=\"112233\"/></a:buClr><a:buSzPts val=\"1800\"/><a:buChar char=\"•\"/></a:pPr>",
420 ),
421 );
422 merge_paragraph(
423 &mut effective,
424 ¶graph("<a:pPr><a:buFont typeface=\"Wingdings\"/><a:buNone/></a:pPr>"),
425 );
426 let bullet = effective.paragraph.bullet.unwrap();
427
428 assert!(bullet.color.is_some());
429 assert!(matches!(
430 bullet.size.unwrap().value,
431 TextBulletSizeValue::Points(1800)
432 ));
433 assert_eq!(bullet.font.unwrap().typeface, "Wingdings");
434 assert!(matches!(bullet.choice, Some(TextBulletChoice::None(_))));
435 }
436
437 #[test]
438 fn shape_owned_style_is_not_shared_by_placeholder_cache() {
439 let fixture = Fixture::new(
440 "",
441 "",
442 "<a:lvl1pPr marL=\"100\"/>",
443 "",
444 &["<a:lvl1pPr marL=\"900\"/>", "<a:lvl1pPr marL=\"800\"/>"],
445 );
446 let context = fixture.context();
447 let first_shape = context.effective_list_style(fixture.slide_shape(0));
448 let second_shape = context.effective_list_style(fixture.slide_shape(1));
449
450 assert_eq!(
451 first_shape.level(0).unwrap().paragraph.left_margin,
452 Some(900)
453 );
454 assert_eq!(
455 second_shape.level(0).unwrap().paragraph.left_margin,
456 Some(800)
457 );
458 }
459
460 #[test]
461 fn default_paragraph_properties_round_trip_in_schema_order() {
462 let xml = format!(
463 "<a:lstStyle xmlns:a=\"{A_NS}\"><x:before xmlns:x=\"urn:x\"/><defPPr marL=\"100\"/><x:between xmlns:x=\"urn:x\"/><a:lvl1pPr/><x:after xmlns:x=\"urn:x\"/></a:lstStyle>"
464 );
465 let style = CT_TextListStyle::from_xml(xml.as_bytes()).unwrap();
466 assert_eq!(
467 style
468 .default_paragraph_properties
469 .as_ref()
470 .unwrap()
471 .left_margin,
472 Some(100)
473 );
474 let output = String::from_utf8(style.to_xml().unwrap()).unwrap();
475
476 assert_eq!(
477 output,
478 "<a:lstStyle xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><x:before xmlns:x=\"urn:x\"/><a:defPPr marL=\"100\"/><x:between xmlns:x=\"urn:x\"/><a:lvl1pPr/><x:after xmlns:x=\"urn:x\"/></a:lstStyle>"
479 );
480 }
481
482 fn list_style(children: &str) -> CT_TextListStyle {
483 CT_TextListStyle::from_xml(
484 format!("<a:lstStyle xmlns:a=\"{A_NS}\">{children}</a:lstStyle>").as_bytes(),
485 )
486 .unwrap()
487 }
488
489 fn paragraph(xml: &str) -> CT_TextParagraphProperties {
490 CT_TextParagraphProperties::from_xml(with_namespace(xml).as_bytes()).unwrap()
491 }
492
493 fn character(xml: &str) -> CT_TextCharacterProperties {
494 CT_TextCharacterProperties::from_xml(with_namespace(xml).as_bytes()).unwrap()
495 }
496
497 fn with_namespace(xml: &str) -> String {
498 xml.replacen('>', &format!(" xmlns:a=\"{A_NS}\">"), 1)
499 }
500
501 struct Fixture {
502 theme: CT_OfficeStyleSheet,
503 master: CT_SlideMaster,
504 layout: CT_SlideLayout,
505 slide: CT_Slide,
506 default_text_style: CT_TextListStyle,
507 }
508
509 impl Fixture {
510 fn new(
511 default_style: &str,
512 master_style: &str,
513 master_placeholder: &str,
514 layout_placeholder: &str,
515 slide_styles: &[&str],
516 ) -> Self {
517 let slide_shapes = slide_styles
518 .iter()
519 .map(|style| shape(7, style))
520 .collect::<String>();
521 Self {
522 theme: CT_OfficeStyleSheet::office_default(),
523 master: CT_SlideMaster::from_xml(
524 master_xml(&shape(7, master_placeholder), master_style).as_bytes(),
525 )
526 .unwrap(),
527 layout: CT_SlideLayout::from_xml(
528 layout_xml(&shape(7, layout_placeholder)).as_bytes(),
529 )
530 .unwrap(),
531 slide: CT_Slide::from_xml(slide_xml(&slide_shapes).as_bytes()).unwrap(),
532 default_text_style: list_style(default_style),
533 }
534 }
535
536 fn context(&self) -> ResolveCtx<'_> {
537 ResolveCtx::new(
538 &self.theme,
539 ColorMap::default(),
540 &self.master,
541 &self.layout,
542 &self.slide,
543 &self.default_text_style,
544 )
545 }
546
547 fn slide_shape(&self, index: usize) -> &CT_Shape {
548 let ShapeTreeChild::Shape(shape) =
549 &self.slide.common_slide_data.shape_tree.children[index]
550 else {
551 panic!("expected slide shape");
552 };
553 shape
554 }
555 }
556
557 fn slide_xml(shapes: &str) -> String {
558 format!(
559 "<p:sld xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld></p:sld>",
560 shape_tree(shapes)
561 )
562 }
563
564 fn layout_xml(shapes: &str) -> String {
565 format!(
566 "<p:sldLayout xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld></p:sldLayout>",
567 shape_tree(shapes)
568 )
569 }
570
571 fn master_xml(shapes: &str, body_style: &str) -> String {
572 format!(
573 "<p:sldMaster xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" 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:txStyles><p:titleStyle/><p:bodyStyle>{body_style}</p:bodyStyle><p:otherStyle/></p:txStyles></p:sldMaster>",
574 shape_tree(shapes)
575 )
576 }
577
578 fn shape_tree(shapes: &str) -> String {
579 format!("<p:spTree><p:nvGrpSpPr/><p:grpSpPr/>{shapes}</p:spTree>")
580 }
581
582 fn shape(index: u32, list_style: &str) -> String {
583 format!(
584 "<p:sp><p:nvSpPr><p:cNvPr/><p:cNvSpPr/><p:nvPr><p:ph type=\"body\" idx=\"{index}\"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle>{list_style}</a:lstStyle><a:p/></p:txBody></p:sp>"
585 )
586 }
587}