1use 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#[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#[derive(Debug, Clone, PartialEq)]
31pub enum FieldType {
32 Page,
34 NumPages,
36 Other(String),
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub enum RunContent {
43 Text(CT_Text),
44 Tab,
45 Break(BreakType),
46 Drawing(CT_Drawing),
47 Field {
49 field_type: FieldType,
50 },
51 FootnoteRef {
53 id: i32,
54 },
55 EndnoteRef {
57 id: i32,
58 },
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum BreakType {
64 Line,
65 Page,
66 Column,
67}
68
69#[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 pub extra_xml: Vec<Vec<u8>>,
77 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 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(_) => {} RunContent::Field { .. } => {} 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 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 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 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 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 }
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 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#[derive(Debug, Clone, PartialEq)]
287pub struct HyperlinkSpan {
288 pub rel_id: Option<String>,
290 pub anchor: Option<String>,
292 pub run_start: usize,
294 pub run_end: usize,
296}
297
298#[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 pub hyperlinks: Vec<HyperlinkSpan>,
306 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 pub fn text(&self) -> String {
323 self.runs.iter().map(|r| r.text()).collect()
324 }
325
326 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 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 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 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 reader.read_to_end_into(name, &mut Vec::new())?;
420
421 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 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 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 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 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 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 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 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 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 if current_hyperlink.is_some() {
539 writer.write_event(Event::End(BytesEnd::new("w:hyperlink")))?;
540 }
541
542 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
554fn parse_field_instruction(instr: &str) -> FieldType {
556 let trimmed = instr.trim().to_uppercase();
557 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 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 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 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 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 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}