mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 19:46:58 -06:00
Merge pull request #885 from switch87/feat/784-transparent-png-export
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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, &[]);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user