Skip to main content

oxml_drawing/text/
body.rs

1use std::fmt;
2use std::io::Write;
3
4use oxml_core::OxmlError;
5use oxml_core::raw_xml::{capture_element, capture_empty_element};
6use oxml_core::xml::{get_attr, local_name, matches_local_name};
7use quick_xml::events::{BytesEnd, BytesStart, Event};
8use quick_xml::{Reader, Writer};
9
10use crate::color::ColorError;
11use crate::fill::FillError;
12use crate::order::OrderedRawChildren;
13
14const MAX_TEXT_SPACING_PERCENT: i32 = 13_200_000;
15
16/// Errors produced while parsing or writing DrawingML text shells.
17#[derive(Debug)]
18pub enum TextError {
19    Xml(OxmlError),
20    Color(ColorError),
21    Fill(FillError),
22    UnexpectedElement(String),
23    MissingAttribute {
24        element: String,
25        attribute: String,
26    },
27    MissingBodyProperties,
28    MissingParagraph,
29    DuplicateElement(String),
30    InvalidAttribute {
31        element: String,
32        attribute: String,
33        value: String,
34    },
35}
36
37impl fmt::Display for TextError {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::Xml(error) => error.fmt(formatter),
41            Self::Color(error) => error.fmt(formatter),
42            Self::Fill(error) => error.fmt(formatter),
43            Self::UnexpectedElement(element) => {
44                write!(formatter, "unexpected DrawingML text element: {element}")
45            }
46            Self::MissingAttribute { element, attribute } => {
47                write!(formatter, "DrawingML {element} requires @{attribute}")
48            }
49            Self::MissingBodyProperties => write!(formatter, "DrawingML txBody requires bodyPr"),
50            Self::MissingParagraph => write!(formatter, "DrawingML txBody requires at least one p"),
51            Self::DuplicateElement(element) => {
52                write!(formatter, "DrawingML text contains duplicate {element}")
53            }
54            Self::InvalidAttribute {
55                element,
56                attribute,
57                value,
58            } => write!(
59                formatter,
60                "DrawingML {element} has invalid @{attribute}: {value}"
61            ),
62        }
63    }
64}
65
66impl std::error::Error for TextError {
67    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
68        match self {
69            Self::Xml(error) => Some(error),
70            Self::Color(error) => Some(error),
71            Self::Fill(error) => Some(error),
72            _ => None,
73        }
74    }
75}
76
77impl From<OxmlError> for TextError {
78    fn from(error: OxmlError) -> Self {
79        Self::Xml(error)
80    }
81}
82
83impl From<ColorError> for TextError {
84    fn from(error: ColorError) -> Self {
85        Self::Color(error)
86    }
87}
88
89impl From<FillError> for TextError {
90    fn from(error: FillError) -> Self {
91        Self::Fill(error)
92    }
93}
94
95pub type Result<T> = std::result::Result<T, TextError>;
96
97/// One strict or transitional lexical form of `ST_Coordinate32`.
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub enum Coordinate32Value {
100    Emu(i32),
101    UniversalMeasure(String),
102}
103
104impl Coordinate32Value {
105    fn parse(element: &str, attribute: &str, value: String) -> Result<Self> {
106        if let Ok(value) = value.parse::<i32>() {
107            return Ok(Self::Emu(value));
108        }
109        if is_universal_measure(&value) {
110            return Ok(Self::UniversalMeasure(value));
111        }
112        Err(invalid_attribute(element, attribute, value))
113    }
114
115    fn as_xml(&self) -> Result<String> {
116        match self {
117            Self::Emu(value) => Ok(value.to_string()),
118            Self::UniversalMeasure(value) if is_universal_measure(value) => Ok(value.clone()),
119            Self::UniversalMeasure(value) => {
120                Err(invalid_attribute("bodyPr", "inset", value.clone()))
121            }
122        }
123    }
124}
125
126/// Vertical anchoring inside the text rectangle.
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub enum TextAnchor {
129    Top,
130    Center,
131    Bottom,
132    Justified,
133    Distributed,
134}
135
136impl TextAnchor {
137    fn parse(value: &str) -> Option<Self> {
138        match value {
139            "t" => Some(Self::Top),
140            "ctr" => Some(Self::Center),
141            "b" => Some(Self::Bottom),
142            "just" => Some(Self::Justified),
143            "dist" => Some(Self::Distributed),
144            _ => None,
145        }
146    }
147
148    const fn as_str(self) -> &'static str {
149        match self {
150            Self::Top => "t",
151            Self::Center => "ctr",
152            Self::Bottom => "b",
153            Self::Justified => "just",
154            Self::Distributed => "dist",
155        }
156    }
157}
158
159/// Text wrapping at the shape boundary.
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub enum TextWrap {
162    None,
163    Square,
164}
165
166impl TextWrap {
167    fn parse(value: &str) -> Option<Self> {
168        match value {
169            "none" => Some(Self::None),
170            "square" => Some(Self::Square),
171            _ => None,
172        }
173    }
174
175    const fn as_str(self) -> &'static str {
176        match self {
177            Self::None => "none",
178            Self::Square => "square",
179        }
180    }
181}
182
183/// The seven values of `ST_TextVerticalType`.
184#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum TextVertical {
186    Horizontal,
187    Vertical,
188    Vertical270,
189    WordArtVertical,
190    EastAsianVertical,
191    MongolianVertical,
192    WordArtVerticalRtl,
193}
194
195impl TextVertical {
196    fn parse(value: &str) -> Option<Self> {
197        match value {
198            "horz" => Some(Self::Horizontal),
199            "vert" => Some(Self::Vertical),
200            "vert270" => Some(Self::Vertical270),
201            "wordArtVert" => Some(Self::WordArtVertical),
202            "eaVert" => Some(Self::EastAsianVertical),
203            "mongolianVert" => Some(Self::MongolianVertical),
204            "wordArtVertRtl" => Some(Self::WordArtVerticalRtl),
205            _ => None,
206        }
207    }
208
209    const fn as_str(self) -> &'static str {
210        match self {
211            Self::Horizontal => "horz",
212            Self::Vertical => "vert",
213            Self::Vertical270 => "vert270",
214            Self::WordArtVertical => "wordArtVert",
215            Self::EastAsianVertical => "eaVert",
216            Self::MongolianVertical => "mongolianVert",
217            Self::WordArtVerticalRtl => "wordArtVertRtl",
218        }
219    }
220}
221
222/// Stored values on one `a:normAutofit` choice.
223#[derive(Clone, Debug, Default, Eq, PartialEq)]
224pub struct NormalAutofit {
225    pub font_scale: Option<String>,
226    pub line_spacing_reduction: Option<String>,
227}
228
229/// The three members of `EG_TextAutofit`.
230#[derive(Clone, Debug, Eq, PartialEq)]
231pub enum TextAutofit {
232    NoAutofit,
233    Normal(NormalAutofit),
234    ShapeAutofit,
235}
236
237impl TextAutofit {
238    fn from_xml(xml: &[u8]) -> Result<Self> {
239        let mut reader = Reader::from_reader(xml);
240        let mut buffer = Vec::new();
241        loop {
242            match reader
243                .read_event_into(&mut buffer)
244                .map_err(OxmlError::from)?
245            {
246                Event::Empty(element) => return Self::from_start(&element),
247                Event::Start(element) => {
248                    let autofit = Self::from_start(&element)?;
249                    ensure_empty_element(&mut reader, local_name(element.name().as_ref()))?;
250                    return Ok(autofit);
251                }
252                Event::Eof => {
253                    return Err(TextError::UnexpectedElement("EOF".to_owned()));
254                }
255                _ => {}
256            }
257            buffer.clear();
258        }
259    }
260
261    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
262        match local_name(start.name().as_ref()) {
263            b"noAutofit" => Ok(Self::NoAutofit),
264            b"spAutoFit" => Ok(Self::ShapeAutofit),
265            b"normAutofit" => {
266                let font_scale = get_attr(start, b"fontScale");
267                let line_spacing_reduction = get_attr(start, b"lnSpcReduction");
268                if let Some(value) = font_scale.as_deref() {
269                    validate_autofit_percent("fontScale", value, PercentKind::FontScale)?;
270                }
271                if let Some(value) = line_spacing_reduction.as_deref() {
272                    validate_autofit_percent("lnSpcReduction", value, PercentKind::LineSpacing)?;
273                }
274                Ok(Self::Normal(NormalAutofit {
275                    font_scale,
276                    line_spacing_reduction,
277                }))
278            }
279            _ => Err(TextError::UnexpectedElement(element_name(start))),
280        }
281    }
282
283    fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
284        match self {
285            Self::NoAutofit => write_empty(writer, BytesStart::new("a:noAutofit")),
286            Self::ShapeAutofit => write_empty(writer, BytesStart::new("a:spAutoFit")),
287            Self::Normal(normal) => {
288                let mut start = BytesStart::new("a:normAutofit");
289                if let Some(value) = normal.font_scale.as_deref() {
290                    validate_autofit_percent("fontScale", value, PercentKind::FontScale)?;
291                    start.push_attribute(("fontScale", value));
292                }
293                if let Some(value) = normal.line_spacing_reduction.as_deref() {
294                    validate_autofit_percent("lnSpcReduction", value, PercentKind::LineSpacing)?;
295                    start.push_attribute(("lnSpcReduction", value));
296                }
297                write_empty(writer, start)
298            }
299        }
300    }
301}
302
303/// Insets, anchoring, wrapping, vertical direction, and autofit on `a:bodyPr`.
304#[allow(non_camel_case_types)]
305#[derive(Clone, Debug, Default, Eq, PartialEq)]
306pub struct CT_TextBodyProperties {
307    pub left_inset: Option<Coordinate32Value>,
308    pub top_inset: Option<Coordinate32Value>,
309    pub right_inset: Option<Coordinate32Value>,
310    pub bottom_inset: Option<Coordinate32Value>,
311    pub anchor: Option<TextAnchor>,
312    pub wrap: Option<TextWrap>,
313    pub vertical: Option<TextVertical>,
314    pub space_first_last_paragraph: Option<bool>,
315    pub autofit: Option<TextAutofit>,
316    raw_children: OrderedRawChildren,
317}
318
319impl CT_TextBodyProperties {
320    /// Parses one complete `a:bodyPr` element with any namespace prefix.
321    pub fn from_xml(xml: &[u8]) -> Result<Self> {
322        let mut reader = Reader::from_reader(xml);
323        let mut buffer = Vec::new();
324        loop {
325            match reader
326                .read_event_into(&mut buffer)
327                .map_err(OxmlError::from)?
328            {
329                Event::Start(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
330                    return Self::from_element(&mut reader, &element);
331                }
332                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
333                    return Self::from_start(&element);
334                }
335                Event::Start(element) | Event::Empty(element) => {
336                    return Err(TextError::UnexpectedElement(element_name(&element)));
337                }
338                Event::Eof => {
339                    return Err(TextError::Xml(OxmlError::MissingElement(
340                        "DrawingML body properties".to_owned(),
341                    )));
342                }
343                _ => {}
344            }
345            buffer.clear();
346        }
347    }
348
349    pub(crate) fn from_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
350        let mut properties = Self::from_start(start)?;
351        let mut boundary = 0;
352        let mut occurrences = [false; 5];
353        let mut buffer = Vec::new();
354        loop {
355            match reader
356                .read_event_into(&mut buffer)
357                .map_err(OxmlError::from)?
358            {
359                Event::Start(element) => {
360                    let name = local_name(element.name().as_ref()).to_vec();
361                    let raw = capture_element(reader, &element)?;
362                    properties.capture_child(&name, raw, &mut boundary, &mut occurrences)?;
363                }
364                Event::Empty(element) => {
365                    let name = local_name(element.name().as_ref()).to_vec();
366                    let raw = capture_empty_element(&element)?;
367                    properties.capture_child(&name, raw, &mut boundary, &mut occurrences)?;
368                }
369                Event::End(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
370                    break;
371                }
372                Event::Eof => return Err(missing_end("bodyPr")),
373                _ => {}
374            }
375            buffer.clear();
376        }
377        Ok(properties)
378    }
379
380    pub(crate) fn from_start(start: &BytesStart<'_>) -> Result<Self> {
381        if !matches_local_name(start.name().as_ref(), b"bodyPr") {
382            return Err(TextError::UnexpectedElement(element_name(start)));
383        }
384        Ok(Self {
385            left_inset: parse_coordinate(start, b"lIns")?,
386            top_inset: parse_coordinate(start, b"tIns")?,
387            right_inset: parse_coordinate(start, b"rIns")?,
388            bottom_inset: parse_coordinate(start, b"bIns")?,
389            anchor: parse_enum(start, b"anchor", TextAnchor::parse)?,
390            wrap: parse_enum(start, b"wrap", TextWrap::parse)?,
391            vertical: parse_enum(start, b"vert", TextVertical::parse)?,
392            space_first_last_paragraph: parse_optional_bool(start, b"spcFirstLastPara")?,
393            ..Self::default()
394        })
395    }
396
397    fn capture_child(
398        &mut self,
399        name: &[u8],
400        raw: Vec<u8>,
401        boundary: &mut usize,
402        occurrences: &mut [bool; 5],
403    ) -> Result<()> {
404        if let Some(index) = schema_choice_index(name) {
405            if occurrences[index] {
406                return Err(TextError::DuplicateElement(
407                    String::from_utf8_lossy(name).into_owned(),
408                ));
409            }
410            occurrences[index] = true;
411        }
412        if is_autofit(name) {
413            self.autofit = Some(TextAutofit::from_xml(&raw)?);
414            *boundary = (*boundary).max(2);
415            return Ok(());
416        }
417        self.raw_children.push(*boundary, raw);
418        *boundary = (*boundary).max(raw_boundary_after(name));
419        Ok(())
420    }
421
422    /// Writes body properties with fixed prefixes and schema child order.
423    pub fn to_xml(&self) -> Result<Vec<u8>> {
424        let mut writer = Writer::new(Vec::new());
425        self.write_xml(&mut writer)?;
426        Ok(writer.into_inner())
427    }
428
429    pub(crate) fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
430        let mut start = BytesStart::new("a:bodyPr");
431        let left_inset = self
432            .left_inset
433            .as_ref()
434            .map(Coordinate32Value::as_xml)
435            .transpose()?;
436        let top_inset = self
437            .top_inset
438            .as_ref()
439            .map(Coordinate32Value::as_xml)
440            .transpose()?;
441        let right_inset = self
442            .right_inset
443            .as_ref()
444            .map(Coordinate32Value::as_xml)
445            .transpose()?;
446        let bottom_inset = self
447            .bottom_inset
448            .as_ref()
449            .map(Coordinate32Value::as_xml)
450            .transpose()?;
451        for (name, value) in [
452            ("lIns", left_inset.as_deref()),
453            ("tIns", top_inset.as_deref()),
454            ("rIns", right_inset.as_deref()),
455            ("bIns", bottom_inset.as_deref()),
456        ] {
457            if let Some(value) = value {
458                start.push_attribute((name, value));
459            }
460        }
461        if let Some(anchor) = self.anchor {
462            start.push_attribute(("anchor", anchor.as_str()));
463        }
464        if let Some(wrap) = self.wrap {
465            start.push_attribute(("wrap", wrap.as_str()));
466        }
467        if let Some(vertical) = self.vertical {
468            start.push_attribute(("vert", vertical.as_str()));
469        }
470        if let Some(value) = self.space_first_last_paragraph {
471            start.push_attribute(("spcFirstLastPara", if value { "1" } else { "0" }));
472        }
473
474        if self.autofit.is_none() && self.raw_children.is_empty() {
475            return write_empty(writer, start);
476        }
477        writer
478            .write_event(Event::Start(start))
479            .map_err(OxmlError::from)?;
480        emit_raw(writer, self.raw_children.at(0))?;
481        emit_raw(writer, self.raw_children.at(1))?;
482        if let Some(autofit) = &self.autofit {
483            autofit.write_xml(writer)?;
484        }
485        for boundary in 2..=5 {
486            emit_raw(writer, self.raw_children.at(boundary))?;
487        }
488        writer
489            .write_event(Event::End(BytesEnd::new("a:bodyPr")))
490            .map_err(OxmlError::from)?;
491        Ok(())
492    }
493
494    pub fn raw_children(&self) -> &OrderedRawChildren {
495        &self.raw_children
496    }
497}
498
499fn parse_coordinate(start: &BytesStart<'_>, attribute: &[u8]) -> Result<Option<Coordinate32Value>> {
500    get_attr(start, attribute)
501        .map(|value| Coordinate32Value::parse("bodyPr", &String::from_utf8_lossy(attribute), value))
502        .transpose()
503}
504
505fn parse_enum<T>(
506    start: &BytesStart<'_>,
507    attribute: &[u8],
508    parse: impl FnOnce(&str) -> Option<T>,
509) -> Result<Option<T>> {
510    let Some(value) = get_attr(start, attribute) else {
511        return Ok(None);
512    };
513    parse(&value)
514        .map(Some)
515        .ok_or_else(|| invalid_attribute("bodyPr", &String::from_utf8_lossy(attribute), value))
516}
517
518fn parse_optional_bool(start: &BytesStart<'_>, attribute: &[u8]) -> Result<Option<bool>> {
519    let Some(value) = get_attr(start, attribute) else {
520        return Ok(None);
521    };
522    match value.as_str() {
523        "1" | "true" => Ok(Some(true)),
524        "0" | "false" => Ok(Some(false)),
525        _ => Err(invalid_attribute(
526            "bodyPr",
527            &String::from_utf8_lossy(attribute),
528            value,
529        )),
530    }
531}
532
533fn is_autofit(name: &[u8]) -> bool {
534    matches!(name, b"noAutofit" | b"normAutofit" | b"spAutoFit")
535}
536
537fn schema_choice_index(name: &[u8]) -> Option<usize> {
538    match name {
539        b"prstTxWarp" => Some(0),
540        name if is_autofit(name) => Some(1),
541        b"scene3d" => Some(2),
542        b"sp3d" | b"flatTx" => Some(3),
543        b"extLst" => Some(4),
544        _ => None,
545    }
546}
547
548fn raw_boundary_after(name: &[u8]) -> usize {
549    match name {
550        b"prstTxWarp" => 1,
551        name if is_autofit(name) => 2,
552        b"scene3d" => 3,
553        b"sp3d" | b"flatTx" => 4,
554        b"extLst" => 5,
555        _ => 0,
556    }
557}
558
559enum PercentKind {
560    FontScale,
561    LineSpacing,
562}
563
564fn validate_autofit_percent(attribute: &str, value: &str, kind: PercentKind) -> Result<()> {
565    if is_percentage_string(value) {
566        return Ok(());
567    }
568    let integer = value
569        .parse::<i32>()
570        .map_err(|_| invalid_attribute("normAutofit", attribute, value.to_owned()))?;
571    let valid = match kind {
572        PercentKind::FontScale => (1_000..=100_000).contains(&integer),
573        PercentKind::LineSpacing => (0..=MAX_TEXT_SPACING_PERCENT).contains(&integer),
574    };
575    if valid {
576        Ok(())
577    } else {
578        Err(invalid_attribute(
579            "normAutofit",
580            attribute,
581            value.to_owned(),
582        ))
583    }
584}
585
586fn is_percentage_string(value: &str) -> bool {
587    let Some(number) = value.strip_suffix('%') else {
588        return false;
589    };
590    is_signed_decimal(number)
591}
592
593fn is_universal_measure(value: &str) -> bool {
594    if value.len() < 3 {
595        return false;
596    }
597    let (number, unit) = value.split_at(value.len() - 2);
598    matches!(unit, "mm" | "cm" | "in" | "pt" | "pc" | "pi") && is_signed_decimal(number)
599}
600
601fn is_signed_decimal(value: &str) -> bool {
602    let unsigned = value.strip_prefix('-').unwrap_or(value);
603    let mut parts = unsigned.split('.');
604    let Some(integer) = parts.next() else {
605        return false;
606    };
607    if integer.is_empty() || !integer.bytes().all(|byte| byte.is_ascii_digit()) {
608        return false;
609    }
610    if let Some(fraction) = parts.next()
611        && (fraction.is_empty() || !fraction.bytes().all(|byte| byte.is_ascii_digit()))
612    {
613        return false;
614    }
615    parts.next().is_none()
616}
617
618fn ensure_empty_element(reader: &mut Reader<&[u8]>, expected: &[u8]) -> Result<()> {
619    let mut buffer = Vec::new();
620    loop {
621        match reader
622            .read_event_into(&mut buffer)
623            .map_err(OxmlError::from)?
624        {
625            Event::End(element) if matches_local_name(element.name().as_ref(), expected) => {
626                return Ok(());
627            }
628            Event::Text(text) if text.iter().all(u8::is_ascii_whitespace) => {}
629            Event::Comment(_) => {}
630            Event::Eof => return Err(missing_end(&String::from_utf8_lossy(expected))),
631            Event::Start(element) | Event::Empty(element) => {
632                return Err(TextError::UnexpectedElement(element_name(&element)));
633            }
634            _ => {
635                return Err(TextError::UnexpectedElement(
636                    String::from_utf8_lossy(expected).into_owned(),
637                ));
638            }
639        }
640        buffer.clear();
641    }
642}
643
644fn emit_raw<'a, W: Write>(
645    writer: &mut Writer<W>,
646    children: impl Iterator<Item = &'a [u8]>,
647) -> Result<()> {
648    for child in children {
649        writer.get_mut().write_all(child).map_err(OxmlError::from)?;
650    }
651    Ok(())
652}
653
654fn write_empty<W: Write>(writer: &mut Writer<W>, start: BytesStart<'_>) -> Result<()> {
655    writer
656        .write_event(Event::Empty(start))
657        .map_err(OxmlError::from)?;
658    Ok(())
659}
660
661fn invalid_attribute(element: &str, attribute: &str, value: String) -> TextError {
662    TextError::InvalidAttribute {
663        element: element.to_owned(),
664        attribute: attribute.to_owned(),
665        value,
666    }
667}
668
669fn element_name(element: &BytesStart<'_>) -> String {
670    String::from_utf8_lossy(element.name().as_ref()).into_owned()
671}
672
673pub(crate) fn missing_end(element: &str) -> TextError {
674    TextError::Xml(OxmlError::MissingElement(format!(
675        "closing DrawingML {element}"
676    )))
677}
678
679#[cfg(test)]
680mod tests {
681    use std::panic;
682
683    use super::{CT_TextBodyProperties, Coordinate32Value, TextAutofit};
684    use crate::text::CT_TextBody;
685
686    #[test]
687    fn every_body_property_autofit_form_round_trips_in_schema_order() {
688        let cases: &[(&[u8], &[u8])] = &[
689            (
690                br#"<q:bodyPr lIns="-2147483648" tIns="2147483647" rIns="1.25in" bIns="0" anchor="dist" wrap="square" vert="wordArtVertRtl" spcFirstLastPara="true"><x:warp/><q:prstTxWarp prst="textPlain"/><x:beforeFit/><q:noAutofit/></q:bodyPr>"#,
691                br#"<a:bodyPr lIns="-2147483648" tIns="2147483647" rIns="1.25in" bIns="0" anchor="dist" wrap="square" vert="wordArtVertRtl" spcFirstLastPara="1"><x:warp/><q:prstTxWarp prst="textPlain"/><x:beforeFit/><a:noAutofit/></a:bodyPr>"#,
692            ),
693            (
694                br#"<q:bodyPr><q:spAutoFit/></q:bodyPr>"#,
695                br#"<a:bodyPr><a:spAutoFit/></a:bodyPr>"#,
696            ),
697            (
698                br#"<q:bodyPr><q:normAutofit fontScale="62.500%" lnSpcReduction="20000"/></q:bodyPr>"#,
699                br#"<a:bodyPr><a:normAutofit fontScale="62.500%" lnSpcReduction="20000"/></a:bodyPr>"#,
700            ),
701        ];
702
703        for (xml, expected) in cases {
704            let parsed = CT_TextBodyProperties::from_xml(xml).unwrap();
705            let written = parsed.to_xml().unwrap();
706            assert_eq!(&written, expected);
707            assert_eq!(CT_TextBodyProperties::from_xml(&written).unwrap(), parsed);
708        }
709
710        let parsed = CT_TextBodyProperties::from_xml(cases[0].0).unwrap();
711        assert_eq!(parsed.left_inset, Some(Coordinate32Value::Emu(i32::MIN)));
712        assert_eq!(parsed.space_first_last_paragraph, Some(true));
713        assert!(matches!(parsed.autofit, Some(TextAutofit::NoAutofit)));
714    }
715
716    #[test]
717    fn body_properties_preserve_unknown_children_at_their_boundaries() {
718        let xml = br#"<q:bodyPr><x:before/><q:prstTxWarp prst="textPlain"><x:warp/></q:prstTxWarp><x:beforeFit/><q:normAutofit fontScale="62500"/><x:afterFit/><q:scene3d><x:scene/></q:scene3d><x:afterScene/><q:sp3d><x:shape/></q:sp3d><x:after3d/><q:extLst><x:ext/></q:extLst><x:afterExt/></q:bodyPr>"#;
719        let written = CT_TextBodyProperties::from_xml(xml)
720            .unwrap()
721            .to_xml()
722            .unwrap();
723        assert_eq!(written, br#"<a:bodyPr><x:before/><q:prstTxWarp prst="textPlain"><x:warp/></q:prstTxWarp><x:beforeFit/><a:normAutofit fontScale="62500"/><x:afterFit/><q:scene3d><x:scene/></q:scene3d><x:afterScene/><q:sp3d><x:shape/></q:sp3d><x:after3d/><q:extLst><x:ext/></q:extLst><x:afterExt/></a:bodyPr>"#);
724    }
725
726    #[test]
727    fn malformed_body_properties_return_errors_without_panicking() {
728        let body_cases: &[&[u8]] = &[
729            br#"<q:notBodyPr/>"#,
730            br#"<q:bodyPr anchor="middle"/>"#,
731            br#"<q:bodyPr wrap="tight"/>"#,
732            br#"<q:bodyPr vert="sideways"/>"#,
733            br#"<q:bodyPr lIns="2147483648"/>"#,
734            br#"<q:bodyPr rIns="1.in"/>"#,
735            br#"<q:bodyPr spcFirstLastPara="yes"/>"#,
736            br#"<q:bodyPr><q:normAutofit fontScale="999"/></q:bodyPr>"#,
737            br#"<q:bodyPr><q:normAutofit fontScale="100001"/></q:bodyPr>"#,
738            br#"<q:bodyPr><q:normAutofit fontScale=".5%"/></q:bodyPr>"#,
739            br#"<q:bodyPr><q:normAutofit lnSpcReduction="13200001"/></q:bodyPr>"#,
740            br#"<q:bodyPr><q:noAutofit><x:child/></q:noAutofit></q:bodyPr>"#,
741            br#"<q:bodyPr><q:noAutofit/><q:spAutoFit/></q:bodyPr>"#,
742            br#"<q:bodyPr><q:sp3d/><q:flatTx/></q:bodyPr>"#,
743        ];
744        for xml in body_cases {
745            let result = panic::catch_unwind(|| CT_TextBodyProperties::from_xml(xml));
746            assert!(result.is_ok(), "body-property parser panicked");
747            assert!(result.unwrap().is_err(), "malformed body properties parsed");
748        }
749
750        let shell_cases: &[&[u8]] = &[
751            br#"<q:txBody><q:p/></q:txBody>"#,
752            br#"<q:txBody/>"#,
753            br#"<q:txBody><q:bodyPr/><q:bodyPr/><q:p/></q:txBody>"#,
754            br#"<q:txBody><q:bodyPr/><q:lstStyle/><q:lstStyle/><q:p/></q:txBody>"#,
755        ];
756        for xml in shell_cases {
757            let result = panic::catch_unwind(|| CT_TextBody::from_xml(xml));
758            assert!(result.is_ok(), "text-body parser panicked");
759            assert!(result.unwrap().is_err(), "malformed text body parsed");
760        }
761
762        let empty = CT_TextBody::from_xml(br#"<q:txBody><q:bodyPr/></q:txBody>"#).unwrap();
763        assert_eq!(empty.paragraph_count(), 0);
764        assert!(empty.to_xml().is_ok());
765
766        let mut properties = CT_TextBodyProperties {
767            left_inset: Some(Coordinate32Value::UniversalMeasure("NaNin".to_owned())),
768            ..CT_TextBodyProperties::default()
769        };
770        assert!(properties.to_xml().is_err());
771        properties.left_inset = Some(Coordinate32Value::Emu(i32::MAX));
772        assert!(properties.to_xml().is_ok());
773    }
774}