Skip to main content

ppt_rs/generator/slide_content/
sections.rs

1//! Slide sections and organization
2//!
3//! Provides section management for grouping slides into logical sections.
4//! Generates proper OOXML `<p:extLst>` section data in presentation.xml.
5
6/// A section that groups consecutive slides
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct SlideSection {
9    pub name: String,
10    pub first_slide: usize,
11    pub slide_count: usize,
12}
13
14impl SlideSection {
15    /// Create a new section starting at a given slide index (0-based)
16    pub fn new(name: &str, first_slide: usize, slide_count: usize) -> Self {
17        Self {
18            name: name.to_string(),
19            first_slide,
20            slide_count,
21        }
22    }
23
24    /// Last slide index (inclusive, 0-based)
25    pub fn last_slide(&self) -> usize {
26        if self.slide_count == 0 {
27            self.first_slide
28        } else {
29            self.first_slide + self.slide_count - 1
30        }
31    }
32
33    /// Check if a slide index belongs to this section
34    pub fn contains_slide(&self, slide_index: usize) -> bool {
35        slide_index >= self.first_slide && slide_index < self.first_slide + self.slide_count
36    }
37}
38
39/// Manages sections across the presentation
40#[derive(Clone, Debug, Default)]
41pub struct SectionManager {
42    sections: Vec<SlideSection>,
43}
44
45impl SectionManager {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Add a section. Returns error if it overlaps with existing sections.
51    pub fn add_section(&mut self, name: &str, first_slide: usize, slide_count: usize) -> Result<(), String> {
52        let new_section = SlideSection::new(name, first_slide, slide_count);
53
54        // Check for overlaps
55        for existing in &self.sections {
56            if sections_overlap(existing, &new_section) {
57                return Err(format!(
58                    "Section '{}' (slides {}-{}) overlaps with '{}' (slides {}-{})",
59                    name, first_slide, new_section.last_slide(),
60                    existing.name, existing.first_slide, existing.last_slide(),
61                ));
62            }
63        }
64
65        self.sections.push(new_section);
66        // Keep sorted by first_slide
67        self.sections.sort_by_key(|s| s.first_slide);
68        Ok(())
69    }
70
71    /// Remove a section by name
72    pub fn remove_section(&mut self, name: &str) -> bool {
73        let before = self.sections.len();
74        self.sections.retain(|s| s.name != name);
75        self.sections.len() < before
76    }
77
78    /// Get section by name
79    pub fn get_section(&self, name: &str) -> Option<&SlideSection> {
80        self.sections.iter().find(|s| s.name == name)
81    }
82
83    /// Find which section a slide belongs to
84    pub fn section_for_slide(&self, slide_index: usize) -> Option<&SlideSection> {
85        self.sections.iter().find(|s| s.contains_slide(slide_index))
86    }
87
88    /// Get all sections
89    pub fn sections(&self) -> &[SlideSection] {
90        &self.sections
91    }
92
93    /// Number of sections
94    pub fn len(&self) -> usize {
95        self.sections.len()
96    }
97
98    /// Whether there are no sections
99    pub fn is_empty(&self) -> bool {
100        self.sections.is_empty()
101    }
102
103    /// Clear all sections
104    pub fn clear(&mut self) {
105        self.sections.clear();
106    }
107
108    /// Rename a section. Returns false if not found.
109    pub fn rename_section(&mut self, old_name: &str, new_name: &str) -> bool {
110        if let Some(section) = self.sections.iter_mut().find(|s| s.name == old_name) {
111            section.name = new_name.to_string();
112            true
113        } else {
114            false
115        }
116    }
117
118    /// Generate OOXML extension XML for sections (used in presentation.xml `<p:extLst>`)
119    pub fn to_xml(&self, total_slides: usize) -> String {
120        if self.sections.is_empty() {
121            return String::new();
122        }
123
124        let mut xml = String::from(
125            r#"<p:extLst><p:ext uri="{521415D9-36F7-43E2-AB2F-B90AF26B5E84}"><p14:sectionLst xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main">"#,
126        );
127
128        for section in &self.sections {
129            xml.push_str(&format!(
130                r#"<p14:section name="{}" id="{{{}}}">"#,
131                xml_escape(&section.name),
132                generate_section_id(&section.name),
133            ));
134            xml.push_str("<p14:sldIdLst>");
135            for i in 0..section.slide_count {
136                let slide_id = 256 + section.first_slide + i;
137                if section.first_slide + i < total_slides {
138                    xml.push_str(&format!(r#"<p14:sldId id="{}"/>"#, slide_id));
139                }
140            }
141            xml.push_str("</p14:sldIdLst>");
142            xml.push_str("</p14:section>");
143        }
144
145        xml.push_str("</p14:sectionLst></p:ext></p:extLst>");
146        xml
147    }
148}
149
150/// Check if two sections overlap
151fn sections_overlap(a: &SlideSection, b: &SlideSection) -> bool {
152    if a.slide_count == 0 || b.slide_count == 0 {
153        return false;
154    }
155    a.first_slide < b.first_slide + b.slide_count && b.first_slide < a.first_slide + a.slide_count
156}
157
158/// Generate a deterministic GUID-like ID from a section name
159fn generate_section_id(name: &str) -> String {
160    let mut hash: u64 = 0xcbf29ce484222325;
161    for byte in name.bytes() {
162        hash ^= byte as u64;
163        hash = hash.wrapping_mul(0x100000001b3);
164    }
165    let a = (hash >> 32) as u32;
166    let b = (hash & 0xFFFF) as u16;
167    let c = ((hash >> 16) & 0xFFFF) as u16;
168    let d = hash.wrapping_mul(0x9e3779b97f4a7c15);
169    format!(
170        "{:08X}-{:04X}-{:04X}-{:04X}-{:012X}",
171        a,
172        b,
173        c,
174        (d & 0xFFFF) as u16,
175        d >> 16,
176    )
177}
178
179fn xml_escape(s: &str) -> String {
180    s.replace('&', "&amp;")
181        .replace('<', "&lt;")
182        .replace('>', "&gt;")
183        .replace('"', "&quot;")
184        .replace('\'', "&apos;")
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_slide_section_new() {
193        let section = SlideSection::new("Introduction", 0, 3);
194        assert_eq!(section.name, "Introduction");
195        assert_eq!(section.first_slide, 0);
196        assert_eq!(section.slide_count, 3);
197    }
198
199    #[test]
200    fn test_slide_section_last_slide() {
201        let section = SlideSection::new("Intro", 0, 3);
202        assert_eq!(section.last_slide(), 2);
203
204        let empty = SlideSection::new("Empty", 5, 0);
205        assert_eq!(empty.last_slide(), 5);
206    }
207
208    #[test]
209    fn test_slide_section_contains() {
210        let section = SlideSection::new("Body", 3, 5);
211        assert!(!section.contains_slide(2));
212        assert!(section.contains_slide(3));
213        assert!(section.contains_slide(7));
214        assert!(!section.contains_slide(8));
215    }
216
217    #[test]
218    fn test_section_manager_new() {
219        let mgr = SectionManager::new();
220        assert!(mgr.is_empty());
221        assert_eq!(mgr.len(), 0);
222    }
223
224    #[test]
225    fn test_section_manager_add() {
226        let mut mgr = SectionManager::new();
227        assert!(mgr.add_section("Intro", 0, 3).is_ok());
228        assert!(mgr.add_section("Body", 3, 5).is_ok());
229        assert_eq!(mgr.len(), 2);
230    }
231
232    #[test]
233    fn test_section_manager_overlap_detection() {
234        let mut mgr = SectionManager::new();
235        mgr.add_section("Intro", 0, 3).unwrap();
236        let result = mgr.add_section("Overlap", 2, 3);
237        assert!(result.is_err());
238        assert!(result.unwrap_err().contains("overlaps"));
239    }
240
241    #[test]
242    fn test_section_manager_no_overlap_adjacent() {
243        let mut mgr = SectionManager::new();
244        mgr.add_section("A", 0, 3).unwrap();
245        assert!(mgr.add_section("B", 3, 2).is_ok());
246    }
247
248    #[test]
249    fn test_section_manager_remove() {
250        let mut mgr = SectionManager::new();
251        mgr.add_section("Intro", 0, 3).unwrap();
252        assert!(mgr.remove_section("Intro"));
253        assert!(mgr.is_empty());
254        assert!(!mgr.remove_section("NonExistent"));
255    }
256
257    #[test]
258    fn test_section_manager_get_section() {
259        let mut mgr = SectionManager::new();
260        mgr.add_section("Intro", 0, 3).unwrap();
261        let section = mgr.get_section("Intro");
262        assert!(section.is_some());
263        assert_eq!(section.unwrap().first_slide, 0);
264        assert!(mgr.get_section("Missing").is_none());
265    }
266
267    #[test]
268    fn test_section_manager_section_for_slide() {
269        let mut mgr = SectionManager::new();
270        mgr.add_section("Intro", 0, 3).unwrap();
271        mgr.add_section("Body", 3, 5).unwrap();
272        assert_eq!(mgr.section_for_slide(0).unwrap().name, "Intro");
273        assert_eq!(mgr.section_for_slide(4).unwrap().name, "Body");
274        assert!(mgr.section_for_slide(10).is_none());
275    }
276
277    #[test]
278    fn test_section_manager_sorted() {
279        let mut mgr = SectionManager::new();
280        mgr.add_section("Body", 3, 5).unwrap();
281        mgr.add_section("Intro", 0, 3).unwrap();
282        assert_eq!(mgr.sections()[0].name, "Intro");
283        assert_eq!(mgr.sections()[1].name, "Body");
284    }
285
286    #[test]
287    fn test_section_manager_clear() {
288        let mut mgr = SectionManager::new();
289        mgr.add_section("A", 0, 2).unwrap();
290        mgr.clear();
291        assert!(mgr.is_empty());
292    }
293
294    #[test]
295    fn test_section_manager_rename() {
296        let mut mgr = SectionManager::new();
297        mgr.add_section("Old", 0, 3).unwrap();
298        assert!(mgr.rename_section("Old", "New"));
299        assert!(mgr.get_section("New").is_some());
300        assert!(mgr.get_section("Old").is_none());
301        assert!(!mgr.rename_section("Missing", "X"));
302    }
303
304    #[test]
305    fn test_section_manager_xml_empty() {
306        let mgr = SectionManager::new();
307        assert_eq!(mgr.to_xml(10), "");
308    }
309
310    #[test]
311    fn test_section_manager_xml() {
312        let mut mgr = SectionManager::new();
313        mgr.add_section("Intro", 0, 2).unwrap();
314        mgr.add_section("Body", 2, 3).unwrap();
315        let xml = mgr.to_xml(5);
316        assert!(xml.contains("<p:extLst>"));
317        assert!(xml.contains("p14:sectionLst"));
318        assert!(xml.contains("Intro"));
319        assert!(xml.contains("Body"));
320        assert!(xml.contains("p14:sldId"));
321        assert!(xml.contains("</p:extLst>"));
322    }
323
324    #[test]
325    fn test_section_manager_xml_slide_ids() {
326        let mut mgr = SectionManager::new();
327        mgr.add_section("Intro", 0, 2).unwrap();
328        let xml = mgr.to_xml(5);
329        // Slide IDs start at 256
330        assert!(xml.contains(r#"id="256""#));
331        assert!(xml.contains(r#"id="257""#));
332    }
333
334    #[test]
335    fn test_sections_overlap_fn() {
336        let a = SlideSection::new("A", 0, 3);
337        let b = SlideSection::new("B", 2, 3);
338        assert!(sections_overlap(&a, &b));
339
340        let c = SlideSection::new("C", 3, 2);
341        assert!(!sections_overlap(&a, &c));
342    }
343
344    #[test]
345    fn test_sections_overlap_empty() {
346        let a = SlideSection::new("A", 0, 0);
347        let b = SlideSection::new("B", 0, 3);
348        assert!(!sections_overlap(&a, &b));
349    }
350
351    #[test]
352    fn test_generate_section_id_deterministic() {
353        let id1 = generate_section_id("Test");
354        let id2 = generate_section_id("Test");
355        assert_eq!(id1, id2);
356    }
357
358    #[test]
359    fn test_generate_section_id_unique() {
360        let id1 = generate_section_id("Intro");
361        let id2 = generate_section_id("Body");
362        assert_ne!(id1, id2);
363    }
364
365    #[test]
366    fn test_section_xml_escaping() {
367        let mut mgr = SectionManager::new();
368        mgr.add_section("Q&A <Session>", 0, 1).unwrap();
369        let xml = mgr.to_xml(1);
370        assert!(xml.contains("Q&amp;A &lt;Session&gt;"));
371    }
372}