1use serde::{Deserialize, Serialize};
2
3use crate::relationships::{Relationship, relationship_types};
4use crate::xml::{ParseBudget, XmlElement, serialize_xml};
5use crate::{PptxError, xml::parse_xml};
6
7pub(crate) const NS_P: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
8pub(crate) const NS_P15: &str = "http://schemas.microsoft.com/office/powerpoint/2012/main";
9pub(crate) const THREADING_EXT: &str = "{C676402C-5697-4E1C-873F-D02D1690AC5C}";
10pub(crate) const NS_P188: &str = "http://schemas.microsoft.com/office/powerpoint/2018/8/main";
11pub(crate) const NS_PC: &str = "http://schemas.microsoft.com/office/powerpoint/2013/main/command";
12pub(crate) const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
13
14pub(crate) const CT_COMMENT_AUTHORS: &str =
15 "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml";
16pub(crate) const CT_COMMENTS: &str =
17 "application/vnd.openxmlformats-officedocument.presentationml.comments+xml";
18pub(crate) const CT_MODERN_AUTHORS: &str = "application/vnd.ms-powerpoint.authors+xml";
19pub(crate) const CT_MODERN_COMMENTS: &str = "application/vnd.ms-powerpoint.comments+xml";
20
21pub(crate) const LEGACY_AUTHORS_PART: &str = "ppt/commentAuthors.xml";
22pub(crate) const MODERN_AUTHORS_PART: &str = "ppt/authors.xml";
23
24pub(crate) const EMU_PER_MASTER_UNIT: f64 = 1587.5;
25
26pub(crate) fn emu_to_master(emu: i64) -> i64 {
27 (emu as f64 / EMU_PER_MASTER_UNIT).round() as i64
28}
29
30pub(crate) fn master_to_emu(master: i64) -> i64 {
31 (master as f64 * EMU_PER_MASTER_UNIT).round() as i64
32}
33
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub enum CommentFlavor {
37 #[default]
38 Legacy,
39 Modern,
40}
41
42#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct CommentAuthor {
45 pub id: String,
46 pub name: String,
47 #[serde(default)]
48 pub initials: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub last_index: Option<u32>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub color_index: Option<u32>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub user_id: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub provider_id: Option<String>,
57}
58
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct Comment {
62 pub id: String,
63 pub author_id: String,
64 pub slide_part_path: String,
65 pub text: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub created: Option<String>,
68 pub x_emu: i64,
69 pub y_emu: i64,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub parent_id: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub status: Option<String>,
74 pub flavor: CommentFlavor,
75}
76
77pub(crate) struct SlideCommentParts {
78 pub legacy: Option<String>,
79 pub modern: Option<String>,
80}
81
82pub(crate) fn slide_comment_parts(relationships: &[Relationship]) -> SlideCommentParts {
83 SlideCommentParts {
84 legacy: resolved_by_exact_type(relationships, relationship_types::COMMENTS),
85 modern: resolved_by_exact_type(relationships, relationship_types::MODERN_COMMENTS),
86 }
87}
88
89pub(crate) fn authors_part(
90 relationships: &[Relationship],
91 flavor: CommentFlavor,
92) -> Option<String> {
93 let relationship_type = match flavor {
94 CommentFlavor::Legacy => relationship_types::COMMENT_AUTHORS,
95 CommentFlavor::Modern => relationship_types::MODERN_AUTHORS,
96 };
97 resolved_by_exact_type(relationships, relationship_type)
98}
99
100fn resolved_by_exact_type(
101 relationships: &[Relationship],
102 relationship_type: &str,
103) -> Option<String> {
104 relationships
105 .iter()
106 .find(|relationship| relationship.is_type(relationship_type))
107 .and_then(|relationship| relationship.resolved_target.clone())
108}
109
110pub(crate) fn parse_comment_authors(
111 bytes: &[u8],
112 part: &str,
113 flavor: CommentFlavor,
114 budget: &mut ParseBudget<'_>,
115) -> Result<Vec<CommentAuthor>, PptxError> {
116 let root = parse_xml(bytes, part, budget)?;
117 let (list, element) = match flavor {
118 CommentFlavor::Legacy => ("cmAuthorLst", "cmAuthor"),
119 CommentFlavor::Modern => ("authorLst", "author"),
120 };
121 if root.local_name() != list {
122 return Ok(Vec::new());
123 }
124 let mut authors = Vec::new();
125 for child in root.children_named(element) {
126 budget.charge_comment(part)?;
127 authors.push(CommentAuthor {
128 id: child.attribute_local("id").unwrap_or_default().to_owned(),
129 name: child.attribute_local("name").unwrap_or_default().to_owned(),
130 initials: child
131 .attribute_local("initials")
132 .unwrap_or_default()
133 .to_owned(),
134 last_index: unsigned_attribute(child, "lastIdx"),
135 color_index: unsigned_attribute(child, "clrIdx"),
136 user_id: child.attribute_local("userId").map(str::to_owned),
137 provider_id: child.attribute_local("providerId").map(str::to_owned),
138 });
139 }
140 Ok(authors)
141}
142
143pub(crate) fn parse_comments(
144 bytes: &[u8],
145 part: &str,
146 slide_part_path: &str,
147 flavor: CommentFlavor,
148 budget: &mut ParseBudget<'_>,
149) -> Result<Vec<Comment>, PptxError> {
150 let root = parse_xml(bytes, part, budget)?;
151 if root.local_name() != "cmLst" {
152 return Ok(Vec::new());
153 }
154 let mut comments = Vec::new();
155 for child in root.children_named("cm") {
156 budget.charge_comment(part)?;
157 match flavor {
158 CommentFlavor::Legacy => comments.push(legacy_comment(child, slide_part_path)),
159 CommentFlavor::Modern => {
160 let comment = modern_comment(child, slide_part_path, None);
161 let parent_id = comment.id.clone();
162 comments.push(comment);
163 for reply in child
164 .child("replyLst")
165 .into_iter()
166 .flat_map(|list| list.children_named("reply"))
167 {
168 budget.charge_comment(part)?;
169 comments.push(modern_comment(
170 reply,
171 slide_part_path,
172 Some(parent_id.clone()),
173 ));
174 }
175 }
176 }
177 }
178 Ok(comments)
179}
180
181fn legacy_comment(element: &XmlElement, slide_part_path: &str) -> Comment {
182 let author_id = element
183 .attribute_local("authorId")
184 .unwrap_or("0")
185 .to_owned();
186 let index = element.attribute_local("idx").unwrap_or("1").to_owned();
187 let position = element.child("pos");
188 Comment {
189 id: format!("{author_id}:{index}"),
190 author_id,
191 slide_part_path: slide_part_path.to_owned(),
192 text: element
193 .child("text")
194 .map(XmlElement::text_content)
195 .unwrap_or_default(),
196 created: element.attribute_local("dt").map(str::to_owned),
197 x_emu: master_to_emu(coordinate(position, "x")),
198 y_emu: master_to_emu(coordinate(position, "y")),
199 parent_id: element
200 .child("extLst")
201 .and_then(|list| {
202 list.children_named("ext")
203 .find(|ext| ext.attribute("uri") == Some(THREADING_EXT))
204 })
205 .and_then(|ext| ext.child("threadingInfo"))
206 .and_then(|thread| thread.child("parentCm"))
207 .and_then(|parent| {
208 Some(format!(
209 "{}:{}",
210 parent.attribute("authorId")?,
211 parent.attribute("idx")?
212 ))
213 }),
214 status: None,
215 flavor: CommentFlavor::Legacy,
216 }
217}
218
219fn modern_comment(
220 element: &XmlElement,
221 slide_part_path: &str,
222 parent_id: Option<String>,
223) -> Comment {
224 let position = element.child("pos");
225 Comment {
226 id: element.attribute_local("id").unwrap_or_default().to_owned(),
227 author_id: element
228 .attribute_local("authorId")
229 .unwrap_or_default()
230 .to_owned(),
231 slide_part_path: slide_part_path.to_owned(),
232 text: element
233 .child("txBody")
234 .map(comment_text)
235 .unwrap_or_default(),
236 created: element.attribute_local("created").map(str::to_owned),
237 x_emu: coordinate(position, "x"),
238 y_emu: coordinate(position, "y"),
239 parent_id,
240 status: element.attribute_local("status").map(str::to_owned),
241 flavor: CommentFlavor::Modern,
242 }
243}
244
245fn comment_text(body: &XmlElement) -> String {
246 body.children_named("p")
247 .map(|paragraph| {
248 paragraph
249 .child_elements()
250 .filter_map(|child| match child.local_name() {
251 "r" | "fld" => child.child("t").map(XmlElement::text_content),
252 "br" => Some("\n".to_owned()),
253 _ => None,
254 })
255 .collect::<String>()
256 })
257 .collect::<Vec<_>>()
258 .join("\n")
259}
260
261fn coordinate(position: Option<&XmlElement>, name: &str) -> i64 {
262 position
263 .and_then(|element| element.attribute_local(name))
264 .and_then(|value| value.parse::<i64>().ok())
265 .unwrap_or(0)
266}
267
268fn unsigned_attribute(element: &XmlElement, name: &str) -> Option<u32> {
269 element.attribute_local(name)?.parse::<u32>().ok()
270}
271
272pub struct CommentsWrite {
273 pub flavor: CommentFlavor,
274 pub authors: Vec<CommentAuthorWrite>,
275 pub per_slide: Vec<(CommentSlide, Vec<CommentWrite>)>,
276}
277
278#[derive(Clone, Debug, PartialEq, Eq)]
279pub enum CommentSlide {
280 Existing(String),
281 Added(usize),
282}
283
284pub struct CommentAuthorWrite {
285 pub id: String,
286 pub name: String,
287 pub initials: String,
288 pub last_index: u32,
289 pub color_index: u32,
290 pub user_id: Option<String>,
291 pub provider_id: Option<String>,
292}
293
294pub struct CommentWrite {
295 pub id: String,
296 pub author_id: String,
297 pub index: u32,
298 pub text: String,
299 pub created: Option<String>,
300 pub x_emu: i64,
301 pub y_emu: i64,
302 pub status: Option<String>,
303 pub replies: Vec<CommentWrite>,
304}
305
306impl CommentsWrite {
307 pub(crate) fn authors_part_path(&self) -> &'static str {
308 match self.flavor {
309 CommentFlavor::Legacy => LEGACY_AUTHORS_PART,
310 CommentFlavor::Modern => MODERN_AUTHORS_PART,
311 }
312 }
313
314 pub(crate) fn authors_content_type(&self) -> &'static str {
315 match self.flavor {
316 CommentFlavor::Legacy => CT_COMMENT_AUTHORS,
317 CommentFlavor::Modern => CT_MODERN_AUTHORS,
318 }
319 }
320
321 pub(crate) fn authors_relationship_type(&self) -> &'static str {
322 match self.flavor {
323 CommentFlavor::Legacy => relationship_types::COMMENT_AUTHORS,
324 CommentFlavor::Modern => relationship_types::MODERN_AUTHORS,
325 }
326 }
327
328 pub(crate) fn comments_content_type(&self) -> &'static str {
329 match self.flavor {
330 CommentFlavor::Legacy => CT_COMMENTS,
331 CommentFlavor::Modern => CT_MODERN_COMMENTS,
332 }
333 }
334
335 pub(crate) fn comments_relationship_type(&self) -> &'static str {
336 match self.flavor {
337 CommentFlavor::Legacy => relationship_types::COMMENTS,
338 CommentFlavor::Modern => relationship_types::MODERN_COMMENTS,
339 }
340 }
341
342 pub(crate) fn comments_part_path(&self, slide_number: usize) -> String {
343 match self.flavor {
344 CommentFlavor::Legacy => format!("ppt/comments/comment{slide_number}.xml"),
345 CommentFlavor::Modern => format!("ppt/comments/modernComment{slide_number}.xml"),
346 }
347 }
348}
349
350pub(crate) fn authors_xml(write: &CommentsWrite) -> Vec<u8> {
351 let root = match write.flavor {
352 CommentFlavor::Legacy => {
353 let mut root = XmlElement::new("p:cmAuthorLst").with_attribute("xmlns:p", NS_P);
354 for author in &write.authors {
355 root = root.with_child(
356 XmlElement::new("p:cmAuthor")
357 .with_attribute("id", author.id.clone())
358 .with_attribute("name", author.name.clone())
359 .with_attribute("initials", author.initials.clone())
360 .with_attribute("lastIdx", author.last_index.to_string())
361 .with_attribute("clrIdx", author.color_index.to_string()),
362 );
363 }
364 root
365 }
366 CommentFlavor::Modern => {
367 let mut root = XmlElement::new("p188:authorLst").with_attribute("xmlns:p188", NS_P188);
368 for author in &write.authors {
369 root = root.with_child(
370 XmlElement::new("p188:author")
371 .with_attribute("id", author.id.clone())
372 .with_attribute("name", author.name.clone())
373 .with_attribute("initials", author.initials.clone())
374 .with_attribute("userId", author.user_id.clone().unwrap_or_default())
375 .with_attribute(
376 "providerId",
377 author.provider_id.clone().unwrap_or_default(),
378 ),
379 );
380 }
381 root
382 }
383 };
384 serialize_xml(&root)
385}
386
387pub(crate) fn comments_xml(
388 write: &CommentsWrite,
389 comments: &[CommentWrite],
390 slide_id: u32,
391) -> Vec<u8> {
392 let root = match write.flavor {
393 CommentFlavor::Legacy => {
394 let mut root = XmlElement::new("p:cmLst").with_attribute("xmlns:p", NS_P);
395 for comment in comments {
396 root = root.with_child(legacy_comment_element(comment, None));
397 for reply in &comment.replies {
398 root = root.with_child(legacy_comment_element(reply, Some(comment)));
399 }
400 }
401 root
402 }
403 CommentFlavor::Modern => {
404 let mut root = XmlElement::new("p188:cmLst")
405 .with_attribute("xmlns:a", NS_A)
406 .with_attribute("xmlns:pc", NS_PC)
407 .with_attribute("xmlns:p188", NS_P188);
408 for comment in comments {
409 root = root.with_child(modern_comment_element(comment, slide_id));
410 }
411 root
412 }
413 };
414 serialize_xml(&root)
415}
416
417pub(crate) fn legacy_comment_element(
418 comment: &CommentWrite,
419 parent: Option<&CommentWrite>,
420) -> XmlElement {
421 let mut element = XmlElement::new("p:cm")
422 .with_attribute("authorId", comment.author_id.clone())
423 .with_attribute("idx", comment.index.to_string());
424 if let Some(created) = &comment.created {
425 element.set_attribute("dt", created.clone());
426 }
427 let anchor = parent.unwrap_or(comment);
428 element = element
429 .with_child(
430 XmlElement::new("p:pos")
431 .with_attribute("x", emu_to_master(anchor.x_emu).to_string())
432 .with_attribute("y", emu_to_master(anchor.y_emu).to_string()),
433 )
434 .with_child(XmlElement::new("p:text").with_text(comment.text.clone()));
435 if let Some(parent) = parent {
436 element = element.with_child(
437 XmlElement::new("p:extLst").with_child(
438 XmlElement::new("p:ext")
439 .with_attribute("uri", THREADING_EXT)
440 .with_child(
441 XmlElement::new("p15:threadingInfo")
442 .with_attribute("xmlns:p15", NS_P15)
443 .with_attribute("timeZoneBias", "0")
444 .with_child(
445 XmlElement::new("p15:parentCm")
446 .with_attribute("authorId", parent.author_id.clone())
447 .with_attribute("idx", parent.index.to_string()),
448 ),
449 ),
450 ),
451 );
452 }
453 element
454}
455
456pub(crate) fn modern_comment_element(comment: &CommentWrite, slide_id: u32) -> XmlElement {
457 let mut element = modern_body_element("p188:cm", comment)
458 .with_child(
459 XmlElement::new("pc:sldMkLst")
460 .with_child(XmlElement::new("pc:docMk"))
461 .with_child(
462 XmlElement::new("pc:sldMk").with_attribute("sldId", slide_id.to_string()),
463 ),
464 )
465 .with_child(
466 XmlElement::new("p188:pos")
467 .with_attribute("x", comment.x_emu.to_string())
468 .with_attribute("y", comment.y_emu.to_string()),
469 );
470 if !comment.replies.is_empty() {
471 let mut replies = XmlElement::new("p188:replyLst");
472 for reply in &comment.replies {
473 replies = replies.with_child(
474 modern_body_element("p188:reply", reply).with_child(text_body(&reply.text)),
475 );
476 }
477 element = element.with_child(replies);
478 }
479 element.with_child(text_body(&comment.text))
480}
481
482pub(crate) fn modern_body_element(name: &str, comment: &CommentWrite) -> XmlElement {
483 let mut element = XmlElement::new(name)
484 .with_attribute("id", comment.id.clone())
485 .with_attribute("authorId", comment.author_id.clone());
486 if let Some(created) = &comment.created {
487 element.set_attribute("created", created.clone());
488 }
489 if let Some(status) = &comment.status {
490 element.set_attribute("status", status.clone());
491 }
492 element
493}
494
495pub(crate) fn text_body(text: &str) -> XmlElement {
496 let mut body = XmlElement::new("p188:txBody")
497 .with_child(XmlElement::new("a:bodyPr"))
498 .with_child(XmlElement::new("a:lstStyle"));
499 for paragraph in text.split('\n') {
500 body = body.with_child(
501 XmlElement::new("a:p").with_child(
502 XmlElement::new("a:r")
503 .with_child(XmlElement::new("a:t").with_text(paragraph.to_owned())),
504 ),
505 );
506 }
507 body
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::relationships::TargetMode;
514 use crate::xml::ParseLimits;
515
516 fn relationship(relationship_type: &str, target: &str) -> Relationship {
517 Relationship {
518 id: "rId9".to_owned(),
519 relationship_type: relationship_type.to_owned(),
520 target: target.to_owned(),
521 target_mode: TargetMode::Internal,
522 resolved_target: Some(target.to_owned()),
523 }
524 }
525
526 #[test]
527 fn the_modern_comment_relationship_is_not_read_as_a_legacy_one() {
528 let modern = [relationship(
529 relationship_types::MODERN_COMMENTS,
530 "ppt/comments/modernComment1.xml",
531 )];
532 let parts = slide_comment_parts(&modern);
533 assert_eq!(
534 parts.modern.as_deref(),
535 Some("ppt/comments/modernComment1.xml")
536 );
537 assert!(
538 parts.legacy.is_none(),
539 "the modern type also ends with /comments, so suffix matching would conflate them"
540 );
541
542 let legacy = [relationship(
543 relationship_types::COMMENTS,
544 "ppt/comments/comment1.xml",
545 )];
546 let parts = slide_comment_parts(&legacy);
547 assert_eq!(parts.legacy.as_deref(), Some("ppt/comments/comment1.xml"));
548 assert!(parts.modern.is_none());
549 }
550
551 #[test]
552 fn master_units_round_trip_through_emu() {
553 assert_eq!(emu_to_master(914_400), 576, "one inch");
554 assert_eq!(master_to_emu(576), 914_400);
555 assert_eq!(emu_to_master(0), 0);
556 assert_eq!(emu_to_master(2_381), 1);
557 assert_eq!(emu_to_master(-914_400), -576);
558 }
559
560 #[test]
561 fn legacy_comments_and_authors_parse() {
562 let limits = ParseLimits::default();
563 let mut budget = ParseBudget::new(&limits);
564 let authors = parse_comment_authors(
565 br#"<p:cmAuthorLst xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cmAuthor id="0" name="Mary Smith" initials="mas" lastIdx="3" clrIdx="0"/></p:cmAuthorLst>"#,
566 "ppt/commentAuthors.xml",
567 CommentFlavor::Legacy,
568 &mut budget,
569 )
570 .unwrap();
571 assert_eq!(authors.len(), 1);
572 assert_eq!(authors[0].name, "Mary Smith");
573 assert_eq!(authors[0].last_index, Some(3));
574 assert_eq!(authors[0].color_index, Some(0));
575
576 let comments = parse_comments(
577 br#"<p:cmLst xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cm authorId="0" dt="2005-11-13T17:00:22.071" idx="1"><p:pos x="576" y="288"/><p:text>Hello & welcome</p:text></p:cm></p:cmLst>"#,
578 "ppt/comments/comment1.xml",
579 "ppt/slides/slide1.xml",
580 CommentFlavor::Legacy,
581 &mut budget,
582 )
583 .unwrap();
584 assert_eq!(comments.len(), 1);
585 assert_eq!(comments[0].id, "0:1");
586 assert_eq!(comments[0].text, "Hello & welcome");
587 assert_eq!(comments[0].x_emu, 914_400);
588 assert_eq!(comments[0].y_emu, 457_200);
589 assert_eq!(comments[0].flavor, CommentFlavor::Legacy);
590 }
591
592 #[test]
593 fn modern_replies_carry_their_thread_root() {
594 let limits = ParseLimits::default();
595 let mut budget = ParseBudget::new(&limits);
596 let comments = parse_comments(
597 br#"<p188:cmLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p188="http://schemas.microsoft.com/office/powerpoint/2018/8/main"><p188:cm id="{ROOT}" authorId="{A}" created="2024-12-30T20:26:06.503" status="resolved"><p188:replyLst><p188:reply id="{REPLY}" authorId="{A}"><p188:txBody><a:p><a:r><a:t>Second</a:t></a:r></a:p></p188:txBody></p188:reply></p188:replyLst><p188:txBody><a:p><a:r><a:t>First</a:t></a:r></a:p></p188:txBody></p188:cm></p188:cmLst>"#,
598 "ppt/comments/modernComment1.xml",
599 "ppt/slides/slide1.xml",
600 CommentFlavor::Modern,
601 &mut budget,
602 )
603 .unwrap();
604 assert_eq!(comments.len(), 2);
605 assert_eq!(comments[0].id, "{ROOT}");
606 assert_eq!(comments[0].text, "First");
607 assert_eq!(comments[0].status.as_deref(), Some("resolved"));
608 assert_eq!(comments[1].id, "{REPLY}");
609 assert_eq!(comments[1].text, "Second");
610 assert_eq!(comments[1].parent_id.as_deref(), Some("{ROOT}"));
611 }
612
613 #[test]
614 fn a_comment_body_is_xml_escaped_on_the_way_out() {
615 let write = CommentsWrite {
616 flavor: CommentFlavor::Legacy,
617 authors: vec![CommentAuthorWrite {
618 id: "0".to_owned(),
619 name: "A & B".to_owned(),
620 initials: "AB".to_owned(),
621 last_index: 1,
622 color_index: 0,
623 user_id: None,
624 provider_id: None,
625 }],
626 per_slide: Vec::new(),
627 };
628 let comment = CommentWrite {
629 id: String::new(),
630 author_id: "0".to_owned(),
631 index: 1,
632 text: "<script> & \"quotes\"".to_owned(),
633 created: None,
634 x_emu: 0,
635 y_emu: 0,
636 status: None,
637 replies: Vec::new(),
638 };
639 let xml = String::from_utf8(comments_xml(&write, &[comment], 256)).unwrap();
640 assert!(xml.contains("<script> & \"quotes\""));
641 assert!(!xml.contains("<script>"));
642 let authors = String::from_utf8(authors_xml(&write)).unwrap();
643 assert!(authors.contains("name=\"A & B\""));
644 }
645}