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::{local_name, matches_local_name};
7use quick_xml::events::{BytesEnd, BytesStart, Event};
8use quick_xml::{Reader, Writer, XmlVersion};
9
10use crate::color::ColorError;
11use crate::fill::FillError;
12use crate::namespace::reject_conflicting_a_prefix;
13use crate::order::OrderedRawChildren;
14
15const MAX_TEXT_SPACING_PERCENT: i32 = 13_200_000;
16
17/// Errors produced while parsing or writing DrawingML text shells.
18#[derive(Debug)]
19pub enum TextError {
20    Xml(OxmlError),
21    Color(ColorError),
22    Fill(FillError),
23    UnexpectedElement(String),
24    MissingAttribute {
25        element: String,
26        attribute: String,
27    },
28    MissingBodyProperties,
29    MissingParagraph,
30    DuplicateElement(String),
31    InvalidAttribute {
32        element: String,
33        attribute: String,
34        value: String,
35    },
36}
37
38impl fmt::Display for TextError {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::Xml(error) => error.fmt(formatter),
42            Self::Color(error) => error.fmt(formatter),
43            Self::Fill(error) => error.fmt(formatter),
44            Self::UnexpectedElement(element) => {
45                write!(formatter, "unexpected DrawingML text element: {element}")
46            }
47            Self::MissingAttribute { element, attribute } => {
48                write!(formatter, "DrawingML {element} requires @{attribute}")
49            }
50            Self::MissingBodyProperties => write!(formatter, "DrawingML txBody requires bodyPr"),
51            Self::MissingParagraph => write!(formatter, "DrawingML txBody requires at least one p"),
52            Self::DuplicateElement(element) => {
53                write!(formatter, "DrawingML text contains duplicate {element}")
54            }
55            Self::InvalidAttribute {
56                element,
57                attribute,
58                value,
59            } => write!(
60                formatter,
61                "DrawingML {element} has invalid @{attribute}: {value}"
62            ),
63        }
64    }
65}
66
67impl std::error::Error for TextError {
68    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
69        match self {
70            Self::Xml(error) => Some(error),
71            Self::Color(error) => Some(error),
72            Self::Fill(error) => Some(error),
73            _ => None,
74        }
75    }
76}
77
78impl From<OxmlError> for TextError {
79    fn from(error: OxmlError) -> Self {
80        Self::Xml(error)
81    }
82}
83
84impl From<ColorError> for TextError {
85    fn from(error: ColorError) -> Self {
86        Self::Color(error)
87    }
88}
89
90impl From<FillError> for TextError {
91    fn from(error: FillError) -> Self {
92        Self::Fill(error)
93    }
94}
95
96pub type Result<T> = std::result::Result<T, TextError>;
97
98/// One strict or transitional lexical form of `ST_Coordinate32`.
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub enum Coordinate32Value {
101    Emu(i32),
102    UniversalMeasure(String),
103}
104
105impl Coordinate32Value {
106    fn parse(element: &str, attribute: &str, value: String) -> Result<Self> {
107        if let Ok(value) = value.parse::<i32>() {
108            return Ok(Self::Emu(value));
109        }
110        if is_universal_measure(&value) {
111            return Ok(Self::UniversalMeasure(value));
112        }
113        Err(invalid_attribute(element, attribute, value))
114    }
115
116    fn as_xml(&self) -> Result<String> {
117        match self {
118            Self::Emu(value) => Ok(value.to_string()),
119            Self::UniversalMeasure(value) if is_universal_measure(value) => Ok(value.clone()),
120            Self::UniversalMeasure(value) => {
121                Err(invalid_attribute("bodyPr", "inset", value.clone()))
122            }
123        }
124    }
125}
126
127/// Vertical anchoring inside the text rectangle.
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub enum TextAnchor {
130    Top,
131    Center,
132    Bottom,
133    Justified,
134    Distributed,
135}
136
137impl TextAnchor {
138    pub(crate) fn parse(value: &str) -> Option<Self> {
139        match value {
140            "t" => Some(Self::Top),
141            "ctr" => Some(Self::Center),
142            "b" => Some(Self::Bottom),
143            "just" => Some(Self::Justified),
144            "dist" => Some(Self::Distributed),
145            _ => None,
146        }
147    }
148
149    pub(crate) const fn as_str(self) -> &'static str {
150        match self {
151            Self::Top => "t",
152            Self::Center => "ctr",
153            Self::Bottom => "b",
154            Self::Justified => "just",
155            Self::Distributed => "dist",
156        }
157    }
158}
159
160/// Text wrapping at the shape boundary.
161#[derive(Clone, Copy, Debug, Eq, PartialEq)]
162pub enum TextWrap {
163    None,
164    Square,
165}
166
167impl TextWrap {
168    fn parse(value: &str) -> Option<Self> {
169        match value {
170            "none" => Some(Self::None),
171            "square" => Some(Self::Square),
172            _ => None,
173        }
174    }
175
176    const fn as_str(self) -> &'static str {
177        match self {
178            Self::None => "none",
179            Self::Square => "square",
180        }
181    }
182}
183
184/// The seven values of `ST_TextVerticalType`.
185#[derive(Clone, Copy, Debug, Eq, PartialEq)]
186pub enum TextVertical {
187    Horizontal,
188    Vertical,
189    Vertical270,
190    WordArtVertical,
191    EastAsianVertical,
192    MongolianVertical,
193    WordArtVerticalRtl,
194}
195
196impl TextVertical {
197    fn parse(value: &str) -> Option<Self> {
198        match value {
199            "horz" => Some(Self::Horizontal),
200            "vert" => Some(Self::Vertical),
201            "vert270" => Some(Self::Vertical270),
202            "wordArtVert" => Some(Self::WordArtVertical),
203            "eaVert" => Some(Self::EastAsianVertical),
204            "mongolianVert" => Some(Self::MongolianVertical),
205            "wordArtVertRtl" => Some(Self::WordArtVerticalRtl),
206            _ => None,
207        }
208    }
209
210    const fn as_str(self) -> &'static str {
211        match self {
212            Self::Horizontal => "horz",
213            Self::Vertical => "vert",
214            Self::Vertical270 => "vert270",
215            Self::WordArtVertical => "wordArtVert",
216            Self::EastAsianVertical => "eaVert",
217            Self::MongolianVertical => "mongolianVert",
218            Self::WordArtVerticalRtl => "wordArtVertRtl",
219        }
220    }
221}
222
223/// Stored values on one `a:normAutofit` choice.
224#[derive(Clone, Debug, Default, Eq, PartialEq)]
225pub struct NormalAutofit {
226    pub font_scale: Option<String>,
227    pub line_spacing_reduction: Option<String>,
228}
229
230/// The three members of `EG_TextAutofit`.
231#[derive(Clone, Debug, Eq, PartialEq)]
232pub enum TextAutofit {
233    NoAutofit,
234    Normal(NormalAutofit),
235    ShapeAutofit,
236}
237
238impl TextAutofit {
239    fn from_xml(xml: &[u8]) -> Result<Self> {
240        let mut reader = Reader::from_reader(xml);
241        let mut buffer = Vec::new();
242        loop {
243            match reader
244                .read_event_into(&mut buffer)
245                .map_err(OxmlError::from)?
246            {
247                Event::Empty(element) => return Self::from_start(&element),
248                Event::Start(element) => {
249                    let autofit = Self::from_start(&element)?;
250                    ensure_empty_element(&mut reader, local_name(element.name().as_ref()))?;
251                    return Ok(autofit);
252                }
253                Event::Eof => {
254                    return Err(TextError::UnexpectedElement("EOF".to_owned()));
255                }
256                _ => {}
257            }
258            buffer.clear();
259        }
260    }
261
262    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
263        match local_name(start.name().as_ref()) {
264            b"noAutofit" => Ok(Self::NoAutofit),
265            b"spAutoFit" => Ok(Self::ShapeAutofit),
266            b"normAutofit" => {
267                let font_scale = get_body_attr(start, b"fontScale");
268                let line_spacing_reduction = get_body_attr(start, b"lnSpcReduction");
269                if let Some(value) = font_scale.as_deref() {
270                    validate_autofit_percent("fontScale", value, PercentKind::FontScale)?;
271                }
272                if let Some(value) = line_spacing_reduction.as_deref() {
273                    validate_autofit_percent("lnSpcReduction", value, PercentKind::LineSpacing)?;
274                }
275                Ok(Self::Normal(NormalAutofit {
276                    font_scale,
277                    line_spacing_reduction,
278                }))
279            }
280            _ => Err(TextError::UnexpectedElement(element_name(start))),
281        }
282    }
283
284    fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
285        match self {
286            Self::NoAutofit => write_empty(writer, BytesStart::new("a:noAutofit")),
287            Self::ShapeAutofit => write_empty(writer, BytesStart::new("a:spAutoFit")),
288            Self::Normal(normal) => {
289                let mut start = BytesStart::new("a:normAutofit");
290                if let Some(value) = normal.font_scale.as_deref() {
291                    validate_autofit_percent("fontScale", value, PercentKind::FontScale)?;
292                    start.push_attribute(("fontScale", value));
293                }
294                if let Some(value) = normal.line_spacing_reduction.as_deref() {
295                    validate_autofit_percent("lnSpcReduction", value, PercentKind::LineSpacing)?;
296                    start.push_attribute(("lnSpcReduction", value));
297                }
298                write_empty(writer, start)
299            }
300        }
301    }
302}
303
304/// Insets, anchoring, wrapping, vertical direction, and autofit on `a:bodyPr`.
305///
306/// Every other attribute, such as `rot`, `numCol`, `spcCol` or `anchorCtr`,
307/// is kept verbatim in source order and written after the modelled ones.
308#[allow(non_camel_case_types)]
309#[derive(Clone, Debug, Default, Eq, PartialEq)]
310pub struct CT_TextBodyProperties {
311    pub left_inset: Option<Coordinate32Value>,
312    pub top_inset: Option<Coordinate32Value>,
313    pub right_inset: Option<Coordinate32Value>,
314    pub bottom_inset: Option<Coordinate32Value>,
315    pub anchor: Option<TextAnchor>,
316    pub wrap: Option<TextWrap>,
317    pub vertical: Option<TextVertical>,
318    pub space_first_last_paragraph: Option<bool>,
319    pub autofit: Option<TextAutofit>,
320    raw_attributes: Vec<(String, String)>,
321    raw_children: OrderedRawChildren,
322    pub(super) autofit_source: Option<Vec<u8>>,
323}
324
325impl CT_TextBodyProperties {
326    /// Parses one complete `a:bodyPr` element with any namespace prefix.
327    pub fn from_xml(xml: &[u8]) -> Result<Self> {
328        let mut reader = Reader::from_reader(xml);
329        let mut buffer = Vec::new();
330        loop {
331            match reader
332                .read_event_into(&mut buffer)
333                .map_err(OxmlError::from)?
334            {
335                Event::Start(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
336                    return Self::from_element(&mut reader, &element);
337                }
338                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
339                    return Self::from_start(&element);
340                }
341                Event::Start(element) | Event::Empty(element) => {
342                    return Err(TextError::UnexpectedElement(element_name(&element)));
343                }
344                Event::Eof => {
345                    return Err(TextError::Xml(OxmlError::MissingElement(
346                        "DrawingML body properties".to_owned(),
347                    )));
348                }
349                _ => {}
350            }
351            buffer.clear();
352        }
353    }
354
355    pub(crate) fn from_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
356        let mut properties = Self::from_start(start)?;
357        let mut boundary = 0;
358        let mut occurrences = [false; 5];
359        let mut buffer = Vec::new();
360        loop {
361            match reader
362                .read_event_into(&mut buffer)
363                .map_err(OxmlError::from)?
364            {
365                Event::Start(element) => {
366                    let name = local_name(element.name().as_ref()).to_vec();
367                    let raw = capture_element(reader, &element)?;
368                    properties.capture_child(&name, raw, &mut boundary, &mut occurrences)?;
369                }
370                Event::Empty(element) => {
371                    let name = local_name(element.name().as_ref()).to_vec();
372                    let raw = capture_empty_element(&element)?;
373                    properties.capture_child(&name, raw, &mut boundary, &mut occurrences)?;
374                }
375                Event::End(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
376                    break;
377                }
378                Event::Eof => return Err(missing_end("bodyPr")),
379                _ => {}
380            }
381            buffer.clear();
382        }
383        Ok(properties)
384    }
385
386    pub(crate) fn from_start(start: &BytesStart<'_>) -> Result<Self> {
387        if !matches_local_name(start.name().as_ref(), b"bodyPr") {
388            return Err(TextError::UnexpectedElement(element_name(start)));
389        }
390        // Raw attributes are written back, so a local `xmlns:a` would rebind
391        // the fixed prefix of this element and of its typed autofit child.
392        reject_conflicting_a_prefix(start)?;
393        Ok(Self {
394            left_inset: parse_coordinate(start, b"lIns")?,
395            top_inset: parse_coordinate(start, b"tIns")?,
396            right_inset: parse_coordinate(start, b"rIns")?,
397            bottom_inset: parse_coordinate(start, b"bIns")?,
398            anchor: parse_enum(start, b"anchor", TextAnchor::parse)?,
399            wrap: parse_enum(start, b"wrap", TextWrap::parse)?,
400            vertical: parse_enum(start, b"vert", TextVertical::parse)?,
401            space_first_last_paragraph: parse_optional_bool(start, b"spcFirstLastPara")?,
402            autofit: None,
403            raw_attributes: capture_raw_attributes(start)?,
404            raw_children: OrderedRawChildren::default(),
405            autofit_source: None,
406        })
407    }
408
409    fn capture_child(
410        &mut self,
411        name: &[u8],
412        raw: Vec<u8>,
413        boundary: &mut usize,
414        occurrences: &mut [bool; 5],
415    ) -> Result<()> {
416        if let Some(index) = schema_choice_index(name) {
417            if occurrences[index] {
418                return Err(TextError::DuplicateElement(
419                    String::from_utf8_lossy(name).into_owned(),
420                ));
421            }
422            occurrences[index] = true;
423        }
424        if is_autofit(name) {
425            self.autofit = Some(TextAutofit::from_xml(&raw)?);
426            let mut reader = Reader::from_reader(raw.as_slice());
427            let has_unknown_attributes = match reader.read_event().map_err(OxmlError::from)? {
428                Event::Start(start) | Event::Empty(start) => start.attributes().any(|attribute| {
429                    attribute.is_ok_and(|attribute| {
430                        !matches!(attribute.key.as_ref(), b"fontScale" | b"lnSpcReduction")
431                    })
432                }),
433                _ => false,
434            };
435            let has_comments = loop {
436                match reader.read_event().map_err(OxmlError::from)? {
437                    Event::Comment(_) => break true,
438                    Event::Eof => break false,
439                    _ => {}
440                }
441            };
442            // Pure modeled choices retain the established canonical writer.
443            // Only a choice owning opaque facts needs lexical provenance.
444            if has_unknown_attributes || has_comments {
445                self.autofit_source = Some(raw);
446            }
447            *boundary = (*boundary).max(2);
448            return Ok(());
449        }
450        // A known child keeps its schema slot, so an autofit choice added
451        // after parsing is still written before a preserved scene or extension.
452        let slot = raw_boundary_after(name).saturating_sub(1);
453        self.raw_children.push((*boundary).max(slot), raw);
454        *boundary = (*boundary).max(raw_boundary_after(name));
455        Ok(())
456    }
457
458    /// Writes body properties with fixed prefixes and schema child order.
459    pub fn to_xml(&self) -> Result<Vec<u8>> {
460        let mut writer = Writer::new(Vec::new());
461        self.write_xml(&mut writer)?;
462        Ok(writer.into_inner())
463    }
464
465    pub(crate) fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
466        let mut start = BytesStart::new("a:bodyPr");
467        let left_inset = self
468            .left_inset
469            .as_ref()
470            .map(Coordinate32Value::as_xml)
471            .transpose()?;
472        let top_inset = self
473            .top_inset
474            .as_ref()
475            .map(Coordinate32Value::as_xml)
476            .transpose()?;
477        let right_inset = self
478            .right_inset
479            .as_ref()
480            .map(Coordinate32Value::as_xml)
481            .transpose()?;
482        let bottom_inset = self
483            .bottom_inset
484            .as_ref()
485            .map(Coordinate32Value::as_xml)
486            .transpose()?;
487        for (name, value) in [
488            ("lIns", left_inset.as_deref()),
489            ("tIns", top_inset.as_deref()),
490            ("rIns", right_inset.as_deref()),
491            ("bIns", bottom_inset.as_deref()),
492        ] {
493            if let Some(value) = value {
494                start.push_attribute((name, value));
495            }
496        }
497        if let Some(anchor) = self.anchor {
498            start.push_attribute(("anchor", anchor.as_str()));
499        }
500        if let Some(wrap) = self.wrap {
501            start.push_attribute(("wrap", wrap.as_str()));
502        }
503        if let Some(vertical) = self.vertical {
504            start.push_attribute(("vert", vertical.as_str()));
505        }
506        if let Some(value) = self.space_first_last_paragraph {
507            start.push_attribute(("spcFirstLastPara", if value { "1" } else { "0" }));
508        }
509        for (name, value) in &self.raw_attributes {
510            start.push_attribute((name.as_str(), value.as_str()));
511        }
512
513        if self.autofit.is_none() && self.raw_children.is_empty() {
514            return write_empty(writer, start);
515        }
516        writer
517            .write_event(Event::Start(start))
518            .map_err(OxmlError::from)?;
519        emit_raw(writer, self.raw_children.at(0))?;
520        emit_raw(writer, self.raw_children.at(1))?;
521        if let Some(autofit) = &self.autofit {
522            if let Some(source) = &self.autofit_source {
523                write_source_autofit(writer, autofit, source)?;
524            } else {
525                autofit.write_xml(writer)?;
526            }
527        }
528        for boundary in 2..=5 {
529            emit_raw(writer, self.raw_children.at(boundary))?;
530        }
531        writer
532            .write_event(Event::End(BytesEnd::new("a:bodyPr")))
533            .map_err(OxmlError::from)?;
534        Ok(())
535    }
536
537    pub fn raw_children(&self) -> &OrderedRawChildren {
538        &self.raw_children
539    }
540}
541
542/// Retains the imported empty choice, changing only owned names and values.
543/// Attribute byte ranges come from the validated quick-xml start, so foreign
544/// attributes retain their entities, quoting, ordering and local declarations.
545fn write_source_autofit<W: Write>(
546    writer: &mut Writer<W>,
547    autofit: &TextAutofit,
548    source: &[u8],
549) -> Result<()> {
550    if TextAutofit::from_xml(source)?.eq(autofit) {
551        writer
552            .get_mut()
553            .write_all(source)
554            .map_err(OxmlError::from)?;
555        return Ok(());
556    }
557    let mut validation = Writer::new(Vec::new());
558    autofit.write_xml(&mut validation)?;
559    let mut reader = Reader::from_reader(source);
560    let event = reader.read_event().map_err(OxmlError::from)?;
561    let (start, empty) = match event {
562        Event::Start(start) => (start, false),
563        Event::Empty(start) => (start, true),
564        _ => return Err(TextError::UnexpectedElement("autofit source".to_owned())),
565    };
566    let old_name = start.name().as_ref().to_vec();
567    let local = match autofit {
568        TextAutofit::Normal(_) => b"normAutofit".as_slice(),
569        TextAutofit::NoAutofit => b"noAutofit".as_slice(),
570        TextAutofit::ShapeAutofit => b"spAutoFit".as_slice(),
571    };
572    let prefix_end = old_name
573        .iter()
574        .rposition(|byte| *byte == b':')
575        .map_or(0, |i| i + 1);
576    let mut name = old_name[..prefix_end].to_vec();
577    name.extend_from_slice(local);
578    let mut edits = vec![(1, 1 + old_name.len(), name.clone())];
579    let values = match autofit {
580        TextAutofit::Normal(normal) => [
581            normal.font_scale.as_deref(),
582            normal.line_spacing_reduction.as_deref(),
583        ],
584        _ => [None, None],
585    };
586    let keys = [b"fontScale".as_slice(), b"lnSpcReduction".as_slice()];
587    let mut found = [false; 2];
588    let content: &[u8] = start.as_ref();
589    for attribute in start.attributes() {
590        let attribute = attribute.map_err(OxmlError::from)?;
591        let Some(index) = keys.iter().position(|key| *key == attribute.key.as_ref()) else {
592            continue;
593        };
594        found[index] = true;
595        let key_at = (attribute.key.as_ref().as_ptr() as usize)
596            .checked_sub(content.as_ptr() as usize)
597            .filter(|offset| *offset + attribute.key.as_ref().len() <= content.len())
598            .ok_or_else(|| TextError::UnexpectedElement("autofit attribute source".to_owned()))?
599            + 1;
600        let value_at = (attribute.value.as_ptr() as usize)
601            .checked_sub(content.as_ptr() as usize)
602            .filter(|offset| *offset + attribute.value.len() <= content.len())
603            .ok_or_else(|| TextError::UnexpectedElement("autofit value source".to_owned()))?
604            + 1;
605        let value_end = value_at + attribute.value.len();
606        if let Some(value) = values[index] {
607            edits.push((value_at, value_end, value.as_bytes().to_vec()));
608        } else {
609            // Remove only the owned attribute, keeping all surrounding source.
610            edits.push((key_at, value_end + 1, Vec::new()));
611        }
612    }
613    let mut added = Vec::new();
614    for (index, value) in values.iter().enumerate() {
615        if !found[index]
616            && let Some(value) = value
617        {
618            added.extend_from_slice(b" ");
619            added.extend_from_slice(keys[index]);
620            added.extend_from_slice(b"=\"");
621            added.extend_from_slice(value.as_bytes());
622            added.extend_from_slice(b"\"");
623        }
624    }
625    let start_end = reader.buffer_position() as usize;
626    let insertion = start_end - if empty { 2 } else { 1 };
627    edits.push((insertion, insertion, added));
628    if !empty {
629        let close = source
630            .windows(2)
631            .rposition(|bytes| bytes == b"</")
632            .ok_or_else(|| missing_end("autofit"))?
633            + 2;
634        edits.push((close, close + old_name.len(), name));
635    }
636    edits.sort_by_key(|edit| std::cmp::Reverse(edit.0));
637    let mut output = source.to_vec();
638    for (start, end, bytes) in edits {
639        output.splice(start..end, bytes);
640    }
641    writer
642        .get_mut()
643        .write_all(&output)
644        .map_err(OxmlError::from)?;
645    Ok(())
646}
647
648/// The attributes `from_start` models, which the raw list must not repeat.
649const MODELLED_ATTRIBUTES: [&[u8]; 8] = [
650    b"lIns",
651    b"tIns",
652    b"rIns",
653    b"bIns",
654    b"anchor",
655    b"wrap",
656    b"vert",
657    b"spcFirstLastPara",
658];
659
660fn capture_raw_attributes(start: &BytesStart<'_>) -> Result<Vec<(String, String)>> {
661    let mut raw = Vec::new();
662    for attribute in start.attributes() {
663        let attribute = attribute.map_err(OxmlError::from)?;
664        let key = attribute.key.as_ref();
665        if MODELLED_ATTRIBUTES.contains(&key) {
666            continue;
667        }
668        let name = std::str::from_utf8(key)
669            .map_err(OxmlError::from)?
670            .to_owned();
671        let value = attribute
672            .decoded_and_normalized_value(XmlVersion::Implicit1_0, start.decoder())
673            .map_err(OxmlError::from)?
674            .into_owned();
675        raw.push((name, value));
676    }
677    Ok(raw)
678}
679
680fn parse_coordinate(start: &BytesStart<'_>, attribute: &[u8]) -> Result<Option<Coordinate32Value>> {
681    get_body_attr(start, attribute)
682        .map(|value| Coordinate32Value::parse("bodyPr", &String::from_utf8_lossy(attribute), value))
683        .transpose()
684}
685
686fn parse_enum<T>(
687    start: &BytesStart<'_>,
688    attribute: &[u8],
689    parse: impl FnOnce(&str) -> Option<T>,
690) -> Result<Option<T>> {
691    let Some(value) = get_body_attr(start, attribute) else {
692        return Ok(None);
693    };
694    parse(&value)
695        .map(Some)
696        .ok_or_else(|| invalid_attribute("bodyPr", &String::from_utf8_lossy(attribute), value))
697}
698
699fn parse_optional_bool(start: &BytesStart<'_>, attribute: &[u8]) -> Result<Option<bool>> {
700    let Some(value) = get_body_attr(start, attribute) else {
701        return Ok(None);
702    };
703    match value.as_str() {
704        "1" | "true" => Ok(Some(true)),
705        "0" | "false" => Ok(Some(false)),
706        _ => Err(invalid_attribute(
707            "bodyPr",
708            &String::from_utf8_lossy(attribute),
709            value,
710        )),
711    }
712}
713
714fn get_body_attr(start: &BytesStart<'_>, name: &[u8]) -> Option<String> {
715    start
716        .attributes()
717        .flatten()
718        .find(|attribute| attribute.key.as_ref() == name)
719        .and_then(|attribute| {
720            std::str::from_utf8(&attribute.value)
721                .ok()
722                .map(str::to_owned)
723        })
724}
725
726fn is_autofit(name: &[u8]) -> bool {
727    matches!(name, b"noAutofit" | b"normAutofit" | b"spAutoFit")
728}
729
730fn schema_choice_index(name: &[u8]) -> Option<usize> {
731    match name {
732        b"prstTxWarp" => Some(0),
733        name if is_autofit(name) => Some(1),
734        b"scene3d" => Some(2),
735        b"sp3d" | b"flatTx" => Some(3),
736        b"extLst" => Some(4),
737        _ => None,
738    }
739}
740
741fn raw_boundary_after(name: &[u8]) -> usize {
742    match name {
743        b"prstTxWarp" => 1,
744        name if is_autofit(name) => 2,
745        b"scene3d" => 3,
746        b"sp3d" | b"flatTx" => 4,
747        b"extLst" => 5,
748        _ => 0,
749    }
750}
751
752enum PercentKind {
753    FontScale,
754    LineSpacing,
755}
756
757fn validate_autofit_percent(attribute: &str, value: &str, kind: PercentKind) -> Result<()> {
758    if is_percentage_string(value) {
759        return Ok(());
760    }
761    let integer = value
762        .parse::<i32>()
763        .map_err(|_| invalid_attribute("normAutofit", attribute, value.to_owned()))?;
764    let valid = match kind {
765        PercentKind::FontScale => (1_000..=100_000).contains(&integer),
766        PercentKind::LineSpacing => (0..=MAX_TEXT_SPACING_PERCENT).contains(&integer),
767    };
768    if valid {
769        Ok(())
770    } else {
771        Err(invalid_attribute(
772            "normAutofit",
773            attribute,
774            value.to_owned(),
775        ))
776    }
777}
778
779fn is_percentage_string(value: &str) -> bool {
780    let Some(number) = value.strip_suffix('%') else {
781        return false;
782    };
783    is_signed_decimal(number)
784}
785
786fn is_universal_measure(value: &str) -> bool {
787    if value.len() < 3 {
788        return false;
789    }
790    let (number, unit) = value.split_at(value.len() - 2);
791    matches!(unit, "mm" | "cm" | "in" | "pt" | "pc" | "pi") && is_signed_decimal(number)
792}
793
794fn is_signed_decimal(value: &str) -> bool {
795    let unsigned = value.strip_prefix('-').unwrap_or(value);
796    let mut parts = unsigned.split('.');
797    let Some(integer) = parts.next() else {
798        return false;
799    };
800    if integer.is_empty() || !integer.bytes().all(|byte| byte.is_ascii_digit()) {
801        return false;
802    }
803    if let Some(fraction) = parts.next()
804        && (fraction.is_empty() || !fraction.bytes().all(|byte| byte.is_ascii_digit()))
805    {
806        return false;
807    }
808    parts.next().is_none()
809}
810
811fn ensure_empty_element(reader: &mut Reader<&[u8]>, expected: &[u8]) -> Result<()> {
812    let mut buffer = Vec::new();
813    loop {
814        match reader
815            .read_event_into(&mut buffer)
816            .map_err(OxmlError::from)?
817        {
818            Event::End(element) if matches_local_name(element.name().as_ref(), expected) => {
819                return Ok(());
820            }
821            Event::Text(text) if text.iter().all(u8::is_ascii_whitespace) => {}
822            Event::Comment(_) => {}
823            Event::Eof => return Err(missing_end(&String::from_utf8_lossy(expected))),
824            Event::Start(element) | Event::Empty(element) => {
825                return Err(TextError::UnexpectedElement(element_name(&element)));
826            }
827            _ => {
828                return Err(TextError::UnexpectedElement(
829                    String::from_utf8_lossy(expected).into_owned(),
830                ));
831            }
832        }
833        buffer.clear();
834    }
835}
836
837fn emit_raw<'a, W: Write>(
838    writer: &mut Writer<W>,
839    children: impl Iterator<Item = &'a [u8]>,
840) -> Result<()> {
841    for child in children {
842        writer.get_mut().write_all(child).map_err(OxmlError::from)?;
843    }
844    Ok(())
845}
846
847fn write_empty<W: Write>(writer: &mut Writer<W>, start: BytesStart<'_>) -> Result<()> {
848    writer
849        .write_event(Event::Empty(start))
850        .map_err(OxmlError::from)?;
851    Ok(())
852}
853
854fn invalid_attribute(element: &str, attribute: &str, value: String) -> TextError {
855    TextError::InvalidAttribute {
856        element: element.to_owned(),
857        attribute: attribute.to_owned(),
858        value,
859    }
860}
861
862fn element_name(element: &BytesStart<'_>) -> String {
863    String::from_utf8_lossy(element.name().as_ref()).into_owned()
864}
865
866pub(crate) fn missing_end(element: &str) -> TextError {
867    TextError::Xml(OxmlError::MissingElement(format!(
868        "closing DrawingML {element}"
869    )))
870}
871
872#[cfg(test)]
873mod tests {
874    use std::panic;
875
876    use super::{CT_TextBodyProperties, Coordinate32Value, TextAnchor, TextAutofit};
877    use crate::text::CT_TextBody;
878
879    #[test]
880    fn every_body_property_autofit_form_round_trips_in_schema_order() {
881        let cases: &[(&[u8], &[u8])] = &[
882            (
883                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>"#,
884                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>"#,
885            ),
886            (
887                br#"<q:bodyPr><q:spAutoFit/></q:bodyPr>"#,
888                br#"<a:bodyPr><a:spAutoFit/></a:bodyPr>"#,
889            ),
890            (
891                br#"<q:bodyPr><q:normAutofit fontScale="62.500%" lnSpcReduction="20000"/></q:bodyPr>"#,
892                br#"<a:bodyPr><a:normAutofit fontScale="62.500%" lnSpcReduction="20000"/></a:bodyPr>"#,
893            ),
894        ];
895
896        for (xml, expected) in cases {
897            let parsed = CT_TextBodyProperties::from_xml(xml).unwrap();
898            let written = parsed.to_xml().unwrap();
899            assert_eq!(&written, expected);
900            assert_eq!(CT_TextBodyProperties::from_xml(&written).unwrap(), parsed);
901        }
902
903        let parsed = CT_TextBodyProperties::from_xml(cases[0].0).unwrap();
904        assert_eq!(parsed.left_inset, Some(Coordinate32Value::Emu(i32::MIN)));
905        assert_eq!(parsed.space_first_last_paragraph, Some(true));
906        assert!(matches!(parsed.autofit, Some(TextAutofit::NoAutofit)));
907    }
908
909    #[test]
910    fn every_body_property_attribute_survives_a_round_trip() {
911        let xml = br#"<q:bodyPr xmlns:x="urn:producer" rot="5400000" spcFirstLastPara="1" vertOverflow="clip" horzOverflow="overflow" vert="vert270" wrap="square" lIns="1" tIns="2" rIns="3" bIns="4" numCol="2" spcCol="91440" rtlCol="1" fromWordArt="1" anchor="ctr" anchorCtr="0" forceAA="1" upright="1" compatLnSpc="0" x:marker="kept"><q:normAutofit fontScale="90000"/></q:bodyPr>"#;
912        let parsed = CT_TextBodyProperties::from_xml(xml).unwrap();
913        let written = parsed.to_xml().unwrap();
914        // Modelled attributes keep their fixed order and every other one
915        // follows in source order, so a second pass writes the same bytes.
916        assert_eq!(
917            written,
918            br#"<a:bodyPr lIns="1" tIns="2" rIns="3" bIns="4" anchor="ctr" wrap="square" vert="vert270" spcFirstLastPara="1" xmlns:x="urn:producer" rot="5400000" vertOverflow="clip" horzOverflow="overflow" numCol="2" spcCol="91440" rtlCol="1" fromWordArt="1" anchorCtr="0" forceAA="1" upright="1" compatLnSpc="0" x:marker="kept"><a:normAutofit fontScale="90000"/></a:bodyPr>"#
919        );
920        let reparsed = CT_TextBodyProperties::from_xml(&written).unwrap();
921        assert_eq!(reparsed, parsed);
922        assert_eq!(reparsed.to_xml().unwrap(), written);
923
924        let mut edited = CT_TextBodyProperties::from_xml(
925            br#"<q:bodyPr numCol="3" spcCol="45720" rot="-600000" anchorCtr="1"/>"#,
926        )
927        .unwrap();
928        edited.anchor = Some(TextAnchor::Bottom);
929        edited.left_inset = Some(Coordinate32Value::Emu(0));
930        assert_eq!(
931            edited.to_xml().unwrap(),
932            br#"<a:bodyPr lIns="0" anchor="b" numCol="3" spcCol="45720" rot="-600000" anchorCtr="1"/>"#
933        );
934    }
935
936    #[test]
937    fn foreign_attribute_with_modelled_local_name_is_retained() {
938        let parsed = CT_TextBodyProperties::from_xml(
939            br#"<q:bodyPr xmlns:x="urn:producer" x:wrap="foreign" wrap="square"/>"#,
940        )
941        .unwrap();
942        assert!(parsed.wrap.is_some());
943        assert_eq!(
944            parsed.to_xml().unwrap(),
945            br#"<a:bodyPr wrap="square" xmlns:x="urn:producer" x:wrap="foreign"/>"#
946        );
947    }
948
949    #[test]
950    fn body_properties_preserve_unknown_children_at_their_boundaries() {
951        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>"#;
952        let written = CT_TextBodyProperties::from_xml(xml)
953            .unwrap()
954            .to_xml()
955            .unwrap();
956        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>"#);
957    }
958
959    #[test]
960    fn an_added_autofit_is_written_before_preserved_scene_and_extension_children() {
961        let mut properties = CT_TextBodyProperties::from_xml(
962            br#"<q:bodyPr><x:first/><q:scene3d><x:scene/></q:scene3d><q:flatTx/><q:extLst><x:ext/></q:extLst></q:bodyPr>"#,
963        )
964        .unwrap();
965        properties.autofit = Some(TextAutofit::ShapeAutofit);
966        let written = properties.to_xml().unwrap();
967        assert_eq!(
968            written,
969            br#"<a:bodyPr><x:first/><a:spAutoFit/><q:scene3d><x:scene/></q:scene3d><q:flatTx/><q:extLst><x:ext/></q:extLst></a:bodyPr>"#
970        );
971        assert_eq!(
972            CT_TextBodyProperties::from_xml(&written).unwrap(),
973            properties
974        );
975    }
976
977    #[test]
978    fn autofit_owned_updates_preserve_opaque_lexical_attributes_and_comments() {
979        let source = br#"<q:bodyPr xmlns:q="http://schemas.openxmlformats.org/drawingml/2006/main"><q:normAutofit xmlns:e='urn:example' e:proof='keep&amp;&#x21;' e:fontScale='foreign' fontScale='62500' lnSpcReduction = "20000"><!--keep opaque--></q:normAutofit></q:bodyPr>"#;
980        let mut body = CT_TextBodyProperties::from_xml(source).unwrap();
981        let opaque = "xmlns:e='urn:example' e:proof='keep&amp;&#x21;' e:fontScale='foreign'";
982        let unchanged = String::from_utf8(body.to_xml().unwrap()).unwrap();
983        assert!(unchanged.contains(opaque));
984        assert!(unchanged.contains("<!--keep opaque-->"));
985        body.autofit = Some(TextAutofit::Normal(super::NormalAutofit {
986            font_scale: Some("85000".to_owned()),
987            line_spacing_reduction: Some("10000".to_owned()),
988        }));
989        let updated = body.to_xml().unwrap();
990        let xml = String::from_utf8(updated.clone()).unwrap();
991        assert!(xml.contains(opaque));
992        assert!(xml.contains("fontScale='85000' lnSpcReduction = \"10000\""));
993        assert!(xml.contains("<!--keep opaque-->"));
994        assert_eq!(
995            CT_TextBodyProperties::from_xml(&updated).unwrap().autofit,
996            body.autofit
997        );
998        body.autofit = Some(TextAutofit::NoAutofit);
999        let removed = body.to_xml().unwrap();
1000        let xml = String::from_utf8(removed.clone()).unwrap();
1001        assert!(xml.contains("<q:noAutofit"));
1002        assert!(xml.contains(opaque));
1003        assert!(!xml.contains(" fontScale="));
1004        assert!(!xml.contains("lnSpcReduction"));
1005        assert!(xml.contains("<!--keep opaque--></q:noAutofit>"));
1006        let reopened = CT_TextBodyProperties::from_xml(&removed).unwrap();
1007        assert_eq!(reopened.autofit, Some(TextAutofit::NoAutofit));
1008        assert_eq!(removed, reopened.to_xml().unwrap());
1009    }
1010
1011    #[test]
1012    fn malformed_body_properties_return_errors_without_panicking() {
1013        let body_cases: &[&[u8]] = &[
1014            br#"<q:notBodyPr/>"#,
1015            br#"<q:bodyPr anchor="middle"/>"#,
1016            br#"<q:bodyPr wrap="tight"/>"#,
1017            br#"<q:bodyPr vert="sideways"/>"#,
1018            br#"<q:bodyPr lIns="2147483648"/>"#,
1019            br#"<q:bodyPr rIns="1.in"/>"#,
1020            br#"<q:bodyPr spcFirstLastPara="yes"/>"#,
1021            br#"<q:bodyPr><q:normAutofit fontScale="999"/></q:bodyPr>"#,
1022            br#"<q:bodyPr><q:normAutofit fontScale="100001"/></q:bodyPr>"#,
1023            br#"<q:bodyPr><q:normAutofit fontScale=".5%"/></q:bodyPr>"#,
1024            br#"<q:bodyPr><q:normAutofit lnSpcReduction="13200001"/></q:bodyPr>"#,
1025            br#"<q:bodyPr><q:noAutofit><x:child/></q:noAutofit></q:bodyPr>"#,
1026            br#"<q:bodyPr><q:noAutofit/><q:spAutoFit/></q:bodyPr>"#,
1027            br#"<q:bodyPr><q:sp3d/><q:flatTx/></q:bodyPr>"#,
1028            br#"<q:bodyPr xmlns:a="urn:producer" numCol="2"/>"#,
1029        ];
1030        for xml in body_cases {
1031            let result = panic::catch_unwind(|| CT_TextBodyProperties::from_xml(xml));
1032            assert!(result.is_ok(), "body-property parser panicked");
1033            assert!(result.unwrap().is_err(), "malformed body properties parsed");
1034        }
1035
1036        let shell_cases: &[&[u8]] = &[
1037            br#"<q:txBody><q:p/></q:txBody>"#,
1038            br#"<q:txBody/>"#,
1039            br#"<q:txBody><q:bodyPr/><q:bodyPr/><q:p/></q:txBody>"#,
1040            br#"<q:txBody><q:bodyPr/><q:lstStyle/><q:lstStyle/><q:p/></q:txBody>"#,
1041        ];
1042        for xml in shell_cases {
1043            let result = panic::catch_unwind(|| CT_TextBody::from_xml(xml));
1044            assert!(result.is_ok(), "text-body parser panicked");
1045            assert!(result.unwrap().is_err(), "malformed text body parsed");
1046        }
1047
1048        let empty = CT_TextBody::from_xml(br#"<q:txBody><q:bodyPr/></q:txBody>"#).unwrap();
1049        assert_eq!(empty.paragraph_count(), 0);
1050        assert!(empty.to_xml().is_ok());
1051
1052        let mut properties = CT_TextBodyProperties {
1053            left_inset: Some(Coordinate32Value::UniversalMeasure("NaNin".to_owned())),
1054            ..CT_TextBodyProperties::default()
1055        };
1056        assert!(properties.to_xml().is_err());
1057        properties.left_inset = Some(Coordinate32Value::Emu(i32::MAX));
1058        assert!(properties.to_xml().is_ok());
1059    }
1060}