1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::format::{SidecarError, SidecarFormat};
7use crate::types::{InkStrokeMeta, PresentationMetadata, SlideGroupMeta, TextBoxMeta};
8
9pub struct DaisFormat;
11
12#[derive(Serialize, Deserialize)]
13struct DaisFile {
14 version: u32,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 title: Option<String>,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 end_slide: Option<usize>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 last_minutes: Option<u32>,
21 #[serde(default, skip_serializing_if = "Vec::is_empty")]
22 groups: Vec<DaisGroup>,
23 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
24 notes: HashMap<String, String>,
25 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
26 slide_timings: HashMap<String, f64>,
27 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
28 slide_target_durations: HashMap<String, f64>,
29 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
30 slide_annotations: HashMap<String, Vec<DaisInkStroke>>,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 whiteboard_annotations: Vec<DaisInkStroke>,
33 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34 slide_text_boxes: HashMap<String, Vec<DaisTextBox>>,
35}
36
37#[derive(Serialize, Deserialize)]
38struct DaisGroup {
39 start_page: usize,
40 end_page: usize,
41}
42
43#[derive(Serialize, Deserialize)]
44struct DaisInkStroke {
45 points: Vec<(f32, f32)>,
46 color: [u8; 4],
47 width: f32,
48}
49
50#[derive(Serialize, Deserialize)]
51struct DaisTextBox {
52 id: u64,
53 rect: (f32, f32, f32, f32),
54 content: String,
55 font_size: f32,
56 color: [u8; 4],
57 #[serde(skip_serializing_if = "Option::is_none")]
58 background: Option<[u8; 4]>,
59 #[serde(default, skip_serializing_if = "String::is_empty")]
60 typst_prelude: String,
61}
62
63impl DaisFile {
66 fn from_metadata(meta: &PresentationMetadata) -> Self {
67 Self {
68 version: 1,
69 title: meta.title.clone(),
70 end_slide: meta.end_slide.map(to_one_based),
71 last_minutes: meta.last_minutes,
72 groups: meta
73 .groups
74 .iter()
75 .map(|g| DaisGroup {
76 start_page: to_one_based(g.start_page),
77 end_page: to_one_based(g.end_page),
78 })
79 .collect(),
80 notes: one_based_map(&meta.notes),
81 slide_timings: one_based_f64_map(&meta.slide_timings),
82 slide_target_durations: meta
83 .slide_target_durations
84 .iter()
85 .map(|(k, v)| (to_one_based(*k).to_string(), *v))
86 .collect(),
87 slide_annotations: meta
88 .slide_annotations
89 .iter()
90 .filter(|(_, strokes)| !strokes.is_empty())
91 .map(|(k, v)| {
92 (
93 to_one_based(*k).to_string(),
94 v.iter()
95 .map(|s| DaisInkStroke {
96 points: s.points.clone(),
97 color: s.color,
98 width: s.width,
99 })
100 .collect(),
101 )
102 })
103 .collect(),
104 whiteboard_annotations: meta
105 .whiteboard_annotations
106 .iter()
107 .map(|s| DaisInkStroke { points: s.points.clone(), color: s.color, width: s.width })
108 .collect(),
109 slide_text_boxes: meta
110 .slide_text_boxes
111 .iter()
112 .filter(|(_, boxes)| !boxes.is_empty())
113 .map(|(k, v)| {
114 (
115 to_one_based(*k).to_string(),
116 v.iter()
117 .map(|tb| DaisTextBox {
118 id: tb.id,
119 rect: tb.rect,
120 content: tb.content.clone(),
121 font_size: tb.font_size,
122 color: tb.color,
123 background: tb.background,
124 typst_prelude: tb.typst_prelude.clone(),
125 })
126 .collect(),
127 )
128 })
129 .collect(),
130 }
131 }
132
133 fn into_metadata(self) -> PresentationMetadata {
134 PresentationMetadata {
135 title: self.title,
136 end_slide: self.end_slide.and_then(to_zero_based),
137 last_minutes: self.last_minutes,
138 groups: self
139 .groups
140 .into_iter()
141 .filter_map(|g| {
142 Some(SlideGroupMeta {
143 start_page: to_zero_based(g.start_page)?,
144 end_page: to_zero_based(g.end_page)?,
145 })
146 })
147 .collect(),
148 notes: self
149 .notes
150 .into_iter()
151 .filter_map(|(k, v)| parse_one_based_key(&k).map(|idx| (idx, v)))
152 .collect(),
153 slide_timings: self
154 .slide_timings
155 .into_iter()
156 .filter_map(|(k, v)| parse_one_based_key(&k).map(|idx| (idx, v)))
157 .collect(),
158 slide_target_durations: self
159 .slide_target_durations
160 .into_iter()
161 .filter_map(|(k, v)| parse_one_based_key(&k).map(|idx| (idx, v)))
162 .collect(),
163 slide_annotations: self
164 .slide_annotations
165 .into_iter()
166 .filter_map(|(k, v)| {
167 parse_one_based_key(&k).map(|idx| {
168 (
169 idx,
170 v.into_iter()
171 .map(|s| InkStrokeMeta {
172 points: s.points,
173 color: s.color,
174 width: s.width,
175 })
176 .collect(),
177 )
178 })
179 })
180 .collect(),
181 whiteboard_annotations: self
182 .whiteboard_annotations
183 .into_iter()
184 .map(|s| InkStrokeMeta { points: s.points, color: s.color, width: s.width })
185 .collect(),
186 slide_text_boxes: self
187 .slide_text_boxes
188 .into_iter()
189 .filter_map(|(k, v)| {
190 parse_one_based_key(&k).map(|idx| {
191 (
192 idx,
193 v.into_iter()
194 .map(|tb| TextBoxMeta {
195 id: tb.id,
196 rect: tb.rect,
197 content: tb.content,
198 font_size: tb.font_size,
199 color: tb.color,
200 background: tb.background,
201 typst_prelude: tb.typst_prelude,
202 })
203 .collect(),
204 )
205 })
206 })
207 .collect(),
208 }
209 }
210}
211
212fn to_one_based(index: usize) -> usize {
213 index + 1
214}
215
216fn to_zero_based(index: usize) -> Option<usize> {
217 index.checked_sub(1)
218}
219
220fn parse_one_based_key(key: &str) -> Option<usize> {
221 key.parse::<usize>().ok().and_then(to_zero_based)
222}
223
224fn one_based_map(map: &HashMap<usize, String>) -> HashMap<String, String> {
225 map.iter().map(|(k, v)| (to_one_based(*k).to_string(), v.clone())).collect()
226}
227
228fn one_based_f64_map(map: &HashMap<usize, f64>) -> HashMap<String, f64> {
229 map.iter().map(|(k, v)| (to_one_based(*k).to_string(), *v)).collect()
230}
231
232impl SidecarFormat for DaisFormat {
233 fn read(&self, path: &Path) -> Result<PresentationMetadata, SidecarError> {
234 let content = std::fs::read_to_string(path)?;
235 let file: DaisFile = eon::from_str(&content)
236 .map_err(|err| SidecarError::Parse { line: 0, message: err.to_string() })?;
237 Ok(file.into_metadata())
238 }
239
240 fn write(&self, path: &Path, metadata: &PresentationMetadata) -> Result<(), SidecarError> {
241 let file = DaisFile::from_metadata(metadata);
242 let options = eon::FormatOptions::default();
243 let content = eon::to_string(&file, &options)
244 .map_err(|err| SidecarError::Parse { line: 0, message: err.to_string() })?;
245 std::fs::write(path, content)?;
246 Ok(())
247 }
248
249 fn file_extension(&self) -> &'static str {
250 "dais"
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn test_dir() -> std::path::PathBuf {
259 let dir = std::env::temp_dir().join("dais_test_dais_format");
260 let _ = std::fs::create_dir_all(&dir);
261 dir
262 }
263
264 #[test]
265 fn roundtrip_empty_metadata() {
266 let dir = test_dir();
267 let path = dir.join("empty.dais");
268 let format = DaisFormat;
269
270 let original = PresentationMetadata::default();
271 format.write(&path, &original).unwrap();
272 let loaded = format.read(&path).unwrap();
273
274 assert!(loaded.title.is_none());
275 assert!(loaded.groups.is_empty());
276 assert!(loaded.notes.is_empty());
277 assert!(loaded.end_slide.is_none());
278 assert!(loaded.last_minutes.is_none());
279 assert!(loaded.slide_timings.is_empty());
280 assert!(loaded.slide_target_durations.is_empty());
281
282 let _ = std::fs::remove_file(&path);
283 }
284
285 #[test]
286 fn roundtrip_with_all_fields() {
287 let dir = test_dir();
288 let path = dir.join("full.dais");
289 let format = DaisFormat;
290
291 let original = PresentationMetadata {
292 title: Some("My Presentation".to_string()),
293 end_slide: Some(25),
294 last_minutes: Some(20),
295 groups: vec![
296 SlideGroupMeta { start_page: 0, end_page: 2 },
297 SlideGroupMeta { start_page: 3, end_page: 3 },
298 ],
299 notes: {
300 let mut n = HashMap::new();
301 n.insert(0, "Welcome everyone".to_string());
302 n.insert(5, "Key point here".to_string());
303 n
304 },
305 slide_timings: {
306 let mut t = HashMap::new();
307 t.insert(0, 12.5);
308 t.insert(1, 45.0);
309 t
310 },
311 slide_target_durations: {
312 let mut t = HashMap::new();
313 t.insert(0, 60.0);
314 t.insert(1, 90.0);
315 t
316 },
317 slide_annotations: HashMap::new(),
318 whiteboard_annotations: Vec::new(),
319 slide_text_boxes: HashMap::new(),
320 };
321
322 format.write(&path, &original).unwrap();
323 let loaded = format.read(&path).unwrap();
324
325 assert_eq!(loaded.title.as_deref(), Some("My Presentation"));
326 assert_eq!(loaded.end_slide, Some(25));
327 assert_eq!(loaded.last_minutes, Some(20));
328 assert_eq!(loaded.groups.len(), 2);
329 assert_eq!(loaded.groups[0].start_page, 0);
330 assert_eq!(loaded.groups[0].end_page, 2);
331 assert_eq!(loaded.groups[1].start_page, 3);
332 assert_eq!(loaded.groups[1].end_page, 3);
333 assert_eq!(loaded.notes.len(), 2);
334 assert_eq!(loaded.notes[&0], "Welcome everyone");
335 assert_eq!(loaded.notes[&5], "Key point here");
336 assert_eq!(loaded.slide_timings.len(), 2);
337 assert!((loaded.slide_timings[&0] - 12.5).abs() < f64::EPSILON);
338 assert!((loaded.slide_timings[&1] - 45.0).abs() < f64::EPSILON);
339 assert_eq!(loaded.slide_target_durations.len(), 2);
340 assert!((loaded.slide_target_durations[&0] - 60.0).abs() < f64::EPSILON);
341 assert!((loaded.slide_target_durations[&1] - 90.0).abs() < f64::EPSILON);
342
343 let _ = std::fs::remove_file(&path);
344 }
345
346 #[test]
347 fn written_indexes_are_one_based() {
348 let dir = test_dir();
349 let path = dir.join("one_based.dais");
350 let format = DaisFormat;
351
352 let mut notes = HashMap::new();
353 notes.insert(0, "First".to_string());
354 let mut slide_timings = HashMap::new();
355 slide_timings.insert(0, 12.0);
356 let mut slide_target_durations = HashMap::new();
357 slide_target_durations.insert(0, 30.0);
358 let mut slide_annotations = HashMap::new();
359 slide_annotations.insert(
360 0,
361 vec![InkStrokeMeta { points: vec![(0.1, 0.2)], color: [0, 0, 0, 255], width: 2.0 }],
362 );
363 let mut slide_text_boxes = HashMap::new();
364 slide_text_boxes.insert(
365 0,
366 vec![TextBoxMeta {
367 id: 1,
368 rect: (0.0, 0.0, 1.0, 1.0),
369 content: "Box".to_string(),
370 font_size: 12.0,
371 color: [0, 0, 0, 255],
372 background: None,
373 typst_prelude: String::new(),
374 }],
375 );
376
377 let original = PresentationMetadata {
378 end_slide: Some(0),
379 groups: vec![SlideGroupMeta { start_page: 0, end_page: 0 }],
380 notes,
381 slide_timings,
382 slide_target_durations,
383 slide_annotations,
384 slide_text_boxes,
385 ..Default::default()
386 };
387
388 format.write(&path, &original).unwrap();
389 let content = std::fs::read_to_string(&path).unwrap();
390
391 assert!(content.contains("end_slide: 1"));
392 assert!(content.contains("start_page: 1"));
393 assert!(content.contains("end_page: 1"));
394 assert!(content.contains("\"1\": \"First\""));
395 assert!(content.contains("slide_timings: {\n\t\"1\": 12.0"));
396 assert!(content.contains("slide_target_durations: {\n\t\"1\": 30.0"));
397 assert!(content.contains("slide_annotations: {\n\t\"1\": ["));
398 assert!(content.contains("slide_text_boxes: {\n\t\"1\": ["));
399
400 let _ = std::fs::remove_file(&path);
401 }
402
403 #[test]
404 fn version_field_is_present() {
405 let dir = test_dir();
406 let path = dir.join("version_check.dais");
407 let format = DaisFormat;
408
409 format.write(&path, &PresentationMetadata::default()).unwrap();
410 let content = std::fs::read_to_string(&path).unwrap();
411 assert!(content.contains("version: 1"));
412
413 let _ = std::fs::remove_file(&path);
414 }
415
416 #[test]
417 fn unknown_version_still_parses() {
418 let dir = test_dir();
419 let path = dir.join("future_version.dais");
420
421 let content = "version: 2\ntitle: \"Future talk\"\n";
422 std::fs::write(&path, content).unwrap();
423
424 let format = DaisFormat;
425 let loaded = format.read(&path).unwrap();
426 assert_eq!(loaded.title.as_deref(), Some("Future talk"));
427
428 let _ = std::fs::remove_file(&path);
429 }
430
431 #[test]
432 fn roundtrip_slide_annotations() {
433 let dir = test_dir();
434 let path = dir.join("annotations.dais");
435 let format = DaisFormat;
436
437 let mut slide_annotations = HashMap::new();
438 slide_annotations.insert(
439 0,
440 vec![InkStrokeMeta {
441 points: vec![(0.1, 0.2), (0.3, 0.4)],
442 color: [255, 0, 0, 255],
443 width: 3.0,
444 }],
445 );
446 slide_annotations.insert(
447 5,
448 vec![
449 InkStrokeMeta { points: vec![(0.5, 0.5)], color: [0, 255, 0, 255], width: 2.0 },
450 InkStrokeMeta {
451 points: vec![(0.7, 0.8), (0.9, 0.1)],
452 color: [0, 0, 255, 128],
453 width: 5.0,
454 },
455 ],
456 );
457
458 let original = PresentationMetadata { slide_annotations, ..Default::default() };
459
460 format.write(&path, &original).unwrap();
461 let loaded = format.read(&path).unwrap();
462
463 assert_eq!(loaded.slide_annotations.len(), 2);
464 assert_eq!(loaded.slide_annotations[&0].len(), 1);
465 assert_eq!(loaded.slide_annotations[&0][0].points, vec![(0.1, 0.2), (0.3, 0.4)]);
466 assert_eq!(loaded.slide_annotations[&0][0].color, [255, 0, 0, 255]);
467 assert!((loaded.slide_annotations[&0][0].width - 3.0).abs() < f32::EPSILON);
468 assert_eq!(loaded.slide_annotations[&5].len(), 2);
469
470 let _ = std::fs::remove_file(&path);
471 }
472
473 #[test]
474 fn roundtrip_whiteboard_annotations() {
475 let dir = test_dir();
476 let path = dir.join("whiteboard.dais");
477 let format = DaisFormat;
478
479 let original = PresentationMetadata {
480 whiteboard_annotations: vec![InkStrokeMeta {
481 points: vec![(0.1, 0.1), (0.9, 0.9)],
482 color: [0, 0, 0, 255],
483 width: 4.0,
484 }],
485 ..Default::default()
486 };
487
488 format.write(&path, &original).unwrap();
489 let loaded = format.read(&path).unwrap();
490
491 assert_eq!(loaded.whiteboard_annotations.len(), 1);
492 assert_eq!(loaded.whiteboard_annotations[0].points, vec![(0.1, 0.1), (0.9, 0.9)]);
493 assert_eq!(loaded.whiteboard_annotations[0].color, [0, 0, 0, 255]);
494 assert!((loaded.whiteboard_annotations[0].width - 4.0).abs() < f32::EPSILON);
495
496 let _ = std::fs::remove_file(&path);
497 }
498
499 #[test]
500 fn alpha_roundtrips_for_slide_annotations() {
501 let dir = test_dir();
502 let path = dir.join("alpha_slide.dais");
503 let format = DaisFormat;
504
505 let mut slide_annotations = HashMap::new();
506 slide_annotations.insert(
507 0,
508 vec![InkStrokeMeta {
509 points: vec![(0.1, 0.2)],
510 color: [255, 128, 0, 77], width: 3.0,
512 }],
513 );
514 let original = PresentationMetadata { slide_annotations, ..Default::default() };
515
516 format.write(&path, &original).unwrap();
517 let loaded = format.read(&path).unwrap();
518
519 let stroke = &loaded.slide_annotations[&0][0];
520 assert_eq!(stroke.color, [255, 128, 0, 77], "RGBA including alpha must roundtrip exactly");
521
522 let _ = std::fs::remove_file(&path);
523 }
524
525 #[test]
526 fn alpha_roundtrips_for_whiteboard_annotations() {
527 let dir = test_dir();
528 let path = dir.join("alpha_whiteboard.dais");
529 let format = DaisFormat;
530
531 let original = PresentationMetadata {
532 whiteboard_annotations: vec![InkStrokeMeta {
533 points: vec![(0.5, 0.5)],
534 color: [0, 200, 255, 51], width: 8.0,
536 }],
537 ..Default::default()
538 };
539
540 format.write(&path, &original).unwrap();
541 let loaded = format.read(&path).unwrap();
542
543 let stroke = &loaded.whiteboard_annotations[0];
544 assert_eq!(stroke.color, [0, 200, 255, 51], "Whiteboard RGBA + alpha must roundtrip");
545 assert!((stroke.width - 8.0).abs() < f32::EPSILON, "Whiteboard width must roundtrip");
546
547 let _ = std::fs::remove_file(&path);
548 }
549
550 #[test]
551 fn width_roundtrips_for_non_default_values() {
552 let dir = test_dir();
553 let path = dir.join("width.dais");
554 let format = DaisFormat;
555
556 let mut slide_annotations = HashMap::new();
557 slide_annotations.insert(
558 2,
559 vec![InkStrokeMeta { points: vec![(0.0, 0.0)], color: [0, 0, 0, 255], width: 12.5 }],
560 );
561 let original = PresentationMetadata { slide_annotations, ..Default::default() };
562
563 format.write(&path, &original).unwrap();
564 let loaded = format.read(&path).unwrap();
565
566 let stroke = &loaded.slide_annotations[&2][0];
567 assert!((stroke.width - 12.5).abs() < f32::EPSILON, "Non-default width must roundtrip");
568
569 let _ = std::fs::remove_file(&path);
570 }
571
572 #[test]
573 fn missing_annotation_fields_parse_cleanly() {
574 let dir = test_dir();
575 let path = dir.join("no_annotations.dais");
576
577 let content = "version: 1\ntitle: \"No annotations\"\n";
579 std::fs::write(&path, content).unwrap();
580
581 let format = DaisFormat;
582 let loaded = format.read(&path).unwrap();
583 assert_eq!(loaded.title.as_deref(), Some("No annotations"));
584 assert!(loaded.slide_annotations.is_empty());
585 assert!(loaded.whiteboard_annotations.is_empty());
586
587 let _ = std::fs::remove_file(&path);
588 }
589
590 #[test]
591 fn text_box_typst_prelude_roundtrips() {
592 let dir = test_dir();
593 let path = dir.join("text_box_prelude.dais");
594 let format = DaisFormat;
595
596 let mut slide_text_boxes = HashMap::new();
597 slide_text_boxes.insert(
598 0,
599 vec![TextBoxMeta {
600 id: 7,
601 rect: (0.1, 0.2, 0.3, 0.4),
602 content: "$pi r^2$".to_string(),
603 font_size: 24.0,
604 color: [0, 0, 0, 255],
605 background: None,
606 typst_prelude: "#set align(horizon)".to_string(),
607 }],
608 );
609 let original = PresentationMetadata { slide_text_boxes, ..Default::default() };
610
611 format.write(&path, &original).unwrap();
612 let content = std::fs::read_to_string(&path).unwrap();
613 assert!(content.contains("typst_prelude"));
614
615 let loaded = format.read(&path).unwrap();
616 assert_eq!(loaded.slide_text_boxes[&0][0].typst_prelude, "#set align(horizon)");
617
618 let _ = std::fs::remove_file(&path);
619 }
620
621 #[test]
622 fn text_box_without_typst_prelude_parses_as_empty() {
623 let dir = test_dir();
624 let path = dir.join("text_box_no_prelude.dais");
625 let format = DaisFormat;
626
627 let mut slide_text_boxes = HashMap::new();
628 slide_text_boxes.insert(
629 0,
630 vec![TextBoxMeta {
631 id: 1,
632 rect: (0.1, 0.2, 0.3, 0.4),
633 content: "Hello".to_string(),
634 font_size: 20.0,
635 color: [0, 0, 0, 255],
636 background: None,
637 typst_prelude: String::new(),
638 }],
639 );
640 let original = PresentationMetadata { slide_text_boxes, ..Default::default() };
641 format.write(&path, &original).unwrap();
642
643 let content = std::fs::read_to_string(&path).unwrap();
644 assert!(!content.contains("typst_prelude"));
645
646 let loaded = format.read(&path).unwrap();
647 assert_eq!(loaded.slide_text_boxes[&0][0].typst_prelude, "");
648
649 let _ = std::fs::remove_file(&path);
650 }
651
652 #[test]
653 fn existing_fields_roundtrip_with_annotations() {
654 let dir = test_dir();
655 let path = dir.join("full_with_annotations.dais");
656 let format = DaisFormat;
657
658 let mut slide_annotations = HashMap::new();
659 slide_annotations.insert(
660 0,
661 vec![InkStrokeMeta { points: vec![(0.1, 0.2)], color: [255, 0, 0, 255], width: 3.0 }],
662 );
663
664 let original = PresentationMetadata {
665 title: Some("With Annotations".to_string()),
666 end_slide: Some(10),
667 last_minutes: Some(15),
668 groups: vec![SlideGroupMeta { start_page: 0, end_page: 2 }],
669 notes: {
670 let mut n = HashMap::new();
671 n.insert(0, "Note".to_string());
672 n
673 },
674 slide_timings: {
675 let mut t = HashMap::new();
676 t.insert(0, 5.0);
677 t
678 },
679 slide_target_durations: {
680 let mut t = HashMap::new();
681 t.insert(0, 30.0);
682 t
683 },
684 slide_annotations,
685 whiteboard_annotations: vec![InkStrokeMeta {
686 points: vec![(0.5, 0.5)],
687 color: [0, 0, 255, 255],
688 width: 2.0,
689 }],
690 slide_text_boxes: HashMap::new(),
691 };
692
693 format.write(&path, &original).unwrap();
694 let loaded = format.read(&path).unwrap();
695
696 assert_eq!(loaded.title.as_deref(), Some("With Annotations"));
698 assert_eq!(loaded.end_slide, Some(10));
699 assert_eq!(loaded.last_minutes, Some(15));
700 assert_eq!(loaded.groups.len(), 1);
701 assert_eq!(loaded.notes.len(), 1);
702 assert_eq!(loaded.slide_timings.len(), 1);
703 assert_eq!(loaded.slide_target_durations.len(), 1);
704
705 assert_eq!(loaded.slide_annotations.len(), 1);
707 assert_eq!(loaded.whiteboard_annotations.len(), 1);
708
709 let _ = std::fs::remove_file(&path);
710 }
711}