1use std::collections::{HashMap, HashSet};
2
3use quick_xml::{
4 XmlVersion,
5 escape::resolve_predefined_entity,
6 events::{BytesStart, Event},
7 name::{Namespace, ResolveResult},
8 reader::NsReader,
9};
10
11use super::{DocumentProjection, TextMaterialization};
12use crate::projection::ooxml::TextControl;
13
14const WORDPROCESSING_TRANSITIONAL: &[u8] =
15 b"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
16const WORDPROCESSING_STRICT: &[u8] = b"http://purl.oclc.org/ooxml/wordprocessingml/main";
17const WORDPROCESSING_2010: &[u8] = b"http://schemas.microsoft.com/office/word/2010/wordml";
18const WORDPROCESSING_2012: &[u8] = b"http://schemas.microsoft.com/office/word/2012/wordml";
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct ReviewFactLimits {
22 pub maximum_comments_xml_bytes: usize,
23 pub maximum_comments_extended_xml_bytes: usize,
24 pub maximum_facts_per_family: usize,
25 pub maximum_review_detail_bytes: usize,
26}
27
28impl Default for ReviewFactLimits {
29 fn default() -> Self {
30 Self {
31 maximum_comments_xml_bytes: 16 * 1024 * 1024,
32 maximum_comments_extended_xml_bytes: 16 * 1024 * 1024,
33 maximum_facts_per_family: 1_000_000,
34 maximum_review_detail_bytes: 32 * 1024 * 1024,
35 }
36 }
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum ReviewFactUnknownReason {
41 InvalidDocument,
42 InvalidComments,
43 InvalidCommentsExtended,
44 ResourceLimit,
45 UnsupportedLocation,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum ReviewFactSet<T> {
50 Known(Vec<T>),
51 Unknown(ReviewFactUnknownReason),
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub enum ReviewDetail<T> {
56 Known(T),
57 Unknown(ReviewFactUnknownReason),
58}
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct ReviewPoint {
62 pub paragraph_ordinal: usize,
63 pub utf8: u32,
64 pub utf16: u32,
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub struct ReviewSpan {
69 pub start: ReviewPoint,
70 pub end: ReviewPoint,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct RevisionContent {
75 pub span: ReviewSpan,
76 pub text: String,
77 pub formatting_only: bool,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct CommentContent {
82 pub anchor: ReviewSpan,
83 pub comment_text: String,
84 pub referenced_text: String,
85}
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum RevisionFactKind {
89 Insertion,
90 Deletion,
91 MoveFrom,
92 MoveTo,
93 CellInsertion,
94 CellDeletion,
95 CellMerge,
96 ParagraphPropertiesChange,
97 RunPropertiesChange,
98 SectionPropertiesChange,
99 TablePropertiesChange,
100 TableRowPropertiesChange,
101 TableCellPropertiesChange,
102 TableGridChange,
103 CustomXmlDeletionRangeStart,
104 CustomXmlDeletionRangeEnd,
105 CustomXmlInsertionRangeStart,
106 CustomXmlInsertionRangeEnd,
107 CustomXmlMoveFromRangeStart,
108 CustomXmlMoveFromRangeEnd,
109 CustomXmlMoveToRangeStart,
110 CustomXmlMoveToRangeEnd,
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct AttributedRevision {
115 pub kind: RevisionFactKind,
116 pub author: String,
117 pub date: Option<String>,
118 pub revision_id: Option<String>,
119 pub content: ReviewDetail<RevisionContent>,
120}
121
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct AttributedComment {
124 pub comment_id: String,
125 pub author: String,
126 pub initials: Option<String>,
127 pub date: Option<String>,
128 pub parent_comment_id: Option<String>,
129 pub resolved: bool,
130 pub content: ReviewDetail<CommentContent>,
131}
132
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub struct DocumentReviewFacts {
135 pub revisions: ReviewFactSet<AttributedRevision>,
136 pub comments: ReviewFactSet<AttributedComment>,
137}
138
139#[derive(Clone)]
140struct CommentRow {
141 comment_id: String,
142 author: String,
143 initials: Option<String>,
144 date: Option<String>,
145 paragraph_id: Option<String>,
146 paragraph_id_from_wrapper: bool,
147 content: String,
148 paragraph_seen: bool,
149}
150
151#[derive(Clone)]
152struct CommentExtension {
153 parent_paragraph_id: Option<String>,
154 resolved: bool,
155}
156
157#[derive(Clone, Copy, Eq, PartialEq)]
158enum NamespaceKind {
159 Other,
160 Wordprocessing,
161 Wordprocessing2010,
162 Wordprocessing2012,
163}
164
165pub(super) fn project_review_facts(
166 revisions: ReviewFactSet<AttributedRevision>,
167 comment_anchors: Option<&HashMap<String, ReviewSpan>>,
168 document: &DocumentProjection,
169 comments_xml: Result<Option<Vec<u8>>, ReviewFactUnknownReason>,
170 comments_extended_xml: Result<Option<Vec<u8>>, ReviewFactUnknownReason>,
171 limits: ReviewFactLimits,
172 text_materialization: TextMaterialization,
173) -> DocumentReviewFacts {
174 let (revisions, remaining_detail_bytes) =
175 bound_revision_details(revisions, limits.maximum_review_detail_bytes);
176 let comments = match comments_xml {
177 Ok(None) => ReviewFactSet::Known(Vec::new()),
178 Ok(Some(comments_xml)) => match comments_extended_xml {
179 Ok(comments_extended_xml) => parse_comments(
180 &comments_xml,
181 comments_extended_xml.as_deref(),
182 comment_anchors,
183 document,
184 limits.maximum_facts_per_family,
185 remaining_detail_bytes,
186 text_materialization,
187 )
188 .map_or_else(ReviewFactSet::Unknown, ReviewFactSet::Known),
189 Err(reason) => ReviewFactSet::Unknown(reason),
190 },
191 Err(reason) => ReviewFactSet::Unknown(reason),
192 };
193 DocumentReviewFacts {
194 revisions,
195 comments,
196 }
197}
198
199fn bound_revision_details(
200 revisions: ReviewFactSet<AttributedRevision>,
201 maximum_detail_bytes: usize,
202) -> (ReviewFactSet<AttributedRevision>, usize) {
203 let ReviewFactSet::Known(facts) = &revisions else {
204 return (revisions, maximum_detail_bytes);
205 };
206 let detail_bytes = facts.iter().try_fold(0_usize, |total, fact| {
207 let bytes = match &fact.content {
208 ReviewDetail::Known(content) => content.text.len(),
209 ReviewDetail::Unknown(_) => 0,
210 };
211 total.checked_add(bytes)
212 });
213 let Some(remaining) = detail_bytes.and_then(|bytes| maximum_detail_bytes.checked_sub(bytes))
214 else {
215 return (
216 ReviewFactSet::Unknown(ReviewFactUnknownReason::ResourceLimit),
217 maximum_detail_bytes,
218 );
219 };
220 (revisions, remaining)
221}
222
223fn namespace_kind(namespace: &ResolveResult<'_>) -> NamespaceKind {
224 let ResolveResult::Bound(Namespace(value)) = namespace else {
225 return NamespaceKind::Other;
226 };
227 match *value {
228 WORDPROCESSING_TRANSITIONAL | WORDPROCESSING_STRICT => NamespaceKind::Wordprocessing,
229 WORDPROCESSING_2010 => NamespaceKind::Wordprocessing2010,
230 WORDPROCESSING_2012 => NamespaceKind::Wordprocessing2012,
231 _ => NamespaceKind::Other,
232 }
233}
234
235fn attribute(
236 reader: &NsReader<&[u8]>,
237 element: &BytesStart<'_>,
238 namespace: NamespaceKind,
239 local_name: &[u8],
240) -> Result<Option<String>, ReviewFactUnknownReason> {
241 for item in element.attributes() {
242 let item = item.map_err(|_| ReviewFactUnknownReason::InvalidDocument)?;
243 let (resolved, name) = reader.resolver().resolve_attribute(item.key);
244 if namespace_kind(&resolved) == namespace && name.as_ref() == local_name {
245 return item
246 .decoded_and_normalized_value(XmlVersion::default(), reader.decoder())
247 .map(|value| Some(value.into_owned()))
248 .map_err(|_| ReviewFactUnknownReason::InvalidDocument);
249 }
250 }
251 Ok(None)
252}
253
254fn comment_attribute(
255 reader: &NsReader<&[u8]>,
256 element: &BytesStart<'_>,
257 namespace: NamespaceKind,
258 local_name: &[u8],
259) -> Result<Option<String>, ReviewFactUnknownReason> {
260 attribute(reader, element, namespace, local_name)
261 .map_err(|_| ReviewFactUnknownReason::InvalidComments)
262}
263
264fn comment_extension_attribute(
265 reader: &NsReader<&[u8]>,
266 element: &BytesStart<'_>,
267 local_name: &[u8],
268) -> Result<Option<String>, ReviewFactUnknownReason> {
269 attribute(
270 reader,
271 element,
272 NamespaceKind::Wordprocessing2012,
273 local_name,
274 )
275 .map_err(|_| ReviewFactUnknownReason::InvalidCommentsExtended)
276}
277
278fn comment_paragraph_id(
279 reader: &NsReader<&[u8]>,
280 element: &BytesStart<'_>,
281) -> Result<Option<String>, ReviewFactUnknownReason> {
282 for namespace in [
283 NamespaceKind::Wordprocessing2010,
284 NamespaceKind::Wordprocessing2012,
285 NamespaceKind::Wordprocessing,
286 ] {
287 if let Some(value) = comment_attribute(reader, element, namespace, b"paraId")? {
288 return Ok(Some(value.to_ascii_uppercase()));
289 }
290 }
291 Ok(None)
292}
293
294fn parse_comments(
295 comments_xml: &[u8],
296 comments_extended_xml: Option<&[u8]>,
297 comment_anchors: Option<&HashMap<String, ReviewSpan>>,
298 document: &DocumentProjection,
299 maximum_facts: usize,
300 maximum_detail_bytes: usize,
301 text_materialization: TextMaterialization,
302) -> Result<Vec<AttributedComment>, ReviewFactUnknownReason> {
303 let comments = parse_comment_rows(comments_xml, maximum_facts, text_materialization)?;
304 let mut detail_budget = ReviewDetailBudget::new(maximum_detail_bytes);
305 let Some(comments_extended_xml) = comments_extended_xml else {
306 let mut output = Vec::with_capacity(comments.len());
307 for comment in comments {
308 let content = comment_content(&comment, comment_anchors, document, &mut detail_budget)?;
309 output.push(attributed_comment(comment, None, false, content));
310 }
311 return Ok(output);
312 };
313 let mut extensions = parse_comment_extensions(comments_extended_xml, maximum_facts)?;
314 let comment_ids_by_paragraph = comments
315 .iter()
316 .filter_map(|comment| {
317 comment
318 .paragraph_id
319 .as_ref()
320 .map(|paragraph_id| (paragraph_id.clone(), comment.comment_id.clone()))
321 })
322 .collect::<HashMap<_, _>>();
323 let comments_with_paragraph_ids = comments
324 .iter()
325 .filter(|comment| comment.paragraph_id.is_some())
326 .count();
327 if comment_ids_by_paragraph.len() != comments_with_paragraph_ids {
328 return Err(ReviewFactUnknownReason::InvalidCommentsExtended);
329 }
330 let mut output = Vec::with_capacity(comments.len());
331 for comment in comments {
332 let content = comment_content(&comment, comment_anchors, document, &mut detail_budget)?;
333 let extension = comment
334 .paragraph_id
335 .as_ref()
336 .and_then(|paragraph_id| extensions.remove(paragraph_id));
337 let (parent_comment_id, resolved) = extension.map_or(Ok((None, false)), |extension| {
338 let parent = extension
339 .parent_paragraph_id
340 .as_ref()
341 .map(|parent| {
342 comment_ids_by_paragraph
343 .get(parent)
344 .cloned()
345 .ok_or(ReviewFactUnknownReason::InvalidCommentsExtended)
346 })
347 .transpose()?;
348 Ok((parent, extension.resolved))
349 })?;
350 output.push(attributed_comment(
351 comment,
352 parent_comment_id,
353 resolved,
354 content,
355 ));
356 }
357 if !extensions.is_empty() || contains_parent_cycle(&output) {
358 return Err(ReviewFactUnknownReason::InvalidCommentsExtended);
359 }
360 Ok(output)
361}
362
363fn attributed_comment(
364 comment: CommentRow,
365 parent_comment_id: Option<String>,
366 resolved: bool,
367 content: ReviewDetail<CommentContent>,
368) -> AttributedComment {
369 AttributedComment {
370 comment_id: comment.comment_id,
371 author: comment.author,
372 initials: comment.initials,
373 date: comment.date,
374 parent_comment_id,
375 resolved,
376 content,
377 }
378}
379
380fn comment_content(
381 comment: &CommentRow,
382 anchors: Option<&HashMap<String, ReviewSpan>>,
383 document: &DocumentProjection,
384 detail_budget: &mut ReviewDetailBudget,
385) -> Result<ReviewDetail<CommentContent>, ReviewFactUnknownReason> {
386 let Some(anchor) = anchors
387 .and_then(|anchors| anchors.get(&comment.comment_id))
388 .copied()
389 else {
390 return Ok(ReviewDetail::Unknown(
391 ReviewFactUnknownReason::UnsupportedLocation,
392 ));
393 };
394 let Some(referenced_text_bytes) = text_for_span_bytes(document, anchor) else {
395 return Ok(ReviewDetail::Unknown(
396 ReviewFactUnknownReason::UnsupportedLocation,
397 ));
398 };
399 let detail_bytes = comment
400 .content
401 .len()
402 .checked_add(referenced_text_bytes)
403 .ok_or(ReviewFactUnknownReason::ResourceLimit)?;
404 detail_budget.reserve(detail_bytes)?;
405 let Some(referenced_text) = text_for_span(document, anchor, referenced_text_bytes) else {
406 return Ok(ReviewDetail::Unknown(
407 ReviewFactUnknownReason::UnsupportedLocation,
408 ));
409 };
410 Ok(ReviewDetail::Known(CommentContent {
411 anchor,
412 comment_text: comment.content.clone(),
413 referenced_text,
414 }))
415}
416
417struct ReviewDetailBudget {
418 remaining: usize,
419}
420
421impl ReviewDetailBudget {
422 const fn new(maximum: usize) -> Self {
423 Self { remaining: maximum }
424 }
425
426 fn reserve(&mut self, bytes: usize) -> Result<(), ReviewFactUnknownReason> {
427 self.remaining = self
428 .remaining
429 .checked_sub(bytes)
430 .ok_or(ReviewFactUnknownReason::ResourceLimit)?;
431 Ok(())
432 }
433}
434
435fn text_for_span_bytes(document: &DocumentProjection, span: ReviewSpan) -> Option<usize> {
436 let start = document.paragraphs.get(span.start.paragraph_ordinal)?;
437 let end = document.paragraphs.get(span.end.paragraph_ordinal)?;
438 let start_utf8 = usize::try_from(span.start.utf8).ok()?;
439 let end_utf8 = usize::try_from(span.end.utf8).ok()?;
440 if span.start.paragraph_ordinal == span.end.paragraph_ordinal {
441 return Some(start.text.get(start_utf8..end_utf8)?.len());
442 }
443 let mut bytes = start.text.get(start_utf8..)?.len();
444 for paragraph in document
445 .paragraphs
446 .get(span.start.paragraph_ordinal.checked_add(1)?..span.end.paragraph_ordinal)?
447 {
448 bytes = bytes.checked_add(1)?.checked_add(paragraph.text.len())?;
449 }
450 bytes
451 .checked_add(1)?
452 .checked_add(end.text.get(..end_utf8)?.len())
453}
454
455fn text_for_span(
456 document: &DocumentProjection,
457 span: ReviewSpan,
458 byte_length: usize,
459) -> Option<String> {
460 let start = document.paragraphs.get(span.start.paragraph_ordinal)?;
461 let end = document.paragraphs.get(span.end.paragraph_ordinal)?;
462 let start_utf8 = usize::try_from(span.start.utf8).ok()?;
463 let end_utf8 = usize::try_from(span.end.utf8).ok()?;
464 let mut text = String::with_capacity(byte_length);
465 if span.start.paragraph_ordinal == span.end.paragraph_ordinal {
466 text.push_str(start.text.get(start_utf8..end_utf8)?);
467 return Some(text);
468 }
469 text.push_str(start.text.get(start_utf8..)?);
470 for paragraph in document
471 .paragraphs
472 .get(span.start.paragraph_ordinal.checked_add(1)?..span.end.paragraph_ordinal)?
473 {
474 text.push('\n');
475 text.push_str(¶graph.text);
476 }
477 text.push('\n');
478 text.push_str(end.text.get(..end_utf8)?);
479 Some(text)
480}
481
482#[allow(clippy::too_many_lines)] fn parse_comment_rows(
484 xml: &[u8],
485 maximum_facts: usize,
486 text_materialization: TextMaterialization,
487) -> Result<Vec<CommentRow>, ReviewFactUnknownReason> {
488 let invalid = ReviewFactUnknownReason::InvalidComments;
489 let mut reader = NsReader::from_reader(xml);
490 reader.config_mut().expand_empty_elements = true;
491 reader.config_mut().check_end_names = true;
492 let mut output = Vec::new();
493 let mut current: Option<(usize, CommentRow)> = None;
494 let mut text_depth: Option<usize> = None;
495 let mut property_depth: Option<usize> = None;
496 let mut depth = 0_usize;
497 let mut root_seen = false;
498 let mut comment_ids = HashSet::new();
499 let mut paragraph_ids = HashSet::new();
500 loop {
501 match reader.read_resolved_event() {
502 Ok((_, Event::Eof)) => {
503 if depth != 0
504 || current.is_some()
505 || text_depth.is_some()
506 || property_depth.is_some()
507 || !root_seen
508 {
509 return Err(invalid);
510 }
511 return Ok(output);
512 }
513 Ok((namespace, Event::Start(element))) => {
514 let kind = namespace_kind(&namespace);
515 if depth == 0 {
516 if root_seen
517 || kind != NamespaceKind::Wordprocessing
518 || element.local_name().as_ref() != b"comments"
519 {
520 return Err(invalid);
521 }
522 root_seen = true;
523 } else if depth == 1
524 && kind == NamespaceKind::Wordprocessing
525 && element.local_name().as_ref() == b"comment"
526 {
527 if current.is_some() || output.len() >= maximum_facts {
528 return Err(if output.len() >= maximum_facts {
529 ReviewFactUnknownReason::ResourceLimit
530 } else {
531 invalid
532 });
533 }
534 let comment_id =
535 comment_attribute(&reader, &element, NamespaceKind::Wordprocessing, b"id")?
536 .ok_or(invalid)?;
537 if !comment_ids.insert(comment_id.clone()) {
538 return Err(invalid);
539 }
540 let wrapper_paragraph_id = comment_paragraph_id(&reader, &element)?;
541 let paragraph_id_from_wrapper = wrapper_paragraph_id.is_some();
542 current = Some((
543 depth,
544 CommentRow {
545 comment_id,
546 author: comment_attribute(
547 &reader,
548 &element,
549 NamespaceKind::Wordprocessing,
550 b"author",
551 )?
552 .unwrap_or_default(),
553 initials: comment_attribute(
554 &reader,
555 &element,
556 NamespaceKind::Wordprocessing,
557 b"initials",
558 )?,
559 date: comment_attribute(
560 &reader,
561 &element,
562 NamespaceKind::Wordprocessing,
563 b"date",
564 )?,
565 paragraph_id: wrapper_paragraph_id,
566 paragraph_id_from_wrapper,
567 content: String::new(),
568 paragraph_seen: false,
569 },
570 ));
571 } else if kind == NamespaceKind::Wordprocessing
572 && element.local_name().as_ref() == b"p"
573 && let Some((comment_depth, comment)) = current.as_mut()
574 && depth == comment_depth.saturating_add(1)
575 {
576 if !comment.paragraph_id_from_wrapper {
579 comment.paragraph_id = comment_paragraph_id(&reader, &element)?;
580 }
581 if comment.paragraph_seen {
582 comment.content.push('\n');
583 }
584 comment.paragraph_seen = true;
585 } else if kind == NamespaceKind::Wordprocessing
586 && matches!(element.local_name().as_ref(), b"pPr" | b"rPr")
587 && current.is_some()
588 && property_depth.is_none()
589 {
590 property_depth = Some(depth);
591 } else if kind == NamespaceKind::Wordprocessing
592 && matches!(element.local_name().as_ref(), b"t" | b"delText")
593 && current.is_some()
594 && property_depth.is_none()
595 {
596 if text_depth.replace(depth).is_some() {
597 return Err(invalid);
598 }
599 } else if kind == NamespaceKind::Wordprocessing
600 && let Some((_, comment)) = current.as_mut()
601 && property_depth.is_none()
602 {
603 let control = match element.local_name().as_ref() {
604 b"tab" | b"ptab" => Some(TextControl::Tab),
605 b"br" => Some(
606 match comment_attribute(
607 &reader,
608 &element,
609 NamespaceKind::Wordprocessing,
610 b"type",
611 )?
612 .as_deref()
613 {
614 Some("page") => TextControl::PageBreak,
615 Some("column") => TextControl::ColumnBreak,
616 _ => TextControl::LineBreak,
617 },
618 ),
619 b"cr" => Some(TextControl::CarriageReturn),
620 b"softHyphen" => Some(TextControl::SoftHyphen),
621 b"noBreakHyphen" => Some(TextControl::NoBreakHyphen),
622 _ => None,
623 };
624 if let Some(text) =
625 control.and_then(|control| control.materialize(text_materialization))
626 {
627 comment.content.push_str(text);
628 }
629 }
630 depth = depth.checked_add(1).ok_or(invalid)?;
631 }
632 Ok((_, Event::DocType(_))) | Err(_) => return Err(invalid),
633 Ok((_, Event::Text(text))) if text_depth.is_some() => {
634 let decoded = text.xml10_content().map_err(|_| invalid)?;
635 current
636 .as_mut()
637 .ok_or(invalid)?
638 .1
639 .content
640 .push_str(&decoded);
641 }
642 Ok((_, Event::CData(text))) if text_depth.is_some() => {
643 let decoded = text.xml10_content().map_err(|_| invalid)?;
644 current
645 .as_mut()
646 .ok_or(invalid)?
647 .1
648 .content
649 .push_str(&decoded);
650 }
651 Ok((_, Event::GeneralRef(reference))) if text_depth.is_some() => {
652 let resolved_character = reference.resolve_char_ref().map_err(|_| invalid)?;
653 let resolved = if let Some(character) = resolved_character {
654 character.to_string()
655 } else {
656 let name = reference.decode().map_err(|_| invalid)?;
657 resolve_predefined_entity(&name).ok_or(invalid)?.to_owned()
658 };
659 current
660 .as_mut()
661 .ok_or(invalid)?
662 .1
663 .content
664 .push_str(&resolved);
665 }
666 Ok((namespace, Event::End(element))) => {
667 depth = depth.checked_sub(1).ok_or(invalid)?;
668 if text_depth == Some(depth) {
669 text_depth = None;
670 }
671 if property_depth == Some(depth) {
672 property_depth = None;
673 }
674 if namespace_kind(&namespace) == NamespaceKind::Wordprocessing
675 && element.local_name().as_ref() == b"comment"
676 {
677 let (comment_depth, comment) = current.take().ok_or(invalid)?;
678 if depth != comment_depth || text_depth.is_some() || property_depth.is_some() {
679 return Err(invalid);
680 }
681 if let Some(paragraph_id) = &comment.paragraph_id
682 && !paragraph_ids.insert(paragraph_id.clone())
683 {
684 return Err(invalid);
685 }
686 output.push(comment);
687 }
688 }
689 Ok(_) => {}
690 }
691 }
692}
693
694fn parse_comment_extensions(
695 xml: &[u8],
696 maximum_facts: usize,
697) -> Result<HashMap<String, CommentExtension>, ReviewFactUnknownReason> {
698 let invalid = ReviewFactUnknownReason::InvalidCommentsExtended;
699 let mut reader = NsReader::from_reader(xml);
700 reader.config_mut().expand_empty_elements = true;
701 reader.config_mut().check_end_names = true;
702 let mut output = HashMap::new();
703 let mut depth = 0_usize;
704 let mut root_seen = false;
705 loop {
706 match reader.read_resolved_event() {
707 Ok((_, Event::Eof)) => {
708 if depth != 0 || !root_seen {
709 return Err(invalid);
710 }
711 return Ok(output);
712 }
713 Ok((namespace, Event::Start(element))) => {
714 let kind = namespace_kind(&namespace);
715 if depth == 0 {
716 if root_seen
717 || kind != NamespaceKind::Wordprocessing2012
718 || element.local_name().as_ref() != b"commentsEx"
719 {
720 return Err(invalid);
721 }
722 root_seen = true;
723 } else if depth == 1
724 && kind == NamespaceKind::Wordprocessing2012
725 && element.local_name().as_ref() == b"commentEx"
726 {
727 if output.len() >= maximum_facts {
728 return Err(ReviewFactUnknownReason::ResourceLimit);
729 }
730 let paragraph_id = comment_extension_attribute(&reader, &element, b"paraId")?
731 .ok_or(invalid)?
732 .to_ascii_uppercase();
733 let parent_paragraph_id =
734 comment_extension_attribute(&reader, &element, b"paraIdParent")?
735 .map(|value| value.to_ascii_uppercase());
736 let resolved = comment_extension_attribute(&reader, &element, b"done")?
737 .map(|value| parse_on_off(&value))
738 .transpose()?
739 .unwrap_or(false);
740 if output
741 .insert(
742 paragraph_id,
743 CommentExtension {
744 parent_paragraph_id,
745 resolved,
746 },
747 )
748 .is_some()
749 {
750 return Err(invalid);
751 }
752 }
753 depth = depth.checked_add(1).ok_or(invalid)?;
754 }
755 Ok((_, Event::End(_))) => depth = depth.checked_sub(1).ok_or(invalid)?,
756 Ok((_, Event::DocType(_))) | Err(_) => return Err(invalid),
757 Ok(_) => {}
758 }
759 }
760}
761
762fn parse_on_off(value: &str) -> Result<bool, ReviewFactUnknownReason> {
763 match value.to_ascii_lowercase().as_str() {
764 "1" | "true" | "on" => Ok(true),
765 "0" | "false" | "off" => Ok(false),
766 _ => Err(ReviewFactUnknownReason::InvalidCommentsExtended),
767 }
768}
769
770fn contains_parent_cycle(comments: &[AttributedComment]) -> bool {
771 let parents = comments
772 .iter()
773 .map(|comment| {
774 (
775 comment.comment_id.as_str(),
776 comment.parent_comment_id.as_deref(),
777 )
778 })
779 .collect::<HashMap<_, _>>();
780 let mut complete = HashSet::new();
781 for comment_id in parents.keys() {
782 let mut path = Vec::new();
783 let mut visiting = HashSet::new();
784 let mut current = Some(*comment_id);
785 while let Some(id) = current {
786 if complete.contains(id) {
787 break;
788 }
789 if !visiting.insert(id) {
790 return true;
791 }
792 path.push(id);
793 current = parents.get(id).copied().flatten();
794 }
795 complete.extend(path);
796 }
797 false
798}