From 22074c4c691944777a175d4b487134e2f4df32a6 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:35:13 +0300 Subject: [PATCH] Format: repair degenerate slide sizes on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/format/src/lib.rs | 74 ++++++++++++++++++++++++-- crates/model/src/lib.rs | 13 ++++- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/show.rs | 6 ++- crates/ui-egui/src/slide_size_tests.rs | 58 ++++++++++++++++++++ 6 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/slide_size_tests.rs diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1b37a232..965c2140 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -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 = 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(); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..3ae8e3ae 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -817,11 +817,22 @@ pub struct MediaItem { pub link: Option, } +/// 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 { + 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>, pub masters: Vec>, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..d8cb6b4e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..66dd41c9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1129,6 +1129,8 @@ fn js_now() -> f64 { #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod slide_size_tests; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..5d1c0827 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -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)) } diff --git a/crates/ui-egui/src/slide_size_tests.rs b/crates/ui-egui/src/slide_size_tests.rs new file mode 100644 index 00000000..0ad77994 --- /dev/null +++ b/crates/ui-egui/src/slide_size_tests.rs @@ -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:?}"); +}