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("slide_durations" | "slide_duration" | "slide_target_durations") => {
94                if let Some((slide, seconds)) = parse_indexed_seconds(trimmed) {
95                    metadata.slide_target_durations.insert(slide, seconds);
96                }
97            }
98            Some("end_slide" | "end_user_slide") => {
99                if let Ok(page) = trimmed.parse::<usize>() {
100                    metadata.end_slide = Some(page.saturating_sub(1));
101                }
102            }
103            _ => {
104                // Unknown section — ignore gracefully (forward compatibility)
105            }
106        }
107    }
108
109    // Flush final pending note
110    if let Some(page) = current_note_page {
111        let note = current_note_text.trim().to_string();
112        if !note.is_empty() {
113            metadata.notes.insert(page, note);
114        }
115    }
116
117    metadata
118}
119
120fn parse_indexed_seconds(line: &str) -> Option<(usize, f64)> {
121    let mut parts = line.split_whitespace();
122    let slide = parts.next()?.parse::<usize>().ok()?.saturating_sub(1);
123    let seconds = parts.next()?.parse::<f64>().ok()?;
124    if seconds.is_finite() && seconds > 0.0 { Some((slide, seconds)) } else { None }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn parse_basic_pdfpc() {
133        let content = "\
134[file]
135test.pdf
136[notes]
137### 1
138First slide notes
139### 2
140Second slide notes
141with multiple lines
142[overlay]
1431 3
1444 5
145[duration]
14620
147[slide_durations]
1481 60
1492 90.5
150";
151        let meta = parse_pdfpc_str(content);
152        assert_eq!(meta.title.as_deref(), Some("test.pdf"));
153        assert_eq!(meta.notes.len(), 2);
154        assert_eq!(meta.notes[&0], "First slide notes");
155        assert_eq!(meta.notes[&1], "Second slide notes\nwith multiple lines");
156        assert_eq!(meta.groups.len(), 2);
157        assert_eq!(meta.groups[0].start_page, 0);
158        assert_eq!(meta.groups[0].end_page, 2);
159        assert_eq!(meta.groups[1].start_page, 3);
160        assert_eq!(meta.groups[1].end_page, 4);
161        assert_eq!(meta.last_minutes, Some(20));
162        assert_eq!(meta.slide_target_durations.len(), 2);
163        assert!((meta.slide_target_durations[&0] - 60.0).abs() < f64::EPSILON);
164        assert!((meta.slide_target_durations[&1] - 90.5).abs() < f64::EPSILON);
165    }
166
167    #[test]
168    fn parse_empty_pdfpc() {
169        let meta = parse_pdfpc_str("");
170        assert!(meta.title.is_none());
171        assert!(meta.notes.is_empty());
172        assert!(meta.groups.is_empty());
173    }
174
175    #[test]
176    fn unknown_sections_ignored() {
177        let content = "\
178[file]
179test.pdf
180[unknown_future_section]
181some data
182[notes]
183### 1
184Note here
185";
186        let meta = parse_pdfpc_str(content);
187        assert_eq!(meta.title.as_deref(), Some("test.pdf"));
188        assert_eq!(meta.notes.len(), 1);
189    }
190
191    #[test]
192    fn round_trip_write_and_read() {
193        use crate::format::SidecarFormat;
194        let format = PdfpcFormat;
195        let original = PresentationMetadata {
196            title: Some("test.pdf".to_string()),
197            last_minutes: Some(30),
198            end_slide: Some(9),
199            groups: vec![
200                crate::types::SlideGroupMeta { start_page: 0, end_page: 2 },
201                crate::types::SlideGroupMeta { start_page: 3, end_page: 5 },
202            ],
203            notes: {
204                let mut n = std::collections::HashMap::new();
205                n.insert(0, "First slide".to_string());
206                n.insert(3, "Fourth slide".to_string());
207                n
208            },
209            slide_timings: std::collections::HashMap::new(),
210            slide_target_durations: std::collections::HashMap::new(),
211            slide_annotations: std::collections::HashMap::new(),
212            whiteboard_annotations: Vec::new(),
213            slide_text_boxes: std::collections::HashMap::new(),
214        };
215
216        let dir = std::env::temp_dir().join("dais_test_roundtrip");
217        let _ = std::fs::create_dir_all(&dir);
218        let path = dir.join("roundtrip.pdfpc");
219        format.write(&path, &original).unwrap();
220        let loaded = format.read(&path).unwrap();
221
222        assert_eq!(loaded.title.as_deref(), Some("test.pdf"));
223        assert_eq!(loaded.last_minutes, Some(30));
224        assert_eq!(loaded.end_slide, Some(9));
225        assert_eq!(loaded.groups.len(), 2);
226        assert_eq!(loaded.groups[0].start_page, 0);
227        assert_eq!(loaded.groups[0].end_page, 2);
228        assert_eq!(loaded.notes.len(), 2);
229        assert_eq!(loaded.notes[&0], "First slide");
230        assert_eq!(loaded.notes[&3], "Fourth slide");
231        assert!(loaded.slide_target_durations.is_empty());
232
233        let _ = std::fs::remove_dir_all(dir);
234    }
235}