Skip to main content

rpptx_layout/
text.rs

1//! Text inheritance resolution.
2
3use 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/// Resolved paragraph and character properties without opaque XML or actions.
13#[derive(Clone, Debug, Default, Eq, PartialEq)]
14pub struct EffectiveTextProperties {
15    pub paragraph: CT_TextParagraphProperties,
16    pub run: CT_TextCharacterProperties,
17}
18
19/// The independently resolved properties for all nine DrawingML list levels.
20#[derive(Clone, Debug, Default, Eq, PartialEq)]
21pub struct EffectiveListStyle {
22    levels: [EffectiveTextProperties; 9],
23}
24
25impl EffectiveListStyle {
26    /// Returns the zero-based DrawingML level selected by `a:pPr/@lvl`.
27    pub fn level(&self, level: u8) -> Option<&EffectiveTextProperties> {
28        self.levels.get(usize::from(level))
29    }
30}
31
32impl ResolveCtx<'_> {
33    /// Resolves sources one through five for every list level of one shape.
34    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    /// Applies paragraph and run properties to the selected zero-based level.
55    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    /// Resolves presentation defaults and the master's other style for table text.
79    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(&paragraph),
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            &paragraph("<a:pPr marL=\"100\" marR=\"200\"><a:defRPr sz=\"1100\" b=\"1\"/></a:pPr>"),
329        );
330        merge_paragraph(
331            &mut effective,
332            &paragraph("<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, &paragraph("<a:pPr rtl=\"1\"></a:pPr>"));
345        assert_eq!(effective.paragraph.right_to_left, Some(true));
346
347        merge_paragraph(&mut effective, &paragraph("<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            &paragraph(
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            &paragraph(
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            &paragraph(
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            &paragraph("<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}