Skip to main content

rust_docx/
revisions.rs

1use std::collections::BTreeMap;
2
3use quick_xml::{events::Event, Reader};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    parse::{parse_run_text, run_text_xml},
8    xml::{
9        decode_xml_entities, direct_child_tag, extract_balanced_tag_slices,
10        extract_direct_child_tag_ranges, get_attribute_ref, tag_token_end,
11    },
12    DocNode, DocumentBlock, ParagraphChildNode, ParagraphNode, SourceRef, StructuredDocument,
13    TableNode, TableRowNode,
14};
15
16/// The current block projection and the tracked changes recorded in the same source.
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18pub struct ReviewedDocument {
19    #[serde(flatten)]
20    pub document: StructuredDocument,
21    pub revisions: Vec<DocumentRevision>,
22    #[serde(default)]
23    pub comments: Vec<crate::review::DocumentComment>,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum RevisionKind {
29    Insertion,
30    Deletion,
31    MoveFrom,
32    MoveTo,
33    Formatting,
34    CellMerge,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum RevisionScope {
40    Text,
41    ParagraphMark,
42    Paragraph,
43    Section,
44    Table,
45    TableGrid,
46    TableRow,
47    TableCell,
48}
49
50/// Direct Word properties, including nested properties and their qualified attributes.
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct WordProperty {
53    pub name: String,
54    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55    pub attributes: BTreeMap<String, String>,
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub children: Vec<WordProperty>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct RevisionProperties {
62    pub before: Vec<WordProperty>,
63    pub after: Vec<WordProperty>,
64}
65
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct DocumentRevision {
69    /// Unique within this document, including when different parts reuse a Word ID.
70    pub id: String,
71    pub kind: RevisionKind,
72    pub scope: RevisionScope,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub source_id: Option<String>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub author: Option<String>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub date: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub move_name: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub text: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub properties: Option<RevisionProperties>,
85    /// Half-open UTF-16 ranges in current paragraph text. A deletion has equal endpoints.
86    /// Structural targets and text which cannot be aligned safely have no endpoints.
87    pub source_refs: Vec<SourceRef>,
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub content: Vec<DocNode>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub child_start: Option<usize>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub child_end: Option<usize>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub source_index: Option<usize>,
96}
97
98#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct ReviewContent {
101    pub source_xml: String,
102    pub children: Vec<ParagraphChildNode>,
103}
104
105#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct ReviewRow {
108    pub source_index: usize,
109    pub source_xml: String,
110    pub row: TableRowNode,
111}
112
113pub(crate) fn removed_fragments(xml: &str) -> Vec<&str> {
114    let mut ranges = crate::xml::extract_balanced_tag_ranges(xml, "w:del");
115    ranges.extend(crate::xml::extract_balanced_tag_ranges(xml, "w:moveFrom"));
116    ranges.sort_by_key(|r| r.start);
117    let mut end = 0;
118    ranges
119        .into_iter()
120        .filter_map(|range| {
121            if range.start < end {
122                return None;
123            }
124            end = range.end;
125            let fragment = &xml[range.start..range.end];
126            fragment.contains("<w:r").then_some(fragment)
127        })
128        .collect()
129}
130
131pub(crate) fn visible_removed_xml(xml: &str) -> String {
132    static TAGS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
133        regex::Regex::new(r"(<\/?)w:(delText|delInstrText|del|moveFrom)([ \t\r\n/>])").unwrap()
134    });
135    TAGS.replace_all(xml, |captures: &regex::Captures<'_>| {
136        let name = match &captures[2] {
137            "delText" => "t",
138            "delInstrText" => "instrText",
139            _ => "ins",
140        };
141        format!("{}w:{name}{}", &captures[1], &captures[3])
142    })
143    .into_owned()
144}
145
146pub(crate) fn fallback_content(xml: &str) -> Vec<ParagraphChildNode> {
147    let parts = Default::default();
148    let assets = Default::default();
149    let context = crate::parse::ParseContext {
150        relationships: Default::default(),
151        content_types: Default::default(),
152        parts: &parts,
153        binary_assets: &assets,
154        style_sheet: crate::parse::empty_style_sheet(),
155        warnings: Default::default(),
156    };
157    crate::parse::parse_paragraph(
158        &format!("<w:p>{}</w:p>", visible_removed_xml(xml)),
159        &context,
160    )
161    .children
162}
163
164fn children(xml: &str) -> impl Iterator<Item = (String, &str)> {
165    extract_direct_child_tag_ranges(xml)
166        .into_iter()
167        .map(|r| (r.tag_name, &xml[r.start..r.end]))
168}
169
170fn attribute(xml: &str, name: &str) -> Option<String> {
171    let tag = &xml[..tag_token_end(xml, 0)?];
172    get_attribute_ref(tag, name).map(decode_xml_entities)
173}
174
175fn revision_kind(name: &str) -> Option<RevisionKind> {
176    Some(match name {
177        "w:ins" | "w:cellIns" => RevisionKind::Insertion,
178        "w:del" | "w:cellDel" => RevisionKind::Deletion,
179        "w:moveFrom" => RevisionKind::MoveFrom,
180        "w:moveTo" => RevisionKind::MoveTo,
181        "w:cellMerge" => RevisionKind::CellMerge,
182        "w:rPrChange" | "w:pPrChange" | "w:sectPrChange" | "w:tblPrChange" | "w:tblGridChange"
183        | "w:tblPrExChange" | "w:trPrChange" | "w:tcPrChange" => RevisionKind::Formatting,
184        _ => return None,
185    })
186}
187
188fn property(name: String, xml: &str) -> WordProperty {
189    let mut reader = Reader::from_str(xml);
190    let mut attributes = BTreeMap::new();
191    if let Ok(Event::Start(tag) | Event::Empty(tag)) = reader.read_event() {
192        for attr in tag.attributes().flatten() {
193            let key = attr.key.as_ref();
194            if !key.starts_with("xmlns") {
195                attributes.insert(key.to_owned(), decode_xml_entities(&attr.value));
196            }
197        }
198    }
199    WordProperty {
200        name,
201        attributes,
202        children: properties(xml),
203    }
204}
205
206fn properties(xml: &str) -> Vec<WordProperty> {
207    children(xml)
208        .filter(|(name, _)| revision_kind(name).is_none())
209        .map(|(name, xml)| property(name, xml))
210        .collect()
211}
212
213#[derive(Default)]
214pub(crate) struct RevisionCollector {
215    pub records: Vec<DocumentRevision>,
216    next_ids: BTreeMap<String, usize>,
217    // The two move ranges have distinct Word IDs and share a move name.
218    pub(crate) moves: Vec<(RevisionKind, String, String)>,
219}
220
221impl RevisionCollector {
222    fn record(
223        &mut self,
224        xml: &str,
225        kind: RevisionKind,
226        scope: RevisionScope,
227        refs: &[SourceRef],
228    ) -> usize {
229        let index = self.records.len();
230        let source_id = attribute(xml, "w:id");
231        let key = format!("{}/revision/{kind:?}/{scope:?}/{}", refs[0].node_id,
232            source_id.as_deref().unwrap_or("anonymous"));
233        let next = self.next_ids.entry(key.clone()).or_default();
234        let id = format!("{key}/{next}");
235        *next += 1;
236        let move_name = self
237            .moves
238            .iter()
239            .rev()
240            .find(|(side, _, _)| *side == kind)
241            .map(|(_, _, name)| name.clone());
242        self.records.push(DocumentRevision {
243            id,
244            source_id,
245            author: attribute(xml, "w:author"),
246            date: attribute(xml, "w:date"),
247            kind,
248            scope,
249            move_name,
250            text: None,
251            properties: None,
252            source_refs: refs.to_vec(),
253            content: Vec::new(),
254            child_start: None,
255            child_end: None,
256            source_index: None,
257        });
258        index
259    }
260
261    fn property_changes(&mut self, xml: &str, scope: RevisionScope, refs: &[SourceRef]) {
262        for (name, change) in children(xml) {
263            let Some(kind) = revision_kind(&name) else {
264                continue;
265            };
266            let index = self.record(change, kind, scope, refs);
267            if kind == RevisionKind::Formatting {
268                let before = children(change)
269                    .next()
270                    .map(|(_, previous)| properties(previous))
271                    .unwrap_or_default();
272                self.records[index].properties = Some(RevisionProperties {
273                    before,
274                    after: properties(xml),
275                });
276            } else if kind == RevisionKind::CellMerge {
277                let merge = |key| {
278                    attribute(change, key)
279                        .map(|value| WordProperty {
280                            name: "w:vMerge".into(),
281                            attributes: BTreeMap::from([("w:val".into(), value)]),
282                            children: Vec::new(),
283                        })
284                        .into_iter()
285                        .collect()
286                };
287                self.records[index].properties = Some(RevisionProperties {
288                    before: merge("w:vMergeOrig"),
289                    after: merge("w:vMerge"),
290                });
291            }
292        }
293    }
294
295    pub fn paragraph(&mut self, xml: &str, block: &DocumentBlock, paragraph: &ParagraphNode) {
296        if !has_markup(xml) {
297            return;
298        }
299        let first = self.records.len();
300        let mut p = ParagraphReview {
301            collector: self,
302            source: &block.source_refs[0],
303            visible: String::new(),
304            quoted: String::new(),
305            offset: 0,
306            child_index: 0,
307            paragraph,
308        };
309        p.visit(xml, false);
310        let aligned = p.visible == block.content;
311        let end = block.content.encode_utf16().count();
312        for revision in &mut p.collector.records[first..] {
313            let target = &mut revision.source_refs[0];
314            if revision.scope == RevisionScope::ParagraphMark {
315                target.start = Some(end);
316                target.end = Some(end);
317            } else if !aligned {
318                target.start = None;
319                target.end = None;
320                revision.child_start = None;
321                revision.child_end = None;
322            }
323        }
324    }
325
326    pub fn final_section(&mut self, xml: &str, source: &SourceRef) {
327        if xml.contains("w:sectPrChange") {
328            self.property_changes(xml, RevisionScope::Section, std::slice::from_ref(source));
329        }
330    }
331
332    pub fn table(&mut self, xml: &str, source: &SourceRef, table: &TableNode) {
333        if !has_markup(xml) {
334            return;
335        }
336        let targets = std::slice::from_ref(source);
337        for (name, scope) in [
338            ("w:tblPr", RevisionScope::Table),
339            ("w:tblGrid", RevisionScope::TableGrid),
340        ] {
341            if let Some(props) = direct_child_tag(xml, name) {
342                self.property_changes(props, scope, targets);
343            }
344        }
345        let mut retained = 0;
346        for (source_index, row_xml) in extract_balanced_tag_slices(xml, "w:tr")
347            .into_iter()
348            .enumerate()
349        {
350            let deleted = direct_child_tag(row_xml, "w:trPr")
351                .and_then(|props| direct_child_tag(props, "w:del"))
352                .is_some();
353            if deleted {
354                let first = self.records.len();
355                if let Some(props) = direct_child_tag(row_xml, "w:trPr") {
356                    self.property_changes(props, RevisionScope::TableRow, targets);
357                }
358                for record in &mut self.records[first..] {
359                    record.source_index = Some(source_index);
360                    if record.kind == RevisionKind::Deletion {
361                        if let Some(removed) = table
362                            .review_rows
363                            .iter()
364                            .flatten()
365                            .find(|r| r.source_xml == row_xml)
366                        {
367                            let mut content = table.clone();
368                            content.rows = vec![removed.row.clone()];
369                            content.review_rows = None;
370                            content.source_xml = None;
371                            content.block_id =
372                                Some(format!("{}/removed-row/{source_index}", source.node_id));
373                            record.content.push(DocNode::Table(content));
374                        }
375                    }
376                }
377                continue;
378            }
379            let ri = retained;
380            retained += 1;
381            let Some(row) = table.rows.get(ri) else {
382                continue;
383            };
384            let cells: Vec<_> = (0..row.cells.len())
385                .map(|ci| SourceRef {
386                    node_id: format!("{}/row/{ri}/cell/{ci}", source.node_id),
387                    ..source.clone()
388                })
389                .collect();
390            let row_targets = if cells.is_empty() {
391                targets
392            } else {
393                cells.as_slice()
394            };
395            for tag in ["w:trPr", "w:tblPrEx"] {
396                if let Some(props) = direct_child_tag(row_xml, tag) {
397                    self.property_changes(props, RevisionScope::TableRow, row_targets);
398                }
399            }
400            for (cell_xml, cell) in extract_balanced_tag_slices(row_xml, "w:tc")
401                .into_iter()
402                .zip(&cells)
403            {
404                if let Some(props) = direct_child_tag(cell_xml, "w:tcPr") {
405                    self.property_changes(
406                        props,
407                        RevisionScope::TableCell,
408                        std::slice::from_ref(cell),
409                    );
410                }
411            }
412        }
413    }
414}
415
416fn has_markup(xml: &str) -> bool {
417    [
418        "<w:ins",
419        "<w:del",
420        "<w:move",
421        "Change",
422        "<w:cellIns",
423        "<w:cellDel",
424        "<w:cellMerge",
425    ]
426    .iter()
427    .any(|marker| xml.contains(marker))
428}
429
430struct ParagraphReview<'a> {
431    collector: &'a mut RevisionCollector,
432    source: &'a SourceRef,
433    visible: String,
434    quoted: String,
435    offset: usize,
436    child_index: usize,
437    paragraph: &'a ParagraphNode,
438}
439
440impl ParagraphReview<'_> {
441    fn visit(&mut self, xml: &str, removed: bool) {
442        for (name, child) in children(xml) {
443            match name.as_str() {
444                "w:r" => self.run(child, removed),
445                "w:ins" | "w:del" | "w:moveFrom" | "w:moveTo" => {
446                    let kind = revision_kind(&name).unwrap();
447                    let index = self.collector.record(
448                        child,
449                        kind,
450                        RevisionScope::Text,
451                        std::slice::from_ref(self.source),
452                    );
453                    let start = self.offset;
454                    let child_start = self.child_index;
455                    let hidden =
456                        removed || matches!(kind, RevisionKind::Deletion | RevisionKind::MoveFrom);
457                    let begin = if hidden {
458                        self.quoted.len()
459                    } else {
460                        self.visible.len()
461                    };
462                    self.visit(child, hidden);
463                    let revision = &mut self.collector.records[index];
464                    revision.text = Some(
465                        (if hidden { &self.quoted } else { &self.visible })[begin..].to_owned(),
466                    );
467                    revision.source_refs[0].start = Some(start);
468                    revision.source_refs[0].end = Some(self.offset);
469                    revision.child_start = Some(child_start);
470                    revision.child_end = Some(self.child_index);
471                    if hidden {
472                        let children = self
473                            .paragraph
474                            .review_content
475                            .iter()
476                            .flatten()
477                            .find(|fragment| fragment.source_xml == child)
478                            .map(|fragment| fragment.children.clone())
479                            .unwrap_or_else(|| fallback_content(child));
480                        let mut content = self.paragraph.clone();
481                        content.block_id = Some(format!("{}/content", revision.id));
482                        content.children = children;
483                        content.review_content = None;
484                        content.source_xml = None;
485                        content.source_text_patch = None;
486                        content.source_run_provenance = None;
487                        revision.content.push(DocNode::Paragraph(content));
488                    }
489                }
490                "w:moveFromRangeStart"
491                | "w:moveToRangeStart"
492                | "w:moveFromRangeEnd"
493                | "w:moveToRangeEnd" => {
494                    let side = if name.starts_with("w:moveFrom") {
495                        RevisionKind::MoveFrom
496                    } else {
497                        RevisionKind::MoveTo
498                    };
499                    if let Some(id) = attribute(child, "w:id") {
500                        if name.ends_with("Start") {
501                            if let Some(label) = attribute(child, "w:name") {
502                                self.collector.moves.push((side, id, label));
503                            }
504                        } else {
505                            self.collector
506                                .moves
507                                .retain(|(s, key, _)| *s != side || *key != id);
508                        }
509                    }
510                }
511                "w:pPr" => {
512                    let refs = std::slice::from_ref(self.source);
513                    self.collector
514                        .property_changes(child, RevisionScope::Paragraph, refs);
515                    if let Some(mark) = direct_child_tag(child, "w:rPr") {
516                        self.collector
517                            .property_changes(mark, RevisionScope::ParagraphMark, refs);
518                    }
519                    if let Some(section) = direct_child_tag(child, "w:sectPr") {
520                        self.collector
521                            .property_changes(section, RevisionScope::Section, refs);
522                    }
523                }
524                "w:hyperlink" | "w:sdt" | "w:sdtContent" | "w:smartTag" | "w:customXml"
525                | "w:fldSimple" | "w:dir" | "w:bdo" => self.visit(child, removed),
526                "mc:AlternateContent" => {
527                    if let Some(active) = direct_child_tag(child, "mc:Choice")
528                        .or_else(|| direct_child_tag(child, "mc:Fallback"))
529                    {
530                        self.visit(active, removed);
531                    }
532                }
533                _ => {}
534            }
535        }
536    }
537
538    fn run(&mut self, xml: &str, removed: bool) {
539        let active = run_text_xml(xml);
540        let text = if removed && active.contains("w:delText") {
541            parse_run_text(
542                &active
543                    .replace("<w:delText", "<w:t")
544                    .replace("</w:delText>", "</w:t>"),
545            )
546        } else {
547            parse_run_text(&active)
548        };
549        let start = self.offset;
550        let child_start = self.child_index;
551        self.quoted.push_str(&text);
552        if !removed {
553            self.visible.push_str(&text);
554            self.offset += text.encode_utf16().count();
555            self.child_index += crate::parse::parse_run_text_tokens(&active)
556                .iter()
557                .filter(|t| !t.text.is_empty() || t.note_reference.is_some())
558                .count();
559            self.child_index += extract_balanced_tag_slices(&active, "w:drawing").len()
560                + extract_balanced_tag_slices(&active, "w:pict").len();
561        }
562        if let Some(props) = direct_child_tag(xml, "w:rPr") {
563            let target = SourceRef {
564                start: Some(start),
565                end: Some(self.offset),
566                ..self.source.clone()
567            };
568            let first = self.collector.records.len();
569            self.collector
570                .property_changes(props, RevisionScope::Text, &[target]);
571            for revision in &mut self.collector.records[first..] {
572                revision.child_start = Some(child_start);
573                revision.child_end = Some(self.child_index);
574            }
575        }
576    }
577}