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.line_spacing.is_some() {
171 effective.paragraph.line_spacing = source.line_spacing.clone();
172 }
173 if source.space_before.is_some() {
174 effective.paragraph.space_before = source.space_before.clone();
175 }
176 if source.space_after.is_some() {
177 effective.paragraph.space_after = source.space_after.clone();
178 }
179 if let Some(source_bullet) = &source.bullet {
180 let bullet = effective.paragraph.bullet.get_or_insert_default();
181 if source_bullet.color.is_some() {
182 bullet.color = source_bullet.color.clone();
183 }
184 if source_bullet.size.is_some() {
185 bullet.size = source_bullet.size.clone();
186 }
187 if source_bullet.font.is_some() {
188 bullet.font = source_bullet.font.clone();
189 }
190 if source_bullet.choice.is_some() {
191 bullet.choice = source_bullet.choice.clone();
192 }
193 }
194 if let Some(properties) = &source.default_run_properties {
195 merge_character_properties(&mut effective.run, properties);
196 }
197}
198
199fn merge_character(effective: &mut EffectiveTextProperties, source: &CT_TextCharacterProperties) {
200 merge_character_properties(&mut effective.run, source);
201}
202
203fn merge_character_properties(
204 effective: &mut CT_TextCharacterProperties,
205 source: &CT_TextCharacterProperties,
206) {
207 if source.font_size.is_some() {
208 effective.font_size = source.font_size;
209 }
210 if source.bold.is_some() {
211 effective.bold = source.bold;
212 }
213 if source.italic.is_some() {
214 effective.italic = source.italic;
215 }
216 if source.all_caps.is_some() {
217 effective.all_caps = source.all_caps;
218 }
219 if source.underline.is_some() {
220 effective.underline = source.underline;
221 }
222 if source.strike.is_some() {
223 effective.strike = source.strike;
224 }
225 if source.spacing.is_some() {
226 effective.spacing = source.spacing.clone();
227 }
228 if source.baseline.is_some() {
229 effective.baseline = source.baseline.clone();
230 }
231 if source.fill.is_some() {
232 effective.fill = source.fill.clone();
233 }
234 if source.latin.is_some() {
235 effective.latin = source.latin.clone();
236 }
237 if source.east_asian.is_some() {
238 effective.east_asian = source.east_asian.clone();
239 }
240 if source.complex_script.is_some() {
241 effective.complex_script = source.complex_script.clone();
242 }
243 if source.symbol.is_some() {
244 effective.symbol = source.symbol.clone();
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use oxml_drawing::text::{
251 CT_TextCharacterProperties, CT_TextListStyle, CT_TextParagraphProperties, TextAlignment,
252 TextBulletChoice, TextBulletSizeValue,
253 };
254 use oxml_drawing::{color::ColorMap, theme::CT_OfficeStyleSheet};
255 use rpptx_oxml::shape_tree::{CT_Shape, ShapeTreeChild};
256 use rpptx_oxml::slide_parts::{CT_Slide, CT_SlideLayout, CT_SlideMaster};
257
258 use super::{EffectiveListStyle, EffectiveTextProperties, merge_list_style, merge_paragraph};
259 use crate::ResolveCtx;
260
261 const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
262
263 #[test]
264 fn seven_source_list_style_merge_resolves_run_size_and_typeface() {
265 let fixture = Fixture::new(
266 "<a:defPPr marL=\"100\"><a:defRPr sz=\"1000\"/></a:defPPr>",
267 "<a:defPPr marR=\"200\"/>",
268 "<a:defPPr indent=\"300\"/>",
269 "<a:defPPr algn=\"ctr\"/>",
270 &["<a:defPPr><a:defRPr b=\"1\"/></a:defPPr>"],
271 );
272 let paragraph = paragraph("<a:pPr><a:defRPr sz=\"2400\"/></a:pPr>");
273 let run = character("<a:rPr><a:latin typeface=\"Run Face\"/></a:rPr>");
274 let resolved = fixture.context().effective_text_properties(
275 fixture.slide_shape(0),
276 Some(¶graph),
277 Some(&run),
278 );
279
280 assert_eq!(resolved.run.font_size, Some(2400));
281 assert_eq!(resolved.run.latin.unwrap().typeface, "Run Face");
282 assert_eq!(resolved.paragraph.left_margin, Some(100));
283 assert_eq!(resolved.paragraph.right_margin, Some(200));
284 assert_eq!(resolved.paragraph.indent, Some(300));
285 assert_eq!(resolved.paragraph.alignment, Some(TextAlignment::Center));
286 assert_eq!(resolved.run.bold, Some(true));
287 }
288
289 #[test]
290 fn default_paragraph_properties_apply_before_each_level() {
291 let source = list_style(
292 "<a:defPPr marL=\"100\"><a:defRPr sz=\"1200\"/></a:defPPr>\
293 <a:lvl1pPr marL=\"200\"/><a:lvl9pPr><a:defRPr b=\"1\"/></a:lvl9pPr>",
294 );
295 let mut effective = EffectiveListStyle::default();
296 merge_list_style(&mut effective, &source);
297
298 assert_eq!(effective.level(0).unwrap().paragraph.left_margin, Some(200));
299 assert_eq!(effective.level(8).unwrap().paragraph.left_margin, Some(100));
300 assert_eq!(effective.level(8).unwrap().run.font_size, Some(1200));
301 assert_eq!(effective.level(8).unwrap().run.bold, Some(true));
302 }
303
304 #[test]
305 fn all_nine_levels_merge_independently() {
306 let levels = (1..=9)
307 .map(|level| format!("<a:lvl{level}pPr marL=\"{}\"/>", level * 100))
308 .collect::<String>();
309 let mut effective = EffectiveListStyle::default();
310 merge_list_style(&mut effective, &list_style(&levels));
311
312 for level in 0..9 {
313 assert_eq!(
314 effective.level(level).unwrap().paragraph.left_margin,
315 Some(i32::from(level + 1) * 100)
316 );
317 }
318 }
319
320 #[test]
321 fn later_sources_win_per_property_without_erasing_other_fields() {
322 let mut effective = EffectiveTextProperties::default();
323 merge_paragraph(
324 &mut effective,
325 ¶graph("<a:pPr marL=\"100\" marR=\"200\"><a:defRPr sz=\"1100\" b=\"1\"/></a:pPr>"),
326 );
327 merge_paragraph(
328 &mut effective,
329 ¶graph("<a:pPr marL=\"300\"><a:defRPr sz=\"2200\"/></a:pPr>"),
330 );
331
332 assert_eq!(effective.paragraph.left_margin, Some(300));
333 assert_eq!(effective.paragraph.right_margin, Some(200));
334 assert_eq!(effective.run.font_size, Some(2200));
335 assert_eq!(effective.run.bold, Some(true));
336 }
337
338 #[test]
339 fn all_caps_inherits_and_a_direct_none_value_overrides_it() {
340 let fixture = Fixture::new(
341 "<a:defPPr><a:defRPr cap=\"all\"/></a:defPPr>",
342 "",
343 "",
344 "",
345 &[""],
346 );
347 let inherited =
348 fixture
349 .context()
350 .effective_text_properties(fixture.slide_shape(0), None, None);
351 assert_eq!(inherited.run.all_caps, Some(true));
352
353 let direct = character("<a:rPr cap=\"none\"></a:rPr>");
354 let overridden = fixture.context().effective_text_properties(
355 fixture.slide_shape(0),
356 None,
357 Some(&direct),
358 );
359 assert_eq!(overridden.run.all_caps, Some(false));
360 }
361
362 #[test]
363 fn fills_and_typeface_slots_merge_as_atomic_properties() {
364 let mut effective = EffectiveTextProperties::default();
365 merge_paragraph(
366 &mut effective,
367 ¶graph(
368 "<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>",
369 ),
370 );
371 merge_paragraph(
372 &mut effective,
373 ¶graph(
374 "<a:pPr><a:defRPr><a:solidFill><a:srgbClr val=\"AABBCC\"/></a:solidFill><a:latin typeface=\"Latin Two\"/></a:defRPr></a:pPr>",
375 ),
376 );
377
378 assert_eq!(effective.run.latin.unwrap().typeface, "Latin Two");
379 assert_eq!(effective.run.east_asian.unwrap().typeface, "East Asian One");
380 assert_eq!(
381 effective.run.fill,
382 character("<a:rPr><a:solidFill><a:srgbClr val=\"AABBCC\"/></a:solidFill></a:rPr>").fill
383 );
384 }
385
386 #[test]
387 fn raw_xml_and_hyperlink_actions_are_not_inherited() {
388 let source = paragraph(
389 "<a:pPr producer=\"raw\"><x:opaque xmlns:x=\"urn:x\"/><a:defRPr><a:hlinkClick action=\"ppaction://jump\"/></a:defRPr></a:pPr>",
390 );
391 let mut effective = EffectiveTextProperties::default();
392 merge_paragraph(&mut effective, &source);
393
394 assert!(effective.paragraph.raw_children().is_empty());
395 assert!(effective.run.raw_children().is_empty());
396 assert!(effective.run.hyperlink_click.is_none());
397 assert!(effective.run.hyperlink_mouse_over.is_none());
398 }
399
400 #[test]
401 fn bullet_components_merge_independently() {
402 let mut effective = EffectiveTextProperties::default();
403 merge_paragraph(
404 &mut effective,
405 ¶graph(
406 "<a:pPr><a:buClr><a:srgbClr val=\"112233\"/></a:buClr><a:buSzPts val=\"1800\"/><a:buChar char=\"•\"/></a:pPr>",
407 ),
408 );
409 merge_paragraph(
410 &mut effective,
411 ¶graph("<a:pPr><a:buFont typeface=\"Wingdings\"/><a:buNone/></a:pPr>"),
412 );
413 let bullet = effective.paragraph.bullet.unwrap();
414
415 assert!(bullet.color.is_some());
416 assert!(matches!(
417 bullet.size.unwrap().value,
418 TextBulletSizeValue::Points(1800)
419 ));
420 assert_eq!(bullet.font.unwrap().typeface, "Wingdings");
421 assert!(matches!(bullet.choice, Some(TextBulletChoice::None(_))));
422 }
423
424 #[test]
425 fn shape_owned_style_is_not_shared_by_placeholder_cache() {
426 let fixture = Fixture::new(
427 "",
428 "",
429 "<a:lvl1pPr marL=\"100\"/>",
430 "",
431 &["<a:lvl1pPr marL=\"900\"/>", "<a:lvl1pPr marL=\"800\"/>"],
432 );
433 let context = fixture.context();
434 let first_shape = context.effective_list_style(fixture.slide_shape(0));
435 let second_shape = context.effective_list_style(fixture.slide_shape(1));
436
437 assert_eq!(
438 first_shape.level(0).unwrap().paragraph.left_margin,
439 Some(900)
440 );
441 assert_eq!(
442 second_shape.level(0).unwrap().paragraph.left_margin,
443 Some(800)
444 );
445 }
446
447 #[test]
448 fn default_paragraph_properties_round_trip_in_schema_order() {
449 let xml = format!(
450 "<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>"
451 );
452 let style = CT_TextListStyle::from_xml(xml.as_bytes()).unwrap();
453 assert_eq!(
454 style
455 .default_paragraph_properties
456 .as_ref()
457 .unwrap()
458 .left_margin,
459 Some(100)
460 );
461 let output = String::from_utf8(style.to_xml().unwrap()).unwrap();
462
463 assert_eq!(
464 output,
465 "<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>"
466 );
467 }
468
469 fn list_style(children: &str) -> CT_TextListStyle {
470 CT_TextListStyle::from_xml(
471 format!("<a:lstStyle xmlns:a=\"{A_NS}\">{children}</a:lstStyle>").as_bytes(),
472 )
473 .unwrap()
474 }
475
476 fn paragraph(xml: &str) -> CT_TextParagraphProperties {
477 CT_TextParagraphProperties::from_xml(with_namespace(xml).as_bytes()).unwrap()
478 }
479
480 fn character(xml: &str) -> CT_TextCharacterProperties {
481 CT_TextCharacterProperties::from_xml(with_namespace(xml).as_bytes()).unwrap()
482 }
483
484 fn with_namespace(xml: &str) -> String {
485 xml.replacen('>', &format!(" xmlns:a=\"{A_NS}\">"), 1)
486 }
487
488 struct Fixture {
489 theme: CT_OfficeStyleSheet,
490 master: CT_SlideMaster,
491 layout: CT_SlideLayout,
492 slide: CT_Slide,
493 default_text_style: CT_TextListStyle,
494 }
495
496 impl Fixture {
497 fn new(
498 default_style: &str,
499 master_style: &str,
500 master_placeholder: &str,
501 layout_placeholder: &str,
502 slide_styles: &[&str],
503 ) -> Self {
504 let slide_shapes = slide_styles
505 .iter()
506 .map(|style| shape(7, style))
507 .collect::<String>();
508 Self {
509 theme: CT_OfficeStyleSheet::office_default(),
510 master: CT_SlideMaster::from_xml(
511 master_xml(&shape(7, master_placeholder), master_style).as_bytes(),
512 )
513 .unwrap(),
514 layout: CT_SlideLayout::from_xml(
515 layout_xml(&shape(7, layout_placeholder)).as_bytes(),
516 )
517 .unwrap(),
518 slide: CT_Slide::from_xml(slide_xml(&slide_shapes).as_bytes()).unwrap(),
519 default_text_style: list_style(default_style),
520 }
521 }
522
523 fn context(&self) -> ResolveCtx<'_> {
524 ResolveCtx::new(
525 &self.theme,
526 ColorMap::default(),
527 &self.master,
528 &self.layout,
529 &self.slide,
530 &self.default_text_style,
531 )
532 }
533
534 fn slide_shape(&self, index: usize) -> &CT_Shape {
535 let ShapeTreeChild::Shape(shape) =
536 &self.slide.common_slide_data.shape_tree.children[index]
537 else {
538 panic!("expected slide shape");
539 };
540 shape
541 }
542 }
543
544 fn slide_xml(shapes: &str) -> String {
545 format!(
546 "<p:sld xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld></p:sld>",
547 shape_tree(shapes)
548 )
549 }
550
551 fn layout_xml(shapes: &str) -> String {
552 format!(
553 "<p:sldLayout xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld></p:sldLayout>",
554 shape_tree(shapes)
555 )
556 }
557
558 fn master_xml(shapes: &str, body_style: &str) -> String {
559 format!(
560 "<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>",
561 shape_tree(shapes)
562 )
563 }
564
565 fn shape_tree(shapes: &str) -> String {
566 format!("<p:spTree><p:nvGrpSpPr/><p:grpSpPr/>{shapes}</p:spTree>")
567 }
568
569 fn shape(index: u32, list_style: &str) -> String {
570 format!(
571 "<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>"
572 )
573 }
574}