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 §ion.nodes,
457 §ion.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 §ion.nodes,
475 §ion.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) = ¬e.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}