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::fill::{Fill, SolidFill};
7use oxml_drawing::text::{
8    CT_TextCharacterProperties, CT_TextListStyle, CT_TextParagraphProperties,
9};
10
11use crate::ResolveCtx;
12
13/// Resolved paragraph and character properties without opaque XML or actions.
14#[derive(Clone, Debug, Default, Eq, PartialEq)]
15pub struct EffectiveTextProperties {
16    pub paragraph: CT_TextParagraphProperties,
17    pub run: CT_TextCharacterProperties,
18    /// Source language selected by the same inheritance chain as the run.
19    pub language: Option<String>,
20}
21
22/// The independently resolved properties for all nine DrawingML list levels.
23#[derive(Clone, Debug, Default, Eq, PartialEq)]
24pub struct EffectiveListStyle {
25    levels: [EffectiveTextProperties; 9],
26}
27
28impl EffectiveListStyle {
29    /// Returns the zero-based DrawingML level selected by `a:pPr/@lvl`.
30    pub fn level(&self, level: u8) -> Option<&EffectiveTextProperties> {
31        self.levels.get(usize::from(level))
32    }
33}
34
35impl ResolveCtx<'_> {
36    /// Resolves sources one through six for every list level of one shape.
37    pub fn effective_list_style(&self, shape: &CT_Shape) -> EffectiveListStyle {
38        let key = shape
39            .placeholder
40            .as_ref()
41            .map(|placeholder| placeholder.key());
42        let mut effective = if let Some(cached) = self.list_style_cache.borrow().get(&key) {
43            cached.clone()
44        } else {
45            let resolved = self.resolve_list_style_prefix(shape);
46            self.list_style_cache
47                .borrow_mut()
48                .insert(key, resolved.clone());
49            resolved
50        };
51        // The colour of the style's font reference overrides the inherited
52        // text styles, and the shape's own list style overrides it in turn.
53        // A colour that does not resolve leaves the inherited colour.
54        if let Some(color) = shape
55            .style()
56            .and_then(|style| style.font_reference.color.as_ref())
57            .filter(|color| self.concrete_color(color).is_ok())
58        {
59            let mut solid = SolidFill::default();
60            solid.color = Some(color.clone());
61            for level in &mut effective.levels {
62                level.run.fill = Some(Fill::Solid(solid.clone()));
63            }
64        }
65        if let Some(list_style) = shape.text_body.as_ref().and_then(|body| body.list_style()) {
66            merge_list_style(&mut effective, list_style);
67        }
68        effective
69    }
70
71    /// Applies paragraph and run properties to the selected zero-based level.
72    pub fn effective_text_properties(
73        &self,
74        shape: &CT_Shape,
75        paragraph: Option<&CT_TextParagraphProperties>,
76        run: Option<&CT_TextCharacterProperties>,
77    ) -> EffectiveTextProperties {
78        let style = self.effective_list_style(shape);
79        let level = paragraph
80            .and_then(|properties| properties.level)
81            .unwrap_or(0);
82        let mut effective = style
83            .level(level)
84            .cloned()
85            .unwrap_or_else(EffectiveTextProperties::default);
86        if let Some(properties) = paragraph {
87            merge_paragraph(&mut effective, properties);
88        }
89        if let Some(properties) = run {
90            merge_character(&mut effective, properties);
91        }
92        effective
93    }
94
95    /// Resolves presentation defaults and the master's other style for table text.
96    pub(crate) fn effective_table_text_properties(
97        &self,
98        table_style: Option<&CT_TextCharacterProperties>,
99        paragraph: Option<&CT_TextParagraphProperties>,
100        run: Option<&CT_TextCharacterProperties>,
101    ) -> EffectiveTextProperties {
102        let mut style = EffectiveListStyle::default();
103        merge_list_style(&mut style, self.default_text_style);
104        if let Some(styles) = &self.master.text_styles {
105            merge_list_style(&mut style, &styles.other_style);
106        }
107        let level = paragraph
108            .and_then(|properties| properties.level)
109            .unwrap_or(0);
110        let mut effective = style
111            .level(level)
112            .cloned()
113            .unwrap_or_else(EffectiveTextProperties::default);
114        if let Some(properties) = table_style {
115            merge_character(&mut effective, properties);
116        }
117        if let Some(properties) = paragraph {
118            merge_paragraph(&mut effective, properties);
119        }
120        if let Some(properties) = run {
121            merge_character(&mut effective, properties);
122        }
123        effective
124    }
125
126    fn resolve_list_style_prefix(&self, shape: &CT_Shape) -> EffectiveListStyle {
127        let mut effective = EffectiveListStyle::default();
128        merge_list_style(&mut effective, self.default_text_style);
129
130        if let Some(styles) = &self.master.text_styles {
131            let master_style = match shape
132                .placeholder
133                .as_ref()
134                .map(|placeholder| placeholder.effective_type())
135            {
136                Some(PhType::Title | PhType::CenteredTitle) => &styles.title_style,
137                Some(PhType::Body | PhType::Subtitle | PhType::Object) => &styles.body_style,
138                _ => &styles.other_style,
139            };
140            merge_list_style(&mut effective, master_style);
141        }
142
143        let (layout_placeholder, master_placeholder) = self.placeholder_chain(shape);
144        if let Some(list_style) = master_placeholder
145            .and_then(|placeholder| placeholder.text_body.as_ref())
146            .and_then(|body| body.list_style())
147        {
148            merge_list_style(&mut effective, list_style);
149        }
150        if let Some(list_style) = layout_placeholder
151            .and_then(|placeholder| placeholder.text_body.as_ref())
152            .and_then(|body| body.list_style())
153        {
154            merge_list_style(&mut effective, list_style);
155        }
156        effective
157    }
158}
159
160fn merge_list_style(effective: &mut EffectiveListStyle, source: &CT_TextListStyle) {
161    for level in 0..9 {
162        if let Some(properties) = &source.default_paragraph_properties {
163            merge_paragraph(&mut effective.levels[level], properties);
164        }
165        if let Some(properties) = source.level(level + 1) {
166            merge_paragraph(&mut effective.levels[level], properties);
167        }
168    }
169}
170
171fn merge_paragraph(effective: &mut EffectiveTextProperties, source: &CT_TextParagraphProperties) {
172    if source.left_margin.is_some() {
173        effective.paragraph.left_margin = source.left_margin;
174    }
175    if source.right_margin.is_some() {
176        effective.paragraph.right_margin = source.right_margin;
177    }
178    if source.level.is_some() {
179        effective.paragraph.level = source.level;
180    }
181    if source.indent.is_some() {
182        effective.paragraph.indent = source.indent;
183    }
184    if source.alignment.is_some() {
185        effective.paragraph.alignment = source.alignment;
186    }
187    if source.right_to_left.is_some() {
188        effective.paragraph.right_to_left = source.right_to_left;
189    }
190    if source.line_spacing.is_some() {
191        effective.paragraph.line_spacing = source.line_spacing.clone();
192    }
193    if source.space_before.is_some() {
194        effective.paragraph.space_before = source.space_before.clone();
195    }
196    if source.space_after.is_some() {
197        effective.paragraph.space_after = source.space_after.clone();
198    }
199    if let Some(source_bullet) = &source.bullet {
200        let bullet = effective.paragraph.bullet.get_or_insert_default();
201        if source_bullet.color.is_some() {
202            bullet.color = source_bullet.color.clone();
203        }
204        if source_bullet.size.is_some() {
205            bullet.size = source_bullet.size.clone();
206        }
207        if source_bullet.font.is_some() {
208            bullet.font = source_bullet.font.clone();
209        }
210        if source_bullet.choice.is_some() {
211            bullet.choice = source_bullet.choice.clone();
212        }
213    }
214    if let Some(properties) = &source.default_run_properties {
215        merge_character(effective, properties);
216    }
217}
218
219fn merge_character(effective: &mut EffectiveTextProperties, source: &CT_TextCharacterProperties) {
220    if let Some(language) = source.language() {
221        effective.language = Some(language.to_owned());
222    }
223    merge_character_properties(&mut effective.run, source);
224}
225
226fn merge_character_properties(
227    effective: &mut CT_TextCharacterProperties,
228    source: &CT_TextCharacterProperties,
229) {
230    if source.font_size.is_some() {
231        effective.font_size = source.font_size;
232    }
233    if source.bold.is_some() {
234        effective.bold = source.bold;
235    }
236    if source.italic.is_some() {
237        effective.italic = source.italic;
238    }
239    if source.all_caps == Some(false) && source.small_caps == Some(false) {
240        // cap="none" clears both modes as one owned value.
241        effective.all_caps = Some(false);
242        effective.small_caps = Some(false);
243    } else {
244        if source.all_caps.is_some() {
245            effective.set_all_caps(source.all_caps);
246        }
247        if source.small_caps.is_some() {
248            effective.set_small_caps(source.small_caps);
249        }
250    }
251    if source.highlight.is_some() {
252        effective.highlight = source.highlight.clone();
253    }
254    if source.underline.is_some() {
255        effective.underline = source.underline;
256    }
257    if source.strike.is_some() {
258        effective.strike = source.strike;
259    }
260    if source.spacing.is_some() {
261        effective.spacing = source.spacing.clone();
262    }
263    if source.baseline.is_some() {
264        effective.baseline = source.baseline.clone();
265    }
266    if source.fill.is_some() {
267        effective.fill = source.fill.clone();
268    }
269    if source.latin.is_some() {
270        effective.latin = source.latin.clone();
271    }
272    if source.east_asian.is_some() {
273        effective.east_asian = source.east_asian.clone();
274    }
275    if source.complex_script.is_some() {
276        effective.complex_script = source.complex_script.clone();
277    }
278    if source.symbol.is_some() {
279        effective.symbol = source.symbol.clone();
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use oxml_drawing::text::{
286        CT_TextCharacterProperties, CT_TextListStyle, CT_TextParagraphProperties, TextAlignment,
287        TextBulletChoice, TextBulletSizeValue,
288    };
289    use oxml_drawing::{color::ColorMap, theme::CT_OfficeStyleSheet};
290    use rpptx_oxml::shape_tree::{CT_Shape, ShapeTreeChild};
291    use rpptx_oxml::slide_parts::{CT_Slide, CT_SlideLayout, CT_SlideMaster};
292
293    use super::{EffectiveListStyle, EffectiveTextProperties, merge_list_style, merge_paragraph};
294    use crate::ResolveCtx;
295
296    const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
297
298    #[test]
299    fn seven_source_list_style_merge_resolves_run_size_and_typeface() {
300        let fixture = Fixture::new(
301            "<a:defPPr marL=\"100\"><a:defRPr sz=\"1000\"/></a:defPPr>",
302            "<a:defPPr marR=\"200\"/>",
303            "<a:defPPr indent=\"300\"/>",
304            "<a:defPPr algn=\"ctr\"/>",
305            &["<a:defPPr><a:defRPr b=\"1\"/></a:defPPr>"],
306        );
307        let paragraph = paragraph("<a:pPr><a:defRPr sz=\"2400\"/></a:pPr>");
308        let run = character("<a:rPr><a:latin typeface=\"Run Face\"/></a:rPr>");
309        let resolved = fixture.context().effective_text_properties(
310            fixture.slide_shape(0),
311            Some(&paragraph),
312            Some(&run),
313        );
314
315        assert_eq!(resolved.run.font_size, Some(2400));
316        assert_eq!(resolved.run.latin.unwrap().typeface, "Run Face");
317        assert_eq!(resolved.paragraph.left_margin, Some(100));
318        assert_eq!(resolved.paragraph.right_margin, Some(200));
319        assert_eq!(resolved.paragraph.indent, Some(300));
320        assert_eq!(resolved.paragraph.alignment, Some(TextAlignment::Center));
321        assert_eq!(resolved.run.bold, Some(true));
322    }
323
324    #[test]
325    fn default_paragraph_properties_apply_before_each_level() {
326        let source = list_style(
327            "<a:defPPr marL=\"100\"><a:defRPr sz=\"1200\"/></a:defPPr>\
328             <a:lvl1pPr marL=\"200\"/><a:lvl9pPr><a:defRPr b=\"1\"/></a:lvl9pPr>",
329        );
330        let mut effective = EffectiveListStyle::default();
331        merge_list_style(&mut effective, &source);
332
333        assert_eq!(effective.level(0).unwrap().paragraph.left_margin, Some(200));
334        assert_eq!(effective.level(8).unwrap().paragraph.left_margin, Some(100));
335        assert_eq!(effective.level(8).unwrap().run.font_size, Some(1200));
336        assert_eq!(effective.level(8).unwrap().run.bold, Some(true));
337    }
338
339    #[test]
340    fn all_nine_levels_merge_independently() {
341        let levels = (1..=9)
342            .map(|level| format!("<a:lvl{level}pPr marL=\"{}\"/>", level * 100))
343            .collect::<String>();
344        let mut effective = EffectiveListStyle::default();
345        merge_list_style(&mut effective, &list_style(&levels));
346
347        for level in 0..9 {
348            assert_eq!(
349                effective.level(level).unwrap().paragraph.left_margin,
350                Some(i32::from(level + 1) * 100)
351            );
352        }
353    }
354
355    #[test]
356    fn later_sources_win_per_property_without_erasing_other_fields() {
357        let mut effective = EffectiveTextProperties::default();
358        merge_paragraph(
359            &mut effective,
360            &paragraph("<a:pPr marL=\"100\" marR=\"200\"><a:defRPr sz=\"1100\" b=\"1\"/></a:pPr>"),
361        );
362        merge_paragraph(
363            &mut effective,
364            &paragraph("<a:pPr marL=\"300\"><a:defRPr sz=\"2200\"/></a:pPr>"),
365        );
366
367        assert_eq!(effective.paragraph.left_margin, Some(300));
368        assert_eq!(effective.paragraph.right_margin, Some(200));
369        assert_eq!(effective.run.font_size, Some(2200));
370        assert_eq!(effective.run.bold, Some(true));
371    }
372
373    #[test]
374    fn paragraph_direction_inherits_and_direct_ltr_overrides_it() {
375        let mut effective = EffectiveTextProperties::default();
376        merge_paragraph(&mut effective, &paragraph("<a:pPr rtl=\"1\"></a:pPr>"));
377        assert_eq!(effective.paragraph.right_to_left, Some(true));
378
379        merge_paragraph(&mut effective, &paragraph("<a:pPr rtl=\"0\"></a:pPr>"));
380        assert_eq!(effective.paragraph.right_to_left, Some(false));
381    }
382
383    #[test]
384    fn all_caps_inherits_and_a_direct_none_value_overrides_it() {
385        let fixture = Fixture::new(
386            "<a:defPPr><a:defRPr cap=\"all\"/></a:defPPr>",
387            "",
388            "",
389            "",
390            &[""],
391        );
392        let inherited =
393            fixture
394                .context()
395                .effective_text_properties(fixture.slide_shape(0), None, None);
396        assert_eq!(inherited.run.all_caps, Some(true));
397
398        let direct = character("<a:rPr cap=\"none\"></a:rPr>");
399        let overridden = fixture.context().effective_text_properties(
400            fixture.slide_shape(0),
401            None,
402            Some(&direct),
403        );
404        assert_eq!(overridden.run.all_caps, Some(false));
405        assert_eq!(overridden.run.small_caps, Some(false));
406        for inherited_small in [false, true] {
407            let mut effective = CT_TextCharacterProperties::default();
408            if inherited_small {
409                effective.set_small_caps(Some(true));
410            } else {
411                effective.set_all_caps(Some(true));
412            }
413            let source_before = direct.clone();
414            super::merge_character_properties(&mut effective, &direct);
415            assert_eq!(effective.all_caps, Some(false));
416            assert_eq!(effective.small_caps, Some(false));
417            assert_eq!(direct, source_before);
418            let mut paragraph = CT_TextParagraphProperties::default();
419            paragraph.default_run_properties = Some(effective);
420            let bytes = paragraph.to_xml().unwrap();
421            let reopened = CT_TextParagraphProperties::from_xml(&bytes)
422                .unwrap()
423                .default_run_properties
424                .unwrap();
425            assert_eq!(reopened.all_caps, Some(false));
426            assert_eq!(reopened.small_caps, Some(false));
427        }
428    }
429
430    #[test]
431    fn fills_and_typeface_slots_merge_as_atomic_properties() {
432        let mut effective = EffectiveTextProperties::default();
433        merge_paragraph(
434            &mut effective,
435            &paragraph(
436                "<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>",
437            ),
438        );
439        merge_paragraph(
440            &mut effective,
441            &paragraph(
442                "<a:pPr><a:defRPr><a:solidFill><a:srgbClr val=\"AABBCC\"/></a:solidFill><a:latin typeface=\"Latin Two\"/></a:defRPr></a:pPr>",
443            ),
444        );
445
446        assert_eq!(effective.run.latin.unwrap().typeface, "Latin Two");
447        assert_eq!(effective.run.east_asian.unwrap().typeface, "East Asian One");
448        assert_eq!(
449            effective.run.fill,
450            character("<a:rPr><a:solidFill><a:srgbClr val=\"AABBCC\"/></a:solidFill></a:rPr>").fill
451        );
452    }
453
454    #[test]
455    fn raw_xml_and_hyperlink_actions_are_not_inherited() {
456        let source = paragraph(
457            "<a:pPr producer=\"raw\"><x:opaque xmlns:x=\"urn:x\"/><a:defRPr><a:hlinkClick action=\"ppaction://jump\"/></a:defRPr></a:pPr>",
458        );
459        let mut effective = EffectiveTextProperties::default();
460        merge_paragraph(&mut effective, &source);
461
462        assert!(effective.paragraph.raw_children().is_empty());
463        assert!(effective.run.raw_children().is_empty());
464        assert!(effective.run.hyperlink_click.is_none());
465        assert!(effective.run.hyperlink_mouse_over.is_none());
466    }
467
468    #[test]
469    fn bullet_components_merge_independently() {
470        let mut effective = EffectiveTextProperties::default();
471        merge_paragraph(
472            &mut effective,
473            &paragraph(
474                "<a:pPr><a:buClr><a:srgbClr val=\"112233\"/></a:buClr><a:buSzPts val=\"1800\"/><a:buChar char=\"•\"/></a:pPr>",
475            ),
476        );
477        merge_paragraph(
478            &mut effective,
479            &paragraph("<a:pPr><a:buFont typeface=\"Wingdings\"/><a:buNone/></a:pPr>"),
480        );
481        let bullet = effective.paragraph.bullet.unwrap();
482
483        assert!(bullet.color.is_some());
484        assert!(matches!(
485            bullet.size.unwrap().value,
486            TextBulletSizeValue::Points(1800)
487        ));
488        assert_eq!(bullet.font.unwrap().typeface, "Wingdings");
489        assert!(matches!(bullet.choice, Some(TextBulletChoice::None(_))));
490    }
491
492    #[test]
493    fn shape_owned_style_is_not_shared_by_placeholder_cache() {
494        let fixture = Fixture::new(
495            "",
496            "",
497            "<a:lvl1pPr marL=\"100\"/>",
498            "",
499            &["<a:lvl1pPr marL=\"900\"/>", "<a:lvl1pPr marL=\"800\"/>"],
500        );
501        let context = fixture.context();
502        let first_shape = context.effective_list_style(fixture.slide_shape(0));
503        let second_shape = context.effective_list_style(fixture.slide_shape(1));
504
505        assert_eq!(
506            first_shape.level(0).unwrap().paragraph.left_margin,
507            Some(900)
508        );
509        assert_eq!(
510            second_shape.level(0).unwrap().paragraph.left_margin,
511            Some(800)
512        );
513    }
514
515    #[test]
516    fn style_font_colour_overrides_inherited_styles_under_the_shape_list_style() {
517        let colour = |value: &str| {
518            format!("<a:defRPr><a:solidFill><a:srgbClr val=\"{value}\"/></a:solidFill></a:defRPr>")
519        };
520        let mut fixture = Fixture::new(
521            "",
522            &format!("<a:lvl1pPr>{}</a:lvl1pPr>", colour("111111")),
523            "",
524            "",
525            &[],
526        );
527        let style = r#"<p:style><a:lnRef idx="1"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="3"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="2"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:srgbClr val="222222"/></a:fontRef></p:style>"#;
528        let styled = |list_style: &str| {
529            shape(7, list_style).replace(
530                "<p:spPr/>",
531                &format!(r#"<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm></p:spPr>{style}"#),
532            )
533        };
534        let shapes = [
535            shape(7, ""),
536            styled(""),
537            styled(&format!("<a:lvl1pPr>{}</a:lvl1pPr>", colour("333333"))),
538            styled("").replace(
539                r#"<a:srgbClr val="222222"/></a:fontRef>"#,
540                r#"<a:schemeClr val="phClr"/></a:fontRef>"#,
541            ),
542            styled("").replace(
543                r#"<a:srgbClr val="222222"/></a:fontRef>"#,
544                r#"<a:prstClr val="red"/></a:fontRef>"#,
545            ),
546        ];
547        fixture.slide = CT_Slide::from_xml(slide_xml(&shapes.concat()).as_bytes()).unwrap();
548        let context = fixture.context();
549        let run_fill = |index| {
550            context
551                .effective_text_properties(fixture.slide_shape(index), None, None)
552                .run
553                .fill
554        };
555        let expected = |value: &str| {
556            character(&format!(
557                "<a:rPr><a:solidFill><a:srgbClr val=\"{value}\"/></a:solidFill></a:rPr>"
558            ))
559            .fill
560        };
561
562        assert_eq!(run_fill(0), expected("111111"));
563        assert_eq!(run_fill(1), expected("222222"));
564        assert_eq!(run_fill(2), expected("333333"));
565        // A font colour that does not resolve keeps the inherited colour.
566        assert_eq!(run_fill(3), expected("111111"));
567        assert_eq!(run_fill(4), expected("111111"));
568        context
569            .resolve_slide((720.0, 540.0))
570            .expect("an unresolvable font colour does not fail the slide");
571    }
572
573    #[test]
574    fn default_paragraph_properties_round_trip_in_schema_order() {
575        let xml = format!(
576            "<a:lstStyle xmlns:a=\"{A_NS}\"><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>"
577        );
578        let unqualified = xml.replace("<a:defPPr", "<defPPr");
579        let opaque = CT_TextListStyle::from_xml(unqualified.as_bytes()).unwrap();
580        assert!(opaque.default_paragraph_properties.is_none());
581        assert!(
582            String::from_utf8(opaque.to_xml().unwrap())
583                .unwrap()
584                .contains("<defPPr marL=\"100\"/>")
585        );
586        let style = CT_TextListStyle::from_xml(xml.as_bytes()).unwrap();
587        assert_eq!(
588            style
589                .default_paragraph_properties
590                .as_ref()
591                .unwrap()
592                .left_margin,
593            Some(100)
594        );
595        let output = String::from_utf8(style.to_xml().unwrap()).unwrap();
596
597        assert_eq!(
598            output,
599            "<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>"
600        );
601    }
602
603    fn list_style(children: &str) -> CT_TextListStyle {
604        CT_TextListStyle::from_xml(
605            format!("<a:lstStyle xmlns:a=\"{A_NS}\">{children}</a:lstStyle>").as_bytes(),
606        )
607        .unwrap()
608    }
609
610    fn paragraph(xml: &str) -> CT_TextParagraphProperties {
611        CT_TextParagraphProperties::from_xml(with_namespace(xml).as_bytes()).unwrap()
612    }
613
614    fn character(xml: &str) -> CT_TextCharacterProperties {
615        CT_TextCharacterProperties::from_xml(with_namespace(xml).as_bytes()).unwrap()
616    }
617
618    fn with_namespace(xml: &str) -> String {
619        xml.replacen('>', &format!(" xmlns:a=\"{A_NS}\">"), 1)
620    }
621
622    struct Fixture {
623        theme: CT_OfficeStyleSheet,
624        master: CT_SlideMaster,
625        layout: CT_SlideLayout,
626        slide: CT_Slide,
627        default_text_style: CT_TextListStyle,
628    }
629
630    impl Fixture {
631        fn new(
632            default_style: &str,
633            master_style: &str,
634            master_placeholder: &str,
635            layout_placeholder: &str,
636            slide_styles: &[&str],
637        ) -> Self {
638            let slide_shapes = slide_styles
639                .iter()
640                .map(|style| shape(7, style))
641                .collect::<String>();
642            Self {
643                theme: CT_OfficeStyleSheet::office_default(),
644                master: CT_SlideMaster::from_xml(
645                    master_xml(&shape(7, master_placeholder), master_style).as_bytes(),
646                )
647                .unwrap(),
648                layout: CT_SlideLayout::from_xml(
649                    layout_xml(&shape(7, layout_placeholder)).as_bytes(),
650                )
651                .unwrap(),
652                slide: CT_Slide::from_xml(slide_xml(&slide_shapes).as_bytes()).unwrap(),
653                default_text_style: list_style(default_style),
654            }
655        }
656
657        fn context(&self) -> ResolveCtx<'_> {
658            ResolveCtx::new(
659                &self.theme,
660                ColorMap::default(),
661                &self.master,
662                &self.layout,
663                &self.slide,
664                &self.default_text_style,
665            )
666        }
667
668        fn slide_shape(&self, index: usize) -> &CT_Shape {
669            let ShapeTreeChild::Shape(shape) =
670                &self.slide.common_slide_data.shape_tree.children[index]
671            else {
672                panic!("expected slide shape");
673            };
674            shape
675        }
676    }
677
678    fn slide_xml(shapes: &str) -> String {
679        format!(
680            "<p:sld xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld></p:sld>",
681            shape_tree(shapes)
682        )
683    }
684
685    fn layout_xml(shapes: &str) -> String {
686        format!(
687            "<p:sldLayout xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld></p:sldLayout>",
688            shape_tree(shapes)
689        )
690    }
691
692    fn master_xml(shapes: &str, body_style: &str) -> String {
693        format!(
694            "<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>",
695            shape_tree(shapes)
696        )
697    }
698
699    fn shape_tree(shapes: &str) -> String {
700        format!("<p:spTree><p:nvGrpSpPr/><p:grpSpPr/>{shapes}</p:spTree>")
701    }
702
703    fn shape(index: u32, list_style: &str) -> String {
704        format!(
705            "<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>"
706        )
707    }
708    #[test]
709    fn fx206_language_inherits_by_occurrence_without_promoting_raw_source() {
710        let raw = br#"<q:rPr xmlns:q="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:x="urn:foreign" lang="tr-TR" x:lang="zz" x:kept="1"/>"#;
711        let props = CT_TextCharacterProperties::from_xml(raw).unwrap();
712        let before = props.clone();
713        assert_eq!(props.language(), Some("tr-TR"));
714        assert_eq!(props, before);
715        let mut effective = EffectiveTextProperties::default();
716        super::merge_character(&mut effective, &props);
717        assert_eq!(effective.language.as_deref(), Some("tr-TR"));
718        let absent = CT_TextCharacterProperties::from_xml(br#"<a:rPr/>"#).unwrap();
719        super::merge_character(&mut effective, &absent);
720        assert_eq!(effective.language.as_deref(), Some("tr-TR"));
721        let own = CT_TextCharacterProperties::from_xml(br#"<a:rPr lang="en-US"/>"#).unwrap();
722        super::merge_character(&mut effective, &own);
723        assert_eq!(effective.language.as_deref(), Some("en-US"));
724        assert_eq!(props, before);
725    }
726}