Skip to main content

pptx_parse/
comments.rs

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