1use std::collections::{HashMap, HashSet};
2
3use quick_xml::{
4 XmlVersion,
5 events::{BytesStart, Event},
6 name::{Namespace, ResolveResult},
7 reader::NsReader,
8};
9
10const WORDPROCESSING_TRANSITIONAL: &[u8] =
11 b"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
12const WORDPROCESSING_STRICT: &[u8] = b"http://purl.oclc.org/ooxml/wordprocessingml/main";
13const WORDPROCESSING_2010: &[u8] = b"http://schemas.microsoft.com/office/word/2010/wordml";
14const WORDPROCESSING_2012: &[u8] = b"http://schemas.microsoft.com/office/word/2012/wordml";
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct ReviewFactLimits {
18 pub maximum_comments_xml_bytes: usize,
19 pub maximum_comments_extended_xml_bytes: usize,
20 pub maximum_facts_per_family: usize,
21}
22
23impl Default for ReviewFactLimits {
24 fn default() -> Self {
25 Self {
26 maximum_comments_xml_bytes: 16 * 1024 * 1024,
27 maximum_comments_extended_xml_bytes: 16 * 1024 * 1024,
28 maximum_facts_per_family: 1_000_000,
29 }
30 }
31}
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum ReviewFactUnknownReason {
35 InvalidDocument,
36 InvalidComments,
37 InvalidCommentsExtended,
38 ResourceLimit,
39 UnsupportedLocation,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub enum ReviewFactSet<T> {
44 Known(Vec<T>),
45 Unknown(ReviewFactUnknownReason),
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum ReviewDetail<T> {
50 Known(T),
51 Unknown(ReviewFactUnknownReason),
52}
53
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub struct ReviewPoint {
56 pub paragraph_ordinal: usize,
57 pub utf8: u32,
58 pub utf16: u32,
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct ReviewSpan {
63 pub start: ReviewPoint,
64 pub end: ReviewPoint,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct RevisionContent {
69 pub span: ReviewSpan,
70 pub text: String,
71 pub formatting_only: bool,
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct CommentContent {
76 pub anchor: ReviewSpan,
77 pub comment_text: String,
78 pub referenced_text: String,
79}
80
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
82pub enum RevisionFactKind {
83 Insertion,
84 Deletion,
85 MoveFrom,
86 MoveTo,
87 CellInsertion,
88 CellDeletion,
89 CellMerge,
90 ParagraphPropertiesChange,
91 RunPropertiesChange,
92 SectionPropertiesChange,
93 TablePropertiesChange,
94 TableRowPropertiesChange,
95 TableCellPropertiesChange,
96 TableGridChange,
97 CustomXmlDeletionRangeStart,
98 CustomXmlDeletionRangeEnd,
99 CustomXmlInsertionRangeStart,
100 CustomXmlInsertionRangeEnd,
101 CustomXmlMoveFromRangeStart,
102 CustomXmlMoveFromRangeEnd,
103 CustomXmlMoveToRangeStart,
104 CustomXmlMoveToRangeEnd,
105}
106
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct AttributedRevision {
109 pub kind: RevisionFactKind,
110 pub author: String,
111 pub date: Option<String>,
112 pub revision_id: Option<String>,
113 pub content: ReviewDetail<RevisionContent>,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct AttributedComment {
118 pub comment_id: String,
119 pub author: String,
120 pub initials: Option<String>,
121 pub date: Option<String>,
122 pub parent_comment_id: Option<String>,
123 pub resolved: bool,
124 pub content: ReviewDetail<CommentContent>,
125}
126
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct DocumentReviewFacts {
129 pub revisions: ReviewFactSet<AttributedRevision>,
130 pub comments: ReviewFactSet<AttributedComment>,
131}
132
133#[derive(Clone)]
134struct CommentRow {
135 comment_id: String,
136 author: String,
137 initials: Option<String>,
138 date: Option<String>,
139 paragraph_id: Option<String>,
140 paragraph_id_from_wrapper: bool,
141}
142
143#[derive(Clone)]
144struct CommentExtension {
145 parent_paragraph_id: Option<String>,
146 resolved: bool,
147}
148
149#[derive(Clone, Copy, Eq, PartialEq)]
150enum NamespaceKind {
151 Other,
152 Wordprocessing,
153 Wordprocessing2010,
154 Wordprocessing2012,
155}
156
157pub(super) fn project_review_facts(
158 revisions: ReviewFactSet<AttributedRevision>,
159 comments_xml: Result<Option<Vec<u8>>, ReviewFactUnknownReason>,
160 comments_extended_xml: Result<Option<Vec<u8>>, ReviewFactUnknownReason>,
161 limits: ReviewFactLimits,
162) -> DocumentReviewFacts {
163 let comments = match comments_xml {
164 Ok(None) => ReviewFactSet::Known(Vec::new()),
165 Ok(Some(comments_xml)) => match comments_extended_xml {
166 Ok(comments_extended_xml) => parse_comments(
167 &comments_xml,
168 comments_extended_xml.as_deref(),
169 limits.maximum_facts_per_family,
170 )
171 .map_or_else(ReviewFactSet::Unknown, ReviewFactSet::Known),
172 Err(reason) => ReviewFactSet::Unknown(reason),
173 },
174 Err(reason) => ReviewFactSet::Unknown(reason),
175 };
176 DocumentReviewFacts {
177 revisions,
178 comments,
179 }
180}
181
182fn namespace_kind(namespace: &ResolveResult<'_>) -> NamespaceKind {
183 let ResolveResult::Bound(Namespace(value)) = namespace else {
184 return NamespaceKind::Other;
185 };
186 match *value {
187 WORDPROCESSING_TRANSITIONAL | WORDPROCESSING_STRICT => NamespaceKind::Wordprocessing,
188 WORDPROCESSING_2010 => NamespaceKind::Wordprocessing2010,
189 WORDPROCESSING_2012 => NamespaceKind::Wordprocessing2012,
190 _ => NamespaceKind::Other,
191 }
192}
193
194fn attribute(
195 reader: &NsReader<&[u8]>,
196 element: &BytesStart<'_>,
197 namespace: NamespaceKind,
198 local_name: &[u8],
199) -> Result<Option<String>, ReviewFactUnknownReason> {
200 for item in element.attributes() {
201 let item = item.map_err(|_| ReviewFactUnknownReason::InvalidDocument)?;
202 let (resolved, name) = reader.resolver().resolve_attribute(item.key);
203 if namespace_kind(&resolved) == namespace && name.as_ref() == local_name {
204 return item
205 .decoded_and_normalized_value(XmlVersion::default(), reader.decoder())
206 .map(|value| Some(value.into_owned()))
207 .map_err(|_| ReviewFactUnknownReason::InvalidDocument);
208 }
209 }
210 Ok(None)
211}
212
213fn comment_attribute(
214 reader: &NsReader<&[u8]>,
215 element: &BytesStart<'_>,
216 namespace: NamespaceKind,
217 local_name: &[u8],
218) -> Result<Option<String>, ReviewFactUnknownReason> {
219 attribute(reader, element, namespace, local_name)
220 .map_err(|_| ReviewFactUnknownReason::InvalidComments)
221}
222
223fn comment_extension_attribute(
224 reader: &NsReader<&[u8]>,
225 element: &BytesStart<'_>,
226 local_name: &[u8],
227) -> Result<Option<String>, ReviewFactUnknownReason> {
228 attribute(
229 reader,
230 element,
231 NamespaceKind::Wordprocessing2012,
232 local_name,
233 )
234 .map_err(|_| ReviewFactUnknownReason::InvalidCommentsExtended)
235}
236
237fn comment_paragraph_id(
238 reader: &NsReader<&[u8]>,
239 element: &BytesStart<'_>,
240) -> Result<Option<String>, ReviewFactUnknownReason> {
241 for namespace in [
242 NamespaceKind::Wordprocessing2010,
243 NamespaceKind::Wordprocessing2012,
244 NamespaceKind::Wordprocessing,
245 ] {
246 if let Some(value) = comment_attribute(reader, element, namespace, b"paraId")? {
247 return Ok(Some(value.to_ascii_uppercase()));
248 }
249 }
250 Ok(None)
251}
252
253fn parse_comments(
254 comments_xml: &[u8],
255 comments_extended_xml: Option<&[u8]>,
256 maximum_facts: usize,
257) -> Result<Vec<AttributedComment>, ReviewFactUnknownReason> {
258 let comments = parse_comment_rows(comments_xml, maximum_facts)?;
259 let Some(comments_extended_xml) = comments_extended_xml else {
260 return Ok(comments
261 .into_iter()
262 .map(|comment| attributed_comment(comment, None, false))
263 .collect());
264 };
265 let mut extensions = parse_comment_extensions(comments_extended_xml, maximum_facts)?;
266 let comment_ids_by_paragraph = comments
267 .iter()
268 .filter_map(|comment| {
269 comment
270 .paragraph_id
271 .as_ref()
272 .map(|paragraph_id| (paragraph_id.clone(), comment.comment_id.clone()))
273 })
274 .collect::<HashMap<_, _>>();
275 let comments_with_paragraph_ids = comments
276 .iter()
277 .filter(|comment| comment.paragraph_id.is_some())
278 .count();
279 if comment_ids_by_paragraph.len() != comments_with_paragraph_ids {
280 return Err(ReviewFactUnknownReason::InvalidCommentsExtended);
281 }
282 let mut output = Vec::with_capacity(comments.len());
283 for comment in comments {
284 let extension = comment
285 .paragraph_id
286 .as_ref()
287 .and_then(|paragraph_id| extensions.remove(paragraph_id));
288 let (parent_comment_id, resolved) = extension.map_or(Ok((None, false)), |extension| {
289 let parent = extension
290 .parent_paragraph_id
291 .as_ref()
292 .map(|parent| {
293 comment_ids_by_paragraph
294 .get(parent)
295 .cloned()
296 .ok_or(ReviewFactUnknownReason::InvalidCommentsExtended)
297 })
298 .transpose()?;
299 Ok((parent, extension.resolved))
300 })?;
301 output.push(attributed_comment(comment, parent_comment_id, resolved));
302 }
303 if !extensions.is_empty() || contains_parent_cycle(&output) {
304 return Err(ReviewFactUnknownReason::InvalidCommentsExtended);
305 }
306 Ok(output)
307}
308
309fn attributed_comment(
310 comment: CommentRow,
311 parent_comment_id: Option<String>,
312 resolved: bool,
313) -> AttributedComment {
314 AttributedComment {
315 comment_id: comment.comment_id,
316 author: comment.author,
317 initials: comment.initials,
318 date: comment.date,
319 parent_comment_id,
320 resolved,
321 content: ReviewDetail::Unknown(ReviewFactUnknownReason::UnsupportedLocation),
322 }
323}
324
325#[allow(clippy::too_many_lines)] fn parse_comment_rows(
327 xml: &[u8],
328 maximum_facts: usize,
329) -> Result<Vec<CommentRow>, ReviewFactUnknownReason> {
330 let invalid = ReviewFactUnknownReason::InvalidComments;
331 let mut reader = NsReader::from_reader(xml);
332 reader.config_mut().expand_empty_elements = true;
333 reader.config_mut().check_end_names = true;
334 let mut output = Vec::new();
335 let mut current: Option<(usize, CommentRow)> = None;
336 let mut depth = 0_usize;
337 let mut root_seen = false;
338 let mut comment_ids = HashSet::new();
339 let mut paragraph_ids = HashSet::new();
340 loop {
341 match reader.read_resolved_event() {
342 Ok((_, Event::Eof)) => {
343 if depth != 0 || current.is_some() || !root_seen {
344 return Err(invalid);
345 }
346 return Ok(output);
347 }
348 Ok((namespace, Event::Start(element))) => {
349 let kind = namespace_kind(&namespace);
350 if depth == 0 {
351 if root_seen
352 || kind != NamespaceKind::Wordprocessing
353 || element.local_name().as_ref() != b"comments"
354 {
355 return Err(invalid);
356 }
357 root_seen = true;
358 } else if depth == 1
359 && kind == NamespaceKind::Wordprocessing
360 && element.local_name().as_ref() == b"comment"
361 {
362 if current.is_some() || output.len() >= maximum_facts {
363 return Err(if output.len() >= maximum_facts {
364 ReviewFactUnknownReason::ResourceLimit
365 } else {
366 invalid
367 });
368 }
369 let comment_id =
370 comment_attribute(&reader, &element, NamespaceKind::Wordprocessing, b"id")?
371 .ok_or(invalid)?;
372 if !comment_ids.insert(comment_id.clone()) {
373 return Err(invalid);
374 }
375 let wrapper_paragraph_id = comment_paragraph_id(&reader, &element)?;
376 let paragraph_id_from_wrapper = wrapper_paragraph_id.is_some();
377 current = Some((
378 depth,
379 CommentRow {
380 comment_id,
381 author: comment_attribute(
382 &reader,
383 &element,
384 NamespaceKind::Wordprocessing,
385 b"author",
386 )?
387 .unwrap_or_default(),
388 initials: comment_attribute(
389 &reader,
390 &element,
391 NamespaceKind::Wordprocessing,
392 b"initials",
393 )?,
394 date: comment_attribute(
395 &reader,
396 &element,
397 NamespaceKind::Wordprocessing,
398 b"date",
399 )?,
400 paragraph_id: wrapper_paragraph_id,
401 paragraph_id_from_wrapper,
402 },
403 ));
404 } else if kind == NamespaceKind::Wordprocessing
405 && element.local_name().as_ref() == b"p"
406 && let Some((comment_depth, comment)) = current.as_mut()
407 && depth == comment_depth.saturating_add(1)
408 {
409 if !comment.paragraph_id_from_wrapper {
412 comment.paragraph_id = comment_paragraph_id(&reader, &element)?;
413 }
414 }
415 depth = depth.checked_add(1).ok_or(invalid)?;
416 }
417 Ok((_, Event::DocType(_))) | Err(_) => return Err(invalid),
418 Ok((namespace, Event::End(element))) => {
419 depth = depth.checked_sub(1).ok_or(invalid)?;
420 if namespace_kind(&namespace) == NamespaceKind::Wordprocessing
421 && element.local_name().as_ref() == b"comment"
422 {
423 let (comment_depth, comment) = current.take().ok_or(invalid)?;
424 if depth != comment_depth {
425 return Err(invalid);
426 }
427 if let Some(paragraph_id) = &comment.paragraph_id
428 && !paragraph_ids.insert(paragraph_id.clone())
429 {
430 return Err(invalid);
431 }
432 output.push(comment);
433 }
434 }
435 Ok(_) => {}
436 }
437 }
438}
439
440fn parse_comment_extensions(
441 xml: &[u8],
442 maximum_facts: usize,
443) -> Result<HashMap<String, CommentExtension>, ReviewFactUnknownReason> {
444 let invalid = ReviewFactUnknownReason::InvalidCommentsExtended;
445 let mut reader = NsReader::from_reader(xml);
446 reader.config_mut().expand_empty_elements = true;
447 reader.config_mut().check_end_names = true;
448 let mut output = HashMap::new();
449 let mut depth = 0_usize;
450 let mut root_seen = false;
451 loop {
452 match reader.read_resolved_event() {
453 Ok((_, Event::Eof)) => {
454 if depth != 0 || !root_seen {
455 return Err(invalid);
456 }
457 return Ok(output);
458 }
459 Ok((namespace, Event::Start(element))) => {
460 let kind = namespace_kind(&namespace);
461 if depth == 0 {
462 if root_seen
463 || kind != NamespaceKind::Wordprocessing2012
464 || element.local_name().as_ref() != b"commentsEx"
465 {
466 return Err(invalid);
467 }
468 root_seen = true;
469 } else if depth == 1
470 && kind == NamespaceKind::Wordprocessing2012
471 && element.local_name().as_ref() == b"commentEx"
472 {
473 if output.len() >= maximum_facts {
474 return Err(ReviewFactUnknownReason::ResourceLimit);
475 }
476 let paragraph_id = comment_extension_attribute(&reader, &element, b"paraId")?
477 .ok_or(invalid)?
478 .to_ascii_uppercase();
479 let parent_paragraph_id =
480 comment_extension_attribute(&reader, &element, b"paraIdParent")?
481 .map(|value| value.to_ascii_uppercase());
482 let resolved = comment_extension_attribute(&reader, &element, b"done")?
483 .map(|value| parse_on_off(&value))
484 .transpose()?
485 .unwrap_or(false);
486 if output
487 .insert(
488 paragraph_id,
489 CommentExtension {
490 parent_paragraph_id,
491 resolved,
492 },
493 )
494 .is_some()
495 {
496 return Err(invalid);
497 }
498 }
499 depth = depth.checked_add(1).ok_or(invalid)?;
500 }
501 Ok((_, Event::End(_))) => depth = depth.checked_sub(1).ok_or(invalid)?,
502 Ok((_, Event::DocType(_))) | Err(_) => return Err(invalid),
503 Ok(_) => {}
504 }
505 }
506}
507
508fn parse_on_off(value: &str) -> Result<bool, ReviewFactUnknownReason> {
509 match value.to_ascii_lowercase().as_str() {
510 "1" | "true" | "on" => Ok(true),
511 "0" | "false" | "off" => Ok(false),
512 _ => Err(ReviewFactUnknownReason::InvalidCommentsExtended),
513 }
514}
515
516fn contains_parent_cycle(comments: &[AttributedComment]) -> bool {
517 let parents = comments
518 .iter()
519 .map(|comment| {
520 (
521 comment.comment_id.as_str(),
522 comment.parent_comment_id.as_deref(),
523 )
524 })
525 .collect::<HashMap<_, _>>();
526 let mut complete = HashSet::new();
527 for comment_id in parents.keys() {
528 let mut path = Vec::new();
529 let mut visiting = HashSet::new();
530 let mut current = Some(*comment_id);
531 while let Some(id) = current {
532 if complete.contains(id) {
533 break;
534 }
535 if !visiting.insert(id) {
536 return true;
537 }
538 path.push(id);
539 current = parents.get(id).copied().flatten();
540 }
541 complete.extend(path);
542 }
543 false
544}