Files
deckcraft/crates/format/src/lib.rs
T
Nikita RakitinandCursor 22074c4c69 Format: repair degenerate slide sizes on load
repair() brings each slide side into PowerPoint's 72–4032 pt (as Design › Slide Size allows;
it accepted 1–4000 before), and replaces a zero, negative or non-finite size with 16:9; a bad
notes size gets the default. The native reader takes a null, string or missing slide/notes
size as NaN for repair to fix, so such a file opens instead of failing as damaged (NaN and
infinity are saved as null). The canvas fit and slide show fit treat a zero or non-finite side
as 1 pt, so an in-memory bad size can't make layout infinite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 04:35:13 +03:00

329 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The native `.deckcraft` format: a zip holding `presentation.json` (the model, minus media
//! bytes), `media/<id>` 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<T> = std::result::Result<T, FormatError>;
#[derive(serde::Serialize, serde::Deserialize)]
struct Manifest {
format: String,
version: u32,
generator: String,
presentation: Presentation,
}
/// Serialize a presentation.
pub fn save(p: &Presentation) -> Result<Vec<u8>> {
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<Cursor<&[u8]>>, name: &str) -> Result<Vec<u8>> {
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<Presentation> {
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;
}
}
}
}
p.slide_size = sane_slide_size(p.slide_size);
// The notes page only lays out notes, but its size divides too.
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
p.notes_size = Presentation::default().notes_size;
}
}
/// Smallest and largest slide side, in points: PowerPoint's 1–56 in, as Design › Slide Size allows.
pub const SLIDE_SIDE: std::ops::RangeInclusive<f64> = 72.0..=4032.0;
/// A slide size the views and renderer can always lay out: a missing, zero, negative or
/// non-finite side gives the 16:9 default, and each side is brought into [`SLIDE_SIDE`].
pub fn sane_slide_size(s: deckcraft_model::Size) -> deckcraft_model::Size {
let ok = |v: f64| v.is_finite() && v > 0.0;
if !(ok(s.width) && ok(s.height)) {
return defaults::WIDE;
}
deckcraft_model::Size::new(s.width.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()), s.height.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()))
}
/// 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<deckcraft_model::Slide> = None;
let flush = |p: &mut Presentation, s: Option<deckcraft_model::Slide>, 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(&para.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 degenerate_slide_sizes_are_repaired() {
use deckcraft_model::Size;
let cases = [
((0.0, 0.0), (960.0, 540.0)),
((-5.0, 540.0), (960.0, 540.0)),
((f64::NAN, 540.0), (960.0, 540.0)),
((f64::INFINITY, 1.0), (960.0, 540.0)),
((1.0, 1.0), (72.0, 72.0)),
((0.5, 4000.0), (72.0, 4000.0)),
((1e9, 20.0), (4032.0, 72.0)),
((720.0, 540.0), (720.0, 540.0)),
];
for ((w, h), want) in cases {
let mut p = Presentation { slide_size: Size::new(w, h), notes_size: Size::new(0.0, f64::NAN), ..Default::default() };
repair(&mut p);
assert_eq!((p.slide_size.width, p.slide_size.height), want, "{w}x{h}");
assert_eq!(p.notes_size, Presentation::default().notes_size);
}
}
/// A file whose slide size is null, a string or missing a side (written by hand or by
/// another tool; NaN is also saved as null) opens with the default size rather than failing
/// as damaged.
#[test]
fn files_with_unreadable_slide_sizes_still_open() {
let saved = save(&Presentation::default()).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(&saved[..])).unwrap();
let json: serde_json::Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
for size in [
serde_json::json!({"width": null, "height": 540}),
serde_json::json!({"width": "big", "height": 540}),
serde_json::json!({"height": 0}),
serde_json::json!(null),
serde_json::json!(7),
] {
let mut m = json.clone();
m["presentation"]["slideSize"] = size.clone();
let mut buf = Cursor::new(Vec::new());
{
let mut w = zip::ZipWriter::new(&mut buf);
let o = zip::write::SimpleFileOptions::default();
w.start_file("mimetype", o).unwrap();
w.write_all(MIMETYPE.as_bytes()).unwrap();
w.start_file("presentation.json", o).unwrap();
w.write_all(&serde_json::to_vec(&m).unwrap()).unwrap();
w.finish().unwrap();
}
let p = load(buf.get_ref()).unwrap_or_else(|e| panic!("{size}: {e}"));
assert_eq!((p.slide_size.width, p.slide_size.height), (960.0, 540.0), "{size}");
}
}
#[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}");
}
}