diff --git a/ROADMAP.md b/ROADMAP.md index 8c1d8367..962bd5e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | | 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9e5c6e55..b9185556 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3677,3 +3677,105 @@ fn opening_a_pptx_with_an_unreadable_slide_says_so() { assert_eq!(r["lostSlides"], json!([]), "{r}"); assert!(u.ui_requests.is_empty()); } + +/// A file with absurd effect sizes opens with them capped at what Format Shape allows: drawn as +/// they were, a glow of 10^18 pt made the renderer ask for 34 GB and abort. +#[test] +fn opening_a_file_caps_absurd_effect_sizes() { + use deckcraft_model::style::{Glow, Reflection}; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Glow"})).unwrap(); + s.execute("shape.effects", &json!({"shadow": "outer", "reflection": "tight", "softEdges": 4, "glow": {"radius": 8}})).unwrap(); + s.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [400, 300, 100, 40]})).unwrap(); + let huge = 1e18; + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).unwrap(); + for sh in list.iter_mut() { + if let Some(e) = sh.effects.as_mut() { + e.glow.as_mut().unwrap().radius = huge; + e.soft_edge = Some(f64::NAN); + let sw = e.outer_shadow.as_mut().unwrap(); + (sw.blur, sw.dist, sw.sx) = (huge, huge, huge); + *e.reflection.as_mut().unwrap() = Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge }; + } + let glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge }); + let runs: Vec<&mut deckcraft_model::text::Run> = match &mut sh.kind { + deckcraft_model::ShapeKind::Table(t) => t + .rows + .iter_mut() + .flat_map(|r| r.cells.iter_mut()) + .flat_map(|c| c.text.paragraphs.iter_mut()) + .flat_map(|p| p.runs.iter_mut()) + .collect(), + _ => sh.text.iter_mut().flat_map(|t| t.paragraphs.iter_mut()).flat_map(|p| p.runs.iter_mut()).collect(), + }; + for r in runs { + r.props.glow = glow.clone(); + } + } + // Inherited styles too: the deck default, a master's body style, a text box's own list + // style, and the theme's effect styles that shape styles point at. + let level = deckcraft_model::text::LevelStyle { + run: deckcraft_model::text::RunProps { + glow: Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge }), + ..Default::default() + }, + ..Default::default() + }; + doc.default_text_style.set(0, level.clone()); + let m = std::sync::Arc::make_mut(&mut doc.masters[0]); + m.body_style.set(0, level.clone()); + if let Some(fx) = m.theme.format.effects.first_mut() { + fx.glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge }); + } + let list = crate::shapes_mut(doc, sel).unwrap(); + if let Some(t) = list.iter_mut().find_map(|sh| sh.text.as_mut()) { + t.list_style.set(0, level); + } + Ok(()) + }) + .unwrap(); + for format in ["deckcraft", "pptx"] { + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, format).unwrap(); + let mut t = Session::new(); + t.execute("file.openBytes", &json!({"name": format!("fx.{format}"), "data": cmd::base64_encode(&bytes)})).unwrap(); + let st = t.doc().unwrap(); + let slide = st.doc.slides.last().unwrap(); + let mut glows = vec![]; + deckcraft_model::walk(&slide.shapes, &mut |sh, _| { + if let Some(e) = &sh.effects { + glows.extend(e.glow.as_ref().map(|g| g.radius)); + assert!(e.soft_edge.is_none_or(|v| (0.0..=100.0).contains(&v)), "{format}: {:?}", e.soft_edge); + if let Some(sw) = &e.outer_shadow { + assert!(sw.blur <= 200.0 && sw.dist <= 200.0 && sw.sx <= 10.0, "{format}: {sw:?}"); + } + if let Some(r) = &e.reflection { + assert!(r.blur <= 100.0 && r.dist <= 100.0, "{format}: {r:?}"); + } + } + for p in sh.text.iter().flat_map(|t| t.paragraphs.iter()) { + glows.extend(p.runs.iter().filter_map(|r| r.props.glow.as_ref().map(|g| g.radius))); + } + if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind { + let cells = tb.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.text.paragraphs.iter()); + glows.extend(cells.flat_map(|p| p.runs.iter()).filter_map(|r| r.props.glow.as_ref().map(|g| g.radius))); + } + }); + let inherited = |ls: &deckcraft_model::text::ListStyle| { + ls.levels.iter().flatten().filter_map(|l| l.run.glow.as_ref().map(|g| g.radius)).collect::>() + }; + glows.extend(inherited(&st.doc.default_text_style)); + let m = &st.doc.masters[0]; + for ls in [&m.title_style, &m.body_style, &m.other_style] { + glows.extend(inherited(ls)); + } + glows.extend(m.theme.format.effects.iter().filter_map(|e| e.glow.as_ref().map(|g| g.radius))); + deckcraft_model::walk(&slide.shapes, &mut |sh, _| glows.extend(sh.text.iter().flat_map(|t| inherited(&t.list_style)))); + assert!(!glows.is_empty(), "{format}: the glows were kept"); + assert!(glows.iter().all(|g| (0.0..=150.0).contains(g)), "{format}: {glows:?}"); + // Capped, the slide renders (checked last: uncapped, this is what ran out of memory). + let i = st.doc.slides.len() - 1; + t.execute("file.render", &json!({"slide": i, "scale": 0.1})).unwrap(); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index c2837a20..f987a2b7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -350,6 +350,66 @@ fn sane_xfrm(x: &mut deckcraft_model::Xfrm) { 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)); + for s in p.shadow.iter_mut().chain(p.inner_shadow.iter_mut()) { + sane_shadow(s); + } + if let Some(g) = p.glow.as_mut() { + g.radius = effect_size(g.radius, MAX_GLOW); + } + if let Some(r) = p.reflection.as_mut() { + sane_reflection(r); + } + p.soft_edge = p.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE)); +} + +/// The largest effect sizes, in points: what Format Shape and `shape.effects` allow. A file can +/// say anything, and the renderer's memory grows with these (a glow of 10^18 pt asked for 34 GB). +const MAX_GLOW: f64 = 150.0; +const MAX_SOFT_EDGE: f64 = 100.0; +const MAX_SHADOW: f64 = 200.0; +const MAX_REFLECTION: f64 = 100.0; + +/// `v` within 0..=`max`; not a number is 0. +fn effect_size(v: f64, max: f64) -> f64 { + if v.is_finite() { v.clamp(0.0, max) } else { 0.0 } +} + +fn sane_shadow(s: &mut deckcraft_model::style::Shadow) { + s.blur = effect_size(s.blur, MAX_SHADOW); + s.dist = effect_size(s.dist, MAX_SHADOW); + s.dir = if s.dir.is_finite() { s.dir.rem_euclid(360.0) } else { 0.0 }; + // Perspective scale (1 = 100%) and skew (degrees). + for v in [&mut s.sx, &mut s.sy] { + *v = if v.is_finite() { v.clamp(-10.0, 10.0) } else { 1.0 }; + } + for v in [&mut s.kx, &mut s.ky] { + *v = if v.is_finite() { v.clamp(-89.0, 89.0) } else { 0.0 }; + } +} + +fn sane_reflection(r: &mut deckcraft_model::style::Reflection) { + r.blur = effect_size(r.blur, MAX_REFLECTION); + r.dist = effect_size(r.dist, MAX_REFLECTION); +} + +/// Run properties a list style hands down to the text that inherits it. +fn sane_list_style(ls: &mut deckcraft_model::text::ListStyle) { + for level in ls.levels.iter_mut().flatten() { + sane_run(&mut level.run); + } +} + +fn sane_effects(e: &mut deckcraft_model::Effects) { + for s in e.outer_shadow.iter_mut().chain(e.inner_shadow.iter_mut()) { + sane_shadow(s); + } + if let Some(g) = e.glow.as_mut() { + g.radius = effect_size(g.radius, MAX_GLOW); + } + e.soft_edge = e.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE)); + if let Some(r) = e.reflection.as_mut() { + sane_reflection(r); + } } fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { @@ -363,7 +423,11 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { 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(e) = sh.effects.as_mut() { + sane_effects(e); + } if let Some(t) = sh.text.as_mut() { + sane_list_style(&mut t.list_style); for para in &mut t.paragraphs { sane_run(&mut para.end_props); for r in &mut para.runs { @@ -381,6 +445,17 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { sane_xfrm(child); sane_shapes(children, depth + 1); } + deckcraft_model::ShapeKind::Table(t) => { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + sane_list_style(&mut cell.text.list_style); + } + for para in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()).flat_map(|c| c.text.paragraphs.iter_mut()) { + sane_run(&mut para.end_props); + for r in &mut para.runs { + sane_run(&mut r.props); + } + } + } _ => {} } } @@ -395,12 +470,21 @@ pub fn sane_numbers(p: &mut Presentation) { *slide = Arc::new(fixed); } } + // What text and shapes inherit: the deck's default text style, each master's title, body and + // other styles, and the theme's effect styles that shape styles point at. + sane_list_style(&mut p.default_text_style); 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); } + for ls in [&mut fixed.title_style, &mut fixed.body_style, &mut fixed.other_style] { + sane_list_style(ls); + } + for e in &mut fixed.theme.format.effects { + sane_effects(e); + } if fixed != **m { *m = Arc::new(fixed); }