diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 535ce848..4227905f 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -19,15 +19,16 @@ The shapes of one slide, with their ids, boxes and text, come from the slide.ins }, Subcommand { name: "render", - usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT", + usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] [--transparent] OUT", summary: "draw slides as PNG images", details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first; OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per slide. Prints each image's path and size. - --slide N the slide at index N; indices start at 0, and 0 is the default - --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080 - --edit draw what the editor shows: placeholder prompts and their dashed outlines + --slide N the slide at index N; indices start at 0, and 0 is the default + --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080 + --edit draw what the editor shows: placeholder prompts and their dashed outlines + --transparent leave the slide background out, so only what is on the slide is drawn deckcraft-cli render deck.deckcraft --slide 2 slide-3.png", }, diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index 5934973e..8fb9f524 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -244,11 +244,13 @@ fn render(args: &[String]) -> Result<(), String> { let mut all = false; let mut scale = 2.0; let mut edit = false; + let mut transparent = false; let mut out = None; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--sample" => sample = true, + "--transparent" => transparent = true, "--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?), "--all" => all = true, "--edit" => edit = true, @@ -281,7 +283,7 @@ fn render(args: &[String]) -> Result<(), String> { std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?; } for i in list { - let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit); + let (png, w, h) = deckcraft_engine::cmd::file::render_png_with(&st.doc, i, scale, edit, transparent); let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() }; std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?; outln!("{path} ({w}×{h})"); @@ -680,6 +682,21 @@ mod tests { } } + /// `render --transparent` leaves the slide background out (#784): the corner is transparent. + #[test] + fn render_transparent_leaves_the_background_out() { + let dir = std::env::temp_dir().join(format!("deckcraft-cli-transparent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let alpha = |path: &std::path::Path| deckcraft_engine::render::decode_image(&std::fs::read(path).unwrap()).unwrap().get_pixel(1, 1).0[3]; + let opaque = dir.join("opaque.png"); + render(&args(&["--sample", "--scale", "0.25", &opaque.to_string_lossy()])).unwrap(); + assert_eq!(alpha(&opaque), 255); + let clear = dir.join("clear.png"); + render(&args(&["--sample", "--transparent", "--scale", "0.25", &clear.to_string_lossy()])).unwrap(); + assert_eq!(alpha(&clear), 0); + let _ = std::fs::remove_dir_all(&dir); + } + /// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs. #[test] fn exports_an_animated_gif() { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4761dc10..9be0863f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export), cmd!( query "file.info", "Info", @@ -43,7 +43,7 @@ pub fn specs() -> Vec { has_doc, info ), - cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render), + cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool, transparentBackground?: bool} → {png: base64, width, height}", has_doc, render), cmd!( query "slide.colorAt", "Color At", @@ -431,8 +431,14 @@ pub fn image_scale(doc: &Presentation, scale: f64) -> f64 { /// Render slide `index` (current when absent) to PNG bytes. pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec, u32, u32) { + render_png_with(doc, index, scale, edit, false) +} + +/// [`render_png`]; `transparent` leaves the slide background out (#784). +pub fn render_png_with(doc: &Presentation, index: usize, scale: f64, edit: bool, transparent: bool) -> (Vec, u32, u32) { let scale = image_scale(doc, scale); - let img = deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale, edit, ..Default::default() }); + let opts = deckcraft_render::RenderOpts { scale, edit, transparent_background: transparent, ..Default::default() }; + let img = deckcraft_render::render_slide(doc, index, &opts); (img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height) } @@ -451,7 +457,7 @@ fn render(s: &mut Session, p: &Value) -> Result { if i >= st.doc.slides.len() { return Err(bad("file.render", format!("no slide {i}"))); } - let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false)); + let (png, w, h) = render_png_with(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false), bool_or(p, "transparentBackground", false)); Ok(json!({"png": base64_encode(&png), "width": w, "height": h})) } @@ -483,6 +489,10 @@ fn export(s: &mut Session, p: &Value) -> Result { let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string(); let st = s.doc()?; let scale = finite_scale(f64_or(p, "scale", 2.0)); + let transparent = bool_or(p, "transparentBackground", false); + if transparent && format != "png" { + return Err(bad("file.export", format!("a transparent background needs PNG; {format} can't keep transparency"))); + } match format.as_str() { "png" | "jpeg" | "jpg" => { let slides: Vec = match usize_param(p, "slide") { @@ -497,7 +507,11 @@ fn export(s: &mut Session, p: &Value) -> Result { let scale = image_scale(&st.doc, scale); let mut written = vec![]; for (k, i) in slides.iter().enumerate() { - let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); + let img = deckcraft_render::render_slide( + &st.doc, + *i, + &deckcraft_render::RenderOpts { scale, transparent_background: transparent, ..Default::default() }, + ); let bytes = picture_bytes(&img, &format, scale); let out = if slides.len() == 1 { path.clone() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f7022eab..b4ca50e6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2777,6 +2777,33 @@ fn a_png_export_records_the_slide_resolution() { let _ = std::fs::remove_dir_all(&dir); } +/// #784: `transparentBackground` leaves the slide background out of a PNG export, so only the +/// slide's content is opaque; formats without transparency refuse it and write nothing. +#[test] +fn a_png_export_can_leave_the_background_transparent() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("design.background", &json!({"color": "#0000C8"})).unwrap(); + let dir = std::env::temp_dir().join(format!("deckcraft-transparent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let read = |path: &std::path::Path| image::load_from_memory(&std::fs::read(path).unwrap()).unwrap().to_rgba8(); + let opaque = dir.join("opaque.png"); + s.execute("file.export", &json!({"path": opaque.to_str().unwrap(), "scale": 1})).unwrap(); + assert_eq!(read(&opaque).get_pixel(5, 5).0, [0, 0, 200, 255], "the background draws by default"); + let clear = dir.join("clear.png"); + s.execute("file.export", &json!({"path": clear.to_str().unwrap(), "scale": 1, "transparentBackground": true})).unwrap(); + let img = read(&clear); + assert_eq!(img.get_pixel(5, 5).0[3], 0, "no background: {:?}", img.get_pixel(5, 5)); + assert_eq!(img.get_pixel(200, 150).0[3], 255, "the shape stays opaque: {:?}", img.get_pixel(200, 150)); + for (name, format) in [("slide.jpg", "jpeg"), ("deck.pdf", "pdf"), ("deck.gif", "gif"), ("deck.pptx", "pptx")] { + let path = dir.join(name); + let e = s.execute("file.export", &json!({"path": path.to_str().unwrap(), "format": format, "transparentBackground": true})).unwrap_err(); + assert!(e.to_string().contains("needs PNG"), "{format}: {e}"); + assert!(!path.exists(), "{format}: nothing is written"); + } + let _ = std::fs::remove_dir_all(&dir); +} + fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { let w: [u8; 4] = bytes.get(16..20)?.try_into().ok()?; let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 93b0dc95..cf124a9f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -238,6 +238,10 @@ pub struct RenderOpts<'a> { pub date_text: Option, /// The local date and time date fields show (`None`: the system clock). pub now: Option, + /// Leave the slide background out (neither the white underlay nor the background fill), so + /// only what is on the slide is drawn over transparency: PNG export with a transparent + /// background. Shapes filled with the background (`useBgFill`) still show it. + pub transparent_background: bool, } impl Default for RenderOpts<'_> { @@ -257,6 +261,7 @@ impl Default for RenderOpts<'_> { clear: None, date_text: None, now: None, + transparent_background: false, } } } @@ -794,6 +799,10 @@ impl Renderer { fn background(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: Option<&Slide>, view: Affine) { let (fill, ph) = resolve::background(rctx, slide); + if f.opts.transparent_background { + self.bg = Some((fill, ph, view)); + return; + } let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); ctx.set_transform(view); let path = r.to_path(0.1); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 05d7c91a..4ee1c889 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -992,6 +992,33 @@ fn a_background_is_laid_over_white() { assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}"); } +#[test] +fn a_transparent_background_leaves_the_slide_background_out() { + // #784: PNG export with a transparent background draws only what is on the slide. The + // background (a solid colour here) and its white underlay are left out; the shapes, and a + // shape filled with the background (`useBgFill`), still draw. + use deckcraft_model::style::Fill; + let blue = ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 200)); + let red = Shape { + id: ShapeId(784), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(220, 0, 0)))), + ..Default::default() + }; + let bg_filled = Shape { id: ShapeId(785), xfrm: Some(Xfrm::new(500.0, 100.0, 200.0, 100.0)), fill: Some(Fill::Background), ..Default::default() }; + let mut p = deck_with(vec![red, bg_filled]); + std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(blue) }); + let opaque = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!(opaque.pixel(20, 20), [0, 0, 200, 255], "without the option the background draws"); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, transparent_background: true, ..Default::default() }); + assert_eq!(img.pixel(20, 20), [0, 0, 0, 0], "no background, no white underlay"); + assert_eq!(img.pixel(200, 150), [220, 0, 0, 255], "the slide's shapes still draw"); + assert_eq!(img.pixel(600, 150), [0, 0, 200, 255], "a background-filled shape still shows the background"); + // The default theme background (a style reference) is left out too. + let img = render_slide(&deck_with(vec![]), 0, &RenderOpts { scale: 0.25, transparent_background: true, ..Default::default() }); + assert!(img.pixels.as_chunks::<4>().0.iter().all(|px| px[3] == 0), "a blank slide is fully transparent"); +} + #[test] fn colour_glyphs_paint_their_layers() { // A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs index bd6e3317..ee760767 100644 --- a/crates/render/src/transition.rs +++ b/crates/render/src/transition.rs @@ -113,6 +113,7 @@ fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> { clear: o.clear, date_text: o.date_text.clone(), now: o.now, + transparent_background: o.transparent_background, } } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f7bfe253..999900d3 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -175,6 +175,8 @@ pub struct Backstage { pub file_type: &'static str, pub handouts_per_page: u8, pub pdf_hidden: bool, + /// Export › Change File Type › PNG: leave the slide background out (#784). + pub png_transparent: bool, } impl Backstage { @@ -197,6 +199,7 @@ impl Backstage { file_type: "pptx", handouts_per_page: 3, pdf_hidden: false, + png_transparent: false, } } @@ -1310,8 +1313,15 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } } ui.add_space(12.0); + let png = bs.file_type == "png"; + if png { + ui.checkbox(&mut bs.png_transparent, "Transparent background") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn"); + ui.add_space(8.0); + } if square_button(ui, Icon::SaveAs, "Save As", true).clicked() { - app.export_as(bs.file_type, json!({})); + let extra = if png && bs.png_transparent { json!({"transparentBackground": true}) } else { json!({}) }; + app.export_as(bs.file_type, extra); } } } diff --git a/crates/ui-egui/src/backstage_tests.rs b/crates/ui-egui/src/backstage_tests.rs index 9b3906a2..c29ef47d 100644 --- a/crates/ui-egui/src/backstage_tests.rs +++ b/crates/ui-egui/src/backstage_tests.rs @@ -302,6 +302,32 @@ fn save_as_and_export_run_the_file_commands() { let _ = std::fs::remove_dir_all(&dir); } +/// Export › Change File Type › PNG offers "Transparent background" (#784): the PNG it saves +/// leaves the slide background out. Other types don't show the option. +#[test] +fn change_file_type_png_can_leave_the_background_transparent() { + let dir = temp_dir("png-clear"); + let (services, _) = saving_into(&dir); + let mut h = harness(SlideApp::new(Session::with_new(), services)); + h.state_mut().run("app.backstage", json!({"page": "export"})).unwrap(); + h.run_steps(3); + click_on_page(&h, "Change File Type"); + h.run_steps(3); + assert!(h.query_by_label("Transparent background").is_none(), "only PNG has the option"); + click_on_page(&h, "PNG Picture (*.png)"); + h.run_steps(2); + h.get_by_label("Transparent background").click(); + h.run_steps(2); + assert!(h.state().backstage.as_ref().is_some_and(|b| b.png_transparent)); + click_on_page(&h, "Save As"); + h.run_steps(3); + let title = h.state().session.active().unwrap().title(); + let png = std::fs::read(dir.join(format!("{title}.png"))).unwrap(); + let img = deckcraft_render::decode_image(&png).unwrap(); + assert_eq!(img.get_pixel(2, 2).0[3], 0, "the background is left out"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Close closes a saved presentation at once and asks about unsaved changes first. #[test] fn close_closes_and_asks_about_unsaved_changes() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 576a2297..f064b7b0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1593,6 +1593,18 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { d.fields.insert("pdfHidden".into(), hidden.to_string()); } } + let png = matches!(fmt.as_str(), "png" | "pngAll"); + if png { + ui.add_space(4.0); + let mut clear = d.get("pngTransparent", "false") == "true"; + if ui + .checkbox(&mut clear, "Transparent background") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn (#784)") + .changed() + { + d.fields.insert("pngTransparent".into(), clear.to_string()); + } + } let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; if let Err(e) = &gif_params { ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); @@ -1618,6 +1630,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let extra = match fmt.as_str() { "pdf" => pdf_params, "gif" => gif_params, + _ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}), _ => json!({}), }; app.export_as(&fmt, extra); @@ -3432,6 +3445,47 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The Export… dialog offers "Transparent background" for PNG only, and the picture it + /// writes then leaves the slide background out (#784). + #[test] + fn export_dialog_writes_a_png_with_a_transparent_background() { + use egui::vec2; + use egui_kittest::kittest::Queryable as _; + use serde_json::json; + + use crate::SlideApp; + let dir = std::env::temp_dir().join(format!("deckcraft-ui-png-clear-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("slide.png"); + let picked = path.to_string_lossy().to_string(); + let services = crate::Services { pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), ..Default::default() }; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(deckcraft_engine::Session::with_new(), services), + ); + harness.run_steps(2); + harness.state_mut().run("app.exportDialog", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label("Transparent background").is_none(), "PDF has no transparent background"); + harness.get_by_label("JPEG image (current slide)").click_accesskit(); + harness.run_steps(2); + assert!(harness.query_by_label("Transparent background").is_none(), "nor has JPEG"); + harness.get_by_label("PNG images (current slide)").click_accesskit(); + harness.run_steps(2); + harness.get_by_label("Transparent background").click_accesskit(); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("pngTransparent")).map(String::as_str), Some("true")); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "the dialog closes after exporting"); + let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(img.get_pixel(2, 2).0[3], 0, "the background is left out"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn no_link_opens_as_a_new_web_address() { let seed = seed_from(CurrentLink::None, &[]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 88674070..dcfc05cf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -769,7 +769,8 @@ impl SlideApp { // Every slide for "all slides", one download each, named like the desktop export (#64). let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); let mut any = false; - for (file, bytes) in web_image_files(&doc, slide, all, &name, ext) { + let transparent = extra.get("transparentBackground").and_then(Value::as_bool).unwrap_or(false); + for (file, bytes) in web_image_files(&doc, slide, all, &name, ext, transparent) { any = self.download(&file, Ok(bytes)) || any; } return any; @@ -1987,8 +1988,16 @@ pub fn now_ms() -> f64 { } /// Images for the browser download: the current slide, or every slide (named `1.png`, -/// `2.png`... like the native export) when `all`. -fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: bool, name: &str, ext: &str) -> Vec<(String, Vec)> { +/// `2.png`... like the native export) when `all`. `transparent` leaves the slide +/// background out (PNG only, #784). +fn web_image_files( + doc: &deckcraft_model::Presentation, + current: usize, + all: bool, + name: &str, + ext: &str, + transparent: bool, +) -> Vec<(String, Vec)> { let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; let single = slides.len() == 1; let scale = 2.0; @@ -1997,7 +2006,8 @@ fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: boo .into_iter() .enumerate() .map(|(k, i)| { - let img = deckcraft_render::render_slide(doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); + let opts = deckcraft_render::RenderOpts { scale, transparent_background: transparent && ext == "png", ..Default::default() }; + let img = deckcraft_render::render_slide(doc, i, &opts); let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; (file, bytes) @@ -2098,14 +2108,38 @@ mod tests { doc.slides.push(first); } assert_eq!(doc.slides.len(), 3); - let all = web_image_files(&doc, 1, true, "Deck", "png"); + let all = web_image_files(&doc, 1, true, "Deck", "png", false); let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); - let one = web_image_files(&doc, 1, false, "Deck", "png"); + let one = web_image_files(&doc, 1, false, "Deck", "png", false); assert_eq!(one.len(), 1); assert_eq!(one[0].0, "Deck.png"); } + /// The web PNG download honours "Transparent background" (#784): the slide background is + /// left out, so the corner is transparent; a JPEG download stays opaque. + #[test] + fn web_png_export_can_leave_the_background_transparent() { + use std::cell::RefCell; + use std::rc::Rc; + let got: Rc)>>> = Rc::default(); + let log = got.clone(); + let services = Services { + download: Some(Box::new(move |name: &str, b: &[u8]| log.borrow_mut().push((name.to_string(), b.to_vec())))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let corner_alpha = |bytes: &[u8]| deckcraft_render::decode_image(bytes).unwrap().get_pixel(2, 2).0[3]; + assert!(app.export_as("png", json!({}))); + assert!(app.export_as("png", json!({"transparentBackground": true}))); + assert!(app.export_as("jpeg", json!({"transparentBackground": true}))); + let got = got.borrow(); + assert_eq!(got.len(), 3); + assert_eq!(corner_alpha(&got[0].1), 255, "opaque by default"); + assert_eq!(corner_alpha(&got[1].1), 0, "transparent background"); + assert_eq!(corner_alpha(&got[2].1), 255, "JPEG has no transparency"); + } + fn title_text(app: &SlideApp) -> String { let d = app.session.active().unwrap(); let sh = &d.doc.slides[0].shapes[0];