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.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(&paragraph),
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            &paragraph("<a:pPr marL=\"100\" marR=\"200\"><a:defRPr sz=\"1100\" b=\"1\"/></a:pPr>"),
326        );
327        merge_paragraph(
328            &mut effective,
329            &paragraph("<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            &paragraph(
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            &paragraph(
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            &paragraph(
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            &paragraph("<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}