1use quick_xml::events::Event;
4use quick_xml::{Reader, Writer, XmlVersion};
5
6use crate::content_control::{CT_Sdt, SdtContent};
7use crate::document::{BodyContent, CT_Document, CT_SectPr};
8use crate::namespace::W_NS;
9use crate::numbering::{parse_scoped_ppr, parse_scoped_rpr, word_prefixes_at};
10use crate::properties::{CT_PPr, CT_RPr, is_word_element};
11use crate::raw_xml::capture_empty_element;
12use crate::table::{CT_Row, CT_Tbl, CT_TblPr, CT_Tc, CellContent};
13use crate::text::{AcceptedRunPath, AcceptedRunPathSegment, CT_P, CT_R, hyperlink_revision_index};
14
15const MAX_REVISION_NESTING_DEPTH: usize = 32;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum RevisionKind {
20 Insertion,
21 Deletion,
22 MoveFrom,
23 MoveTo,
24 RunPropertyChange,
25 ParagraphPropertyChange,
26 TablePropertyChange,
27 SectionPropertyChange,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub enum RevisionContent {
33 Runs(Vec<CT_R>),
34 Marker,
35 PriorRunProperties(Box<CT_RPr>),
36 PriorParagraphProperties(Box<CT_PPr>),
37 PriorTableProperties(Box<CT_TblPr>),
38 PriorSectionProperties(Box<CT_SectPr>),
39}
40
41#[derive(Debug, Clone, PartialEq)]
43#[allow(non_camel_case_types)]
44pub struct CT_Revision {
45 kind: RevisionKind,
46 id: i32,
47 author: String,
48 timestamp: Option<String>,
49 raw_xml: Vec<u8>,
50 content: RevisionContent,
51 content_paragraph: Option<Box<CT_P>>,
52 nested_revisions: Vec<(usize, CT_Revision)>,
53 parse_word_prefixes: Vec<String>,
54 require_author: bool,
55}
56
57impl CT_Revision {
58 pub(crate) fn from_raw(raw_xml: Vec<u8>, word_prefixes: &[String]) -> Option<Self> {
59 Self::try_from_raw(raw_xml, word_prefixes, true).ok()
60 }
61
62 pub(crate) fn from_raw_content(raw_xml: Vec<u8>, word_prefixes: &[String]) -> Option<Self> {
63 Self::try_from_raw(raw_xml, word_prefixes, false).ok()
64 }
65
66 #[doc(hidden)]
68 pub fn story_raw_is_typed(raw_xml: &[u8], word_prefixes: &[String]) -> bool {
69 Self::try_from_raw(raw_xml.to_vec(), word_prefixes, true).is_ok()
70 }
71
72 pub(crate) fn into_raw_xml(self) -> Vec<u8> {
73 self.raw_xml
74 }
75
76 fn try_from_raw(
77 raw_xml: Vec<u8>,
78 word_prefixes: &[String],
79 require_author: bool,
80 ) -> crate::Result<Self> {
81 validate_revision_nesting_depth(&raw_xml, word_prefixes)?;
82 let mut reader = Reader::from_reader(raw_xml.as_slice());
83 reader.config_mut().trim_text(false);
84 let mut buffer = Vec::new();
85 let (kind, prefixes) = loop {
86 match reader.read_event_into(&mut buffer)? {
87 Event::Start(start) => {
88 let prefixes = word_prefixes_at(&start, word_prefixes)?;
89 let kind =
90 revision_kind(start.name().as_ref(), &prefixes).ok_or_else(|| {
91 crate::OxmlError::InvalidValue(
92 "unsupported revision element".to_owned(),
93 )
94 })?;
95 let id = required_word_attribute(&start, b"id", &prefixes)?.parse()?;
96 let author = optional_word_attribute(&start, b"author", &prefixes)?;
97 let author = match (author, require_author) {
98 (Some(author), _) => author,
99 (None, true) => required_word_attribute(&start, b"author", &prefixes)?,
100 (None, false) => String::new(),
101 };
102 let timestamp = optional_word_attribute(&start, b"date", &prefixes)?;
103 break ((kind, id, author, timestamp), prefixes);
104 }
105 Event::Empty(start) => {
106 let prefixes = word_prefixes_at(&start, word_prefixes)?;
107 let kind =
108 revision_kind(start.name().as_ref(), &prefixes).ok_or_else(|| {
109 crate::OxmlError::InvalidValue(
110 "unsupported revision element".to_owned(),
111 )
112 })?;
113 let id = required_word_attribute(&start, b"id", &prefixes)?.parse()?;
114 let author = optional_word_attribute(&start, b"author", &prefixes)?;
115 let author = match (author, require_author) {
116 (Some(author), _) => author,
117 (None, true) => required_word_attribute(&start, b"author", &prefixes)?,
118 (None, false) => String::new(),
119 };
120 let timestamp = optional_word_attribute(&start, b"date", &prefixes)?;
121 return Ok(Self {
122 kind,
123 id,
124 author,
125 timestamp,
126 raw_xml,
127 content: RevisionContent::Marker,
128 content_paragraph: None,
129 nested_revisions: Vec::new(),
130 parse_word_prefixes: word_prefixes.to_vec(),
131 require_author,
132 });
133 }
134 Event::Eof => {
135 return Err(crate::OxmlError::MissingElement(
136 "revision element".to_owned(),
137 ));
138 }
139 _ => {}
140 }
141 buffer.clear();
142 };
143 let (kind, id, author, timestamp) = kind;
144 let (content, content_paragraph, nested_revisions) =
145 if matches!(kind, RevisionKind::Insertion | RevisionKind::MoveTo) {
146 let (content, nested_revisions) = parse_content(&mut reader, kind, &prefixes)?;
147 let paragraph = parse_accepted_revision_content(&raw_xml, &prefixes)?;
148 (content, Some(Box::new(paragraph)), nested_revisions)
149 } else {
150 let (content, nested_revisions) = parse_content(&mut reader, kind, &prefixes)?;
151 (content, None, nested_revisions)
152 };
153 Ok(Self {
154 kind,
155 id,
156 author,
157 timestamp,
158 raw_xml,
159 content,
160 content_paragraph,
161 nested_revisions,
162 parse_word_prefixes: word_prefixes.to_vec(),
163 require_author,
164 })
165 }
166
167 pub fn kind(&self) -> RevisionKind {
168 self.kind
169 }
170
171 pub fn id(&self) -> i32 {
172 self.id
173 }
174
175 pub fn author(&self) -> &str {
176 &self.author
177 }
178
179 pub fn timestamp(&self) -> Option<&str> {
180 self.timestamp.as_deref()
181 }
182
183 pub fn content(&self) -> &RevisionContent {
184 &self.content
185 }
186
187 #[doc(hidden)]
189 pub fn content_paragraph(&self) -> Option<&CT_P> {
190 self.content_paragraph.as_deref()
191 }
192
193 #[doc(hidden)]
197 pub fn physical_content_paragraph_mut(&mut self) -> crate::Result<Option<&mut CT_P>> {
198 if !matches!(
199 self.kind,
200 RevisionKind::Insertion
201 | RevisionKind::Deletion
202 | RevisionKind::MoveFrom
203 | RevisionKind::MoveTo
204 ) {
205 return Ok(None);
206 }
207 if self.content_paragraph.is_none() {
208 self.content_paragraph = Some(Box::new(parse_accepted_revision_content(
209 &self.raw_xml,
210 &self.parse_word_prefixes,
211 )?));
212 }
213 Ok(self.content_paragraph.as_deref_mut())
214 }
215
216 #[doc(hidden)]
218 pub fn nested_revisions(&self) -> &[(usize, CT_Revision)] {
219 &self.nested_revisions
220 }
221
222 pub(crate) fn append_accepted_run_paths(
223 &self,
224 prefix: &mut Vec<AcceptedRunPathSegment>,
225 output: &mut Vec<AcceptedRunPath>,
226 ) {
227 if !matches!(self.kind, RevisionKind::Insertion | RevisionKind::MoveTo) {
228 return;
229 }
230 if let Some(paragraph) = self.content_paragraph.as_deref() {
231 super::text::append_accepted_paragraph_run_paths(paragraph, prefix, output);
232 }
233 }
234
235 pub(crate) fn accepted_run_segments(&self, path: &[AcceptedRunPathSegment]) -> Option<&CT_R> {
236 if !matches!(self.kind, RevisionKind::Insertion | RevisionKind::MoveTo) {
237 return None;
238 }
239 self.content_paragraph
240 .as_deref()?
241 .accepted_run_segments(path)
242 }
243
244 pub(crate) fn replace_accepted_run_segments(
245 &mut self,
246 path: &[AcceptedRunPathSegment],
247 replacement: CT_R,
248 ) -> crate::Result<bool> {
249 self.edit_accepted_paragraph(|paragraph| {
250 paragraph.replace_accepted_run_segments(path, replacement)
251 })
252 }
253
254 pub(crate) fn split_accepted_run_segments(
255 &mut self,
256 path: &[AcceptedRunPathSegment],
257 offset: usize,
258 ) -> crate::Result<bool> {
259 self.edit_accepted_paragraph(|paragraph| {
260 paragraph.split_accepted_run_segments(path, offset)
261 })
262 }
263
264 pub(crate) fn remove_accepted_run_segments(
269 &mut self,
270 path: &[AcceptedRunPathSegment],
271 ) -> crate::Result<Option<bool>> {
272 if self.kind == RevisionKind::MoveTo {
273 return Err(crate::OxmlError::InvalidValue(
274 "it is part of a tracked move destination, whose source would no longer match it"
275 .to_owned(),
276 ));
277 }
278 let mut outcome = None;
279 self.edit_accepted_paragraph(|paragraph| {
280 if !paragraph.remove_accepted_run_segments(path)? {
281 return Ok(false);
282 }
283 let emptied = paragraph.has_no_children();
284 outcome = Some(emptied);
285 Ok(!emptied)
286 })?;
287 Ok(outcome)
288 }
289
290 pub(crate) fn editable_accepted_paragraph(&self) -> Option<&CT_P> {
293 if !matches!(self.kind, RevisionKind::Insertion | RevisionKind::MoveTo)
294 || ensure_revision_word_prefix_is_mutable(&self.raw_xml).is_err()
295 {
296 return None;
297 }
298 self.content_paragraph.as_deref()
299 }
300
301 pub(crate) fn edit_accepted_paragraph(
306 &mut self,
307 edit: impl FnOnce(&mut CT_P) -> crate::Result<bool>,
308 ) -> crate::Result<bool> {
309 if !matches!(self.kind, RevisionKind::Insertion | RevisionKind::MoveTo) {
310 return Ok(false);
311 }
312 ensure_revision_word_prefix_is_mutable(&self.raw_xml)?;
313 let mut candidate = self.clone();
314 let Some(paragraph) = candidate.content_paragraph.as_deref_mut() else {
315 return Ok(false);
316 };
317 if !edit(paragraph)? {
318 return Ok(false);
319 }
320 candidate.refresh_accepted_content()?;
321 *self = candidate;
322 Ok(true)
323 }
324
325 fn refresh_accepted_content(&mut self) -> crate::Result<()> {
326 let paragraph = self.content_paragraph.as_deref().ok_or_else(|| {
327 crate::OxmlError::MissingElement("accepted revision paragraph".to_owned())
328 })?;
329 let mut writer = Writer::new(Vec::new());
330 paragraph.to_xml(&mut writer)?;
331 let paragraph_xml = writer.into_inner();
332 let paragraph_start = paragraph_xml
333 .iter()
334 .position(|byte| *byte == b'>')
335 .map(|index| index + 1)
336 .ok_or_else(|| crate::OxmlError::MissingElement("paragraph start".to_owned()))?;
337 let paragraph_end = paragraph_xml
338 .windows(b"</w:p>".len())
339 .rposition(|window| window == b"</w:p>")
340 .ok_or_else(|| crate::OxmlError::MissingElement("paragraph end".to_owned()))?;
341 let (content_start, content_end) = revision_content_bounds(&self.raw_xml)?;
342 let mut updated = Vec::with_capacity(
343 self.raw_xml.len() + paragraph_xml.len() - (content_end - content_start),
344 );
345 updated.extend_from_slice(&self.raw_xml[..content_start]);
346 updated.extend_from_slice(¶graph_xml[paragraph_start..paragraph_end]);
347 updated.extend_from_slice(&self.raw_xml[content_end..]);
348 let prefixes = self.parse_word_prefixes.clone();
349 let require_author = self.require_author;
350 *self = Self::try_from_raw(updated, &prefixes, require_author)?;
351 Ok(())
352 }
353
354 pub(crate) fn write_xml<W: std::io::Write>(
355 &self,
356 writer: &mut quick_xml::Writer<W>,
357 ) -> crate::Result<()> {
358 writer.get_mut().write_all(&self.raw_xml)?;
359 Ok(())
360 }
361
362 pub(crate) fn write_xml_with_word_override<W: std::io::Write>(
363 &self,
364 writer: &mut quick_xml::Writer<W>,
365 foreign_word_namespace: Option<&str>,
366 ) -> crate::Result<()> {
367 crate::text::write_raw_with_word_override(writer, &self.raw_xml, foreign_word_namespace)
368 }
369}
370
371fn validate_revision_nesting_depth(raw_xml: &[u8], word_prefixes: &[String]) -> crate::Result<()> {
372 let mut reader = Reader::from_reader(raw_xml);
373 reader.config_mut().trim_text(false);
374 let mut buffer = Vec::new();
375 let mut scopes = vec![(word_prefixes.to_vec(), false)];
376 let mut revision_depth = 0usize;
377
378 loop {
379 match reader.read_event_into(&mut buffer)? {
380 Event::Start(start) => {
381 let inherited = &scopes.last().expect("root namespace scope").0;
382 let prefixes = word_prefixes_at(&start, inherited)?;
383 let is_revision = revision_kind(start.name().as_ref(), &prefixes).is_some();
384 if is_revision {
385 revision_depth += 1;
386 if revision_depth > MAX_REVISION_NESTING_DEPTH {
387 return Err(crate::OxmlError::InvalidValue(
388 "revision nesting depth exceeds reader limit".to_owned(),
389 ));
390 }
391 }
392 scopes.push((prefixes, is_revision));
393 }
394 Event::Empty(start) => {
395 let inherited = &scopes.last().expect("root namespace scope").0;
396 let prefixes = word_prefixes_at(&start, inherited)?;
397 if revision_kind(start.name().as_ref(), &prefixes).is_some()
398 && revision_depth + 1 > MAX_REVISION_NESTING_DEPTH
399 {
400 return Err(crate::OxmlError::InvalidValue(
401 "revision nesting depth exceeds reader limit".to_owned(),
402 ));
403 }
404 }
405 Event::End(_) => {
406 let (_, was_revision) = scopes.pop().expect("balanced namespace scope");
407 if was_revision {
408 revision_depth -= 1;
409 }
410 }
411 Event::Eof => break,
412 _ => {}
413 }
414 buffer.clear();
415 }
416
417 Ok(())
418}
419
420impl CT_Document {
421 pub fn revisions(&self) -> Vec<&CT_Revision> {
423 let mut revisions = Vec::new();
424 for content in &self.body.content {
425 collect_body_content(content, &mut revisions);
426 }
427 if let Some(section) = &self.body.sect_pr {
428 collect_section_properties(section, &mut revisions);
429 }
430 revisions
431 }
432}
433
434fn collect_body_content<'a>(content: &'a BodyContent, revisions: &mut Vec<&'a CT_Revision>) {
435 match content {
436 BodyContent::Paragraph(paragraph) => collect_paragraph(paragraph, revisions),
437 BodyContent::Table(table) => collect_table(table, revisions),
438 BodyContent::ContentControl(control) => collect_control(control, revisions),
439 BodyContent::RawXml(_) => {}
440 }
441}
442
443fn collect_paragraph<'a>(paragraph: &'a CT_P, revisions: &mut Vec<&'a CT_Revision>) {
444 if let Some(properties) = ¶graph.properties {
445 if let Some(revision) = &properties.numbering_revision {
446 revisions.push(revision);
447 }
448 if let Some(run_properties) = &properties.rpr {
449 collect_run_properties(run_properties, revisions);
450 }
451 if let Some(section) = &properties.sect_pr {
452 collect_section_properties(section, revisions);
453 }
454 if let Some(change) = &properties.change {
455 revisions.push(change);
456 }
457 }
458
459 for boundary in 0..=paragraph.runs.len() {
460 let raw_count = paragraph
461 .extra_xml
462 .iter()
463 .filter(|(at, _)| *at == boundary)
464 .count();
465 for raw_index in 0..=raw_count {
466 for (_, _, _, control) in
467 paragraph
468 .content_controls
469 .iter()
470 .filter(|(at, raw_before, _, _)| {
471 *at == boundary && (*raw_before).min(raw_count) == raw_index
472 })
473 {
474 collect_control(control, revisions);
475 }
476 for (_, _, revision) in paragraph.revisions.iter().filter(|(at, raw_before, _)| {
477 *at == boundary
478 && hyperlink_revision_index(*raw_before).is_none()
479 && (*raw_before).min(raw_count) == raw_index
480 }) {
481 collect_revision(revision, revisions);
482 }
483 for (hyperlink_index, _hyperlink) in
484 paragraph
485 .hyperlinks
486 .iter()
487 .enumerate()
488 .filter(|(_, hyperlink)| {
489 hyperlink.run_start == boundary
490 && hyperlink.run_end == boundary
491 && hyperlink.preserved_raw_before == Some(raw_index)
492 })
493 {
494 for (_, _, revision) in paragraph.revisions.iter().filter(|(at, slot, _)| {
495 *at == boundary && hyperlink_revision_index(*slot) == Some(hyperlink_index)
496 }) {
497 collect_revision(revision, revisions);
498 }
499 }
500 }
501 for (_, _, revision) in paragraph.revisions.iter().filter(|(at, slot, _)| {
502 *at == boundary
503 && hyperlink_revision_index(*slot).is_some_and(|index| {
504 paragraph
505 .hyperlinks
506 .get(index)
507 .is_none_or(|hyperlink| hyperlink.preserved_raw_before.is_none())
508 })
509 }) {
510 collect_revision(revision, revisions);
511 }
512 if let Some(run) = paragraph.runs.get(boundary) {
513 collect_run(run, revisions);
514 }
515 }
516}
517
518fn collect_run<'a>(run: &'a CT_R, revisions: &mut Vec<&'a CT_Revision>) {
519 if let Some(properties) = &run.properties {
520 collect_run_properties(properties, revisions);
521 }
522}
523
524fn collect_run_properties<'a>(properties: &'a CT_RPr, revisions: &mut Vec<&'a CT_Revision>) {
525 revisions.extend(properties.revision_markers.iter());
526 if let Some(change) = &properties.change {
527 revisions.push(change);
528 }
529}
530
531fn collect_section_properties<'a>(properties: &'a CT_SectPr, revisions: &mut Vec<&'a CT_Revision>) {
532 if let Some(change) = &properties.change {
533 revisions.push(change);
534 }
535}
536
537fn collect_table<'a>(table: &'a CT_Tbl, revisions: &mut Vec<&'a CT_Revision>) {
538 if let Some(change) = table
539 .properties
540 .as_ref()
541 .and_then(|properties| properties.change.as_ref())
542 {
543 revisions.push(change);
544 }
545 for boundary in 0..=table.rows.len() {
546 collect_controls_at_table_boundary(table, boundary, revisions);
547 if let Some(row) = table.rows.get(boundary) {
548 collect_row(row, revisions);
549 }
550 }
551}
552
553fn collect_controls_at_table_boundary<'a>(
554 table: &'a CT_Tbl,
555 boundary: usize,
556 revisions: &mut Vec<&'a CT_Revision>,
557) {
558 let raw_count = table
559 .extra_xml
560 .iter()
561 .filter(|(at, _)| *at == boundary)
562 .count();
563 for raw_index in 0..=raw_count {
564 for (_, _, control) in table.content_controls.iter().filter(|(at, raw_before, _)| {
565 *at == boundary && (*raw_before).min(raw_count) == raw_index
566 }) {
567 collect_control(control, revisions);
568 }
569 }
570}
571
572fn collect_row<'a>(row: &'a CT_Row, revisions: &mut Vec<&'a CT_Revision>) {
573 if let Some(properties) = &row.properties {
574 revisions.extend(properties.revision_markers.iter());
575 }
576 for boundary in 0..=row.cells.len() {
577 let raw_count = row
578 .extra_xml
579 .iter()
580 .filter(|(at, _)| *at == boundary)
581 .count();
582 for raw_index in 0..=raw_count {
583 for (_, _, control) in row.content_controls.iter().filter(|(at, raw_before, _)| {
584 *at == boundary && (*raw_before).min(raw_count) == raw_index
585 }) {
586 collect_control(control, revisions);
587 }
588 }
589 if let Some(cell) = row.cells.get(boundary) {
590 collect_cell(cell, revisions);
591 }
592 }
593}
594
595fn collect_cell<'a>(cell: &'a CT_Tc, revisions: &mut Vec<&'a CT_Revision>) {
596 for content in &cell.content {
597 match content {
598 CellContent::Paragraph(paragraph) => collect_paragraph(paragraph, revisions),
599 CellContent::Table(table) => collect_table(table, revisions),
600 CellContent::ContentControl(control) => collect_control(control, revisions),
601 }
602 }
603}
604
605fn collect_control<'a>(control: &'a CT_Sdt, revisions: &mut Vec<&'a CT_Revision>) {
606 for (content_index, content) in control.content.iter().enumerate() {
607 for (_, revision) in control
608 .revisions
609 .iter()
610 .filter(|(at, _)| *at == content_index)
611 {
612 collect_revision(revision, revisions);
613 }
614 match content {
615 SdtContent::Paragraph(paragraph) => collect_paragraph(paragraph, revisions),
616 SdtContent::Table(table) => collect_table(table, revisions),
617 SdtContent::Row(row) => collect_row(row, revisions),
618 SdtContent::Cell(cell) => collect_cell(cell, revisions),
619 SdtContent::Run(run) => collect_run(run, revisions),
620 SdtContent::ContentControl(control) => collect_control(control, revisions),
621 SdtContent::RawXml(_) => {}
622 }
623 }
624}
625
626fn collect_revision<'a>(revision: &'a CT_Revision, revisions: &mut Vec<&'a CT_Revision>) {
627 revisions.push(revision);
628 if let Some(paragraph) = revision.content_paragraph() {
629 collect_paragraph(paragraph, revisions);
630 return;
631 }
632 if let RevisionContent::Runs(runs) = revision.content() {
633 for boundary in 0..=runs.len() {
634 for (_, nested) in revision
635 .nested_revisions
636 .iter()
637 .filter(|(at, _)| *at == boundary)
638 {
639 collect_revision(nested, revisions);
640 }
641 if let Some(run) = runs.get(boundary) {
642 collect_run(run, revisions);
643 }
644 }
645 } else {
646 for (_, nested) in &revision.nested_revisions {
647 collect_revision(nested, revisions);
648 }
649 }
650}
651
652fn parse_accepted_revision_content(
653 raw_xml: &[u8],
654 word_prefixes: &[String],
655) -> crate::Result<CT_P> {
656 let (content_start, content_end) = revision_content_bounds(raw_xml)?;
657 let mut paragraph_xml = Vec::from(b"<w:p>".as_slice());
658 paragraph_xml.extend_from_slice(&raw_xml[content_start..content_end]);
659 paragraph_xml.extend_from_slice(b"</w:p>");
660 let mut paragraph_reader = Reader::from_reader(paragraph_xml.as_slice());
661 let mut paragraph_buffer = Vec::new();
662 match paragraph_reader.read_event_into(&mut paragraph_buffer)? {
663 Event::Start(start) => CT_P::from_xml_with_prefixes_and_root(
664 &mut paragraph_reader,
665 word_prefixes,
666 Some(&start),
667 ),
668 _ => Err(crate::OxmlError::MissingElement(
669 "insertion content".to_owned(),
670 )),
671 }
672}
673
674fn revision_content_bounds(raw_xml: &[u8]) -> crate::Result<(usize, usize)> {
675 let mut reader = Reader::from_reader(raw_xml);
676 reader.config_mut().trim_text(false);
677 let mut buffer = Vec::new();
678 let content_start = loop {
679 match reader.read_event_into(&mut buffer)? {
680 Event::Start(_) => break reader.buffer_position() as usize,
681 Event::Eof => {
682 return Err(crate::OxmlError::MissingElement(
683 "insertion element".to_owned(),
684 ));
685 }
686 _ => {}
687 }
688 buffer.clear();
689 };
690 let content_end = raw_xml
691 .iter()
692 .enumerate()
693 .rev()
694 .find_map(|(index, byte)| (*byte == b'<').then_some(index))
695 .ok_or_else(|| crate::OxmlError::MissingElement("insertion close tag".to_owned()))?;
696 Ok((content_start, content_end))
697}
698
699fn ensure_revision_word_prefix_is_mutable(raw_xml: &[u8]) -> crate::Result<()> {
700 let mut reader = Reader::from_reader(raw_xml);
701 let mut buffer = Vec::new();
702 let root = match reader.read_event_into(&mut buffer)? {
703 Event::Start(root) => root,
704 _ => {
705 return Err(crate::OxmlError::MissingElement(
706 "revision start".to_owned(),
707 ));
708 }
709 };
710 for attribute in root.attributes() {
711 let attribute = attribute?;
712 if attribute.key.as_ref() != b"xmlns:w" {
713 continue;
714 }
715 let namespace =
716 attribute.decoded_and_normalized_value(XmlVersion::Implicit1_0, root.decoder())?;
717 if namespace.as_ref() != W_NS {
718 return Err(crate::OxmlError::InvalidValue(
719 "revision binds the reserved w prefix to a foreign namespace".to_owned(),
720 ));
721 }
722 }
723 Ok(())
724}
725
726fn revision_kind(name: &[u8], prefixes: &[String]) -> Option<RevisionKind> {
727 [
728 (b"ins".as_slice(), RevisionKind::Insertion),
729 (b"del".as_slice(), RevisionKind::Deletion),
730 (b"moveFrom".as_slice(), RevisionKind::MoveFrom),
731 (b"moveTo".as_slice(), RevisionKind::MoveTo),
732 (b"rPrChange".as_slice(), RevisionKind::RunPropertyChange),
733 (
734 b"pPrChange".as_slice(),
735 RevisionKind::ParagraphPropertyChange,
736 ),
737 (b"tblPrChange".as_slice(), RevisionKind::TablePropertyChange),
738 (
739 b"sectPrChange".as_slice(),
740 RevisionKind::SectionPropertyChange,
741 ),
742 ]
743 .into_iter()
744 .find_map(|(local, kind)| is_word_element(name, local, prefixes).then_some(kind))
745}
746
747fn parse_content(
748 reader: &mut Reader<&[u8]>,
749 kind: RevisionKind,
750 word_prefixes: &[String],
751) -> crate::Result<(RevisionContent, Vec<(usize, CT_Revision)>)> {
752 let mut runs = Vec::new();
753 let mut nested_revisions = Vec::new();
754 let mut buffer = Vec::new();
755 loop {
756 match reader.read_event_into(&mut buffer)? {
757 Event::Start(start) => {
758 let prefixes = word_prefixes_at(&start, word_prefixes)?;
759 let name = start.name();
760 if is_word_element(name.as_ref(), b"r", &prefixes) {
761 runs.push(CT_R::from_xml_with_prefixes_and_root(
762 reader,
763 &prefixes,
764 Some(&start),
765 )?);
766 } else if revision_kind(name.as_ref(), &prefixes).is_some() {
767 let raw = crate::raw_xml::capture_element(reader, &start)?;
768 if let Some(revision) = CT_Revision::from_raw(raw, &prefixes) {
769 nested_revisions.push((runs.len(), revision));
770 }
771 } else if is_word_element(name.as_ref(), b"hyperlink", &prefixes) {
772 parse_run_content_container(
773 reader,
774 b"hyperlink",
775 &prefixes,
776 &mut runs,
777 &mut nested_revisions,
778 )?;
779 } else if is_word_element(name.as_ref(), b"rPr", &prefixes)
780 && kind == RevisionKind::RunPropertyChange
781 {
782 let raw = crate::raw_xml::capture_element(reader, &start)?;
783 return Ok((
784 RevisionContent::PriorRunProperties(Box::new(parse_scoped_rpr(
785 &raw,
786 word_prefixes,
787 )?)),
788 nested_revisions,
789 ));
790 } else if is_word_element(name.as_ref(), b"pPr", &prefixes)
791 && kind == RevisionKind::ParagraphPropertyChange
792 {
793 let raw = crate::raw_xml::capture_element(reader, &start)?;
794 return Ok((
795 RevisionContent::PriorParagraphProperties(Box::new(parse_scoped_ppr(
796 &raw,
797 word_prefixes,
798 )?)),
799 nested_revisions,
800 ));
801 } else if is_word_element(name.as_ref(), b"tblPr", &prefixes)
802 && kind == RevisionKind::TablePropertyChange
803 {
804 let owner_bindings =
805 crate::numbering::local_namespace_overrides(&start, word_prefixes)?;
806 return Ok((
807 RevisionContent::PriorTableProperties(Box::new(
808 CT_TblPr::from_xml_with_prefixes_and_owner_bindings(
809 reader,
810 &prefixes,
811 &owner_bindings,
812 )?,
813 )),
814 nested_revisions,
815 ));
816 } else if is_word_element(name.as_ref(), b"sectPr", &prefixes)
817 && kind == RevisionKind::SectionPropertyChange
818 {
819 let owner_bindings =
820 crate::numbering::local_namespace_overrides(&start, word_prefixes)?;
821 return Ok((
822 RevisionContent::PriorSectionProperties(Box::new(
823 CT_SectPr::from_xml_with_prefixes_owner_bindings_and_root(
824 reader,
825 &prefixes,
826 &owner_bindings,
827 Some(&start),
828 )?,
829 )),
830 nested_revisions,
831 ));
832 } else {
833 reader.read_to_end_into(name, &mut Vec::new())?;
834 }
835 }
836 Event::Empty(start) => {
837 let prefixes = word_prefixes_at(&start, word_prefixes)?;
838 if revision_kind(start.name().as_ref(), &prefixes).is_some() {
839 let raw = capture_empty_element(&start)?;
840 if let Some(revision) = CT_Revision::from_raw(raw, &prefixes) {
841 nested_revisions.push((runs.len(), revision));
842 }
843 }
844 }
845 Event::End(_) | Event::Eof => break,
846 _ => {}
847 }
848 buffer.clear();
849 }
850 if runs.is_empty() {
851 Ok((RevisionContent::Marker, nested_revisions))
852 } else {
853 Ok((RevisionContent::Runs(runs), nested_revisions))
854 }
855}
856
857fn parse_run_content_container(
858 reader: &mut Reader<&[u8]>,
859 closing_local: &[u8],
860 word_prefixes: &[String],
861 runs: &mut Vec<CT_R>,
862 nested_revisions: &mut Vec<(usize, CT_Revision)>,
863) -> crate::Result<()> {
864 let mut buffer = Vec::new();
865 loop {
866 match reader.read_event_into(&mut buffer)? {
867 Event::Start(start) => {
868 let prefixes = word_prefixes_at(&start, word_prefixes)?;
869 let name = start.name();
870 if is_word_element(name.as_ref(), b"r", &prefixes) {
871 runs.push(CT_R::from_xml_with_prefixes_and_root(
872 reader,
873 &prefixes,
874 Some(&start),
875 )?);
876 } else if revision_kind(name.as_ref(), &prefixes).is_some() {
877 let raw = crate::raw_xml::capture_element(reader, &start)?;
878 if let Some(revision) = CT_Revision::from_raw(raw, &prefixes) {
879 nested_revisions.push((runs.len(), revision));
880 }
881 } else if is_word_element(name.as_ref(), b"hyperlink", &prefixes) {
882 parse_run_content_container(
883 reader,
884 b"hyperlink",
885 &prefixes,
886 runs,
887 nested_revisions,
888 )?;
889 } else {
890 reader.read_to_end_into(name, &mut Vec::new())?;
891 }
892 }
893 Event::Empty(start) => {
894 let prefixes = word_prefixes_at(&start, word_prefixes)?;
895 if revision_kind(start.name().as_ref(), &prefixes).is_some() {
896 let raw = capture_empty_element(&start)?;
897 if let Some(revision) = CT_Revision::from_raw(raw, &prefixes) {
898 nested_revisions.push((runs.len(), revision));
899 }
900 }
901 }
902 Event::End(end)
903 if is_word_element(end.name().as_ref(), closing_local, word_prefixes) =>
904 {
905 break;
906 }
907 Event::Eof => break,
908 _ => {}
909 }
910 buffer.clear();
911 }
912 Ok(())
913}
914
915fn required_word_attribute(
916 start: &quick_xml::events::BytesStart<'_>,
917 local: &[u8],
918 prefixes: &[String],
919) -> crate::Result<String> {
920 optional_word_attribute(start, local, prefixes)?.ok_or_else(|| {
921 crate::OxmlError::MissingElement(format!("w:{} attribute", String::from_utf8_lossy(local)))
922 })
923}
924
925fn optional_word_attribute(
926 start: &quick_xml::events::BytesStart<'_>,
927 local: &[u8],
928 prefixes: &[String],
929) -> crate::Result<Option<String>> {
930 for attribute in start.attributes() {
931 let attribute = attribute?;
932 let key = attribute.key.as_ref();
933 let Some(separator) = key.iter().position(|byte| *byte == b':') else {
934 continue;
935 };
936 if key.get(separator + 1..) == Some(local)
937 && prefixes
938 .iter()
939 .any(|prefix| prefix.as_bytes() == &key[..separator])
940 {
941 return Ok(Some(
942 attribute
943 .decoded_and_normalized_value(XmlVersion::Implicit1_0, start.decoder())?
944 .into_owned(),
945 ));
946 }
947 }
948 Ok(None)
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use crate::namespace::W_NS;
955 use crate::text::RunContent;
956
957 fn nested_insertions(depth: usize) -> Vec<u8> {
958 let mut xml = format!(r#"<w:ins xmlns:w="{W_NS}" w:id="0" w:author="Ada">"#);
959 for id in 1..depth {
960 xml.push_str(&format!(r#"<w:ins w:id="{id}" w:author="Ada">"#));
961 }
962 xml.push_str("<w:r><w:t>visible</w:t></w:r>");
963 for _ in 0..depth {
964 xml.push_str("</w:ins>");
965 }
966 xml.into_bytes()
967 }
968
969 #[test]
970 fn revision_nesting_depth_is_bounded_before_recursive_projection() {
971 let prefixes = ["w".to_owned()];
972 assert!(
973 validate_revision_nesting_depth(
974 &nested_insertions(MAX_REVISION_NESTING_DEPTH),
975 &prefixes,
976 )
977 .is_ok()
978 );
979 let too_deep = nested_insertions(MAX_REVISION_NESTING_DEPTH + 1);
980 assert!(validate_revision_nesting_depth(&too_deep, &prefixes).is_err());
981 assert!(CT_Revision::from_raw(too_deep, &prefixes).is_none());
982 }
983
984 #[test]
985 fn revision_attributes_are_prefix_tolerant_and_namespace_checked() {
986 let raw = format!(
987 r#"<word:ins xmlns:word="{W_NS}" word:id="7" word:author="Ada"><word:r><word:t>added</word:t></word:r></word:ins>"#
988 )
989 .into_bytes();
990 let revision = CT_Revision::from_raw(raw, &["word".to_owned()])
991 .expect("aliased WordprocessingML revision should be projected");
992 assert_eq!(revision.id(), 7);
993 assert_eq!(revision.author(), "Ada");
994 assert_eq!(revision.timestamp(), None);
995 assert_eq!(revision.kind(), RevisionKind::Insertion);
996
997 let escaped = format!(
998 r#"<word:ins xmlns:word="{W_NS}" word:id="8" word:author="A & B" word:date="2026-08-17T09:00:00+01:00 & later"/>"#
999 )
1000 .into_bytes();
1001 let escaped = CT_Revision::from_raw(escaped, &["word".to_owned()])
1002 .expect("escaped revision metadata should parse");
1003 assert_eq!(escaped.author(), "A & B");
1004 assert_eq!(
1005 escaped.timestamp(),
1006 Some("2026-08-17T09:00:00+01:00 & later")
1007 );
1008
1009 let foreign = br#"<x:ins xmlns:x="urn:not-word" x:id="9" x:author="Eve"/>"#.to_vec();
1010 assert!(CT_Revision::from_raw(foreign, &["word".to_owned()]).is_none());
1011
1012 let missing_author = format!(r#"<word:del xmlns:word="{W_NS}" word:id="9"/>"#).into_bytes();
1013 assert!(CT_Revision::from_raw(missing_author, &["word".to_owned()]).is_none());
1014
1015 let document_xml = format!(
1016 r#"<word:document xmlns:word="{W_NS}" xmlns:x="urn:not-word"><word:body><word:p><word:pPr><word:pPrChange word:id="10" word:author="Gia"><word:pPr><word:jc word:val="right"/></word:pPr></word:pPrChange><x:pPrChange x:id="11" x:author="No"/><word:pPrChange word:id="12"/></word:pPr></word:p></word:body></word:document>"#
1017 );
1018 let document = CT_Document::from_xml(document_xml.as_bytes()).expect("document parses");
1019 assert_eq!(document.revisions().len(), 1);
1020 assert_eq!(document.revisions()[0].id(), 10);
1021 let output = String::from_utf8(document.to_xml().expect("document writes")).unwrap();
1022 assert!(output.contains(r#"<word:pPrChange word:id="10" word:author="Gia">"#));
1023 assert!(output.contains(r#"<x:pPrChange x:id="11" x:author="No"/>"#));
1024 assert!(output.contains(r#"<word:pPrChange word:id="12"/>"#));
1025
1026 let collision_xml = format!(
1027 r#"<word:document xmlns:word="{W_NS}" xmlns:x="urn:not-word"><word:body>
1028<word:tbl><word:tblPr><word:tblPrChange word:id="20" word:author="Tab"><word:tblPr><x:jc x:val="center"/><word:jc x:val="right"/><word:tblBorders><x:top x:val="single" word:sz="invalid"/><word:bottom x:val="single"/></word:tblBorders><word:tblCellMar><x:top x:w="120"/><word:bottom x:w="240"/></word:tblCellMar></word:tblPr></word:tblPrChange></word:tblPr><word:tblGrid/><word:tr><word:tc><word:p/></word:tc></word:tr></word:tbl>
1029<word:sectPr><word:sectPrChange word:id="21" word:author="Sec"><word:sectPr><x:titlePg/><word:pgSz x:w="12240" x:h="15840"/><word:pgMar x:producer="not-a-number" word:top="720"/></word:sectPr></word:sectPrChange></word:sectPr>
1030</word:body></word:document>"#
1031 );
1032 let collision = CT_Document::from_xml(collision_xml.as_bytes()).expect("document parses");
1033 let collision_revisions = collision.revisions();
1034 assert!(matches!(
1035 collision_revisions[0].content(),
1036 RevisionContent::PriorTableProperties(properties)
1037 if properties.jc.is_none()
1038 && properties
1039 .borders
1040 .as_ref()
1041 .is_some_and(|borders| borders.top.is_none()
1042 && borders.bottom.as_ref().is_some_and(|edge| {
1043 edge.val == crate::shared::ST_Border::None
1044 }))
1045 && properties
1046 .cell_margin
1047 .as_ref()
1048 .is_some_and(|margin| margin.top.is_none() && margin.bottom.is_none())
1049 ));
1050 assert!(matches!(
1051 collision_revisions[1].content(),
1052 RevisionContent::PriorSectionProperties(properties)
1053 if properties.title_pg.is_none()
1054 && properties.page_width.is_none()
1055 && properties.page_height.is_none()
1056 && properties.margin_top == Some(crate::units::Twips(720))
1057 ));
1058
1059 let paragraph_collision_xml = format!(
1060 r#"<word:document xmlns:word="{W_NS}" xmlns:x="urn:not-word"><word:body><word:p><word:pPr><word:pPrChange word:id="22" word:author="Para"><word:pPr><word:pBdr><x:top x:val="single" word:sz="invalid"/><word:bottom x:val="single"/></word:pBdr></word:pPr></word:pPrChange></word:pPr></word:p></word:body></word:document>"#
1061 );
1062 let paragraph_collision =
1063 CT_Document::from_xml(paragraph_collision_xml.as_bytes()).expect("document parses");
1064 assert!(matches!(
1065 paragraph_collision.revisions()[0].content(),
1066 RevisionContent::PriorParagraphProperties(properties)
1067 if properties.borders.as_ref().is_some_and(|borders| {
1068 borders.top.is_none()
1069 && borders.bottom.as_ref().is_some_and(|edge| {
1070 edge.val == crate::shared::ST_Border::None
1071 })
1072 })
1073 ));
1074
1075 let control_xml = format!(
1076 r#"<word:document xmlns:word="{W_NS}"><word:body><word:p><word:sdt><word:sdtContent><word:ins word:id="30" word:author="Sdt"><word:r><word:t>inside</word:t></word:r></word:ins></word:sdtContent></word:sdt></word:p></word:body></word:document>"#
1077 );
1078 let control = CT_Document::from_xml(control_xml.as_bytes()).expect("document parses");
1079 assert_eq!(
1080 control
1081 .revisions()
1082 .iter()
1083 .map(|revision| revision.id())
1084 .collect::<Vec<_>>(),
1085 vec![30]
1086 );
1087 }
1088
1089 #[test]
1090 fn insertion_content_retains_inline_paragraph_structure() {
1091 let raw = format!(
1092 r#"<w:ins xmlns:w="{W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" w:id="7" w:author="Ada"><w:r><w:t>before</w:t></w:r><w:hyperlink r:id="rId9"><w:r><w:t>linked</w:t></w:r></w:hyperlink></w:ins>"#
1093 )
1094 .into_bytes();
1095 let revision = CT_Revision::from_raw(raw, &["w".to_owned()]).expect("insertion parses");
1096 let paragraph = revision
1097 .content_paragraph()
1098 .expect("insertion exposes paragraph content");
1099
1100 assert_eq!(paragraph.text(), "beforelinked");
1101 assert_eq!(paragraph.hyperlinks.len(), 1);
1102 assert_eq!(paragraph.hyperlinks[0].rel_id.as_deref(), Some("rId9"));
1103 }
1104
1105 #[test]
1106 fn accepted_content_revisions_retain_inline_content_controls() {
1107 for kind in ["ins", "moveTo"] {
1108 let raw = format!(
1109 r#"<w:{kind} xmlns:w="{W_NS}" w:id="7" w:author="Ada"><w:r><w:t>before</w:t></w:r><w:sdt><w:sdtContent><w:r><w:t>control</w:t></w:r></w:sdtContent></w:sdt></w:{kind}>"#
1110 )
1111 .into_bytes();
1112 let revision = CT_Revision::from_raw(raw, &["w".to_owned()]).expect("revision parses");
1113 let paragraph = revision
1114 .content_paragraph()
1115 .expect("accepted revision exposes paragraph content");
1116
1117 assert_eq!(paragraph.text(), "beforecontrol");
1118 assert_eq!(paragraph.content_controls.len(), 1);
1119 let crate::content_control::SdtContent::Run(run) =
1120 ¶graph.content_controls[0].3.content[0]
1121 else {
1122 panic!("expected controlled run");
1123 };
1124 assert_eq!(run.text(), "control");
1125 }
1126 }
1127
1128 #[test]
1129 fn accepted_revision_mutation_rejects_a_shadowed_reserved_word_prefix() {
1130 let raw = format!(
1131 r#"<wx:ins xmlns:wx="{W_NS}" xmlns:w="urn:foreign" wx:id="7" wx:author="Ada"><wx:r><wx:t>before</wx:t></wx:r></wx:ins>"#
1132 )
1133 .into_bytes();
1134 let mut revision =
1135 CT_Revision::from_raw(raw, &["wx".to_owned()]).expect("insertion parses");
1136 let error = revision
1137 .replace_accepted_run_segments(&[AcceptedRunPathSegment::Run(0)], CT_R::new("after"))
1138 .expect_err("shadowed reserved prefix must fail closed");
1139
1140 assert_eq!(
1141 error.to_string(),
1142 "invalid value: revision binds the reserved w prefix to a foreign namespace"
1143 );
1144 assert_eq!(
1145 revision
1146 .content_paragraph()
1147 .expect("accepted content remains available")
1148 .text(),
1149 "before"
1150 );
1151 }
1152
1153 #[test]
1154 fn accepted_revision_refresh_changes_one_field_of_a_shared_run() {
1155 let raw = format!(
1156 concat!(
1157 r#"<w:ins xmlns:w="{}" w:id="7" w:author="Ada"><w:r>"#,
1158 r#"<w:fldChar w:fldCharType="begin"/><w:instrText>DATE</w:instrText><w:fldChar w:fldCharType="separate"/><w:t>first</w:t><w:fldChar w:fldCharType="end"/>"#,
1159 r#"<w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:t>second</w:t><w:fldChar w:fldCharType="end"/>"#,
1160 r#"</w:r></w:ins>"#,
1161 ),
1162 W_NS
1163 )
1164 .into_bytes();
1165 let mut revision = CT_Revision::from_raw(raw, &["w".to_owned()]).expect("insertion parses");
1166 let paragraph = revision
1167 .content_paragraph()
1168 .expect("accepted content is projected");
1169 let page_index = paragraph
1170 .runs
1171 .iter()
1172 .position(|run| {
1173 run.content.iter().any(|content| {
1174 matches!(content, crate::text::RunContent::Field(field) if field.instruction.name == "PAGE")
1175 })
1176 })
1177 .expect("PAGE field is projected");
1178 let mut replacement = paragraph.runs[page_index].clone();
1179 let field = replacement
1180 .content
1181 .iter_mut()
1182 .find_map(|content| match content {
1183 crate::text::RunContent::Field(field) => Some(field),
1184 _ => None,
1185 })
1186 .expect("replacement contains a field");
1187 field.cached_result = "changed".to_owned();
1188
1189 assert!(
1190 revision
1191 .replace_accepted_run_segments(
1192 &[AcceptedRunPathSegment::Run(page_index)],
1193 replacement
1194 )
1195 .expect("one field of a shared physical run can change")
1196 );
1197
1198 assert_eq!(
1199 revision
1200 .content_paragraph()
1201 .expect("accepted content remains")
1202 .text(),
1203 "firstchanged"
1204 );
1205 let raw = std::str::from_utf8(&revision.raw_xml).unwrap();
1206 assert!(raw.contains("<w:instrText>DATE</w:instrText>"), "{raw}");
1207 assert!(raw.contains("<w:t>first</w:t>"), "{raw}");
1208 assert!(!raw.contains("second"), "{raw}");
1209 }
1210
1211 #[test]
1212 fn insertion_content_retains_nested_revision_projection() {
1213 let raw = format!(
1214 r#"<w:ins xmlns:w="{W_NS}" w:id="7" w:author="Ada"><w:r><w:t>before</w:t></w:r><w:del w:id="8" w:author="Ben"><w:r><w:delText>removed</w:delText></w:r></w:del><w:ins w:id="9" w:author="Cy"><w:r><w:t>nested</w:t></w:r></w:ins><w:r><w:t>after</w:t></w:r></w:ins>"#
1215 )
1216 .into_bytes();
1217 let revision = CT_Revision::from_raw(raw, &["w".to_owned()]).expect("insertion parses");
1218
1219 let RevisionContent::Runs(runs) = revision.content() else {
1220 panic!("insertion exposes direct runs");
1221 };
1222 assert_eq!(
1223 runs.iter().map(CT_R::text).collect::<String>(),
1224 "beforeafter"
1225 );
1226 assert_eq!(
1227 revision
1228 .nested_revisions()
1229 .iter()
1230 .map(|(boundary, nested)| (*boundary, nested.kind()))
1231 .collect::<Vec<_>>(),
1232 vec![(1, RevisionKind::Deletion), (1, RevisionKind::Insertion),]
1233 );
1234 let paragraph = revision
1235 .content_paragraph()
1236 .expect("insertion exposes paragraph content");
1237 assert_eq!(paragraph.text(), "beforeafter");
1238 assert_eq!(
1239 paragraph
1240 .revisions
1241 .iter()
1242 .map(|(_, _, nested)| nested.kind())
1243 .collect::<Vec<_>>(),
1244 vec![RevisionKind::Deletion, RevisionKind::Insertion]
1245 );
1246 }
1247
1248 #[test]
1249 fn property_changes_write_in_their_schema_final_slots() {
1250 let xml = format!(
1251 r#"<w:document xmlns:w="{W_NS}"><w:body>
1252<w:p><w:pPr><w:jc w:val="center"/><w:pPrChange w:id="1" w:author="Ada"><w:pPr><w:jc w:val="left"/></w:pPr></w:pPrChange></w:pPr>
1253<w:r><w:rPr><w:b/><w:rPrChange w:id="2" w:author="Ben"><w:rPr><w:i/></w:rPr></w:rPrChange></w:rPr><w:t>x</w:t></w:r></w:p>
1254<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblPrChange w:id="3" w:author="Cy"><w:tblPr><w:jc w:val="right"/></w:tblPr></w:tblPrChange></w:tblPr><w:tblGrid/><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl>
1255<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:sectPrChange w:id="4" w:author="Dee"><w:sectPr><w:titlePg/></w:sectPr></w:sectPrChange></w:sectPr>
1256</w:body></w:document>"#
1257 );
1258 let document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1259 let output =
1260 String::from_utf8(document.to_xml().expect("document writes")).expect("UTF-8 output");
1261
1262 for change in ["pPrChange", "rPrChange", "tblPrChange", "sectPrChange"] {
1263 assert_eq!(
1264 output.matches(&format!("<w:{change} ")).count(),
1265 1,
1266 "{output}"
1267 );
1268 }
1269 assert!(
1270 output.find("<w:jc w:val=\"center\"").unwrap() < output.find("<w:pPrChange ").unwrap()
1271 );
1272 assert!(output.find("<w:b").unwrap() < output.find("<w:rPrChange ").unwrap());
1273 assert!(output.find("<w:tblW").unwrap() < output.find("<w:tblPrChange ").unwrap());
1274 assert!(output.find("<w:pgSz").unwrap() < output.find("<w:sectPrChange ").unwrap());
1275 }
1276
1277 #[test]
1278 fn run_property_raw_positions_cannot_follow_the_schema_final_change() {
1279 let xml = format!(
1280 r#"<w:document xmlns:w="{W_NS}" xmlns:x="urn:producer"><w:body><w:p><w:r><w:rPr><x:raw/><w:rPrChange w:id="1" w:author="Ada"><w:rPr><w:b/></w:rPr></w:rPrChange></w:rPr><w:t>x</w:t></w:r></w:p></w:body></w:document>"#
1281 );
1282 let mut document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1283 {
1284 let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
1285 panic!("expected paragraph");
1286 };
1287 paragraph.runs[0]
1288 .properties
1289 .as_mut()
1290 .expect("run properties")
1291 .revision_xml_positions[0] = (41, 9);
1292 }
1293
1294 let output = String::from_utf8(document.to_xml().expect("document writes")).unwrap();
1295 assert!(output.find("<x:raw").unwrap() < output.find("<w:rPrChange").unwrap());
1296
1297 {
1298 let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
1299 panic!("expected paragraph");
1300 };
1301 paragraph.runs[0]
1302 .properties
1303 .as_mut()
1304 .expect("run properties")
1305 .revision_xml_positions
1306 .clear();
1307 }
1308 let output = String::from_utf8(document.to_xml().expect("fallback writes")).unwrap();
1309 assert!(output.find("<x:raw").unwrap() < output.find("<w:rPrChange").unwrap());
1310
1311 let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
1312 panic!("expected paragraph");
1313 };
1314 paragraph.runs[0]
1315 .properties
1316 .as_mut()
1317 .expect("run properties")
1318 .revision_xml_positions
1319 .extend([(41, 0), (41, 1)]);
1320 let output = String::from_utf8(document.to_xml().expect("mismatch writes")).unwrap();
1321 assert!(output.find("<x:raw").unwrap() < output.find("<w:rPrChange").unwrap());
1322 }
1323
1324 #[test]
1325 fn retained_run_property_children_keep_owner_local_namespace_bindings() {
1326 let xml = format!(
1327 r#"<w:document xmlns:w="{W_NS}"><w:body><w:p><w:r><w:rPr xmlns:x="urn:producer" xmlns:wa="{W_NS}"><x:raw/><wa:outline><wa:opaque/></wa:outline><wa:rPrChange wa:id="7" wa:author="Ada"><wa:rPr><wa:b/></wa:rPr></wa:rPrChange></w:rPr><w:t>x</w:t></w:r></w:p></w:body></w:document>"#
1328 );
1329 let document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1330 assert_eq!(document.revisions()[0].id(), 7);
1331
1332 let output = String::from_utf8(document.to_xml().expect("document writes")).unwrap();
1333 assert_eq!(
1334 output.matches(r#"<x:raw xmlns:x="urn:producer"/>"#).count(),
1335 1
1336 );
1337 assert_eq!(
1338 output
1339 .matches(&format!(r#"<wa:outline xmlns:wa="{W_NS}">"#))
1340 .count(),
1341 1
1342 );
1343 assert_eq!(
1344 output
1345 .matches(&format!(
1346 r#"<wa:rPrChange wa:id="7" wa:author="Ada" xmlns:wa="{W_NS}">"#
1347 ))
1348 .count(),
1349 1
1350 );
1351
1352 let reopened = CT_Document::from_xml(output.as_bytes()).expect("output reparses");
1353 assert_eq!(reopened.revisions()[0].id(), 7);
1354 let rewritten = String::from_utf8(reopened.to_xml().expect("output rewrites")).unwrap();
1355 assert_eq!(rewritten.matches(r#"xmlns:x="urn:producer""#).count(), 1);
1356 assert_eq!(
1357 rewritten.matches(&format!(r#"xmlns:wa="{W_NS}""#)).count(),
1358 2
1359 );
1360 }
1361
1362 #[test]
1363 fn duplicate_property_changes_are_retained_without_replacing_the_first_projection() {
1364 let xml = format!(
1365 r#"<w:document xmlns:w="{W_NS}" xmlns:x="urn:producer"><w:body>
1366<w:p><w:pPr><w:numPr><w:ins w:id="501" w:author="Ada"/><x:ins x:mark="num"/><w:ins w:id="502" w:author="Ada"/></w:numPr><w:pPrChange w:id="101" w:author="Ada"><w:pPr><w:jc w:val="left"/></w:pPr></w:pPrChange><x:pPrChange x:mark="paragraph"/><w:pPrChange w:id="102" w:author="Ada"><w:pPr><w:jc w:val="right"/></w:pPr></w:pPrChange></w:pPr><w:r><w:rPr><w:rPrChange w:id="201" w:author="Ada"><w:rPr><w:b/></w:rPr></w:rPrChange><x:rPrChange x:mark="run"/><w:rPrChange w:id="202" w:author="Ada"><w:rPr><w:i/></w:rPr></w:rPrChange></w:rPr><w:t>x</w:t></w:r></w:p>
1367<w:tbl><w:tblPr><w:tblPrChange w:id="301" w:author="Ada"><w:tblPr><w:jc w:val="left"/></w:tblPr></w:tblPrChange><x:tblPrChange x:mark="table"/><w:tblPrChange w:id="302" w:author="Ada"><w:tblPr><w:jc w:val="right"/></w:tblPr></w:tblPrChange></w:tblPr><w:tblGrid/><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl>
1368<w:sectPr><w:sectPrChange w:id="401" w:author="Ada"><w:sectPr><w:titlePg/></w:sectPr></w:sectPrChange><x:sectPrChange x:mark="section"/><w:sectPrChange w:id="402" w:author="Ada"><w:sectPr><w:pgSz w:w="12240"/></w:sectPr></w:sectPrChange></w:sectPr>
1369</w:body></w:document>"#
1370 );
1371 let document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1372 let mut ids = document
1373 .revisions()
1374 .iter()
1375 .map(|revision| revision.id())
1376 .collect::<Vec<_>>();
1377 ids.sort_unstable();
1378 assert_eq!(ids, [102, 202, 302, 402, 502]);
1379
1380 let output = String::from_utf8(document.to_xml().expect("document writes")).unwrap();
1381 for id in [101, 102, 201, 202, 301, 302, 401, 402, 501, 502] {
1382 assert_eq!(output.matches(&format!(r#"w:id="{id}""#)).count(), 1);
1383 }
1384 for owner in ["pPr", "rPr", "tblPr", "sectPr"] {
1385 let first = output
1386 .find(&format!(
1387 r#"w:{owner}Change w:id="{}""#,
1388 match owner {
1389 "pPr" => 101,
1390 "rPr" => 201,
1391 "tblPr" => 301,
1392 _ => 401,
1393 }
1394 ))
1395 .unwrap();
1396 let typed = output
1397 .find(&format!(
1398 r#"w:{owner}Change w:id="{}""#,
1399 match owner {
1400 "pPr" => 102,
1401 "rPr" => 202,
1402 "tblPr" => 302,
1403 _ => 402,
1404 }
1405 ))
1406 .unwrap();
1407 assert!(
1408 first < typed,
1409 "duplicate must precede typed schema-final change"
1410 );
1411 let foreign = output
1412 .find(&format!(r#"<x:{owner}Change x:mark="#))
1413 .unwrap();
1414 assert!(first < foreign && foreign < typed);
1415 }
1416 let numbering_positions = [
1417 output.find(r#"w:id="501""#).unwrap(),
1418 output.find("<x:ins ").unwrap(),
1419 output.find(r#"w:id="502""#).unwrap(),
1420 ];
1421 assert!(output.contains(r#"x:mark="num""#));
1422 assert!(numbering_positions.windows(2).all(|pair| pair[0] < pair[1]));
1423
1424 let reopened = CT_Document::from_xml(output.as_bytes()).expect("output reparses");
1425 let mut reopened_ids = reopened
1426 .revisions()
1427 .iter()
1428 .map(|revision| revision.id())
1429 .collect::<Vec<_>>();
1430 reopened_ids.sort_unstable();
1431 assert_eq!(reopened_ids, [102, 202, 302, 402, 502]);
1432 }
1433
1434 #[test]
1435 fn every_property_revision_keeps_owner_local_aliases() {
1436 let xml = format!(
1437 r#"<w:document xmlns:w="{W_NS}"><w:body>
1438<w:p><w:pPr xmlns:pa="{W_NS}" xmlns:px="urn:paragraph"><w:numPr xmlns:na="{W_NS}" xmlns:nx="urn:numbering"><na:ins na:id="501" na:author="Ada"/><nx:ins nx:mark="raw"/><na:ins na:id="502" na:author="Ada"/></w:numPr><pa:pPrChange pa:id="101" pa:author="Ada"><pa:pPr/></pa:pPrChange><px:pPrChange px:mark="raw"/><pa:pPrChange pa:id="102" pa:author="Ada"><pa:pPr/></pa:pPrChange></w:pPr><w:r><w:t>x</w:t></w:r></w:p>
1439<w:tbl><w:tblPr xmlns:ta="{W_NS}" xmlns:tx="urn:table"><ta:tblPrChange ta:id="301" ta:author="Ada"><ta:tblPr/></ta:tblPrChange><tx:tblPrChange tx:mark="raw"/><ta:tblPrChange ta:id="302" ta:author="Ada"><ta:tblPr/></ta:tblPrChange></w:tblPr><w:tblGrid/><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl>
1440<w:sectPr xmlns:sa="{W_NS}" xmlns:sx="urn:section"><sa:sectPrChange sa:id="401" sa:author="Ada"><sa:sectPr/></sa:sectPrChange><sx:sectPrChange sx:mark="raw"/><sa:sectPrChange sa:id="402" sa:author="Ada"><sa:sectPr/></sa:sectPrChange></w:sectPr>
1441</w:body></w:document>"#
1442 );
1443 let document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1444 let output = String::from_utf8(document.to_xml().expect("document writes")).unwrap();
1445 for (prefix, local) in [
1446 ("pa", "pPrChange"),
1447 ("na", "ins"),
1448 ("ta", "tblPrChange"),
1449 ("sa", "sectPrChange"),
1450 ] {
1451 assert_eq!(output.matches(&format!("<{prefix}:{local} ")).count(), 2);
1452 assert_eq!(
1453 output
1454 .matches(&format!(r#"xmlns:{prefix}="{W_NS}""#))
1455 .count(),
1456 if prefix == "na" { 1 } else { 2 }
1457 );
1458 }
1459 for (prefix, namespace, local) in [
1460 ("px", "urn:paragraph", "pPrChange"),
1461 ("nx", "urn:numbering", "ins"),
1462 ("tx", "urn:table", "tblPrChange"),
1463 ("sx", "urn:section", "sectPrChange"),
1464 ] {
1465 assert_eq!(output.matches(&format!("<{prefix}:{local} ")).count(), 1);
1466 assert_eq!(
1467 output
1468 .matches(&format!(r#"xmlns:{prefix}="{namespace}""#))
1469 .count(),
1470 1
1471 );
1472 }
1473 for (first, foreign, second) in [
1474 (101, "<px:pPrChange", 102),
1475 (501, "<nx:ins", 502),
1476 (301, "<tx:tblPrChange", 302),
1477 (401, "<sx:sectPrChange", 402),
1478 ] {
1479 let positions = [
1480 output.find(&format!(r#"id="{first}""#)).unwrap(),
1481 output.find(foreign).unwrap(),
1482 output.find(&format!(r#"id="{second}""#)).unwrap(),
1483 ];
1484 assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
1485 }
1486
1487 let reopened = CT_Document::from_xml(output.as_bytes()).expect("output reparses");
1488 let mut ids = reopened
1489 .revisions()
1490 .iter()
1491 .map(|revision| revision.id())
1492 .collect::<Vec<_>>();
1493 ids.sort_unstable();
1494 assert_eq!(ids, [102, 302, 402, 502]);
1495 let rewritten = String::from_utf8(reopened.to_xml().expect("output rewrites")).unwrap();
1496 for prefix in ["pa", "na", "ta", "sa"] {
1497 assert_eq!(
1498 rewritten
1499 .matches(&format!(r#"xmlns:{prefix}="{W_NS}""#))
1500 .count(),
1501 if prefix == "na" { 1 } else { 2 }
1502 );
1503 }
1504 for (prefix, namespace) in [
1505 ("px", "urn:paragraph"),
1506 ("nx", "urn:numbering"),
1507 ("tx", "urn:table"),
1508 ("sx", "urn:section"),
1509 ] {
1510 assert_eq!(
1511 rewritten
1512 .matches(&format!(r#"xmlns:{prefix}="{namespace}""#))
1513 .count(),
1514 1
1515 );
1516 }
1517 }
1518
1519 #[test]
1520 fn a_section_root_retains_no_namespace_declaration_its_attributes_do_not_use() {
1521 let xml = format!(
1525 r#"<w:document xmlns:w="{W_NS}"><w:body><w:p/>
1526<w:sectPr xmlns:sa="{W_NS}" xmlns:sx="urn:section"><sa:sectPrChange sa:id="401" sa:author="Ada"><sa:sectPr/></sa:sectPrChange><sx:sectPrChange sx:mark="raw"/></w:sectPr>
1527</w:body></w:document>"#
1528 );
1529 let document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1530 let output = String::from_utf8(document.to_xml().expect("document writes")).unwrap();
1531
1532 assert_eq!(
1534 output.matches(&format!(r#"xmlns:sa="{W_NS}""#)).count(),
1535 1,
1536 "{output}"
1537 );
1538 assert_eq!(
1539 output.matches(r#"xmlns:sx="urn:section""#).count(),
1540 1,
1541 "{output}"
1542 );
1543 assert!(
1544 !output.contains(&format!(r#"<w:sectPr xmlns:sa="{W_NS}""#)),
1545 "the section root kept a declaration none of its attributes use: {output}"
1546 );
1547 assert!(output.contains(r#"sa:id="401""#), "{output}");
1548 assert!(output.contains(r#"sx:mark="raw""#), "{output}");
1549 }
1550
1551 #[test]
1552 fn revision_elements_round_trip_unchanged_and_report_metadata() {
1553 let revisions = [
1554 r#"<w:ins w:id="1" w:author="Ada" w:date="2026-08-01T10:00:00Z"><w:r><w:t>added</w:t></w:r></w:ins>"#,
1555 r#"<w:del w:id="2" w:author="Ben"><w:r><w:delText xml:space="preserve"> gone </w:delText></w:r></w:del>"#,
1556 r#"<w:moveFrom w:id="3" w:author="Cy"><w:r><w:t>from</w:t></w:r></w:moveFrom>"#,
1557 r#"<w:moveTo w:id="4" w:author="Dee"><w:r><w:t>to</w:t></w:r></w:moveTo>"#,
1558 ];
1559 let xml = format!(
1560 r#"<w:document xmlns:w="{W_NS}"><w:body><w:p>{}<w:r><w:rPr><w:ins w:id="5" w:author="Eve"/><w:rPrChange w:id="6" w:author="Fox"><w:rPr><w:i/></w:rPr></w:rPrChange></w:rPr><w:t>end</w:t></w:r><x:ins xmlns:x="urn:not-word" x:id="99" x:author="No"/></w:p><w:tbl><w:tblPr><w:tblPrChange w:id="7" w:author="Gia"><w:tblPr><w:jc w:val="center"/></w:tblPr></w:tblPrChange></w:tblPr><w:tblGrid/><w:tr><w:trPr><w:del w:id="8" w:author="Hal"/></w:trPr><w:tc><w:p><w:pPr><w:pPrChange w:id="9" w:author="Ivy"><w:pPr><w:jc w:val="right"/></w:pPr></w:pPrChange></w:pPr></w:p></w:tc></w:tr></w:tbl><w:sectPr><w:sectPrChange w:id="10" w:author="Jay"><w:sectPr><w:titlePg/></w:sectPr></w:sectPrChange></w:sectPr></w:body></w:document>"#,
1561 revisions.concat()
1562 );
1563 let document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1564 let reported = document.revisions();
1565 assert_eq!(
1566 reported
1567 .iter()
1568 .map(|revision| revision.id())
1569 .collect::<Vec<_>>(),
1570 (1..=10).collect::<Vec<_>>()
1571 );
1572 assert_eq!(reported[0].author(), "Ada");
1573 assert_eq!(reported[0].timestamp(), Some("2026-08-01T10:00:00Z"));
1574 assert_eq!(reported[1].timestamp(), None);
1575 assert_eq!(reported[1].kind(), RevisionKind::Deletion);
1576 let RevisionContent::Runs(runs) = reported[1].content() else {
1577 panic!("deletion should project runs");
1578 };
1579 assert!(matches!(
1580 runs[0].content.as_slice(),
1581 [RunContent::DeletedText(text)] if text.text == " gone " && text.preserve_space
1582 ));
1583 assert!(matches!(
1584 reported[5].content(),
1585 RevisionContent::PriorRunProperties(properties) if properties.italic == Some(true)
1586 ));
1587 assert!(matches!(
1588 reported[6].content(),
1589 RevisionContent::PriorTableProperties(properties) if properties.jc.is_some()
1590 ));
1591 assert!(matches!(
1592 reported[8].content(),
1593 RevisionContent::PriorParagraphProperties(properties) if properties.jc.is_some()
1594 ));
1595 assert!(matches!(
1596 reported[9].content(),
1597 RevisionContent::PriorSectionProperties(properties) if properties.title_pg == Some(true)
1598 ));
1599
1600 let output =
1601 String::from_utf8(document.to_xml().expect("document writes")).expect("UTF-8 output");
1602 for raw in revisions {
1603 assert!(
1604 output.contains(raw),
1605 "missing exact subtree {raw} in {output}"
1606 );
1607 }
1608 for raw in [
1609 r#"<w:rPrChange w:id="6" w:author="Fox"><w:rPr><w:i/></w:rPr></w:rPrChange>"#,
1610 r#"<w:tblPrChange w:id="7" w:author="Gia"><w:tblPr><w:jc w:val="center"/></w:tblPr></w:tblPrChange>"#,
1611 r#"<w:pPrChange w:id="9" w:author="Ivy"><w:pPr><w:jc w:val="right"/></w:pPr></w:pPrChange>"#,
1612 r#"<w:sectPrChange w:id="10" w:author="Jay"><w:sectPr><w:titlePg/></w:sectPr></w:sectPrChange>"#,
1613 ] {
1614 assert!(
1615 output.contains(raw),
1616 "missing exact subtree {raw} in {output}"
1617 );
1618 }
1619 assert!(output.contains(r#"<x:ins xmlns:x="urn:not-word" x:id="99" x:author="No"/>"#));
1620 let reopened = CT_Document::from_xml(output.as_bytes()).expect("output reparses");
1621 assert_eq!(reopened.revisions().len(), 10);
1622 }
1623
1624 #[test]
1625 fn hyperlink_and_nested_content_revisions_round_trip_and_report_in_order() {
1626 let nested = r#"<w:ins w:id="11" w:author="Ada"><w:del w:id="12" w:author="Ben"><w:r><w:delText>nested</w:delText></w:r></w:del></w:ins>"#;
1627 let hyperlink_before = r#"<w:hyperlink><w:ins w:id="21" w:author="Cy"><w:r><w:t>before</w:t></w:r></w:ins></w:hyperlink>"#;
1628 let direct_after =
1629 r#"<w:del w:id="22" w:author="Dee"><w:r><w:delText>after</w:delText></w:r></w:del>"#;
1630 let direct_before =
1631 r#"<w:ins w:id="23" w:author="Eve"><w:r><w:t>before</w:t></w:r></w:ins>"#;
1632 let hyperlink_after = r#"<w:hyperlink><w:del w:id="24" w:author="Fox"><w:r><w:delText>after</w:delText></w:r></w:del></w:hyperlink>"#;
1633 let xml = format!(
1634 r#"<w:document xmlns:w="{W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:hyperlink r:id="rId5"><w:r><w:t xml:space="preserve">before </w:t></w:r>{nested}<w:r><w:t xml:space="preserve"> after</w:t></w:r></w:hyperlink></w:p><w:p>{hyperlink_before}{direct_after}{direct_before}{hyperlink_after}</w:p></w:body></w:document>"#
1635 );
1636 let document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1637
1638 assert_eq!(
1639 document
1640 .revisions()
1641 .iter()
1642 .map(|revision| revision.id())
1643 .collect::<Vec<_>>(),
1644 [11, 12, 21, 22, 23, 24]
1645 );
1646 let output =
1647 String::from_utf8(document.to_xml().expect("document writes")).expect("UTF-8 output");
1648 assert!(output.contains(nested), "missing exact subtree in {output}");
1649 for raw in [
1650 hyperlink_before,
1651 direct_after,
1652 direct_before,
1653 hyperlink_after,
1654 ] {
1655 assert!(
1656 output.contains(raw),
1657 "missing exact subtree {raw} in {output}"
1658 );
1659 }
1660 let positions = [21, 22, 23, 24].map(|id| {
1661 output
1662 .find(&format!(r#"w:id="{id}""#))
1663 .expect("revision id remains present")
1664 });
1665 assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
1666
1667 let reopened = CT_Document::from_xml(output.as_bytes()).expect("output reparses");
1668 assert_eq!(
1669 reopened
1670 .revisions()
1671 .iter()
1672 .map(|revision| revision.id())
1673 .collect::<Vec<_>>(),
1674 [11, 12, 21, 22, 23, 24]
1675 );
1676 }
1677
1678 #[test]
1679 fn modeled_hyperlinks_preserve_unreported_raw_children_and_foreign_owners() {
1680 let malformed = r#"<w:ins w:id="bad"><w:r><w:t>raw revision</w:t></w:r></w:ins>"#;
1681 let foreign_child = r#"<x:opaque x:flag="1"/>"#;
1682 let foreign_hyperlink = r#"<x:hyperlink xmlns:x="urn:not-word"><w:r><w:t>foreign owner</w:t></w:r></x:hyperlink>"#;
1683 let xml = format!(
1684 r#"<w:document xmlns:w="{W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:hyperlink r:id="rId5" xmlns:x="urn:opaque"><w:r><w:t>before</w:t></w:r>{malformed}<w:ins w:id="31" w:author="Ada"><w:r><w:t>reported</w:t></w:r></w:ins>{foreign_child}<w:r><w:t>after</w:t></w:r></w:hyperlink>{foreign_hyperlink}</w:p></w:body></w:document>"#
1685 );
1686 let document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1687
1688 assert_eq!(
1689 document
1690 .revisions()
1691 .iter()
1692 .map(|revision| revision.id())
1693 .collect::<Vec<_>>(),
1694 [31]
1695 );
1696 let output =
1697 String::from_utf8(document.to_xml().expect("document writes")).expect("UTF-8 output");
1698 assert!(
1699 output.contains(malformed),
1700 "missing malformed subtree in {output}"
1701 );
1702 assert!(
1703 output.contains(foreign_child),
1704 "missing foreign child in {output}"
1705 );
1706 assert!(
1707 output.contains(foreign_hyperlink),
1708 "foreign owner was promoted or lost in {output}"
1709 );
1710 let positions = [
1711 output.find("<w:t>before</w:t>").unwrap(),
1712 output.find(malformed).unwrap(),
1713 output.find(r#"w:id="31""#).unwrap(),
1714 output.find(foreign_child).unwrap(),
1715 output.find("<w:t>after</w:t>").unwrap(),
1716 ];
1717 assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
1718
1719 let reopened = CT_Document::from_xml(output.as_bytes()).expect("output reparses");
1720 assert_eq!(reopened.revisions().len(), 1);
1721 assert_eq!(reopened.revisions()[0].id(), 31);
1722 }
1723
1724 #[test]
1725 fn aliased_hyperlink_runs_survive_a_locally_shadowed_word_prefix() {
1726 let foreign_run_properties = r#"<w:rPr/>"#;
1727 let foreign_text = r#"<w:t>foreign</w:t>"#;
1728 let foreign_drawing = r#"<w:drawing><w:opaque/></w:drawing>"#;
1729 let foreign_after = r#"<w:after/>"#;
1730 let lexical_literal = r#"<f:literal xmlns:f="urn:foreign" f:value="w: >">w:</f:literal>"#;
1731 let nested_word =
1732 r#"<f:box xmlns:f="urn:foreign" xmlns:w2="urn:two" f:value=">"><w:nested/></f:box>"#;
1733 let raw_at_boundary = r#"<w:boundary/>"#;
1734 let xml = format!(
1735 r#"<w:document xmlns:w="{W_NS}" xmlns:wx="{W_NS}"><w:body><w:p><wx:hyperlink xmlns:w="urn:foreign"><wx:r>{foreign_run_properties}<wx:rPr><wx:rStyle><wx:opaque/></wx:rStyle><w:pre/><wx:b wx:val="0"/><w:betweenDuplicate/><wx:b/><w:b/><w:between/><wx:i/><w:beforeChange/><wx:rPrChange wx:id="62" wx:author="Ada"><wx:rPr><wx:b/></wx:rPr></wx:rPrChange><w:afterChange/></wx:rPr>{foreign_text}<wx:t>before</wx:t>{foreign_drawing}<wx:t>middle</wx:t>{foreign_after}{lexical_literal}{nested_word}</wx:r><wx:ins wx:id="61" wx:author="Ada"><wx:r><wx:t>reported</wx:t></wx:r></wx:ins>{raw_at_boundary}<wx:r><wx:t>after</wx:t></wx:r></wx:hyperlink></w:p></w:body></w:document>"#
1736 );
1737 let document = CT_Document::from_xml(xml.as_bytes()).expect("document parses");
1738
1739 assert_eq!(
1740 document.body.paragraphs().next().unwrap().text(),
1741 "beforemiddleafter"
1742 );
1743 assert_eq!(
1744 document
1745 .revisions()
1746 .iter()
1747 .map(|revision| revision.id())
1748 .collect::<Vec<_>>(),
1749 vec![62, 61]
1750 );
1751 let output =
1752 String::from_utf8(document.to_xml().expect("document writes")).expect("UTF-8 output");
1753 assert!(output.contains(r#"xmlns:w="urn:foreign""#));
1754 let foreign_run_properties = r#"<w:rPr xmlns:w="urn:foreign"/>"#;
1755 let foreign_property = r#"<w:b xmlns:w="urn:foreign"/>"#;
1756 let foreign_before_property = r#"<w:pre xmlns:w="urn:foreign"/>"#;
1757 let foreign_between_duplicate = r#"<w:betweenDuplicate xmlns:w="urn:foreign"/>"#;
1758 let foreign_between_property = r#"<w:between xmlns:w="urn:foreign"/>"#;
1759 let foreign_before_change = r#"<w:beforeChange xmlns:w="urn:foreign"/>"#;
1760 let foreign_after_change = r#"<w:afterChange xmlns:w="urn:foreign"/>"#;
1761 let foreign_text = r#"<w:t xmlns:w="urn:foreign">foreign</w:t>"#;
1762 let foreign_drawing = r#"<w:drawing xmlns:w="urn:foreign"><w:opaque/></w:drawing>"#;
1763 let foreign_after = r#"<w:after xmlns:w="urn:foreign"/>"#;
1764 for raw in [
1765 foreign_run_properties,
1766 foreign_before_property,
1767 foreign_between_duplicate,
1768 foreign_property,
1769 foreign_between_property,
1770 foreign_before_change,
1771 foreign_after_change,
1772 foreign_text,
1773 foreign_drawing,
1774 foreign_after,
1775 ] {
1776 assert_eq!(output.matches(raw).count(), 1, "raw child {raw}");
1777 }
1778 assert_eq!(output.matches(lexical_literal).count(), 1);
1779 assert!(!output.contains(r#"<f:literal xmlns:w="urn:foreign""#));
1780 let nested_word = r#"<f:box xmlns:f="urn:foreign" xmlns:w2="urn:two" f:value=">" xmlns:w="urn:foreign"><w:nested/></f:box>"#;
1781 assert_eq!(output.matches(nested_word).count(), 1);
1782 assert!(output.contains(raw_at_boundary));
1783 assert!(output.contains(&format!(r#"<w:r xmlns:w="{W_NS}">"#)));
1784 let positions = [
1785 output.find(foreign_run_properties).unwrap(),
1786 output.find("<wx:rStyle>").unwrap(),
1787 output.find(foreign_before_property).unwrap(),
1788 output.find(foreign_between_duplicate).unwrap(),
1789 output.find("<w:b/>").unwrap(),
1790 output.find(foreign_property).unwrap(),
1791 output.find(foreign_between_property).unwrap(),
1792 output.find("<w:i/>").unwrap(),
1793 output.find(foreign_before_change).unwrap(),
1794 output.find(foreign_after_change).unwrap(),
1795 output.find(r#"wx:id="62""#).unwrap(),
1796 output.find(foreign_text).unwrap(),
1797 output.find("<w:t>before</w:t>").unwrap(),
1798 output.find(foreign_drawing).unwrap(),
1799 output.find("<w:t>middle</w:t>").unwrap(),
1800 output.find(foreign_after).unwrap(),
1801 output.find(r#"wx:id="61""#).unwrap(),
1802 output.find(raw_at_boundary).unwrap(),
1803 output.find("<w:t>after</w:t>").unwrap(),
1804 ];
1805 assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
1806
1807 let reopened = CT_Document::from_xml(output.as_bytes()).expect("output reparses");
1808 assert_eq!(
1809 reopened.body.paragraphs().next().unwrap().text(),
1810 "beforemiddleafter"
1811 );
1812 assert_eq!(
1813 reopened
1814 .revisions()
1815 .iter()
1816 .map(|revision| revision.id())
1817 .collect::<Vec<_>>(),
1818 vec![62, 61]
1819 );
1820 }
1821}