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("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 }
101 }
102 }
103
104 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}