Files
deckcraft/crates/format/src/lib.rs
T
skylar taylor-barrick 1b64de3499 Merge pull request #816 from albayazid/fix/outline-tables-814
Format: .txt and .md outline includes table cell text (#814)
2026-10-15 22:48:23 -06:00

883 lines
39 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)]
mod finite;
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_model::text::{Paragraph, TextBody};
use deckcraft_model::{LayoutType, Presentation, ShapeKind, 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(&finite::Finite(&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 = parse_manifest(&json)?;
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, tables
/// are rectangular, and duplicate ids are re-id'd — unless the deck has used every id, in which
/// case a duplicate stays (growth is refused; see the comment in the loop).
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 {
// An exhausted id space leaves a duplicate slide as it is: the engine refuses to
// grow such a deck (edits by id stay ambiguous for the duplicate), and the duplicate
// stays visible rather than the file failing to open (#407).
let new_id = if dup { p.alloc_id().map(deckcraft_model::SlideId) } 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;
}
sane_numbers(p);
}
/// 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 mut made = 0;
for (title, body) in slides {
// An exhausted id space makes no new slides: what fit is kept (#407).
let Some(mut s) = defaults::new_slide(p, lay) else { break };
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 += 1;
}
made
}
/// Most `null`s standing in for numbers that [`parse_manifest`] patches in one file, and the
/// candidates it tries for each.
const MAX_NULL_FIXES: usize = 256;
const MAX_TRIES_PER_FIX: usize = 32;
/// Bytes [`parse_manifest`] may re-parse in all while patching, so a hostile file full of `null`s
/// can't keep it busy: past this the file is reported damaged.
const MAX_REPARSE_BYTES: usize = 256 * 1024 * 1024;
/// Where a parse failed, comparable: later is progress.
fn failure(src: &[u8]) -> std::result::Result<Manifest, (String, (usize, usize))> {
serde_json::from_slice::<Manifest>(src).map_err(|e| (e.to_string(), (e.line(), e.column())))
}
/// Parse `presentation.json`. serde_json writes a NaN or infinite number as `null`, which then
/// fails to read back as a number ("invalid type: null, expected f64"), so DeckCraft could save
/// a file it couldn't open. Each such `null` is read as 0 instead ([`repair`] then brings the
/// value into range); anything else that doesn't parse is damage.
///
/// serde reports the failing `null` exactly, except inside tagged enums (buffered), where it
/// reports the end of the enclosing object: there the `null`s before that point are tried
/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since
/// [`finite::Finite`] never contain such `null`s; this reads older ones.)
fn parse_manifest(json: &[u8]) -> Result<Manifest> {
let mut src = json.to_vec();
let mut last = match failure(&src) {
Ok(m) => return Ok(m),
Err(f) => f,
};
let mut budget = MAX_REPARSE_BYTES;
'fixes: for _ in 0..MAX_NULL_FIXES {
let (msg, pos) = &last;
let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number");
if !number {
break;
}
let end = offset(&src, pos.0, pos.1).unwrap_or(src.len());
let mut progressed = false;
let candidates: Vec<usize> = value_nulls_before(&src, end + 4).take(MAX_TRIES_PER_FIX).collect();
for at in candidates {
let (Some(head), Some(tail)) = (src.get(..at), src.get(at.saturating_add(4)..)) else { continue };
let Some(left) = budget.checked_sub(src.len()) else { break 'fixes };
budget = left;
let mut next = Vec::with_capacity(src.len());
next.extend_from_slice(head);
next.push(b'0');
next.extend_from_slice(tail);
match failure(&next) {
Ok(m) => return Ok(m),
// The patch shortened the text by 3 bytes before the error: compare positions
// allowing for that.
Err(f) if f.1 > (pos.0, pos.1.saturating_sub(3)) => {
src = next;
last = f;
progressed = true;
break;
}
Err(_) => {}
}
}
if !progressed {
break;
}
}
Err(FormatError::Damaged(last.0))
}
/// Byte offset of a 1-based `line`:`column`.
fn offset(src: &[u8], line: usize, column: usize) -> Option<usize> {
let mut start = 0;
for _ in 1..line {
start += src.get(start..)?.iter().position(|b| *b == b'\n')? + 1;
}
Some((start + column).min(src.len()))
}
/// Starts of `null` tokens in value position (after `:`, `[` or `,`) before `end`, nearest first.
fn value_nulls_before(src: &[u8], end: usize) -> impl Iterator<Item = usize> + '_ {
let end = end.min(src.len());
(0..end.saturating_sub(3)).rev().filter(move |&i| {
src.get(i..i + 4) == Some(b"null")
&& src[..i].iter().rev().find(|b| !b.is_ascii_whitespace()).is_some_and(|b| matches!(b, b':' | b'[' | b','))
&& src.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric())
})
}
/// Largest coordinate or size kept, in points (about 350 m; PowerPoint's own limit is far smaller).
const MAX_COORD: f64 = 1.0e6;
/// Numbers no view can draw or no file should keep: non-finite positions, sizes and angles become
/// 0, huge ones are clamped, angles are brought into 0–360, crops into a sane range, font sizes
/// into 1–4000 pt and line widths into 0–1584 pt (PowerPoint's limits).
fn sane_xfrm(x: &mut deckcraft_model::Xfrm) {
let fin = |v: f64| if v.is_finite() { v } else { 0.0 };
x.x = fin(x.x).clamp(-MAX_COORD, MAX_COORD);
x.y = fin(x.y).clamp(-MAX_COORD, MAX_COORD);
x.w = fin(x.w).clamp(0.0, MAX_COORD);
x.h = fin(x.h).clamp(0.0, MAX_COORD);
x.rot = fin(x.rot).rem_euclid(360.0);
if x.rot >= 360.0 {
x.rot = 0.0;
}
}
fn sane_run(p: &mut deckcraft_model::text::RunProps) {
p.size = p.size.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.clamp(1.0, 4000.0));
}
fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
if depth > 64 {
return;
}
for sh in shapes {
if let Some(x) = sh.xfrm.as_mut() {
sane_xfrm(x);
}
if let Some(l) = sh.line.as_mut() {
l.width = l.width.filter(|v| v.is_finite() && *v >= 0.0).map(|v| v.min(1584.0));
}
if let Some(t) = sh.text.as_mut() {
for para in &mut t.paragraphs {
sane_run(&mut para.end_props);
for r in &mut para.runs {
sane_run(&mut r.props);
}
}
}
match &mut sh.kind {
deckcraft_model::ShapeKind::Picture { fill } => {
for c in &mut fill.crop {
*c = if c.is_finite() { c.clamp(-10.0, 1.0) } else { 0.0 };
}
}
deckcraft_model::ShapeKind::Group { children, child } => {
sane_xfrm(child);
sane_shapes(children, depth + 1);
}
_ => {}
}
}
}
/// [`sane_shapes`] over every slide, layout and master; slides are only copied when they change.
pub fn sane_numbers(p: &mut Presentation) {
for slide in &mut p.slides {
let mut fixed = (**slide).clone();
sane_shapes(&mut fixed.shapes, 0);
if fixed != **slide {
*slide = Arc::new(fixed);
}
}
for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) {
let mut fixed = (**m).clone();
sane_shapes(&mut fixed.shapes, 0);
for l in &mut fixed.layouts {
sane_shapes(&mut l.shapes, 0);
}
if fixed != **m {
*m = Arc::new(fixed);
}
}
}
/// 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 t = raw.trim();
let line = match t.strip_prefix(['-', '*', '•']) {
Some(rest) if rest.starts_with([' ', '\t']) => rest.trim(),
_ => t,
}
.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');
}
}
if let ShapeKind::Table(t) = &sh.kind {
for row in &t.rows {
let cells: Vec<String> = row.cells.iter().map(|c| c.text.text().replace(['\u{b}', '\n'], " ")).collect();
out.push_str(&cells.join("\t"));
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()).expect("media");
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![]).expect("media");
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]).expect("media");
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());
}
/// Non-finite numbers are written as `null` by serde_json; the file still opens, with each
/// such number repaired (it failed as "invalid type: null, expected f64").
#[test]
fn a_file_saved_with_non_finite_numbers_reopens() {
use deckcraft_model::{Shape, ShapeKind, Xfrm};
let mut p = Presentation::default();
let nan = f64::NAN;
let inf = f64::INFINITY;
let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() };
pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } };
pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() });
let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() };
let mut body = deckcraft_model::text::TextBody::default();
let mut para = deckcraft_model::text::Paragraph::default();
para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() }));
para.end_props.size = Some(1e9);
body.paragraphs.push(para);
text.text = Some(body);
let group = Shape {
xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }),
kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes.push(group);
let bytes = save(&p).unwrap();
let back = load(&bytes).unwrap_or_else(|e| panic!("{e}"));
let g = back.slides[0].shapes.last().unwrap();
let ShapeKind::Group { children, child } = &g.kind else { panic!("group") };
assert_eq!((child.x, child.w), (0.0, 0.0));
let x = children[0].xfrm.unwrap();
assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0));
let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") };
assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]);
assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0));
assert_eq!(children[1].xfrm.unwrap().rot, 0.0);
let para = &children[1].text.as_ref().unwrap().paragraphs[0];
assert_eq!(para.runs[0].props.size, None);
assert_eq!(para.end_props.size, Some(4000.0));
// Saved again, it is clean.
assert_eq!(load(&save(&back).unwrap()).unwrap(), back);
}
/// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too,
/// including a `null` inside a tagged enum, where serde reports only the enclosing object.
#[test]
fn older_files_with_null_numbers_open() {
use deckcraft_model::{Shape, ShapeKind, Xfrm};
let nan = f64::NAN;
for case in 0..3 {
let mut p = Presentation::default();
let sh = match case {
0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() },
1 => Shape {
kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } },
..Default::default()
},
_ => Shape {
kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } },
..Default::default()
},
};
Arc::make_mut(&mut p.slides[0]).shapes.push(sh);
let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p };
let json = serde_json::to_vec(&m).unwrap();
assert!(json.windows(4).any(|w| w == b"null"));
let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}"));
assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}");
}
}
/// A body whose file had no `fontScale` (`<a:normAutofit/>`) keeps that state through the
/// native format: it is drawn at 100 % and only fitted when its text changes (#724).
#[test]
fn a_bare_normautofit_body_round_trips() {
use deckcraft_model::text::{AutoFit, TextBody};
let mut p = Presentation::default();
let mut body = TextBody::from_text("Overflowing");
body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 });
let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(sh);
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap();
assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }));
// A scale the file saved round-trips as itself.
let mut body = TextBody::from_text("Overflowing");
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 });
let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(sh);
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap();
assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }));
}
/// Only `null`s where numbers belong are patched: other damage still fails.
#[test]
fn other_damage_is_still_reported() {
let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap();
assert!(parse_manifest(&ok).is_ok());
let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#;
assert!(parse_manifest(bad).is_err());
let truncated = &ok[..ok.len() - 3];
assert!(parse_manifest(truncated).is_err());
// A null number on a later line (pretty-printed by another tool) is found too.
let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}";
let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0);
}
#[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);
}
/// A duplicate slide in a deck whose ids are used up cannot be re-id'd: it stays as it is
/// (the engine refuses edits to such a deck) instead of a duplicated fresh id (#407).
#[test]
fn repair_keeps_a_duplicate_it_cannot_reid() {
let mut p = Presentation { next_id: u32::MAX, ..Default::default() };
p.slides.push(p.slides[0].clone());
repair(&mut p);
assert_eq!(p.slides.len(), 2, "the duplicate stays visible");
assert_eq!(p.slides[0].id, p.slides[1].id, "no fresh id was invented");
}
#[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 outline_keeps_leading_dash_text() {
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other");
assert_eq!(n, 2);
let o = slides_to_outline(&p);
assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{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:?}");
}
}
}
/// #814: slides_to_outline includes table cell text, which it used to skip.
#[test]
fn outline_includes_table_text() {
use deckcraft_model::table::{Cell, Row, Table};
use deckcraft_model::{Shape, ShapeKind, Xfrm};
let mut p = defaults::new_presentation(None);
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
body.text = Some(TextBody::from_text("Text box body"));
s.shapes.push(body);
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
table.rows.push(Row { height: 30.0, cells });
}
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
tbl.kind = ShapeKind::Table(table);
s.shapes.push(tbl);
p.slides = vec![std::sync::Arc::new(s)];
let out = slides_to_outline(&p);
assert!(out.contains("Text box body"), "{out}");
assert!(out.contains("Cell A1\tCell B1"), "{out}");
assert!(out.contains("Cell A2\tCell B2"), "{out}");
}