Skip to main content

rust_docx/
revision_inlines.rs

1//! Ordered inline spans annotated with tracked insertions and deletions.
2use crate::{
3    BlockDetails, DocumentBlock, DocumentRevision, ParagraphInline, RevisionKind, RevisionScope,
4};
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, HashMap, HashSet};
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum InlineChange {
11    #[default]
12    Unchanged,
13    Deletion,
14    Insertion,
15}
16
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18pub struct RevisionInline {
19    pub inline: ParagraphInline,
20    pub change: InlineChange,
21}
22
23#[derive(Default)]
24struct Boundary {
25    delta: i64,
26    removed: Vec<String>,
27}
28struct Edit {
29    start: usize,
30    end: usize,
31    removed: Option<String>,
32}
33
34fn paragraph_inlines(
35    block: &DocumentBlock,
36    edits: &[Edit],
37    inherited: InlineChange,
38) -> Vec<RevisionInline> {
39    let mut length = 0;
40    let mut offsets = HashSet::from([0]);
41    for c in block.content.chars() {
42        length += c.len_utf16();
43        offsets.insert(length);
44    }
45    let mut boundaries: BTreeMap<usize, Boundary> = BTreeMap::new();
46    for edit in edits {
47        if edit.start > edit.end || !offsets.contains(&edit.start) || !offsets.contains(&edit.end) {
48            continue;
49        }
50        if let Some(text) = &edit.removed {
51            boundaries
52                .entry(edit.start)
53                .or_default()
54                .removed
55                .push(text.clone());
56        } else {
57            boundaries.entry(edit.start).or_default().delta += 1;
58            boundaries.entry(edit.end).or_default().delta -= 1;
59        }
60    }
61    let inlines = match &block.details {
62        BlockDetails::Paragraph(d) => d.inlines.clone(),
63        BlockDetails::Raw(d) => d
64            .get("inlines")
65            .and_then(|v| serde_json::from_value(v.clone()).ok())
66            .unwrap_or_else(|| {
67                vec![ParagraphInline::Text {
68                    start: 0,
69                    end: length,
70                    text: block.content.clone(),
71                    style: None,
72                    link: None,
73                    note_reference: None,
74                }]
75            }),
76        _ => Vec::new(),
77    };
78    let mut events = boundaries.into_iter().peekable();
79    let mut position = 0;
80    let mut inserted = 0i64;
81    let mut output = Vec::new();
82    fn flush(
83        events: &mut std::iter::Peekable<std::collections::btree_map::IntoIter<usize, Boundary>>,
84        position: usize,
85        inserted: &mut i64,
86        output: &mut Vec<RevisionInline>,
87    ) {
88        if events.peek().is_some_and(|(offset, _)| *offset == position) {
89            let (_, event) = events.next().unwrap();
90            for text in event.removed {
91                output.push(RevisionInline {
92                    inline: ParagraphInline::Text {
93                        start: position,
94                        end: position,
95                        text,
96                        style: None,
97                        link: None,
98                        note_reference: None,
99                    },
100                    change: InlineChange::Deletion,
101                });
102            }
103            *inserted += event.delta;
104        }
105    }
106    for inline in inlines {
107        flush(&mut events, position, &mut inserted, &mut output);
108        let change = |n: i64| {
109            if inherited != InlineChange::Unchanged {
110                inherited
111            } else if n > 0 {
112                InlineChange::Insertion
113            } else {
114                InlineChange::Unchanged
115            }
116        };
117        let text = match &inline {
118            ParagraphInline::Block { block_id, .. } => {
119                output.push(RevisionInline {
120                    inline: ParagraphInline::Block {
121                        block_id: block_id.clone(),
122                        offset: position,
123                    },
124                    change: change(inserted),
125                });
126                continue;
127            }
128            ParagraphInline::Text { text, .. } | ParagraphInline::FormField { text, .. } => text,
129        };
130        let start = position;
131        let utf16: Vec<_> = text.encode_utf16().collect();
132        let end = start + utf16.len();
133        if start == end && matches!(inline, ParagraphInline::FormField { .. }) {
134            output.push(RevisionInline {
135                inline: inline.clone(),
136                change: change(inserted),
137            });
138        }
139        while position < end {
140            flush(&mut events, position, &mut inserted, &mut output);
141            let next = end.min(events.peek().map(|(offset, _)| *offset).unwrap_or(end));
142            let Ok(text) = String::from_utf16(&utf16[position - start..next - start]) else {
143                // Ignore invalid boundaries rather than splitting a Unicode scalar.
144                events.next();
145                continue;
146            };
147            let piece = match &inline {
148                ParagraphInline::Text {
149                    style,
150                    link,
151                    note_reference,
152                    ..
153                } => ParagraphInline::Text {
154                    start: position,
155                    end: next,
156                    text,
157                    style: style.clone(),
158                    link: link.clone(),
159                    note_reference: note_reference.clone(),
160                },
161                ParagraphInline::FormField { block_id, .. } => ParagraphInline::FormField {
162                    block_id: block_id.clone(),
163                    start: position,
164                    end: next,
165                    text,
166                },
167                _ => unreachable!(),
168            };
169            output.push(RevisionInline {
170                inline: piece,
171                change: change(inserted),
172            });
173            position = next;
174        }
175    }
176    flush(&mut events, position, &mut inserted, &mut output);
177    output
178}
179
180/// Produce spans only for affected paragraphs; all offsets address current text in UTF-16.
181/// Deleted text is anchored at an empty range and does not shift subsequent offsets.
182pub fn revision_inlines(
183    blocks: &[DocumentBlock],
184    revisions: &[DocumentRevision],
185) -> BTreeMap<String, Vec<RevisionInline>> {
186    let mut edits: HashMap<&str, Vec<Edit>> = HashMap::new();
187    let mut containers = HashMap::new();
188    for revision in revisions {
189        let deletion = matches!(
190            revision.kind,
191            RevisionKind::Deletion | RevisionKind::MoveFrom
192        );
193        if !deletion
194            && !matches!(
195                revision.kind,
196                RevisionKind::Insertion | RevisionKind::MoveTo
197            )
198        {
199            continue;
200        }
201        for source in &revision.source_refs {
202            if matches!(
203                revision.scope,
204                RevisionScope::TableRow | RevisionScope::TableCell
205            ) {
206                containers.insert(
207                    source.node_id.as_str(),
208                    if deletion {
209                        InlineChange::Deletion
210                    } else {
211                        InlineChange::Insertion
212                    },
213                );
214            } else if revision.scope == RevisionScope::Text {
215                if let (Some(start), Some(end)) = (source.start, source.end) {
216                    if if deletion {
217                        start != end || revision.text.as_ref().is_none_or(String::is_empty)
218                    } else {
219                        start >= end
220                    } {
221                        continue;
222                    }
223                    edits.entry(&source.node_id).or_default().push(Edit {
224                        start,
225                        end,
226                        removed: if deletion {
227                            revision.text.clone()
228                        } else {
229                            None
230                        },
231                    });
232                }
233            }
234        }
235    }
236    fn visit(
237        block: &DocumentBlock,
238        inherited: InlineChange,
239        edits: &HashMap<&str, Vec<Edit>>,
240        containers: &HashMap<&str, InlineChange>,
241        output: &mut BTreeMap<String, Vec<RevisionInline>>,
242    ) {
243        let state = containers
244            .get(block.id.as_str())
245            .copied()
246            .unwrap_or(inherited);
247        let changes = edits.get(block.id.as_str());
248        let paragraph = match &block.details {
249            BlockDetails::Paragraph(_) => true,
250            BlockDetails::Raw(d) => d["kind"] == "paragraph",
251            _ => false,
252        };
253        if paragraph && (changes.is_some() || state != InlineChange::Unchanged) {
254            output.insert(
255                block.id.clone(),
256                paragraph_inlines(block, changes.map(Vec::as_slice).unwrap_or_default(), state),
257            );
258        }
259        for child in &block.children {
260            visit(child, state, edits, containers, output);
261        }
262    }
263    let mut output = BTreeMap::new();
264    if !edits.is_empty() || !containers.is_empty() {
265        for block in blocks {
266            visit(
267                block,
268                InlineChange::Unchanged,
269                &edits,
270                &containers,
271                &mut output,
272            );
273        }
274    }
275    output
276}