Skip to main content

stella_docx_kernel/projection/
review.rs

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(&paragraph.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)] // One event loop keeps comment shape and depth validation atomic.
483fn 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                    // Exporters may put the join key on the wrapper. Otherwise
577                    // Word keys commentsExtended by the last direct-child paragraph.
578                    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}