collective

Browse / github.com/storytold/deckcraft / patch

Save as Picture… on a shape's right-click menu writes the selection as one picture

There is no "Save as picture..." option when right clicking a shape In PowerPoint, when we right click in a shape (or a selection of shapes), there is the option "Save as picture...". When clicked, it opens a new window that allows the user to choose the location where to store the shape as an image in the file system. Moreover, this new window allows the user to select the new file name and the image type (with the options "Portable Network Graphics", "JPEG File Interchange Format", "Graphics Interchange Format", "Tag Image File Format", "Windows Bitmap", "Scalable Vector Graphics", "Enhanced Metafile", "Windows Metafile"). This option is especially handy wh

Status
open
Score
0.00
Adopters
0 other operators
Author
marcoarnulfo (imported from pull request #1222, open)
Base
b47195feec0f0fe4220d8943ad1c8add5dad8072
Size
+608 −21 in 12 files
Review
pending
Shared
2026-10-28
Id
8a42f5800eed991577ba9ce9

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/deckcraft-web/src/web.rs crates/engine/src/cmd/file.rs crates/engine/src/cmd/tests_save_picture.rs crates/engine/src/gif_export.rs crates/render/src/extent.rs crates/render/src/lib.rs crates/render/src/tests.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/lib.rs docs/mcp.md docs/ui-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | What landed | |---|---|+| 2026-10-23 | Save as Picture… on a shape's right-click menu: the selected shapes as one picture cut to them, PNG (see-through around them), JPEG, GIF, TIFF or BMP by the file name; a shape inside a group is saved as the slide shows it (`shape.saveAsPicture`) (#1073) | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | | 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 |@@ -241,6 +242,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | Change | Summary | |---|---|---|+| 2026-10-23 | trivial | Progress log: Save as Picture… on a shape's right-click menu (#1073) | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | | 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs--- a/apps/deckcraft-web/src/web.rs+++ b/apps/deckcraft-web/src/web.rs@@ -135,11 +135,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {             });         })),         write: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))),-        download: Some(Box::new(|name: &str, bytes: &[u8]| {-            if let Err(e) = download(name, bytes) {-                log::error!("download of {name} failed: {e}");-            }-        })),+        download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))),         inbox: Some(inbox),         ..Default::default()     }diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -34,6 +34,15 @@ pub fn specs() -> Vec<CommandSpec> {         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?, 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!(+            noundo "shape.saveAsPicture",+            "Save as Picture…",+            [],+            None,+            "{path, format?: png|jpeg|gif|tiff|bmp (else by the path's extension), ids?: [shape id] (else the selection), scale?: px per pt (2; the longer side stops at 2048 px)} → {path, bytes, width, height}: the shapes as one picture cut to them (with what their outlines and effects draw outside them, at least 8 pt); PNG and TIFF keep the background see-through, JPEG, GIF and BMP put the shapes on white",+            has_selection,+            save_as_picture+        ),         cmd!(             query "file.info",             "Info",@@ -482,6 +491,152 @@ fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec     if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) } } +/// The right-click menu's Save as Picture… (#1073): the shapes, not the slide, as one picture.+fn save_as_picture(s: &mut Session, p: &Value) -> Result<Value> {+    let path = str_param(p, "path").ok_or_else(|| bad(SAVE_AS_PICTURE, "missing `path`"))?.to_string();+    let format = match str_param(p, "format") {+        Some(f) => f.to_ascii_lowercase(),+        None => picture_format_for_path(&path).ok_or_else(|| bad(SAVE_AS_PICTURE, PICTURE_TYPES))?.to_string(),+    };+    let ids = targets(s, p)?;+    let (bytes, width, height) = shapes_picture(s.doc()?, &ids, &format, finite_scale(f64_or(p, "scale", 2.0)))?;+    write_file(&path, &bytes)?;+    Ok(json!({"path": path, "bytes": bytes.len(), "width": width, "height": height}))+}++const SAVE_AS_PICTURE: &str = "shape.saveAsPicture";+const PICTURE_TYPES: &str = "Save as Picture writes PNG, JPEG, GIF, TIFF or BMP: end the file name with .png, .jpg, .gif, .tif or .bmp";++/// The picture type Save as Picture writes for a file name, by its extension.+pub fn picture_format_for_path(path: &str) -> Option<&'static str> {+    let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();+    Some(match ext.as_str() {+        "png" => "png",+        "jpg" | "jpeg" | "jpe" | "jfif" => "jpeg",+        "gif" => "gif",+        "tif" | "tiff" => "tiff",+        "bmp" | "dib" => "bmp",+        _ => return None,+    })+}++/// Every shape id of `shapes`, groups before their children, back to front.+fn paint_order(shapes: &[deckcraft_model::Shape]) -> Vec<deckcraft_model::ShapeId> {+    fn walk(shapes: &[deckcraft_model::Shape], depth: usize, out: &mut Vec<deckcraft_model::ShapeId>) {+        if depth > 64 {+            return;+        }+        for s in shapes {+            out.push(s.id);+            walk(s.children(), depth + 1, out);+        }+    }+    let mut out = vec![];+    walk(shapes, 0, &mut out);+    out+}++/// Whether a group holding `id`, at any depth, is among `ids`.+fn has_ancestor_in(+    shapes: &[deckcraft_model::Shape],+    id: deckcraft_model::ShapeId,+    ids: &std::collections::HashSet<deckcraft_model::ShapeId>,+) -> bool {+    let mut parent = deckcraft_model::parent_of(shapes, id).flatten();+    while let Some(g) = parent {+        if ids.contains(&g) {+            return true;+        }+        parent = deckcraft_model::parent_of(shapes, g).flatten();+    }+    false+}++/// Shape `id` of the page being edited with its box in page space: a shape inside groups gets+/// the box their transforms give it. `None` when the page has no such shape.+fn on_the_page(st: &DocState, id: deckcraft_model::ShapeId) -> Option<deckcraft_model::Shape> {+    let shapes = st.shapes();+    let mut shape = deckcraft_model::find_shape(shapes, id)?.clone();+    let mut parent = deckcraft_model::parent_of(shapes, id)?;+    // parent_of stops at 64 levels, so this walk does too.+    while let Some(g) = parent {+        let group = deckcraft_model::find_shape(shapes, g)?;+        if let (deckcraft_model::ShapeKind::Group { child, .. }, Some(gx), Some(x)) = (&group.kind, group.xfrm, shape.xfrm) {+            shape.xfrm = Some(super::arrange::child_to_parent(&gx, child, &x));+        }+        parent = deckcraft_model::parent_of(shapes, g)?;+    }+    Some(shape)+}++/// The shapes `ids` of the page being edited drawn as one picture file in `format`, with its+/// width and height in pixels. Shared by Save as Picture and the web app's download.+pub fn shapes_picture(st: &DocState, ids: &[deckcraft_model::ShapeId], format: &str, scale: f64) -> Result<(Vec<u8>, u32, u32)> {+    if ids.is_empty() {+        return Err(bad(SAVE_AS_PICTURE, NOTHING_SELECTED));+    }+    if !matches!(format, "png" | "jpeg" | "jpg" | "gif" | "tiff" | "tif" | "bmp") {+        return Err(bad(SAVE_AS_PICTURE, format!("{PICTURE_TYPES}, not “{format}”")));+    }+    let shapes = st.shapes();+    // Back to front as the page stacks them, whatever order they were picked in, and a shape+    // whose group is picked too only once, with the group.+    let order: std::collections::HashMap<deckcraft_model::ShapeId, usize> =+        paint_order(shapes).into_iter().enumerate().map(|(i, id)| (id, i)).collect();+    if let Some(id) = ids.iter().find(|id| !order.contains_key(id)) {+        return Err(bad(SAVE_AS_PICTURE, format!("unknown shape id {}", id.0)));+    }+    let picked: std::collections::HashSet<deckcraft_model::ShapeId> = ids.iter().copied().collect();+    let mut ids: Vec<deckcraft_model::ShapeId> = ids.iter().copied().filter(|id| !has_ancestor_in(shapes, *id, &picked)).collect();+    ids.sort_by_key(|id| order.get(id).copied());+    ids.dedup();+    // The shapes are drawn on a copy of the current slide (a master or layout is not a slide),+    // each lifted out of its groups to where the slide shows it, as Ungroup would place it.+    let mut page = st.current_slide().cloned().unwrap_or_default();+    page.shapes = ids.iter().filter_map(|id| on_the_page(st, *id)).collect();+    let (img, drawn) =+        deckcraft_render::render_shapes_scaled(&st.doc, &page, &ids, scale).ok_or_else(|| bad(SAVE_AS_PICTURE, "the shapes could not be drawn"))?;+    let (w, h) = (img.width, img.height);+    let dpi = deckcraft_render::dpi_for_scale(drawn);+    // JPEG and BMP have no transparency and GIF only an on/off one: the shapes go on white, as+    // in File › Export, rather than on the black a dropped alpha channel leaves.+    let on_white = || deckcraft_render::Image { width: w, height: h, pixels: crate::gif_export::on_white(&img, w, h) };+    let encode_err = |e: image::ImageError| EngineError::Other(format!("the picture could not be written: {e}"));+    let bytes = match format {+        "png" => img.to_png_at(dpi),+        "jpeg" | "jpg" => on_white().to_jpeg_at(92, Some(dpi)),+        "gif" => {+            let mut buf = Vec::new();+            let mut enc = image::codecs::gif::GifEncoder::new_with_speed(&mut buf, crate::gif_export::QUANTIZER_SPEED);+            enc.encode(&on_white().pixels, w, h, image::ExtendedColorType::Rgba8).map_err(encode_err)?;+            drop(enc);+            buf+        }+        "bmp" => {+            let rgb: Vec<u8> = on_white().pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();+            let mut buf = Vec::new();+            image::codecs::bmp::BmpEncoder::new(&mut buf).encode(&rgb, w, h, image::ExtendedColorType::Rgb8).map_err(encode_err)?;+            buf+        }+        _ => {+            let mut buf = std::io::Cursor::new(Vec::new());+            image::ImageEncoder::write_image(+                image::codecs::tiff::TiffEncoder::new(&mut buf),+                &img.to_straight(),+                w,+                h,+                image::ExtendedColorType::Rgba8,+            )+            .map_err(encode_err)?;+            buf.into_inner()+        }+    };+    if bytes.is_empty() {+        return Err(bad(SAVE_AS_PICTURE, "the picture could not be written"));+    }+    Ok((bytes, w, h))+}+ fn render(s: &mut Session, p: &Value) -> Result<Value> {     let st = s.doc()?;     let i = usize_param(p, "slide").unwrap_or(st.selection.slide);@@ -707,6 +862,10 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> { #[path = "color_at_tests.rs"] mod color_at_tests; +#[cfg(all(test, not(target_arch = "wasm32")))]+#[path = "tests_save_picture.rs"]+mod save_picture_tests;+ #[cfg(all(test, not(target_arch = "wasm32")))] mod tests {     #![allow(clippy::unwrap_used, clippy::expect_used)]diff --git a/crates/engine/src/cmd/tests_save_picture.rs b/crates/engine/src/cmd/tests_save_picture.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/tests_save_picture.rs@@ -0,0 +1,243 @@+//! `shape.saveAsPicture`: the right-click menu's Save as Picture…, the selected shapes as one+//! picture file (#1073).++#![allow(clippy::unwrap_used, clippy::expect_used)]++use serde_json::{Value, json};++use crate::*;++/// A red rectangle and a blue one on a blank slide, both selected; returns their ids.+fn deck() -> (Session, u64, u64) {+    let mut s = Session::with_new();+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    let red = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();+    s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();+    s.execute("shape.line", &json!({"none": true})).unwrap();+    let blue = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 100, 50]})).unwrap()["id"].as_u64().unwrap();+    s.execute("shape.fill", &json!({"color": "#0000ff"})).unwrap();+    s.execute("shape.line", &json!({"none": true})).unwrap();+    s.execute("edit.select", &json!({"ids": [red, blue]})).unwrap();+    (s, red, blue)+}++fn scratch(name: &str) -> std::path::PathBuf {+    let dir = std::env::temp_dir().join(format!("deckcraft-save-picture-{}-{name}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);+    std::fs::create_dir_all(&dir).unwrap();+    dir+}++fn save(s: &mut Session, path: &std::path::Path, extra: Value) -> Value {+    let mut p = json!({"path": path.to_str().unwrap()});+    p.as_object_mut().unwrap().extend(extra.as_object().cloned().unwrap_or_default());+    s.execute("shape.saveAsPicture", &p).unwrap()+}++#[test]+fn the_selection_is_one_png_cut_to_its_shapes_with_a_transparent_background() {+    let (mut s, _, _) = deck();+    let dir = scratch("png");+    let path = dir.join("Picture.png");+    let revision = s.doc().unwrap().revision;+    let v = save(&mut s, &path, json!({"scale": 1}));+    assert_eq!(s.doc().unwrap().revision, revision, "saving a picture does not change the presentation");+    let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap();+    // The union of the two shapes (100,100)-(500,350) and the 8 pt margin render_shapes keeps+    // for outlines and shadows: not the slide.+    assert_eq!((img.width(), img.height()), (416, 266));+    assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(416), Some(266)));+    let px = |x: u32, y: u32| img.get_pixel(x, y).0;+    assert_eq!(px(1, 1)[3], 0, "outside the shapes the picture is see-through");+    assert_eq!(px(300, 30)[3], 0, "the slide between the shapes is left out too");+    let red = px(8 + 100, 8 + 50);+    assert!(red[0] > 200 && red[2] < 40 && red[3] == 255, "{red:?}");+    let blue = px(8 + 350, 8 + 225);+    assert!(blue[2] > 200 && blue[0] < 40 && blue[3] == 255, "{blue:?}");+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn one_shape_alone_and_named_shapes_without_a_selection() {+    let (mut s, red, blue) = deck();+    let dir = scratch("ids");+    let path = dir.join("one.png");+    let v = save(&mut s, &path, json!({"ids": [blue], "scale": 1}));+    assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(116), Some(66)));+    s.execute("edit.deselect", &json!({})).unwrap();+    assert!(s.execute("shape.saveAsPicture", &json!({"path": dir.join("none.png").to_str().unwrap()})).is_err(), "nothing selected");+    assert!(!dir.join("none.png").exists());+    let v = save(&mut s, &dir.join("red.png"), json!({"ids": [red], "scale": 1}));+    assert_eq!(v["width"].as_u64(), Some(216));+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn the_format_comes_from_the_name_and_opaque_formats_have_a_white_background() {+    let (mut s, _, _) = deck();+    let dir = scratch("formats");+    for (name, magic) in [+        ("p.jpg", &b"\xFF\xD8\xFF"[..]),+        ("p.jpeg", b"\xFF\xD8\xFF"),+        ("p.gif", b"GIF8"),+        ("p.tif", b"II*\0"),+        ("p.tiff", b"II*\0"),+        ("p.bmp", b"BM"),+        ("p.PNG", b"\x89PNG"),+    ] {+        let path = dir.join(name);+        save(&mut s, &path, json!({"scale": 1}));+        let bytes = std::fs::read(&path).unwrap();+        assert!(bytes.starts_with(magic), "{name}: {:?}", &bytes[..8.min(bytes.len())]);+    }+    for name in ["p.jpg", "p.bmp", "p.gif"] {+        let img = deckcraft_render::decode_image(&std::fs::read(dir.join(name)).unwrap()).unwrap();+        let corner = img.get_pixel(1, 1).0;+        assert!(corner[0] > 245 && corner[1] > 245 && corner[2] > 245, "{name}: white around the shapes, not black: {corner:?}");+        let red = img.get_pixel(8 + 100, 8 + 50).0;+        assert!(red[0] > 200 && red[1] < 60, "{name}: {red:?}");+    }+    // `format` wins over the name.+    let path = dir.join("named.png");+    save(&mut s, &path, json!({"format": "jpeg", "scale": 1}));+    assert!(std::fs::read(&path).unwrap().starts_with(b"\xFF\xD8\xFF"));+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn a_type_it_cannot_write_is_an_error_and_writes_nothing() {+    let (mut s, _, _) = deck();+    let dir = scratch("unknown");+    for name in ["p.svg", "p.emf", "p.wmf", "p"] {+        let path = dir.join(name);+        let e = s.execute("shape.saveAsPicture", &json!({"path": path.to_str().unwrap()})).unwrap_err();+        assert!(e.to_string().contains("PNG"), "{name}: {e}");+        assert!(!path.exists(), "{name}");+    }+    let e = s.execute("shape.saveAsPicture", &json!({"path": dir.join("p.png").to_str().unwrap(), "format": "svg"})).unwrap_err();+    assert!(e.to_string().contains("svg"), "{e}");+    assert!(s.execute("shape.saveAsPicture", &json!({})).is_err(), "a path is required");+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn the_default_resolution_is_the_picture_export_s() {+    let (mut s, _, _) = deck();+    let dir = scratch("scale");+    let path = dir.join("p.png");+    let v = save(&mut s, &path, json!({}));+    // 2 px per point, as File › Export's pictures, and the PNG says 144 dpi so it opens at the+    // shapes' real size.+    assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(832), Some(532)));+    assert_eq!(deckcraft_render::image_dpi(&std::fs::read(&path).unwrap()), Some((144.0, 144.0)));+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn a_picture_capped_at_2048_px_says_the_resolution_it_was_drawn_at() {+    let mut s = Session::with_new();+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 100, 1200, 100]})).unwrap();+    let dir = scratch("cap");+    let path = dir.join("wide.png");+    let v = save(&mut s, &path, json!({}));+    // 1200 pt plus the margins is 1216 pt: at 2 px per pt that is over render_shapes' 2048 px.+    assert_eq!(v["width"].as_u64(), Some(2048));+    let (dpi, _) = deckcraft_render::image_dpi(&std::fs::read(&path).unwrap()).unwrap();+    let drawn = 2048.0 / 1216.0 * 72.0;+    assert!((dpi - drawn).abs() < 1.0, "the PNG says {dpi} dpi, it was drawn at {drawn}");+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn a_shape_inside_a_resized_and_turned_group_is_saved_as_the_slide_shows_it() {+    let (mut s, red, blue) = deck();+    let g = s.execute("arrange.group", &json!({"ids": [red, blue]})).unwrap()["id"].as_u64().unwrap();+    // The group (400 × 250 pt) doubled: the red rectangle shows at 400 × 200 pt.+    s.execute("shape.setBounds", &json!({"ids": [g], "w": 800, "h": 500})).unwrap();+    let dir = scratch("group");+    let v = save(&mut s, &dir.join("child.png"), json!({"ids": [red], "scale": 1}));+    assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(416), Some(216)));+    let img = deckcraft_render::decode_image(&std::fs::read(dir.join("child.png")).unwrap()).unwrap();+    let mid = img.get_pixel(208, 108).0;+    assert!(mid[0] > 200 && mid[2] < 40 && mid[3] == 255, "{mid:?}");+    // Turned a quarter, it stands upright.+    s.execute("shape.rotate", &json!({"ids": [g], "deg": 90})).unwrap();+    let v = save(&mut s, &dir.join("turned.png"), json!({"ids": [red], "scale": 1}));+    assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(216), Some(416)));+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn an_id_not_on_the_page_is_an_error_and_writes_nothing() {+    let (mut s, red, _) = deck();+    let dir = scratch("unknown-id");+    let path = dir.join("p.png");+    let e = s.execute("shape.saveAsPicture", &json!({"path": path.to_str().unwrap(), "ids": [red, 9999]})).unwrap_err();+    assert!(e.to_string().contains("unknown shape id 9999"), "{e}");+    assert!(!path.exists());+    // The red rectangle is on the slide before this one.+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    let e = s.execute("shape.saveAsPicture", &json!({"path": path.to_str().unwrap(), "ids": [red]})).unwrap_err();+    assert!(e.to_string().contains(&format!("unknown shape id {red}")), "{e}");+    assert!(!path.exists());+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn shapes_keep_their_stacking_order_whatever_order_they_were_picked_in() {+    let mut s = Session::with_new();+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    let back = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();+    s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();+    let front = s.execute("shape.insert", &json!({"preset": "rect", "rect": [150, 120, 200, 100]})).unwrap()["id"].as_u64().unwrap();+    s.execute("shape.fill", &json!({"color": "#0000ff"})).unwrap();+    let dir = scratch("order");+    let path = dir.join("p.png");+    save(&mut s, &path, json!({"ids": [front, back], "scale": 1}));+    let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap();+    // Slide point (200, 150), inside both: the picture starts at (100 - 8, 100 - 8).+    let px = img.get_pixel(108, 58).0;+    assert!(px[2] > 200 && px[0] < 40, "the front shape stays in front: {px:?}");+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn a_group_and_a_shape_inside_it_draw_that_shape_once() {+    let (mut s, red, blue) = deck();+    s.execute("shape.fill", &json!({"ids": [red], "transparency": 0.5})).unwrap();+    let g = s.execute("arrange.group", &json!({"ids": [red, blue]})).unwrap()["id"].as_u64().unwrap();+    let dir = scratch("group-and-child");+    let pixel = |s: &mut Session, ids: Value, name: &str| {+        let path = dir.join(name);+        save(s, &path, json!({"ids": ids, "scale": 1}));+        deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap().get_pixel(108, 58).0+    };+    let once = pixel(&mut s, json!([g]), "group.png");+    assert!(once[3] < 200, "the half see-through red: {once:?}");+    assert_eq!(pixel(&mut s, json!([g, red]), "both.png"), once);+    assert_eq!(pixel(&mut s, json!([red, g]), "both2.png"), once);+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn a_wide_outline_is_saved_whole() {+    let mut s = Session::with_new();+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();+    s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();+    // 24 pt wide: it reaches 12 pt outside the box, past an 8 pt margin.+    s.execute("shape.line", &json!({"color": "#0000ff", "width": 24})).unwrap();+    let dir = scratch("outline");+    let path = dir.join("p.png");+    save(&mut s, &path, json!({"scale": 1}));+    let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap();+    let mid = img.height() / 2;+    assert_eq!(img.get_pixel(0, mid).0[3], 0, "nothing is cut at the picture's edge");+    let row: Vec<[u8; 4]> = (0..img.width()).map(|x| img.get_pixel(x, mid).0).collect();+    let first = row.iter().position(|p| p[3] > 200).unwrap();+    let last = row.iter().rposition(|p| p[3] > 200).unwrap();+    assert!((last - first + 1).abs_diff(224) <= 2, "the outline's outer edges are 224 pt apart: {first}..{last}");+    assert!(row[first + 2][2] > 200 && row[first + 2][0] < 40, "the outer edge is the blue outline: {:?}", row[first + 2]);+    let _ = std::fs::remove_dir_all(&dir);+}diff --git a/crates/engine/src/gif_export.rs b/crates/engine/src/gif_export.rs--- a/crates/engine/src/gif_export.rs+++ b/crates/engine/src/gif_export.rs@@ -33,7 +33,7 @@ pub const MAX_FRAMES: usize = 500; /// Cap on frames × width × height (≈ 96 Full HD frames or 480 frames at 960×540). pub const MAX_TOTAL_PIXELS: u64 = 200_000_000; /// NeuQuant sampling: 1 is slowest and best, 30 fastest; 10 is the usual balance.-const QUANTIZER_SPEED: i32 = 10;+pub(crate) const QUANTIZER_SPEED: i32 = 10;  /// What to export. #[derive(Clone, Debug, PartialEq)]@@ -181,7 +181,7 @@ pub fn frame_size(doc: &Presentation, width: u32) -> (u32, u32) {  /// Straight RGBA of a premultiplied render composited onto white (GIF has no partial alpha, and /// fully transparent pixels would otherwise become see-through).-fn on_white(img: &deckcraft_render::Image, w: u32, h: u32) -> Vec<u8> {+pub(crate) fn on_white(img: &deckcraft_render::Image, w: u32, h: u32) -> Vec<u8> {     let len = (w as usize).saturating_mul(h as usize).saturating_mul(4);     if img.width != w || img.height != h || img.pixels.len() != len {         log::error!("GIF export: a slide rendered at {}×{} instead of {w}×{h}; writing a blank frame", img.width, img.height);diff --git a/crates/render/src/extent.rs b/crates/render/src/extent.rs--- a/crates/render/src/extent.rs+++ b/crates/render/src/extent.rs@@ -51,8 +51,14 @@ pub(crate) fn spill(line: Option<&Line>, effects: Option<&Effects>, w: f64, h: f }  /// Slide-space box a top-level shape draws into, its spill included.-fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {-    if s.hidden {+pub(crate) fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {+    shape_box_in(rctx, s, 0)+}++/// [`shape_box`] in the space of the shape's parent: a group takes in what its children draw,+/// their spill scaled with them from the child space.+fn shape_box_in(rctx: &Ctx, s: &Shape, depth: usize) -> Option<Rect> {+    if depth > 32 || s.hidden {         return None;     }     let x: Xfrm = resolve::xfrm(rctx, s);@@ -72,7 +78,16 @@ fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {     let (line, _) = resolve::line(rctx, s);     let (fx, _) = resolve::effects(rctx, s);     let d = spill(Some(&line), fx.as_ref(), x.w, x.h);-    Some(b.inflate(d, d))+    let mut r = b.inflate(d, d);+    if let ShapeKind::Group { children, child } = &s.kind {+        let gm = deckcraft_geom::group_child_affine(&x, Rect::new(child.x, child.y, child.x + child.w, child.y + child.h));+        for c in children {+            if let Some(cb) = shape_box_in(rctx, c, depth + 1).map(|cb| gm.transform_rect_bbox(cb)).filter(|cb| cb.is_finite()) {+                r = r.union(cb);+            }+        }+    }+    Some(r) }  fn union_boxes<'a>(rctx: &Ctx, shapes: impl IntoIterator<Item = &'a Shape>, into: Rect) -> Rect {diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -2306,6 +2306,12 @@ fn draw_glyph_with(  /// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture). pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option<Image> {+    render_shapes_scaled(pres, slide, ids, scale).map(|(img, _)| img)+}++/// [`render_shapes`], with the pixels per point it drew at: less than `scale` when the+/// picture's longer side would pass 2048 px.+pub fn render_shapes_scaled(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option<(Image, f64)> {     if ids.is_empty() || !scale.is_finite() || scale <= 0.0 {         return None;     }@@ -2318,13 +2324,18 @@ pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale:         if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 {             continue;         }+        // At least 8 pt around the box, more where the outline or effects reach further.+        let b = match extent::shape_box(&rctx, s).filter(|r| r.is_finite()) {+            Some(drawn) => drawn.union(b.inflate(8.0, 8.0)),+            None => b.inflate(8.0, 8.0),+        };         bounds = Some(match bounds {             Some(u) => u.union(b),             None => b,         });         items.push(s);     }-    let b = bounds?.inflate(8.0, 8.0);+    let b = bounds?;     if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) {         return None;     }@@ -2343,7 +2354,7 @@ pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale:         for s in items {             r.shape(&mut ctx, &f, &rctx, s, view, 0);         }-        Some(r.finish(ctx, w, h))+        Some((r.finish(ctx, w, h), fit))     }) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs--- a/crates/render/src/tests.rs+++ b/crates/render/src/tests.rs@@ -51,6 +51,36 @@ fn render_shapes_draws_the_selection_on_a_transparent_canvas() {     assert_eq!(img.pixel(0, 0)[3], 0, "{:?}", img.pixel(0, 0)); } +#[test]+fn a_group_picture_takes_in_the_glow_of_a_child_at_its_edge() {+    // The child fills the group and glows 40 units in a child space twice the group's size: 20 pt+    // on the slide, past the 8 pt margin, and not 40.+    let mut child = Shape {+        id: ShapeId(882),+        xfrm: Some(Xfrm::new(0.0, 0.0, 200.0, 100.0)),+        fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))),+        line: Some(deckcraft_model::Line::none()),+        ..Default::default()+    };+    child.effects = Some(deckcraft_model::Effects {+        glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 255)), radius: 40.0 }),+        ..Default::default()+    });+    let g = Shape {+        id: ShapeId(881),+        xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 50.0)),+        kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 200.0, 100.0) },+        ..Default::default()+    };+    let p = deck_with(vec![g]);+    let (img, k) = render_shapes_scaled(&p, &p.slides[0], &[ShapeId(881)], 1.0).expect("picture");+    assert_eq!(k, 1.0);+    assert!((140..150).contains(&img.width), "{}", img.width);+    // 12 pt left of the group's box, where the 8 pt margin alone would have cut: still glowing.+    let px = img.pixel(img.width / 2 - 62, img.height / 2);+    assert!(px[3] > 0 && px[2] > px[0], "{px:?}");+}+ #[test] fn ink_pressure_tapers_the_stroke_and_a_flat_half_stays_full_width() {     let opts = RenderOpts { scale: 1.0, ..Default::default() };diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -1252,6 +1252,10 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {                 app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));                 ui.close();             }+            if ui.button("Save as Picture…").clicked() {+                let _ = app.run("app.saveAsPictureDialog", json!({}));+                ui.close();+            }             if ui.button("Edit Alt Text…").clicked() {                 app.dialog = Some(crate::dialogs::Dialog::new("altText"));                 ui.close();@@ -1594,6 +1598,51 @@ mod tests {         assert!(super::grid_line_step(6.0, 0.0).is_none());         assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point");     }++    /// Right-clicking a shape offers Save as Picture…, which asks where to save with a PNG name+    /// and writes the shape alone, not the slide (#1073).+    #[test]+    fn right_click_save_as_picture_writes_the_shape() {+        use egui_kittest::kittest::Queryable as _;+        let dir = std::env::temp_dir().join(format!("deckcraft-ui-save-picture-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let path = dir.join("shape.png");+        let picked = path.to_string_lossy().to_string();+        let suggested = std::rc::Rc::new(std::cell::RefCell::new(String::new()));+        let seen = suggested.clone();+        let services = Services {+            pick_save: Some(Box::new(move |s: &str| {+                *seen.borrow_mut() = s.to_string();+                Some(picked.clone())+            })),+            ..Default::default()+        };+        let mut s = Session::with_new();+        s.execute("slide.layout", &json!({"layout": "blank"})).unwrap();+        s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();+        s.execute("edit.deselect", &json!({})).unwrap();+        let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(+            |ui, app: &mut SlideApp| {+                app.logic(ui.ctx());+                app.ui(ui);+            },+            SlideApp::new(s, services),+        );+        h.run_steps(3);+        let p = screen(h.state(), 200.0, 150.0);+        h.event(Event::PointerMoved(p));+        for pressed in [true, false] {+            h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });+        }+        h.run_steps(3);+        h.get_by_label("Save as Picture…").click();+        h.run_steps(3);+        assert_eq!(suggested.borrow().as_str(), "Picture.png");+        let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap();+        // 200 × 100 pt plus the 8 pt margin on each side, at 2 px per pt.+        assert_eq!((img.width(), img.height()), (432, 232));+        let _ = std::fs::remove_dir_all(&dir);+    } }  #[cfg(test)]diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -54,7 +54,7 @@ pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>; /// carrying that same id. Completing a cancelled or unknown id does nothing: the file is /// not applied to whichever document happens to be active. pub type OpenAsyncFn = Box<dyn FnMut(&str, u64)>;-pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;+pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;  /// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`]. #[derive(Clone, Debug)]@@ -404,6 +404,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[     ("app.insertVideoDialog", "Video from File…", None, "{}"),     ("app.saveAsDialog", "Save As…", None, "{}"),     ("app.exportDialog", "Export…", None, "{}"),+    ("app.saveAsPictureDialog", "Save as Picture…", None, "{}"),     ("app.browseThemeDialog", "Browse for Themes…", None, "{}"),     ("app.compareDialog", "Compare…", None, "{}"),     ("app.dialog", "Open Dialog", None, "{id}"),@@ -789,10 +790,13 @@ impl SlideApp {     /// bar) when there are no bytes or no way to download.     fn download(&mut self, name: &str, bytes: deckcraft_engine::Result<Vec<u8>>) -> bool {         match (bytes, self.services.download.as_mut()) {-            (Ok(b), Some(dl)) => {-                dl(name, &b);-                true-            }+            (Ok(b), Some(dl)) => match dl(name, &b) {+                Ok(()) => true,+                Err(e) => {+                    self.set_status(e);+                    false+                }+            },             (Err(e), _) => {                 self.set_status(e.to_string());                 false@@ -1018,6 +1022,7 @@ impl SlideApp {             "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"),             "app.saveAsDialog" => self.save_as_dialog("deckcraft"),             "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")),+            "app.saveAsPictureDialog" => return self.save_as_picture_dialog(),             "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")),             "app.browseThemeDialog" => {                 if self.session.active().is_none() {@@ -1278,6 +1283,40 @@ impl SlideApp {         }     } +    /// Save as Picture… on the slide's right-click menu (#1073): the selected shapes as one+    /// picture, through the save picker on the desktop or as a PNG download on the web.+    fn save_as_picture_dialog(&mut self) -> Result<Value, String> {+        let Some(d) = self.session.active() else { return Err("open a presentation first".into()) };+        if d.selection.shapes.is_empty() {+            return Err(deckcraft_engine::cmd::NOTHING_SELECTED.into());+        }+        // PNG first, as in PowerPoint: it keeps the background around the shapes see-through.+        let suggested = "Picture.png";+        if let Some(pick) = self.services.pick_save.as_mut() {+            let Some(mut path) = pick(suggested) else { return Ok(Value::Null) };+            if std::path::Path::new(&path).extension().is_none() {+                path.push_str(".png");+            }+            return match self.session.execute("shape.saveAsPicture", &json!({"path": path})) {+                Ok(v) => {+                    self.set_status(format!("Saved {path}"));+                    Ok(v)+                }+                Err(e) => {+                    self.set_status(e.to_string());+                    Err(e.to_string())+                }+            };+        }+        let bytes = deckcraft_engine::cmd::file::shapes_picture(d, &d.selection.shapes, "png", 2.0).map(|(b, _, _)| b);+        if self.download(suggested, bytes) {+            Ok(Value::Null)+        } else {+            // download() has put the reason in the status bar; run() would overwrite it with this.+            Err(self.status.as_ref().map(|s| s.0.clone()).unwrap_or_else(|| "could not save the picture".into()))+        }+    }+     pub fn save_as_dialog(&mut self, format: &str) {         let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into());         let ext = if format == "pptx" { "pptx" } else { "deckcraft" };@@ -1297,7 +1336,11 @@ impl SlideApp {             && let Some(d) = self.session.active()         {             match deckcraft_engine::cmd::file::save_bytes(&d.doc, ext) {-                Ok(bytes) => dl(&suggested, &bytes),+                Ok(bytes) => {+                    if let Err(e) = dl(&suggested, &bytes) {+                        self.set_status(e);+                    }+                }                 Err(e) => self.set_status(e.to_string()),             }         }@@ -2148,7 +2191,10 @@ mod tests {         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())))),+            download: Some(Box::new(move |name: &str, b: &[u8]| {+                log.borrow_mut().push((name.to_string(), b.to_vec()));+                Ok(())+            })),             ..Default::default()         };         let mut app = SlideApp::new(Session::with_new(), services);@@ -2163,6 +2209,42 @@ mod tests {         assert_eq!(corner_alpha(&got[2].1), 255, "JPEG has no transparency");     } +    /// Save as Picture… on the web hands the PNG to the browser; when nothing can take it (no+    /// download hook), the command fails rather than answering Ok as if it had saved.+    #[test]+    fn save_as_picture_on_the_web_reports_a_download_that_did_not_happen() {+        use std::cell::RefCell;+        use std::rc::Rc;+        let selected = || {+            let mut s = Session::with_new();+            s.execute("slide.layout", &json!({"layout": "blank"})).unwrap();+            s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();+            s+        };+        let got: Rc<RefCell<Vec<String>>> = Rc::default();+        let log = got.clone();+        let services = Services {+            download: Some(Box::new(move |name: &str, _: &[u8]| {+                log.borrow_mut().push(name.to_string());+                Ok(())+            })),+            ..Default::default()+        };+        let mut app = SlideApp::new(selected(), services);+        assert!(app.run("app.saveAsPictureDialog", json!({})).is_ok());+        assert_eq!(*got.borrow(), ["Picture.png"]);++        let mut app = SlideApp::new(selected(), Services::default());+        let err = app.run("app.saveAsPictureDialog", json!({})).unwrap_err();+        assert!(err.contains("file picker"), "{err}");++        // The browser refused the download (Blob or DOM setup failed): the error comes through.+        let services = Services { download: Some(Box::new(|_: &str, _: &[u8]| Err("blob failed".to_string()))), ..Default::default() };+        let mut app = SlideApp::new(selected(), services);+        let err = app.run("app.saveAsPictureDialog", json!({})).unwrap_err();+        assert!(err.contains("blob failed"), "{err}");+    }+     fn title_text(app: &SlideApp) -> String {         let d = app.session.active().unwrap();         let sh = &d.doc.slides[0].shapes[0];diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -45,7 +45,7 @@ config works across the suite) keep the agent to the folders you give it:   inserted, so playing them later reads nothing from disk. - Every file a command writes must lie inside the **write root**: `save_presentation`, `export`   (every format, and every slide of an image export), `file.save`, `file.saveAs`, `file.export`,-  and `render_slide` with a `path`, plus the `.saving` temporary file a save goes through.+  `shape.saveAsPicture`, and `render_slide` with a `path`, plus the `.saving` temporary file a save goes through. - Read and write access are separate. Give the same folder twice for both; a root left out grants   none of its access (with only `--automation-read-root`, nothing can be written to disk). Without   either flag nothing changes.diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -93,7 +93,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time | | Drag the frame of a text box being edited to move it | yes | yes | | Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells |-| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first |+| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first. Save as Picture… on shapes writes PNG, JPEG, GIF, TIFF or BMP (no SVG, EMF or WMF yet) | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. Adjust handles: see WordArt adjust handles | | Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog | | Drag and drop of files and between thumbnails | yes | yes |