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.rs
new 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 |
