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    DocumentBlock, SourceRef, StructuredDocument, TableNode,
13};
14
15/// The current block projection and the tracked changes recorded in the same source.
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
17pub struct ReviewedDocument {
18    #[serde(flatten)]
19    pub document: StructuredDocument,
20    pub revisions: Vec<DocumentRevision>,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum RevisionKind {
26    Insertion,
27    Deletion,
28    MoveFrom,
29    MoveTo,
30    Formatting,
31    CellMerge,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum RevisionScope {
37    Text,
38    ParagraphMark,
39    Paragraph,
40    Section,
41    Table,
42    TableGrid,
43    TableRow,
44    TableCell,
45}
46
47/// Direct Word properties, including nested properties and their qualified attributes.
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub struct WordProperty {
50    pub name: String,
51    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
52    pub attributes: BTreeMap<String, String>,
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub children: Vec<WordProperty>,
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58pub struct RevisionProperties {
59    pub before: Vec<WordProperty>,
60    pub after: Vec<WordProperty>,
61}
62
63#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct DocumentRevision {
66    /// Unique within this document, including when different parts reuse a Word ID.
67    pub id: String,
68    pub kind: RevisionKind,
69    pub scope: RevisionScope,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub source_id: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub author: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub date: Option<String>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub move_name: Option<String>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub text: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub properties: Option<RevisionProperties>,
82    /// Half-open UTF-16 ranges in current paragraph text. A deletion has equal endpoints.
83    /// Structural targets and text which cannot be aligned safely have no endpoints.
84    pub source_refs: Vec<SourceRef>,
85}
86
87fn children(xml: &str) -> impl Iterator<Item = (String, &str)> {
88    extract_direct_child_tag_ranges(xml)
89        .into_iter()
90        .map(|r| (r.tag_name, &xml[r.start..r.end]))
91}
92
93fn attribute(xml: &str, name: &str) -> Option<String> {
94    let tag = &xml[..tag_token_end(xml, 0)?];
95    get_attribute_ref(tag, name).map(decode_xml_entities)
96}
97
98fn revision_kind(name: &str) -> Option<RevisionKind> {
99    Some(match name {
100        "w:ins" | "w:cellIns" => RevisionKind::Insertion,
101        "w:del" | "w:cellDel" => RevisionKind::Deletion,
102        "w:moveFrom" => RevisionKind::MoveFrom,
103        "w:moveTo" => RevisionKind::MoveTo,
104        "w:cellMerge" => RevisionKind::CellMerge,
105        "w:rPrChange" | "w:pPrChange" | "w:sectPrChange" | "w:tblPrChange" | "w:tblGridChange"
106        | "w:tblPrExChange" | "w:trPrChange" | "w:tcPrChange" => RevisionKind::Formatting,
107        _ => return None,
108    })
109}
110
111fn property(name: String, xml: &str) -> WordProperty {
112    let mut reader = Reader::from_str(xml);
113    let mut attributes = BTreeMap::new();
114    if let Ok(Event::Start(tag) | Event::Empty(tag)) = reader.read_event() {
115        for attr in tag.attributes().flatten() {
116            let key = attr.key.as_ref();
117            if !key.starts_with("xmlns") {
118                attributes.insert(key.to_owned(), decode_xml_entities(&attr.value));
119            }
120        }
121    }
122    WordProperty {
123        name,
124        attributes,
125        children: properties(xml),
126    }
127}
128
129fn properties(xml: &str) -> Vec<WordProperty> {
130    children(xml)
131        .filter(|(name, _)| revision_kind(name).is_none())
132        .map(|(name, xml)| property(name, xml))
133        .collect()
134}
135
136#[derive(Default)]
137pub(crate) struct RevisionCollector {
138    pub records: Vec<DocumentRevision>,
139    // The two move ranges have distinct Word IDs and share a move name.
140    pub(crate) moves: Vec<(RevisionKind, String, String)>,
141}
142
143impl RevisionCollector {
144    fn record(
145        &mut self,
146        xml: &str,
147        kind: RevisionKind,
148        scope: RevisionScope,
149        refs: &[SourceRef],
150    ) -> usize {
151        let index = self.records.len();
152        let move_name = self
153            .moves
154            .iter()
155            .rev()
156            .find(|(side, _, _)| *side == kind)
157            .map(|(_, _, name)| name.clone());
158        self.records.push(DocumentRevision {
159            id: format!("docx:{}/revision/{index}", refs[0].document_id),
160            source_id: attribute(xml, "w:id"),
161            author: attribute(xml, "w:author"),
162            date: attribute(xml, "w:date"),
163            kind,
164            scope,
165            move_name,
166            text: None,
167            properties: None,
168            source_refs: refs.to_vec(),
169        });
170        index
171    }
172
173    fn property_changes(&mut self, xml: &str, scope: RevisionScope, refs: &[SourceRef]) {
174        for (name, change) in children(xml) {
175            let Some(kind) = revision_kind(&name) else {
176                continue;
177            };
178            let index = self.record(change, kind, scope, refs);
179            if kind == RevisionKind::Formatting {
180                let before = children(change)
181                    .next()
182                    .map(|(_, previous)| properties(previous))
183                    .unwrap_or_default();
184                self.records[index].properties = Some(RevisionProperties {
185                    before,
186                    after: properties(xml),
187                });
188            } else if kind == RevisionKind::CellMerge {
189                let merge = |key| {
190                    attribute(change, key)
191                        .map(|value| WordProperty {
192                            name: "w:vMerge".into(),
193                            attributes: BTreeMap::from([("w:val".into(), value)]),
194                            children: Vec::new(),
195                        })
196                        .into_iter()
197                        .collect()
198                };
199                self.records[index].properties = Some(RevisionProperties {
200                    before: merge("w:vMergeOrig"),
201                    after: merge("w:vMerge"),
202                });
203            }
204        }
205    }
206
207    pub fn paragraph(&mut self, xml: &str, block: &DocumentBlock) {
208        if !has_markup(xml) {
209            return;
210        }
211        let first = self.records.len();
212        let mut p = ParagraphReview {
213            collector: self,
214            source: &block.source_refs[0],
215            visible: String::new(),
216            quoted: String::new(),
217            offset: 0,
218        };
219        p.visit(xml, false);
220        let aligned = p.visible == block.content;
221        let end = block.content.encode_utf16().count();
222        for revision in &mut p.collector.records[first..] {
223            let target = &mut revision.source_refs[0];
224            if revision.scope == RevisionScope::ParagraphMark {
225                target.start = Some(end);
226                target.end = Some(end);
227            } else if !aligned {
228                target.start = None;
229                target.end = None;
230            }
231        }
232    }
233
234    pub fn final_section(&mut self, xml: &str, source: &SourceRef) {
235        if xml.contains("w:sectPrChange") {
236            self.property_changes(xml, RevisionScope::Section, std::slice::from_ref(source));
237        }
238    }
239
240    pub fn table(&mut self, xml: &str, source: &SourceRef, table: &TableNode) {
241        if !has_markup(xml) {
242            return;
243        }
244        let targets = std::slice::from_ref(source);
245        for (name, scope) in [
246            ("w:tblPr", RevisionScope::Table),
247            ("w:tblGrid", RevisionScope::TableGrid),
248        ] {
249            if let Some(props) = direct_child_tag(xml, name) {
250                self.property_changes(props, scope, targets);
251            }
252        }
253        for (ri, (row_xml, row)) in extract_balanced_tag_slices(xml, "w:tr")
254            .into_iter()
255            .zip(&table.rows)
256            .enumerate()
257        {
258            let cells: Vec<_> = (0..row.cells.len())
259                .map(|ci| SourceRef {
260                    node_id: format!("{}/row/{ri}/cell/{ci}", source.node_id),
261                    ..source.clone()
262                })
263                .collect();
264            let row_targets = if cells.is_empty() {
265                targets
266            } else {
267                cells.as_slice()
268            };
269            for tag in ["w:trPr", "w:tblPrEx"] {
270                if let Some(props) = direct_child_tag(row_xml, tag) {
271                    self.property_changes(props, RevisionScope::TableRow, row_targets);
272                }
273            }
274            for (cell_xml, cell) in extract_balanced_tag_slices(row_xml, "w:tc")
275                .into_iter()
276                .zip(&cells)
277            {
278                if let Some(props) = direct_child_tag(cell_xml, "w:tcPr") {
279                    self.property_changes(
280                        props,
281                        RevisionScope::TableCell,
282                        std::slice::from_ref(cell),
283                    );
284                }
285            }
286        }
287    }
288}
289
290fn has_markup(xml: &str) -> bool {
291    [
292        "<w:ins",
293        "<w:del",
294        "<w:move",
295        "Change",
296        "<w:cellIns",
297        "<w:cellDel",
298        "<w:cellMerge",
299    ]
300    .iter()
301    .any(|marker| xml.contains(marker))
302}
303
304struct ParagraphReview<'a> {
305    collector: &'a mut RevisionCollector,
306    source: &'a SourceRef,
307    visible: String,
308    quoted: String,
309    offset: usize,
310}
311
312impl ParagraphReview<'_> {
313    fn visit(&mut self, xml: &str, removed: bool) {
314        for (name, child) in children(xml) {
315            match name.as_str() {
316                "w:r" => self.run(child, removed),
317                "w:ins" | "w:del" | "w:moveFrom" | "w:moveTo" => {
318                    let kind = revision_kind(&name).unwrap();
319                    let index = self.collector.record(
320                        child,
321                        kind,
322                        RevisionScope::Text,
323                        std::slice::from_ref(self.source),
324                    );
325                    let start = self.offset;
326                    let hidden =
327                        removed || matches!(kind, RevisionKind::Deletion | RevisionKind::MoveFrom);
328                    let begin = if hidden {
329                        self.quoted.len()
330                    } else {
331                        self.visible.len()
332                    };
333                    self.visit(child, hidden);
334                    let revision = &mut self.collector.records[index];
335                    revision.text = Some(
336                        (if hidden { &self.quoted } else { &self.visible })[begin..].to_owned(),
337                    );
338                    revision.source_refs[0].start = Some(start);
339                    revision.source_refs[0].end = Some(self.offset);
340                }
341                "w:moveFromRangeStart"
342                | "w:moveToRangeStart"
343                | "w:moveFromRangeEnd"
344                | "w:moveToRangeEnd" => {
345                    let side = if name.starts_with("w:moveFrom") {
346                        RevisionKind::MoveFrom
347                    } else {
348                        RevisionKind::MoveTo
349                    };
350                    if let Some(id) = attribute(child, "w:id") {
351                        if name.ends_with("Start") {
352                            if let Some(label) = attribute(child, "w:name") {
353                                self.collector.moves.push((side, id, label));
354                            }
355                        } else {
356                            self.collector
357                                .moves
358                                .retain(|(s, key, _)| *s != side || *key != id);
359                        }
360                    }
361                }
362                "w:pPr" => {
363                    let refs = std::slice::from_ref(self.source);
364                    self.collector
365                        .property_changes(child, RevisionScope::Paragraph, refs);
366                    if let Some(mark) = direct_child_tag(child, "w:rPr") {
367                        self.collector
368                            .property_changes(mark, RevisionScope::ParagraphMark, refs);
369                    }
370                    if let Some(section) = direct_child_tag(child, "w:sectPr") {
371                        self.collector
372                            .property_changes(section, RevisionScope::Section, refs);
373                    }
374                }
375                "w:hyperlink" | "w:sdt" | "w:sdtContent" | "w:smartTag" | "w:customXml"
376                | "w:fldSimple" | "w:dir" | "w:bdo" => self.visit(child, removed),
377                "mc:AlternateContent" => {
378                    if let Some(active) = direct_child_tag(child, "mc:Choice")
379                        .or_else(|| direct_child_tag(child, "mc:Fallback"))
380                    {
381                        self.visit(active, removed);
382                    }
383                }
384                _ => {}
385            }
386        }
387    }
388
389    fn run(&mut self, xml: &str, removed: bool) {
390        let active = run_text_xml(xml);
391        let text = if removed && active.contains("w:delText") {
392            parse_run_text(
393                &active
394                    .replace("<w:delText", "<w:t")
395                    .replace("</w:delText>", "</w:t>"),
396            )
397        } else {
398            parse_run_text(&active)
399        };
400        let start = self.offset;
401        self.quoted.push_str(&text);
402        if !removed {
403            self.visible.push_str(&text);
404            self.offset += text.encode_utf16().count();
405        }
406        if let Some(props) = direct_child_tag(xml, "w:rPr") {
407            let target = SourceRef {
408                start: Some(start),
409                end: Some(self.offset),
410                ..self.source.clone()
411            };
412            self.collector
413                .property_changes(props, RevisionScope::Text, &[target]);
414        }
415    }
416}