From 39e7fb1d4f2188ad9b2bcd5bd31437b8684f37cd Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 16:30:52 +0200 Subject: [PATCH 1/2] Export: PNG with a transparent background (#784) The renderer lays every slide background over white (as PowerPoint does), so a PNG export was always opaque. A new transparentBackground option leaves the slide background out (both the white underlay and the background fill), so only what is on the slide is drawn over transparency. Shapes filled with the background (useBgFill) still show it. - render: RenderOpts::transparent_background - engine: file.export {transparentBackground} (PNG only; other formats refuse it and write nothing), file.render {transparentBackground} - UI: Transparent background checkbox in the Export dialog (PNG) and in File > Export > Change File Type > PNG; the web download honours it - CLI: render --transparent --- apps/deckcraft-cli/src/help.rs | 9 +++-- apps/deckcraft-cli/src/main.rs | 19 +++++++++- crates/engine/src/cmd/file.rs | 24 +++++++++--- crates/engine/src/tests.rs | 27 ++++++++++++++ crates/render/src/lib.rs | 9 +++++ crates/render/src/tests.rs | 27 ++++++++++++++ crates/render/src/transition.rs | 1 + crates/ui-egui/src/backstage.rs | 12 +++++- crates/ui-egui/src/backstage_tests.rs | 26 +++++++++++++ crates/ui-egui/src/dialogs.rs | 54 +++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 46 ++++++++++++++++++++--- 11 files changed, 237 insertions(+), 17 deletions(-) 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]; From 392734a135ed51ee5dd7cf8d0751efb1c302577e Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 16:42:42 +0200 Subject: [PATCH 2/2] Export: transparent background docs and clearer refusal (#784) The error for a non-PNG export says the option is for PNG export. ROADMAP, file-format, UI parity and control protocol docs mention the option. --- ROADMAP.md | 2 ++ crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/tests.rs | 2 +- crates/ui-egui/src/dialogs.rs | 2 +- docs/control-protocol.md | 2 +- docs/file-format-parity.md | 3 ++- docs/ui-parity.md | 3 ++- 7 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7dfa96f8..d31c2a30 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) | @@ -227,6 +228,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9be0863f..c4cb6de6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -491,7 +491,7 @@ fn export(s: &mut Session, p: &Value) -> Result { 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"))); + return Err(bad("file.export", format!("a transparent background is for PNG export, not {format}"))); } match format.as_str() { "png" | "jpeg" | "jpg" => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b4ca50e6..9b7115c5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2798,7 +2798,7 @@ fn a_png_export_can_leave_the_background_transparent() { 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!(e.to_string().contains("for PNG export"), "{format}: {e}"); assert!(!path.exists(), "{format}: nothing is written"); } let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f064b7b0..d37952ea 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1599,7 +1599,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { 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)") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn") .changed() { d.fields.insert("pngTransparent".into(), clear.to_string()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e715d1c7..707b90b4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -45,7 +45,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | | `ui.resize` / `ui.focus` | | Window control | | `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`, or `{"path": "slide.png", "transparentBackground": true}` for a PNG without the slide background) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a5f72fe7..1532ff40 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -26,7 +26,7 @@ PowerPoint opens and saves; Viewer = opens only). | `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | | Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | -| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size | render and engine tests | +| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size. PNG can leave the slide background out (`transparentBackground`, Export… “Transparent background”, `deckcraft-cli render --transparent`), so only the slide's content is opaque (#784) | render, engine, UI and CLI tests | | TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none | | Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests | | MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none | @@ -103,6 +103,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | PNG slide export with a transparent background (#784) | | 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | | 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc97dae4..3b1fedf7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -55,7 +55,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Save As | Places, folder, file name, type, Save, More options | Type (PowerPoint, DeckCraft, PDF, Outline, PNG, JPEG) and Save, which opens the system Save dialog (web: Download). .pptx/.deckcraft take the new file; the others are copies (`file.saveAs`, `file.export`) | | Print | Print and Copies, Printer, Print All / Selection / Current / Custom Range, Slides box, layout (Full Page Slides, Notes Pages, Outline, Handouts 1–9), sides, collation, colour, Edit Header & Footer; page preview with pager and zoom | Print, Print All Slides / Current Slide / Custom Range, Slides box (`1-3, 5`), Full Page Slides / Notes Pages / Handouts 1–9, Print Hidden Slides, Frame Slides, Edit Header & Footer; preview with pager. DeckCraft prints through a PDF (`file.export`) opened in the system viewer, which has the printers (web: downloaded). No printer list, copies, sides or colour | | Share | Cloud sharing | Not offered | -| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type. No video, GIF or CD package | +| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type (PNG with an optional transparent background, #784). No video, GIF or CD package | | Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too | | Account | User information, sign-in, Office Background and Office Theme, product information, About | Appearance (light/dark, `view.dark`), product information, About DeckCraft. No accounts to sign in to | | Options | Opens the PowerPoint Options dialog | Opens Preferences | @@ -165,6 +165,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Export › Change File Type › PNG and the Export… dialog offer a transparent background (#784) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) | | 2026-10-13 | trivial | Eyedropper in the colour menus (#367) |