//! The native `.deckcraft` format: a zip holding `presentation.json` (the model, minus media //! bytes), `media/` files with the embedded media, and `mimetype`. Readers ignore unknown //! fields, so newer files open in older builds with what those builds understand. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] use std::io::{Cursor, Read, Write}; use std::sync::Arc; use deckcraft_model::text::{Paragraph, TextBody}; use deckcraft_model::{LayoutType, Presentation, defaults}; pub const MIMETYPE: &str = "application/vnd.storyteller.deckcraft+zip"; /// Files written before the SlideCraft → DeckCraft rename (`.slidecraft`) are still read. pub const LEGACY_MIMETYPE: &str = concat!("application/vnd.storyteller.", "slide", "craft+zip"); pub const EXTENSION: &str = "deckcraft"; /// Format version written into the manifest. pub const VERSION: u32 = 1; /// Largest entry we will inflate (guards against zip bombs). const MAX_ENTRY: u64 = 2 << 30; #[derive(Debug, thiserror::Error)] pub enum FormatError { #[error("not a DeckCraft file: {0}")] NotOurs(String), #[error("damaged file: {0}")] Damaged(String), #[error("write failed: {0}")] Write(String), } pub type Result = std::result::Result; #[derive(serde::Serialize, serde::Deserialize)] struct Manifest { format: String, version: u32, generator: String, presentation: Presentation, } /// Serialize a presentation. pub fn save(p: &Presentation) -> Result> { let mut buf = Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); let w = |e: zip::result::ZipError| FormatError::Write(e.to_string()); z.start_file("mimetype", stored).map_err(w)?; z.write_all(MIMETYPE.as_bytes()).map_err(|e| FormatError::Write(e.to_string()))?; let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation: p.clone(), }; let json = serde_json::to_vec(&m).map_err(|e| FormatError::Write(e.to_string()))?; z.start_file("presentation.json", deflate).map_err(w)?; z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?; for item in &p.media { if item.link.is_some() && item.data.is_empty() { continue; } // Already-compressed media is stored, not deflated again. let opts = if is_compressed(&item.content_type) { stored } else { deflate }; z.start_file(format!("media/{}", item.id.0), opts).map_err(w)?; z.write_all(&item.data).map_err(|e| FormatError::Write(e.to_string()))?; } z.finish().map_err(w)?; } Ok(buf.into_inner()) } fn is_compressed(ct: &str) -> bool { ct.starts_with("image/jpeg") || ct.starts_with("image/png") || ct.starts_with("video/") || ct.starts_with("audio/mpeg") || ct.starts_with("audio/mp4") || ct.contains("zip") } /// Does this look like a `.deckcraft` file? pub fn sniff(bytes: &[u8]) -> bool { bytes.starts_with(b"PK") && [MIMETYPE, LEGACY_MIMETYPE].iter().any(|m| bytes.windows(m.len()).take(200).any(|w| w == m.as_bytes())) } fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result> { let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; if f.size() > MAX_ENTRY { return Err(FormatError::Damaged(format!("{name} is too large"))); } let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize); f.take(MAX_ENTRY).read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; Ok(out) } /// Read a presentation. pub fn load(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?; let m: Manifest = serde_json::from_slice(&json).map_err(|e| FormatError::Damaged(e.to_string()))?; if m.format != "deckcraft" { return Err(FormatError::NotOurs(m.format)); } let mut p = m.presentation; for item in &mut p.media { if let Ok(data) = read_entry(&mut z, &format!("media/{}", item.id.0)) { item.data = Arc::new(data); } } repair(&mut p); Ok(p) } /// Make a loaded presentation safe to edit: a master exists, every slide's layout exists, /// ids are unique, tables are rectangular. pub fn repair(p: &mut Presentation) { if p.masters.is_empty() { let fresh = defaults::blank_presentation(p.slide_size, Default::default(), false); p.masters = fresh.masters; } p.fix_next_id(); let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); let mut seen = std::collections::HashSet::new(); for i in 0..p.slides.len() { let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none()); let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id)); if needs_layout || dup { let new_id = if dup { Some(deckcraft_model::SlideId(p.alloc_id())) } else { None }; if let Some(s) = p.slides.get_mut(i) { let s = Arc::make_mut(s); if needs_layout { s.layout = first_layout; } if let Some(id) = new_id { s.id = id; } } } } if !(p.slide_size.width.is_finite() && p.slide_size.width >= 1.0 && p.slide_size.height.is_finite() && p.slide_size.height >= 1.0) { p.slide_size = defaults::WIDE; } p.slide_size.width = p.slide_size.width.min(4000.0); p.slide_size.height = p.slide_size.height.min(4000.0); } /// Outline text: one slide per unindented line; tab-indented lines become body bullets at their /// level (Insert ▸ Slides from Outline, View ▸ Outline copy). pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize { let Some(lay) = defaults::layout_of_kind(p, LayoutType::TitleAndContent) else { return 0 }; let mut made = 0; let mut cur: Option = None; let flush = |p: &mut Presentation, s: Option, made: &mut usize| { if let Some(s) = s { p.slides.push(Arc::new(s)); *made += 1; } }; for raw in text.lines() { if raw.trim().is_empty() { continue; } let tabs = raw.chars().take_while(|c| *c == '\t').count(); let spaces = raw.chars().take_while(|c| *c == ' ').count() / 2; let level = tabs.max(spaces); let line = raw.trim().trim_start_matches(['-', '*', '•']).trim(); if level == 0 { flush(p, cur.take(), &mut made); let mut s = defaults::new_slide(p, lay); if let Some(t) = s.shapes.first_mut() { t.text = Some(TextBody::from_text(line)); } cur = Some(s); } else if let Some(s) = cur.as_mut() && let Some(body) = s.shapes.get_mut(1) { let tb = body.text.get_or_insert_with(TextBody::default); let mut para = Paragraph::new(line); para.level = (level - 1).min(8) as u8; tb.paragraphs.push(para); } } flush(p, cur, &mut made); made } /// The deck as outline text (titles unindented, body paragraphs tab-indented by level). pub fn slides_to_outline(p: &Presentation) -> String { let mut out = String::new(); for s in &p.slides { out.push_str(&s.title()); out.push('\n'); for sh in &s.shapes { if sh.ph_type().is_some_and(|k| k.is_title()) { continue; } if let Some(t) = &sh.text { for para in &t.paragraphs { if para.is_empty() { continue; } for _ in 0..=para.level { out.push('\t'); } out.push_str(¶.text().replace('\u{b}', " ")); out.push('\n'); } } } } out } #[cfg(test)] mod tests { use super::*; #[test] fn roundtrip_with_media() { let mut p = Presentation::default(); let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]); let bytes = save(&p).unwrap(); assert!(sniff(&bytes)); let q = load(&bytes).unwrap(); assert_eq!(q.slides.len(), p.slides.len()); assert_eq!(q.media(id).map(|m| m.data.as_slice()), Some(&[1u8, 2, 3, 4][..])); assert_eq!(serde_json::to_value(&q.slides).unwrap(), serde_json::to_value(&p.slides).unwrap()); } #[test] fn garbage_is_an_error_not_a_panic() { assert!(load(b"").is_err()); assert!(load(b"PK\x03\x04garbage").is_err()); assert!(load(&[0u8; 1000]).is_err()); let mut b = save(&Presentation::default()).unwrap(); let n = b.len(); b.truncate(n / 2); assert!(load(&b).is_err()); } #[test] fn repair_fixes_missing_layout_and_size() { let mut p = Presentation::default(); Arc::make_mut(&mut p.slides[0]).layout = deckcraft_model::LayoutId(99999); p.slide_size.width = f64::NAN; repair(&mut p); assert!(p.validate().is_empty()); assert_eq!(p.slide_size.width, 960.0); } #[test] fn outline_roundtrip() { let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); let n = outline_to_slides(&mut p, "Intro\n\tPoint one\n\t\tSub point\nSecond\n\tMore"); assert_eq!(n, 2); let o = slides_to_outline(&p); assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}"); } }