Skip to main content

rdocx_oxml/
text.rs

1//! Text content elements: `CT_P` (paragraph), `CT_R` (run), `CT_Text`.
2
3use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
4use quick_xml::{Reader, Writer};
5
6use crate::drawing::CT_Drawing;
7use crate::error::Result;
8use crate::namespace::matches_local_name;
9use crate::numbering::{parse_scoped_ppr, word_prefixes_at};
10use crate::properties::{CT_PPr, CT_RPr, is_word_element};
11use crate::raw_xml::{capture_element, capture_empty_element};
12
13/// `CT_Text` — The text content of a run, with optional xml:space="preserve".
14#[derive(Debug, Clone, PartialEq)]
15pub struct CT_Text {
16    pub text: String,
17    pub preserve_space: bool,
18}
19
20impl CT_Text {
21    pub fn new(text: &str) -> Self {
22        CT_Text {
23            text: text.to_string(),
24            preserve_space: text.starts_with(' ') || text.ends_with(' '),
25        }
26    }
27}
28
29/// Types of simple fields.
30#[derive(Debug, Clone, PartialEq)]
31pub enum FieldType {
32    /// Current page number (PAGE field).
33    Page,
34    /// Total number of pages (NUMPAGES field).
35    NumPages,
36    /// Any other field instruction.
37    Other(String),
38}
39
40/// Content that can appear inside a run.
41#[derive(Debug, Clone, PartialEq)]
42pub enum RunContent {
43    Text(CT_Text),
44    Tab,
45    Break(BreakType),
46    Drawing(CT_Drawing),
47    /// A simple field (from `<w:fldSimple>`).
48    Field {
49        field_type: FieldType,
50    },
51    /// A footnote reference (`<w:footnoteReference w:id="..."/>`).
52    FootnoteRef {
53        id: i32,
54    },
55    /// An endnote reference (`<w:endnoteReference w:id="..."/>`).
56    EndnoteRef {
57        id: i32,
58    },
59}
60
61/// Types of breaks.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum BreakType {
64    Line,
65    Page,
66    Column,
67}
68
69/// `CT_R` — A run of text with uniform formatting.
70#[derive(Debug, Clone, PartialEq)]
71#[allow(non_snake_case)]
72pub struct CT_R {
73    pub properties: Option<CT_RPr>,
74    pub content: Vec<RunContent>,
75    /// Unknown child elements captured as raw XML.
76    pub extra_xml: Vec<Vec<u8>>,
77    /// Drawings read out of an `mc:AlternateContent` block, for layout only.
78    ///
79    /// Never serialised. The verbatim copy in `extra_xml` is what gets
80    /// written, so emitting these as well would duplicate the element.
81    pub alt_drawings: Vec<CT_Drawing>,
82}
83
84#[allow(non_snake_case)]
85impl CT_R {
86    pub fn new(text: &str) -> Self {
87        CT_R {
88            properties: None,
89            content: vec![RunContent::Text(CT_Text::new(text))],
90            extra_xml: Vec::new(),
91            alt_drawings: Vec::new(),
92        }
93    }
94
95    /// Get the combined text of all text content in this run.
96    pub fn text(&self) -> String {
97        let mut result = String::new();
98        for item in &self.content {
99            match item {
100                RunContent::Text(t) => result.push_str(&t.text),
101                RunContent::Tab => result.push('\t'),
102                RunContent::Break(_) => result.push('\n'),
103                RunContent::Drawing(_) => {} // Drawings have no text content
104                RunContent::Field { .. } => {} // Fields have no static text
105                RunContent::FootnoteRef { .. } | RunContent::EndnoteRef { .. } => {}
106            }
107        }
108        result
109    }
110
111    pub fn from_xml(reader: &mut Reader<&[u8]>) -> Result<Self> {
112        let mut properties = None;
113        let mut content = Vec::new();
114        let mut extra_xml = Vec::new();
115        let mut alt_drawings = Vec::new();
116        let mut buf = Vec::new();
117
118        loop {
119            match reader.read_event_into(&mut buf) {
120                Ok(Event::Start(ref e)) => {
121                    let name = e.name();
122                    if matches_local_name(name.as_ref(), b"rPr") {
123                        properties = Some(CT_RPr::from_xml(reader)?);
124                    } else if matches_local_name(name.as_ref(), b"t") {
125                        let preserve = e.attributes().any(|a| {
126                            a.ok()
127                                .map(|a| {
128                                    a.key.as_ref() == b"xml:space"
129                                        && a.value.as_ref() == b"preserve"
130                                })
131                                .unwrap_or(false)
132                        });
133                        // `read_text` returns the raw markup span, so entity
134                        // references in it still need resolving.
135                        let text = reader
136                            .read_text(name)
137                            .map(|t| crate::xml_text::decode_escaped(&t))
138                            .unwrap_or_default();
139                        content.push(RunContent::Text(CT_Text {
140                            text,
141                            preserve_space: preserve,
142                        }));
143                    } else if matches_local_name(name.as_ref(), b"drawing") {
144                        content.push(RunContent::Drawing(CT_Drawing::from_xml(reader)?));
145                    } else if matches_local_name(name.as_ref(), b"AlternateContent") {
146                        // Keep the block verbatim so the VML fallback survives
147                        // a write, and separately read the DrawingML out of it
148                        // so layout can see the shape. alt_drawings is never
149                        // serialised, the raw copy below is what gets written.
150                        let raw = capture_element(reader, e)?;
151                        if let Some(drawing) = crate::drawing::parse_alternate_content(&raw) {
152                            alt_drawings.push(drawing);
153                        }
154                        extra_xml.push(raw);
155                    } else {
156                        // Capture unknown child elements as raw XML
157                        extra_xml.push(capture_element(reader, e)?);
158                    }
159                }
160                Ok(Event::Empty(ref e)) => {
161                    let name = e.name();
162                    if matches_local_name(name.as_ref(), b"tab") {
163                        content.push(RunContent::Tab);
164                    } else if matches_local_name(name.as_ref(), b"br") {
165                        let break_type = e
166                            .attributes()
167                            .filter_map(|a| a.ok())
168                            .find(|a| matches_local_name(a.key.as_ref(), b"type"))
169                            .map(|a| match a.value.as_ref() {
170                                b"page" => BreakType::Page,
171                                b"column" => BreakType::Column,
172                                _ => BreakType::Line,
173                            })
174                            .unwrap_or(BreakType::Line);
175                        content.push(RunContent::Break(break_type));
176                    } else if matches_local_name(name.as_ref(), b"footnoteReference") {
177                        let id = e
178                            .attributes()
179                            .filter_map(|a| a.ok())
180                            .find(|a| matches_local_name(a.key.as_ref(), b"id"))
181                            .and_then(|a| std::str::from_utf8(&a.value).ok()?.parse::<i32>().ok())
182                            .unwrap_or(0);
183                        content.push(RunContent::FootnoteRef { id });
184                    } else if matches_local_name(name.as_ref(), b"endnoteReference") {
185                        let id = e
186                            .attributes()
187                            .filter_map(|a| a.ok())
188                            .find(|a| matches_local_name(a.key.as_ref(), b"id"))
189                            .and_then(|a| std::str::from_utf8(&a.value).ok()?.parse::<i32>().ok())
190                            .unwrap_or(0);
191                        content.push(RunContent::EndnoteRef { id });
192                    } else if !matches_local_name(name.as_ref(), b"rPr") {
193                        // Capture unknown empty child elements (e.g.
194                        // w:commentReference) as raw XML, mirroring the
195                        // Event::Start fallback above.
196                        //
197                        // A self-closing <w:rPr/> is deliberately skipped.
198                        // extra_xml is re-emitted after the run content, but
199                        // CT_R requires w:rPr to be the first child, so
200                        // capturing it here would move it past <w:t> and
201                        // produce schema-invalid output. An empty rPr carries
202                        // no formatting, so dropping it loses nothing.
203                        extra_xml.push(capture_empty_element(e)?);
204                    }
205                }
206                Ok(Event::End(ref e)) if matches_local_name(e.name().as_ref(), b"r") => {
207                    break;
208                }
209                Ok(Event::Eof) => break,
210                Err(e) => return Err(e.into()),
211                _ => {}
212            }
213            buf.clear();
214        }
215
216        Ok(CT_R {
217            properties,
218            content,
219            extra_xml,
220            alt_drawings,
221        })
222    }
223
224    pub fn to_xml<W: std::io::Write>(&self, writer: &mut Writer<W>) -> Result<()> {
225        writer.write_event(Event::Start(BytesStart::new("w:r")))?;
226
227        if let Some(ref props) = self.properties {
228            props.to_xml(writer)?;
229        }
230
231        for item in &self.content {
232            match item {
233                RunContent::Text(t) => {
234                    let mut e = BytesStart::new("w:t");
235                    if t.preserve_space {
236                        e.push_attribute(("xml:space", "preserve"));
237                    }
238                    writer.write_event(Event::Start(e))?;
239                    writer.write_event(Event::Text(BytesText::new(&t.text)))?;
240                    writer.write_event(Event::End(BytesEnd::new("w:t")))?;
241                }
242                RunContent::Tab => {
243                    writer.write_event(Event::Empty(BytesStart::new("w:tab")))?;
244                }
245                RunContent::Break(bt) => {
246                    let mut e = BytesStart::new("w:br");
247                    match bt {
248                        BreakType::Page => e.push_attribute(("w:type", "page")),
249                        BreakType::Column => e.push_attribute(("w:type", "column")),
250                        BreakType::Line => {}
251                    }
252                    writer.write_event(Event::Empty(e))?;
253                }
254                RunContent::Drawing(d) => {
255                    d.to_xml(writer)?;
256                }
257                RunContent::Field { .. } => {
258                    // Field runs are serialized at the paragraph level as <w:fldSimple>
259                }
260                RunContent::FootnoteRef { id } => {
261                    let mut buf = itoa::Buffer::new();
262                    let mut e = BytesStart::new("w:footnoteReference");
263                    e.push_attribute(("w:id", buf.format(*id)));
264                    writer.write_event(Event::Empty(e))?;
265                }
266                RunContent::EndnoteRef { id } => {
267                    let mut buf = itoa::Buffer::new();
268                    let mut e = BytesStart::new("w:endnoteReference");
269                    e.push_attribute(("w:id", buf.format(*id)));
270                    writer.write_event(Event::Empty(e))?;
271                }
272            }
273        }
274
275        // Write captured unknown child elements
276        for raw in &self.extra_xml {
277            writer.get_mut().write_all(raw)?;
278        }
279
280        writer.write_event(Event::End(BytesEnd::new("w:r")))?;
281        Ok(())
282    }
283}
284
285/// A hyperlink span that wraps a range of runs.
286#[derive(Debug, Clone, PartialEq)]
287pub struct HyperlinkSpan {
288    /// The relationship ID for the hyperlink target.
289    pub rel_id: Option<String>,
290    /// Optional anchor within the document (for internal links).
291    pub anchor: Option<String>,
292    /// Index of the first run in the hyperlink (inclusive).
293    pub run_start: usize,
294    /// Index of the last run in the hyperlink (exclusive).
295    pub run_end: usize,
296}
297
298/// `CT_P` — A paragraph element containing runs and properties.
299#[derive(Debug, Clone, PartialEq)]
300#[allow(non_snake_case)]
301pub struct CT_P {
302    pub properties: Option<CT_PPr>,
303    pub runs: Vec<CT_R>,
304    /// Hyperlink spans referencing ranges of runs.
305    pub hyperlinks: Vec<HyperlinkSpan>,
306    /// Unknown child elements captured as raw XML with their insertion position (run index).
307    pub extra_xml: Vec<(usize, Vec<u8>)>,
308}
309
310#[allow(non_snake_case)]
311impl CT_P {
312    pub fn new() -> Self {
313        CT_P {
314            properties: None,
315            runs: Vec::new(),
316            hyperlinks: Vec::new(),
317            extra_xml: Vec::new(),
318        }
319    }
320
321    /// Get the combined text of all runs in this paragraph.
322    pub fn text(&self) -> String {
323        self.runs.iter().map(|r| r.text()).collect()
324    }
325
326    /// Add a run with the given text.
327    pub fn add_run(&mut self, text: &str) -> &mut CT_R {
328        self.runs.push(CT_R::new(text));
329        self.runs.last_mut().unwrap()
330    }
331
332    pub fn from_xml(reader: &mut Reader<&[u8]>) -> Result<Self> {
333        Self::from_xml_with_prefixes(reader, &["w".to_string()])
334    }
335
336    pub(crate) fn from_xml_with_prefixes(
337        reader: &mut Reader<&[u8]>,
338        word_prefixes: &[String],
339    ) -> Result<Self> {
340        let mut properties = None;
341        let mut runs = Vec::new();
342        let mut hyperlinks = Vec::new();
343        let mut extra_xml = Vec::new();
344        let mut buf = Vec::new();
345
346        loop {
347            match reader.read_event_into(&mut buf) {
348                Ok(Event::Start(ref e)) => {
349                    let name = e.name();
350                    let prefixes = word_prefixes_at(e, word_prefixes)?;
351                    if is_word_element(name.as_ref(), b"pPr", &prefixes) {
352                        let raw = capture_element(reader, e)?;
353                        properties = Some(parse_scoped_ppr(&raw, &prefixes)?);
354                    } else if matches_local_name(name.as_ref(), b"r") {
355                        runs.push(CT_R::from_xml(reader)?);
356                    } else if matches_local_name(name.as_ref(), b"hyperlink") {
357                        // Parse hyperlink: extract r:id and/or w:anchor, then parse child runs
358                        let mut rel_id = None;
359                        let mut anchor = None;
360                        for attr in e.attributes().flatten() {
361                            let key = attr.key.as_ref();
362                            if matches_local_name(key, b"id") {
363                                rel_id = Some(
364                                    std::str::from_utf8(&attr.value).unwrap_or("").to_string(),
365                                );
366                            } else if matches_local_name(key, b"anchor") {
367                                anchor = Some(
368                                    std::str::from_utf8(&attr.value).unwrap_or("").to_string(),
369                                );
370                            }
371                        }
372
373                        let run_start = runs.len();
374                        // Parse child runs within the hyperlink
375                        let mut inner_buf = Vec::new();
376                        loop {
377                            match reader.read_event_into(&mut inner_buf) {
378                                Ok(Event::Start(ref ie)) => {
379                                    let iname = ie.name();
380                                    if matches_local_name(iname.as_ref(), b"r") {
381                                        runs.push(CT_R::from_xml(reader)?);
382                                    } else {
383                                        reader.read_to_end_into(iname, &mut Vec::new())?;
384                                    }
385                                }
386                                Ok(Event::End(ref ie))
387                                    if matches_local_name(ie.name().as_ref(), b"hyperlink") =>
388                                {
389                                    break;
390                                }
391                                Ok(Event::Eof) => break,
392                                Err(e) => return Err(e.into()),
393                                _ => {}
394                            }
395                            inner_buf.clear();
396                        }
397
398                        let run_end = runs.len();
399                        if run_start < run_end && (rel_id.is_some() || anchor.is_some()) {
400                            hyperlinks.push(HyperlinkSpan {
401                                rel_id,
402                                anchor,
403                                run_start,
404                                run_end,
405                            });
406                        }
407                    } else if matches_local_name(name.as_ref(), b"fldSimple") {
408                        // Parse simple field: extract w:instr attribute
409                        let mut instr = String::new();
410                        for attr in e.attributes().flatten() {
411                            if matches_local_name(attr.key.as_ref(), b"instr") {
412                                instr = std::str::from_utf8(&attr.value).unwrap_or("").to_string();
413                            }
414                        }
415
416                        let field_type = parse_field_instruction(&instr);
417
418                        // Skip child runs (they contain the default display value)
419                        reader.read_to_end_into(name, &mut Vec::new())?;
420
421                        // Add a synthetic run with the field content
422                        runs.push(CT_R {
423                            properties: None,
424                            content: vec![RunContent::Field { field_type }],
425                            extra_xml: Vec::new(),
426                            alt_drawings: Vec::new(),
427                        });
428                    } else {
429                        // Capture unknown elements (bookmarks, comments, etc.) as raw XML
430                        extra_xml.push((runs.len(), capture_element(reader, e)?));
431                    }
432                }
433                Ok(Event::Empty(ref e)) => {
434                    let name = e.name();
435                    if !matches_local_name(name.as_ref(), b"p") {
436                        extra_xml.push((runs.len(), capture_empty_element(e)?));
437                    }
438                }
439                Ok(Event::End(ref e)) if matches_local_name(e.name().as_ref(), b"p") => {
440                    break;
441                }
442                Ok(Event::Eof) => break,
443                Err(e) => return Err(e.into()),
444                _ => {}
445            }
446            buf.clear();
447        }
448
449        Ok(CT_P {
450            properties,
451            runs,
452            hyperlinks,
453            extra_xml,
454        })
455    }
456
457    pub fn to_xml<W: std::io::Write>(&self, writer: &mut Writer<W>) -> Result<()> {
458        writer.write_event(Event::Start(BytesStart::new("w:p")))?;
459
460        if let Some(ref props) = self.properties {
461            props.to_xml(writer)?;
462        }
463
464        // Build a set of run indices that are inside hyperlinks
465        let mut hyperlink_runs: std::collections::HashMap<usize, usize> =
466            std::collections::HashMap::new();
467        for (hl_idx, hl) in self.hyperlinks.iter().enumerate() {
468            for run_idx in hl.run_start..hl.run_end {
469                hyperlink_runs.insert(run_idx, hl_idx);
470            }
471        }
472
473        // Build index of extra_xml elements by position for interleaving
474        let mut extras_by_pos: std::collections::HashMap<usize, Vec<&Vec<u8>>> =
475            std::collections::HashMap::new();
476        for (pos, raw) in &self.extra_xml {
477            extras_by_pos.entry(*pos).or_default().push(raw);
478        }
479
480        let mut current_hyperlink: Option<usize> = None;
481        for (run_idx, run) in self.runs.iter().enumerate() {
482            // Write any extras that should appear before this run
483            if let Some(extras) = extras_by_pos.get(&run_idx) {
484                for raw in extras {
485                    writer.get_mut().write_all(raw)?;
486                }
487            }
488            let in_hl = hyperlink_runs.get(&run_idx).copied();
489
490            // Close hyperlink if we left it
491            if current_hyperlink.is_some() && current_hyperlink != in_hl {
492                writer.write_event(Event::End(BytesEnd::new("w:hyperlink")))?;
493                current_hyperlink = None;
494            }
495
496            // Open hyperlink if entering one
497            if let Some(hl_idx) = in_hl
498                && current_hyperlink != in_hl
499            {
500                let hl = &self.hyperlinks[hl_idx];
501                let mut e = BytesStart::new("w:hyperlink");
502                if let Some(ref rid) = hl.rel_id {
503                    e.push_attribute(("r:id", rid.as_str()));
504                }
505                if let Some(ref anchor) = hl.anchor {
506                    e.push_attribute(("w:anchor", anchor.as_str()));
507                }
508                writer.write_event(Event::Start(e))?;
509                current_hyperlink = in_hl;
510            }
511
512            // Check if this run is a field run
513            if run.content.len() == 1
514                && let RunContent::Field { field_type } = &run.content[0]
515            {
516                let instr = match field_type {
517                    FieldType::Page => " PAGE ",
518                    FieldType::NumPages => " NUMPAGES ",
519                    FieldType::Other(s) => s.as_str(),
520                };
521                let mut fld = BytesStart::new("w:fldSimple");
522                fld.push_attribute(("w:instr", instr));
523                writer.write_event(Event::Start(fld))?;
524                // Emit a default display run
525                writer.write_event(Event::Start(BytesStart::new("w:r")))?;
526                writer.write_event(Event::Start(BytesStart::new("w:t")))?;
527                writer.write_event(Event::Text(BytesText::new("1")))?;
528                writer.write_event(Event::End(BytesEnd::new("w:t")))?;
529                writer.write_event(Event::End(BytesEnd::new("w:r")))?;
530                writer.write_event(Event::End(BytesEnd::new("w:fldSimple")))?;
531                continue;
532            }
533
534            run.to_xml(writer)?;
535        }
536
537        // Close any remaining open hyperlink
538        if current_hyperlink.is_some() {
539            writer.write_event(Event::End(BytesEnd::new("w:hyperlink")))?;
540        }
541
542        // Write any extras that come after the last run
543        if let Some(extras) = extras_by_pos.get(&self.runs.len()) {
544            for raw in extras {
545                writer.get_mut().write_all(raw)?;
546            }
547        }
548
549        writer.write_event(Event::End(BytesEnd::new("w:p")))?;
550        Ok(())
551    }
552}
553
554/// Parse a field instruction string into a FieldType.
555fn parse_field_instruction(instr: &str) -> FieldType {
556    let trimmed = instr.trim().to_uppercase();
557    // Field instruction may have switches like "PAGE \* MERGEFORMAT"
558    let keyword = trimmed.split_whitespace().next().unwrap_or("");
559    match keyword {
560        "PAGE" => FieldType::Page,
561        "NUMPAGES" => FieldType::NumPages,
562        _ => FieldType::Other(instr.trim().to_string()),
563    }
564}
565
566impl Default for CT_P {
567    fn default() -> Self {
568        Self::new()
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    fn parse_paragraph(xml: &str) -> CT_P {
577        let full = format!("<w:p>{xml}</w:p>");
578        let mut reader = Reader::from_str(&full);
579        reader.config_mut().trim_text(true);
580        let mut buf = Vec::new();
581        loop {
582            match reader.read_event_into(&mut buf) {
583                Ok(Event::Start(ref e)) if matches_local_name(e.name().as_ref(), b"p") => break,
584                _ => {}
585            }
586            buf.clear();
587        }
588        CT_P::from_xml(&mut reader).unwrap()
589    }
590
591    #[test]
592    fn parse_simple_paragraph() {
593        let p = parse_paragraph(r#"<w:r><w:t>Hello World</w:t></w:r>"#);
594        assert_eq!(p.text(), "Hello World");
595        assert_eq!(p.runs.len(), 1);
596    }
597
598    #[test]
599    fn parse_paragraph_with_properties() {
600        let p = parse_paragraph(
601            r#"<w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:t>Centered</w:t></w:r>"#,
602        );
603        assert_eq!(p.text(), "Centered");
604        assert!(p.properties.is_some());
605        assert_eq!(
606            p.properties.as_ref().unwrap().jc,
607            Some(crate::shared::ST_Jc::Center)
608        );
609    }
610
611    #[test]
612    fn direct_paragraph_parser_accepts_explicit_property_binding() {
613        let xml = format!(
614            r#"<outer xmlns:ext="urn:producer"><q:p xmlns:q="{}"><ext:pPr><ext:jc ext:val="right"/></ext:pPr><q:pPr xmlns:q="{}"><ext:jc ext:val="right"/><q:jc q:val="center"/></q:pPr><q:r><q:t>Direct</q:t></q:r></q:p></outer>"#,
615            crate::namespace::W_NS,
616            crate::namespace::W_NS
617        );
618        let mut reader = Reader::from_str(&xml);
619        let mut buf = Vec::new();
620        let parsed = loop {
621            match reader.read_event_into(&mut buf) {
622                Ok(Event::Start(ref element)) if element.local_name().as_ref() == b"p" => {
623                    break CT_P::from_xml(&mut reader).unwrap();
624                }
625                Ok(Event::Eof) => panic!("missing paragraph"),
626                event => {
627                    event.unwrap();
628                }
629            }
630            buf.clear();
631        };
632        assert_eq!(parsed.text(), "Direct");
633        assert_eq!(
634            parsed.properties.as_ref().unwrap().jc,
635            Some(crate::shared::ST_Jc::Center)
636        );
637    }
638
639    #[test]
640    fn direct_paragraph_parser_does_not_invent_foreign_word_identity() {
641        let xml = r#"<outer><ext:p xmlns:ext="urn:producer"><ext:pPr xmlns:ext="urn:producer"><ext:jc ext:val="right"/></ext:pPr><ext:r><ext:t>Foreign</ext:t></ext:r></ext:p></outer>"#;
642        let mut reader = Reader::from_str(xml);
643        let mut buf = Vec::new();
644        let parsed = loop {
645            match reader.read_event_into(&mut buf) {
646                Ok(Event::Start(ref element)) if element.local_name().as_ref() == b"p" => {
647                    break CT_P::from_xml(&mut reader).unwrap();
648                }
649                Ok(Event::Eof) => panic!("missing paragraph"),
650                event => {
651                    event.unwrap();
652                }
653            }
654            buf.clear();
655        };
656        assert!(parsed.properties.is_none());
657    }
658
659    #[test]
660    fn direct_paragraph_parser_accepts_default_word_namespace() {
661        let xml = format!(
662            r#"<outer xmlns:ext="urn:producer"><p xmlns="{0}" xmlns:w="{0}"><ext:pPr><ext:jc ext:val="right"/></ext:pPr><pPr xmlns="{0}" xmlns:w="{0}"><ext:jc ext:val="right"/><jc w:val="center"/></pPr><r><t>Direct</t></r></p></outer>"#,
663            crate::namespace::W_NS
664        );
665        let mut reader = Reader::from_str(&xml);
666        let mut buf = Vec::new();
667        let parsed = loop {
668            match reader.read_event_into(&mut buf) {
669                Ok(Event::Start(ref element)) if element.local_name().as_ref() == b"p" => {
670                    break CT_P::from_xml(&mut reader).unwrap();
671                }
672                Ok(Event::Eof) => panic!("missing paragraph"),
673                event => {
674                    event.unwrap();
675                }
676            }
677            buf.clear();
678        };
679        assert_eq!(parsed.text(), "Direct");
680        assert_eq!(
681            parsed.properties.as_ref().unwrap().jc,
682            Some(crate::shared::ST_Jc::Center)
683        );
684    }
685
686    #[test]
687    fn parse_run_with_formatting() {
688        let p = parse_paragraph(r#"<w:r><w:rPr><w:b/><w:i/></w:rPr><w:t>Bold Italic</w:t></w:r>"#);
689        let run = &p.runs[0];
690        let rpr = run.properties.as_ref().unwrap();
691        assert_eq!(rpr.bold, Some(true));
692        assert_eq!(rpr.italic, Some(true));
693    }
694
695    #[test]
696    fn parse_multiple_runs() {
697        let p = parse_paragraph(r#"<w:r><w:t>Hello </w:t></w:r><w:r><w:t>World</w:t></w:r>"#);
698        assert_eq!(p.runs.len(), 2);
699        assert_eq!(p.text(), "Hello World");
700    }
701
702    #[test]
703    fn parse_hyperlink() {
704        let p = parse_paragraph(
705            r#"<w:hyperlink r:id="rId5"><w:r><w:t>Click here</w:t></w:r></w:hyperlink>"#,
706        );
707        assert_eq!(p.runs.len(), 1);
708        assert_eq!(p.text(), "Click here");
709        assert_eq!(p.hyperlinks.len(), 1);
710        assert_eq!(p.hyperlinks[0].rel_id, Some("rId5".to_string()));
711        assert_eq!(p.hyperlinks[0].run_start, 0);
712        assert_eq!(p.hyperlinks[0].run_end, 1);
713    }
714
715    #[test]
716    fn parse_hyperlink_with_anchor() {
717        let p = parse_paragraph(
718            r#"<w:hyperlink w:anchor="section1"><w:r><w:t>Go to section</w:t></w:r></w:hyperlink>"#,
719        );
720        assert_eq!(p.hyperlinks.len(), 1);
721        assert_eq!(p.hyperlinks[0].anchor, Some("section1".to_string()));
722        assert!(p.hyperlinks[0].rel_id.is_none());
723    }
724
725    #[test]
726    fn parse_hyperlink_multiple_runs() {
727        let p = parse_paragraph(
728            r#"<w:r><w:t>Before </w:t></w:r><w:hyperlink r:id="rId6"><w:r><w:t>link </w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>text</w:t></w:r></w:hyperlink><w:r><w:t> after</w:t></w:r>"#,
729        );
730        assert_eq!(p.runs.len(), 4);
731        assert_eq!(p.text(), "Before link text after");
732        assert_eq!(p.hyperlinks.len(), 1);
733        assert_eq!(p.hyperlinks[0].run_start, 1);
734        assert_eq!(p.hyperlinks[0].run_end, 3);
735    }
736
737    #[test]
738    fn round_trip_hyperlink() {
739        let mut p = CT_P::new();
740        p.add_run("Before ");
741        p.add_run("link text");
742        p.add_run(" after");
743        p.hyperlinks.push(HyperlinkSpan {
744            rel_id: Some("rId7".to_string()),
745            anchor: None,
746            run_start: 1,
747            run_end: 2,
748        });
749
750        let mut output = Vec::new();
751        let mut writer = Writer::new(&mut output);
752        p.to_xml(&mut writer).unwrap();
753        let xml = String::from_utf8(output).unwrap();
754
755        let parsed = parse_paragraph(
756            xml.strip_prefix("<w:p>")
757                .unwrap()
758                .strip_suffix("</w:p>")
759                .unwrap(),
760        );
761        assert_eq!(parsed.text(), "Before link text after");
762        assert_eq!(parsed.hyperlinks.len(), 1);
763        assert_eq!(parsed.hyperlinks[0].rel_id, Some("rId7".to_string()));
764        assert_eq!(parsed.hyperlinks[0].run_start, 1);
765        assert_eq!(parsed.hyperlinks[0].run_end, 2);
766    }
767
768    #[test]
769    fn unknown_empty_run_children_roundtrip() {
770        // Unknown empty elements inside a run (e.g. w:commentReference)
771        // must be captured and re-emitted, not silently dropped.
772        let p = parse_paragraph(
773            r#"<w:r><w:t>flagged</w:t></w:r><w:r><w:commentReference w:id="1"/></w:r>"#,
774        );
775        assert_eq!(p.runs.len(), 2);
776        assert_eq!(p.runs[1].extra_xml.len(), 1);
777
778        let mut output = Vec::new();
779        let mut writer = Writer::new(&mut output);
780        p.to_xml(&mut writer).unwrap();
781        let xml = String::from_utf8(output).unwrap();
782        assert!(
783            xml.contains(r#"<w:commentReference w:id="1"/>"#),
784            "comment reference must survive round-trip: {xml}"
785        );
786        assert!(xml.contains("flagged"));
787    }
788
789    #[test]
790    fn alternate_content_is_preserved_once_and_parsed_for_layout() {
791        // A shape as Word writes it: DrawingML in mc:Choice, VML in the
792        // fallback. The block has to come back out verbatim, exactly once,
793        // while still being visible to layout.
794        let src = concat!(
795            r#"<w:r><mc:AlternateContent><mc:Choice Requires="wps">"#,
796            r#"<w:drawing><wp:anchor behindDoc="0">"#,
797            r#"<wp:positionH relativeFrom="column"><wp:posOffset>914400</wp:posOffset></wp:positionH>"#,
798            r#"<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>"#,
799            r#"<wp:extent cx="914400" cy="457200"/>"#,
800            r#"<a:graphic><a:graphicData><wps:wsp><wps:spPr>"#,
801            r#"<a:prstGeom prst="rect"/><a:solidFill><a:srgbClr val="729FCF"/></a:solidFill>"#,
802            r#"<a:ln><a:solidFill><a:srgbClr val="000000"/></a:solidFill></a:ln>"#,
803            r#"</wps:spPr><wps:txbx><w:txbxContent>"#,
804            r#"<w:p><w:r><w:t>boxed</w:t></w:r></w:p>"#,
805            r#"</w:txbxContent></wps:txbx></wps:wsp></a:graphicData></a:graphic>"#,
806            r#"</wp:anchor></w:drawing></mc:Choice>"#,
807            r#"<mc:Fallback><w:pict><v:rect/></w:pict></mc:Fallback>"#,
808            r#"</mc:AlternateContent></w:r>"#,
809        );
810        let p = parse_paragraph(src);
811        assert_eq!(p.runs.len(), 1);
812        let run = &p.runs[0];
813
814        // Visible to layout.
815        assert_eq!(
816            run.alt_drawings.len(),
817            1,
818            "the drawing must reach the model"
819        );
820        let anchor = run.alt_drawings[0]
821            .anchor
822            .as_ref()
823            .expect("should be an anchored drawing");
824        let shape = anchor.shape.as_ref().expect("should carry shape content");
825        assert_eq!(shape.preset.as_deref(), Some("rect"));
826        assert_eq!(
827            shape.solid_fill.as_deref(),
828            Some("729FCF"),
829            "the fill colour must win over the outline colour"
830        );
831        assert_eq!(shape.text.len(), 1);
832        assert_eq!(shape.text[0].text(), "boxed");
833
834        // Preserved verbatim, exactly once.
835        let mut output = Vec::new();
836        let mut writer = Writer::new(&mut output);
837        p.to_xml(&mut writer).unwrap();
838        let xml = String::from_utf8(output).unwrap();
839        assert_eq!(
840            xml.matches("<mc:AlternateContent").count(),
841            1,
842            "the block must not be duplicated: {xml}"
843        );
844        assert_eq!(
845            xml.matches("<mc:Fallback").count(),
846            1,
847            "the VML fallback must survive"
848        );
849        assert!(xml.contains(r#"<a:prstGeom prst="rect"/>"#));
850    }
851
852    #[test]
853    fn empty_run_properties_are_not_moved_after_content() {
854        // extra_xml is written after the run content, and CT_R requires
855        // w:rPr first, so a self-closing <w:rPr/> must not be captured.
856        // Capturing it would emit <w:t> before <w:rPr/> and break the schema.
857        let p = parse_paragraph(r#"<w:r><w:rPr/><w:t>x</w:t></w:r>"#);
858        assert_eq!(p.runs.len(), 1);
859        assert!(
860            p.runs[0].extra_xml.is_empty(),
861            "an empty w:rPr must not be captured as extra_xml"
862        );
863
864        let mut output = Vec::new();
865        let mut writer = Writer::new(&mut output);
866        p.to_xml(&mut writer).unwrap();
867        let xml = String::from_utf8(output).unwrap();
868        assert!(
869            !xml.contains(r#"<w:t>x</w:t><w:rPr/>"#),
870            "w:rPr must never follow run content: {xml}"
871        );
872    }
873
874    #[test]
875    fn parse_fld_simple_page() {
876        let p = parse_paragraph(
877            r#"<w:fldSimple w:instr=" PAGE "><w:r><w:t>1</w:t></w:r></w:fldSimple>"#,
878        );
879        assert_eq!(p.runs.len(), 1);
880        assert_eq!(p.runs[0].content.len(), 1);
881        assert!(matches!(
882            p.runs[0].content[0],
883            RunContent::Field {
884                field_type: FieldType::Page
885            }
886        ));
887    }
888
889    #[test]
890    fn parse_fld_simple_numpages() {
891        let p = parse_paragraph(
892            r#"<w:fldSimple w:instr=" NUMPAGES \* MERGEFORMAT "><w:r><w:t>5</w:t></w:r></w:fldSimple>"#,
893        );
894        assert_eq!(p.runs.len(), 1);
895        assert!(matches!(
896            p.runs[0].content[0],
897            RunContent::Field {
898                field_type: FieldType::NumPages
899            }
900        ));
901    }
902
903    #[test]
904    fn parse_fld_simple_mixed_with_text() {
905        let p = parse_paragraph(
906            r#"<w:r><w:t>Page </w:t></w:r><w:fldSimple w:instr=" PAGE "><w:r><w:t>1</w:t></w:r></w:fldSimple><w:r><w:t> of </w:t></w:r><w:fldSimple w:instr=" NUMPAGES "><w:r><w:t>5</w:t></w:r></w:fldSimple>"#,
907        );
908        assert_eq!(p.runs.len(), 4);
909        assert_eq!(p.text(), "Page  of ");
910        assert!(matches!(
911            p.runs[1].content[0],
912            RunContent::Field {
913                field_type: FieldType::Page
914            }
915        ));
916        assert!(matches!(
917            p.runs[3].content[0],
918            RunContent::Field {
919                field_type: FieldType::NumPages
920            }
921        ));
922    }
923
924    #[test]
925    fn round_trip_fld_simple() {
926        let mut p = CT_P::new();
927        p.add_run("Page ");
928        p.runs.push(CT_R {
929            properties: None,
930            content: vec![RunContent::Field {
931                field_type: FieldType::Page,
932            }],
933            extra_xml: Vec::new(),
934            alt_drawings: Vec::new(),
935        });
936
937        let mut output = Vec::new();
938        let mut writer = Writer::new(&mut output);
939        p.to_xml(&mut writer).unwrap();
940        let xml = String::from_utf8(output).unwrap();
941
942        let parsed = parse_paragraph(
943            xml.strip_prefix("<w:p>")
944                .unwrap()
945                .strip_suffix("</w:p>")
946                .unwrap(),
947        );
948        assert_eq!(parsed.runs.len(), 2);
949        assert!(matches!(
950            parsed.runs[1].content[0],
951            RunContent::Field {
952                field_type: FieldType::Page
953            }
954        ));
955    }
956
957    #[test]
958    fn round_trip_paragraph() {
959        let mut p = CT_P::new();
960        p.add_run("Hello ");
961        let run = p.add_run("World");
962        run.properties = Some(CT_RPr {
963            bold: Some(true),
964            ..Default::default()
965        });
966
967        let mut output = Vec::new();
968        let mut writer = Writer::new(&mut output);
969        p.to_xml(&mut writer).unwrap();
970        let xml = String::from_utf8(output).unwrap();
971
972        let parsed = parse_paragraph(
973            xml.strip_prefix("<w:p>")
974                .unwrap()
975                .strip_suffix("</w:p>")
976                .unwrap(),
977        );
978        assert_eq!(parsed.text(), "Hello World");
979        assert_eq!(parsed.runs.len(), 2);
980        assert_eq!(parsed.runs[1].properties.as_ref().unwrap().bold, Some(true));
981    }
982
983    #[test]
984    fn parse_footnote_reference() {
985        let p = parse_paragraph(
986            r#"<w:r><w:t>Some text</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r>"#,
987        );
988        assert_eq!(p.runs.len(), 2);
989        assert_eq!(p.runs[0].text(), "Some text");
990        assert_eq!(p.runs[1].content.len(), 1);
991        assert!(matches!(
992            p.runs[1].content[0],
993            RunContent::FootnoteRef { id: 1 }
994        ));
995    }
996
997    #[test]
998    fn parse_endnote_reference() {
999        let p = parse_paragraph(r#"<w:r><w:endnoteReference w:id="3"/></w:r>"#);
1000        assert_eq!(p.runs.len(), 1);
1001        assert!(matches!(
1002            p.runs[0].content[0],
1003            RunContent::EndnoteRef { id: 3 }
1004        ));
1005    }
1006
1007    #[test]
1008    fn round_trip_footnote_reference() {
1009        let mut p = CT_P::new();
1010        p.add_run("Text before");
1011        p.runs.push(CT_R {
1012            properties: None,
1013            content: vec![RunContent::FootnoteRef { id: 2 }],
1014            extra_xml: Vec::new(),
1015            alt_drawings: Vec::new(),
1016        });
1017        p.add_run(" text after");
1018
1019        let mut output = Vec::new();
1020        let mut writer = Writer::new(&mut output);
1021        p.to_xml(&mut writer).unwrap();
1022        let xml = String::from_utf8(output).unwrap();
1023
1024        let parsed = parse_paragraph(
1025            xml.strip_prefix("<w:p>")
1026                .unwrap()
1027                .strip_suffix("</w:p>")
1028                .unwrap(),
1029        );
1030        assert_eq!(parsed.runs.len(), 3);
1031        assert!(matches!(
1032            parsed.runs[1].content[0],
1033            RunContent::FootnoteRef { id: 2 }
1034        ));
1035    }
1036}