use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::format::{SidecarError, SidecarFormat};
use crate::types::{InkStrokeMeta, PresentationMetadata, SlideGroupMeta, TextBoxMeta};
pub struct DaisFormat;
#[derive(Serialize, Deserialize)]
struct DaisFile {
version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
end_slide: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
last_minutes: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
groups: Vec<DaisGroup>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
notes: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
slide_timings: HashMap<String, f64>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
slide_annotations: HashMap<String, Vec<DaisInkStroke>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
whiteboard_annotations: Vec<DaisInkStroke>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
slide_text_boxes: HashMap<String, Vec<DaisTextBox>>,
}
#[derive(Serialize, Deserialize)]
struct DaisGroup {
start_page: usize,
end_page: usize,
}
#[derive(Serialize, Deserialize)]
struct DaisInkStroke {
points: Vec<(f32, f32)>,
color: [u8; 4],
width: f32,
}
#[derive(Serialize, Deserialize)]
struct DaisTextBox {
id: u64,
rect: (f32, f32, f32, f32),
content: String,
font_size: f32,
color: [u8; 4],
#[serde(skip_serializing_if = "Option::is_none")]
background: Option<[u8; 4]>,
}
impl DaisFile {
fn from_metadata(meta: &PresentationMetadata) -> Self {
Self {
version: 1,
title: meta.title.clone(),
end_slide: meta.end_slide,
last_minutes: meta.last_minutes,
groups: meta
.groups
.iter()
.map(|g| DaisGroup { start_page: g.start_page, end_page: g.end_page })
.collect(),
notes: meta.notes.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
slide_timings: meta.slide_timings.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
slide_annotations: meta
.slide_annotations
.iter()
.filter(|(_, strokes)| !strokes.is_empty())
.map(|(k, v)| {
(
k.to_string(),
v.iter()
.map(|s| DaisInkStroke {
points: s.points.clone(),
color: s.color,
width: s.width,
})
.collect(),
)
})
.collect(),
whiteboard_annotations: meta
.whiteboard_annotations
.iter()
.map(|s| DaisInkStroke { points: s.points.clone(), color: s.color, width: s.width })
.collect(),
slide_text_boxes: meta
.slide_text_boxes
.iter()
.filter(|(_, boxes)| !boxes.is_empty())
.map(|(k, v)| {
(
k.to_string(),
v.iter()
.map(|tb| DaisTextBox {
id: tb.id,
rect: tb.rect,
content: tb.content.clone(),
font_size: tb.font_size,
color: tb.color,
background: tb.background,
})
.collect(),
)
})
.collect(),
}
}
fn into_metadata(self) -> PresentationMetadata {
PresentationMetadata {
title: self.title,
end_slide: self.end_slide,
last_minutes: self.last_minutes,
groups: self
.groups
.into_iter()
.map(|g| SlideGroupMeta { start_page: g.start_page, end_page: g.end_page })
.collect(),
notes: self
.notes
.into_iter()
.filter_map(|(k, v)| k.parse::<usize>().ok().map(|idx| (idx, v)))
.collect(),
slide_timings: self
.slide_timings
.into_iter()
.filter_map(|(k, v)| k.parse::<usize>().ok().map(|idx| (idx, v)))
.collect(),
slide_annotations: self
.slide_annotations
.into_iter()
.filter_map(|(k, v)| {
k.parse::<usize>().ok().map(|idx| {
(
idx,
v.into_iter()
.map(|s| InkStrokeMeta {
points: s.points,
color: s.color,
width: s.width,
})
.collect(),
)
})
})
.collect(),
whiteboard_annotations: self
.whiteboard_annotations
.into_iter()
.map(|s| InkStrokeMeta { points: s.points, color: s.color, width: s.width })
.collect(),
slide_text_boxes: self
.slide_text_boxes
.into_iter()
.filter_map(|(k, v)| {
k.parse::<usize>().ok().map(|idx| {
(
idx,
v.into_iter()
.map(|tb| TextBoxMeta {
id: tb.id,
rect: tb.rect,
content: tb.content,
font_size: tb.font_size,
color: tb.color,
background: tb.background,
})
.collect(),
)
})
})
.collect(),
}
}
}
impl SidecarFormat for DaisFormat {
fn read(&self, path: &Path) -> Result<PresentationMetadata, SidecarError> {
let content = std::fs::read_to_string(path)?;
let file: DaisFile = eon::from_str(&content)
.map_err(|err| SidecarError::Parse { line: 0, message: err.to_string() })?;
Ok(file.into_metadata())
}
fn write(&self, path: &Path, metadata: &PresentationMetadata) -> Result<(), SidecarError> {
let file = DaisFile::from_metadata(metadata);
let options = eon::FormatOptions::default();
let content = eon::to_string(&file, &options)
.map_err(|err| SidecarError::Parse { line: 0, message: err.to_string() })?;
std::fs::write(path, content)?;
Ok(())
}
fn file_extension(&self) -> &'static str {
"dais"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join("dais_test_dais_format");
let _ = std::fs::create_dir_all(&dir);
dir
}
#[test]
fn roundtrip_empty_metadata() {
let dir = test_dir();
let path = dir.join("empty.dais");
let format = DaisFormat;
let original = PresentationMetadata::default();
format.write(&path, &original).unwrap();
let loaded = format.read(&path).unwrap();
assert!(loaded.title.is_none());
assert!(loaded.groups.is_empty());
assert!(loaded.notes.is_empty());
assert!(loaded.end_slide.is_none());
assert!(loaded.last_minutes.is_none());
assert!(loaded.slide_timings.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn roundtrip_with_all_fields() {
let dir = test_dir();
let path = dir.join("full.dais");
let format = DaisFormat;
let original = PresentationMetadata {
title: Some("My Presentation".to_string()),
end_slide: Some(25),
last_minutes: Some(20),
groups: vec![
SlideGroupMeta { start_page: 0, end_page: 2 },
SlideGroupMeta { start_page: 3, end_page: 3 },
],
notes: {
let mut n = HashMap::new();
n.insert(0, "Welcome everyone".to_string());
n.insert(5, "Key point here".to_string());
n
},
slide_timings: {
let mut t = HashMap::new();
t.insert(0, 12.5);
t.insert(1, 45.0);
t
},
slide_annotations: HashMap::new(),
whiteboard_annotations: Vec::new(),
slide_text_boxes: HashMap::new(),
};
format.write(&path, &original).unwrap();
let loaded = format.read(&path).unwrap();
assert_eq!(loaded.title.as_deref(), Some("My Presentation"));
assert_eq!(loaded.end_slide, Some(25));
assert_eq!(loaded.last_minutes, Some(20));
assert_eq!(loaded.groups.len(), 2);
assert_eq!(loaded.groups[0].start_page, 0);
assert_eq!(loaded.groups[0].end_page, 2);
assert_eq!(loaded.groups[1].start_page, 3);
assert_eq!(loaded.groups[1].end_page, 3);
assert_eq!(loaded.notes.len(), 2);
assert_eq!(loaded.notes[&0], "Welcome everyone");
assert_eq!(loaded.notes[&5], "Key point here");
assert_eq!(loaded.slide_timings.len(), 2);
assert!((loaded.slide_timings[&0] - 12.5).abs() < f64::EPSILON);
assert!((loaded.slide_timings[&1] - 45.0).abs() < f64::EPSILON);
let _ = std::fs::remove_file(&path);
}
#[test]
fn version_field_is_present() {
let dir = test_dir();
let path = dir.join("version_check.dais");
let format = DaisFormat;
format.write(&path, &PresentationMetadata::default()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("version: 1"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn unknown_version_still_parses() {
let dir = test_dir();
let path = dir.join("future_version.dais");
let content = "version: 2\ntitle: \"Future talk\"\n";
std::fs::write(&path, content).unwrap();
let format = DaisFormat;
let loaded = format.read(&path).unwrap();
assert_eq!(loaded.title.as_deref(), Some("Future talk"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn roundtrip_slide_annotations() {
let dir = test_dir();
let path = dir.join("annotations.dais");
let format = DaisFormat;
let mut slide_annotations = HashMap::new();
slide_annotations.insert(
0,
vec![InkStrokeMeta {
points: vec![(0.1, 0.2), (0.3, 0.4)],
color: [255, 0, 0, 255],
width: 3.0,
}],
);
slide_annotations.insert(
5,
vec![
InkStrokeMeta { points: vec![(0.5, 0.5)], color: [0, 255, 0, 255], width: 2.0 },
InkStrokeMeta {
points: vec![(0.7, 0.8), (0.9, 0.1)],
color: [0, 0, 255, 128],
width: 5.0,
},
],
);
let original = PresentationMetadata { slide_annotations, ..Default::default() };
format.write(&path, &original).unwrap();
let loaded = format.read(&path).unwrap();
assert_eq!(loaded.slide_annotations.len(), 2);
assert_eq!(loaded.slide_annotations[&0].len(), 1);
assert_eq!(loaded.slide_annotations[&0][0].points, vec![(0.1, 0.2), (0.3, 0.4)]);
assert_eq!(loaded.slide_annotations[&0][0].color, [255, 0, 0, 255]);
assert!((loaded.slide_annotations[&0][0].width - 3.0).abs() < f32::EPSILON);
assert_eq!(loaded.slide_annotations[&5].len(), 2);
let _ = std::fs::remove_file(&path);
}
#[test]
fn roundtrip_whiteboard_annotations() {
let dir = test_dir();
let path = dir.join("whiteboard.dais");
let format = DaisFormat;
let original = PresentationMetadata {
whiteboard_annotations: vec![InkStrokeMeta {
points: vec![(0.1, 0.1), (0.9, 0.9)],
color: [0, 0, 0, 255],
width: 4.0,
}],
..Default::default()
};
format.write(&path, &original).unwrap();
let loaded = format.read(&path).unwrap();
assert_eq!(loaded.whiteboard_annotations.len(), 1);
assert_eq!(loaded.whiteboard_annotations[0].points, vec![(0.1, 0.1), (0.9, 0.9)]);
assert_eq!(loaded.whiteboard_annotations[0].color, [0, 0, 0, 255]);
assert!((loaded.whiteboard_annotations[0].width - 4.0).abs() < f32::EPSILON);
let _ = std::fs::remove_file(&path);
}
#[test]
fn alpha_roundtrips_for_slide_annotations() {
let dir = test_dir();
let path = dir.join("alpha_slide.dais");
let format = DaisFormat;
let mut slide_annotations = HashMap::new();
slide_annotations.insert(
0,
vec![InkStrokeMeta {
points: vec![(0.1, 0.2)],
color: [255, 128, 0, 77], width: 3.0,
}],
);
let original = PresentationMetadata { slide_annotations, ..Default::default() };
format.write(&path, &original).unwrap();
let loaded = format.read(&path).unwrap();
let stroke = &loaded.slide_annotations[&0][0];
assert_eq!(stroke.color, [255, 128, 0, 77], "RGBA including alpha must roundtrip exactly");
let _ = std::fs::remove_file(&path);
}
#[test]
fn alpha_roundtrips_for_whiteboard_annotations() {
let dir = test_dir();
let path = dir.join("alpha_whiteboard.dais");
let format = DaisFormat;
let original = PresentationMetadata {
whiteboard_annotations: vec![InkStrokeMeta {
points: vec![(0.5, 0.5)],
color: [0, 200, 255, 51], width: 8.0,
}],
..Default::default()
};
format.write(&path, &original).unwrap();
let loaded = format.read(&path).unwrap();
let stroke = &loaded.whiteboard_annotations[0];
assert_eq!(stroke.color, [0, 200, 255, 51], "Whiteboard RGBA + alpha must roundtrip");
assert!((stroke.width - 8.0).abs() < f32::EPSILON, "Whiteboard width must roundtrip");
let _ = std::fs::remove_file(&path);
}
#[test]
fn width_roundtrips_for_non_default_values() {
let dir = test_dir();
let path = dir.join("width.dais");
let format = DaisFormat;
let mut slide_annotations = HashMap::new();
slide_annotations.insert(
2,
vec![InkStrokeMeta { points: vec![(0.0, 0.0)], color: [0, 0, 0, 255], width: 12.5 }],
);
let original = PresentationMetadata { slide_annotations, ..Default::default() };
format.write(&path, &original).unwrap();
let loaded = format.read(&path).unwrap();
let stroke = &loaded.slide_annotations[&2][0];
assert!((stroke.width - 12.5).abs() < f32::EPSILON, "Non-default width must roundtrip");
let _ = std::fs::remove_file(&path);
}
#[test]
fn missing_annotation_fields_parse_cleanly() {
let dir = test_dir();
let path = dir.join("no_annotations.dais");
let content = "version: 1\ntitle: \"No annotations\"\n";
std::fs::write(&path, content).unwrap();
let format = DaisFormat;
let loaded = format.read(&path).unwrap();
assert_eq!(loaded.title.as_deref(), Some("No annotations"));
assert!(loaded.slide_annotations.is_empty());
assert!(loaded.whiteboard_annotations.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn existing_fields_roundtrip_with_annotations() {
let dir = test_dir();
let path = dir.join("full_with_annotations.dais");
let format = DaisFormat;
let mut slide_annotations = HashMap::new();
slide_annotations.insert(
0,
vec![InkStrokeMeta { points: vec![(0.1, 0.2)], color: [255, 0, 0, 255], width: 3.0 }],
);
let original = PresentationMetadata {
title: Some("With Annotations".to_string()),
end_slide: Some(10),
last_minutes: Some(15),
groups: vec![SlideGroupMeta { start_page: 0, end_page: 2 }],
notes: {
let mut n = HashMap::new();
n.insert(0, "Note".to_string());
n
},
slide_timings: {
let mut t = HashMap::new();
t.insert(0, 5.0);
t
},
slide_annotations,
whiteboard_annotations: vec![InkStrokeMeta {
points: vec![(0.5, 0.5)],
color: [0, 0, 255, 255],
width: 2.0,
}],
slide_text_boxes: HashMap::new(),
};
format.write(&path, &original).unwrap();
let loaded = format.read(&path).unwrap();
assert_eq!(loaded.title.as_deref(), Some("With Annotations"));
assert_eq!(loaded.end_slide, Some(10));
assert_eq!(loaded.last_minutes, Some(15));
assert_eq!(loaded.groups.len(), 1);
assert_eq!(loaded.notes.len(), 1);
assert_eq!(loaded.slide_timings.len(), 1);
assert_eq!(loaded.slide_annotations.len(), 1);
assert_eq!(loaded.whiteboard_annotations.len(), 1);
let _ = std::fs::remove_file(&path);
}
}