Skip to main content

dais_sidecar/pdfpc/
parser.rs

1use std::path::Path;
2
3use crate::format::{SidecarError, SidecarFormat};
4use crate::types::PresentationMetadata;
5
6use super::PdfpcFormat;
7
8impl SidecarFormat for PdfpcFormat {
9    fn read(&self, path: &Path) -> Result<PresentationMetadata, SidecarError> {
10        let content = std::fs::read_to_string(path)?;
11        Ok(parse_pdfpc_str(&content))
12    }
13
14    fn write(&self, path: &Path, metadata: &PresentationMetadata) -> Result<(), SidecarError> {
15        crate::pdfpc::writer::write_pdfpc(path, metadata)
16    }
17
18    fn file_extension(&self) -> &'static str {
19        "pdfpc"
20    }
21}
22
23/// Parse `.pdfpc` file content into presentation metadata.
24pub fn parse_pdfpc_str(content: &str) -> PresentationMetadata {
25    let mut metadata = PresentationMetadata::default();
26    let mut current_section: Option<String> = None;
27    let mut current_note_page: Option<usize> = None;
28    let mut current_note_text = String::new();
29
30    for line in content.lines() {
31        let trimmed = line.trim();
32
33        // Section headers
34        if trimmed.starts_with('[') && trimmed.ends_with(']') {
35            // Flush any pending note
36            if let Some(page) = current_note_page.take() {
37                let note = current_note_text.trim().to_string();
38                if !note.is_empty() {
39                    metadata.notes.insert(page, note);
40                }
41                current_note_text.clear();
42            }
43
44            let section = trimmed[1..trimmed.len() - 1].to_string();
45            current_section = Some(section);
46            continue;
47        }
48
49        match current_section.as_deref() {
50            Some("file") if !trimmed.is_empty() => {
51                metadata.title = Some(trimmed.to_string());
52            }
53            Some("file") => {}
54            Some("notes") => {
55                if let Some(rest) = trimmed.strip_prefix("### ") {
56                    // Flush previous note
57                    if let Some(page) = current_note_page.take() {
58                        let note = current_note_text.trim().to_string();
59                        if !note.is_empty() {
60                            metadata.notes.insert(page, note);
61                        }
62                        current_note_text.clear();
63                    }
64                    // Parse page number (1-based in file, 0-based internally)
65                    if let Ok(page_num) = rest.trim().parse::<usize>() {
66                        current_note_page = Some(page_num.saturating_sub(1));
67                    }
68                } else if current_note_page.is_some() {
69                    if !current_note_text.is_empty() {
70                        current_note_text.push('\n');
71                    }
72                    current_note_text.push_str(line);
73                }
74            }
75            Some("overlay") => {
76                // Format: "start_page end_page" (1-based)
77                let parts: Vec<&str> = trimmed.split_whitespace().collect();
78                if parts.len() == 2
79                    && let (Ok(start), Ok(end)) =
80                        (parts[0].parse::<usize>(), parts[1].parse::<usize>())
81                {
82                    metadata.groups.push(crate::types::SlideGroupMeta {
83                        start_page: start.saturating_sub(1),
84                        end_page: end.saturating_sub(1),
85                    });
86                }
87            }
88            Some("duration") => {
89                if let Ok(minutes) = trimmed.parse::<u32>() {
90                    metadata.last_minutes = Some(minutes);
91                }
92            }
93            Some("end_slide" | "end_user_slide") => {
94                if let Ok(page) = trimmed.parse::<usize>() {
95                    metadata.end_slide = Some(page.saturating_sub(1));
96                }
97            }
98            _ => {
99                // Unknown section — ignore gracefully (forward compatibility)
100            }
101        }
102    }
103
104    // Flush final pending note
105    if let Some(page) = current_note_page {
106        let note = current_note_text.trim().to_string();
107        if !note.is_empty() {
108            metadata.notes.insert(page, note);
109        }
110    }
111
112    metadata
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn parse_basic_pdfpc() {
121        let content = "\
122[file]
123test.pdf
124[notes]
125### 1
126First slide notes
127### 2
128Second slide notes
129with multiple lines
130[overlay]
1311 3
1324 5
133[duration]
13420
135";
136        let meta = parse_pdfpc_str(content);
137        assert_eq!(meta.title.as_deref(), Some("test.pdf"));
138        assert_eq!(meta.notes.len(), 2);
139        assert_eq!(meta.notes[&0], "First slide notes");
140        assert_eq!(meta.notes[&1], "Second slide notes\nwith multiple lines");
141        assert_eq!(meta.groups.len(), 2);
142        assert_eq!(meta.groups[0].start_page, 0);
143        assert_eq!(meta.groups[0].end_page, 2);
144        assert_eq!(meta.groups[1].start_page, 3);
145        assert_eq!(meta.groups[1].end_page, 4);
146        assert_eq!(meta.last_minutes, Some(20));
147    }
148
149    #[test]
150    fn parse_empty_pdfpc() {
151        let meta = parse_pdfpc_str("");
152        assert!(meta.title.is_none());
153        assert!(meta.notes.is_empty());
154        assert!(meta.groups.is_empty());
155    }
156
157    #[test]
158    fn unknown_sections_ignored() {
159        let content = "\
160[file]
161test.pdf
162[unknown_future_section]
163some data
164[notes]
165### 1
166Note here
167";
168        let meta = parse_pdfpc_str(content);
169        assert_eq!(meta.title.as_deref(), Some("test.pdf"));
170        assert_eq!(meta.notes.len(), 1);
171    }
172
173    #[test]
174    fn round_trip_write_and_read() {
175        use crate::format::SidecarFormat;
176        let format = PdfpcFormat;
177        let original = PresentationMetadata {
178            title: Some("test.pdf".to_string()),
179            last_minutes: Some(30),
180            end_slide: Some(9),
181            groups: vec![
182                crate::types::SlideGroupMeta { start_page: 0, end_page: 2 },
183                crate::types::SlideGroupMeta { start_page: 3, end_page: 5 },
184            ],
185            notes: {
186                let mut n = std::collections::HashMap::new();
187                n.insert(0, "First slide".to_string());
188                n.insert(3, "Fourth slide".to_string());
189                n
190            },
191            slide_timings: std::collections::HashMap::new(),
192            slide_annotations: std::collections::HashMap::new(),
193            whiteboard_annotations: Vec::new(),
194            slide_text_boxes: std::collections::HashMap::new(),
195        };
196
197        let dir = std::env::temp_dir().join("dais_test_roundtrip");
198        let _ = std::fs::create_dir_all(&dir);
199        let path = dir.join("roundtrip.pdfpc");
200        format.write(&path, &original).unwrap();
201        let loaded = format.read(&path).unwrap();
202
203        assert_eq!(loaded.title.as_deref(), Some("test.pdf"));
204        assert_eq!(loaded.last_minutes, Some(30));
205        assert_eq!(loaded.end_slide, Some(9));
206        assert_eq!(loaded.groups.len(), 2);
207        assert_eq!(loaded.groups[0].start_page, 0);
208        assert_eq!(loaded.groups[0].end_page, 2);
209        assert_eq!(loaded.notes.len(), 2);
210        assert_eq!(loaded.notes[&0], "First slide");
211        assert_eq!(loaded.notes[&3], "Fourth slide");
212
213        let _ = std::fs::remove_dir_all(dir);
214    }
215}