Merge PR #681 from NikitkaRa: Export: image exports keep the whole slide; partial failures say what was written

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:09:07 -04:00
2 changed files with 50 additions and 10 deletions
+19 -10
View File
@@ -329,10 +329,19 @@ fn save_bytes_cmd(s: &mut Session, p: &Value) -> Result<Value> {
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<u8>, 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)
}
@@ -381,16 +390,14 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
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()
@@ -400,7 +407,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
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}))
+31
View File
@@ -1618,3 +1618,34 @@ fn paste_special_keeps_source_look_picture_or_text() {
assert_eq!(last.text.as_ref().unwrap().text(), "Q3 plan");
assert!(s.execute("edit.pasteSpecial", &json!({"as": "bogus"})).is_err());
}
/// 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);
}