Skip to main content

rdocx_oxml/
revision.rs

1//! Tracked revision metadata and read-only content projections.
2
3use 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/// The modeled tracked-change element kind.
18#[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/// The typed content projected from a preserved revision subtree.
31#[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/// A tracked revision whose captured subtree remains its serialization source.
42#[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    /// Whether a preserved wrapper is admitted by the complete typed parser.
67    #[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    /// Return the paragraph projection for an insertion, when it has inline wrappers.
188    #[doc(hidden)]
189    pub fn content_paragraph(&self) -> Option<&CT_P> {
190        self.content_paragraph.as_deref()
191    }
192
193    /// Mutate physical read-projection metadata without rewriting revision XML.
194    /// Used on layout-only clones before selecting the accepted revision view.
195    /// The original raw XML remains the serializer's authority.
196    #[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    /// Return nested revision wrappers at their direct-run boundaries.
217    #[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    /// Remove an accepted-view run of an insertion. Returns `None` for a
265    /// stale path, and otherwise whether nothing is left in the wrapper, which
266    /// is then left unchanged for its owner to remove. A move destination is
267    /// refused, since its source would no longer match it.
268    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    /// The paragraph of an insertion or a move destination, when
291    /// [`Self::edit_accepted_paragraph`] can write an edit of it back.
292    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    /// Apply `edit` to the paragraph of an insertion or a move destination
302    /// and, when it reports a change, write its runs back inside the
303    /// wrapper, whose start tag keeps its id, author and date. Returns false
304    /// for any other revision.
305    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(&paragraph_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    /// Return every valid modeled main-document revision in document order.
422    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) = &paragraph.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 &amp; B" word:date="2026-08-17T09:00:00+01:00 &amp; 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                &paragraph.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        // F-X128 retained every attribute on a modeled root, declarations
1522        // included, so a binding used only by a child was written twice, once
1523        // on `w:sectPr` and once on the child that already carried it.
1524        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        // The declaration reaches the one child that uses it, and no more.
1533        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}