diff --git a/ROADMAP.md b/ROADMAP.md index 76f27b5c..a8ea9ca4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 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-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | | 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-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 | 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) | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | @@ -232,6 +233,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | +| 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/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 04338a9a..c6276efb 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -260,11 +260,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, @@ -297,7 +299,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})"); @@ -696,6 +698,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 8e4daad9..ba567571 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", @@ -462,8 +462,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) } @@ -482,7 +488,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})) } @@ -514,6 +520,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 is for PNG export, not {format}"))); + } match format.as_str() { "png" | "jpeg" | "jpg" => { let slides: Vec = match usize_param(p, "slide") { @@ -528,7 +538,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 e963027d..95ca00f5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2797,6 +2797,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("for PNG export"), "{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..d37952ea 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") + .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]; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e0d6d227..4ec7b72e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,7 +49,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 fbab8036..f15ec795 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 | @@ -105,6 +105,7 @@ the round trip even though DeckCraft doesn't model it. |---|---|---| | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | | 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | +| 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) |