From 3f82866aafc3f37b273efc16ceef61517265be9b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 05:20:06 +0300 Subject: [PATCH] Export: image exports keep the whole slide; partial failures say what was written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer caps a picture at MAX_SIDE (16,000 px) by clipping, so an image export whose slide × scale passed that (4× of a 4032 pt slide) came out cropped. file.export and file.render now lower the scale to fit (cmd::file::image_scale). When exporting every slide and one file can't be written, the error says which slides already were, and slide numbers are checked before anything is written. Co-authored-by: Cursor --- crates/engine/src/cmd/file.rs | 29 +++++++++++++++++++---------- crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f6424f7c..c48dd482 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -252,10 +252,19 @@ fn save_bytes_cmd(s: &mut Session, p: &Value) -> Result { Ok(json!({"data": base64_encode(&bytes), "bytes": bytes.len()})) } +/// `scale` for rendering `doc`'s slides to an image: within 0.01–16, and small enough that the +/// slide fits the renderer's largest picture ([`deckcraft_render::MAX_SIDE`]). Past that the +/// renderer clips, so a 4× export of a 4032 pt slide came out cropped to its first 16,000 pixels. +pub fn image_scale(doc: &Presentation, scale: f64) -> f64 { + let scale = if scale.is_finite() { scale.clamp(0.01, 16.0) } else { 1.0 }; + let longest = doc.slide_size.width.max(doc.slide_size.height).max(1.0); + scale.min(f64::from(deckcraft_render::MAX_SIDE) / longest) +} + /// Render slide `index` (current when absent) to PNG bytes. pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec, u32, u32) { let img = - deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), edit, ..Default::default() }); + deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale: image_scale(doc, scale), edit, ..Default::default() }); (img.to_png(), img.width, img.height) } @@ -281,16 +290,14 @@ fn export(s: &mut Session, p: &Value) -> Result { None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(), None => vec![st.selection.slide], }; + // Every slide checked before any file is written, so a bad index leaves nothing behind. + if let Some(i) = slides.iter().find(|i| **i >= st.doc.slides.len()) { + return Err(bad("file.export", format!("no slide {i}"))); + } + let scale = image_scale(&st.doc, scale); let mut written = vec![]; for (k, i) in slides.iter().enumerate() { - if *i >= st.doc.slides.len() { - return Err(bad("file.export", format!("no slide {i}"))); - } - let img = deckcraft_render::render_slide( - &st.doc, - *i, - &deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), ..Default::default() }, - ); + let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); let bytes = if format == "png" { img.to_png() } else { img.to_jpeg(92) }; let out = if slides.len() == 1 { path.clone() @@ -300,7 +307,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let ext = pth.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| format.clone()); pth.with_file_name(format!("{stem}{}.{ext}", k + 1)).to_string_lossy().to_string() }; - write_file(&out, &bytes)?; + // Say which slides made it when one can't be written (disk full part-way). + write_file(&out, &bytes) + .map_err(|e| if written.is_empty() { e } else { EngineError::Other(format!("{e} (slides 1–{k} were written)")) })?; written.push(out); } Ok(json!({"files": written})) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..b0638db2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,34 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +/// Image export keeps the whole slide: the scale is lowered so the picture fits the renderer's +/// largest side (past it the renderer clips, and big exports came out cropped). +#[test] +fn image_export_scale_fits_the_largest_picture() { + let mut s = Session::with_new(); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(crate::cmd::file::image_scale(&doc, 2.0), 2.0); + assert_eq!(crate::cmd::file::image_scale(&doc, f64::NAN), 1.0); + let max = f64::from(deckcraft_render::MAX_SIDE); + assert_eq!(crate::cmd::file::image_scale(&doc, 16.0), 16.0, "16× of 960 pt fits"); + s.execute("design.slideSize", &json!({"w": 4032, "h": 72})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let k = crate::cmd::file::image_scale(&doc, 4.0); + assert!(k * 4032.0 <= max + 1e-6 && k * 4032.0 > max - 1.0, "{k}"); +} + +/// Exporting every slide as images: when one file can't be written, the error says which +/// slides already were. +#[test] +fn a_failed_all_slides_export_says_what_was_written() { + let dir = std::env::temp_dir().join(format!("deckcraft-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("deck2.png")).unwrap(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let e = s.execute("file.export", &json!({"path": dir.join("deck.png").to_string_lossy(), "all": true, "scale": 0.1})).unwrap_err().to_string(); + assert!(e.contains("slides 1–1 were written"), "{e}"); + assert!(dir.join("deck1.png").is_file()); + let _ = std::fs::remove_dir_all(&dir); +}