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>
This commit is contained in:
Nikita Rakitin
2026-10-14 04:35:13 +03:00
co-authored by Cursor
parent 457406a7a5
commit 22074c4c69
6 changed files with 149 additions and 8 deletions
+70 -4
View File
@@ -140,11 +140,24 @@ pub fn repair(p: &mut Presentation) {
}
}
}
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 = 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;
}
p.slide_size.width = p.slide_size.width.min(4000.0);
p.slide_size.height = p.slide_size.height.min(4000.0);
}
/// 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
@@ -241,6 +254,59 @@ mod tests {
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();
+12 -1
View File
@@ -817,11 +817,22 @@ pub struct MediaItem {
pub link: Option<String>,
}
/// A size whose sides may be missing, null or not numbers (NaN then); see `Presentation::slide_size`.
fn lenient_size<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Size, D::Error> {
let v = serde_json::Value::deserialize(d)?;
let side = |k: &str| v.get(k).and_then(serde_json::Value::as_f64).unwrap_or(f64::NAN);
Ok(Size::new(side("width"), side("height")))
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Presentation {
/// Slide size in points (default 16:9, 960 × 540).
/// Slide size in points (default 16:9, 960 × 540). Read leniently: a missing, null or
/// non-numeric side becomes NaN for `deckcraft_format::repair` to replace, instead of the
/// whole file failing to open.
#[serde(deserialize_with = "lenient_size")]
pub slide_size: Size,
#[serde(deserialize_with = "lenient_size")]
pub notes_size: Size,
pub slides: Vec<Arc<Slide>>,
pub masters: Vec<Arc<Master>>,
+3 -1
View File
@@ -38,7 +38,9 @@ fn mods(ui: &Ui) -> Mods {
/// The slide's rect on screen for the current zoom.
pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) {
let Some(d) = app.session.active() else { return (avail, 1.0) };
let (sw, sh) = (d.doc.slide_size.width as f32, d.doc.slide_size.height as f32);
// Loaded sizes are repaired; a zero or non-finite side would still make the fit infinite.
let side = |v: f64| if v.is_finite() { (v as f32).max(1.0) } else { 1.0 };
let (sw, sh) = (side(d.doc.slide_size.width), side(d.doc.slide_size.height));
let margin = 28.0;
let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05);
let scale = app.ui.zoom.unwrap_or(fit);
+2
View File
@@ -1129,6 +1129,8 @@ fn js_now() -> f64 {
#[cfg(test)]
mod ime_tests;
#[cfg(test)]
mod slide_size_tests;
#[cfg(test)]
mod tests {
+4 -2
View File
@@ -201,8 +201,10 @@ fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState {
}
fn fit(avail: Rect, doc: &Presentation) -> Rect {
let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32);
let s = (avail.width() / sw).min(avail.height() / sh);
// Loaded sizes are repaired, but a zero side here would make every rectangle NaN.
let side = |v: f64| if v.is_finite() { (v as f32).max(1.0) } else { 1.0 };
let (sw, sh) = (side(doc.slide_size.width), side(doc.slide_size.height));
let s = (avail.width() / sw).min(avail.height() / sh).max(0.0);
Rect::from_center_size(avail.center(), vec2(sw * s, sh * s))
}
+58
View File
@@ -0,0 +1,58 @@
//! Slide sizes at the edges of what a loaded file can give (after `deckcraft_format::repair`):
//! the editor, sorter, slide show and presenter view open without panicking. (NaN and infinity
//! are saved as null, which used to make the file fail to open.)
#![allow(clippy::unwrap_used, clippy::expect_used)]
use serde_json::json;
use crate::{Services, SlideApp};
use deckcraft_engine::Session;
fn open_with_size(w: f64, h: f64) -> egui_kittest::Harness<'static, SlideApp> {
// Written with the size, then opened the way File › Open does (load, then repair).
let mut s = Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).slide_size = deckcraft_model::Size::new(w, h);
let path = std::env::temp_dir().join(format!("deckcraft-size-{}-{w}x{h}.deckcraft", std::process::id()));
s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
let mut s = Session::new();
s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
let _ = std::fs::remove_file(&path);
egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(s, Services::default()),
)
}
#[test]
fn extreme_loaded_slide_sizes_open_in_every_view() {
for (w, h) in [(0.0, 0.0), (-5.0, 540.0), (f64::NAN, 540.0), (f64::INFINITY, 1.0), (1.0, 1.0), (1e9, 1e9), (4000.0, 4000.0)] {
let mut hs = open_with_size(w, h);
hs.run_steps(3);
let size = hs.state().session.active().unwrap().doc.slide_size;
assert!((72.0..=4032.0).contains(&size.width) && (72.0..=4032.0).contains(&size.height), "{w}x{h}: {size:?}");
for cmd in ["view.sorter", "view.normal"] {
hs.state_mut().run(cmd, json!({})).unwrap();
hs.run_steps(2);
}
hs.state_mut().run("show.presenter", json!({"on": true})).unwrap();
hs.state_mut().run("show.start", json!({"from": 0})).unwrap();
hs.run_steps(3);
assert!(hs.state().show.is_some(), "{w}x{h}");
}
}
/// A presentation whose size went bad in memory (not through a file) still lays out: the
/// canvas fit stays finite and positive.
#[test]
fn the_canvas_fit_survives_a_zero_slide_size_in_memory() {
let mut s = Session::with_new();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).slide_size = deckcraft_model::Size::new(0.0, f64::NAN);
let app = SlideApp::new(s, Services::default());
let (r, scale) = crate::canvas::layout(&app, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)));
assert!(scale.is_finite() && scale > 0.0, "{scale}");
assert!(r.min.x.is_finite() && r.max.y.is_finite(), "{r:?}");
}