Merge pull request #885 from switch87/feat/784-transparent-png-export

This commit is contained in:
Brandon Thomas
2026-10-18 11:17:19 -04:00
15 changed files with 244 additions and 20 deletions
+2
View File
@@ -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 |
+5 -4
View File
@@ -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",
},
+18 -1
View File
@@ -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() {
+19 -5
View File
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<u8>, 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<u8>, 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<Value> {
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<Value> {
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<usize> = match usize_param(p, "slide") {
@@ -528,7 +538,11 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
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()
+27
View File
@@ -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()?;
+9
View File
@@ -238,6 +238,10 @@ pub struct RenderOpts<'a> {
pub date_text: Option<String>,
/// The local date and time date fields show (`None`: the system clock).
pub now: Option<DateTime>,
/// 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);
+27
View File
@@ -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
+1
View File
@@ -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,
}
}
+11 -1
View File
@@ -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);
}
}
}
+26
View File
@@ -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() {
+54
View File
@@ -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, &[]);
+40 -6
View File
@@ -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 `<name>1.png`,
/// `<name>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<u8>)> {
/// `<name>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<u8>)> {
let slides: Vec<usize> = 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<RefCell<Vec<(String, Vec<u8>)>>> = 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];
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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 |
+2 -1
View File
@@ -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) |