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#[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#[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 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 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 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}