Files
deckcraft/crates/format/src/lib.rs
T
Brandon Thomas b3c9cc099c Merge the PPTX writer, notes/handout master, PDF and slide-show PRs integrated on land-r3 (#518 #531 #535 #533 #572 #573 #577 #580 #583 #592 #685 #528 #483 #490 #441 #671 #679 #649 #656 #660 #664 #224)
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/model/src/lib.rs
#	crates/pptx/src/write/mod.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/thumbs.rs
2026-10-14 13:56:41 -04:00

543 lines
23 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;
/// All embedded media together, inflated (#393).
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
/// Media items one file may list.
const MAX_MEDIA_ITEMS: usize = 100_000;
/// What one load may inflate.
#[derive(Clone, Copy)]
struct Budget {
total: u64,
items: usize,
}
#[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>> {
read_entry_max(z, name, MAX_ENTRY)
}
/// Read one member, refusing it before inflating if it declares more than `max` bytes, and
/// stopping at `max` if it inflates further than it declared.
fn read_entry_max(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64) -> Result<Vec<u8>> {
let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
if f.size() > max {
return Err(FormatError::Damaged(format!("{name} is too large")));
}
let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize);
let mut f = f.take(max.saturating_add(1));
f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
if out.len() as u64 > max {
return Err(FormatError::Damaged(format!("{name} is too large")));
}
Ok(out)
}
/// Read a presentation. Embedded media that is missing or damaged is left empty; use
/// [`load_checked`] to learn which.
pub fn load(bytes: &[u8]) -> Result<Presentation> {
load_checked(bytes).map(|(p, _)| p)
}
/// Read a presentation and list the embedded media that couldn't be read (missing, too large
/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage.
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
}
fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String>)> {
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;
let mut damaged = vec![];
if p.media.len() > budget.items {
return Err(FormatError::Damaged(format!("{} media items (at most {})", p.media.len(), budget.items)));
}
// A repeated id names the same member: keep the first item, so the member is inflated once
// and the deck can be saved again (a zip can't hold two `media/<id>`).
let mut seen = std::collections::HashSet::new();
p.media.retain(|m| seen.insert(m.id));
let mut left = budget.total;
for item in &mut p.media {
let name = format!("media/{}", item.id.0);
let linked_only = item.link.is_some() && z.index_for_name(&name).is_none();
if z.by_name(&name).is_ok_and(|f| f.size() > left) {
return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into()));
}
match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) {
Ok(data) => {
left -= data.len() as u64;
item.data = Arc::new(data);
}
Err(_) if linked_only => {}
Err(e) => damaged.push(e.to_string()),
}
}
repair(&mut p);
Ok((p, damaged))
}
/// 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()))
}
/// Slides read from an outline: each title with its body paragraphs (level 0–8, text).
type OutlineSlides = Vec<(String, Vec<(u8, String)>)>;
/// Append a Title and Content slide per outline entry; the number made.
fn add_outline_slides(p: &mut Presentation, slides: OutlineSlides) -> usize {
let Some(lay) = defaults::layout_of_kind(p, LayoutType::TitleAndContent) else { return 0 };
let made = slides.len();
for (title, body) in slides {
let mut s = defaults::new_slide(p, lay);
if let Some(t) = s.shapes.first_mut() {
t.text = Some(TextBody::from_text(&title));
}
if let Some(b) = s.shapes.get_mut(1) {
for (level, line) in body {
let tb = b.text.get_or_insert_with(TextBody::default);
let mut para = Paragraph::new(&line);
para.level = level.min(8);
tb.paragraphs.push(para);
}
}
p.slides.push(Arc::new(s));
}
made
}
/// 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 mut slides: OutlineSlides = Vec::new();
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().to_string();
if level == 0 {
slides.push((line, Vec::new()));
} else if let Some((_, body)) = slides.last_mut() {
body.push(((level - 1).min(8) as u8, line));
}
}
add_outline_slides(p, slides)
}
/// An ATX heading's text (CommonMark: up to three spaces, one to six `#`, then a space or the end;
/// a closing run of `#` is dropped).
fn md_heading(line: &str) -> Option<&str> {
let t = line.strip_prefix(" ").or_else(|| line.strip_prefix(" ")).or_else(|| line.strip_prefix(' ')).unwrap_or(line);
let hashes = t.chars().take_while(|c| *c == '#').count();
let rest = t.get(hashes..)?;
if !(1..=6).contains(&hashes) || !(rest.is_empty() || rest.starts_with([' ', '\t'])) {
return None;
}
let rest = rest.trim();
let closed = rest.trim_end_matches('#');
Some(if closed.is_empty() || closed.ends_with([' ', '\t']) { closed.trim_end() } else { rest })
}
/// A list item's nesting level (two spaces or a tab each) and text: `-`, `*` or `+`, or digits
/// then `.` or `)`, followed by a space.
fn md_list_item(line: &str) -> Option<(u8, &str)> {
let indent: usize = line.chars().take_while(|c| matches!(c, ' ' | '\t')).map(|c| if c == '\t' { 2 } else { 1 }).sum();
let t = line.trim_start();
let rest = match t.strip_prefix(['-', '*', '+']) {
Some(r) => r,
None => {
let digits = t.chars().take_while(char::is_ascii_digit).count();
if digits == 0 || digits > 9 {
return None;
}
t.get(digits..)?.strip_prefix(['.', ')'])?
}
};
rest.starts_with([' ', '\t']).then(|| ((indent / 2).min(8) as u8, rest.trim()))
}
/// Markdown outline text (`.md`): each heading starts a slide with its text as the title; list
/// items under it become bullets nested by their indentation, other lines body paragraphs. Text
/// before the first heading is skipped, as indented lines before the first title are in the
/// plain format. A file without headings is read as a plain outline ([`outline_to_slides`]).
pub fn markdown_to_slides(p: &mut Presentation, text: &str) -> usize {
if !text.lines().any(|l| md_heading(l).is_some()) {
return outline_to_slides(p, text);
}
let mut slides: OutlineSlides = Vec::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
if let Some(title) = md_heading(line) {
slides.push((title.to_string(), Vec::new()));
} else if let Some((_, body)) = slides.last_mut() {
let (level, text) = md_list_item(line).unwrap_or((0, line.trim()));
body.push((level, text.to_string()));
}
}
add_outline_slides(p, slides)
}
/// 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::*;
/// The same package without the entries `drop` names.
fn without(bytes: &[u8], drop: &str) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..z.len() {
let f = z.by_index(i).unwrap();
if f.name() != drop {
out.raw_copy_file(f).unwrap();
}
}
out.finish().unwrap().into_inner()
}
#[test]
fn missing_or_corrupt_media_is_reported() {
let mut p = Presentation::default();
let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec());
let good = save(&p).unwrap();
assert!(load_checked(&good).unwrap().1.is_empty());
// Missing member.
let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap();
assert_eq!(damaged.len(), 1, "{damaged:?}");
assert!(q.media(id).is_some_and(|m| m.data.is_empty()));
// Bad CRC: PNGs are stored, so their bytes sit in the file as they are.
let mut bad = good.clone();
let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap();
bad[at] ^= 0xff;
let (_, damaged) = load_checked(&bad).unwrap();
assert_eq!(damaged.len(), 1, "{damaged:?}");
// Linked media saved without its bytes isn't damage.
let mut l = Presentation::default();
let lid = l.add_media("v.mp4", "video/mp4", vec![]);
if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) {
m.link = Some("file:///movies/v.mp4".into());
}
assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty());
}
#[test]
fn media_budget_is_cumulative() {
let mut p = Presentation::default();
p.add_media("a.bin", "application/octet-stream", vec![1; 600]);
p.add_media("b.bin", "application/octet-stream", vec![2; 600]);
let bytes = save(&p).unwrap();
// Exactly at the limit opens; one byte under is refused, though each item fits.
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err());
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err());
}
#[test]
fn repeated_media_ids_are_read_once() {
use std::io::Write;
let mut p = Presentation::default();
p.add_media("a.bin", "application/octet-stream", vec![7; 1000]);
let good = save(&p).unwrap();
// The same file with its media item listed 51 times.
let mut z = zip::ZipArchive::new(Cursor::new(good.as_slice())).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
if f.name() == "presentation.json" {
let mut json = vec![];
f.read_to_end(&mut json).unwrap();
let mut v: serde_json::Value = serde_json::from_slice(&json).unwrap();
let media = &mut v["presentation"]["media"];
let one = media[0].clone();
*media = serde_json::Value::Array(vec![one; 51]);
out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&serde_json::to_vec(&v).unwrap()).unwrap();
} else {
out.raw_copy_file(f).unwrap();
}
}
let bytes = out.finish().unwrap().into_inner();
// 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once.
let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0;
assert_eq!(q.media.len(), 1);
assert_eq!(q.media[0].data.len(), 1000);
// And the deck saves again.
assert!(save(&q).is_ok());
}
#[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}");
}
#[test]
fn markdown_headings_and_list_items() {
assert_eq!(md_heading("# Intro"), Some("Intro"));
assert_eq!(md_heading(" ### Deep ### "), Some("Deep"));
assert_eq!(md_heading("## C# and F#"), Some("C# and F#"));
assert_eq!(md_heading("#"), Some(""));
assert_eq!(md_heading("## Ünïcödé"), Some("Ünïcödé"));
for not in ["#5 priority", "####### seven", " # code", "text", "", "é#"] {
assert_eq!(md_heading(not), None, "{not:?}");
}
assert_eq!(md_list_item("- a"), Some((0, "a")));
assert_eq!(md_list_item(" * b"), Some((2, "b")));
assert_eq!(md_list_item("\t+ c"), Some((1, "c")));
assert_eq!(md_list_item("12) d"), Some((0, "d")));
for not in ["-x", "1.x", "1", "1234567890. long", "• bullet", "é. x", ""] {
assert_eq!(md_list_item(not), None, "{not:?}");
}
}
}