Skip to main content

pptxboss_core/
comments.rs

1//! Slide comments: the PresentationML Comments part (ECMA-376 Part 1,
2//! clause 19.4, `p:cmLst`) with its Comment Authors part, and the 2018
3//! comments part current PowerPoint writes (`p188:cmLst`, threaded
4//! replies, rich text) with its Authors part.
5
6use crate::mce::children;
7use crate::slide::parse_text_body;
8use crate::xml::{unescape_attr, Event, Ns, Reader, XmlError};
9
10/// One entry of a comment authors part, either flavour.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct CommentAuthor {
13    /// `@id`: a small integer in the 2006 part, a GUID in the 2018 part.
14    pub id: String,
15    pub name: String,
16    pub initials: Option<String>,
17}
18
19/// A comment on a slide; replies follow their parent with `reply` set.
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct Comment {
22    /// The author's name when the authors part resolves the id, else the raw id.
23    pub author: Option<String>,
24    pub initials: Option<String>,
25    /// `@dt` (2006) or `@created` (2018) as written.
26    pub date: Option<String>,
27    pub text: String,
28    pub reply: bool,
29}
30
31/// Parses a comment authors part of either flavour.
32pub fn parse_authors(xml: &[u8]) -> Result<Vec<CommentAuthor>, XmlError> {
33    let mut reader = Reader::new(xml);
34    if !skip_to_root(&mut reader)? {
35        return Ok(Vec::new());
36    }
37    let mut authors = Vec::new();
38    children(&mut reader, &mut |reader, child| {
39        let is_author = child.name.is(Ns::Pml, b"cmAuthor") || child.name.is(Ns::P188, b"author");
40        if !is_author {
41            return reader.skip_element();
42        }
43        let id = reader.attr(&child, Ns::None, b"id").map(unescape_attr);
44        let name = reader.attr(&child, Ns::None, b"name").map(unescape_attr);
45        if let (Some(id), Some(name)) = (id, name) {
46            authors.push(CommentAuthor {
47                id,
48                name,
49                initials: reader
50                    .attr(&child, Ns::None, b"initials")
51                    .map(unescape_attr)
52                    .filter(|initials| !initials.is_empty()),
53            });
54        }
55        reader.skip_element()
56    })?;
57    Ok(authors)
58}
59
60/// Parses a comments part of either flavour, resolving author ids through `authors`.
61pub fn parse_comments(xml: &[u8], authors: &[CommentAuthor]) -> Result<Vec<Comment>, XmlError> {
62    let mut reader = Reader::new(xml);
63    if !skip_to_root(&mut reader)? {
64        return Ok(Vec::new());
65    }
66    let mut comments = Vec::new();
67    children(&mut reader, &mut |reader, child| {
68        if child.name.is(Ns::Pml, b"cm") {
69            let mut comment = header(reader, &child, b"dt", authors);
70            children(reader, &mut |reader, item| {
71                if !item.name.is(Ns::Pml, b"text") {
72                    return reader.skip_element();
73                }
74                reader.text_content(&mut comment.text)
75            })?;
76            comments.push(comment);
77            return Ok(());
78        }
79        if child.name.is(Ns::P188, b"cm") {
80            let mut comment = header(reader, &child, b"created", authors);
81            let mut replies = Vec::new();
82            children(reader, &mut |reader, item| {
83                if item.name.is(Ns::P188, b"txBody") {
84                    comment.text = parse_text_body(reader)?.text();
85                    return Ok(());
86                }
87                if item.name.is(Ns::P188, b"replyLst") {
88                    return children(reader, &mut |reader, reply| {
89                        if !reply.name.is(Ns::P188, b"reply") {
90                            return reader.skip_element();
91                        }
92                        let mut comment = header(reader, &reply, b"created", authors);
93                        comment.reply = true;
94                        children(reader, &mut |reader, part| {
95                            if !part.name.is(Ns::P188, b"txBody") {
96                                return reader.skip_element();
97                            }
98                            comment.text = parse_text_body(reader)?.text();
99                            Ok(())
100                        })?;
101                        replies.push(comment);
102                        Ok(())
103                    });
104                }
105                reader.skip_element()
106            })?;
107            comments.push(comment);
108            comments.append(&mut replies);
109            return Ok(());
110        }
111        reader.skip_element()
112    })?;
113    Ok(comments)
114}
115
116fn header(
117    reader: &Reader<'_>,
118    start: &crate::xml::Start<'_>,
119    date_attr: &[u8],
120    authors: &[CommentAuthor],
121) -> Comment {
122    let author_id = reader.attr(start, Ns::None, b"authorId").map(unescape_attr);
123    let author = author_id
124        .as_deref()
125        .and_then(|id| authors.iter().find(|author| author.id == id));
126    Comment {
127        author: author.map(|author| author.name.clone()).or(author_id),
128        initials: author.and_then(|author| author.initials.clone()),
129        date: reader.attr(start, Ns::None, date_attr).map(unescape_attr),
130        text: String::new(),
131        reply: false,
132    }
133}
134
135/// Positions the reader after the root start tag; false for an empty document.
136fn skip_to_root(reader: &mut Reader<'_>) -> Result<bool, XmlError> {
137    loop {
138        match reader.next()? {
139            Event::Start(_) => return Ok(true),
140            Event::Eof => return Ok(false),
141            _ => {}
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    const AUTHORS_2006: &[u8] = br#"<p:cmAuthorLst xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cmAuthor id="0" name="Ada Lovelace" initials="AL" lastIdx="2" clrIdx="0"/><p:cmAuthor id="1" name="Bob" lastIdx="1" clrIdx="1"/></p:cmAuthorLst>"#;
151
152    #[test]
153    fn legacy_comments_resolve_authors_and_keep_order() {
154        let authors = parse_authors(AUTHORS_2006).unwrap();
155        assert_eq!(authors.len(), 2);
156        assert_eq!(authors[0].initials.as_deref(), Some("AL"));
157        let xml = br#"<p:cmLst xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cm authorId="0" dt="2024-05-01T10:00:00.000" idx="1"><p:pos x="10" y="20"/><p:text>First &amp; foremost</p:text></p:cm><p:cm authorId="9" idx="2"><p:text>Orphan</p:text></p:cm></p:cmLst>"#;
158        let comments = parse_comments(xml, &authors).unwrap();
159        assert_eq!(comments.len(), 2);
160        assert_eq!(comments[0].author.as_deref(), Some("Ada Lovelace"));
161        assert_eq!(comments[0].date.as_deref(), Some("2024-05-01T10:00:00.000"));
162        assert_eq!(comments[0].text, "First & foremost");
163        assert_eq!(comments[1].author.as_deref(), Some("9"));
164        assert!(!comments[1].reply);
165    }
166
167    #[test]
168    fn modern_comments_carry_rich_text_and_replies() {
169        let authors = parse_authors(br#"<p188:authorLst xmlns:p188="http://schemas.microsoft.com/office/powerpoint/2018/8/main"><p188:author id="{AAAA-1}" name="Ada" initials="A" userId="ada" providerId="AD"/></p188:authorLst>"#).unwrap();
170        let xml = br#"<p188:cmLst xmlns:p188="http://schemas.microsoft.com/office/powerpoint/2018/8/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p188:cm id="{C1}" authorId="{AAAA-1}" created="2024-05-01T10:00:00.000"><p188:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US"/><a:t>Please </a:t></a:r><a:r><a:t>fix</a:t></a:r></a:p></p188:txBody><p188:replyLst><p188:reply id="{R1}" authorId="{ZZZ}" created="2024-05-02T09:00:00.000"><p188:txBody><a:bodyPr/><a:p><a:r><a:t>Done</a:t></a:r></a:p></p188:txBody></p188:reply></p188:replyLst></p188:cm></p188:cmLst>"#;
171        let comments = parse_comments(xml, &authors).unwrap();
172        assert_eq!(comments.len(), 2);
173        assert_eq!(comments[0].author.as_deref(), Some("Ada"));
174        assert_eq!(comments[0].text, "Please fix");
175        assert!(!comments[0].reply);
176        assert_eq!(comments[1].author.as_deref(), Some("{ZZZ}"));
177        assert_eq!(comments[1].text, "Done");
178        assert!(comments[1].reply);
179    }
180}