Skip to main content

ppt_rs/generator/slide_content/
comments.rs

1//! Comments and review annotations for slides
2//!
3//! Supports adding review comments to slides with author, date, and position.
4//! Generates proper OOXML `<p:cmLst>` and `<p:cmAuthorLst>` XML.
5
6use std::collections::HashMap;
7
8/// A comment author
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct CommentAuthor {
11    pub id: u32,
12    pub name: String,
13    pub initials: String,
14    pub color_index: u32,
15}
16
17impl CommentAuthor {
18    pub fn new(id: u32, name: &str, initials: &str) -> Self {
19        Self {
20            id,
21            name: name.to_string(),
22            initials: initials.to_string(),
23            color_index: id,
24        }
25    }
26
27    pub fn color_index(mut self, idx: u32) -> Self {
28        self.color_index = idx;
29        self
30    }
31
32    /// Generate XML for `<p:cmAuthor>` element
33    pub fn to_xml(&self) -> String {
34        format!(
35            r#"<p:cmAuthor id="{}" name="{}" initials="{}" lastIdx="1" clrIdx="{}"/>"#,
36            self.id,
37            xml_escape(&self.name),
38            xml_escape(&self.initials),
39            self.color_index,
40        )
41    }
42}
43
44/// A single comment on a slide
45#[derive(Clone, Debug)]
46pub struct Comment {
47    pub author_id: u32,
48    pub text: String,
49    pub date: String,
50    pub x: u32,
51    pub y: u32,
52    pub index: u32,
53}
54
55impl Comment {
56    /// Create a new comment at a position (x, y in EMU)
57    pub fn new(author_id: u32, text: &str) -> Self {
58        Self {
59            author_id,
60            text: text.to_string(),
61            date: "2025-01-01T00:00:00.000".to_string(),
62            x: 0,
63            y: 0,
64            index: 1,
65        }
66    }
67
68    /// Set comment position (in EMU)
69    pub fn position(mut self, x: u32, y: u32) -> Self {
70        self.x = x;
71        self.y = y;
72        self
73    }
74
75    /// Set comment date (ISO 8601 format)
76    pub fn date(mut self, date: &str) -> Self {
77        self.date = date.to_string();
78        self
79    }
80
81    /// Set comment index
82    pub fn index(mut self, idx: u32) -> Self {
83        self.index = idx;
84        self
85    }
86
87    /// Generate XML for `<p:cm>` element
88    pub fn to_xml(&self) -> String {
89        format!(
90            r#"<p:cm authorId="{}" dt="{}" idx="{}"><p:pos x="{}" y="{}"/><p:text>{}</p:text></p:cm>"#,
91            self.author_id,
92            xml_escape(&self.date),
93            self.index,
94            self.x,
95            self.y,
96            xml_escape(&self.text),
97        )
98    }
99}
100
101/// Manages comment authors across the presentation
102#[derive(Clone, Debug, Default)]
103pub struct CommentAuthorList {
104    authors: Vec<CommentAuthor>,
105    name_to_id: HashMap<String, u32>,
106    next_id: u32,
107}
108
109impl CommentAuthorList {
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    /// Add or get an author by name. Returns the author ID.
115    pub fn get_or_add(&mut self, name: &str, initials: &str) -> u32 {
116        if let Some(&id) = self.name_to_id.get(name) {
117            return id;
118        }
119        let id = self.next_id;
120        self.next_id += 1;
121        let author = CommentAuthor::new(id, name, initials);
122        self.authors.push(author);
123        self.name_to_id.insert(name.to_string(), id);
124        id
125    }
126
127    /// Get author by ID
128    pub fn get_by_id(&self, id: u32) -> Option<&CommentAuthor> {
129        self.authors.iter().find(|a| a.id == id)
130    }
131
132    /// Get all authors
133    pub fn authors(&self) -> &[CommentAuthor] {
134        &self.authors
135    }
136
137    /// Number of authors
138    pub fn len(&self) -> usize {
139        self.authors.len()
140    }
141
142    /// Whether the list is empty
143    pub fn is_empty(&self) -> bool {
144        self.authors.is_empty()
145    }
146
147    /// Generate `commentAuthors.xml` content
148    pub fn to_xml(&self) -> String {
149        let mut xml = String::from(
150            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
151        );
152        xml.push_str(
153            r#"<p:cmAuthorLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">"#,
154        );
155        for author in &self.authors {
156            xml.push_str(&author.to_xml());
157        }
158        xml.push_str("</p:cmAuthorLst>");
159        xml
160    }
161}
162
163/// Comments for a single slide
164#[derive(Clone, Debug, Default)]
165pub struct SlideComments {
166    comments: Vec<Comment>,
167}
168
169impl SlideComments {
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    /// Add a comment
175    pub fn add(&mut self, comment: Comment) {
176        self.comments.push(comment);
177    }
178
179    /// Add a comment with auto-incrementing index
180    pub fn add_comment(&mut self, author_id: u32, text: &str, x: u32, y: u32) {
181        let idx = self.comments.len() as u32 + 1;
182        self.comments.push(
183            Comment::new(author_id, text)
184                .position(x, y)
185                .index(idx),
186        );
187    }
188
189    /// Get all comments
190    pub fn comments(&self) -> &[Comment] {
191        &self.comments
192    }
193
194    /// Number of comments
195    pub fn len(&self) -> usize {
196        self.comments.len()
197    }
198
199    /// Whether there are no comments
200    pub fn is_empty(&self) -> bool {
201        self.comments.is_empty()
202    }
203
204    /// Generate `commentN.xml` content for a slide
205    pub fn to_xml(&self) -> String {
206        let mut xml = String::from(
207            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
208        );
209        xml.push_str(
210            r#"<p:cmLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">"#,
211        );
212        for comment in &self.comments {
213            xml.push_str(&comment.to_xml());
214        }
215        xml.push_str("</p:cmLst>");
216        xml
217    }
218}
219
220fn xml_escape(s: &str) -> String {
221    s.replace('&', "&amp;")
222        .replace('<', "&lt;")
223        .replace('>', "&gt;")
224        .replace('"', "&quot;")
225        .replace('\'', "&apos;")
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_comment_author_new() {
234        let author = CommentAuthor::new(0, "John Doe", "JD");
235        assert_eq!(author.id, 0);
236        assert_eq!(author.name, "John Doe");
237        assert_eq!(author.initials, "JD");
238        assert_eq!(author.color_index, 0);
239    }
240
241    #[test]
242    fn test_comment_author_color_index() {
243        let author = CommentAuthor::new(0, "Jane", "J").color_index(5);
244        assert_eq!(author.color_index, 5);
245    }
246
247    #[test]
248    fn test_comment_author_xml() {
249        let author = CommentAuthor::new(0, "John Doe", "JD");
250        let xml = author.to_xml();
251        assert!(xml.contains(r#"id="0""#));
252        assert!(xml.contains(r#"name="John Doe""#));
253        assert!(xml.contains(r#"initials="JD""#));
254    }
255
256    #[test]
257    fn test_comment_new() {
258        let comment = Comment::new(0, "Review this slide");
259        assert_eq!(comment.author_id, 0);
260        assert_eq!(comment.text, "Review this slide");
261        assert_eq!(comment.x, 0);
262        assert_eq!(comment.y, 0);
263    }
264
265    #[test]
266    fn test_comment_position() {
267        let comment = Comment::new(0, "Note").position(100, 200);
268        assert_eq!(comment.x, 100);
269        assert_eq!(comment.y, 200);
270    }
271
272    #[test]
273    fn test_comment_date() {
274        let comment = Comment::new(0, "Note").date("2025-06-15T10:30:00.000");
275        assert_eq!(comment.date, "2025-06-15T10:30:00.000");
276    }
277
278    #[test]
279    fn test_comment_xml() {
280        let comment = Comment::new(0, "Fix this").position(100, 200).index(1);
281        let xml = comment.to_xml();
282        assert!(xml.contains(r#"authorId="0""#));
283        assert!(xml.contains(r#"idx="1""#));
284        assert!(xml.contains(r#"x="100""#));
285        assert!(xml.contains(r#"y="200""#));
286        assert!(xml.contains("Fix this"));
287    }
288
289    #[test]
290    fn test_comment_xml_escaping() {
291        let comment = Comment::new(0, "Use <b> & \"quotes\"");
292        let xml = comment.to_xml();
293        assert!(xml.contains("&lt;b&gt;"));
294        assert!(xml.contains("&amp;"));
295        assert!(xml.contains("&quot;quotes&quot;"));
296    }
297
298    #[test]
299    fn test_comment_author_list_new() {
300        let list = CommentAuthorList::new();
301        assert!(list.is_empty());
302        assert_eq!(list.len(), 0);
303    }
304
305    #[test]
306    fn test_comment_author_list_add() {
307        let mut list = CommentAuthorList::new();
308        let id1 = list.get_or_add("Alice", "A");
309        let id2 = list.get_or_add("Bob", "B");
310        assert_eq!(id1, 0);
311        assert_eq!(id2, 1);
312        assert_eq!(list.len(), 2);
313    }
314
315    #[test]
316    fn test_comment_author_list_dedup() {
317        let mut list = CommentAuthorList::new();
318        let id1 = list.get_or_add("Alice", "A");
319        let id2 = list.get_or_add("Alice", "A");
320        assert_eq!(id1, id2);
321        assert_eq!(list.len(), 1);
322    }
323
324    #[test]
325    fn test_comment_author_list_get_by_id() {
326        let mut list = CommentAuthorList::new();
327        list.get_or_add("Alice", "A");
328        let author = list.get_by_id(0);
329        assert!(author.is_some());
330        assert_eq!(author.unwrap().name, "Alice");
331        assert!(list.get_by_id(99).is_none());
332    }
333
334    #[test]
335    fn test_comment_author_list_xml() {
336        let mut list = CommentAuthorList::new();
337        list.get_or_add("Alice", "A");
338        let xml = list.to_xml();
339        assert!(xml.contains("<p:cmAuthorLst"));
340        assert!(xml.contains("Alice"));
341        assert!(xml.contains("</p:cmAuthorLst>"));
342    }
343
344    #[test]
345    fn test_slide_comments_new() {
346        let comments = SlideComments::new();
347        assert!(comments.is_empty());
348        assert_eq!(comments.len(), 0);
349    }
350
351    #[test]
352    fn test_slide_comments_add() {
353        let mut comments = SlideComments::new();
354        comments.add(Comment::new(0, "First comment").position(10, 20));
355        comments.add(Comment::new(1, "Second comment").position(30, 40));
356        assert_eq!(comments.len(), 2);
357    }
358
359    #[test]
360    fn test_slide_comments_add_comment() {
361        let mut comments = SlideComments::new();
362        comments.add_comment(0, "Auto-indexed", 100, 200);
363        comments.add_comment(0, "Second", 300, 400);
364        assert_eq!(comments.comments()[0].index, 1);
365        assert_eq!(comments.comments()[1].index, 2);
366    }
367
368    #[test]
369    fn test_slide_comments_xml() {
370        let mut comments = SlideComments::new();
371        comments.add_comment(0, "Review needed", 100, 200);
372        let xml = comments.to_xml();
373        assert!(xml.contains("<p:cmLst"));
374        assert!(xml.contains("Review needed"));
375        assert!(xml.contains("</p:cmLst>"));
376    }
377
378    #[test]
379    fn test_slide_comments_xml_empty() {
380        let comments = SlideComments::new();
381        let xml = comments.to_xml();
382        assert!(xml.contains("<p:cmLst"));
383        assert!(xml.contains("</p:cmLst>"));
384    }
385
386    #[test]
387    fn test_comment_author_list_xml_multiple() {
388        let mut list = CommentAuthorList::new();
389        list.get_or_add("Alice", "A");
390        list.get_or_add("Bob", "B");
391        let xml = list.to_xml();
392        assert!(xml.contains("Alice"));
393        assert!(xml.contains("Bob"));
394        assert!(xml.matches("<p:cmAuthor ").count() == 2);
395    }
396}