Skip to main content

stella_docx_kernel/projection/
review.rs

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