Skip to main content

oxml_drawing/text/
mod.rs

1use std::io::Write;
2
3use oxml_core::OxmlError;
4use oxml_core::raw_xml::{capture_element, capture_empty_element};
5use oxml_core::xml::matches_local_name;
6use quick_xml::events::{BytesEnd, BytesStart, Event};
7use quick_xml::{Reader, Writer};
8
9use crate::order::OrderedRawChildren;
10
11pub mod body;
12pub mod bullet;
13pub mod list_style;
14pub mod paragraph;
15
16pub use body::{
17    CT_TextBodyProperties, Coordinate32Value, NormalAutofit, TextAnchor, TextAutofit, TextError,
18    TextVertical, TextWrap,
19};
20pub use bullet::{
21    TextAutoNumber, TextAutoNumberScheme, TextBullet, TextBulletCharacter, TextBulletChoice,
22    TextBulletColor, TextBulletSize, TextBulletSizeValue, TextNoBullet,
23};
24pub use list_style::CT_TextListStyle;
25pub use paragraph::{
26    CT_RegularTextRun, CT_TextCharacterProperties, CT_TextField, CT_TextLineBreak,
27    CT_TextParagraph, CT_TextParagraphProperties, TextAlignment, TextFont, TextHyperlink,
28    TextPointValue, TextRun, TextSpace, TextSpacing, TextStrike, TextUnderline, TextValue,
29    escape_invalid_xml_characters,
30};
31
32use body::{Result, missing_end};
33use paragraph::{
34    TextNamespaceScope, text_capture_opaque_event, text_close_generated_child, text_fragment_scope,
35    text_namespace_scope, text_owned_name, text_reject_trailing, text_root_spellings_scoped,
36};
37
38#[derive(Clone, Debug)]
39struct SourceBodyProperties {
40    raw: Vec<u8>,
41    original: CT_TextBodyProperties,
42    _namespaces: Vec<(String, String)>,
43}
44
45/// The `a:txBody` shell with typed body properties and opaque later text stages.
46#[allow(non_camel_case_types)]
47#[derive(Clone, Debug)]
48pub struct CT_TextBody {
49    pub body_properties: CT_TextBodyProperties,
50    list_style: Option<Box<CT_TextListStyle>>,
51    paragraphs: Vec<CT_TextParagraph>,
52    raw_children: OrderedRawChildren,
53    source_root: Vec<String>,
54    source_tag: String,
55    source_body_properties: Option<SourceBodyProperties>,
56}
57impl PartialEq for CT_TextBody {
58    fn eq(&self, other: &Self) -> bool {
59        self.body_properties == other.body_properties
60            && self.list_style == other.list_style
61            && self.paragraphs == other.paragraphs
62            && self.raw_children == other.raw_children
63    }
64}
65impl Eq for CT_TextBody {}
66
67impl Default for CT_TextBody {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl CT_TextBody {
74    /// Creates a minimal valid text body with one empty paragraph.
75    pub fn new() -> Self {
76        Self {
77            body_properties: CT_TextBodyProperties::default(),
78            list_style: None,
79            paragraphs: vec![CT_TextParagraph::default()],
80            raw_children: OrderedRawChildren::default(),
81            source_root: Vec::new(),
82            source_tag: String::new(),
83            source_body_properties: None,
84        }
85    }
86
87    /// Parses a complete `a:txBody` while retaining later text stages.
88    pub fn from_xml(xml: &[u8]) -> Result<Self> {
89        Self::from_xml_with_inherited_namespaces(xml, &[])
90    }
91
92    /// Parses a complete text body under a caller-selected root local name.
93    pub fn from_xml_as(xml: &[u8], root_local_name: &[u8]) -> Result<Self> {
94        Self::from_xml_as_with_namespaces(xml, root_local_name, &[])
95    }
96
97    /// Parses a text-body fragment in its actual enclosing namespace scope.
98    pub fn from_xml_with_inherited_namespaces(
99        xml: &[u8],
100        inherited: &[(String, String)],
101    ) -> Result<Self> {
102        Self::from_xml_as_with_namespaces(xml, b"txBody", inherited)
103    }
104
105    /// Parses a caller-selected text-body root in its actual enclosing namespace scope.
106    /// Retained source declarations stay separate from inherited lookup bindings.
107    pub fn from_xml_as_with_namespaces(
108        xml: &[u8],
109        root_local_name: &[u8],
110        inherited: &[(String, String)],
111    ) -> Result<Self> {
112        let mut reader = Reader::from_reader(xml);
113        let mut buffer = Vec::new();
114        loop {
115            match reader
116                .read_event_into(&mut buffer)
117                .map_err(OxmlError::from)?
118            {
119                Event::Start(element)
120                    if matches_local_name(element.name().as_ref(), root_local_name) =>
121                {
122                    let scope = text_namespace_scope(&element, inherited)?;
123                    let name = element.name();
124                    let spelling = std::str::from_utf8(name.as_ref()).map_err(OxmlError::from)?;
125                    let prefix = spelling.split_once(':').map_or("", |(prefix, _)| prefix);
126                    if scope.iter().any(|(key, uri)| {
127                        key == prefix
128                            && uri != crate::namespace::A_NS
129                            && uri != "http://schemas.openxmlformats.org/presentationml/2006/main"
130                            && !(root_local_name == b"t"
131                                && uri
132                                    == "http://schemas.openxmlformats.org/drawingml/2006/diagram")
133                            && !(root_local_name == b"txPr"
134                                && uri == "http://schemas.openxmlformats.org/drawingml/2006/chart")
135                            && !(root_local_name == b"txBody"
136                                && uri
137                                    == "http://schemas.microsoft.com/office/powerpoint/2018/8/main")
138                    }) {
139                        return Err(TextError::UnexpectedElement(
140                            "foreign text body namespace".to_owned(),
141                        ));
142                    }
143                    let parsed = Self::from_element(
144                        &mut reader,
145                        root_local_name,
146                        &element,
147                        &TextNamespaceScope::inherited(inherited),
148                    )?;
149                    text_reject_trailing(&mut reader)?;
150                    return Ok(parsed);
151                }
152                Event::Empty(element)
153                    if matches_local_name(element.name().as_ref(), root_local_name) =>
154                {
155                    return Err(TextError::MissingBodyProperties);
156                }
157                Event::Start(element) | Event::Empty(element) => {
158                    return Err(TextError::UnexpectedElement(element_name(&element)));
159                }
160                Event::Eof => {
161                    return Err(TextError::Xml(OxmlError::MissingElement(
162                        "DrawingML text body".to_owned(),
163                    )));
164                }
165                _ => {}
166            }
167            buffer.clear();
168        }
169    }
170
171    fn from_element(
172        reader: &mut Reader<&[u8]>,
173        root_local_name: &[u8],
174        start: &BytesStart<'_>,
175        inherited: &TextNamespaceScope,
176    ) -> Result<Self> {
177        let namespaces = text_fragment_scope(start, inherited)?;
178        let source_root = text_root_spellings_scoped(
179            start,
180            inherited,
181            &capture_element(&mut reader.clone(), start)?,
182        )?;
183        let mut body_properties = None;
184        let mut source_body_properties = None;
185        let mut list_style = None;
186        let mut paragraphs = Vec::new();
187        let mut raw_children = OrderedRawChildren::default();
188        let mut boundary = 0;
189        let mut buffer = Vec::new();
190
191        loop {
192            match reader
193                .read_event_into(&mut buffer)
194                .map_err(OxmlError::from)?
195            {
196                Event::Start(element) if text_owned_name(&element, &namespaces)? == b"bodyPr" => {
197                    if body_properties.is_some() {
198                        return Err(TextError::DuplicateElement("bodyPr".to_owned()));
199                    }
200                    let raw = capture_element(reader, &element)?;
201                    let parsed = CT_TextBodyProperties::from_xml(&raw)?;
202                    source_body_properties = Some(SourceBodyProperties {
203                        raw,
204                        original: parsed.clone(),
205                        _namespaces: namespaces.physical.clone(),
206                    });
207                    body_properties = Some(parsed);
208                    boundary = boundary.max(1);
209                }
210                Event::Empty(element) if text_owned_name(&element, &namespaces)? == b"bodyPr" => {
211                    if body_properties.is_some() {
212                        return Err(TextError::DuplicateElement("bodyPr".to_owned()));
213                    }
214                    let raw = capture_empty_element(&element)?;
215                    let parsed = CT_TextBodyProperties::from_xml(&raw)?;
216                    source_body_properties = Some(SourceBodyProperties {
217                        raw,
218                        original: parsed.clone(),
219                        _namespaces: namespaces.physical.clone(),
220                    });
221                    body_properties = Some(parsed);
222                    boundary = boundary.max(1);
223                }
224                Event::Start(element) if text_owned_name(&element, &namespaces)? == b"lstStyle" => {
225                    if list_style.is_some() {
226                        return Err(TextError::DuplicateElement("lstStyle".to_owned()));
227                    }
228                    let raw = capture_element(reader, &element)?;
229                    list_style = Some(CT_TextListStyle::from_xml_boxed_with_namespaces(
230                        &raw,
231                        &namespaces,
232                    )?);
233                    boundary = boundary.max(2);
234                }
235                Event::Empty(element) if text_owned_name(&element, &namespaces)? == b"lstStyle" => {
236                    if list_style.is_some() {
237                        return Err(TextError::DuplicateElement("lstStyle".to_owned()));
238                    }
239                    let raw = capture_empty_element(&element)?;
240                    list_style = Some(CT_TextListStyle::from_xml_boxed_with_namespaces(
241                        &raw,
242                        &namespaces,
243                    )?);
244                    boundary = boundary.max(2);
245                }
246                Event::Start(element) if text_owned_name(&element, &namespaces)? == b"p" => {
247                    let raw = capture_element(reader, &element)?;
248                    paragraphs.push(CT_TextParagraph::from_xml_with_namespaces(
249                        &raw,
250                        &namespaces,
251                    )?);
252                    boundary = boundary.max(2 + paragraphs.len());
253                }
254                Event::Empty(element) if text_owned_name(&element, &namespaces)? == b"p" => {
255                    let raw = capture_empty_element(&element)?;
256                    paragraphs.push(CT_TextParagraph::from_xml_with_namespaces(
257                        &raw,
258                        &namespaces,
259                    )?);
260                    boundary = boundary.max(2 + paragraphs.len());
261                }
262                Event::Start(element) => {
263                    raw_children.push(boundary, capture_element(reader, &element)?)
264                }
265                Event::Empty(element) => {
266                    raw_children.push(boundary, capture_empty_element(&element)?)
267                }
268                Event::End(element)
269                    if matches_local_name(element.name().as_ref(), root_local_name) =>
270                {
271                    break;
272                }
273                Event::Eof => {
274                    return Err(missing_end(&String::from_utf8_lossy(root_local_name)));
275                }
276                event @ (Event::Comment(_) | Event::PI(_) | Event::Text(_) | Event::CData(_)) => {
277                    raw_children.push(boundary, text_capture_opaque_event(event)?);
278                }
279                _ => {}
280            }
281            buffer.clear();
282        }
283
284        let body_properties = body_properties.ok_or(TextError::MissingBodyProperties)?;
285        Ok(Self {
286            body_properties,
287            list_style,
288            paragraphs,
289            raw_children,
290            source_root,
291            source_tag: std::str::from_utf8(start.name().as_ref())
292                .map_err(OxmlError::from)?
293                .to_owned(),
294            source_body_properties,
295        })
296    }
297
298    /// Writes the shell with canonical outer prefixes and schema order.
299    pub fn to_xml(&self) -> Result<Vec<u8>> {
300        let mut writer = Writer::new(Vec::new());
301        self.write_xml(&mut writer)?;
302        Ok(writer.into_inner())
303    }
304
305    /// Writes the shell into an existing XML writer.
306    pub fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
307        self.write_xml_as(writer, "a:txBody")
308    }
309
310    /// Writes the text body using a caller-selected OOXML wrapper tag.
311    pub fn write_xml_as<W: Write>(&self, writer: &mut Writer<W>, tag: &str) -> Result<()> {
312        let start = paragraph::text_wrapper_start(tag, &self.source_tag, &self.source_root)?;
313        let owned_tag = std::str::from_utf8(start.name().as_ref())
314            .map_err(OxmlError::from)?
315            .to_owned();
316        let tag = owned_tag.as_str();
317        let namespaces = paragraph::text_namespace_scope(&start, &[])?;
318        writer
319            .write_event(Event::Start(start))
320            .map_err(OxmlError::from)?;
321        emit_raw(writer, self.raw_children.at(0))?;
322        if let Some(SourceBodyProperties {
323            raw,
324            original,
325            _namespaces,
326        }) = &self.source_body_properties
327        {
328            if &self.body_properties == original && !_namespaces.is_empty() {
329                writer.get_mut().write_all(raw).map_err(OxmlError::from)?;
330            } else {
331                {
332                    let mut child = Writer::new(Vec::new());
333                    self.body_properties.write_xml(&mut child)?;
334                    writer
335                        .get_mut()
336                        .write_all(&text_close_generated_child(
337                            &child.into_inner(),
338                            &namespaces,
339                        )?)
340                        .map_err(OxmlError::from)?;
341                }
342            }
343        } else {
344            {
345                let mut child = Writer::new(Vec::new());
346                self.body_properties.write_xml(&mut child)?;
347                writer
348                    .get_mut()
349                    .write_all(&text_close_generated_child(
350                        &child.into_inner(),
351                        &namespaces,
352                    )?)
353                    .map_err(OxmlError::from)?;
354            }
355        }
356        emit_raw(writer, self.raw_children.at(1))?;
357        if let Some(list_style) = &self.list_style {
358            {
359                let mut child = Writer::new(Vec::new());
360                list_style.write_xml(&mut child)?;
361                writer
362                    .get_mut()
363                    .write_all(&text_close_generated_child(
364                        &child.into_inner(),
365                        &namespaces,
366                    )?)
367                    .map_err(OxmlError::from)?;
368            }
369        }
370        emit_raw(writer, self.raw_children.at(2))?;
371        for (index, paragraph) in self.paragraphs.iter().enumerate() {
372            {
373                let mut child = Writer::new(Vec::new());
374                paragraph.write_xml(&mut child)?;
375                writer
376                    .get_mut()
377                    .write_all(&text_close_generated_child(
378                        &child.into_inner(),
379                        &namespaces,
380                    )?)
381                    .map_err(OxmlError::from)?;
382            }
383            emit_raw(writer, self.raw_children.at(3 + index))?;
384        }
385        writer
386            .write_event(Event::End(BytesEnd::new(tag)))
387            .map_err(OxmlError::from)?;
388        Ok(())
389    }
390
391    /// Returns plain text in paragraph and text-content order.
392    pub fn plain_text(&self) -> String {
393        self.paragraphs
394            .iter()
395            .map(|paragraph| {
396                let mut text = String::new();
397                for run in &paragraph.runs {
398                    match run {
399                        TextRun::Run(run) => text.push_str(&run.text.value),
400                        TextRun::Break(_) => text.push('\n'),
401                        TextRun::Field(field) => {
402                            if let Some(value) = &field.text {
403                                text.push_str(&value.value);
404                            }
405                        }
406                    }
407                }
408                text
409            })
410            .collect::<Vec<_>>()
411            .join("\n")
412    }
413
414    pub fn has_list_style(&self) -> bool {
415        self.list_style.is_some()
416    }
417
418    pub fn list_style(&self) -> Option<&CT_TextListStyle> {
419        self.list_style.as_deref()
420    }
421
422    pub fn paragraph_count(&self) -> usize {
423        self.paragraphs.len()
424    }
425
426    pub fn paragraphs(&self) -> &[CT_TextParagraph] {
427        &self.paragraphs
428    }
429
430    /// Replaces all text content while retaining body-level state.
431    ///
432    /// Each line feed or CRLF pair starts a paragraph, and a vertical tab
433    /// creates an `a:br`. New paragraphs copy the first paragraph's formatting.
434    pub fn set_text(&mut self, text: &str) {
435        let old_paragraph_count = self.paragraphs.len();
436        let mut first = self.paragraphs.drain(..).next().unwrap_or_default();
437        let mut lines = text.split("\r\n").flat_map(|part| part.split('\n'));
438        first.set_text(lines.next().unwrap_or_default());
439        let mut template = CT_TextParagraph::default();
440        template.properties = first.properties.clone();
441        template.runs = first.runs.iter().take(1).cloned().collect();
442        template.end_properties = first.end_properties.clone();
443        self.paragraphs.push(first);
444        for line in lines {
445            let mut paragraph = template.clone();
446            paragraph.set_text(line);
447            self.paragraphs.push(paragraph);
448        }
449        self.reconcile_paragraph_raw_children(old_paragraph_count, self.paragraphs.len());
450    }
451
452    /// Returns an empty body that keeps this body's properties and list style
453    /// and the formatting of its first paragraph, as PowerPoint fills the
454    /// cells of a new table row or column.
455    ///
456    /// The one paragraph keeps the first paragraph's properties. The
457    /// character properties of that paragraph's first regular run, or its end
458    /// properties when the run is missing or has none, become the new end
459    /// properties without their hyperlinks. Unmodelled body and paragraph
460    /// children are not copied.
461    pub fn empty_like(&self) -> Self {
462        let mut paragraph = CT_TextParagraph::default();
463        if let Some(first) = self.paragraphs.first() {
464            let first_run = first.runs.iter().find_map(|run| match run {
465                TextRun::Run(run) => Some(run),
466                TextRun::Break(_) | TextRun::Field(_) => None,
467            });
468            paragraph.properties = first.properties.clone();
469            paragraph.end_properties = first_run
470                .and_then(|run| run.properties.clone())
471                .or_else(|| first.end_properties.clone())
472                .map(CT_TextCharacterProperties::without_hyperlinks);
473        }
474        Self {
475            body_properties: self.body_properties.clone(),
476            list_style: self.list_style.clone(),
477            paragraphs: vec![paragraph],
478            raw_children: OrderedRawChildren::default(),
479            source_root: if self.body_properties.autofit_source.is_some() {
480                self.source_root.clone()
481            } else {
482                Vec::new()
483            },
484            source_tag: if self.body_properties.autofit_source.is_some() {
485                self.source_tag.clone()
486            } else {
487                String::new()
488            },
489            source_body_properties: if self.body_properties.autofit_source.is_some() {
490                self.source_body_properties.clone()
491            } else {
492                None
493            },
494        }
495    }
496
497    /// Returns one paragraph for in-place mutation.
498    pub fn paragraph_mut(&mut self, index: usize) -> Option<&mut CT_TextParagraph> {
499        self.paragraphs.get_mut(index)
500    }
501
502    /// Appends one empty paragraph after all existing paragraphs.
503    pub fn add_paragraph(&mut self) -> &mut CT_TextParagraph {
504        self.raw_children
505            .shift_boundaries_from(2 + self.paragraphs.len());
506        self.paragraphs.push(CT_TextParagraph::default());
507        self.paragraphs.last_mut().expect("paragraph was appended")
508    }
509
510    /// Moves visible typed paragraphs to `destination`, retaining body-level
511    /// state and leaving one empty paragraph in this body.
512    pub fn move_content_to(&mut self, destination: &mut Self) {
513        if self.plain_text().is_empty() {
514            return;
515        }
516
517        let source_paragraph_count = self.paragraphs.len();
518        let moved = std::mem::take(&mut self.paragraphs);
519        self.paragraphs.push(CT_TextParagraph::default());
520        self.reconcile_paragraph_raw_children(source_paragraph_count, 1);
521
522        if destination.plain_text().is_empty() {
523            let destination_paragraph_count = destination.paragraphs.len();
524            destination.paragraphs = moved;
525            destination.reconcile_paragraph_raw_children(
526                destination_paragraph_count,
527                destination.paragraphs.len(),
528            );
529            return;
530        }
531
532        for paragraph in moved {
533            destination
534                .raw_children
535                .shift_boundaries_from(2 + destination.paragraphs.len());
536            destination.paragraphs.push(paragraph);
537        }
538    }
539
540    fn reconcile_paragraph_raw_children(
541        &mut self,
542        old_paragraph_count: usize,
543        new_paragraph_count: usize,
544    ) {
545        let mut raw_children = OrderedRawChildren::default();
546        for boundary in 0..=2 + old_paragraph_count {
547            let new_boundary = if boundary <= 2 {
548                boundary
549            } else {
550                2 + new_paragraph_count
551            };
552            for child in self.raw_children.at(boundary) {
553                raw_children.push(new_boundary, child.to_vec());
554            }
555        }
556        self.raw_children = raw_children;
557    }
558
559    pub fn raw_children(&self) -> &OrderedRawChildren {
560        &self.raw_children
561    }
562}
563
564fn emit_raw<'a, W: Write>(
565    writer: &mut Writer<W>,
566    children: impl Iterator<Item = &'a [u8]>,
567) -> Result<()> {
568    for child in children {
569        writer.get_mut().write_all(child).map_err(OxmlError::from)?;
570    }
571    Ok(())
572}
573
574fn element_name(element: &BytesStart<'_>) -> String {
575    String::from_utf8_lossy(element.name().as_ref()).into_owned()
576}
577
578#[cfg(test)]
579mod tests {
580    use std::panic;
581
582    use super::{CT_TextBody, CT_TextListStyle};
583
584    #[test]
585    fn fx225_text_style_boxing_preserves_source_and_public_callers() {
586        let opaque = "<e:foreign v='keep&amp;exact'><!--raw--><e:child/></e:foreign>";
587        let mut levels = String::new();
588        for level in 1..=9 {
589            levels.push_str(&format!("<q:lvl{level}pPr marL=\"{level}\"><q:defRPr lang=\"en-US\"/><e:level n='{level}'/></q:lvl{level}pPr>"));
590        }
591        let xml = format!(
592            "<q:txBody xmlns:q='http://schemas.openxmlformats.org/drawingml/2006/main' xmlns:e='urn:extension'><q:bodyPr/><q:lstStyle e:owner='original'><q:defPPr algn=\"ctr\"/>{levels}{opaque}</q:lstStyle><q:p><q:r><q:t>source</q:t></q:r></q:p></q:txBody>"
593        );
594        let body = CT_TextBody::from_xml(xml.as_bytes()).unwrap();
595        let style: Option<&CT_TextListStyle> = body.list_style();
596        assert_eq!(style.unwrap().level(1).unwrap().left_margin, Some(1));
597        assert_eq!(style.unwrap().level(9).unwrap().left_margin, Some(9));
598        let serialized = body.to_xml().unwrap();
599        assert!(String::from_utf8_lossy(&serialized).contains(opaque));
600        let mut clone = body.clone();
601        assert_eq!(clone, body);
602        clone.set_text("changed");
603        clone.paragraph_mut(0).unwrap().add_run(" appended");
604        assert_eq!(body.plain_text(), "source");
605        assert_eq!(clone.list_style(), body.list_style());
606        assert_eq!(clone.empty_like().list_style(), body.list_style());
607        let saved = clone.to_xml().unwrap();
608        let saved_text = String::from_utf8_lossy(&saved);
609        assert!(saved_text.contains(opaque));
610        assert!(saved_text.contains("e:owner='original'"));
611        for level in 1..9 {
612            assert!(
613                saved_text.find(&format!("<q:lvl{level}pPr")).unwrap()
614                    < saved_text.find(&format!("<q:lvl{}pPr", level + 1)).unwrap()
615            );
616        }
617        assert_eq!(
618            CT_TextBody::from_xml(&saved).unwrap().to_xml().unwrap(),
619            saved
620        );
621        let mut direct = style.unwrap().clone();
622        direct.level1.as_mut().unwrap().left_margin = Some(73);
623        assert_eq!(
624            CT_TextListStyle::from_xml(&direct.to_xml().unwrap())
625                .unwrap()
626                .level(1)
627                .unwrap()
628                .left_margin,
629            Some(73)
630        );
631        assert_eq!(
632            body.list_style().unwrap().level(1).unwrap().left_margin,
633            Some(1)
634        );
635        for xml in [
636            b"<a:txBody><a:bodyPr/><a:p/></a:txBody>".as_slice(),
637            b"<a:txBody><a:bodyPr/><a:lstStyle/><a:p/></a:txBody>".as_slice(),
638        ] {
639            let value = CT_TextBody::from_xml(xml).unwrap();
640            assert_eq!(value.clone().to_xml().unwrap(), value.to_xml().unwrap());
641            assert_eq!(
642                CT_TextBody::from_xml(&value.to_xml().unwrap()).unwrap(),
643                value
644            );
645        }
646    }
647
648    #[test]
649    fn autofit_opaque_choice_keeps_inherited_wrapper_namespace_scope() {
650        let xml = br#"<q:txBody xmlns:q='http://schemas.openxmlformats.org/drawingml/2006/main' xmlns:e='urn:example'><q:bodyPr><q:normAutofit e:proof='keep&amp;exact'/></q:bodyPr><q:p/></q:txBody>"#;
651        let mut body = CT_TextBody::from_xml(xml).unwrap();
652        body.body_properties.autofit = Some(super::TextAutofit::NoAutofit);
653        let saved = body.to_xml().unwrap();
654        let saved = String::from_utf8(saved).unwrap();
655        assert!(saved.starts_with("<q:txBody xmlns:q='http://schemas.openxmlformats.org/drawingml/2006/main' xmlns:e='urn:example'>"));
656        assert!(saved.contains("<q:noAutofit e:proof='keep&amp;exact'/>"));
657        let empty = String::from_utf8(body.empty_like().to_xml().unwrap()).unwrap();
658        assert!(empty.contains("xmlns:q='http://schemas.openxmlformats.org/drawingml/2006/main'"));
659        assert!(empty.contains("xmlns:e='urn:example'"));
660        assert!(empty.contains("<q:noAutofit e:proof='keep&amp;exact'/>"));
661        assert_eq!(
662            CT_TextBody::from_xml(saved.as_bytes())
663                .unwrap()
664                .to_xml()
665                .unwrap(),
666            saved.as_bytes()
667        );
668
669        // The retained owner spelling remains qualified even when a is foreign.
670        // Generated canonical descendants close their own DrawingML binding.
671        let conflicting = String::from_utf8(xml.to_vec())
672            .unwrap()
673            .replace("xmlns:e=", "xmlns:a='urn:foreign' xmlns:e=");
674        let mut body = CT_TextBody::from_xml(conflicting.as_bytes()).unwrap();
675        body.body_properties.autofit = Some(super::TextAutofit::NoAutofit);
676        let saved = String::from_utf8(body.to_xml().unwrap()).unwrap();
677        assert!(saved.starts_with("<q:txBody"));
678        assert!(saved.contains("xmlns:a='urn:foreign'"));
679        assert!(saved.contains("<q:noAutofit e:proof='keep&amp;exact'/>"));
680        assert_eq!(
681            CT_TextBody::from_xml(saved.as_bytes())
682                .unwrap()
683                .to_xml()
684                .unwrap(),
685            saved.as_bytes()
686        );
687    }
688
689    #[test]
690    fn text_body_reads_any_prefix_and_writes_the_fixed_a_prefix() {
691        let xml = br#"<q:txBody><x:before/><q:bodyPr anchor="ctr"><q:noAutofit/></q:bodyPr><x:afterBody/><q:lstStyle><x:listChild/></q:lstStyle><x:beforeParagraph/><q:p><x:run>kept</x:run></q:p><x:afterParagraph/></q:txBody>"#;
692        let body = CT_TextBody::from_xml(xml).unwrap();
693        assert!(body.has_list_style());
694        assert_eq!(body.paragraph_count(), 1);
695        assert_eq!(body.to_xml().unwrap(), br#"<a:txBody><x:before/><a:bodyPr anchor="ctr"><a:noAutofit/></a:bodyPr><x:afterBody/><a:lstStyle><x:listChild/></a:lstStyle><x:beforeParagraph/><a:p><x:run>kept</x:run></a:p><x:afterParagraph/></a:txBody>"#);
696    }
697
698    #[test]
699    fn moving_content_retains_typed_paragraphs_and_source_body_state() {
700        let mut destination = CT_TextBody::from_xml(
701            br#"<a:txBody><a:bodyPr/><a:p><a:r><a:rPr b="1"/><a:t>one</a:t></a:r></a:p></a:txBody>"#,
702        )
703        .unwrap();
704        let mut source = CT_TextBody::from_xml(
705            br#"<a:txBody><a:bodyPr lIns="10"/><a:p><a:r><a:rPr i="1"/><a:t>two</a:t></a:r></a:p><a:p><a:r><a:t>three</a:t></a:r></a:p></a:txBody>"#,
706        )
707        .unwrap();
708
709        source.move_content_to(&mut destination);
710
711        assert_eq!(destination.plain_text(), "one\ntwo\nthree");
712        assert_eq!(destination.paragraph_count(), 3);
713        assert_eq!(source.plain_text(), "");
714        assert_eq!(source.paragraph_count(), 1);
715        assert!(
716            String::from_utf8(source.to_xml().unwrap())
717                .unwrap()
718                .contains(r#"lIns="10""#)
719        );
720        let written = String::from_utf8(destination.to_xml().unwrap()).unwrap();
721        assert!(written.contains(r#"<a:rPr b="1"/>"#));
722        assert!(written.contains(r#"<a:rPr i="1"/>"#));
723    }
724
725    #[test]
726    fn set_text_starts_a_paragraph_at_each_line_feed_and_breaks_at_each_vertical_tab() {
727        let mut body = CT_TextBody::from_xml(
728            br#"<a:txBody xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:x="urn:x"><a:bodyPr/><a:p><a:pPr algn="ctr"/><a:r><a:rPr b="1"/><a:t>old</a:t></a:r><x:in-paragraph/><a:endParaRPr sz="1200"/></a:p><a:p><a:r><a:t>gone</a:t></a:r></a:p><x:after-paragraphs/></a:txBody>"#,
729        )
730        .unwrap();
731        body.set_text("one\r\ntwo\u{b}soft\u{b}\n");
732        assert_eq!(
733            String::from_utf8(body.to_xml().unwrap()).unwrap(),
734            concat!(
735                r#"<a:txBody xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:x="urn:x"><a:bodyPr/>"#,
736                r#"<a:p xmlns:x="urn:x"><a:pPr algn="ctr"/><a:r><a:rPr b="1"/><a:t>one</a:t></a:r><x:in-paragraph/><a:endParaRPr sz="1200"/></a:p>"#,
737                r#"<a:p><a:pPr algn="ctr"/><a:r><a:rPr b="1"/><a:t>two</a:t></a:r><a:br><a:rPr b="1"/></a:br><a:r><a:rPr b="1"/><a:t>soft</a:t></a:r><a:br><a:rPr b="1"/></a:br><a:endParaRPr sz="1200"/></a:p>"#,
738                r#"<a:p><a:pPr algn="ctr"/><a:r><a:rPr b="1"/><a:t/></a:r><a:endParaRPr sz="1200"/></a:p>"#,
739                r#"<x:after-paragraphs/></a:txBody>"#,
740            )
741        );
742        assert_eq!(body.plain_text(), "one\ntwo\nsoft\n\n");
743
744        let saved = body.to_xml().unwrap();
745        assert_eq!(
746            CT_TextBody::from_xml(&saved).unwrap().to_xml().unwrap(),
747            saved
748        );
749
750        let mut plain = CT_TextBody::new();
751        plain.set_text("single line");
752        assert_eq!(plain.paragraph_count(), 1);
753        assert_eq!(plain.plain_text(), "single line");
754    }
755
756    #[test]
757    fn schema_valid_text_body_using_all_nine_list_levels_round_trips_structurally() {
758        let xml = br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl1pPr lvl="0"><q:buChar char="*"/></q:lvl1pPr><q:lvl2pPr marL="100"><q:buAutoNum type="arabicPeriod" startAt="2"/></q:lvl2pPr><q:lvl3pPr><q:buNone/></q:lvl3pPr><q:lvl4pPr><q:defRPr sz="1200"/></q:lvl4pPr><q:lvl5pPr algn="ctr"/><x:extension x:id="5"><x:child>one &amp; two</x:child></x:extension><q:lvl6pPr marR="200"/><q:lvl7pPr><q:spcBef><q:spcPts val="600"/></q:spcBef></q:lvl7pPr><q:lvl8pPr><q:buSzPct val="125000"/><q:buFont typeface="Wingdings"/><q:buChar char="o"/></q:lvl8pPr><q:lvl9pPr indent="-100"/></q:lstStyle><q:p><q:pPr lvl="1"/><q:r><q:t xml:space="preserve"> item </q:t></q:r></q:p></q:txBody>"#;
759        let expected = br#"<a:txBody><a:bodyPr/><a:lstStyle><a:lvl1pPr lvl="0"><a:buChar char="*"/></a:lvl1pPr><a:lvl2pPr marL="100"><a:buAutoNum type="arabicPeriod" startAt="2"/></a:lvl2pPr><a:lvl3pPr><a:buNone/></a:lvl3pPr><a:lvl4pPr><a:defRPr sz="1200"/></a:lvl4pPr><a:lvl5pPr algn="ctr"/><x:extension x:id="5"><x:child>one &amp; two</x:child></x:extension><a:lvl6pPr marR="200"/><a:lvl7pPr><a:spcBef><a:spcPts val="600"/></a:spcBef></a:lvl7pPr><a:lvl8pPr><a:buSzPct val="125000"/><a:buFont typeface="Wingdings"/><a:buChar char="o"/></a:lvl8pPr><a:lvl9pPr indent="-100"/></a:lstStyle><a:p><a:pPr lvl="1"/><a:r><a:t xml:space="preserve"> item </a:t></a:r></a:p></a:txBody>"#;
760
761        let body = CT_TextBody::from_xml(xml).unwrap();
762        let written = body.to_xml().unwrap();
763        assert_eq!(written, expected);
764        assert_eq!(CT_TextBody::from_xml(&written).unwrap(), body);
765    }
766
767    #[test]
768    fn list_style_levels_write_in_ascending_schema_order() {
769        let body = CT_TextBody::from_xml(
770            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl9pPr indent="-9"/><q:lvl5pPr indent="-5"/><q:lvl1pPr indent="-1"/></q:lstStyle><q:p/></q:txBody>"#,
771        )
772        .unwrap();
773        assert_eq!(
774            body.to_xml().unwrap(),
775            br#"<a:txBody><a:bodyPr/><a:lstStyle><a:lvl1pPr indent="-1"/><a:lvl5pPr indent="-5"/><a:lvl9pPr indent="-9"/></a:lstStyle><a:p/></a:txBody>"#
776        );
777    }
778
779    #[test]
780    fn unknown_list_style_children_round_trip_byte_for_byte() {
781        let body = CT_TextBody::from_xml(
782            br#"<q:txBody><q:bodyPr/><q:lstStyle><x:before x:id="1"/><q:lvl1pPr/><x:between><x:nested>one &amp; two</x:nested><!--note--></x:between><q:lvl2pPr/><x:after x:id="9"/></q:lstStyle><q:p/></q:txBody>"#,
783        )
784        .unwrap();
785        assert_eq!(
786            body.to_xml().unwrap(),
787            br#"<a:txBody><a:bodyPr/><a:lstStyle><x:before x:id="1"/><a:lvl1pPr/><x:between><x:nested>one &amp; two</x:nested><!--note--></x:between><a:lvl2pPr/><x:after x:id="9"/></a:lstStyle><a:p/></a:txBody>"#
788        );
789    }
790
791    #[test]
792    fn list_style_rejects_nested_fixed_prefix_rebinding() {
793        let positive = br#"<p:defaultTextStyle xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><d:lvl1pPr/></p:defaultTextStyle>"#;
794        assert!(CT_TextListStyle::from_xml(positive).is_ok());
795        let xml = br#"<p:defaultTextStyle xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><d:lvl1pPr xmlns:a="urn:producer"><a:raw/></d:lvl1pPr></p:defaultTextStyle>"#;
796        assert!(CT_TextListStyle::from_xml(xml).is_err());
797    }
798
799    #[test]
800    fn list_style_rejects_fixed_prefix_rebinding_at_typed_descendants() {
801        let descendants: &[&[u8]] = &[
802            br#"<d:defRPr xmlns:a="urn:producer"/>"#,
803            br#"<d:spcBef xmlns:a="urn:producer"><d:spcPts val="600"/></d:spcBef>"#,
804            br#"<d:spcBef><d:spcPts xmlns:a="urn:producer" val="600"/></d:spcBef>"#,
805            br#"<d:buChar xmlns:a="urn:producer" char="*"/>"#,
806            br#"<d:buClr xmlns:a="urn:producer"><d:srgbClr val="102030"/></d:buClr>"#,
807            br#"<d:buClr><d:srgbClr xmlns:a="urn:producer" val="102030"/></d:buClr>"#,
808            br#"<d:defRPr><d:solidFill xmlns:a="urn:producer"><d:srgbClr val="102030"/></d:solidFill></d:defRPr>"#,
809            br#"<d:defRPr><d:solidFill><d:srgbClr xmlns:a="urn:producer" val="102030"/></d:solidFill></d:defRPr>"#,
810            br#"<d:defRPr><d:solidFill><d:srgbClr val="102030"><d:alpha xmlns:a="urn:producer" val="50000"/></d:srgbClr></d:solidFill></d:defRPr>"#,
811        ];
812
813        for descendant in descendants {
814            let mut xml = br#"<p:defaultTextStyle xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><d:lvl1pPr>"#.to_vec();
815            xml.extend_from_slice(descendant);
816            xml.extend_from_slice(br#"</d:lvl1pPr></p:defaultTextStyle>"#);
817            assert!(
818                CT_TextListStyle::from_xml(&xml).is_err(),
819                "typed descendant accepted a conflicting xmlns:a: {}",
820                String::from_utf8_lossy(descendant)
821            );
822        }
823    }
824
825    #[test]
826    fn opaque_list_style_child_preserves_its_local_prefix_binding() {
827        let opaque = br#"<x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension>"#;
828        let xml = br#"<p:defaultTextStyle xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension><d:lvl1pPr/></p:defaultTextStyle>"#;
829        let parsed = CT_TextListStyle::from_xml(xml).unwrap();
830        let written = parsed.to_xml().unwrap();
831        assert!(
832            written
833                .windows(opaque.len())
834                .any(|window| window == opaque.as_slice())
835        );
836    }
837
838    #[test]
839    fn opaque_typed_descendants_preserve_their_local_prefix_bindings() {
840        let character_extension = br#"<x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension>"#;
841        let transform_with_content =
842            br#"<d:alpha xmlns:a="urn:producer" val="50000"><a:data/></d:alpha>"#;
843        let xml = br#"<p:defaultTextStyle xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><d:lvl1pPr><d:defRPr><x:extension xmlns:x="urn:extension" xmlns:a="urn:producer"><a:data/></x:extension><d:solidFill><d:srgbClr val="102030"><d:alpha xmlns:a="urn:producer" val="50000"><a:data/></d:alpha></d:srgbClr></d:solidFill></d:defRPr></d:lvl1pPr></p:defaultTextStyle>"#;
844
845        let written = CT_TextListStyle::from_xml(xml).unwrap().to_xml().unwrap();
846        for opaque in [
847            character_extension.as_slice(),
848            transform_with_content.as_slice(),
849        ] {
850            assert!(
851                written.windows(opaque.len()).any(|window| window == opaque),
852                "opaque descendant was not preserved: {}",
853                String::from_utf8_lossy(opaque)
854            );
855        }
856    }
857
858    #[test]
859    fn invalid_list_levels_return_errors_without_panicking() {
860        let cases: &[&[u8]] = &[
861            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl0pPr/></q:lstStyle><q:p/></q:txBody>"#,
862            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl10pPr/></q:lstStyle><q:p/></q:txBody>"#,
863            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl01pPr/></q:lstStyle><q:p/></q:txBody>"#,
864            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl1pPr/><q:lvl1pPr/></q:lstStyle><q:p/></q:txBody>"#,
865            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl4pPr lvl="9"/></q:lstStyle><q:p/></q:txBody>"#,
866            br#"<q:txBody><q:bodyPr/><q:lstStyle><q:lvl7pPr><q:buChar/></q:lvl7pPr></q:lstStyle><q:p/></q:txBody>"#,
867        ];
868
869        for xml in cases {
870            let result = panic::catch_unwind(|| CT_TextBody::from_xml(xml));
871            assert!(result.is_ok(), "list-style parser panicked");
872            assert!(result.unwrap().is_err(), "invalid list level parsed");
873        }
874    }
875
876    #[test]
877    fn fx206_text_body_inherited_link_namespace_and_opaque_owner_closure() {
878        let inherited = vec![
879            ("a".to_owned(), crate::namespace::A_NS.to_owned()),
880            (
881                "p".to_owned(),
882                "http://schemas.openxmlformats.org/presentationml/2006/main".to_owned(),
883            ),
884            ("q".to_owned(), crate::namespace::A_NS.to_owned()),
885            (
886                "rel".to_owned(),
887                "http://schemas.openxmlformats.org/officeDocument/2006/relationships".to_owned(),
888            ),
889            ("f".to_owned(), "urn:foreign".to_owned()),
890        ];
891        let xml = br#"<p:txBody f:body='&#65;'><q:bodyPr f:bodyPr='&#76;'/><q:lstStyle f:list='&#66;'><q:defPPr f:default='&#67;'><q:defRPr><q:hlinkClick rel:id='default'/></q:defRPr></q:defPPr></q:lstStyle><q:p f:paragraph='&#68;'><f:r><q:r><q:t>opaque</q:t></q:r></f:r><q:r f:run='&#69;'><q:rPr xmlns:a='urn:foreign-a' f:properties='&#70;'><a:opaque f:keep='&#80;'/><q:solidFill f:fill='&#79;'><q:srgbClr val='123456'/></q:solidFill><q:latin typeface='Arial' f:font='&#78;'/><f:hlinkClick f:keep='&#71;'><q:hlinkClick rel:id='shadow'/></f:hlinkClick><q:hlinkClick rel:id='owned' tooltip='&#72;'><f:payload f:keep='&#73;'/></q:hlinkClick></q:rPr><q:t f:text='&#77;'>visible</q:t></q:r><q:br xmlns:rel='urn:foreign' f:break='&#74;'><q:rPr><q:hlinkClick rel:id='opaque'/></q:rPr></q:br><q:fld id='{00000000-0000-0000-0000-000000000001}' f:field='&#75;'><q:rPr><q:hlinkClick rel:id='field'/></q:rPr><q:t>field</q:t></q:fld><q:endParaRPr><q:hlinkMouseOver rel:id='hover'/></q:endParaRPr></q:p></p:txBody>"#;
892        let mut body = CT_TextBody::from_xml_with_inherited_namespaces(xml, &inherited).unwrap();
893        assert_eq!(body.paragraphs.len(), 1);
894        assert_eq!(body.paragraphs[0].runs.len(), 3);
895        let default = body
896            .list_style
897            .as_ref()
898            .unwrap()
899            .default_paragraph_properties
900            .as_ref()
901            .unwrap()
902            .default_run_properties
903            .as_ref()
904            .unwrap();
905        assert_eq!(
906            default
907                .hyperlink_click
908                .as_ref()
909                .unwrap()
910                .relationship_id
911                .as_deref(),
912            Some("default")
913        );
914        let super::TextRun::Run(run) = &mut body.paragraphs[0].runs[0] else {
915            panic!("owned regular run");
916        };
917        let click = run
918            .properties
919            .as_mut()
920            .unwrap()
921            .hyperlink_click
922            .as_mut()
923            .unwrap();
924        assert_eq!(click.relationship_id.as_deref(), Some("owned"));
925        assert_eq!(click.tooltip.as_deref(), Some("H"));
926        click.relationship_id = Some("next".to_owned());
927        click.action = Some("ppaction://hlinksldjump".to_owned());
928        let super::TextRun::Break(br) = &body.paragraphs[0].runs[1] else {
929            panic!("owned break");
930        };
931        assert_eq!(
932            br.properties
933                .as_ref()
934                .unwrap()
935                .hyperlink_click
936                .as_ref()
937                .unwrap()
938                .relationship_id,
939            None
940        );
941        let super::TextRun::Field(field) = &body.paragraphs[0].runs[2] else {
942            panic!("owned field");
943        };
944        assert_eq!(
945            field
946                .run_properties
947                .as_ref()
948                .unwrap()
949                .hyperlink_click
950                .as_ref()
951                .unwrap()
952                .relationship_id
953                .as_deref(),
954            Some("field")
955        );
956        assert_eq!(
957            body.paragraphs[0]
958                .end_properties
959                .as_ref()
960                .unwrap()
961                .hyperlink_mouse_over
962                .as_ref()
963                .unwrap()
964                .relationship_id
965                .as_deref(),
966            Some("hover")
967        );
968        let saved = body.to_xml().unwrap();
969        let text = std::str::from_utf8(&saved).unwrap();
970        for token in [
971            "xmlns:a='urn:foreign-a'",
972            "<a:opaque f:keep='&#80;'/>",
973            "f:font='&#78;'",
974            "f:fill='&#79;'",
975            "f:bodyPr='&#76;'",
976            "f:text='&#77;'",
977            "f:body='&#65;'",
978            "f:list='&#66;'",
979            "f:default='&#67;'",
980            "f:paragraph='&#68;'",
981            "f:run='&#69;'",
982            "f:properties='&#70;'",
983            "tooltip='&#72;'",
984            "f:break='&#74;'",
985            "f:field='&#75;'",
986        ] {
987            assert!(text.contains(token), "{token}");
988        }
989        assert!(text.contains("<f:r><q:r><q:t>opaque</q:t></q:r></f:r>"));
990        assert!(text.contains(
991            "<f:hlinkClick f:keep='&#71;'><q:hlinkClick rel:id='shadow'/></f:hlinkClick>"
992        ));
993        assert!(text.contains("<f:payload f:keep='&#73;'/>"));
994        assert!(text.contains("rel:id=\"next\""));
995        assert!(text.contains("rel:id='opaque'"));
996        assert_eq!(
997            CT_TextBody::from_xml(&saved).unwrap().to_xml().unwrap(),
998            saved
999        );
1000    }
1001    #[test]
1002    fn fx206_list_style_foreign_presentation_wrapper_is_not_owned() {
1003        let input = br#"<p:defaultTextStyle xmlns:p="urn:presentation" xmlns:d="http://schemas.openxmlformats.org/drawingml/2006/main"><d:lvl1pPr/></p:defaultTextStyle>"#;
1004        assert!(CT_TextListStyle::from_xml(input).is_err());
1005    }
1006    #[test]
1007    fn fx206_presentation_wrappers_preserve_alias_default_and_foreign_canonical_bindings() {
1008        const P: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
1009        const A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
1010        for local in [
1011            "defaultTextStyle",
1012            "titleStyle",
1013            "bodyStyle",
1014            "otherStyle",
1015            "notesStyle",
1016        ] {
1017            for prefix in ["q:", ""] {
1018                let declaration = if prefix.is_empty() {
1019                    "xmlns"
1020                } else {
1021                    "xmlns:q"
1022                };
1023                let input = format!(
1024                    "<{prefix}{local} {declaration}=\"{P}\" xmlns:p='urn:foreign' xmlns:d=\"{A}\"><d:lvl1pPr algn='ctr'/><x:opaque xmlns:x='urn:opaque' x:keep='&#65;'/></{prefix}{local}>"
1025                );
1026                let parsed = CT_TextListStyle::from_xml(input.as_bytes()).unwrap();
1027                let save = |value: &CT_TextListStyle| {
1028                    let mut writer = quick_xml::Writer::new(Vec::new());
1029                    value
1030                        .write_xml_as(&mut writer, &format!("p:{local}"))
1031                        .unwrap();
1032                    writer.into_inner()
1033                };
1034                let saved = save(&parsed);
1035                let xml = std::str::from_utf8(&saved).unwrap();
1036                assert!(xml.starts_with(&format!("<{prefix}{local} ")));
1037                assert!(xml.contains("xmlns:p='urn:foreign'"));
1038                assert!(xml.contains("<x:opaque xmlns:x='urn:opaque' x:keep='&#65;'/>"));
1039                assert_eq!(
1040                    parsed.level1.as_ref().unwrap().alignment,
1041                    Some(super::paragraph::TextAlignment::Center)
1042                );
1043                assert_eq!(save(&CT_TextListStyle::from_xml(&saved).unwrap()), saved);
1044                let foreign = input.replacen(P, "urn:not-presentation", 1);
1045                assert!(CT_TextListStyle::from_xml(foreign.as_bytes()).is_err());
1046            }
1047        }
1048    }
1049    #[test]
1050    fn fx206_requested_wrapper_namespace_converts_both_directions_without_rebinding_opaque_prefixes()
1051     {
1052        use quick_xml::{NsReader, Writer, events::Event, name::ResolveResult};
1053        const A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
1054        const P: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
1055        for (source, requested, expected) in [(A, "p:txBody", P), (P, "a:txBody", A)] {
1056            for alias in ["q:", ""] {
1057                let declaration = if alias.is_empty() { "xmlns" } else { "xmlns:q" };
1058                let input = format!(
1059                    "<{alias}txBody {declaration}='{source}' xmlns:a='{A}' xmlns:p='urn:opaque'><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>retained text</a:t></a:r></a:p><p:opaque p:keep='&#65;'/></{alias}txBody>"
1060                );
1061                let parsed = CT_TextBody::from_xml(input.as_bytes()).unwrap();
1062                let save = |value: &CT_TextBody| {
1063                    let mut writer = Writer::new(Vec::new());
1064                    value.write_xml_as(&mut writer, requested).unwrap();
1065                    writer.into_inner()
1066                };
1067                let saved = save(&parsed);
1068                let mut reader = NsReader::from_reader(saved.as_slice());
1069                let (namespace, event) = reader.read_resolved_event().unwrap();
1070                assert_eq!(
1071                    namespace,
1072                    ResolveResult::Bound(quick_xml::name::Namespace(expected.as_bytes()))
1073                );
1074                assert!(
1075                    matches!(event, Event::Start(ref start) if start.local_name().as_ref() == b"txBody")
1076                );
1077                let xml = std::str::from_utf8(&saved).unwrap();
1078                assert!(xml.contains("xmlns:p='urn:opaque'"));
1079                assert!(xml.contains("<p:opaque p:keep='&#65;'/>"));
1080                let reopened = CT_TextBody::from_xml(&saved).unwrap();
1081                assert_eq!(reopened.plain_text(), "retained text");
1082                assert_eq!(save(&reopened), saved);
1083            }
1084        }
1085    }
1086}