Skip to main content

rust_docx/
review.rs

1use crate::xml::{
2    direct_child_tag, extract_direct_child_tag_ranges, get_attribute_ref, tag_token_end,
3};
4use crate::{
5    model::*, revisions::RevisionCollector, BlockType, DocumentBlock, DocumentRevision, SourceRef,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet, HashSet};
9
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct CommentTarget {
13    #[serde(flatten)]
14    pub source: SourceRef,
15    pub child_start: usize,
16    pub child_end: usize,
17}
18
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct DocumentComment {
22    pub id: i64,
23    pub author: Option<String>,
24    pub initials: Option<String>,
25    pub date: Option<String>,
26    pub text: String,
27    pub parent_id: Option<i64>,
28    pub resolved: Option<bool>,
29    pub block_ids: Vec<String>,
30    pub targets: Vec<CommentTarget>,
31    pub reference: Option<SourceRef>,
32}
33
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct DocumentReview {
37    pub schema_version: u32,
38    pub revisions: Vec<DocumentRevision>,
39    pub comments: Vec<DocumentComment>,
40}
41
42pub fn extract_document_review(model: &DocModel) -> DocumentReview {
43    collect(model, true)
44}
45
46pub fn extract_document_comments(model: &DocModel) -> Vec<DocumentComment> {
47    collect(model, false).comments
48}
49
50fn source(model: &DocModel, part: &str, story: &str, id: String) -> SourceRef {
51    SourceRef {
52        document_id: model
53            .metadata
54            .document_id
55            .clone()
56            .unwrap_or_else(|| "unsaved".into()),
57        node_id: id,
58        part_name: part.into(),
59        story: story.into(),
60        start: None,
61        end: None,
62        offset_encoding: "utf-16".into(),
63    }
64}
65
66fn node_id(node: &DocNode) -> Option<&str> {
67    match node {
68        DocNode::Paragraph(p) => p.block_id.as_deref(),
69        DocNode::Table(t) => t.block_id.as_deref(),
70    }
71}
72
73fn block_ids(nodes: &[DocNode], ids: &mut Vec<String>) {
74    for node in nodes {
75        if let Some(id) = node_id(node) {
76            ids.push(id.to_owned());
77        }
78        match node {
79            DocNode::Paragraph(p) => {
80                for child in &p.children {
81                    if let ParagraphChildNode::Image(image) = child {
82                        if let Some(id) = &image.block_id {
83                            ids.push(id.clone());
84                        }
85                        if let Some(nodes) = &image.content_nodes {
86                            block_ids(nodes, ids);
87                        }
88                    }
89                }
90            }
91            DocNode::Table(t) => {
92                for row in &t.rows {
93                    for cell in &row.cells {
94                        for n in &cell.nodes {
95                            match n {
96                                TableCellContentNode::Paragraph(p) => {
97                                    block_ids(&[DocNode::Paragraph(p.clone())], ids)
98                                }
99                                TableCellContentNode::Table(t) => {
100                                    block_ids(&[DocNode::Table(*t.clone())], ids)
101                                }
102                            }
103                        }
104                    }
105                }
106            }
107        }
108    }
109}
110
111struct Collector<'a> {
112    model: &'a DocModel,
113    revisions: Option<RevisionCollector>,
114    comments: BTreeMap<i64, DocumentComment>,
115}
116
117impl Collector<'_> {
118    fn story(&mut self, nodes: &[DocNode], part: &str, kind: &str, root: &str) {
119        let moves = self
120            .revisions
121            .as_mut()
122            .map(|r| std::mem::take(&mut r.moves));
123        self.nodes(nodes, part, kind, root, &mut BTreeSet::new());
124        if let (Some(r), Some(moves)) = (&mut self.revisions, moves) {
125            r.moves = moves;
126        }
127    }
128
129    fn nodes(
130        &mut self,
131        nodes: &[DocNode],
132        part: &str,
133        kind: &str,
134        root: &str,
135        active: &mut BTreeSet<i64>,
136    ) {
137        for (index, node) in nodes.iter().enumerate() {
138            let id = node_id(node)
139                .map(str::to_owned)
140                .unwrap_or_else(|| format!("{root}/{index}"));
141            let source = source(self.model, part, kind, id);
142            match node {
143                DocNode::Paragraph(p) => self.paragraph(p, &source, active),
144                DocNode::Table(t) => self.table(t, &source, active),
145            }
146        }
147    }
148
149    fn table(&mut self, table: &TableNode, source: &SourceRef, active: &mut BTreeSet<i64>) {
150        if let (Some(r), Some(xml)) = (&mut self.revisions, &table.source_xml) {
151            r.table(xml, source, table);
152        }
153        for (ri, row) in table.rows.iter().enumerate() {
154            for (ci, cell) in row.cells.iter().enumerate() {
155                for (ni, node) in cell.nodes.iter().enumerate() {
156                    let fallback = format!("{}/row/{ri}/cell/{ci}/{ni}", source.node_id);
157                    match node {
158                        TableCellContentNode::Paragraph(p) => self.paragraph(
159                            p,
160                            &SourceRef {
161                                node_id: p.block_id.clone().unwrap_or(fallback),
162                                ..source.clone()
163                            },
164                            active,
165                        ),
166                        TableCellContentNode::Table(t) => self.table(
167                            t,
168                            &SourceRef {
169                                node_id: t.block_id.clone().unwrap_or(fallback),
170                                ..source.clone()
171                            },
172                            active,
173                        ),
174                    }
175                }
176            }
177        }
178    }
179
180    fn paragraph(&mut self, p: &ParagraphNode, source: &SourceRef, active: &mut BTreeSet<i64>) {
181        if let Some(xml) = &p.source_xml {
182            if let Some(r) = &mut self.revisions {
183                let content = p
184                    .children
185                    .iter()
186                    .map(|c| match c {
187                        ParagraphChildNode::Text(t) => t.text.as_str(),
188                        ParagraphChildNode::FormField(f) => f.value.as_deref().unwrap_or(""),
189                        ParagraphChildNode::Image(_) => "",
190                    })
191                    .collect();
192                let block = DocumentBlock {
193                    object: "block".into(),
194                    id: source.node_id.clone(),
195                    r#type: BlockType::Text,
196                    content,
197                    details: serde_json::Value::Null.into(),
198                    source_refs: vec![source.clone()],
199                    parent_block_id: None,
200                    children: vec![],
201                    words: None,
202                };
203                r.paragraph(xml, &block, p);
204            }
205            if !self.comments.is_empty() {
206                CommentWalk {
207                    collector: self,
208                    paragraph: p,
209                    source,
210                    active,
211                    offset: 0,
212                    child: 0,
213                }
214                .visit(xml);
215            }
216        }
217        if p.source_xml.is_none() && !active.is_empty() {
218            let mut offset = 0;
219            for (index, child) in p.children.iter().enumerate() {
220                let length = match child {
221                    ParagraphChildNode::Text(t) => t.text.encode_utf16().count(),
222                    ParagraphChildNode::FormField(f) => {
223                        f.value.as_deref().unwrap_or("").encode_utf16().count()
224                    }
225                    _ => 0,
226                };
227                let target = match child {
228                    ParagraphChildNode::Image(image) => SourceRef {
229                        node_id: image
230                            .block_id
231                            .clone()
232                            .unwrap_or_else(|| format!("{}/child/{index}", source.node_id)),
233                        ..source.clone()
234                    },
235                    _ => SourceRef {
236                        start: Some(offset),
237                        end: Some(offset + length),
238                        ..source.clone()
239                    },
240                };
241                for id in active.iter() {
242                    self.comments
243                        .get_mut(id)
244                        .unwrap()
245                        .targets
246                        .push(CommentTarget {
247                            source: target.clone(),
248                            child_start: index,
249                            child_end: index + 1,
250                        });
251                }
252                offset += length;
253            }
254        }
255        for (index, child) in p.children.iter().enumerate() {
256            if let ParagraphChildNode::Image(image) = child {
257                if let Some(nodes) = &image.content_nodes {
258                    let root = image
259                        .block_id
260                        .clone()
261                        .unwrap_or_else(|| format!("{}/child/{index}", source.node_id));
262                    self.story(
263                        nodes,
264                        &source.part_name,
265                        "textbox",
266                        &format!("{root}/content"),
267                    );
268                }
269            }
270        }
271    }
272}
273
274struct CommentWalk<'a, 'b> {
275    collector: &'a mut Collector<'b>,
276    paragraph: &'a ParagraphNode,
277    source: &'a SourceRef,
278    active: &'a mut BTreeSet<i64>,
279    offset: usize,
280    child: usize,
281}
282
283impl CommentWalk<'_, '_> {
284    fn visit(&mut self, xml: &str) {
285        for range in extract_direct_child_tag_ranges(xml) {
286            let child = &xml[range.start..range.end];
287            match range.tag_name.as_str() {
288                "w:commentRangeStart" | "w:commentRangeEnd" | "w:commentReference" => {
289                    let id = tag_token_end(child, 0)
290                        .and_then(|end| get_attribute_ref(&child[..end], "w:id"))
291                        .and_then(|s| s.parse::<i64>().ok());
292                    if let Some(id) = id.filter(|id| self.collector.comments.contains_key(id)) {
293                        match range.tag_name.as_str() {
294                            "w:commentRangeStart" => {
295                                self.active.insert(id);
296                            }
297                            "w:commentRangeEnd" => {
298                                self.active.remove(&id);
299                            }
300                            _ => {
301                                self.collector.comments.get_mut(&id).unwrap().reference =
302                                    Some(SourceRef {
303                                        start: Some(self.offset),
304                                        end: Some(self.offset),
305                                        ..self.source.clone()
306                                    });
307                            }
308                        }
309                    }
310                }
311                "w:r" => {
312                    self.visit(child);
313                    let active = crate::parse::run_text_xml(child);
314                    let tokens = crate::parse::parse_run_text_tokens(&active);
315                    let count = tokens
316                        .iter()
317                        .filter(|t| !t.text.is_empty() || t.note_reference.is_some())
318                        .count()
319                        + crate::xml::extract_balanced_tag_slices(&active, "w:drawing").len()
320                        + crate::xml::extract_balanced_tag_slices(&active, "w:pict").len();
321                    for _ in 0..count {
322                        let Some(node) = self.paragraph.children.get(self.child) else {
323                            break;
324                        };
325                        let length = match node {
326                            ParagraphChildNode::Text(t) => t.text.encode_utf16().count(),
327                            ParagraphChildNode::FormField(f) => {
328                                f.value.as_deref().unwrap_or("").encode_utf16().count()
329                            }
330                            _ => 0,
331                        };
332                        let target_source = if let ParagraphChildNode::Image(image) = node {
333                            SourceRef {
334                                node_id: image.block_id.clone().unwrap_or_else(|| {
335                                    format!("{}/child/{}", self.source.node_id, self.child)
336                                }),
337                                ..self.source.clone()
338                            }
339                        } else {
340                            SourceRef {
341                                start: Some(self.offset),
342                                end: Some(self.offset + length),
343                                ..self.source.clone()
344                            }
345                        };
346                        for id in self.active.iter() {
347                            let comment = self.collector.comments.get_mut(id).unwrap();
348                            if let Some(last) = comment.targets.last_mut().filter(|last| {
349                                target_source.start.is_some()
350                                    && last.source.node_id == target_source.node_id
351                                    && last.source.end == target_source.start
352                                    && last.child_end == self.child
353                            }) {
354                                last.source.end = target_source.end;
355                                last.child_end = self.child + 1;
356                            } else {
357                                comment.targets.push(CommentTarget {
358                                    source: target_source.clone(),
359                                    child_start: self.child,
360                                    child_end: self.child + 1,
361                                });
362                            }
363                        }
364                        self.offset += length;
365                        self.child += 1;
366                    }
367                }
368                "w:ins" | "w:moveTo" | "w:hyperlink" | "w:sdt" | "w:sdtContent" | "w:smartTag"
369                | "w:customXml" | "w:fldSimple" | "w:dir" | "w:bdo" => self.visit(child),
370                "mc:AlternateContent" => {
371                    if let Some(xml) = direct_child_tag(child, "mc:Choice")
372                        .or_else(|| direct_child_tag(child, "mc:Fallback"))
373                    {
374                        self.visit(xml);
375                    }
376                }
377                _ => {}
378            }
379        }
380    }
381}
382
383fn collect(model: &DocModel, revisions: bool) -> DocumentReview {
384    let comments = model
385        .metadata
386        .comments
387        .iter()
388        .flatten()
389        .map(|c| {
390            let mut ids = vec![];
391            if let Some(nodes) = &c.nodes {
392                block_ids(nodes, &mut ids);
393            }
394            (
395                c.id,
396                DocumentComment {
397                    id: c.id,
398                    author: c.author.clone(),
399                    initials: c.initials.clone(),
400                    date: c.date.clone(),
401                    text: c.text.clone(),
402                    parent_id: c.parent_id,
403                    resolved: c.resolved,
404                    block_ids: ids,
405                    targets: vec![],
406                    reference: None,
407                },
408            )
409        })
410        .collect();
411    let mut collector = Collector {
412        model,
413        revisions: revisions.then(RevisionCollector::default),
414        comments,
415    };
416    let part = |name: &str| {
417        model
418            .metadata
419            .source_part_names
420            .as_ref()
421            .and_then(|parts| parts.get(name))
422            .cloned()
423            .unwrap_or_else(|| format!("word/{name}.xml"))
424    };
425    let root = format!(
426        "docx:{}",
427        model.metadata.document_id.as_deref().unwrap_or("unsaved")
428    );
429    let main = part("document");
430    collector.story(&model.nodes, &main, "body", &format!("{root}/{main}/body"));
431    if let (Some(r), Some(section), Some(last)) = (
432        &mut collector.revisions,
433        &model.metadata.section_properties_xml,
434        model.nodes.last(),
435    ) {
436        let in_paragraph = matches!(last, DocNode::Paragraph(p) if p.source_xml.as_deref().is_some_and(|xml| xml.contains(section)));
437        if !in_paragraph {
438            let id = node_id(last)
439                .map(str::to_owned)
440                .unwrap_or_else(|| format!("{root}/{main}/body/{}", model.nodes.len() - 1));
441            r.final_section(section, &source(model, &main, "body", id));
442        }
443    }
444    let mut seen = HashSet::new();
445    for (kind, sections) in [("header", &model.metadata.header_sections)] {
446        for section in sections.iter().chain(
447            model
448                .metadata
449                .sections
450                .iter()
451                .flatten()
452                .flat_map(|s| s.header_sections.iter()),
453        ) {
454            if seen.insert(section.part_name.clone()) {
455                collector.story(
456                    &section.nodes,
457                    &section.part_name,
458                    kind,
459                    &format!("{root}/{}", section.part_name),
460                );
461            }
462        }
463    }
464    for section in model.metadata.footer_sections.iter().chain(
465        model
466            .metadata
467            .sections
468            .iter()
469            .flatten()
470            .flat_map(|s| s.footer_sections.iter()),
471    ) {
472        if seen.insert(section.part_name.clone()) {
473            collector.story(
474                &section.nodes,
475                &section.part_name,
476                "footer",
477                &format!("{root}/{}", section.part_name),
478            );
479        }
480    }
481    for (kind, notes) in [
482        ("footnote", &model.metadata.footnotes),
483        ("endnote", &model.metadata.endnotes),
484    ] {
485        let part = part(&format!("{kind}s"));
486        for note in notes.iter().flatten() {
487            if let Some(nodes) = &note.nodes {
488                collector.story(
489                    nodes,
490                    &part,
491                    kind,
492                    &format!("{root}/{part}/{kind}/{}", note.id),
493                );
494            }
495        }
496    }
497    let part = part("comments");
498    for comment in model.metadata.comments.iter().flatten() {
499        if let Some(nodes) = &comment.nodes {
500            collector.story(
501                nodes,
502                &part,
503                "comment",
504                &format!("{root}/{part}/comment/{}", comment.id),
505            );
506        }
507    }
508    for c in collector.comments.values_mut() {
509        if c.targets.is_empty() {
510            if let Some(source) = &c.reference {
511                c.targets.push(CommentTarget {
512                    source: source.clone(),
513                    child_start: 0,
514                    child_end: 0,
515                });
516            }
517        }
518    }
519    let inherited: Vec<_> = collector
520        .comments
521        .values()
522        .filter_map(|c| {
523            if !c.targets.is_empty() {
524                return None;
525            }
526            let parent = collector.comments.get(&c.parent_id?)?;
527            Some((c.id, parent.targets.clone(), parent.reference.clone()))
528        })
529        .collect();
530    for (id, targets, reference) in inherited {
531        let c = collector.comments.get_mut(&id).unwrap();
532        c.targets = targets;
533        c.reference = reference;
534    }
535    DocumentReview {
536        schema_version: 1,
537        revisions: collector.revisions.map(|r| r.records).unwrap_or_default(),
538        comments: collector.comments.into_values().collect(),
539    }
540}