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#[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#[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 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 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: ®ex::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 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}