Skip to main content

pptx_rs/oxml/
comments.rs

1//! 幻灯片评论(`<p:cmLst>` / `<p:cmAuthorLst>`)。
2//!
3//! 对标 python-pptx 的 `Slide.comments`(v0.6.21+)与 `Comment` / `CommentAuthor`。
4//!
5//! # OOXML 结构
6//!
7//! ## 评论列表(`/ppt/comments/commentN.xml`)
8//!
9//! ```text
10//! <p:cmLst xmlns:a="..." xmlns:p="..." xmlns:r="...">
11//!   <p:cm authorId="0" dt="2024-01-01T12:00:00Z" idx="1">
12//!     <p:pos x="100" y="100"/>
13//!     <p:text>评论正文</p:text>
14//!   </p:cm>
15//! </p:cmLst>
16//! ```
17//!
18//! ## 作者列表(`/ppt/commentAuthors.xml`,全局共享)
19//!
20//! ```text
21//! <p:cmAuthorLst xmlns:p="...">
22//!   <p:cmAuthor id="0" name="张三" initials="ZS"/>
23//! </p:cmAuthorLst>
24//! ```
25//!
26//! # 在三层架构中的位置
27//!
28//! 本模块属于 **OOXML 模型层**:负责把 `<p:cmLst>` / `<p:cmAuthorLst>` 序列化为 XML,
29//! 以及从 XML 解析回内存模型。高阶 API(`Slide::add_comment` 等)在 `slide.rs` 中。
30//!
31//! # 与 python-pptx 的对应
32//!
33//! - `pptx.comments.Comment` ←→ [`Comment`];
34//! - `pptx.comments.CommentAuthor` ←→ [`CommentAuthor`];
35//! - `_CommentAuthors` 集合 ←→ [`CommentAuthorList`]。
36
37use crate::oxml::ns::{NS_DRAWING_MAIN, NS_DRAWING_RELS, NS_PRESENTATION_MAIN};
38use crate::oxml::writer::XmlWriter;
39
40/// 一条评论(`<p:cm>`)。
41///
42/// # 字段
43/// - `author_id`:作者 ID(指向 [`CommentAuthorList`] 中的作者);
44/// - `date_time`:评论时间(ISO 8601 格式字符串,如 `"2024-01-01T12:00:00Z"`);
45/// - `idx`:评论在该 slide 中的唯一索引(PowerPoint 用此关联批注锚点);
46/// - `pos_x` / `pos_y`:评论锚点在 slide 上的坐标(EMU);
47/// - `text`:评论正文。
48#[derive(Clone, Debug, Default, PartialEq, Eq)]
49pub struct Comment {
50    /// 作者 ID(对应 `<p:cmAuthor id="...">`)。
51    pub author_id: u32,
52    /// 评论时间(ISO 8601 字符串)。
53    pub date_time: String,
54    /// 评论索引(`<p:cm idx="...">`)。
55    pub idx: u32,
56    /// 锚点 X 坐标(EMU)。
57    pub pos_x: i64,
58    /// 锚点 Y 坐标(EMU)。
59    pub pos_y: i64,
60    /// 评论正文。
61    pub text: String,
62}
63
64impl Comment {
65    /// 创建一条新评论。
66    ///
67    /// # 参数
68    /// - `author_id`:作者 ID;
69    /// - `idx`:评论索引;
70    /// - `pos_x` / `pos_y`:锚点坐标(EMU);
71    /// - `text`:评论正文。
72    pub fn new(author_id: u32, idx: u32, pos_x: i64, pos_y: i64, text: impl Into<String>) -> Self {
73        Comment {
74            author_id,
75            date_time: String::new(),
76            idx,
77            pos_x,
78            pos_y,
79            text: text.into(),
80        }
81    }
82
83    /// 序列化为 `<p:cm>` 元素(不含外层 `<p:cmLst>`)。
84    ///
85    /// # XML 结构
86    ///
87    /// ```text
88    /// <p:cm authorId="0" dt="..." idx="1">
89    ///   <p:pos x="100" y="100"/>
90    ///   <p:text>评论正文</p:text>
91    /// </p:cm>
92    /// ```
93    pub fn write_xml(&self, w: &mut XmlWriter) {
94        w.open_with(
95            "p:cm",
96            &[
97                ("authorId", &self.author_id.to_string()),
98                ("dt", &self.date_time),
99                ("idx", &self.idx.to_string()),
100            ],
101        );
102        w.empty_with(
103            "p:pos",
104            &[
105                ("x", &self.pos_x.to_string()),
106                ("y", &self.pos_y.to_string()),
107            ],
108        );
109        w.open("p:text");
110        w.text(&self.text);
111        w.close("p:text");
112        w.close("p:cm");
113    }
114}
115
116/// 评论列表(`<p:cmLst>`),对应 `/ppt/comments/commentN.xml`。
117#[derive(Clone, Debug, Default, PartialEq, Eq)]
118pub struct CommentList {
119    /// 评论条目(按插入顺序保留)。
120    pub comments: Vec<Comment>,
121}
122
123impl CommentList {
124    /// 创建空列表。
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    /// 是否为空。
130    pub fn is_empty(&self) -> bool {
131        self.comments.is_empty()
132    }
133
134    /// 返回条目数。
135    pub fn len(&self) -> usize {
136        self.comments.len()
137    }
138
139    /// 追加一条评论。
140    pub fn push(&mut self, c: Comment) {
141        self.comments.push(c);
142    }
143
144    /// 序列化为完整的 `commentN.xml` 文档字符串。
145    ///
146    /// # XML 结构
147    ///
148    /// ```text
149    /// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
150    /// <p:cmLst xmlns:a="..." xmlns:p="..." xmlns:r="...">
151    ///   <p:cm ...>...</p:cm>
152    /// </p:cmLst>
153    /// ```
154    pub fn to_xml(&self) -> String {
155        let mut w = XmlWriter::with_decl();
156        let attrs: Vec<(&str, &str)> = vec![
157            ("xmlns:a", NS_DRAWING_MAIN),
158            ("xmlns:p", NS_PRESENTATION_MAIN),
159            ("xmlns:r", NS_DRAWING_RELS),
160        ];
161        w.open_with("p:cmLst", &attrs);
162        for c in &self.comments {
163            c.write_xml(&mut w);
164        }
165        w.close("p:cmLst");
166        w.into_string()
167    }
168}
169
170/// 一位评论作者(`<p:cmAuthor>`)。
171#[derive(Clone, Debug, Default, PartialEq, Eq)]
172pub struct CommentAuthor {
173    /// 作者 ID(全局唯一,`<p:cmAuthor id="...">`)。
174    pub id: u32,
175    /// 作者显示名。
176    pub name: String,
177    /// 作者缩写(姓名首字母)。
178    pub initials: String,
179}
180
181impl CommentAuthor {
182    /// 创建一位作者。
183    pub fn new(id: u32, name: impl Into<String>, initials: impl Into<String>) -> Self {
184        CommentAuthor {
185            id,
186            name: name.into(),
187            initials: initials.into(),
188        }
189    }
190
191    /// 序列化为 `<p:cmAuthor>` 元素。
192    pub fn write_xml(&self, w: &mut XmlWriter) {
193        w.empty_with(
194            "p:cmAuthor",
195            &[
196                ("id", &self.id.to_string()),
197                ("name", &self.name),
198                ("initials", &self.initials),
199            ],
200        );
201    }
202}
203
204/// 评论作者列表(`<p:cmAuthorLst>`),对应 `/ppt/commentAuthors.xml`。
205#[derive(Clone, Debug, Default, PartialEq, Eq)]
206pub struct CommentAuthorList {
207    /// 作者条目。
208    pub authors: Vec<CommentAuthor>,
209}
210
211impl CommentAuthorList {
212    /// 创建空列表。
213    pub fn new() -> Self {
214        Self::default()
215    }
216
217    /// 是否为空。
218    pub fn is_empty(&self) -> bool {
219        self.authors.is_empty()
220    }
221
222    /// 返回条目数。
223    pub fn len(&self) -> usize {
224        self.authors.len()
225    }
226
227    /// 追加一位作者。
228    pub fn push(&mut self, a: CommentAuthor) {
229        self.authors.push(a);
230    }
231
232    /// 按 ID 查找作者。
233    pub fn get_by_id(&self, id: u32) -> Option<&CommentAuthor> {
234        self.authors.iter().find(|a| a.id == id)
235    }
236
237    /// 按名字查找作者 ID。若不存在则分配新 ID 并插入,返回新 ID。
238    ///
239    /// 用于 `Slide::add_comment` 时自动维护作者列表。
240    pub fn get_or_insert_id(&mut self, name: &str, initials: &str) -> u32 {
241        if let Some(a) = self.authors.iter().find(|a| a.name == name) {
242            return a.id;
243        }
244        let next_id = self
245            .authors
246            .iter()
247            .map(|a| a.id)
248            .max()
249            .unwrap_or(0)
250            .saturating_add(1);
251        self.authors
252            .push(CommentAuthor::new(next_id, name, initials));
253        next_id
254    }
255
256    /// 序列化为完整的 `commentAuthors.xml` 文档字符串。
257    pub fn to_xml(&self) -> String {
258        let mut w = XmlWriter::with_decl();
259        let attrs: Vec<(&str, &str)> = vec![("xmlns:p", NS_PRESENTATION_MAIN)];
260        w.open_with("p:cmAuthorLst", &attrs);
261        for a in &self.authors {
262            a.write_xml(&mut w);
263        }
264        w.close("p:cmAuthorLst");
265        w.into_string()
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn comment_write_xml_basic() {
275        let c = Comment::new(0, 1, 100, 200, "Hello");
276        let mut w = XmlWriter::new();
277        c.write_xml(&mut w);
278        let xml = &w.buf;
279        assert!(xml.contains("<p:cm authorId=\"0\""));
280        assert!(xml.contains("idx=\"1\""));
281        assert!(xml.contains("<p:pos x=\"100\" y=\"200\"/>"));
282        assert!(xml.contains("<p:text>Hello</p:text>"));
283    }
284
285    #[test]
286    fn comment_list_to_xml() {
287        let mut lst = CommentList::new();
288        lst.push(Comment::new(0, 1, 100, 200, "First"));
289        lst.push(Comment::new(1, 2, 300, 400, "Second"));
290        let xml = lst.to_xml();
291        assert!(xml.contains("<?xml"));
292        assert!(xml.contains("<p:cmLst"));
293        assert!(xml.contains("xmlns:a="));
294        assert!(xml.contains("xmlns:p="));
295        assert!(xml.contains("First"));
296        assert!(xml.contains("Second"));
297    }
298
299    #[test]
300    fn comment_list_empty_to_xml() {
301        let lst = CommentList::new();
302        let xml = lst.to_xml();
303        assert!(xml.contains("<p:cmLst"));
304        assert!(xml.contains("</p:cmLst>"));
305        // 空列表不应包含 <p:cm
306        assert!(!xml.contains("<p:cm "));
307    }
308
309    #[test]
310    fn comment_author_write_xml() {
311        let a = CommentAuthor::new(0, "张三", "ZS");
312        let mut w = XmlWriter::new();
313        a.write_xml(&mut w);
314        let xml = &w.buf;
315        assert!(xml.contains("<p:cmAuthor id=\"0\""));
316        assert!(xml.contains("name=\"张三\""));
317        assert!(xml.contains("initials=\"ZS\""));
318    }
319
320    #[test]
321    fn comment_author_list_to_xml() {
322        let mut lst = CommentAuthorList::new();
323        lst.push(CommentAuthor::new(0, "张三", "ZS"));
324        lst.push(CommentAuthor::new(1, "李四", "LS"));
325        let xml = lst.to_xml();
326        assert!(xml.contains("<?xml"));
327        assert!(xml.contains("<p:cmAuthorLst"));
328        assert!(xml.contains("张三"));
329        assert!(xml.contains("李四"));
330    }
331
332    #[test]
333    fn comment_author_get_or_insert() {
334        let mut lst = CommentAuthorList::new();
335        let id1 = lst.get_or_insert_id("张三", "ZS");
336        assert_eq!(id1, 1);
337        let id2 = lst.get_or_insert_id("李四", "LS");
338        assert_eq!(id2, 2);
339        // 已存在的作者返回原 ID
340        let id3 = lst.get_or_insert_id("张三", "ZS");
341        assert_eq!(id3, 1);
342        assert_eq!(lst.len(), 2);
343    }
344
345    #[test]
346    fn comment_author_get_by_id() {
347        let mut lst = CommentAuthorList::new();
348        lst.push(CommentAuthor::new(0, "张三", "ZS"));
349        lst.push(CommentAuthor::new(1, "李四", "LS"));
350        assert_eq!(lst.get_by_id(0).unwrap().name, "张三");
351        assert_eq!(lst.get_by_id(1).unwrap().name, "李四");
352        assert!(lst.get_by_id(2).is_none());
353    }
354}