dais_sidecar/pdfpc/
parser.rs1use 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
23pub 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 if trimmed.starts_with('[') && trimmed.ends_with(']') {
35 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 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 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 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 }
106 }
107 }
108
109 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}