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};
30
31use body::{Result, missing_end};
32
33/// The `a:txBody` shell with typed body properties and opaque later text stages.
34#[allow(non_camel_case_types)]
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct CT_TextBody {
37    pub body_properties: CT_TextBodyProperties,
38    list_style: Option<CT_TextListStyle>,
39    paragraphs: Vec<CT_TextParagraph>,
40    raw_children: OrderedRawChildren,
41}
42
43impl Default for CT_TextBody {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl CT_TextBody {
50    /// Creates a minimal valid text body with one empty paragraph.
51    pub fn new() -> Self {
52        Self {
53            body_properties: CT_TextBodyProperties::default(),
54            list_style: None,
55            paragraphs: vec![CT_TextParagraph::default()],
56            raw_children: OrderedRawChildren::default(),
57        }
58    }
59
60    /// Parses a complete `a:txBody` while retaining later text stages.
61    pub fn from_xml(xml: &[u8]) -> Result<Self> {
62        Self::from_xml_as(xml, b"txBody")
63    }
64
65    /// Parses a complete text body under a caller-selected root local name.
66    pub fn from_xml_as(xml: &[u8], root_local_name: &[u8]) -> Result<Self> {
67        let mut reader = Reader::from_reader(xml);
68        let mut buffer = Vec::new();
69        loop {
70            match reader
71                .read_event_into(&mut buffer)
72                .map_err(OxmlError::from)?
73            {
74                Event::Start(element)
75                    if matches_local_name(element.name().as_ref(), root_local_name) =>
76                {
77                    return Self::from_element(&mut reader, root_local_name);
78                }
79                Event::Empty(element)
80                    if matches_local_name(element.name().as_ref(), root_local_name) =>
81                {
82                    return Err(TextError::MissingBodyProperties);
83                }
84                Event::Start(element) | Event::Empty(element) => {
85                    return Err(TextError::UnexpectedElement(element_name(&element)));
86                }
87                Event::Eof => {
88                    return Err(TextError::Xml(OxmlError::MissingElement(
89                        "DrawingML text body".to_owned(),
90                    )));
91                }
92                _ => {}
93            }
94            buffer.clear();
95        }
96    }
97
98    fn from_element(reader: &mut Reader<&[u8]>, root_local_name: &[u8]) -> Result<Self> {
99        let mut body_properties = None;
100        let mut list_style = None;
101        let mut paragraphs = Vec::new();
102        let mut raw_children = OrderedRawChildren::default();
103        let mut boundary = 0;
104        let mut buffer = Vec::new();
105
106        loop {
107            match reader
108                .read_event_into(&mut buffer)
109                .map_err(OxmlError::from)?
110            {
111                Event::Start(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
112                    if body_properties.is_some() {
113                        return Err(TextError::DuplicateElement("bodyPr".to_owned()));
114                    }
115                    body_properties = Some(CT_TextBodyProperties::from_element(reader, &element)?);
116                    boundary = boundary.max(1);
117                }
118                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
119                    if body_properties.is_some() {
120                        return Err(TextError::DuplicateElement("bodyPr".to_owned()));
121                    }
122                    body_properties = Some(CT_TextBodyProperties::from_start(&element)?);
123                    boundary = boundary.max(1);
124                }
125                Event::Start(element)
126                    if matches_local_name(element.name().as_ref(), b"lstStyle") =>
127                {
128                    if list_style.is_some() {
129                        return Err(TextError::DuplicateElement("lstStyle".to_owned()));
130                    }
131                    let raw = capture_element(reader, &element)?;
132                    list_style = Some(CT_TextListStyle::from_xml(&raw)?);
133                    boundary = boundary.max(2);
134                }
135                Event::Empty(element)
136                    if matches_local_name(element.name().as_ref(), b"lstStyle") =>
137                {
138                    if list_style.is_some() {
139                        return Err(TextError::DuplicateElement("lstStyle".to_owned()));
140                    }
141                    let raw = capture_empty_element(&element)?;
142                    list_style = Some(CT_TextListStyle::from_xml(&raw)?);
143                    boundary = boundary.max(2);
144                }
145                Event::Start(element) if matches_local_name(element.name().as_ref(), b"p") => {
146                    let raw = capture_element(reader, &element)?;
147                    paragraphs.push(CT_TextParagraph::from_xml(&raw)?);
148                    boundary = boundary.max(2 + paragraphs.len());
149                }
150                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"p") => {
151                    let raw = capture_empty_element(&element)?;
152                    paragraphs.push(CT_TextParagraph::from_xml(&raw)?);
153                    boundary = boundary.max(2 + paragraphs.len());
154                }
155                Event::Start(element) => {
156                    raw_children.push(boundary, capture_element(reader, &element)?)
157                }
158                Event::Empty(element) => {
159                    raw_children.push(boundary, capture_empty_element(&element)?)
160                }
161                Event::End(element)
162                    if matches_local_name(element.name().as_ref(), root_local_name) =>
163                {
164                    break;
165                }
166                Event::Eof => {
167                    return Err(missing_end(&String::from_utf8_lossy(root_local_name)));
168                }
169                _ => {}
170            }
171            buffer.clear();
172        }
173
174        let body_properties = body_properties.ok_or(TextError::MissingBodyProperties)?;
175        Ok(Self {
176            body_properties,
177            list_style,
178            paragraphs,
179            raw_children,
180        })
181    }
182
183    /// Writes the shell with canonical outer prefixes and schema order.
184    pub fn to_xml(&self) -> Result<Vec<u8>> {
185        let mut writer = Writer::new(Vec::new());
186        self.write_xml(&mut writer)?;
187        Ok(writer.into_inner())
188    }
189
190    /// Writes the shell into an existing XML writer.
191    pub fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
192        self.write_xml_as(writer, "a:txBody")
193    }
194
195    /// Writes the text body using a caller-selected OOXML wrapper tag.
196    pub fn write_xml_as<W: Write>(&self, writer: &mut Writer<W>, tag: &str) -> Result<()> {
197        writer
198            .write_event(Event::Start(BytesStart::new(tag)))
199            .map_err(OxmlError::from)?;
200        emit_raw(writer, self.raw_children.at(0))?;
201        self.body_properties.write_xml(writer)?;
202        emit_raw(writer, self.raw_children.at(1))?;
203        if let Some(list_style) = &self.list_style {
204            list_style.write_xml(writer)?;
205        }
206        emit_raw(writer, self.raw_children.at(2))?;
207        for (index, paragraph) in self.paragraphs.iter().enumerate() {
208            paragraph.write_xml(writer)?;
209            emit_raw(writer, self.raw_children.at(3 + index))?;
210        }
211        writer
212            .write_event(Event::End(BytesEnd::new(tag)))
213            .map_err(OxmlError::from)?;
214        Ok(())
215    }
216
217    /// Returns plain text in paragraph and text-content order.
218    pub fn plain_text(&self) -> String {
219        self.paragraphs
220            .iter()
221            .map(|paragraph| {
222                let mut text = String::new();
223                for run in &paragraph.runs {
224                    match run {
225                        TextRun::Run(run) => text.push_str(&run.text.value),
226                        TextRun::Break(_) => text.push('\n'),
227                        TextRun::Field(field) => {
228                            if let Some(value) = &field.text {
229                                text.push_str(&value.value);
230                            }
231                        }
232                    }
233                }
234                text
235            })
236            .collect::<Vec<_>>()
237            .join("\n")
238    }
239
240    pub fn has_list_style(&self) -> bool {
241        self.list_style.is_some()
242    }
243
244    pub fn list_style(&self) -> Option<&CT_TextListStyle> {
245        self.list_style.as_ref()
246    }
247
248    pub fn paragraph_count(&self) -> usize {
249        self.paragraphs.len()
250    }
251
252    pub fn paragraphs(&self) -> &[CT_TextParagraph] {
253        &self.paragraphs
254    }
255
256    /// Replaces all text content while retaining body-level state.
257    pub fn set_text(&mut self, text: &str) {
258        let old_paragraph_count = self.paragraphs.len();
259        let mut paragraph = self.paragraphs.drain(..).next().unwrap_or_default();
260        paragraph.set_text(text);
261        self.paragraphs.push(paragraph);
262
263        let mut raw_children = OrderedRawChildren::default();
264        for boundary in 0..=2 + old_paragraph_count {
265            let new_boundary = boundary.min(3);
266            for child in self.raw_children.at(boundary) {
267                raw_children.push(new_boundary, child.to_vec());
268            }
269        }
270        self.raw_children = raw_children;
271    }
272
273    /// Returns one paragraph for in-place mutation.
274    pub fn paragraph_mut(&mut self, index: usize) -> Option<&mut CT_TextParagraph> {
275        self.paragraphs.get_mut(index)
276    }
277
278    /// Appends one empty paragraph after all existing paragraphs.
279    pub fn add_paragraph(&mut self) -> &mut CT_TextParagraph {
280        self.raw_children
281            .shift_boundaries_from(2 + self.paragraphs.len());
282        self.paragraphs.push(CT_TextParagraph::default());
283        self.paragraphs.last_mut().expect("paragraph was appended")
284    }
285
286    /// Moves visible typed paragraphs to `destination`, retaining body-level
287    /// state and leaving one empty paragraph in this body.
288    pub fn move_content_to(&mut self, destination: &mut Self) {
289        if self.plain_text().is_empty() {
290            return;
291        }
292
293        let source_paragraph_count = self.paragraphs.len();
294        let moved = std::mem::take(&mut self.paragraphs);
295        self.paragraphs.push(CT_TextParagraph::default());
296        self.reconcile_paragraph_raw_children(source_paragraph_count, 1);
297
298        if destination.plain_text().is_empty() {
299            let destination_paragraph_count = destination.paragraphs.len();
300            destination.paragraphs = moved;
301            destination.reconcile_paragraph_raw_children(
302                destination_paragraph_count,
303                destination.paragraphs.len(),
304            );
305            return;
306        }
307
308        for paragraph in moved {
309            destination
310                .raw_children
311                .shift_boundaries_from(2 + destination.paragraphs.len());
312            destination.paragraphs.push(paragraph);
313        }
314    }
315
316    fn reconcile_paragraph_raw_children(
317        &mut self,
318        old_paragraph_count: usize,
319        new_paragraph_count: usize,
320    ) {
321        let mut raw_children = OrderedRawChildren::default();
322        for boundary in 0..=2 + old_paragraph_count {
323            let new_boundary = if boundary <= 2 {
324                boundary
325            } else {
326                2 + new_paragraph_count
327            };
328            for child in self.raw_children.at(boundary) {
329                raw_children.push(new_boundary, child.to_vec());
330            }
331        }
332        self.raw_children = raw_children;
333    }
334
335    pub fn raw_children(&self) -> &OrderedRawChildren {
336        &self.raw_children
337    }
338}
339
340fn emit_raw<'a, W: Write>(
341    writer: &mut Writer<W>,
342    children: impl Iterator<Item = &'a [u8]>,
343) -> Result<()> {
344    for child in children {
345        writer.get_mut().write_all(child).map_err(OxmlError::from)?;
346    }
347    Ok(())
348}
349
350fn element_name(element: &BytesStart<'_>) -> String {
351    String::from_utf8_lossy(element.name().as_ref()).into_owned()
352}
353
354#[cfg(test)]
355mod tests {
356    use std::panic;
357
358    use super::{CT_TextBody, CT_TextListStyle};
359
360    #[test]
361    fn text_body_reads_any_prefix_and_writes_the_fixed_a_prefix() {
362        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>"#;
363        let body = CT_TextBody::from_xml(xml).unwrap();
364        assert!(body.has_list_style());
365        assert_eq!(body.paragraph_count(), 1);
366        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>"#);
367    }
368
369    #[test]
370    fn moving_content_retains_typed_paragraphs_and_source_body_state() {
371        let mut destination = CT_TextBody::from_xml(
372            br#"<a:txBody><a:bodyPr/><a:p><a:r><a:rPr b="1"/><a:t>one</a:t></a:r></a:p></a:txBody>"#,
373        )
374        .unwrap();
375        let mut source = CT_TextBody::from_xml(
376            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>"#,
377        )
378        .unwrap();
379
380        source.move_content_to(&mut destination);
381
382        assert_eq!(destination.plain_text(), "one\ntwo\nthree");
383        assert_eq!(destination.paragraph_count(), 3);
384        assert_eq!(source.plain_text(), "");
385        assert_eq!(source.paragraph_count(), 1);
386        assert!(
387            String::from_utf8(source.to_xml().unwrap())
388                .unwrap()
389                .contains(r#"lIns="10""#)
390        );
391        let written = String::from_utf8(destination.to_xml().unwrap()).unwrap();
392        assert!(written.contains(r#"<a:rPr b="1"/>"#));
393        assert!(written.contains(r#"<a:rPr i="1"/>"#));
394    }
395
396    #[test]
397    fn schema_valid_text_body_using_all_nine_list_levels_round_trips_structurally() {
398        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>"#;
399        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>"#;
400
401        let body = CT_TextBody::from_xml(xml).unwrap();
402        let written = body.to_xml().unwrap();
403        assert_eq!(written, expected);
404        assert_eq!(CT_TextBody::from_xml(&written).unwrap(), body);
405    }
406
407    #[test]
408    fn list_style_levels_write_in_ascending_schema_order() {
409        let body = CT_TextBody::from_xml(
410            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>"#,
411        )
412        .unwrap();
413        assert_eq!(
414            body.to_xml().unwrap(),
415            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>"#
416        );
417    }
418
419    #[test]
420    fn unknown_list_style_children_round_trip_byte_for_byte() {
421        let body = CT_TextBody::from_xml(
422            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>"#,
423        )
424        .unwrap();
425        assert_eq!(
426            body.to_xml().unwrap(),
427            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>"#
428        );
429    }
430
431    #[test]
432    fn list_style_rejects_nested_fixed_prefix_rebinding() {
433        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>"#;
434        assert!(CT_TextListStyle::from_xml(xml).is_err());
435    }
436
437    #[test]
438    fn list_style_rejects_fixed_prefix_rebinding_at_typed_descendants() {
439        let descendants: &[&[u8]] = &[
440            br#"<d:defRPr xmlns:a="urn:producer"/>"#,
441            br#"<d:spcBef xmlns:a="urn:producer"><d:spcPts val="600"/></d:spcBef>"#,
442            br#"<d:spcBef><d:spcPts xmlns:a="urn:producer" val="600"/></d:spcBef>"#,
443            br#"<d:buChar xmlns:a="urn:producer" char="*"/>"#,
444            br#"<d:buClr xmlns:a="urn:producer"><d:srgbClr val="102030"/></d:buClr>"#,
445            br#"<d:buClr><d:srgbClr xmlns:a="urn:producer" val="102030"/></d:buClr>"#,
446            br#"<d:defRPr><d:solidFill xmlns:a="urn:producer"><d:srgbClr val="102030"/></d:solidFill></d:defRPr>"#,
447            br#"<d:defRPr><d:solidFill><d:srgbClr xmlns:a="urn:producer" val="102030"/></d:solidFill></d:defRPr>"#,
448            br#"<d:defRPr><d:solidFill><d:srgbClr val="102030"><d:alpha xmlns:a="urn:producer" val="50000"/></d:srgbClr></d:solidFill></d:defRPr>"#,
449        ];
450
451        for descendant in descendants {
452            let mut xml = br#"<p:defaultTextStyle xmlns:p="urn:presentation" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><d:lvl1pPr>"#.to_vec();
453            xml.extend_from_slice(descendant);
454            xml.extend_from_slice(br#"</d:lvl1pPr></p:defaultTextStyle>"#);
455            assert!(
456                CT_TextListStyle::from_xml(&xml).is_err(),
457                "typed descendant accepted a conflicting xmlns:a: {}",
458                String::from_utf8_lossy(descendant)
459            );
460        }
461    }
462
463    #[test]
464    fn opaque_list_style_child_preserves_its_local_prefix_binding() {
465        let opaque = br#"<x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension>"#;
466        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>"#;
467        let parsed = CT_TextListStyle::from_xml(xml).unwrap();
468        let written = parsed.to_xml().unwrap();
469        assert!(
470            written
471                .windows(opaque.len())
472                .any(|window| window == opaque.as_slice())
473        );
474    }
475
476    #[test]
477    fn opaque_typed_descendants_preserve_their_local_prefix_bindings() {
478        let character_extension = br#"<x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension>"#;
479        let transform_with_content =
480            br#"<d:alpha xmlns:a="urn:producer" val="50000"><a:data/></d:alpha>"#;
481        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>"#;
482
483        let written = CT_TextListStyle::from_xml(xml).unwrap().to_xml().unwrap();
484        for opaque in [
485            character_extension.as_slice(),
486            transform_with_content.as_slice(),
487        ] {
488            assert!(
489                written.windows(opaque.len()).any(|window| window == opaque),
490                "opaque descendant was not preserved: {}",
491                String::from_utf8_lossy(opaque)
492            );
493        }
494    }
495
496    #[test]
497    fn invalid_list_levels_return_errors_without_panicking() {
498        let cases: &[&[u8]] = &[
499            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl0pPr/></q:lstStyle><q:p/></q:txBody>"#,
500            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl10pPr/></q:lstStyle><q:p/></q:txBody>"#,
501            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl01pPr/></q:lstStyle><q:p/></q:txBody>"#,
502            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl1pPr/><q:lvl1pPr/></q:lstStyle><q:p/></q:txBody>"#,
503            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl4pPr lvl="9"/></q:lstStyle><q:p/></q:txBody>"#,
504            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl7pPr><q:buChar/></q:lvl7pPr></q:lstStyle><q:p/></q:txBody>"#,
505        ];
506
507        for xml in cases {
508            let result = panic::catch_unwind(|| CT_TextBody::from_xml(xml));
509            assert!(result.is_ok(), "list-style parser panicked");
510            assert!(result.unwrap().is_err(), "invalid list level parsed");
511        }
512    }
513}