Skip to main content

oxml_drawing/text/
paragraph.rs

1use std::borrow::Cow;
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 oxml_core::xml_text::{decode_plain, resolve_entity};
8use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
9use quick_xml::{Reader, Writer, XmlVersion};
10
11use crate::fill::Fill;
12use crate::namespace::reject_conflicting_a_prefix;
13use crate::order::OrderedRawChildren;
14
15use super::body::{Result, TextError, missing_end};
16use super::bullet::TextBullet;
17
18const MAX_TEXT_MARGIN: i32 = 51_206_400;
19const MAX_TEXT_POINT: i32 = 400_000;
20const MAX_TEXT_SPACING_POINT: i32 = 158_400;
21const MAX_TRANSITIONAL_SPACING_PERCENT: i32 = 13_200_000;
22
23/// Whether source text explicitly requested XML whitespace preservation.
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
25pub enum TextSpace {
26    #[default]
27    Default,
28    Preserve,
29}
30
31/// One DrawingML `a:t` value and its source whitespace intent.
32///
33/// `value` is the text as the part stores it and PowerPoint shows it. The
34/// text setters store a character XML 1.0 cannot carry, such as U+0001, as
35/// `_xHHHH_` with four uppercase hexadecimal digits, as python-pptx does, and
36/// reading never decodes that form, as neither python-pptx nor PowerPoint
37/// does.
38#[derive(Clone, Debug, Default, Eq, PartialEq)]
39pub struct TextValue {
40    pub value: String,
41    pub space: TextSpace,
42    raw_attributes: Vec<(String, String)>,
43}
44
45impl TextValue {
46    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
47        if !matches_local_name(start.name().as_ref(), b"t") {
48            return Err(unexpected(start));
49        }
50        let space_value = text_attr(start, b"xml:space")?;
51        let space = match space_value.as_deref() {
52            None | Some("default") => TextSpace::Default,
53            Some("preserve") => TextSpace::Preserve,
54            Some(value) => return Err(invalid_attribute("t", "xml:space", value)),
55        };
56        Ok(Self {
57            value: String::new(),
58            space,
59            raw_attributes: capture_raw_attributes(start, &[b"xml:space"])?,
60        })
61    }
62
63    fn from_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
64        let mut text = Self::from_start(start)?;
65        let mut buffer = Vec::new();
66        loop {
67            match reader
68                .read_event_into(&mut buffer)
69                .map_err(OxmlError::from)?
70            {
71                Event::Text(value) => text.value.push_str(&decode_plain(&value)),
72                Event::CData(value) => {
73                    let value = std::str::from_utf8(value.as_ref()).map_err(OxmlError::from)?;
74                    text.value.push_str(value);
75                }
76                Event::GeneralRef(value) => text.value.push_str(&resolve_entity(&value)),
77                Event::End(element) if matches_local_name(element.name().as_ref(), b"t") => {
78                    if text.space == TextSpace::Default
79                        && (text.value.chars().next().is_some_and(char::is_whitespace)
80                            || text
81                                .value
82                                .chars()
83                                .next_back()
84                                .is_some_and(char::is_whitespace))
85                    {
86                        text.space = TextSpace::Preserve;
87                    }
88                    return Ok(text);
89                }
90                Event::Start(element) | Event::Empty(element) => {
91                    return Err(unexpected(&element));
92                }
93                Event::Eof => return Err(missing_end("t")),
94                _ => {}
95            }
96            buffer.clear();
97        }
98    }
99
100    fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
101        let preserve = self.space == TextSpace::Preserve
102            || self.value.chars().next().is_some_and(char::is_whitespace)
103            || self
104                .value
105                .chars()
106                .next_back()
107                .is_some_and(char::is_whitespace);
108        let mut start = BytesStart::new("a:t");
109        if preserve {
110            start.push_attribute(("xml:space", "preserve"));
111        }
112        push_raw_attributes(&mut start, &self.raw_attributes);
113        if self.value.is_empty() {
114            return write_empty(writer, start);
115        }
116        write_start(writer, start)?;
117        writer
118            .write_event(Event::Text(BytesText::new(&self.value)))
119            .map_err(OxmlError::from)?;
120        write_end(writer, "a:t")
121    }
122}
123
124/// Spells each character XML 1.0 cannot carry as `_xHHHH_`, as python-pptx's
125/// run text setter does, so text holding one can still be saved.
126pub fn escape_invalid_xml_characters(text: &str) -> Cow<'_, str> {
127    let is_invalid = |character: char| !oxml_core::xml::is_xml_1_0_character(character);
128    if !text.chars().any(is_invalid) {
129        return Cow::Borrowed(text);
130    }
131    let mut escaped = String::with_capacity(text.len() + 6);
132    for character in text.chars() {
133        if is_invalid(character) {
134            escaped.push_str(&format!("_x{:04X}_", u32::from(character)));
135        } else {
136            escaped.push(character);
137        }
138    }
139    Cow::Owned(escaped)
140}
141
142/// The seven members of `ST_TextAlignType`.
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum TextAlignment {
145    Left,
146    Center,
147    Right,
148    Justified,
149    JustifiedLow,
150    Distributed,
151    ThaiDistributed,
152}
153
154impl TextAlignment {
155    fn parse(value: &str) -> Option<Self> {
156        match value {
157            "l" => Some(Self::Left),
158            "ctr" => Some(Self::Center),
159            "r" => Some(Self::Right),
160            "just" => Some(Self::Justified),
161            "justLow" => Some(Self::JustifiedLow),
162            "dist" => Some(Self::Distributed),
163            "thaiDist" => Some(Self::ThaiDistributed),
164            _ => None,
165        }
166    }
167
168    const fn as_str(self) -> &'static str {
169        match self {
170            Self::Left => "l",
171            Self::Center => "ctr",
172            Self::Right => "r",
173            Self::Justified => "just",
174            Self::JustifiedLow => "justLow",
175            Self::Distributed => "dist",
176            Self::ThaiDistributed => "thaiDist",
177        }
178    }
179}
180
181/// DrawingML paragraph spacing in percentage text or centipoints.
182#[derive(Clone, Debug, Eq, PartialEq)]
183pub enum TextSpacing {
184    Percent(String),
185    Points(i32),
186}
187
188impl TextSpacing {
189    fn from_xml(xml: &[u8], wrapper: &[u8]) -> Result<Self> {
190        parse_complete(
191            xml,
192            wrapper,
193            |reader, start| Self::from_element(reader, start, wrapper),
194            |_| {
195                Err(TextError::UnexpectedElement(
196                    String::from_utf8_lossy(wrapper).into_owned(),
197                ))
198            },
199        )
200    }
201
202    fn from_element(
203        reader: &mut Reader<&[u8]>,
204        start: &BytesStart<'_>,
205        wrapper: &[u8],
206    ) -> Result<Self> {
207        reject_conflicting_a_prefix(start)?;
208        let mut spacing = None;
209        let mut buffer = Vec::new();
210        loop {
211            match reader
212                .read_event_into(&mut buffer)
213                .map_err(OxmlError::from)?
214            {
215                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"spcPct") => {
216                    reject_conflicting_a_prefix(&element)?;
217                    if spacing.is_some() {
218                        return Err(duplicate("text spacing choice"));
219                    }
220                    let value = required_attr(&element, b"val")?;
221                    validate_spacing_percent(&value)?;
222                    spacing = Some(Self::Percent(value));
223                }
224                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"spcPts") => {
225                    reject_conflicting_a_prefix(&element)?;
226                    if spacing.is_some() {
227                        return Err(duplicate("text spacing choice"));
228                    }
229                    let value = parse_i32_attr(&element, b"val", 0, MAX_TEXT_SPACING_POINT)?;
230                    spacing = Some(Self::Points(value));
231                }
232                Event::Start(element)
233                    if matches_local_name(element.name().as_ref(), b"spcPct")
234                        || matches_local_name(element.name().as_ref(), b"spcPts") =>
235                {
236                    reject_conflicting_a_prefix(&element)?;
237                    if spacing.is_some() {
238                        return Err(duplicate("text spacing choice"));
239                    }
240                    let name = local_name(element.name().as_ref()).to_vec();
241                    let value = if name == b"spcPct" {
242                        let value = required_attr(&element, b"val")?;
243                        validate_spacing_percent(&value)?;
244                        Self::Percent(value)
245                    } else {
246                        Self::Points(parse_i32_attr(&element, b"val", 0, MAX_TEXT_SPACING_POINT)?)
247                    };
248                    ensure_empty(reader, &name)?;
249                    spacing = Some(value);
250                }
251                Event::Start(element) | Event::Empty(element) => return Err(unexpected(&element)),
252                Event::End(element) if matches_local_name(element.name().as_ref(), wrapper) => {
253                    return spacing.ok_or_else(|| {
254                        TextError::UnexpectedElement(String::from_utf8_lossy(wrapper).into_owned())
255                    });
256                }
257                Event::Eof => return Err(missing_end(&String::from_utf8_lossy(wrapper))),
258                _ => {}
259            }
260            buffer.clear();
261        }
262    }
263
264    fn write_xml<W: Write>(&self, writer: &mut Writer<W>, wrapper: &str) -> Result<()> {
265        write_start(writer, BytesStart::new(wrapper))?;
266        match self {
267            Self::Percent(value) => {
268                validate_spacing_percent(value)?;
269                let mut start = BytesStart::new("a:spcPct");
270                start.push_attribute(("val", value.as_str()));
271                write_empty(writer, start)?;
272            }
273            Self::Points(value) if (0..=MAX_TEXT_SPACING_POINT).contains(value) => {
274                let value = value.to_string();
275                let mut start = BytesStart::new("a:spcPts");
276                start.push_attribute(("val", value.as_str()));
277                write_empty(writer, start)?;
278            }
279            Self::Points(value) => {
280                return Err(invalid_attribute("spcPts", "val", &value.to_string()));
281            }
282        }
283        write_end(writer, wrapper)
284    }
285}
286
287/// Character spacing in centipoints or a strict universal measure.
288#[derive(Clone, Debug, Eq, PartialEq)]
289pub enum TextPointValue {
290    Centipoints(i32),
291    UniversalMeasure(String),
292}
293
294impl TextPointValue {
295    fn parse(value: String) -> Result<Self> {
296        if let Ok(point) = value.parse::<i32>() {
297            if (-MAX_TEXT_POINT..=MAX_TEXT_POINT).contains(&point) {
298                return Ok(Self::Centipoints(point));
299            }
300            return Err(invalid_attribute("rPr", "spc", &value));
301        }
302        if is_universal_measure(&value) {
303            Ok(Self::UniversalMeasure(value))
304        } else {
305            Err(invalid_attribute("rPr", "spc", &value))
306        }
307    }
308
309    fn as_xml(&self) -> Result<String> {
310        match self {
311            Self::Centipoints(value) if (-MAX_TEXT_POINT..=MAX_TEXT_POINT).contains(value) => {
312                Ok(value.to_string())
313            }
314            Self::Centipoints(value) => Err(invalid_attribute("rPr", "spc", &value.to_string())),
315            Self::UniversalMeasure(value) if is_universal_measure(value) => Ok(value.clone()),
316            Self::UniversalMeasure(value) => Err(invalid_attribute("rPr", "spc", value)),
317        }
318    }
319}
320
321/// DrawingML underline styles.
322#[derive(Clone, Copy, Debug, Eq, PartialEq)]
323pub enum TextUnderline {
324    None,
325    Words,
326    Single,
327    Double,
328    Heavy,
329    Dotted,
330    DottedHeavy,
331    Dash,
332    DashHeavy,
333    DashLong,
334    DashLongHeavy,
335    DotDash,
336    DotDashHeavy,
337    DotDotDash,
338    DotDotDashHeavy,
339    Wavy,
340    WavyHeavy,
341    WavyDouble,
342}
343
344impl TextUnderline {
345    fn parse(value: &str) -> Option<Self> {
346        Some(match value {
347            "none" => Self::None,
348            "words" => Self::Words,
349            "sng" => Self::Single,
350            "dbl" => Self::Double,
351            "heavy" => Self::Heavy,
352            "dotted" => Self::Dotted,
353            "dottedHeavy" => Self::DottedHeavy,
354            "dash" => Self::Dash,
355            "dashHeavy" => Self::DashHeavy,
356            "dashLong" => Self::DashLong,
357            "dashLongHeavy" => Self::DashLongHeavy,
358            "dotDash" => Self::DotDash,
359            "dotDashHeavy" => Self::DotDashHeavy,
360            "dotDotDash" => Self::DotDotDash,
361            "dotDotDashHeavy" => Self::DotDotDashHeavy,
362            "wavy" => Self::Wavy,
363            "wavyHeavy" => Self::WavyHeavy,
364            "wavyDbl" => Self::WavyDouble,
365            _ => return None,
366        })
367    }
368
369    /// Returns the `ST_TextUnderlineType` token as written in the file.
370    pub const fn as_str(self) -> &'static str {
371        match self {
372            Self::None => "none",
373            Self::Words => "words",
374            Self::Single => "sng",
375            Self::Double => "dbl",
376            Self::Heavy => "heavy",
377            Self::Dotted => "dotted",
378            Self::DottedHeavy => "dottedHeavy",
379            Self::Dash => "dash",
380            Self::DashHeavy => "dashHeavy",
381            Self::DashLong => "dashLong",
382            Self::DashLongHeavy => "dashLongHeavy",
383            Self::DotDash => "dotDash",
384            Self::DotDashHeavy => "dotDashHeavy",
385            Self::DotDotDash => "dotDotDash",
386            Self::DotDotDashHeavy => "dotDotDashHeavy",
387            Self::Wavy => "wavy",
388            Self::WavyHeavy => "wavyHeavy",
389            Self::WavyDouble => "wavyDbl",
390        }
391    }
392}
393
394/// DrawingML strike styles.
395#[derive(Clone, Copy, Debug, Eq, PartialEq)]
396pub enum TextStrike {
397    None,
398    Single,
399    Double,
400}
401
402impl TextStrike {
403    fn parse(value: &str) -> Option<Self> {
404        match value {
405            "noStrike" => Some(Self::None),
406            "sngStrike" => Some(Self::Single),
407            "dblStrike" => Some(Self::Double),
408            _ => None,
409        }
410    }
411
412    const fn as_str(self) -> &'static str {
413        match self {
414            Self::None => "noStrike",
415            Self::Single => "sngStrike",
416            Self::Double => "dblStrike",
417        }
418    }
419}
420
421/// A DrawingML text typeface reference.
422#[derive(Clone, Debug, Eq, PartialEq)]
423pub struct TextFont {
424    pub typeface: String,
425    raw_attributes: Vec<(String, String)>,
426}
427
428impl TextFont {
429    pub fn new(typeface: impl Into<String>) -> Result<Self> {
430        let typeface = typeface.into();
431        if typeface.is_empty() {
432            return Err(invalid_attribute("font", "typeface", ""));
433        }
434        Ok(Self {
435            typeface,
436            raw_attributes: Vec::new(),
437        })
438    }
439
440    pub(crate) fn from_xml(xml: &[u8], expected: &[u8]) -> Result<Self> {
441        parse_complete(
442            xml,
443            expected,
444            |reader, start| {
445                let font = Self::from_start(start)?;
446                ensure_empty(reader, expected)?;
447                Ok(font)
448            },
449            Self::from_start,
450        )
451    }
452
453    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
454        reject_conflicting_a_prefix(start)?;
455        Ok(Self {
456            typeface: required_attr(start, b"typeface")?,
457            raw_attributes: capture_raw_attributes(start, &[b"typeface"])?,
458        })
459    }
460
461    pub(crate) fn write_xml<W: Write>(&self, writer: &mut Writer<W>, tag: &str) -> Result<()> {
462        if self.typeface.is_empty() {
463            return Err(invalid_attribute(tag, "typeface", ""));
464        }
465        let mut start = BytesStart::new(tag);
466        start.push_attribute(("typeface", self.typeface.as_str()));
467        push_raw_attributes(&mut start, &self.raw_attributes);
468        write_empty(writer, start)
469    }
470}
471
472/// Hyperlink attributes retained on text character properties.
473#[derive(Clone, Debug, Default, Eq, PartialEq)]
474pub struct TextHyperlink {
475    pub relationship_id: Option<String>,
476    pub invalid_url: Option<String>,
477    pub action: Option<String>,
478    pub target_frame: Option<String>,
479    pub tooltip: Option<String>,
480    pub history: Option<bool>,
481    pub highlight_click: Option<bool>,
482    pub end_sound: Option<bool>,
483    raw_attributes: Vec<(String, String)>,
484    raw_children: OrderedRawChildren,
485}
486
487impl TextHyperlink {
488    fn from_xml(xml: &[u8], expected: &[u8]) -> Result<Self> {
489        parse_complete(
490            xml,
491            expected,
492            |reader, start| Self::from_element(reader, start, expected),
493            Self::from_start,
494        )
495    }
496
497    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
498        reject_conflicting_a_prefix(start)?;
499        let relationship_id = text_attr(start, b"r:id")?;
500        Ok(Self {
501            relationship_id,
502            invalid_url: text_attr(start, b"invalidUrl")?,
503            action: text_attr(start, b"action")?,
504            target_frame: text_attr(start, b"tgtFrame")?,
505            tooltip: text_attr(start, b"tooltip")?,
506            history: parse_optional_bool(start, b"history")?,
507            highlight_click: parse_optional_bool(start, b"highlightClick")?,
508            end_sound: parse_optional_bool(start, b"endSnd")?,
509            raw_attributes: capture_raw_attributes(
510                start,
511                &[
512                    b"r:id",
513                    b"invalidUrl",
514                    b"action",
515                    b"tgtFrame",
516                    b"tooltip",
517                    b"history",
518                    b"highlightClick",
519                    b"endSnd",
520                ],
521            )?,
522            raw_children: OrderedRawChildren::default(),
523        })
524    }
525
526    fn from_element(
527        reader: &mut Reader<&[u8]>,
528        start: &BytesStart<'_>,
529        expected: &[u8],
530    ) -> Result<Self> {
531        let mut hyperlink = Self::from_start(start)?;
532        let mut boundary = 0;
533        let mut seen = [false; 2];
534        let mut buffer = Vec::new();
535        loop {
536            match reader
537                .read_event_into(&mut buffer)
538                .map_err(OxmlError::from)?
539            {
540                Event::Start(element) => {
541                    let name = local_name(element.name().as_ref()).to_vec();
542                    let raw = capture_element(reader, &element)?;
543                    capture_hyperlink_raw(&mut hyperlink, &name, raw, &mut boundary, &mut seen)?;
544                }
545                Event::Empty(element) => {
546                    let name = local_name(element.name().as_ref()).to_vec();
547                    let raw = capture_empty_element(&element)?;
548                    capture_hyperlink_raw(&mut hyperlink, &name, raw, &mut boundary, &mut seen)?;
549                }
550                Event::End(element) if matches_local_name(element.name().as_ref(), expected) => {
551                    return Ok(hyperlink);
552                }
553                Event::Eof => return Err(missing_end(&String::from_utf8_lossy(expected))),
554                _ => {}
555            }
556            buffer.clear();
557        }
558    }
559
560    fn write_xml<W: Write>(&self, writer: &mut Writer<W>, tag: &str) -> Result<()> {
561        let mut start = BytesStart::new(tag);
562        push_optional_attr(&mut start, "r:id", self.relationship_id.as_deref());
563        push_optional_attr(&mut start, "invalidUrl", self.invalid_url.as_deref());
564        push_optional_attr(&mut start, "action", self.action.as_deref());
565        push_optional_attr(&mut start, "tgtFrame", self.target_frame.as_deref());
566        push_optional_attr(&mut start, "tooltip", self.tooltip.as_deref());
567        push_optional_bool(&mut start, "history", self.history);
568        push_optional_bool(&mut start, "highlightClick", self.highlight_click);
569        push_optional_bool(&mut start, "endSnd", self.end_sound);
570        push_raw_attributes(&mut start, &self.raw_attributes);
571        if self.raw_children.is_empty() {
572            return write_empty(writer, start);
573        }
574        write_start(writer, start)?;
575        for boundary in 0..=2 {
576            emit_raw(writer, self.raw_children.at(boundary))?;
577        }
578        write_end(writer, tag)
579    }
580}
581
582fn capture_hyperlink_raw(
583    hyperlink: &mut TextHyperlink,
584    name: &[u8],
585    raw: Vec<u8>,
586    boundary: &mut usize,
587    seen: &mut [bool; 2],
588) -> Result<()> {
589    let slot = match name {
590        b"snd" => Some(0),
591        b"extLst" => Some(1),
592        _ => None,
593    };
594    if let Some(slot) = slot {
595        if seen[slot] {
596            return Err(duplicate(&String::from_utf8_lossy(name)));
597        }
598        seen[slot] = true;
599        hyperlink.raw_children.push((*boundary).max(slot), raw);
600        *boundary = (*boundary).max(slot + 1);
601    } else {
602        hyperlink.raw_children.push(*boundary, raw);
603    }
604    Ok(())
605}
606
607/// DrawingML character properties shared by runs, breaks, fields, and defaults.
608#[allow(non_camel_case_types)]
609#[derive(Clone, Debug, Default, Eq, PartialEq)]
610pub struct CT_TextCharacterProperties {
611    pub font_size: Option<i32>,
612    pub bold: Option<bool>,
613    pub italic: Option<bool>,
614    pub all_caps: Option<bool>,
615    pub underline: Option<TextUnderline>,
616    pub strike: Option<TextStrike>,
617    pub spacing: Option<TextPointValue>,
618    pub baseline: Option<String>,
619    pub fill: Option<Fill>,
620    pub latin: Option<TextFont>,
621    pub east_asian: Option<TextFont>,
622    pub complex_script: Option<TextFont>,
623    pub symbol: Option<TextFont>,
624    pub hyperlink_click: Option<TextHyperlink>,
625    pub hyperlink_mouse_over: Option<TextHyperlink>,
626    raw_attributes: Vec<(String, String)>,
627    raw_children: OrderedRawChildren,
628}
629
630impl CT_TextCharacterProperties {
631    /// Parses one complete character-property element with any prefix.
632    pub fn from_xml(xml: &[u8]) -> Result<Self> {
633        let expected = root_local_name(xml)?;
634        if !is_character_property_tag(&expected) {
635            return Err(TextError::UnexpectedElement(
636                String::from_utf8_lossy(&expected).into_owned(),
637            ));
638        }
639        parse_complete(xml, &expected, Self::from_element, Self::from_start)
640    }
641
642    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
643        reject_conflicting_a_prefix(start)?;
644        let element = String::from_utf8_lossy(local_name(start.name().as_ref())).into_owned();
645        let font_size = text_attr(start, b"sz")?
646            .map(|value| parse_i32_value(&element, "sz", &value, 100, MAX_TEXT_POINT))
647            .transpose()?;
648        let spacing = text_attr(start, b"spc")?
649            .map(TextPointValue::parse)
650            .transpose()?;
651        let baseline = text_attr(start, b"baseline")?;
652        if let Some(value) = baseline.as_deref() {
653            validate_baseline(value)?;
654        }
655        let all_caps = match text_attr(start, b"cap")?.as_deref() {
656            Some("all") => Some(true),
657            Some("none") => Some(false),
658            _ => None,
659        };
660        let mut raw_attributes = capture_raw_attributes(
661            start,
662            &[b"sz", b"b", b"i", b"u", b"strike", b"spc", b"baseline"],
663        )?;
664        if all_caps.is_some() {
665            raw_attributes.retain(|(name, _)| name != "cap");
666        }
667        Ok(Self {
668            font_size,
669            bold: parse_optional_bool(start, b"b")?,
670            italic: parse_optional_bool(start, b"i")?,
671            all_caps,
672            underline: parse_optional_enum(start, b"u", TextUnderline::parse)?,
673            strike: parse_optional_enum(start, b"strike", TextStrike::parse)?,
674            spacing,
675            baseline,
676            raw_attributes,
677            ..Self::default()
678        })
679    }
680
681    fn from_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
682        let expected = local_name(start.name().as_ref()).to_vec();
683        let mut properties = Self::from_start(start)?;
684        let mut boundary = 0;
685        let mut seen = [false; 14];
686        let mut buffer = Vec::new();
687        loop {
688            match reader
689                .read_event_into(&mut buffer)
690                .map_err(OxmlError::from)?
691            {
692                Event::Start(element) => {
693                    let name = local_name(element.name().as_ref()).to_vec();
694                    let raw = capture_element(reader, &element)?;
695                    properties.capture_child(&name, raw, &mut boundary, &mut seen)?;
696                }
697                Event::Empty(element) => {
698                    let name = local_name(element.name().as_ref()).to_vec();
699                    let raw = capture_empty_element(&element)?;
700                    properties.capture_child(&name, raw, &mut boundary, &mut seen)?;
701                }
702                Event::End(element) if matches_local_name(element.name().as_ref(), &expected) => {
703                    return Ok(properties);
704                }
705                Event::Eof => return Err(missing_end(&String::from_utf8_lossy(&expected))),
706                _ => {}
707            }
708            buffer.clear();
709        }
710    }
711
712    fn capture_child(
713        &mut self,
714        name: &[u8],
715        raw: Vec<u8>,
716        boundary: &mut usize,
717        seen: &mut [bool; 14],
718    ) -> Result<()> {
719        let slot = character_property_slot(name);
720        if let Some(slot) = slot {
721            if seen[slot - 1] {
722                return Err(duplicate(&String::from_utf8_lossy(name)));
723            }
724            seen[slot - 1] = true;
725        }
726        match name {
727            name if is_fill(name) => self.fill = Some(Fill::from_xml(&raw)?),
728            b"latin" => self.latin = Some(TextFont::from_xml(&raw, b"latin")?),
729            b"ea" => self.east_asian = Some(TextFont::from_xml(&raw, b"ea")?),
730            b"cs" => self.complex_script = Some(TextFont::from_xml(&raw, b"cs")?),
731            b"sym" => self.symbol = Some(TextFont::from_xml(&raw, b"sym")?),
732            b"hlinkClick" => {
733                self.hyperlink_click = Some(TextHyperlink::from_xml(&raw, b"hlinkClick")?)
734            }
735            b"hlinkMouseOver" => {
736                self.hyperlink_mouse_over = Some(TextHyperlink::from_xml(&raw, b"hlinkMouseOver")?)
737            }
738            _ => self.raw_children.push(
739                slot.map_or(*boundary, |slot| (*boundary).max(slot - 1)),
740                raw,
741            ),
742        }
743        if let Some(slot) = slot {
744            *boundary = (*boundary).max(slot);
745        }
746        Ok(())
747    }
748
749    /// Writes character properties with a caller-selected fixed `a:` tag.
750    pub fn write_xml<W: Write>(&self, writer: &mut Writer<W>, tag: &str) -> Result<()> {
751        let mut start = BytesStart::new(tag);
752        if let Some(value) = self.font_size {
753            parse_i32_value(tag, "sz", &value.to_string(), 100, MAX_TEXT_POINT)?;
754            push_owned_attr(&mut start, "sz", value.to_string());
755        }
756        push_optional_bool(&mut start, "b", self.bold);
757        push_optional_bool(&mut start, "i", self.italic);
758        if let Some(value) = self.all_caps {
759            start.push_attribute(("cap", if value { "all" } else { "none" }));
760        }
761        if let Some(value) = self.underline {
762            start.push_attribute(("u", value.as_str()));
763        }
764        if let Some(value) = self.strike {
765            start.push_attribute(("strike", value.as_str()));
766        }
767        if let Some(value) = &self.spacing {
768            push_owned_attr(&mut start, "spc", value.as_xml()?);
769        }
770        if let Some(value) = self.baseline.as_deref() {
771            validate_baseline(value)?;
772            start.push_attribute(("baseline", value));
773        }
774        for (name, value) in &self.raw_attributes {
775            // A typed capitalisation replaces a preserved `cap="small"`.
776            if name == "cap" && self.all_caps.is_some() {
777                continue;
778            }
779            start.push_attribute((name.as_str(), value.as_str()));
780        }
781
782        let has_modelled_children = self.fill.is_some()
783            || self.latin.is_some()
784            || self.east_asian.is_some()
785            || self.complex_script.is_some()
786            || self.symbol.is_some()
787            || self.hyperlink_click.is_some()
788            || self.hyperlink_mouse_over.is_some();
789        if !has_modelled_children && self.raw_children.is_empty() {
790            return write_empty(writer, start);
791        }
792        write_start(writer, start)?;
793        emit_raw(writer, self.raw_children.at(0))?;
794        emit_raw(writer, self.raw_children.at(1))?;
795        if let Some(fill) = &self.fill {
796            fill.write_xml(writer)?;
797        }
798        for boundary in 2..=6 {
799            emit_raw(writer, self.raw_children.at(boundary))?;
800        }
801        if let Some(font) = &self.latin {
802            font.write_xml(writer, "a:latin")?;
803        }
804        emit_raw(writer, self.raw_children.at(7))?;
805        if let Some(font) = &self.east_asian {
806            font.write_xml(writer, "a:ea")?;
807        }
808        emit_raw(writer, self.raw_children.at(8))?;
809        if let Some(font) = &self.complex_script {
810            font.write_xml(writer, "a:cs")?;
811        }
812        emit_raw(writer, self.raw_children.at(9))?;
813        if let Some(font) = &self.symbol {
814            font.write_xml(writer, "a:sym")?;
815        }
816        emit_raw(writer, self.raw_children.at(10))?;
817        if let Some(hyperlink) = &self.hyperlink_click {
818            hyperlink.write_xml(writer, "a:hlinkClick")?;
819        }
820        emit_raw(writer, self.raw_children.at(11))?;
821        if let Some(hyperlink) = &self.hyperlink_mouse_over {
822            hyperlink.write_xml(writer, "a:hlinkMouseOver")?;
823        }
824        for boundary in 12..=14 {
825            emit_raw(writer, self.raw_children.at(boundary))?;
826        }
827        write_end(writer, tag)
828    }
829
830    pub fn raw_children(&self) -> &OrderedRawChildren {
831        &self.raw_children
832    }
833
834    /// Returns these properties as formatting for new text, without the click
835    /// and mouse-over hyperlinks, which name relationships rather than format.
836    pub(crate) fn without_hyperlinks(mut self) -> Self {
837        self.hyperlink_click = None;
838        self.hyperlink_mouse_over = None;
839        self
840    }
841}
842
843fn character_property_slot(name: &[u8]) -> Option<usize> {
844    match name {
845        b"ln" => Some(1),
846        name if is_fill(name) => Some(2),
847        b"effectLst" | b"effectDag" => Some(3),
848        b"highlight" => Some(4),
849        b"uLnTx" | b"uLn" => Some(5),
850        b"uFillTx" | b"uFill" => Some(6),
851        b"latin" => Some(7),
852        b"ea" => Some(8),
853        b"cs" => Some(9),
854        b"sym" => Some(10),
855        b"hlinkClick" => Some(11),
856        b"hlinkMouseOver" => Some(12),
857        b"rtl" => Some(13),
858        b"extLst" => Some(14),
859        _ => None,
860    }
861}
862
863fn is_character_property_tag(name: &[u8]) -> bool {
864    matches!(name, b"rPr" | b"defRPr" | b"endParaRPr")
865}
866
867fn is_fill(name: &[u8]) -> bool {
868    matches!(
869        name,
870        b"noFill" | b"solidFill" | b"gradFill" | b"pattFill" | b"blipFill"
871    )
872}
873
874/// Paragraph attributes and the story-owned spacing and default-run children.
875#[allow(non_camel_case_types)]
876#[derive(Clone, Debug, Default, Eq, PartialEq)]
877pub struct CT_TextParagraphProperties {
878    pub left_margin: Option<i32>,
879    pub right_margin: Option<i32>,
880    pub level: Option<u8>,
881    pub indent: Option<i32>,
882    pub alignment: Option<TextAlignment>,
883    pub right_to_left: Option<bool>,
884    pub line_spacing: Option<TextSpacing>,
885    pub space_before: Option<TextSpacing>,
886    pub space_after: Option<TextSpacing>,
887    pub bullet: Option<TextBullet>,
888    pub default_run_properties: Option<CT_TextCharacterProperties>,
889    raw_attributes: Vec<(String, String)>,
890    raw_children: OrderedRawChildren,
891}
892
893impl CT_TextParagraphProperties {
894    /// Parses one complete paragraph-property element with any prefix.
895    pub fn from_xml(xml: &[u8]) -> Result<Self> {
896        let expected = root_local_name(xml)?;
897        if !is_paragraph_property_tag(&expected) {
898            return Err(TextError::UnexpectedElement(
899                String::from_utf8_lossy(&expected).into_owned(),
900            ));
901        }
902        parse_complete(xml, &expected, Self::from_element, Self::from_start)
903    }
904
905    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
906        reject_conflicting_a_prefix(start)?;
907        let element = String::from_utf8_lossy(local_name(start.name().as_ref())).into_owned();
908        let level = text_attr(start, b"lvl")?
909            .map(|value| parse_i32_value(&element, "lvl", &value, 0, 8).map(|v| v as u8))
910            .transpose()?;
911        Ok(Self {
912            left_margin: parse_optional_i32(start, b"marL", 0, MAX_TEXT_MARGIN)?,
913            right_margin: parse_optional_i32(start, b"marR", 0, MAX_TEXT_MARGIN)?,
914            level,
915            indent: parse_optional_i32(start, b"indent", -MAX_TEXT_MARGIN, MAX_TEXT_MARGIN)?,
916            alignment: parse_optional_enum(start, b"algn", TextAlignment::parse)?,
917            right_to_left: parse_optional_bool(start, b"rtl")?,
918            raw_attributes: capture_raw_attributes(
919                start,
920                &[b"marL", b"marR", b"lvl", b"indent", b"algn"],
921            )?,
922            ..Self::default()
923        })
924    }
925
926    fn from_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
927        let expected = local_name(start.name().as_ref()).to_vec();
928        let mut properties = Self::from_start(start)?;
929        let mut boundary = 0;
930        let mut seen = [false; 10];
931        let mut buffer = Vec::new();
932        loop {
933            match reader
934                .read_event_into(&mut buffer)
935                .map_err(OxmlError::from)?
936            {
937                Event::Start(element) => {
938                    let name = local_name(element.name().as_ref()).to_vec();
939                    let raw = capture_element(reader, &element)?;
940                    properties.capture_child(&name, raw, &mut boundary, &mut seen)?;
941                }
942                Event::Empty(element) => {
943                    let name = local_name(element.name().as_ref()).to_vec();
944                    let raw = capture_empty_element(&element)?;
945                    properties.capture_child(&name, raw, &mut boundary, &mut seen)?;
946                }
947                Event::End(element) if matches_local_name(element.name().as_ref(), &expected) => {
948                    return Ok(properties);
949                }
950                Event::Eof => return Err(missing_end(&String::from_utf8_lossy(&expected))),
951                _ => {}
952            }
953            buffer.clear();
954        }
955    }
956
957    fn capture_child(
958        &mut self,
959        name: &[u8],
960        raw: Vec<u8>,
961        boundary: &mut usize,
962        seen: &mut [bool; 10],
963    ) -> Result<()> {
964        let slot = paragraph_property_slot(name);
965        if let Some(slot) = slot {
966            if seen[slot - 1] {
967                return Err(duplicate(&String::from_utf8_lossy(name)));
968            }
969            seen[slot - 1] = true;
970        }
971        match name {
972            b"lnSpc" => self.line_spacing = Some(TextSpacing::from_xml(&raw, b"lnSpc")?),
973            b"spcBef" => self.space_before = Some(TextSpacing::from_xml(&raw, b"spcBef")?),
974            b"spcAft" => self.space_after = Some(TextSpacing::from_xml(&raw, b"spcAft")?),
975            b"defRPr" => {
976                self.default_run_properties = Some(CT_TextCharacterProperties::from_xml(&raw)?)
977            }
978            _ => {
979                let mut bullet = self.bullet.take().unwrap_or_default();
980                if bullet.capture_component(name, &raw)? {
981                    self.bullet = Some(bullet);
982                } else {
983                    if !bullet.is_empty() {
984                        self.bullet = Some(bullet);
985                    }
986                    self.raw_children.push(
987                        slot.map_or(*boundary, |slot| (*boundary).max(slot - 1)),
988                        raw,
989                    );
990                }
991            }
992        }
993        if let Some(slot) = slot {
994            *boundary = (*boundary).max(slot);
995        }
996        Ok(())
997    }
998
999    /// Writes paragraph properties with a caller-selected fixed `a:` tag.
1000    pub fn write_xml<W: Write>(&self, writer: &mut Writer<W>, tag: &str) -> Result<()> {
1001        let mut start = BytesStart::new(tag);
1002        if let Some(value) = self.left_margin {
1003            parse_i32_value(tag, "marL", &value.to_string(), 0, MAX_TEXT_MARGIN)?;
1004            push_owned_attr(&mut start, "marL", value.to_string());
1005        }
1006        if let Some(value) = self.right_margin {
1007            parse_i32_value(tag, "marR", &value.to_string(), 0, MAX_TEXT_MARGIN)?;
1008            push_owned_attr(&mut start, "marR", value.to_string());
1009        }
1010        if let Some(value) = self.level {
1011            if value > 8 {
1012                return Err(invalid_attribute(tag, "lvl", &value.to_string()));
1013            }
1014            push_owned_attr(&mut start, "lvl", value.to_string());
1015        }
1016        if let Some(value) = self.indent {
1017            parse_i32_value(
1018                tag,
1019                "indent",
1020                &value.to_string(),
1021                -MAX_TEXT_MARGIN,
1022                MAX_TEXT_MARGIN,
1023            )?;
1024            push_owned_attr(&mut start, "indent", value.to_string());
1025        }
1026        if let Some(value) = self.alignment {
1027            start.push_attribute(("algn", value.as_str()));
1028        }
1029        let mut wrote_right_to_left = false;
1030        for (name, value) in &self.raw_attributes {
1031            if name == "rtl" {
1032                if let Some(value) = self.right_to_left {
1033                    start.push_attribute(("rtl", if value { "1" } else { "0" }));
1034                    wrote_right_to_left = true;
1035                }
1036            } else {
1037                start.push_attribute((name.as_str(), value.as_str()));
1038            }
1039        }
1040        if !wrote_right_to_left {
1041            push_optional_bool(&mut start, "rtl", self.right_to_left);
1042        }
1043
1044        let modelled = self.line_spacing.is_some()
1045            || self.space_before.is_some()
1046            || self.space_after.is_some()
1047            || self.bullet.is_some()
1048            || self.default_run_properties.is_some();
1049        if !modelled && self.raw_children.is_empty() {
1050            return write_empty(writer, start);
1051        }
1052        write_start(writer, start)?;
1053        emit_raw(writer, self.raw_children.at(0))?;
1054        if let Some(spacing) = &self.line_spacing {
1055            spacing.write_xml(writer, "a:lnSpc")?;
1056        }
1057        emit_raw(writer, self.raw_children.at(1))?;
1058        if let Some(spacing) = &self.space_before {
1059            spacing.write_xml(writer, "a:spcBef")?;
1060        }
1061        emit_raw(writer, self.raw_children.at(2))?;
1062        if let Some(spacing) = &self.space_after {
1063            spacing.write_xml(writer, "a:spcAft")?;
1064        }
1065        emit_raw(writer, self.raw_children.at(3))?;
1066        if let Some(bullet) = &self.bullet {
1067            bullet.write_color(writer)?;
1068        }
1069        emit_raw(writer, self.raw_children.at(4))?;
1070        if let Some(bullet) = &self.bullet {
1071            bullet.write_size(writer)?;
1072        }
1073        emit_raw(writer, self.raw_children.at(5))?;
1074        if let Some(bullet) = &self.bullet {
1075            bullet.write_font(writer)?;
1076        }
1077        emit_raw(writer, self.raw_children.at(6))?;
1078        if let Some(bullet) = &self.bullet {
1079            bullet.write_choice(writer)?;
1080        }
1081        emit_raw(writer, self.raw_children.at(7))?;
1082        emit_raw(writer, self.raw_children.at(8))?;
1083        if let Some(properties) = &self.default_run_properties {
1084            properties.write_xml(writer, "a:defRPr")?;
1085        }
1086        emit_raw(writer, self.raw_children.at(9))?;
1087        emit_raw(writer, self.raw_children.at(10))?;
1088        write_end(writer, tag)
1089    }
1090
1091    pub fn raw_children(&self) -> &OrderedRawChildren {
1092        &self.raw_children
1093    }
1094
1095    /// Returns whether the paragraph keeps a preserved `a:buBlip` picture bullet.
1096    pub fn has_picture_bullet(&self) -> bool {
1097        (0..=10).any(|boundary| {
1098            self.raw_children
1099                .at(boundary)
1100                .any(|raw| raw_local_name(raw) == b"buBlip")
1101        })
1102    }
1103
1104    /// Replaces the direct bullet.
1105    ///
1106    /// Each bullet group holds one member, so a colour, size, font, or choice
1107    /// set here removes its preserved `a:buClrTx`, `a:buSzTx`, `a:buFontTx`,
1108    /// or `a:buBlip` alternative. Clearing the bullet removes all four.
1109    pub fn set_bullet(&mut self, bullet: Option<TextBullet>) {
1110        let replaces = |name: &[u8]| {
1111            let Some(bullet) = bullet.as_ref() else {
1112                return matches!(name, b"buClrTx" | b"buSzTx" | b"buFontTx" | b"buBlip");
1113            };
1114            match name {
1115                b"buClrTx" => bullet.color.is_some(),
1116                b"buSzTx" => bullet.size.is_some(),
1117                b"buFontTx" => bullet.font.is_some(),
1118                b"buBlip" => bullet.choice.is_some(),
1119                _ => false,
1120            }
1121        };
1122        self.raw_children
1123            .retain(|raw| !replaces(raw_local_name(raw)));
1124        self.bullet = bullet;
1125    }
1126
1127    /// Serialises a complete paragraph-property fragment.
1128    pub fn to_xml(&self) -> Result<Vec<u8>> {
1129        let mut writer = Writer::new(Vec::new());
1130        self.write_xml(&mut writer, "a:pPr")?;
1131        Ok(writer.into_inner())
1132    }
1133}
1134
1135fn paragraph_property_slot(name: &[u8]) -> Option<usize> {
1136    match name {
1137        b"lnSpc" => Some(1),
1138        b"spcBef" => Some(2),
1139        b"spcAft" => Some(3),
1140        b"buClrTx" | b"buClr" => Some(4),
1141        b"buSzTx" | b"buSzPct" | b"buSzPts" => Some(5),
1142        b"buFontTx" | b"buFont" => Some(6),
1143        b"buNone" | b"buAutoNum" | b"buChar" | b"buBlip" => Some(7),
1144        b"tabLst" => Some(8),
1145        b"defRPr" => Some(9),
1146        b"extLst" => Some(10),
1147        _ => None,
1148    }
1149}
1150
1151/// Returns the local name of a captured raw element.
1152fn raw_local_name(raw: &[u8]) -> &[u8] {
1153    let tag = raw.strip_prefix(b"<").unwrap_or(raw);
1154    let end = tag
1155        .iter()
1156        .position(|byte| byte.is_ascii_whitespace() || matches!(byte, b'/' | b'>'))
1157        .unwrap_or(tag.len());
1158    local_name(&tag[..end])
1159}
1160
1161fn is_paragraph_property_tag(name: &[u8]) -> bool {
1162    matches!(
1163        name,
1164        b"pPr"
1165            | b"defPPr"
1166            | b"lvl1pPr"
1167            | b"lvl2pPr"
1168            | b"lvl3pPr"
1169            | b"lvl4pPr"
1170            | b"lvl5pPr"
1171            | b"lvl6pPr"
1172            | b"lvl7pPr"
1173            | b"lvl8pPr"
1174            | b"lvl9pPr"
1175    )
1176}
1177
1178/// A regular DrawingML run containing optional properties and required text.
1179#[allow(non_camel_case_types)]
1180#[derive(Clone, Debug, Eq, PartialEq)]
1181pub struct CT_RegularTextRun {
1182    pub properties: Option<CT_TextCharacterProperties>,
1183    pub text: TextValue,
1184    raw_children: OrderedRawChildren,
1185}
1186
1187impl CT_RegularTextRun {
1188    /// Creates a regular text run without direct character formatting.
1189    pub fn new(text: impl Into<String>) -> Self {
1190        Self {
1191            properties: None,
1192            text: TextValue {
1193                value: escape_invalid_xml_characters(&text.into()).into_owned(),
1194                ..TextValue::default()
1195            },
1196            raw_children: OrderedRawChildren::default(),
1197        }
1198    }
1199
1200    /// Replaces the run text while retaining properties and unmodelled XML.
1201    ///
1202    /// A character XML 1.0 cannot carry is stored as `_xHHHH_`, as
1203    /// python-pptx stores it.
1204    pub fn set_text(&mut self, text: &str) {
1205        self.text.value = escape_invalid_xml_characters(text).into_owned();
1206    }
1207
1208    /// Parses one complete `a:r` element with any prefix.
1209    pub fn from_xml(xml: &[u8]) -> Result<Self> {
1210        parse_complete(xml, b"r", Self::from_element, |_| {
1211            Err(TextError::UnexpectedElement("r".to_owned()))
1212        })
1213    }
1214
1215    fn from_element(reader: &mut Reader<&[u8]>, _start: &BytesStart<'_>) -> Result<Self> {
1216        let mut properties = None;
1217        let mut text = None;
1218        let mut raw_children = OrderedRawChildren::default();
1219        let mut boundary = 0;
1220        let mut buffer = Vec::new();
1221        loop {
1222            match reader
1223                .read_event_into(&mut buffer)
1224                .map_err(OxmlError::from)?
1225            {
1226                Event::Start(element) if matches_local_name(element.name().as_ref(), b"rPr") => {
1227                    if properties.is_some() {
1228                        return Err(duplicate("rPr"));
1229                    }
1230                    properties = Some(CT_TextCharacterProperties::from_element(reader, &element)?);
1231                    boundary = boundary.max(1);
1232                }
1233                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"rPr") => {
1234                    if properties.is_some() {
1235                        return Err(duplicate("rPr"));
1236                    }
1237                    properties = Some(CT_TextCharacterProperties::from_start(&element)?);
1238                    boundary = boundary.max(1);
1239                }
1240                Event::Start(element) if matches_local_name(element.name().as_ref(), b"t") => {
1241                    if text.is_some() {
1242                        return Err(duplicate("t"));
1243                    }
1244                    text = Some(TextValue::from_element(reader, &element)?);
1245                    boundary = boundary.max(2);
1246                }
1247                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"t") => {
1248                    if text.is_some() {
1249                        return Err(duplicate("t"));
1250                    }
1251                    text = Some(TextValue::from_start(&element)?);
1252                    boundary = boundary.max(2);
1253                }
1254                Event::Start(element) => {
1255                    raw_children.push(boundary, capture_element(reader, &element)?)
1256                }
1257                Event::Empty(element) => {
1258                    raw_children.push(boundary, capture_empty_element(&element)?)
1259                }
1260                Event::End(element) if matches_local_name(element.name().as_ref(), b"r") => break,
1261                Event::Eof => return Err(missing_end("r")),
1262                _ => {}
1263            }
1264            buffer.clear();
1265        }
1266        Ok(Self {
1267            properties,
1268            text: text.ok_or_else(|| {
1269                TextError::Xml(OxmlError::MissingElement("DrawingML run text".to_owned()))
1270            })?,
1271            raw_children,
1272        })
1273    }
1274
1275    fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
1276        write_start(writer, BytesStart::new("a:r"))?;
1277        emit_raw(writer, self.raw_children.at(0))?;
1278        if let Some(properties) = &self.properties {
1279            properties.write_xml(writer, "a:rPr")?;
1280        }
1281        emit_raw(writer, self.raw_children.at(1))?;
1282        self.text.write_xml(writer)?;
1283        emit_raw(writer, self.raw_children.at(2))?;
1284        write_end(writer, "a:r")
1285    }
1286}
1287
1288/// A DrawingML explicit line break and its optional run properties.
1289#[allow(non_camel_case_types)]
1290#[derive(Clone, Debug, Default, Eq, PartialEq)]
1291pub struct CT_TextLineBreak {
1292    pub properties: Option<CT_TextCharacterProperties>,
1293    raw_children: OrderedRawChildren,
1294}
1295
1296impl CT_TextLineBreak {
1297    /// Parses one complete `a:br` element with any prefix.
1298    pub fn from_xml(xml: &[u8]) -> Result<Self> {
1299        parse_complete(xml, b"br", Self::from_element, |_| Ok(Self::default()))
1300    }
1301
1302    fn from_element(reader: &mut Reader<&[u8]>, _start: &BytesStart<'_>) -> Result<Self> {
1303        let mut line_break = Self::default();
1304        let mut boundary = 0;
1305        let mut buffer = Vec::new();
1306        loop {
1307            match reader
1308                .read_event_into(&mut buffer)
1309                .map_err(OxmlError::from)?
1310            {
1311                Event::Start(element) if matches_local_name(element.name().as_ref(), b"rPr") => {
1312                    if line_break.properties.is_some() {
1313                        return Err(duplicate("rPr"));
1314                    }
1315                    line_break.properties =
1316                        Some(CT_TextCharacterProperties::from_element(reader, &element)?);
1317                    boundary = 1;
1318                }
1319                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"rPr") => {
1320                    if line_break.properties.is_some() {
1321                        return Err(duplicate("rPr"));
1322                    }
1323                    line_break.properties = Some(CT_TextCharacterProperties::from_start(&element)?);
1324                    boundary = 1;
1325                }
1326                Event::Start(element) => line_break
1327                    .raw_children
1328                    .push(boundary, capture_element(reader, &element)?),
1329                Event::Empty(element) => line_break
1330                    .raw_children
1331                    .push(boundary, capture_empty_element(&element)?),
1332                Event::End(element) if matches_local_name(element.name().as_ref(), b"br") => {
1333                    return Ok(line_break);
1334                }
1335                Event::Eof => return Err(missing_end("br")),
1336                _ => {}
1337            }
1338            buffer.clear();
1339        }
1340    }
1341
1342    fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
1343        if self.properties.is_none() && self.raw_children.is_empty() {
1344            return write_empty(writer, BytesStart::new("a:br"));
1345        }
1346        write_start(writer, BytesStart::new("a:br"))?;
1347        emit_raw(writer, self.raw_children.at(0))?;
1348        if let Some(properties) = &self.properties {
1349            properties.write_xml(writer, "a:rPr")?;
1350        }
1351        emit_raw(writer, self.raw_children.at(1))?;
1352        write_end(writer, "a:br")
1353    }
1354}
1355
1356/// A DrawingML field with required GUID, optional type, properties, and text.
1357#[allow(non_camel_case_types)]
1358#[derive(Clone, Debug, Eq, PartialEq)]
1359pub struct CT_TextField {
1360    pub id: String,
1361    pub field_type: Option<String>,
1362    pub run_properties: Option<CT_TextCharacterProperties>,
1363    pub paragraph_properties: Option<CT_TextParagraphProperties>,
1364    pub text: Option<TextValue>,
1365    raw_children: OrderedRawChildren,
1366}
1367
1368impl CT_TextField {
1369    /// Parses one complete `a:fld` element with any prefix.
1370    pub fn from_xml(xml: &[u8]) -> Result<Self> {
1371        parse_complete(xml, b"fld", Self::from_element, Self::from_empty)
1372    }
1373
1374    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
1375        let id = required_attr(start, b"id")?;
1376        if !is_guid(&id) {
1377            return Err(invalid_attribute("fld", "id", &id));
1378        }
1379        Ok(Self {
1380            id,
1381            field_type: text_attr(start, b"type")?,
1382            run_properties: None,
1383            paragraph_properties: None,
1384            text: None,
1385            raw_children: OrderedRawChildren::default(),
1386        })
1387    }
1388
1389    fn from_empty(start: &BytesStart<'_>) -> Result<Self> {
1390        Self::from_start(start)
1391    }
1392
1393    fn from_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
1394        let mut field = Self::from_start(start)?;
1395        let mut boundary = 0;
1396        let mut buffer = Vec::new();
1397        loop {
1398            match reader
1399                .read_event_into(&mut buffer)
1400                .map_err(OxmlError::from)?
1401            {
1402                Event::Start(element) if matches_local_name(element.name().as_ref(), b"rPr") => {
1403                    if field.run_properties.is_some() {
1404                        return Err(duplicate("rPr"));
1405                    }
1406                    field.run_properties =
1407                        Some(CT_TextCharacterProperties::from_element(reader, &element)?);
1408                    boundary = boundary.max(1);
1409                }
1410                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"rPr") => {
1411                    if field.run_properties.is_some() {
1412                        return Err(duplicate("rPr"));
1413                    }
1414                    field.run_properties = Some(CT_TextCharacterProperties::from_start(&element)?);
1415                    boundary = boundary.max(1);
1416                }
1417                Event::Start(element) if matches_local_name(element.name().as_ref(), b"pPr") => {
1418                    if field.paragraph_properties.is_some() {
1419                        return Err(duplicate("pPr"));
1420                    }
1421                    field.paragraph_properties =
1422                        Some(CT_TextParagraphProperties::from_element(reader, &element)?);
1423                    boundary = boundary.max(2);
1424                }
1425                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"pPr") => {
1426                    if field.paragraph_properties.is_some() {
1427                        return Err(duplicate("pPr"));
1428                    }
1429                    field.paragraph_properties =
1430                        Some(CT_TextParagraphProperties::from_start(&element)?);
1431                    boundary = boundary.max(2);
1432                }
1433                Event::Start(element) if matches_local_name(element.name().as_ref(), b"t") => {
1434                    if field.text.is_some() {
1435                        return Err(duplicate("t"));
1436                    }
1437                    field.text = Some(TextValue::from_element(reader, &element)?);
1438                    boundary = boundary.max(3);
1439                }
1440                Event::Empty(element) if matches_local_name(element.name().as_ref(), b"t") => {
1441                    if field.text.is_some() {
1442                        return Err(duplicate("t"));
1443                    }
1444                    field.text = Some(TextValue::from_start(&element)?);
1445                    boundary = boundary.max(3);
1446                }
1447                Event::Start(element) => field
1448                    .raw_children
1449                    .push(boundary, capture_element(reader, &element)?),
1450                Event::Empty(element) => field
1451                    .raw_children
1452                    .push(boundary, capture_empty_element(&element)?),
1453                Event::End(element) if matches_local_name(element.name().as_ref(), b"fld") => {
1454                    return Ok(field);
1455                }
1456                Event::Eof => return Err(missing_end("fld")),
1457                _ => {}
1458            }
1459            buffer.clear();
1460        }
1461    }
1462
1463    fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
1464        if !is_guid(&self.id) {
1465            return Err(invalid_attribute("fld", "id", &self.id));
1466        }
1467        let mut start = BytesStart::new("a:fld");
1468        start.push_attribute(("id", self.id.as_str()));
1469        push_optional_attr(&mut start, "type", self.field_type.as_deref());
1470        let modelled = self.run_properties.is_some()
1471            || self.paragraph_properties.is_some()
1472            || self.text.is_some();
1473        if !modelled && self.raw_children.is_empty() {
1474            return write_empty(writer, start);
1475        }
1476        write_start(writer, start)?;
1477        emit_raw(writer, self.raw_children.at(0))?;
1478        if let Some(properties) = &self.run_properties {
1479            properties.write_xml(writer, "a:rPr")?;
1480        }
1481        emit_raw(writer, self.raw_children.at(1))?;
1482        if let Some(properties) = &self.paragraph_properties {
1483            properties.write_xml(writer, "a:pPr")?;
1484        }
1485        emit_raw(writer, self.raw_children.at(2))?;
1486        if let Some(text) = &self.text {
1487            text.write_xml(writer)?;
1488        }
1489        emit_raw(writer, self.raw_children.at(3))?;
1490        write_end(writer, "a:fld")
1491    }
1492}
1493
1494/// One member of the paragraph text-content choice.
1495#[derive(Clone, Debug, Eq, PartialEq)]
1496pub enum TextRun {
1497    Run(CT_RegularTextRun),
1498    Break(CT_TextLineBreak),
1499    Field(Box<CT_TextField>),
1500}
1501
1502impl TextRun {
1503    fn from_xml(xml: &[u8], name: &[u8]) -> Result<Self> {
1504        match name {
1505            b"r" => Ok(Self::Run(CT_RegularTextRun::from_xml(xml)?)),
1506            b"br" => Ok(Self::Break(CT_TextLineBreak::from_xml(xml)?)),
1507            b"fld" => Ok(Self::Field(Box::new(CT_TextField::from_xml(xml)?))),
1508            _ => Err(TextError::UnexpectedElement(
1509                String::from_utf8_lossy(name).into_owned(),
1510            )),
1511        }
1512    }
1513
1514    fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
1515        match self {
1516            Self::Run(run) => run.write_xml(writer),
1517            Self::Break(line_break) => line_break.write_xml(writer),
1518            Self::Field(field) => field.write_xml(writer),
1519        }
1520    }
1521}
1522
1523/// A DrawingML paragraph with ordered runs, breaks, and fields.
1524#[allow(non_camel_case_types)]
1525#[derive(Clone, Debug, Default, Eq, PartialEq)]
1526pub struct CT_TextParagraph {
1527    pub properties: Option<CT_TextParagraphProperties>,
1528    pub runs: Vec<TextRun>,
1529    pub end_properties: Option<CT_TextCharacterProperties>,
1530    raw_children: OrderedRawChildren,
1531}
1532
1533impl CT_TextParagraph {
1534    /// Returns direct properties, inserting them before preserved content.
1535    pub fn properties_mut(&mut self) -> &mut CT_TextParagraphProperties {
1536        if self.properties.is_none() {
1537            let mut raw_children = OrderedRawChildren::default();
1538            for boundary in 0..=2 + self.runs.len() {
1539                let new_boundary = if boundary == 0 { 1 } else { boundary };
1540                for child in self.raw_children.at(boundary) {
1541                    raw_children.push(new_boundary, child.to_vec());
1542                }
1543            }
1544            self.raw_children = raw_children;
1545            self.properties = Some(CT_TextParagraphProperties::default());
1546        }
1547        self.properties
1548            .as_mut()
1549            .expect("paragraph properties were inserted")
1550    }
1551
1552    /// Replaces ordered text choices with one regular run, split at each
1553    /// vertical tab by an `a:br` with the run's formatting, since a vertical
1554    /// tab is how the paragraph text reports a line break.
1555    ///
1556    /// The first existing regular run supplies direct formatting and
1557    /// unmodelled run content for the replacement. A paragraph without one
1558    /// formats the new run with its end-of-paragraph properties, without
1559    /// their hyperlinks, as PowerPoint formats text typed into an empty
1560    /// paragraph.
1561    pub fn set_text(&mut self, text: &str) {
1562        let old_run_count = self.runs.len();
1563        let retained_run = self.runs.iter().find_map(|run| match run {
1564            TextRun::Run(run) => Some(run.clone()),
1565            TextRun::Break(_) | TextRun::Field(_) => None,
1566        });
1567        let mut raw_children = OrderedRawChildren::default();
1568        for boundary in 0..=2 + old_run_count {
1569            let new_boundary = if boundary <= 1 {
1570                boundary
1571            } else if boundary <= 1 + old_run_count {
1572                2
1573            } else {
1574                3
1575            };
1576            for child in self.raw_children.at(boundary) {
1577                raw_children.push(new_boundary, child.to_vec());
1578            }
1579        }
1580        self.raw_children = raw_children;
1581        let mut run = retained_run.unwrap_or_else(|| CT_RegularTextRun {
1582            properties: self
1583                .end_properties
1584                .clone()
1585                .map(CT_TextCharacterProperties::without_hyperlinks),
1586            ..CT_RegularTextRun::new(text)
1587        });
1588        run.set_text(text);
1589        self.runs = vec![TextRun::Run(run)];
1590        self.split_line_breaks(text);
1591    }
1592
1593    /// Splits the one run `set_text` leaves at each vertical tab of `text`.
1594    /// A later run that would be empty is left out, as python-pptx leaves it
1595    /// out, and content preserved after the old runs stays after the new ones.
1596    fn split_line_breaks(&mut self, text: &str) {
1597        let [TextRun::Run(first)] = self.runs.as_mut_slice() else {
1598            return;
1599        };
1600        if !text.contains('\u{b}') {
1601            return;
1602        }
1603        let mut lines = text.split('\u{b}');
1604        first.set_text(lines.next().unwrap_or_default());
1605        let properties = first.properties.clone();
1606        for line in lines {
1607            self.raw_children.shift_boundaries_from(2);
1608            self.runs.push(TextRun::Break(CT_TextLineBreak {
1609                properties: properties.clone(),
1610                ..CT_TextLineBreak::default()
1611            }));
1612            if !line.is_empty() {
1613                self.raw_children.shift_boundaries_from(2);
1614                self.runs.push(TextRun::Run(CT_RegularTextRun {
1615                    properties: properties.clone(),
1616                    ..CT_RegularTextRun::new(line)
1617                }));
1618            }
1619        }
1620    }
1621
1622    /// Appends one regular run after the existing ordered text choices.
1623    pub fn add_run(&mut self, text: &str) -> &mut CT_RegularTextRun {
1624        self.raw_children.shift_boundaries_from(2 + self.runs.len());
1625        self.runs.push(TextRun::Run(CT_RegularTextRun::new(text)));
1626        let Some(TextRun::Run(run)) = self.runs.last_mut() else {
1627            unreachable!("the appended text choice is a regular run")
1628        };
1629        run
1630    }
1631
1632    /// Parses one complete `a:p` element with any prefix.
1633    pub fn from_xml(xml: &[u8]) -> Result<Self> {
1634        parse_complete(xml, b"p", Self::from_element, |_| Ok(Self::default()))
1635    }
1636
1637    fn from_element(reader: &mut Reader<&[u8]>, _start: &BytesStart<'_>) -> Result<Self> {
1638        let mut paragraph = Self::default();
1639        let mut boundary = 0;
1640        let mut buffer = Vec::new();
1641        loop {
1642            match reader
1643                .read_event_into(&mut buffer)
1644                .map_err(OxmlError::from)?
1645            {
1646                // The schema allows one leading pPr, but real decks carry a
1647                // later one between runs. The first governs the paragraph and
1648                // every later one falls through to verbatim preservation at
1649                // its position, which is also how python-pptx treats it.
1650                Event::Start(element)
1651                    if matches_local_name(element.name().as_ref(), b"pPr")
1652                        && paragraph.properties.is_none() =>
1653                {
1654                    paragraph.properties =
1655                        Some(CT_TextParagraphProperties::from_element(reader, &element)?);
1656                    boundary = boundary.max(1);
1657                }
1658                Event::Empty(element)
1659                    if matches_local_name(element.name().as_ref(), b"pPr")
1660                        && paragraph.properties.is_none() =>
1661                {
1662                    paragraph.properties = Some(CT_TextParagraphProperties::from_start(&element)?);
1663                    boundary = boundary.max(1);
1664                }
1665                Event::Start(element) if is_text_run(element.name().as_ref()) => {
1666                    let name = local_name(element.name().as_ref()).to_vec();
1667                    let raw = capture_element(reader, &element)?;
1668                    paragraph.runs.push(TextRun::from_xml(&raw, &name)?);
1669                    boundary = boundary.max(1 + paragraph.runs.len());
1670                }
1671                Event::Empty(element) if is_text_run(element.name().as_ref()) => {
1672                    let name = local_name(element.name().as_ref()).to_vec();
1673                    let raw = capture_empty_element(&element)?;
1674                    paragraph.runs.push(TextRun::from_xml(&raw, &name)?);
1675                    boundary = boundary.max(1 + paragraph.runs.len());
1676                }
1677                Event::Start(element)
1678                    if matches_local_name(element.name().as_ref(), b"endParaRPr") =>
1679                {
1680                    if paragraph.end_properties.is_some() {
1681                        return Err(duplicate("endParaRPr"));
1682                    }
1683                    paragraph.end_properties =
1684                        Some(CT_TextCharacterProperties::from_element(reader, &element)?);
1685                    boundary = boundary.max(2 + paragraph.runs.len());
1686                }
1687                Event::Empty(element)
1688                    if matches_local_name(element.name().as_ref(), b"endParaRPr") =>
1689                {
1690                    if paragraph.end_properties.is_some() {
1691                        return Err(duplicate("endParaRPr"));
1692                    }
1693                    paragraph.end_properties =
1694                        Some(CT_TextCharacterProperties::from_start(&element)?);
1695                    boundary = boundary.max(2 + paragraph.runs.len());
1696                }
1697                Event::Start(element) => paragraph
1698                    .raw_children
1699                    .push(boundary, capture_element(reader, &element)?),
1700                Event::Empty(element) => paragraph
1701                    .raw_children
1702                    .push(boundary, capture_empty_element(&element)?),
1703                Event::End(element) if matches_local_name(element.name().as_ref(), b"p") => {
1704                    return Ok(paragraph);
1705                }
1706                Event::Eof => return Err(missing_end("p")),
1707                _ => {}
1708            }
1709            buffer.clear();
1710        }
1711    }
1712
1713    /// Writes a paragraph with fixed prefixes and schema child order.
1714    pub fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
1715        if self.properties.is_none()
1716            && self.runs.is_empty()
1717            && self.end_properties.is_none()
1718            && self.raw_children.is_empty()
1719        {
1720            return write_empty(writer, BytesStart::new("a:p"));
1721        }
1722        write_start(writer, BytesStart::new("a:p"))?;
1723        emit_raw(writer, self.raw_children.at(0))?;
1724        if let Some(properties) = &self.properties {
1725            properties.write_xml(writer, "a:pPr")?;
1726        }
1727        emit_raw(writer, self.raw_children.at(1))?;
1728        for (index, run) in self.runs.iter().enumerate() {
1729            run.write_xml(writer)?;
1730            emit_raw(writer, self.raw_children.at(2 + index))?;
1731        }
1732        if let Some(properties) = &self.end_properties {
1733            properties.write_xml(writer, "a:endParaRPr")?;
1734        }
1735        emit_raw(writer, self.raw_children.at(2 + self.runs.len()))?;
1736        write_end(writer, "a:p")
1737    }
1738
1739    /// Serialises a complete paragraph fragment.
1740    pub fn to_xml(&self) -> Result<Vec<u8>> {
1741        let mut writer = Writer::new(Vec::new());
1742        self.write_xml(&mut writer)?;
1743        Ok(writer.into_inner())
1744    }
1745
1746    pub fn raw_children(&self) -> &OrderedRawChildren {
1747        &self.raw_children
1748    }
1749}
1750
1751fn is_text_run(name: &[u8]) -> bool {
1752    matches!(local_name(name), b"r" | b"br" | b"fld")
1753}
1754
1755fn parse_complete<T>(
1756    xml: &[u8],
1757    expected: &[u8],
1758    parse_start: impl FnOnce(&mut Reader<&[u8]>, &BytesStart<'_>) -> Result<T>,
1759    parse_empty: impl FnOnce(&BytesStart<'_>) -> Result<T>,
1760) -> Result<T> {
1761    let mut reader = Reader::from_reader(xml);
1762    let mut buffer = Vec::new();
1763    loop {
1764        match reader
1765            .read_event_into(&mut buffer)
1766            .map_err(OxmlError::from)?
1767        {
1768            Event::Start(element) if matches_local_name(element.name().as_ref(), expected) => {
1769                reject_conflicting_a_prefix(&element)?;
1770                return parse_start(&mut reader, &element);
1771            }
1772            Event::Empty(element) if matches_local_name(element.name().as_ref(), expected) => {
1773                reject_conflicting_a_prefix(&element)?;
1774                return parse_empty(&element);
1775            }
1776            Event::Start(element) | Event::Empty(element) => return Err(unexpected(&element)),
1777            Event::Eof => {
1778                return Err(TextError::Xml(OxmlError::MissingElement(
1779                    String::from_utf8_lossy(expected).into_owned(),
1780                )));
1781            }
1782            _ => {}
1783        }
1784        buffer.clear();
1785    }
1786}
1787
1788fn root_local_name(xml: &[u8]) -> Result<Vec<u8>> {
1789    let mut reader = Reader::from_reader(xml);
1790    let mut buffer = Vec::new();
1791    loop {
1792        match reader
1793            .read_event_into(&mut buffer)
1794            .map_err(OxmlError::from)?
1795        {
1796            Event::Start(element) | Event::Empty(element) => {
1797                return Ok(local_name(element.name().as_ref()).to_vec());
1798            }
1799            Event::Eof => {
1800                return Err(TextError::Xml(OxmlError::MissingElement(
1801                    "DrawingML text properties".to_owned(),
1802                )));
1803            }
1804            _ => {}
1805        }
1806        buffer.clear();
1807    }
1808}
1809
1810fn ensure_empty(reader: &mut Reader<&[u8]>, expected: &[u8]) -> Result<()> {
1811    let mut buffer = Vec::new();
1812    loop {
1813        match reader
1814            .read_event_into(&mut buffer)
1815            .map_err(OxmlError::from)?
1816        {
1817            Event::End(element) if matches_local_name(element.name().as_ref(), expected) => {
1818                return Ok(());
1819            }
1820            Event::Text(text) if text.iter().all(u8::is_ascii_whitespace) => {}
1821            Event::Comment(_) => {}
1822            Event::Start(element) | Event::Empty(element) => return Err(unexpected(&element)),
1823            Event::Eof => return Err(missing_end(&String::from_utf8_lossy(expected))),
1824            _ => {
1825                return Err(TextError::UnexpectedElement(
1826                    String::from_utf8_lossy(expected).into_owned(),
1827                ));
1828            }
1829        }
1830        buffer.clear();
1831    }
1832}
1833
1834fn text_attr(start: &BytesStart<'_>, name: &[u8]) -> Result<Option<String>> {
1835    for attribute in start.attributes() {
1836        let attribute = attribute.map_err(OxmlError::from)?;
1837        if attribute.key.as_ref() == name {
1838            let value = attribute
1839                .decoded_and_normalized_value(XmlVersion::Implicit1_0, start.decoder())
1840                .map_err(OxmlError::from)?;
1841            return Ok(Some(value.into_owned()));
1842        }
1843    }
1844    Ok(None)
1845}
1846
1847fn capture_raw_attributes(
1848    start: &BytesStart<'_>,
1849    modelled: &[&[u8]],
1850) -> Result<Vec<(String, String)>> {
1851    let mut raw = Vec::new();
1852    for attribute in start.attributes() {
1853        let attribute = attribute.map_err(OxmlError::from)?;
1854        if modelled.iter().any(|name| attribute.key.as_ref() == *name) {
1855            continue;
1856        }
1857        let name = std::str::from_utf8(attribute.key.as_ref())
1858            .map_err(OxmlError::from)?
1859            .to_owned();
1860        let value = attribute
1861            .decoded_and_normalized_value(XmlVersion::Implicit1_0, start.decoder())
1862            .map_err(OxmlError::from)?
1863            .into_owned();
1864        raw.push((name, value));
1865    }
1866    Ok(raw)
1867}
1868
1869fn push_raw_attributes(start: &mut BytesStart<'_>, attributes: &[(String, String)]) {
1870    for (name, value) in attributes {
1871        start.push_attribute((name.as_str(), value.as_str()));
1872    }
1873}
1874
1875fn required_attr(start: &BytesStart<'_>, name: &[u8]) -> Result<String> {
1876    text_attr(start, name)?
1877        .filter(|value| !value.is_empty())
1878        .ok_or_else(|| {
1879            missing_attribute(
1880                &String::from_utf8_lossy(local_name(start.name().as_ref())),
1881                &String::from_utf8_lossy(name),
1882            )
1883        })
1884}
1885
1886fn parse_optional_i32(
1887    start: &BytesStart<'_>,
1888    name: &[u8],
1889    min: i32,
1890    max: i32,
1891) -> Result<Option<i32>> {
1892    text_attr(start, name)?
1893        .map(|value| {
1894            parse_i32_value(
1895                &String::from_utf8_lossy(local_name(start.name().as_ref())),
1896                &String::from_utf8_lossy(name),
1897                &value,
1898                min,
1899                max,
1900            )
1901        })
1902        .transpose()
1903}
1904
1905fn parse_i32_attr(start: &BytesStart<'_>, name: &[u8], min: i32, max: i32) -> Result<i32> {
1906    let value = required_attr(start, name)?;
1907    parse_i32_value(
1908        &String::from_utf8_lossy(local_name(start.name().as_ref())),
1909        &String::from_utf8_lossy(name),
1910        &value,
1911        min,
1912        max,
1913    )
1914}
1915
1916fn parse_i32_value(element: &str, attribute: &str, value: &str, min: i32, max: i32) -> Result<i32> {
1917    let parsed = value
1918        .parse::<i32>()
1919        .map_err(|_| invalid_attribute(element, attribute, value))?;
1920    if (min..=max).contains(&parsed) {
1921        Ok(parsed)
1922    } else {
1923        Err(invalid_attribute(element, attribute, value))
1924    }
1925}
1926
1927fn parse_optional_bool(start: &BytesStart<'_>, name: &[u8]) -> Result<Option<bool>> {
1928    text_attr(start, name)?
1929        .map(|value| match value.as_str() {
1930            "true" | "1" => Ok(true),
1931            "false" | "0" => Ok(false),
1932            _ => Err(invalid_attribute(
1933                &String::from_utf8_lossy(local_name(start.name().as_ref())),
1934                &String::from_utf8_lossy(name),
1935                &value,
1936            )),
1937        })
1938        .transpose()
1939}
1940
1941fn parse_optional_enum<T>(
1942    start: &BytesStart<'_>,
1943    name: &[u8],
1944    parse: impl FnOnce(&str) -> Option<T>,
1945) -> Result<Option<T>> {
1946    let Some(value) = text_attr(start, name)? else {
1947        return Ok(None);
1948    };
1949    parse(&value).map(Some).ok_or_else(|| {
1950        invalid_attribute(
1951            &String::from_utf8_lossy(local_name(start.name().as_ref())),
1952            &String::from_utf8_lossy(name),
1953            &value,
1954        )
1955    })
1956}
1957
1958fn validate_spacing_percent(value: &str) -> Result<()> {
1959    if is_percentage_string(value) {
1960        return Ok(());
1961    }
1962    parse_i32_value("spcPct", "val", value, 0, MAX_TRANSITIONAL_SPACING_PERCENT)?;
1963    Ok(())
1964}
1965
1966fn validate_baseline(value: &str) -> Result<()> {
1967    if is_percentage_string(value) {
1968        return Ok(());
1969    }
1970    parse_i32_value("rPr", "baseline", value, -100_000, 100_000)?;
1971    Ok(())
1972}
1973
1974fn is_percentage_string(value: &str) -> bool {
1975    value.strip_suffix('%').is_some_and(is_signed_decimal)
1976}
1977
1978fn is_universal_measure(value: &str) -> bool {
1979    ["mm", "cm", "in", "pt", "pc", "pi"]
1980        .iter()
1981        .find_map(|unit| value.strip_suffix(unit))
1982        .is_some_and(is_signed_decimal)
1983}
1984
1985fn is_signed_decimal(value: &str) -> bool {
1986    let unsigned = value.strip_prefix('-').unwrap_or(value);
1987    let mut parts = unsigned.split('.');
1988    let Some(integer) = parts.next() else {
1989        return false;
1990    };
1991    if integer.is_empty() || !integer.bytes().all(|byte| byte.is_ascii_digit()) {
1992        return false;
1993    }
1994    if let Some(fraction) = parts.next()
1995        && (fraction.is_empty() || !fraction.bytes().all(|byte| byte.is_ascii_digit()))
1996    {
1997        return false;
1998    }
1999    parts.next().is_none()
2000}
2001
2002fn is_guid(value: &str) -> bool {
2003    if value.len() != 38 || !value.starts_with('{') || !value.ends_with('}') {
2004        return false;
2005    }
2006    value.bytes().enumerate().all(|(index, byte)| match index {
2007        0 => byte == b'{',
2008        37 => byte == b'}',
2009        9 | 14 | 19 | 24 => byte == b'-',
2010        _ => byte.is_ascii_hexdigit(),
2011    })
2012}
2013
2014fn push_optional_attr<'a>(start: &mut BytesStart<'a>, name: &'a str, value: Option<&'a str>) {
2015    if let Some(value) = value {
2016        start.push_attribute((name, value));
2017    }
2018}
2019
2020fn push_optional_bool(start: &mut BytesStart<'_>, name: &'static str, value: Option<bool>) {
2021    if let Some(value) = value {
2022        start.push_attribute((name, if value { "1" } else { "0" }));
2023    }
2024}
2025
2026fn push_owned_attr(start: &mut BytesStart<'_>, name: &'static str, value: String) {
2027    start.push_attribute((name, value.as_str()));
2028}
2029
2030fn emit_raw<'a, W: Write>(
2031    writer: &mut Writer<W>,
2032    children: impl Iterator<Item = &'a [u8]>,
2033) -> Result<()> {
2034    for child in children {
2035        writer.get_mut().write_all(child).map_err(OxmlError::from)?;
2036    }
2037    Ok(())
2038}
2039
2040fn write_start<W: Write>(writer: &mut Writer<W>, start: BytesStart<'_>) -> Result<()> {
2041    writer
2042        .write_event(Event::Start(start))
2043        .map_err(OxmlError::from)?;
2044    Ok(())
2045}
2046
2047fn write_empty<W: Write>(writer: &mut Writer<W>, start: BytesStart<'_>) -> Result<()> {
2048    writer
2049        .write_event(Event::Empty(start))
2050        .map_err(OxmlError::from)?;
2051    Ok(())
2052}
2053
2054fn write_end<W: Write>(writer: &mut Writer<W>, tag: &str) -> Result<()> {
2055    writer
2056        .write_event(Event::End(BytesEnd::new(tag)))
2057        .map_err(OxmlError::from)?;
2058    Ok(())
2059}
2060
2061fn unexpected(element: &BytesStart<'_>) -> TextError {
2062    TextError::UnexpectedElement(String::from_utf8_lossy(element.name().as_ref()).into_owned())
2063}
2064
2065fn duplicate(element: &str) -> TextError {
2066    TextError::DuplicateElement(element.to_owned())
2067}
2068
2069fn missing_attribute(element: &str, attribute: &str) -> TextError {
2070    TextError::MissingAttribute {
2071        element: element.to_owned(),
2072        attribute: attribute.to_owned(),
2073    }
2074}
2075
2076fn invalid_attribute(element: &str, attribute: &str, value: &str) -> TextError {
2077    TextError::InvalidAttribute {
2078        element: element.to_owned(),
2079        attribute: attribute.to_owned(),
2080        value: value.to_owned(),
2081    }
2082}
2083
2084#[cfg(test)]
2085mod tests {
2086    use std::panic;
2087
2088    use super::{
2089        CT_TextCharacterProperties, CT_TextParagraph, CT_TextParagraphProperties, TextAlignment,
2090        TextRun, TextSpace, TextSpacing,
2091    };
2092    use crate::color::ColorChoice;
2093    use crate::text::CT_TextBody;
2094    use crate::text::{
2095        TextAutoNumberScheme, TextBullet, TextBulletCharacter, TextBulletChoice,
2096        TextBulletSizeValue,
2097    };
2098
2099    #[test]
2100    fn drawingml_rtl_attribute_becomes_typed_without_reordering_unknown_content() {
2101        let parsed = CT_TextParagraphProperties::from_xml(
2102            br#"<q:pPr xmlns:q="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:x="urn:test" defTabSz="457200" rtl="1" x:keep="yes"/>"#,
2103        )
2104        .unwrap();
2105
2106        assert_eq!(parsed.right_to_left, Some(true));
2107        let serialized = String::from_utf8(parsed.to_xml().unwrap()).unwrap();
2108        assert!(serialized.contains("rtl=\"1\""));
2109        assert!(serialized.contains("x:keep=\"yes\""));
2110        assert!(
2111            serialized.find("defTabSz").unwrap() < serialized.find("rtl").unwrap(),
2112            "{serialized}"
2113        );
2114
2115        let mut changed = parsed;
2116        changed.right_to_left = Some(false);
2117        let serialized = String::from_utf8(changed.to_xml().unwrap()).unwrap();
2118        assert!(serialized.contains("rtl=\"0\""), "{serialized}");
2119        assert!(!serialized.contains("rtl=\"1\""), "{serialized}");
2120    }
2121
2122    #[test]
2123    fn leading_and_trailing_text_whitespace_survives_via_xml_space_preserve() {
2124        let xml = br#"<q:p><q:r><q:t> leading</q:t></q:r><q:r><q:t>trailing </q:t></q:r><q:fld id="{00112233-4455-6677-8899-AABBCCDDEEFF}"><q:t xml:space="preserve">middle</q:t></q:fld></q:p>"#;
2125        let paragraph = CT_TextParagraph::from_xml(xml).unwrap();
2126        assert_eq!(paragraph.to_xml().unwrap(), br#"<a:p><a:r><a:t xml:space="preserve"> leading</a:t></a:r><a:r><a:t xml:space="preserve">trailing </a:t></a:r><a:fld id="{00112233-4455-6677-8899-AABBCCDDEEFF}"><a:t xml:space="preserve">middle</a:t></a:fld></a:p>"#);
2127
2128        let TextRun::Run(first) = &paragraph.runs[0] else {
2129            panic!("expected regular run");
2130        };
2131        assert_eq!(first.text.value, " leading");
2132        assert_eq!(first.text.space, TextSpace::Preserve);
2133
2134        let body = CT_TextBody::from_xml(
2135            br#"<q:txBody><q:bodyPr/><q:p><q:r><q:t> body </q:t></q:r></q:p></q:txBody>"#,
2136        )
2137        .unwrap();
2138        assert_eq!(body.to_xml().unwrap(), br#"<a:txBody><a:bodyPr/><a:p><a:r><a:t xml:space="preserve"> body </a:t></a:r></a:p></a:txBody>"#);
2139
2140        let hostile = CT_TextParagraph::from_xml(
2141            br#"<q:p><q:r><q:t x:space="preserve">plain</q:t></q:r></q:p>"#,
2142        )
2143        .unwrap();
2144        let TextRun::Run(run) = &hostile.runs[0] else {
2145            panic!("expected regular run");
2146        };
2147        assert_eq!(run.text.space, TextSpace::Default);
2148        assert_eq!(
2149            hostile.to_xml().unwrap(),
2150            br#"<a:p><a:r><a:t x:space="preserve">plain</a:t></a:r></a:p>"#
2151        );
2152    }
2153
2154    #[test]
2155    fn canonical_text_state_matches_whitespace_and_empty_hyperlinks_from_real_decks() {
2156        let paragraph = CT_TextParagraph::from_xml(
2157            br#"<q:p><q:r><q:rPr><q:hlinkClick r:id=""/></q:rPr><q:t>trailing </q:t></q:r></q:p>"#,
2158        )
2159        .unwrap();
2160        let TextRun::Run(run) = &paragraph.runs[0] else {
2161            panic!("expected regular run");
2162        };
2163        assert_eq!(run.text.space, TextSpace::Preserve);
2164        assert_eq!(
2165            run.properties
2166                .as_ref()
2167                .and_then(|properties| properties.hyperlink_click.as_ref())
2168                .and_then(|hyperlink| hyperlink.relationship_id.as_deref()),
2169            Some("")
2170        );
2171        let written = paragraph.to_xml().unwrap();
2172        assert_eq!(CT_TextParagraph::from_xml(&written).unwrap(), paragraph);
2173    }
2174
2175    #[test]
2176    fn text_written_into_an_empty_paragraph_takes_its_end_formatting() {
2177        let mut paragraph = CT_TextParagraph::from_xml(
2178            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><a:pPr algn="r"/><a:endParaRPr sz="1500" b="1"><a:hlinkClick r:id="rId9"/></a:endParaRPr></a:p>"#,
2179        )
2180        .unwrap();
2181        paragraph.set_text("Risk");
2182        assert_eq!(
2183            String::from_utf8(paragraph.to_xml().unwrap()).unwrap(),
2184            r#"<a:p><a:pPr algn="r"/><a:r><a:rPr sz="1500" b="1"/><a:t>Risk</a:t></a:r><a:endParaRPr sz="1500" b="1"><a:hlinkClick r:id="rId9"/></a:endParaRPr></a:p>"#
2185        );
2186
2187        let mut formatted = CT_TextParagraph::from_xml(
2188            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr i="1"/><a:t>old</a:t></a:r><a:endParaRPr sz="1500"/></a:p>"#,
2189        )
2190        .unwrap();
2191        formatted.set_text("new");
2192        assert_eq!(
2193            String::from_utf8(formatted.to_xml().unwrap()).unwrap(),
2194            r#"<a:p><a:r><a:rPr i="1"/><a:t>new</a:t></a:r><a:endParaRPr sz="1500"/></a:p>"#
2195        );
2196
2197        let mut bare = CT_TextParagraph::default();
2198        bare.set_text("plain");
2199        assert_eq!(
2200            bare.to_xml().unwrap(),
2201            br#"<a:p><a:r><a:t>plain</a:t></a:r></a:p>"#
2202        );
2203    }
2204
2205    #[test]
2206    fn paragraph_runs_fields_and_breaks_round_trip_structurally() {
2207        let xml = br#"<q:p><x:before/><q:pPr algn="ctr"/><x:afterPPr/><q:r><q:rPr b="1"><q:hlinkClick x:id="not-a-relationship" r:id="rId7" action="ppaction://hlinksldjump" tooltip="go &amp; stay" history="0" highlightClick="1" endSnd="0"><x:sound/></q:hlinkClick></q:rPr><q:t>one</q:t></q:r><x:between/><q:br><q:rPr i="true"/></q:br><q:fld id="{00112233-4455-6677-8899-AABBCCDDEEFF}" type="slidenum"><q:rPr sz="1200"/><q:pPr lvl="1"/><q:t>2</q:t></q:fld><q:endParaRPr sz="1400"/><x:after/></q:p>"#;
2208        let paragraph = CT_TextParagraph::from_xml(xml).unwrap();
2209        assert_eq!(paragraph.runs.len(), 3);
2210        let written = paragraph.to_xml().unwrap();
2211        assert_eq!(written, br#"<a:p><x:before/><a:pPr algn="ctr"/><x:afterPPr/><a:r><a:rPr b="1"><a:hlinkClick r:id="rId7" action="ppaction://hlinksldjump" tooltip="go &amp; stay" history="0" highlightClick="1" endSnd="0" x:id="not-a-relationship"><x:sound/></a:hlinkClick></a:rPr><a:t>one</a:t></a:r><x:between/><a:br><a:rPr i="1"/></a:br><a:fld id="{00112233-4455-6677-8899-AABBCCDDEEFF}" type="slidenum"><a:rPr sz="1200"/><a:pPr lvl="1"/><a:t>2</a:t></a:fld><a:endParaRPr sz="1400"/><x:after/></a:p>"#);
2212        assert_eq!(CT_TextParagraph::from_xml(&written).unwrap(), paragraph);
2213
2214        let hostile = CT_TextCharacterProperties::from_xml(
2215            br#"<q:rPr><q:hlinkClick x:id="not-a-relationship"/></q:rPr>"#,
2216        )
2217        .unwrap();
2218        assert!(
2219            hostile
2220                .hyperlink_click
2221                .as_ref()
2222                .unwrap()
2223                .relationship_id
2224                .is_none()
2225        );
2226        let mut writer = quick_xml::Writer::new(Vec::new());
2227        hostile.write_xml(&mut writer, "a:rPr").unwrap();
2228        assert_eq!(
2229            writer.into_inner(),
2230            br#"<a:rPr><a:hlinkClick x:id="not-a-relationship"/></a:rPr>"#
2231        );
2232    }
2233
2234    #[test]
2235    fn a_later_paragraph_properties_element_is_preserved_where_it_stands() {
2236        let xml = br#"<a:p><a:pPr algn="l"/><a:r><a:t>One.</a:t></a:r><a:pPr algn="r"><a:lnSpc><a:spcPct val="90000"/></a:lnSpc></a:pPr><a:r><a:t>Two.</a:t></a:r><a:pPr/><a:endParaRPr lang="en-US"/></a:p>"#;
2237        let mut paragraph = CT_TextParagraph::from_xml(xml).unwrap();
2238        let properties = paragraph.properties.as_ref().unwrap();
2239        assert_eq!(properties.alignment, Some(TextAlignment::Left));
2240        assert_eq!(properties.line_spacing, None);
2241        let texts = paragraph
2242            .runs
2243            .iter()
2244            .map(|run| match run {
2245                TextRun::Run(run) => run.text.value.as_str(),
2246                TextRun::Break(_) | TextRun::Field(_) => panic!("expected regular runs"),
2247            })
2248            .collect::<Vec<_>>();
2249        assert_eq!(texts, ["One.", "Two."]);
2250        assert_eq!(paragraph.to_xml().unwrap(), xml);
2251        assert_eq!(CT_TextParagraph::from_xml(xml).unwrap(), paragraph);
2252
2253        let TextRun::Run(first) = &mut paragraph.runs[0] else {
2254            panic!("expected regular run");
2255        };
2256        first.set_text("Uno.");
2257        paragraph.properties_mut().level = Some(1);
2258        paragraph.add_run("Three.");
2259        assert_eq!(
2260            paragraph.to_xml().unwrap(),
2261            br#"<a:p><a:pPr lvl="1" algn="l"/><a:r><a:t>Uno.</a:t></a:r><a:pPr algn="r"><a:lnSpc><a:spcPct val="90000"/></a:lnSpc></a:pPr><a:r><a:t>Two.</a:t></a:r><a:pPr/><a:r><a:t>Three.</a:t></a:r><a:endParaRPr lang="en-US"/></a:p>"#
2262        );
2263
2264        paragraph.set_text("Whole.");
2265        assert_eq!(
2266            paragraph.to_xml().unwrap(),
2267            br#"<a:p><a:pPr lvl="1" algn="l"/><a:r><a:t>Whole.</a:t></a:r><a:pPr algn="r"><a:lnSpc><a:spcPct val="90000"/></a:lnSpc></a:pPr><a:pPr/><a:endParaRPr lang="en-US"/></a:p>"#
2268        );
2269
2270        let late = CT_TextParagraph::from_xml(
2271            br#"<a:p><a:r><a:t>One.</a:t></a:r><a:pPr algn="l"/><a:r><a:t>Two.</a:t></a:r><a:pPr algn="r"/></a:p>"#,
2272        )
2273        .unwrap();
2274        assert_eq!(
2275            late.properties.as_ref().unwrap().alignment,
2276            Some(TextAlignment::Left)
2277        );
2278        assert_eq!(
2279            late.to_xml().unwrap(),
2280            br#"<a:p><a:pPr algn="l"/><a:r><a:t>One.</a:t></a:r><a:r><a:t>Two.</a:t></a:r><a:pPr algn="r"/></a:p>"#
2281        );
2282    }
2283
2284    #[test]
2285    fn character_all_caps_round_trips_while_small_caps_stays_unmodelled() {
2286        let all = CT_TextCharacterProperties::from_xml(
2287            br#"<q:rPr cap="all" sz="1800" x:producer="kept"/>"#,
2288        )
2289        .unwrap();
2290        assert_eq!(all.all_caps, Some(true));
2291        let mut writer = quick_xml::Writer::new(Vec::new());
2292        all.write_xml(&mut writer, "a:rPr").unwrap();
2293        let written = writer.into_inner();
2294        assert_eq!(
2295            written,
2296            br#"<a:rPr sz="1800" cap="all" x:producer="kept"/>"#
2297        );
2298        assert_eq!(CT_TextCharacterProperties::from_xml(&written).unwrap(), all);
2299
2300        let none = CT_TextCharacterProperties::from_xml(br#"<q:rPr cap="none"/>"#).unwrap();
2301        assert_eq!(none.all_caps, Some(false));
2302
2303        let small = CT_TextCharacterProperties::from_xml(br#"<q:rPr cap="small"/>"#).unwrap();
2304        assert_eq!(small.all_caps, None);
2305        let mut writer = quick_xml::Writer::new(Vec::new());
2306        small.write_xml(&mut writer, "a:rPr").unwrap();
2307        assert_eq!(writer.into_inner(), br#"<a:rPr cap="small"/>"#);
2308    }
2309
2310    #[test]
2311    fn setting_all_caps_over_preserved_small_caps_writes_one_cap_attribute() {
2312        let mut properties =
2313            CT_TextCharacterProperties::from_xml(br#"<q:rPr cap="small" lang="en-US"/>"#).unwrap();
2314        properties.all_caps = Some(true);
2315        let mut writer = quick_xml::Writer::new(Vec::new());
2316        properties.write_xml(&mut writer, "a:rPr").unwrap();
2317        let written = writer.into_inner();
2318        assert_eq!(written, br#"<a:rPr cap="all" lang="en-US"/>"#);
2319        assert_eq!(
2320            CT_TextCharacterProperties::from_xml(&written)
2321                .unwrap()
2322                .all_caps,
2323            Some(true)
2324        );
2325
2326        properties.all_caps = None;
2327        let mut writer = quick_xml::Writer::new(Vec::new());
2328        properties.write_xml(&mut writer, "a:rPr").unwrap();
2329        assert_eq!(writer.into_inner(), br#"<a:rPr cap="small" lang="en-US"/>"#);
2330    }
2331
2332    #[test]
2333    fn spacing_percentages_above_two_lines_parse_up_to_the_schema_maximum() {
2334        let xml = br#"<q:pPr><q:lnSpc><q:spcPct val="250000"/></q:lnSpc><q:spcBef><q:spcPct val="13200000"/></q:spcBef></q:pPr>"#;
2335        let properties = CT_TextParagraphProperties::from_xml(xml).unwrap();
2336        assert_eq!(
2337            properties.line_spacing,
2338            Some(TextSpacing::Percent("250000".to_owned()))
2339        );
2340        assert_eq!(
2341            properties.space_before,
2342            Some(TextSpacing::Percent("13200000".to_owned()))
2343        );
2344        assert_eq!(
2345            properties.to_xml().unwrap(),
2346            br#"<a:pPr><a:lnSpc><a:spcPct val="250000"/></a:lnSpc><a:spcBef><a:spcPct val="13200000"/></a:spcBef></a:pPr>"#
2347        );
2348    }
2349
2350    #[test]
2351    fn setting_a_bullet_part_removes_the_preserved_alternative_it_replaces() {
2352        let mut properties = CT_TextParagraphProperties::from_xml(
2353            br#"<q:pPr><q:buClrTx/><q:buSzTx/><q:buFontTx/><q:buBlip><q:blip x:embed="rId9"/></q:buBlip><q:tabLst/></q:pPr>"#,
2354        )
2355        .unwrap();
2356        assert!(properties.has_picture_bullet());
2357
2358        properties.set_bullet(Some(TextBullet {
2359            choice: Some(TextBulletChoice::Character(
2360                TextBulletCharacter::new("*").unwrap(),
2361            )),
2362            ..TextBullet::default()
2363        }));
2364        assert!(!properties.has_picture_bullet());
2365        assert_eq!(
2366            properties.to_xml().unwrap(),
2367            br#"<a:pPr><q:buClrTx/><q:buSzTx/><q:buFontTx/><a:buChar char="*"/><q:tabLst/></a:pPr>"#
2368        );
2369
2370        properties.set_bullet(None);
2371        assert_eq!(
2372            properties.to_xml().unwrap(),
2373            br#"<a:pPr><q:tabLst/></a:pPr>"#
2374        );
2375    }
2376
2377    #[test]
2378    fn paragraph_and_run_properties_use_drawingml_units_and_schema_order() {
2379        let paragraph_properties = CT_TextParagraphProperties::from_xml(br#"<q:pPr marL="0" marR="51206400" lvl="8" indent="-51206400" algn="thaiDist" rtl="1" defTabSz="914400"><x:before/><q:lnSpc><q:spcPct val="100000"/></q:lnSpc><q:spcBef><q:spcPts val="158400"/></q:spcBef><q:spcAft><q:spcPct val="125.5%"/></q:spcAft><q:buChar char="*"/><x:afterBullet/><q:defRPr sz="400000" b="true" i="0" u="wavyDbl" strike="dblStrike" spc="-400000" baseline="25%" xmlns:b="urn:test" lang="en-US" kern="1200"><q:solidFill><q:srgbClr val="AABBCC"/></q:solidFill><q:latin typeface="Aptos" pitchFamily="34" charset="0"/><q:ea typeface="Yu Gothic"/><q:cs typeface="Arial"/><q:sym typeface="Symbol"/><q:hlinkMouseOver r:id="rId8" tgtFrame="_blank" invalidUrl="bad"/></q:defRPr><x:last/></q:pPr>"#).unwrap();
2380        let written = {
2381            let mut writer = quick_xml::Writer::new(Vec::new());
2382            paragraph_properties
2383                .write_xml(&mut writer, "a:pPr")
2384                .unwrap();
2385            writer.into_inner()
2386        };
2387        assert_eq!(written, br#"<a:pPr marL="0" marR="51206400" lvl="8" indent="-51206400" algn="thaiDist" rtl="1" defTabSz="914400"><x:before/><a:lnSpc><a:spcPct val="100000"/></a:lnSpc><a:spcBef><a:spcPts val="158400"/></a:spcBef><a:spcAft><a:spcPct val="125.5%"/></a:spcAft><a:buChar char="*"/><x:afterBullet/><a:defRPr sz="400000" b="1" i="0" u="wavyDbl" strike="dblStrike" spc="-400000" baseline="25%" xmlns:b="urn:test" lang="en-US" kern="1200"><a:solidFill><a:srgbClr val="AABBCC"/></a:solidFill><a:latin typeface="Aptos" pitchFamily="34" charset="0"/><a:ea typeface="Yu Gothic"/><a:cs typeface="Arial"/><a:sym typeface="Symbol"/><a:hlinkMouseOver r:id="rId8" invalidUrl="bad" tgtFrame="_blank"/></a:defRPr><x:last/></a:pPr>"#);
2388
2389        let run_properties = CT_TextCharacterProperties::from_xml(
2390            br#"<q:rPr sz="100" spc="1.25pt" baseline="-100000"/>"#,
2391        )
2392        .unwrap();
2393        let mut writer = quick_xml::Writer::new(Vec::new());
2394        run_properties.write_xml(&mut writer, "a:rPr").unwrap();
2395        assert_eq!(
2396            writer.into_inner(),
2397            br#"<a:rPr sz="100" spc="1.25pt" baseline="-100000"/>"#
2398        );
2399
2400        let mut inserted_spacing =
2401            CT_TextParagraphProperties::from_xml(br#"<q:pPr><q:buChar char="*"/></q:pPr>"#)
2402                .unwrap();
2403        inserted_spacing.line_spacing = Some(TextSpacing::Percent("100000".to_owned()));
2404        let mut writer = quick_xml::Writer::new(Vec::new());
2405        inserted_spacing.write_xml(&mut writer, "a:pPr").unwrap();
2406        assert_eq!(
2407            writer.into_inner(),
2408            br#"<a:pPr><a:lnSpc><a:spcPct val="100000"/></a:lnSpc><a:buChar char="*"/></a:pPr>"#
2409        );
2410    }
2411
2412    #[test]
2413    fn every_modelled_bullet_form_round_trips_in_schema_order() {
2414        let cases: &[(&[u8], &[u8])] = &[
2415            (
2416                br#"<q:pPr><q:buClr><q:srgbClr val="AABBCC"/></q:buClr><q:buSzPct val="125000"/><q:buFont typeface="Wingdings"/><q:buChar char="*"/></q:pPr>"#,
2417                br#"<a:pPr><a:buClr><a:srgbClr val="AABBCC"/></a:buClr><a:buSzPct val="125000"/><a:buFont typeface="Wingdings"/><a:buChar char="*"/></a:pPr>"#,
2418            ),
2419            (
2420                br#"<q:pPr><q:buSzPts val="1800"/><q:buAutoNum type="arabicPeriod" startAt="3"/></q:pPr>"#,
2421                br#"<a:pPr><a:buSzPts val="1800"/><a:buAutoNum type="arabicPeriod" startAt="3"/></a:pPr>"#,
2422            ),
2423            (br#"<q:pPr><q:buNone/></q:pPr>"#, br#"<a:pPr><a:buNone/></a:pPr>"#),
2424        ];
2425
2426        for (xml, expected) in cases {
2427            let properties = CT_TextParagraphProperties::from_xml(xml).unwrap();
2428            let mut writer = quick_xml::Writer::new(Vec::new());
2429            properties.write_xml(&mut writer, "a:pPr").unwrap();
2430            let written = writer.into_inner();
2431            assert_eq!(written, *expected);
2432            assert_eq!(
2433                CT_TextParagraphProperties::from_xml(&written).unwrap(),
2434                properties
2435            );
2436        }
2437
2438        let character = CT_TextParagraphProperties::from_xml(cases[0].0).unwrap();
2439        let bullet = character.bullet.as_ref().unwrap();
2440        assert!(matches!(
2441            bullet.choice,
2442            Some(TextBulletChoice::Character(_))
2443        ));
2444        assert_eq!(bullet.font.as_ref().unwrap().typeface, "Wingdings");
2445        assert!(matches!(
2446            bullet.size.as_ref().unwrap().value,
2447            TextBulletSizeValue::Percent(ref value) if value == "125000"
2448        ));
2449        assert!(matches!(
2450            bullet.color.as_ref().unwrap().color,
2451            ColorChoice::Srgb { .. }
2452        ));
2453
2454        let automatic = CT_TextParagraphProperties::from_xml(cases[1].0).unwrap();
2455        let Some(TextBulletChoice::AutoNumber(numbering)) =
2456            automatic.bullet.as_ref().unwrap().choice.as_ref()
2457        else {
2458            panic!("expected automatic-number bullet");
2459        };
2460        assert_eq!(numbering.scheme, TextAutoNumberScheme::ArabicPeriod);
2461        assert_eq!(numbering.start_at, Some(3));
2462        assert!(matches!(
2463            automatic
2464                .bullet
2465                .as_ref()
2466                .unwrap()
2467                .size
2468                .as_ref()
2469                .unwrap()
2470                .value,
2471            TextBulletSizeValue::Points(1800)
2472        ));
2473
2474        let none = CT_TextParagraphProperties::from_xml(cases[2].0).unwrap();
2475        assert!(matches!(
2476            none.bullet.as_ref().unwrap().choice,
2477            Some(TextBulletChoice::None(_))
2478        ));
2479    }
2480
2481    #[test]
2482    fn bullet_font_size_and_colour_keep_their_schema_positions() {
2483        let properties = CT_TextParagraphProperties::from_xml(
2484            br#"<q:pPr><q:buFont typeface="Aptos"/><q:buChar char="*"/><q:buClr><q:schemeClr val="accent1"/></q:buClr><q:buSzPct val="80%"/><q:defRPr b="1"/></q:pPr>"#,
2485        )
2486        .unwrap();
2487        let mut writer = quick_xml::Writer::new(Vec::new());
2488        properties.write_xml(&mut writer, "a:pPr").unwrap();
2489        assert_eq!(
2490            writer.into_inner(),
2491            br#"<a:pPr><a:buClr><a:schemeClr val="accent1"/></a:buClr><a:buSzPct val="80%"/><a:buFont typeface="Aptos"/><a:buChar char="*"/><a:defRPr b="1"/></a:pPr>"#
2492        );
2493    }
2494
2495    #[test]
2496    fn unknown_bullet_children_round_trip_byte_for_byte() {
2497        let properties = CT_TextParagraphProperties::from_xml(
2498            br#"<q:pPr><q:buClrTx x:mode="stay"><x:nested>one &amp; two</x:nested><!--note--></q:buClrTx><q:buChar char="*"/><x:after/></q:pPr>"#,
2499        )
2500        .unwrap();
2501        let mut writer = quick_xml::Writer::new(Vec::new());
2502        properties.write_xml(&mut writer, "a:pPr").unwrap();
2503        assert_eq!(
2504            writer.into_inner(),
2505            br#"<a:pPr><q:buClrTx x:mode="stay"><x:nested>one &amp; two</x:nested><!--note--></q:buClrTx><a:buChar char="*"/><x:after/></a:pPr>"#
2506        );
2507    }
2508
2509    #[test]
2510    fn malformed_bullet_values_return_errors_without_panicking() {
2511        let cases: &[&[u8]] = &[
2512            br#"<q:pPr><q:buChar/></q:pPr>"#,
2513            br#"<q:pPr><q:buChar char=""/></q:pPr>"#,
2514            br#"<q:pPr><q:buSzPct val="24999"/></q:pPr>"#,
2515            br#"<q:pPr><q:buSzPct val="400001"/></q:pPr>"#,
2516            br#"<q:pPr><q:buSzPts val="99"/></q:pPr>"#,
2517            br#"<q:pPr><q:buSzPts val="400001"/></q:pPr>"#,
2518            br#"<q:pPr><q:buAutoNum type="unknown"/></q:pPr>"#,
2519            br#"<q:pPr><q:buAutoNum/></q:pPr>"#,
2520            br#"<q:pPr><q:buAutoNum type="arabicPeriod" startAt="0"/></q:pPr>"#,
2521            br#"<q:pPr><q:buAutoNum type="arabicPeriod" startAt="32768"/></q:pPr>"#,
2522            br#"<q:pPr><q:buFont/></q:pPr>"#,
2523            br#"<q:pPr><q:buClr/></q:pPr>"#,
2524            br#"<q:pPr><q:buClr><q:srgbClr x:val="AABBCC"/></q:buClr></q:pPr>"#,
2525            br#"<q:pPr><q:buClr><q:srgbClr val="GGGGGG"/></q:buClr></q:pPr>"#,
2526            br#"<q:pPr><q:buClr><q:srgbClr val="AABBCC"/><q:schemeClr val="accent1"/></q:buClr></q:pPr>"#,
2527            br#"<q:pPr><q:buChar char="*"/><q:buNone/></q:pPr>"#,
2528            br#"<q:pPr><q:buSzPct val="100000"/><q:buSzPts val="1200"/></q:pPr>"#,
2529        ];
2530
2531        for xml in cases {
2532            let result = panic::catch_unwind(|| CT_TextParagraphProperties::from_xml(xml));
2533            assert!(result.is_ok(), "bullet parser panicked");
2534            assert!(result.unwrap().is_err(), "malformed bullet parsed");
2535        }
2536    }
2537
2538    #[test]
2539    fn malformed_text_content_returns_errors_without_panicking() {
2540        let cases: &[&[u8]] = &[
2541            br#"<q:r/>"#,
2542            br#"<q:r><q:t><x:nested/></q:t></q:r>"#,
2543            br#"<q:r><q:t xml:space="keep">x</q:t></q:r>"#,
2544            br#"<q:r><q:t>x</q:t><q:t>y</q:t></q:r>"#,
2545            br#"<q:pPr lvl="9"/>"#,
2546            br#"<q:pPr marL="-1"/>"#,
2547            br#"<q:pPr indent="51206401"/>"#,
2548            br#"<q:pPr algn="middle"/>"#,
2549            br#"<q:pPr><q:lnSpc/></q:pPr>"#,
2550            br#"<q:pPr><q:lnSpc><q:spcPts val="158401"/></q:lnSpc></q:pPr>"#,
2551            br#"<q:pPr><q:lnSpc><q:spcPct val="13200001"/></q:lnSpc></q:pPr>"#,
2552            br#"<q:rPr sz="99"/>"#,
2553            br#"<q:rPr b="yes"/>"#,
2554            br#"<q:rPr u="triple"/>"#,
2555            br#"<q:rPr spc="400001"/>"#,
2556            r#"<q:rPr spc="éx"/>"#.as_bytes(),
2557            br#"<q:rPr baseline="100001"/>"#,
2558            br#"<q:rPr><q:latin/></q:rPr>"#,
2559            br#"<q:fld type="slidenum"/>"#,
2560            br#"<q:fld id="not-a-guid"/>"#,
2561            br#"<q:p><q:endParaRPr/><q:endParaRPr/></q:p>"#,
2562            br#"<q:notPPr/>"#,
2563            br#"<q:notRPr/>"#,
2564        ];
2565        for xml in cases {
2566            let result = panic::catch_unwind(|| match super::root_local_name(xml).as_deref() {
2567                Ok(b"r") => super::CT_RegularTextRun::from_xml(xml).map(|_| ()),
2568                Ok(b"pPr") => CT_TextParagraphProperties::from_xml(xml).map(|_| ()),
2569                Ok(b"rPr") => CT_TextCharacterProperties::from_xml(xml).map(|_| ()),
2570                Ok(b"fld") => super::CT_TextField::from_xml(xml).map(|_| ()),
2571                Ok(b"p") => CT_TextParagraph::from_xml(xml).map(|_| ()),
2572                Ok(b"notPPr") => CT_TextParagraphProperties::from_xml(xml).map(|_| ()),
2573                Ok(b"notRPr") => CT_TextCharacterProperties::from_xml(xml).map(|_| ()),
2574                _ => unreachable!(),
2575            });
2576            assert!(result.is_ok(), "text parser panicked");
2577            assert!(result.unwrap().is_err(), "malformed text parsed");
2578        }
2579    }
2580
2581    #[test]
2582    fn characters_xml_cannot_carry_are_stored_escaped_and_never_decoded() {
2583        let mut paragraph = CT_TextParagraph::default();
2584        paragraph.add_run("a\u{1}b\u{b}c\u{1f}d\u{fffe}e\u{0}");
2585        let TextRun::Run(run) = &paragraph.runs[0] else {
2586            panic!("expected a run");
2587        };
2588        assert_eq!(
2589            run.text.value, "a_x0001_b_x000B_c_x001F_d_xFFFE_e_x0000_",
2590            "the setter stores the escaped form, as python-pptx does"
2591        );
2592        let written = paragraph.to_xml().unwrap();
2593        assert_eq!(
2594            written,
2595            br#"<a:p><a:r><a:t>a_x0001_b_x000B_c_x001F_d_xFFFE_e_x0000_</a:t></a:r></a:p>"#
2596        );
2597        let declared = [
2598            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#.as_slice(),
2599            &written[4..],
2600        ]
2601        .concat();
2602        oxml_core::xml::validate_strict_xml_1_0(&declared)
2603            .expect("the written paragraph is XML 1.0");
2604        assert_eq!(CT_TextParagraph::from_xml(&written).unwrap(), paragraph);
2605
2606        // Reading keeps every `_xHHHH_` spelling as text, as PowerPoint shows
2607        // it and python-pptx reads it.
2608        let xml = br#"<a:p><a:r><a:t>C_x0041_D bell_x0007_ _x000b_ _x0001</a:t></a:r></a:p>"#;
2609        let paragraph = CT_TextParagraph::from_xml(xml).unwrap();
2610        let TextRun::Run(run) = &paragraph.runs[0] else {
2611            panic!("expected a run");
2612        };
2613        assert_eq!(run.text.value, "C_x0041_D bell_x0007_ _x000b_ _x0001");
2614        assert_eq!(paragraph.to_xml().unwrap(), xml);
2615    }
2616
2617    #[test]
2618    fn a_vertical_tab_set_as_paragraph_text_becomes_a_line_break() {
2619        let mut paragraph = CT_TextParagraph::from_xml(
2620            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:x="urn:x"><a:r><a:rPr b="1"/><a:t>old</a:t></a:r><x:after/><a:endParaRPr sz="1200"/><x:end/></a:p>"#,
2621        )
2622        .unwrap();
2623        paragraph.set_text("one\u{b}two\u{1}\u{b}");
2624        assert_eq!(
2625            String::from_utf8(paragraph.to_xml().unwrap()).unwrap(),
2626            r#"<a:p><a:r><a:rPr b="1"/><a:t>one</a:t></a:r><a:br><a:rPr b="1"/></a:br><a:r><a:rPr b="1"/><a:t>two_x0001_</a:t></a:r><a:br><a:rPr b="1"/></a:br><x:after/><a:endParaRPr sz="1200"/><x:end/></a:p>"#
2627        );
2628
2629        let mut plain = CT_TextParagraph::default();
2630        plain.set_text("\u{b}");
2631        assert_eq!(
2632            plain.to_xml().unwrap(),
2633            br#"<a:p><a:r><a:t/></a:r><a:br/></a:p>"#
2634        );
2635    }
2636}