use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommentAuthor {
pub id: u32,
pub name: String,
pub initials: String,
pub color_index: u32,
}
impl CommentAuthor {
pub fn new(id: u32, name: &str, initials: &str) -> Self {
Self {
id,
name: name.to_string(),
initials: initials.to_string(),
color_index: id,
}
}
pub fn color_index(mut self, idx: u32) -> Self {
self.color_index = idx;
self
}
pub fn to_xml(&self) -> String {
format!(
r#"<p:cmAuthor id="{}" name="{}" initials="{}" lastIdx="1" clrIdx="{}"/>"#,
self.id,
xml_escape(&self.name),
xml_escape(&self.initials),
self.color_index,
)
}
}
#[derive(Clone, Debug)]
pub struct Comment {
pub author_id: u32,
pub text: String,
pub date: String,
pub x: u32,
pub y: u32,
pub index: u32,
}
impl Comment {
pub fn new(author_id: u32, text: &str) -> Self {
Self {
author_id,
text: text.to_string(),
date: "2025-01-01T00:00:00.000".to_string(),
x: 0,
y: 0,
index: 1,
}
}
pub fn position(mut self, x: u32, y: u32) -> Self {
self.x = x;
self.y = y;
self
}
pub fn date(mut self, date: &str) -> Self {
self.date = date.to_string();
self
}
pub fn index(mut self, idx: u32) -> Self {
self.index = idx;
self
}
pub fn to_xml(&self) -> String {
format!(
r#"<p:cm authorId="{}" dt="{}" idx="{}"><p:pos x="{}" y="{}"/><p:text>{}</p:text></p:cm>"#,
self.author_id,
xml_escape(&self.date),
self.index,
self.x,
self.y,
xml_escape(&self.text),
)
}
}
#[derive(Clone, Debug, Default)]
pub struct CommentAuthorList {
authors: Vec<CommentAuthor>,
name_to_id: HashMap<String, u32>,
next_id: u32,
}
impl CommentAuthorList {
pub fn new() -> Self {
Self::default()
}
pub fn get_or_add(&mut self, name: &str, initials: &str) -> u32 {
if let Some(&id) = self.name_to_id.get(name) {
return id;
}
let id = self.next_id;
self.next_id += 1;
let author = CommentAuthor::new(id, name, initials);
self.authors.push(author);
self.name_to_id.insert(name.to_string(), id);
id
}
pub fn get_by_id(&self, id: u32) -> Option<&CommentAuthor> {
self.authors.iter().find(|a| a.id == id)
}
pub fn authors(&self) -> &[CommentAuthor] {
&self.authors
}
pub fn len(&self) -> usize {
self.authors.len()
}
pub fn is_empty(&self) -> bool {
self.authors.is_empty()
}
pub fn to_xml(&self) -> String {
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
);
xml.push_str(
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">"#,
);
for author in &self.authors {
xml.push_str(&author.to_xml());
}
xml.push_str("</p:cmAuthorLst>");
xml
}
}
#[derive(Clone, Debug, Default)]
pub struct SlideComments {
comments: Vec<Comment>,
}
impl SlideComments {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, comment: Comment) {
self.comments.push(comment);
}
pub fn add_comment(&mut self, author_id: u32, text: &str, x: u32, y: u32) {
let idx = self.comments.len() as u32 + 1;
self.comments.push(
Comment::new(author_id, text)
.position(x, y)
.index(idx),
);
}
pub fn comments(&self) -> &[Comment] {
&self.comments
}
pub fn len(&self) -> usize {
self.comments.len()
}
pub fn is_empty(&self) -> bool {
self.comments.is_empty()
}
pub fn to_xml(&self) -> String {
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
);
xml.push_str(
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">"#,
);
for comment in &self.comments {
xml.push_str(&comment.to_xml());
}
xml.push_str("</p:cmLst>");
xml
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_comment_author_new() {
let author = CommentAuthor::new(0, "John Doe", "JD");
assert_eq!(author.id, 0);
assert_eq!(author.name, "John Doe");
assert_eq!(author.initials, "JD");
assert_eq!(author.color_index, 0);
}
#[test]
fn test_comment_author_color_index() {
let author = CommentAuthor::new(0, "Jane", "J").color_index(5);
assert_eq!(author.color_index, 5);
}
#[test]
fn test_comment_author_xml() {
let author = CommentAuthor::new(0, "John Doe", "JD");
let xml = author.to_xml();
assert!(xml.contains(r#"id="0""#));
assert!(xml.contains(r#"name="John Doe""#));
assert!(xml.contains(r#"initials="JD""#));
}
#[test]
fn test_comment_new() {
let comment = Comment::new(0, "Review this slide");
assert_eq!(comment.author_id, 0);
assert_eq!(comment.text, "Review this slide");
assert_eq!(comment.x, 0);
assert_eq!(comment.y, 0);
}
#[test]
fn test_comment_position() {
let comment = Comment::new(0, "Note").position(100, 200);
assert_eq!(comment.x, 100);
assert_eq!(comment.y, 200);
}
#[test]
fn test_comment_date() {
let comment = Comment::new(0, "Note").date("2025-06-15T10:30:00.000");
assert_eq!(comment.date, "2025-06-15T10:30:00.000");
}
#[test]
fn test_comment_xml() {
let comment = Comment::new(0, "Fix this").position(100, 200).index(1);
let xml = comment.to_xml();
assert!(xml.contains(r#"authorId="0""#));
assert!(xml.contains(r#"idx="1""#));
assert!(xml.contains(r#"x="100""#));
assert!(xml.contains(r#"y="200""#));
assert!(xml.contains("Fix this"));
}
#[test]
fn test_comment_xml_escaping() {
let comment = Comment::new(0, "Use <b> & \"quotes\"");
let xml = comment.to_xml();
assert!(xml.contains("<b>"));
assert!(xml.contains("&"));
assert!(xml.contains(""quotes""));
}
#[test]
fn test_comment_author_list_new() {
let list = CommentAuthorList::new();
assert!(list.is_empty());
assert_eq!(list.len(), 0);
}
#[test]
fn test_comment_author_list_add() {
let mut list = CommentAuthorList::new();
let id1 = list.get_or_add("Alice", "A");
let id2 = list.get_or_add("Bob", "B");
assert_eq!(id1, 0);
assert_eq!(id2, 1);
assert_eq!(list.len(), 2);
}
#[test]
fn test_comment_author_list_dedup() {
let mut list = CommentAuthorList::new();
let id1 = list.get_or_add("Alice", "A");
let id2 = list.get_or_add("Alice", "A");
assert_eq!(id1, id2);
assert_eq!(list.len(), 1);
}
#[test]
fn test_comment_author_list_get_by_id() {
let mut list = CommentAuthorList::new();
list.get_or_add("Alice", "A");
let author = list.get_by_id(0);
assert!(author.is_some());
assert_eq!(author.unwrap().name, "Alice");
assert!(list.get_by_id(99).is_none());
}
#[test]
fn test_comment_author_list_xml() {
let mut list = CommentAuthorList::new();
list.get_or_add("Alice", "A");
let xml = list.to_xml();
assert!(xml.contains("<p:cmAuthorLst"));
assert!(xml.contains("Alice"));
assert!(xml.contains("</p:cmAuthorLst>"));
}
#[test]
fn test_slide_comments_new() {
let comments = SlideComments::new();
assert!(comments.is_empty());
assert_eq!(comments.len(), 0);
}
#[test]
fn test_slide_comments_add() {
let mut comments = SlideComments::new();
comments.add(Comment::new(0, "First comment").position(10, 20));
comments.add(Comment::new(1, "Second comment").position(30, 40));
assert_eq!(comments.len(), 2);
}
#[test]
fn test_slide_comments_add_comment() {
let mut comments = SlideComments::new();
comments.add_comment(0, "Auto-indexed", 100, 200);
comments.add_comment(0, "Second", 300, 400);
assert_eq!(comments.comments()[0].index, 1);
assert_eq!(comments.comments()[1].index, 2);
}
#[test]
fn test_slide_comments_xml() {
let mut comments = SlideComments::new();
comments.add_comment(0, "Review needed", 100, 200);
let xml = comments.to_xml();
assert!(xml.contains("<p:cmLst"));
assert!(xml.contains("Review needed"));
assert!(xml.contains("</p:cmLst>"));
}
#[test]
fn test_slide_comments_xml_empty() {
let comments = SlideComments::new();
let xml = comments.to_xml();
assert!(xml.contains("<p:cmLst"));
assert!(xml.contains("</p:cmLst>"));
}
#[test]
fn test_comment_author_list_xml_multiple() {
let mut list = CommentAuthorList::new();
list.get_or_add("Alice", "A");
list.get_or_add("Bob", "B");
let xml = list.to_xml();
assert!(xml.contains("Alice"));
assert!(xml.contains("Bob"));
assert!(xml.matches("<p:cmAuthor ").count() == 2);
}
}