Skip to main content

oxml_drawing/text/
mod.rs

1use std::io::Write;
2
3use oxml_core::OxmlError;
4use oxml_core::raw_xml::{capture_element, capture_empty_element};
5use oxml_core::xml::matches_local_name;
6use quick_xml::events::{BytesEnd, BytesStart, Event};
7use quick_xml::{Reader, Writer};
8
9use crate::order::OrderedRawChildren;
10
11pub mod body;
12pub mod bullet;
13pub mod list_style;
14pub mod paragraph;
15
16pub use body::{
17    CT_TextBodyProperties, Coordinate32Value, NormalAutofit, TextAnchor, TextAutofit, TextError,
18    TextVertical, TextWrap,
19};
20pub use bullet::{
21    TextAutoNumber, TextAutoNumberScheme, TextBullet, TextBulletCharacter, TextBulletChoice,
22    TextBulletColor, TextBulletSize, TextBulletSizeValue, TextNoBullet,
23};
24pub use list_style::CT_TextListStyle;
25pub use paragraph::{
26    CT_RegularTextRun, CT_TextCharacterProperties, CT_TextField, CT_TextLineBreak,
27    CT_TextParagraph, CT_TextParagraphProperties, TextAlignment, TextFont, TextHyperlink,
28    TextPointValue, TextRun, TextSpace, TextSpacing, TextStrike, TextUnderline, TextValue,
29    escape_invalid_xml_characters,
30};
31
32use body::{Result, missing_end};
33
34/// The `a:txBody` shell with typed body properties and opaque later text stages.
35#[allow(non_camel_case_types)]
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct CT_TextBody {
38    pub body_properties: CT_TextBodyProperties,
39    list_style: Option<CT_TextListStyle>,
40    paragraphs: Vec<CT_TextParagraph>,
41    raw_children: OrderedRawChildren,
42}
43
44impl Default for CT_TextBody {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl CT_TextBody {
51    /// Creates a minimal valid text body with one empty paragraph.
52    pub fn new() -> Self {
53        Self {
54            body_properties: CT_TextBodyProperties::default(),
55            list_style: None,
56            paragraphs: vec![CT_TextParagraph::default()],
57            raw_children: OrderedRawChildren::default(),
58        }
59    }
60
61    /// Parses a complete `a:txBody` while retaining later text stages.
62    pub fn from_xml(xml: &[u8]) -> Result<Self> {
63        Self::from_xml_as(xml, b"txBody")
64    }
65
66    /// Parses a complete text body under a caller-selected root local name.
67    pub fn from_xml_as(xml: &[u8], root_local_name: &[u8]) -> Result<Self> {
68        let mut reader = Reader::from_reader(xml);
69        let mut buffer = Vec::new();
70        loop {
71            match reader
72                .read_event_into(&mut buffer)
73                .map_err(OxmlError::from)?
74            {
75                Event::Start(element)
76                    if matches_local_name(element.name().as_ref(), root_local_name) =>
77                {
78                    return Self::from_element(&mut reader, root_local_name);
79                }
80                Event::Empty(element)
81                    if matches_local_name(element.name().as_ref(), root_local_name) =>
82                {
83                    return Err(TextError::MissingBodyProperties);
84                }
85                Event::Start(element) | Event::Empty(element) => {
86                    return Err(TextError::UnexpectedElement(element_name(&element)));
87                }
88                Event::Eof => {
89                    return Err(TextError::Xml(OxmlError::MissingElement(
90                        "DrawingML text body".to_owned(),
91                    )));
92                }
93                _ => {}
94            }
95            buffer.clear();
96        }
97    }
98
99    fn from_element(reader: &mut Reader<&[u8]>, root_local_name: &[u8]) -> Result<Self> {
100        let mut body_properties = None;
101        let mut list_style = None;
102        let mut paragraphs = Vec::new();
103        let mut raw_children = OrderedRawChildren::default();
104        let mut boundary = 0;
105        let mut buffer = Vec::new();
106
107        loop {
108            match reader
109                .read_event_into(&mut buffer)
110                .map_err(OxmlError::from)?
111            {
112                Event::Start(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
113                    if body_properties.is_some() {
114                        return Err(TextError::DuplicateElement("bodyPr".to_owned()));
115                    }
116                    body_properties = Some(CT_TextBodyProperties::from_element(reader, &element)?);
117                    boundary = boundary.max(1);
118                }
119                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
120                    if body_properties.is_some() {
121                        return Err(TextError::DuplicateElement("bodyPr".to_owned()));
122                    }
123                    body_properties = Some(CT_TextBodyProperties::from_start(&element)?);
124                    boundary = boundary.max(1);
125                }
126                Event::Start(element)
127                    if matches_local_name(element.name().as_ref(), b"lstStyle") =>
128                {
129                    if list_style.is_some() {
130                        return Err(TextError::DuplicateElement("lstStyle".to_owned()));
131                    }
132                    let raw = capture_element(reader, &element)?;
133                    list_style = Some(CT_TextListStyle::from_xml(&raw)?);
134                    boundary = boundary.max(2);
135                }
136                Event::Empty(element)
137                    if matches_local_name(element.name().as_ref(), b"lstStyle") =>
138                {
139                    if list_style.is_some() {
140                        return Err(TextError::DuplicateElement("lstStyle".to_owned()));
141                    }
142                    let raw = capture_empty_element(&element)?;
143                    list_style = Some(CT_TextListStyle::from_xml(&raw)?);
144                    boundary = boundary.max(2);
145                }
146                Event::Start(element) if matches_local_name(element.name().as_ref(), b"p") => {
147                    let raw = capture_element(reader, &element)?;
148                    paragraphs.push(CT_TextParagraph::from_xml(&raw)?);
149                    boundary = boundary.max(2 + paragraphs.len());
150                }
151                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"p") => {
152                    let raw = capture_empty_element(&element)?;
153                    paragraphs.push(CT_TextParagraph::from_xml(&raw)?);
154                    boundary = boundary.max(2 + paragraphs.len());
155                }
156                Event::Start(element) => {
157                    raw_children.push(boundary, capture_element(reader, &element)?)
158                }
159                Event::Empty(element) => {
160                    raw_children.push(boundary, capture_empty_element(&element)?)
161                }
162                Event::End(element)
163                    if matches_local_name(element.name().as_ref(), root_local_name) =>
164                {
165                    break;
166                }
167                Event::Eof => {
168                    return Err(missing_end(&String::from_utf8_lossy(root_local_name)));
169                }
170                _ => {}
171            }
172            buffer.clear();
173        }
174
175        let body_properties = body_properties.ok_or(TextError::MissingBodyProperties)?;
176        Ok(Self {
177            body_properties,
178            list_style,
179            paragraphs,
180            raw_children,
181        })
182    }
183
184    /// Writes the shell with canonical outer prefixes and schema order.
185    pub fn to_xml(&self) -> Result<Vec<u8>> {
186        let mut writer = Writer::new(Vec::new());
187        self.write_xml(&mut writer)?;
188        Ok(writer.into_inner())
189    }
190
191    /// Writes the shell into an existing XML writer.
192    pub fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
193        self.write_xml_as(writer, "a:txBody")
194    }
195
196    /// Writes the text body using a caller-selected OOXML wrapper tag.
197    pub fn write_xml_as<W: Write>(&self, writer: &mut Writer<W>, tag: &str) -> Result<()> {
198        writer
199            .write_event(Event::Start(BytesStart::new(tag)))
200            .map_err(OxmlError::from)?;
201        emit_raw(writer, self.raw_children.at(0))?;
202        self.body_properties.write_xml(writer)?;
203        emit_raw(writer, self.raw_children.at(1))?;
204        if let Some(list_style) = &self.list_style {
205            list_style.write_xml(writer)?;
206        }
207        emit_raw(writer, self.raw_children.at(2))?;
208        for (index, paragraph) in self.paragraphs.iter().enumerate() {
209            paragraph.write_xml(writer)?;
210            emit_raw(writer, self.raw_children.at(3 + index))?;
211        }
212        writer
213            .write_event(Event::End(BytesEnd::new(tag)))
214            .map_err(OxmlError::from)?;
215        Ok(())
216    }
217
218    /// Returns plain text in paragraph and text-content order.
219    pub fn plain_text(&self) -> String {
220        self.paragraphs
221            .iter()
222            .map(|paragraph| {
223                let mut text = String::new();
224                for run in &paragraph.runs {
225                    match run {
226                        TextRun::Run(run) => text.push_str(&run.text.value),
227                        TextRun::Break(_) => text.push('\n'),
228                        TextRun::Field(field) => {
229                            if let Some(value) = &field.text {
230                                text.push_str(&value.value);
231                            }
232                        }
233                    }
234                }
235                text
236            })
237            .collect::<Vec<_>>()
238            .join("\n")
239    }
240
241    pub fn has_list_style(&self) -> bool {
242        self.list_style.is_some()
243    }
244
245    pub fn list_style(&self) -> Option<&CT_TextListStyle> {
246        self.list_style.as_ref()
247    }
248
249    pub fn paragraph_count(&self) -> usize {
250        self.paragraphs.len()
251    }
252
253    pub fn paragraphs(&self) -> &[CT_TextParagraph] {
254        &self.paragraphs
255    }
256
257    /// Replaces all text content while retaining body-level state.
258    ///
259    /// Each line feed or CRLF pair starts a paragraph, and a vertical tab
260    /// creates an `a:br`. New paragraphs copy the first paragraph's formatting.
261    pub fn set_text(&mut self, text: &str) {
262        let old_paragraph_count = self.paragraphs.len();
263        let mut first = self.paragraphs.drain(..).next().unwrap_or_default();
264        let mut lines = text.split("\r\n").flat_map(|part| part.split('\n'));
265        first.set_text(lines.next().unwrap_or_default());
266        let mut template = CT_TextParagraph::default();
267        template.properties = first.properties.clone();
268        template.runs = first.runs.iter().take(1).cloned().collect();
269        template.end_properties = first.end_properties.clone();
270        self.paragraphs.push(first);
271        for line in lines {
272            let mut paragraph = template.clone();
273            paragraph.set_text(line);
274            self.paragraphs.push(paragraph);
275        }
276        self.reconcile_paragraph_raw_children(old_paragraph_count, self.paragraphs.len());
277    }
278
279    /// Returns an empty body that keeps this body's properties and list style
280    /// and the formatting of its first paragraph, as PowerPoint fills the
281    /// cells of a new table row or column.
282    ///
283    /// The one paragraph keeps the first paragraph's properties. The
284    /// character properties of that paragraph's first regular run, or its end
285    /// properties when the run is missing or has none, become the new end
286    /// properties without their hyperlinks. Unmodelled body and paragraph
287    /// children are not copied.
288    pub fn empty_like(&self) -> Self {
289        let mut paragraph = CT_TextParagraph::default();
290        if let Some(first) = self.paragraphs.first() {
291            let first_run = first.runs.iter().find_map(|run| match run {
292                TextRun::Run(run) => Some(run),
293                TextRun::Break(_) | TextRun::Field(_) => None,
294            });
295            paragraph.properties = first.properties.clone();
296            paragraph.end_properties = first_run
297                .and_then(|run| run.properties.clone())
298                .or_else(|| first.end_properties.clone())
299                .map(CT_TextCharacterProperties::without_hyperlinks);
300        }
301        Self {
302            body_properties: self.body_properties.clone(),
303            list_style: self.list_style.clone(),
304            paragraphs: vec![paragraph],
305            raw_children: OrderedRawChildren::default(),
306        }
307    }
308
309    /// Returns one paragraph for in-place mutation.
310    pub fn paragraph_mut(&mut self, index: usize) -> Option<&mut CT_TextParagraph> {
311        self.paragraphs.get_mut(index)
312    }
313
314    /// Appends one empty paragraph after all existing paragraphs.
315    pub fn add_paragraph(&mut self) -> &mut CT_TextParagraph {
316        self.raw_children
317            .shift_boundaries_from(2 + self.paragraphs.len());
318        self.paragraphs.push(CT_TextParagraph::default());
319        self.paragraphs.last_mut().expect("paragraph was appended")
320    }
321
322    /// Moves visible typed paragraphs to `destination`, retaining body-level
323    /// state and leaving one empty paragraph in this body.
324    pub fn move_content_to(&mut self, destination: &mut Self) {
325        if self.plain_text().is_empty() {
326            return;
327        }
328
329        let source_paragraph_count = self.paragraphs.len();
330        let moved = std::mem::take(&mut self.paragraphs);
331        self.paragraphs.push(CT_TextParagraph::default());
332        self.reconcile_paragraph_raw_children(source_paragraph_count, 1);
333
334        if destination.plain_text().is_empty() {
335            let destination_paragraph_count = destination.paragraphs.len();
336            destination.paragraphs = moved;
337            destination.reconcile_paragraph_raw_children(
338                destination_paragraph_count,
339                destination.paragraphs.len(),
340            );
341            return;
342        }
343
344        for paragraph in moved {
345            destination
346                .raw_children
347                .shift_boundaries_from(2 + destination.paragraphs.len());
348            destination.paragraphs.push(paragraph);
349        }
350    }
351
352    fn reconcile_paragraph_raw_children(
353        &mut self,
354        old_paragraph_count: usize,
355        new_paragraph_count: usize,
356    ) {
357        let mut raw_children = OrderedRawChildren::default();
358        for boundary in 0..=2 + old_paragraph_count {
359            let new_boundary = if boundary <= 2 {
360                boundary
361            } else {
362                2 + new_paragraph_count
363            };
364            for child in self.raw_children.at(boundary) {
365                raw_children.push(new_boundary, child.to_vec());
366            }
367        }
368        self.raw_children = raw_children;
369    }
370
371    pub fn raw_children(&self) -> &OrderedRawChildren {
372        &self.raw_children
373    }
374}
375
376fn emit_raw<'a, W: Write>(
377    writer: &mut Writer<W>,
378    children: impl Iterator<Item = &'a [u8]>,
379) -> Result<()> {
380    for child in children {
381        writer.get_mut().write_all(child).map_err(OxmlError::from)?;
382    }
383    Ok(())
384}
385
386fn element_name(element: &BytesStart<'_>) -> String {
387    String::from_utf8_lossy(element.name().as_ref()).into_owned()
388}
389
390#[cfg(test)]
391mod tests {
392    use std::panic;
393
394    use super::{CT_TextBody, CT_TextListStyle};
395
396    #[test]
397    fn text_body_reads_any_prefix_and_writes_the_fixed_a_prefix() {
398        let xml = br#"<q:txBody><x:before/><q:bodyPr anchor="ctr"><q:noAutofit/></q:bodyPr><x:afterBody/><q:lstStyle><x:listChild/></q:lstStyle><x:beforeParagraph/><q:p><x:run>kept</x:run></q:p><x:afterParagraph/></q:txBody>"#;
399        let body = CT_TextBody::from_xml(xml).unwrap();
400        assert!(body.has_list_style());
401        assert_eq!(body.paragraph_count(), 1);
402        assert_eq!(body.to_xml().unwrap(), br#"<a:txBody><x:before/><a:bodyPr anchor="ctr"><a:noAutofit/></a:bodyPr><x:afterBody/><a:lstStyle><x:listChild/></a:lstStyle><x:beforeParagraph/><a:p><x:run>kept</x:run></a:p><x:afterParagraph/></a:txBody>"#);
403    }
404
405    #[test]
406    fn moving_content_retains_typed_paragraphs_and_source_body_state() {
407        let mut destination = CT_TextBody::from_xml(
408            br#"<a:txBody><a:bodyPr/><a:p><a:r><a:rPr b="1"/><a:t>one</a:t></a:r></a:p></a:txBody>"#,
409        )
410        .unwrap();
411        let mut source = CT_TextBody::from_xml(
412            br#"<a:txBody><a:bodyPr lIns="10"/><a:p><a:r><a:rPr i="1"/><a:t>two</a:t></a:r></a:p><a:p><a:r><a:t>three</a:t></a:r></a:p></a:txBody>"#,
413        )
414        .unwrap();
415
416        source.move_content_to(&mut destination);
417
418        assert_eq!(destination.plain_text(), "one\ntwo\nthree");
419        assert_eq!(destination.paragraph_count(), 3);
420        assert_eq!(source.plain_text(), "");
421        assert_eq!(source.paragraph_count(), 1);
422        assert!(
423            String::from_utf8(source.to_xml().unwrap())
424                .unwrap()
425                .contains(r#"lIns="10""#)
426        );
427        let written = String::from_utf8(destination.to_xml().unwrap()).unwrap();
428        assert!(written.contains(r#"<a:rPr b="1"/>"#));
429        assert!(written.contains(r#"<a:rPr i="1"/>"#));
430    }
431
432    #[test]
433    fn set_text_starts_a_paragraph_at_each_line_feed_and_breaks_at_each_vertical_tab() {
434        let mut body = CT_TextBody::from_xml(
435            br#"<a:txBody xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:x="urn:x"><a:bodyPr/><a:p><a:pPr algn="ctr"/><a:r><a:rPr b="1"/><a:t>old</a:t></a:r><x:in-paragraph/><a:endParaRPr sz="1200"/></a:p><a:p><a:r><a:t>gone</a:t></a:r></a:p><x:after-paragraphs/></a:txBody>"#,
436        )
437        .unwrap();
438        body.set_text("one\r\ntwo\u{b}soft\u{b}\n");
439        assert_eq!(
440            String::from_utf8(body.to_xml().unwrap()).unwrap(),
441            concat!(
442                r#"<a:txBody><a:bodyPr/>"#,
443                r#"<a:p><a:pPr algn="ctr"/><a:r><a:rPr b="1"/><a:t>one</a:t></a:r><x:in-paragraph/><a:endParaRPr sz="1200"/></a:p>"#,
444                r#"<a:p><a:pPr algn="ctr"/><a:r><a:rPr b="1"/><a:t>two</a:t></a:r><a:br><a:rPr b="1"/></a:br><a:r><a:rPr b="1"/><a:t>soft</a:t></a:r><a:br><a:rPr b="1"/></a:br><a:endParaRPr sz="1200"/></a:p>"#,
445                r#"<a:p><a:pPr algn="ctr"/><a:r><a:rPr b="1"/><a:t/></a:r><a:endParaRPr sz="1200"/></a:p>"#,
446                r#"<x:after-paragraphs/></a:txBody>"#,
447            )
448        );
449        assert_eq!(body.plain_text(), "one\ntwo\nsoft\n\n");
450
451        let mut plain = CT_TextBody::new();
452        plain.set_text("single line");
453        assert_eq!(plain.paragraph_count(), 1);
454        assert_eq!(plain.plain_text(), "single line");
455    }
456
457    #[test]
458    fn schema_valid_text_body_using_all_nine_list_levels_round_trips_structurally() {
459        let xml = br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl1pPr lvl="0"><q:buChar char="*"/></q:lvl1pPr><q:lvl2pPr marL="100"><q:buAutoNum type="arabicPeriod" startAt="2"/></q:lvl2pPr><q:lvl3pPr><q:buNone/></q:lvl3pPr><q:lvl4pPr><q:defRPr sz="1200"/></q:lvl4pPr><q:lvl5pPr algn="ctr"/><x:extension x:id="5"><x:child>one &amp; two</x:child></x:extension><q:lvl6pPr marR="200"/><q:lvl7pPr><q:spcBef><q:spcPts val="600"/></q:spcBef></q:lvl7pPr><q:lvl8pPr><q:buSzPct val="125000"/><q:buFont typeface="Wingdings"/><q:buChar char="o"/></q:lvl8pPr><q:lvl9pPr indent="-100"/></q:lstStyle><q:p><q:pPr lvl="1"/><q:r><q:t xml:space="preserve"> item </q:t></q:r></q:p></q:txBody>"#;
460        let expected = br#"<a:txBody><a:bodyPr/><a:lstStyle><a:lvl1pPr lvl="0"><a:buChar char="*"/></a:lvl1pPr><a:lvl2pPr marL="100"><a:buAutoNum type="arabicPeriod" startAt="2"/></a:lvl2pPr><a:lvl3pPr><a:buNone/></a:lvl3pPr><a:lvl4pPr><a:defRPr sz="1200"/></a:lvl4pPr><a:lvl5pPr algn="ctr"/><x:extension x:id="5"><x:child>one &amp; two</x:child></x:extension><a:lvl6pPr marR="200"/><a:lvl7pPr><a:spcBef><a:spcPts val="600"/></a:spcBef></a:lvl7pPr><a:lvl8pPr><a:buSzPct val="125000"/><a:buFont typeface="Wingdings"/><a:buChar char="o"/></a:lvl8pPr><a:lvl9pPr indent="-100"/></a:lstStyle><a:p><a:pPr lvl="1"/><a:r><a:t xml:space="preserve"> item </a:t></a:r></a:p></a:txBody>"#;
461
462        let body = CT_TextBody::from_xml(xml).unwrap();
463        let written = body.to_xml().unwrap();
464        assert_eq!(written, expected);
465        assert_eq!(CT_TextBody::from_xml(&written).unwrap(), body);
466    }
467
468    #[test]
469    fn list_style_levels_write_in_ascending_schema_order() {
470        let body = CT_TextBody::from_xml(
471            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl9pPr indent="-9"/><q:lvl5pPr indent="-5"/><q:lvl1pPr indent="-1"/></q:lstStyle><q:p/></q:txBody>"#,
472        )
473        .unwrap();
474        assert_eq!(
475            body.to_xml().unwrap(),
476            br#"<a:txBody><a:bodyPr/><a:lstStyle><a:lvl1pPr indent="-1"/><a:lvl5pPr indent="-5"/><a:lvl9pPr indent="-9"/></a:lstStyle><a:p/></a:txBody>"#
477        );
478    }
479
480    #[test]
481    fn unknown_list_style_children_round_trip_byte_for_byte() {
482        let body = CT_TextBody::from_xml(
483            br#"<q:txBody><q:bodyPr/><q:lstStyle><x:before x:id="1"/><q:lvl1pPr/><x:between><x:nested>one &amp; two</x:nested><!--note--></x:between><q:lvl2pPr/><x:after x:id="9"/></q:lstStyle><q:p/></q:txBody>"#,
484        )
485        .unwrap();
486        assert_eq!(
487            body.to_xml().unwrap(),
488            br#"<a:txBody><a:bodyPr/><a:lstStyle><x:before x:id="1"/><a:lvl1pPr/><x:between><x:nested>one &amp; two</x:nested><!--note--></x:between><a:lvl2pPr/><x:after x:id="9"/></a:lstStyle><a:p/></a:txBody>"#
489        );
490    }
491
492    #[test]
493    fn list_style_rejects_nested_fixed_prefix_rebinding() {
494        let xml = br#"<p:defaultTextStyle xmlns:p="urn:presentation" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><d:lvl1pPr xmlns:a="urn:producer"><a:raw/></d:lvl1pPr></p:defaultTextStyle>"#;
495        assert!(CT_TextListStyle::from_xml(xml).is_err());
496    }
497
498    #[test]
499    fn list_style_rejects_fixed_prefix_rebinding_at_typed_descendants() {
500        let descendants: &[&[u8]] = &[
501            br#"<d:defRPr xmlns:a="urn:producer"/>"#,
502            br#"<d:spcBef xmlns:a="urn:producer"><d:spcPts val="600"/></d:spcBef>"#,
503            br#"<d:spcBef><d:spcPts xmlns:a="urn:producer" val="600"/></d:spcBef>"#,
504            br#"<d:buChar xmlns:a="urn:producer" char="*"/>"#,
505            br#"<d:buClr xmlns:a="urn:producer"><d:srgbClr val="102030"/></d:buClr>"#,
506            br#"<d:buClr><d:srgbClr xmlns:a="urn:producer" val="102030"/></d:buClr>"#,
507            br#"<d:defRPr><d:solidFill xmlns:a="urn:producer"><d:srgbClr val="102030"/></d:solidFill></d:defRPr>"#,
508            br#"<d:defRPr><d:solidFill><d:srgbClr xmlns:a="urn:producer" val="102030"/></d:solidFill></d:defRPr>"#,
509            br#"<d:defRPr><d:solidFill><d:srgbClr val="102030"><d:alpha xmlns:a="urn:producer" val="50000"/></d:srgbClr></d:solidFill></d:defRPr>"#,
510        ];
511
512        for descendant in descendants {
513            let mut xml = br#"<p:defaultTextStyle xmlns:p="urn:presentation" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><d:lvl1pPr>"#.to_vec();
514            xml.extend_from_slice(descendant);
515            xml.extend_from_slice(br#"</d:lvl1pPr></p:defaultTextStyle>"#);
516            assert!(
517                CT_TextListStyle::from_xml(&xml).is_err(),
518                "typed descendant accepted a conflicting xmlns:a: {}",
519                String::from_utf8_lossy(descendant)
520            );
521        }
522    }
523
524    #[test]
525    fn opaque_list_style_child_preserves_its_local_prefix_binding() {
526        let opaque = br#"<x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension>"#;
527        let xml = br#"<p:defaultTextStyle xmlns:p="urn:presentation" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension><d:lvl1pPr/></p:defaultTextStyle>"#;
528        let parsed = CT_TextListStyle::from_xml(xml).unwrap();
529        let written = parsed.to_xml().unwrap();
530        assert!(
531            written
532                .windows(opaque.len())
533                .any(|window| window == opaque.as_slice())
534        );
535    }
536
537    #[test]
538    fn opaque_typed_descendants_preserve_their_local_prefix_bindings() {
539        let character_extension = br#"<x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension>"#;
540        let transform_with_content =
541            br#"<d:alpha xmlns:a="urn:producer" val="50000"><a:data/></d:alpha>"#;
542        let xml = br#"<p:defaultTextStyle xmlns:p="urn:presentation" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><d:lvl1pPr><d:defRPr><x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension><d:solidFill><d:srgbClr val="102030"><d:alpha xmlns:a="urn:producer" val="50000"><a:data/></d:alpha></d:srgbClr></d:solidFill></d:defRPr></d:lvl1pPr></p:defaultTextStyle>"#;
543
544        let written = CT_TextListStyle::from_xml(xml).unwrap().to_xml().unwrap();
545        for opaque in [
546            character_extension.as_slice(),
547            transform_with_content.as_slice(),
548        ] {
549            assert!(
550                written.windows(opaque.len()).any(|window| window == opaque),
551                "opaque descendant was not preserved: {}",
552                String::from_utf8_lossy(opaque)
553            );
554        }
555    }
556
557    #[test]
558    fn invalid_list_levels_return_errors_without_panicking() {
559        let cases: &[&[u8]] = &[
560            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl0pPr/></q:lstStyle><q:p/></q:txBody>"#,
561            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl10pPr/></q:lstStyle><q:p/></q:txBody>"#,
562            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl01pPr/></q:lstStyle><q:p/></q:txBody>"#,
563            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl1pPr/><q:lvl1pPr/></q:lstStyle><q:p/></q:txBody>"#,
564            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl4pPr lvl="9"/></q:lstStyle><q:p/></q:txBody>"#,
565            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl7pPr><q:buChar/></q:lvl7pPr></q:lstStyle><q:p/></q:txBody>"#,
566        ];
567
568        for xml in cases {
569            let result = panic::catch_unwind(|| CT_TextBody::from_xml(xml));
570            assert!(result.is_ok(), "list-style parser panicked");
571            assert!(result.unwrap().is_err(), "invalid list level parsed");
572        }
573    }
574}