diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 
 | Date | What landed |
 |---|---|
+| 2026-10-26 | Picture crop handles and image drags snap to slide edges and centres, visible drawing guides, other shapes and the enabled grid, using the same rules as shape resizing; Ctrl/Cmd bypasses snapping and rotated pictures keep free crop gestures (#1069) |
 | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
 | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
 | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -5,6 +5,7 @@ pub mod arrange;
 pub mod artistic;
 pub mod background;
 pub mod compress;
+pub mod crop;
 pub mod design;
 pub mod edit;
 pub mod file;
@@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
         v.extend(selection::specs());
         v.extend(merge::specs());
         v.extend(points::specs());
+        v.extend(crop::specs());
         v.extend(text::specs());
         v.extend(format::specs());
         v.extend(design::specs());
diff --git a/crates/engine/src/cmd/crop.rs b/crates/engine/src/cmd/crop.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/cmd/crop.rs
@@ -0,0 +1,374 @@
+//! Crop (Picture Format ▸ Size ▸ Crop), the way PowerPoint does it.
+//!
+//! A picture's `crop` fractions say which part of the image fills its box, so the whole image sits
+//! at a rectangle of its own in the box's local points ([`image_rect`]). Cropping keeps that image
+//! rectangle where it is on the slide and moves the box's edges over it: the visible part never
+//! stretches. In crop mode ([`picture.cropMode`]) the box's edges are dragged with the crop
+//! handles, the image is moved under the box by dragging it and scaled from its own corners.
+//!
+//! Everything here works in the box's local frame (before rotation and flips), so it holds for
+//! rotated and flipped pictures alike.
+
+use deckcraft_geom::{Point, Rect, Xfrm};
+use deckcraft_model::{ShapeId, ShapeKind};
+use serde_json::{Value, json};
+
+use super::*;
+use crate::{Result, Session};
+
+/// Smallest box or image side a crop gesture leaves, in points.
+pub const MIN_SIDE: f64 = 1.0;
+
+pub fn specs() -> Vec<CommandSpec> {
+    vec![
+        cmd!(
+            noundo "picture.cropMode",
+            "Crop",
+            ["Picture Format", "Size", "Crop"],
+            None,
+            "{on?: bool (default: toggle), id?} — show the crop handles on one selected picture: drag them to crop, drag the picture to move it inside, its round handles to scale it; Enter, Escape or a click elsewhere ends",
+            has_one_picture,
+            crop_mode
+        ),
+        cmd!(
+            "picture.cropAspect",
+            "Aspect Ratio",
+            ["Picture Format", "Size", "Crop", "Aspect Ratio"],
+            None,
+            "{ratio: \"w:h\" (1:1, 2:3, 3:4, 3:5, 4:5, 3:2, 4:3, 5:3, 5:4, 16:9, 16:10…), ids?} — crop to that shape, as large as the picture allows, centred",
+            has_picture,
+            crop_aspect
+        ),
+        cmd!(
+            "picture.cropFill",
+            "Fill / Fit",
+            ["Picture Format", "Size", "Crop"],
+            None,
+            "{mode?: fill|fit (default fill), ids?} — keep the box; fill: the picture covers it, fit: the whole picture shows inside it",
+            has_picture,
+            crop_fill
+        ),
+    ]
+}
+
+pub fn has_picture(s: &Session) -> std::result::Result<(), String> {
+    has_selection(s)?;
+    let any = s.active().is_some_and(|d| d.selected_shapes().iter().any(|sh| matches!(sh.kind, ShapeKind::Picture { .. })));
+    if any { Ok(()) } else { Err("select a picture".into()) }
+}
+
+/// Crop mode works on one picture: with anything else selected too it is off.
+pub fn has_one_picture(s: &Session) -> std::result::Result<(), String> {
+    has_selection(s)?;
+    let one = s.active().is_some_and(|d| matches!(d.selected_shapes().as_slice(), [sh] if matches!(sh.kind, ShapeKind::Picture { .. })));
+    if one { Ok(()) } else { Err("select one picture".into()) }
+}
+
+/// The whole image in the box's local points (`w`×`h` box, `crop` = left, top, right, bottom).
+pub fn image_rect(w: f64, h: f64, crop: [f64; 4]) -> Option<Rect> {
+    let [l, t, r, b] = crop;
+    let (fx, fy) = (1.0 - l - r, 1.0 - t - b);
+    if !(fx > 1e-6 && fy > 1e-6 && w.is_finite() && h.is_finite()) {
+        return None;
+    }
+    let (iw, ih) = (w / fx, h / fy);
+    let (x0, y0) = (-l * iw, -t * ih);
+    let r = Rect::new(x0, y0, x0 + iw, y0 + ih);
+    (r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()).then_some(r)
+}
+
+/// The crop fractions that put the image at `img` in a `w`×`h` box (inverse of [`image_rect`]).
+pub fn crop_of(w: f64, h: f64, img: Rect) -> Option<[f64; 4]> {
+    let (iw, ih) = (img.width(), img.height());
+    if !(iw > 1e-6 && ih > 1e-6) {
+        return None;
+    }
+    let c = [-img.x0 / iw, -img.y0 / ih, (img.x1 - w) / iw, (img.y1 - h) / ih];
+    c.iter().all(|v| v.is_finite()).then_some(c)
+}
+
+/// The box that shows local rectangle `f` of box `orig` in place on the slide (rotation and flips
+/// kept). Points of `orig`'s frame at `q` are at `q - f.origin()` in the new one.
+pub fn frame_xfrm(orig: &Xfrm, f: Rect) -> Xfrm {
+    let c = orig.affine() * f.center();
+    let (w, h) = (f.width(), f.height());
+    Xfrm { x: c.x - w / 2.0, y: c.y - h / 2.0, w, h, ..*orig }
+}
+
+/// Handle positions of a local rectangle, in the order of box handles: NW, N, NE, E, SE, S, SW, W.
+pub fn rect_handles(r: Rect) -> [Point; 8] {
+    let (cx, cy) = ((r.x0 + r.x1) / 2.0, (r.y0 + r.y1) / 2.0);
+    [
+        Point::new(r.x0, r.y0),
+        Point::new(cx, r.y0),
+        Point::new(r.x1, r.y0),
+        Point::new(r.x1, cy),
+        Point::new(r.x1, r.y1),
+        Point::new(cx, r.y1),
+        Point::new(r.x0, r.y1),
+        Point::new(r.x0, cy),
+    ]
+}
+
+/// Which sides a handle moves: (left, top, right, bottom).
+fn sides(handle: u8) -> [bool; 4] {
+    match handle {
+        0 => [true, true, false, false],
+        1 => [false, true, false, false],
+        2 => [false, true, true, false],
+        3 => [false, false, true, false],
+        4 => [false, false, true, true],
+        5 => [false, false, false, true],
+        6 => [true, false, false, true],
+        _ => [true, false, false, false],
+    }
+}
+
+/// Crop handle `handle` of a `w`×`h` box dragged by local `d`: the new frame (orig's local points).
+pub fn drag_frame(w: f64, h: f64, handle: u8, d: deckcraft_geom::Vec2) -> Rect {
+    let [l, t, r, b] = sides(handle);
+    let mut x0 = if l { d.x } else { 0.0 };
+    let mut y0 = if t { d.y } else { 0.0 };
+    let mut x1 = if r { w + d.x } else { w };
+    let mut y1 = if b { h + d.y } else { h };
+    if l {
+        x0 = x0.min(x1 - MIN_SIDE);
+    } else if r {
+        x1 = x1.max(x0 + MIN_SIDE);
+    }
+    if t {
+        y0 = y0.min(y1 - MIN_SIDE);
+    } else if b {
+        y1 = y1.max(y0 + MIN_SIDE);
+    }
+    Rect::new(x0, y0, x1, y1)
+}
+
+/// The image's own handle `handle` dragged by local `d`, the opposite side staying put. Corners
+/// keep the picture's proportions (`free` lets them go); sides stretch one way.
+pub fn drag_image(img: Rect, handle: u8, d: deckcraft_geom::Vec2, free: bool) -> Rect {
+    let [l, t, r, b] = sides(handle);
+    let corner = (l || r) && (t || b);
+    if corner && !free {
+        let anchor = Point::new(if l { img.x1 } else { img.x0 }, if t { img.y1 } else { img.y0 });
+        let from = Point::new(if l { img.x0 } else { img.x1 }, if t { img.y0 } else { img.y1 });
+        let diag = from - anchor;
+        let len2 = diag.hypot2();
+        if len2 <= 1e-9 {
+            return img;
+        }
+        let min = MIN_SIDE / img.width().min(img.height()).max(1e-9);
+        let s = ((from + d - anchor).dot(diag) / len2).max(min);
+        let to = anchor + diag * s;
+        return Rect::from_points(anchor, to);
+    }
+    let mut n = img;
+    if l {
+        n.x0 = (img.x0 + d.x).min(img.x1 - MIN_SIDE);
+    }
+    if r {
+        n.x1 = (img.x1 + d.x).max(img.x0 + MIN_SIDE);
+    }
+    if t {
+        n.y0 = (img.y0 + d.y).min(img.y1 - MIN_SIDE);
+    }
+    if b {
+        n.y1 = (img.y1 + d.y).max(img.y0 + MIN_SIDE);
+    }
+    n
+}
+
+/// The one picture crop mode works on: given by `id` or the one selected, on the slide itself.
+fn crop_target(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
+    let st = s.doc()?;
+    let id = match id_param(p, "id", cmd)? {
+        Some(i) => i,
+        None => match st.selection.shapes.as_slice() {
+            [one] => *one,
+            _ => return Err(bad(cmd, "select one picture")),
+        },
+    };
+    let sh = st.shape(id).ok_or_else(|| bad(cmd, "no such shape"))?;
+    if !matches!(sh.kind, ShapeKind::Picture { .. }) {
+        return Err(bad(cmd, "only a picture can be cropped"));
+    }
+    if sh.locked {
+        return Err(bad(cmd, "the object is locked"));
+    }
+    if !st.shapes().iter().any(|t| t.id == id) {
+        return Err(bad(cmd, "a picture in a group can't be cropped; ungroup it first"));
+    }
+    Ok(id)
+}
+
+fn crop_mode(s: &mut Session, p: &Value) -> Result<Value> {
+    let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.crop.is_none());
+    if !on {
+        s.exit_crop();
+        return Ok(json!({"cropping": Value::Null}));
+    }
+    let id = crop_target(s, p, "picture.cropMode")?;
+    s.select(|_, sel| {
+        sel.shapes = vec![id];
+        sel.text = None;
+        sel.cells = None;
+    })?;
+    s.tool.edit_points = None;
+    s.tool.edit_vertex = None;
+    s.tool.crop = Some(id);
+    Ok(json!({"cropping": id.0}))
+}
+
+/// Each target picture's box (slide points) and crop, before the change.
+fn pictures(s: &Session, p: &Value) -> Result<Vec<(ShapeId, Xfrm, [f64; 4])>> {
+    let ids = targets(s, p)?;
+    let st = s.doc()?;
+    Ok(ids
+        .iter()
+        .filter_map(|id| {
+            let sh = st.shape(*id)?;
+            match &sh.kind {
+                ShapeKind::Picture { fill } if !sh.locked => Some((*id, xfrm_of(&st.doc, &st.selection, sh), fill.crop)),
+                _ => None,
+            }
+        })
+        .collect())
+}
+
+/// Store a new box and crop on each picture (`None` leaves the box).
+pub(crate) fn store(s: &mut Session, cmd: &str, items: &[(ShapeId, Option<Xfrm>, [f64; 4])]) -> Result<Value> {
+    s.edit(|doc, sel| {
+        let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
+        for (id, x, c) in items {
+            if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) {
+                if let Some(x) = x {
+                    sh.xfrm = Some(*x);
+                }
+                if let ShapeKind::Picture { fill } = &mut sh.kind {
+                    fill.crop = *c;
+                }
+            }
+        }
+        Ok(())
+    })?;
+    Ok(json!({"cropped": items.len()}))
+}
+
+/// `picture.crop` with the frame following the crop (PowerPoint): the image keeps its size and
+/// place and the box shrinks or grows over it.
+pub(crate) fn crop_keeping_scale(s: &mut Session, p: &Value, want: [Option<f64>; 4]) -> Result<Value> {
+    let mut out = vec![];
+    for (id, x, old) in pictures(s, p)? {
+        let Some(img) = image_rect(x.w, x.h, old) else { continue };
+        let mut c = old;
+        for (slot, v) in c.iter_mut().zip(want) {
+            if let Some(v) = v.filter(|v| v.is_finite()) {
+                *slot = v.clamp(-10.0, 0.95);
+            }
+        }
+        // Keep at least a sliver showing either way.
+        if c[0] + c[2] > 0.99 || c[1] + c[3] > 0.99 {
+            return Err(bad("picture.crop", "the crop leaves nothing of the picture"));
+        }
+        let (iw, ih) = (img.width(), img.height());
+        let f = Rect::new(img.x0 + c[0] * iw, img.y0 + c[1] * ih, img.x1 - c[2] * iw, img.y1 - c[3] * ih);
+        out.push((id, Some(frame_xfrm(&x, f)), c));
+    }
+    store(s, "picture.crop", &out)
+}
+
+fn parse_ratio(v: &Value) -> Option<f64> {
+    let r = match v {
+        Value::String(t) => {
+            let (a, b) = t.split_once([':', '/', 'x'])?;
+            a.trim().parse::<f64>().ok()? / b.trim().parse::<f64>().ok()?
+        }
+        Value::Array(a) => a.first()?.as_f64()? / a.get(1)?.as_f64()?,
+        _ => v.as_f64()?,
+    };
+    (r.is_finite() && (0.01..=100.0).contains(&r)).then_some(r)
+}
+
+fn crop_aspect(s: &mut Session, p: &Value) -> Result<Value> {
+    let ratio = p.get("ratio").and_then(parse_ratio).ok_or_else(|| bad("picture.cropAspect", "ratio must be like \"16:9\""))?;
+    let mut out = vec![];
+    for (id, x, c) in pictures(s, p)? {
+        let Some(img) = image_rect(x.w, x.h, c) else { continue };
+        // The largest box of that shape inside the picture, centred on it.
+        let (iw, ih) = (img.width(), img.height());
+        let (w, h) = if iw / ih > ratio { (ih * ratio, ih) } else { (iw, iw / ratio) };
+        let ctr = img.center();
+        let f = Rect::new(ctr.x - w / 2.0, ctr.y - h / 2.0, ctr.x + w / 2.0, ctr.y + h / 2.0);
+        let Some(nc) = crop_of(w, h, img - f.origin().to_vec2()) else { continue };
+        out.push((id, Some(frame_xfrm(&x, f)), nc));
+    }
+    let r = store(s, "picture.cropAspect", &out)?;
+    // Then straight into crop mode to adjust it, as PowerPoint does.
+    if let [(id, ..)] = out.as_slice() {
+        s.tool.crop = Some(*id);
+    }
+    Ok(r)
+}
+
+fn crop_fill(s: &mut Session, p: &Value) -> Result<Value> {
+    let fit = match str_param(p, "mode").unwrap_or("fill") {
+        "fill" => false,
+        "fit" => true,
+        m => return Err(bad("picture.cropFill", format!("mode {m:?} is not fill or fit"))),
+    };
+    let mut out = vec![];
+    for (id, x, c) in pictures(s, p)? {
+        let Some(img) = image_rect(x.w, x.h, c) else { continue };
+        let (kx, ky) = (x.w / img.width(), x.h / img.height());
+        let k = if fit { kx.min(ky) } else { kx.max(ky) };
+        let (w, h) = (img.width() * k, img.height() * k);
+        let (x0, y0) = ((x.w - w) / 2.0, (x.h - h) / 2.0);
+        let Some(nc) = crop_of(x.w, x.h, Rect::new(x0, y0, x0 + w, y0 + h)) else { continue };
+        out.push((id, None, nc));
+    }
+    store(s, "picture.cropFill", &out)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use deckcraft_geom::Vec2;
+
+    #[test]
+    fn image_rect_and_crop_of_round_trip() {
+        let c = [0.1, 0.2, 0.3, 0.05];
+        let img = image_rect(60.0, 75.0, c).unwrap();
+        assert!((img.width() - 100.0).abs() < 1e-9 && (img.height() - 100.0).abs() < 1e-9);
+        let back = crop_of(60.0, 75.0, img).unwrap();
+        for (a, b) in back.iter().zip(c) {
+            assert!((a - b).abs() < 1e-9);
+        }
+        assert!(image_rect(10.0, 10.0, [0.5, 0.0, 0.5, 0.0]).is_none());
+    }
+
+    #[test]
+    fn frame_handles_keep_a_minimum_size() {
+        let f = drag_frame(100.0, 50.0, 3, Vec2::new(-500.0, 0.0));
+        assert!((f.width() - MIN_SIDE).abs() < 1e-9);
+        let f = drag_frame(100.0, 50.0, 0, Vec2::new(10.0, 5.0));
+        assert_eq!((f.x0, f.y0, f.x1, f.y1), (10.0, 5.0, 100.0, 50.0));
+    }
+
+    #[test]
+    fn image_corners_keep_proportions() {
+        let img = Rect::new(0.0, 0.0, 200.0, 100.0);
+        let n = drag_image(img, 4, Vec2::new(200.0, 0.0), false);
+        assert!((n.width() / n.height() - 2.0).abs() < 1e-9);
+        assert_eq!((n.x0, n.y0), (0.0, 0.0));
+        let n = drag_image(img, 3, Vec2::new(50.0, 0.0), false);
+        assert_eq!((n.width(), n.height()), (250.0, 100.0));
+    }
+
+    #[test]
+    fn ratios_parse() {
+        assert!((parse_ratio(&json!("16:9")).unwrap() - 16.0 / 9.0).abs() < 1e-12);
+        assert_eq!(parse_ratio(&json!([4, 3])), Some(4.0 / 3.0));
+        assert!(parse_ratio(&json!("0:3")).is_none());
+        assert!(parse_ratio(&json!("a:b")).is_none());
+    }
+}
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -47,6 +47,8 @@ pub fn specs() -> Vec<CommandSpec> {
 }
 
 fn restore(s: &mut Session, redo: bool) -> Result<Value> {
+    // A crop gesture in progress becomes its own step first, so Undo takes exactly that back.
+    s.end_crop_drag();
     let vertex_kind = s.tool.vertex_kind.clone();
     let st = s.doc_mut()?;
     let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) };
@@ -58,6 +60,7 @@ fn restore(s: &mut Session, redo: bool) -> Result<Value> {
     // A later shape can get an undone copy's id: Duplicate starts over with the usual offset.
     st.last_duplicate = None;
     st.revision += 1;
+    s.settle_crop();
     // The point type Edit Points remembered with the restored document: handle drags follow it.
     s.tool.vertex_kind = e.vertex_kind;
     Ok(json!({"label": e.label}))
diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs
--- a/crates/engine/src/cmd/shape.rs
+++ b/crates/engine/src/cmd/shape.rs
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Crop",
             ["Picture Format", "Size"],
             None,
-            "{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}",
+            "{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, keepFrame?: bool (default false: the box follows the crop and the picture keeps its size, as in PowerPoint; true: the box stays and the rest stretches into it), ids?}",
             has_selection,
             crop
         ),
diff --git a/crates/engine/src/cmd/shape/picture.rs b/crates/engine/src/cmd/shape/picture.rs
--- a/crates/engine/src/cmd/shape/picture.rs
+++ b/crates/engine/src/cmd/shape/picture.rs
@@ -2,6 +2,9 @@ use super::*;
 
 pub(super) fn crop(s: &mut Session, p: &Value) -> Result<Value> {
     let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
+    if !bool_or(p, "keepFrame", false) {
+        return crate::cmd::crop::crop_keeping_scale(s, p, c);
+    }
     edit_geometry(s, p, "picture.crop", |sh| {
         if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
             for (i, v) in c.iter().enumerate() {
@@ -9,6 +12,10 @@ pub(super) fn crop(s: &mut Session, p: &Value) -> Result<Value> {
                     *slot = v.clamp(-10.0, 0.95);
                 }
             }
+            // Keep at least a sliver showing, as without keepFrame.
+            if fill.crop[0] + fill.crop[2] > 0.99 || fill.crop[1] + fill.crop[3] > 0.99 {
+                return Err(bad("picture.crop", "the crop leaves nothing of the picture"));
+            }
         }
         Ok(())
     })
diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs
--- a/crates/engine/src/keys.rs
+++ b/crates/engine/src/keys.rs
@@ -60,6 +60,11 @@ impl Session {
         if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
             return self.finish_freeform(false);
         }
+        self.settle_crop();
+        if self.tool.crop.is_some() && matches!(k, "enter" | "return" | "escape") {
+            self.exit_crop();
+            return Ok(json!({"handled": true, "command": "picture.cropMode"}));
+        }
         if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") {
             self.tool.edit_points = None;
             self.tool.edit_vertex = None;
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -584,25 +584,40 @@ impl Session {
     pub fn set_active(&mut self, i: usize) {
         let was = self.active().map(|d| d.uid);
         if i < self.docs.len() {
+            let before = self.active_uid();
+            if before != self.docs.get(i).map(|d| d.uid) {
+                self.end_crop_drag();
+            }
             self.active = Some(i);
+            self.document_changed(before);
         }
         self.forget_vertex_kind_unless(was);
     }
     pub fn add_document(&mut self, d: DocState) -> usize {
-        let was = self.active().map(|d| d.uid);
+        let before = self.active_uid();
+        self.end_crop_drag();
         self.docs.push(d);
         self.active = Some(self.docs.len() - 1);
-        self.forget_vertex_kind_unless(was);
+        self.document_changed(before);
+        self.forget_vertex_kind_unless(before);
         self.docs.len() - 1
     }
     pub fn close_document(&mut self, i: usize) {
         let was = self.active().map(|d| d.uid);
         if i < self.docs.len() {
+            let before = self.active_uid();
+            // The presentation that takes index `i` (or the last one) becomes active, even when
+            // `i` wasn't: a crop drag ends wherever the active presentation changes.
+            let next = self.docs.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, d)| d.uid).nth(i.min(self.docs.len().saturating_sub(2)));
+            if next != before {
+                self.end_crop_drag();
+            }
             let d = self.docs.remove(i);
             if let Some(dir) = &self.recovery_dir {
                 recovery::discard(dir, d.uid);
             }
             self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };
+            self.document_changed(before);
         }
         self.forget_vertex_kind_unless(was);
     }
@@ -613,6 +628,18 @@ impl Session {
             self.tool.vertex_kind = None;
         }
     }
+    fn active_uid(&self) -> Option<u64> {
+        self.active().map(|d| d.uid)
+    }
+    /// Crop mode belongs to a picture in one presentation: another one becoming active ends it,
+    /// so coming back doesn't bring it back.
+    fn document_changed(&mut self, before: Option<u64>) {
+        if self.active_uid() != before {
+            self.tool.crop = None;
+            self.tool.drop_crop_drag();
+        }
+        self.settle_crop();
+    }
     pub fn next_untitled(&mut self) -> String {
         self.untitled += 1;
         format!("Presentation{}", self.untitled)
@@ -667,6 +694,7 @@ impl Session {
         if let Some(st) = self.active_mut() {
             clamp_selection(st);
         }
+        self.settle_crop();
         if let (Some((uid, old, sel)), Some(st)) = (before, self.active_mut())
             && st.uid == uid
             && !Arc::ptr_eq(&old, &st.doc)
@@ -754,6 +782,7 @@ impl Session {
         if st.composition.is_some() && owner(&st.selection) != before {
             self.settle_composition();
         }
+        self.settle_crop();
         Ok(())
     }
 }
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -5462,6 +5462,419 @@ fn a_shape_in_front_of_edited_text_takes_the_click() {
     assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection);
 }
 
+/// A 1×1 PNG inserted at [100, 100, 200, 150]; returns its id.
+fn cropped_picture(s: &mut Session) -> u64 {
+    const PNG: &[u8] = &[
+        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
+        0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf,
+        0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,
+        0x82,
+    ];
+    s.prefs.snap_to_grid = false;
+    s.prefs.smart_guides = false;
+    let r = s.execute("insert.picture", &json!({"data": cmd::base64_encode(PNG), "name": "a.png", "rect": [100.0, 100.0, 200.0, 150.0]}));
+    r.unwrap()["id"].as_u64().unwrap()
+}
+
+/// The picture's box (x, y, w, h) and crop.
+fn crop_state(s: &Session, id: u64) -> ([f64; 4], [f64; 4]) {
+    let st = s.doc().unwrap();
+    let sh = st.shapes().iter().find(|sh| sh.id.0 as u64 == id).unwrap();
+    let x = sh.xfrm.unwrap();
+    let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("not a picture") };
+    ([x.x, x.y, x.w, x.h], fill.crop)
+}
+
+fn near(a: [f64; 4], b: [f64; 4]) -> bool {
+    a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6)
+}
+
+#[test]
+fn crop_command_moves_the_box_over_the_picture_like_powerpoint() {
+    let mut s = session();
+    let id = cropped_picture(&mut s);
+    s.execute("picture.crop", &json!({"ids": [id], "left": 0.25, "bottom": 0.2})).unwrap();
+    let (b, c) = crop_state(&s, id);
+    // The picture keeps its size and place: the box loses a quarter on the left, a fifth below.
+    assert!(near(b, [150.0, 100.0, 150.0, 120.0]), "{b:?}");
+    assert!(near(c, [0.25, 0.0, 0.0, 0.2]), "{c:?}");
+    // Resetting the crop brings the whole picture back at the same scale.
+    s.execute("picture.crop", &json!({"ids": [id], "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0})).unwrap();
+    assert!(near(crop_state(&s, id).0, [100.0, 100.0, 200.0, 150.0]));
+    // keepFrame: the old behaviour, the box stays and the rest stretches into it.
+    s.execute("picture.crop", &json!({"ids": [id], "left": 0.5, "keepFrame": true})).unwrap();
+    assert!(near(crop_state(&s, id).0, [100.0, 100.0, 200.0, 150.0]));
+    assert!(s.execute("picture.crop", &json!({"ids": [id], "left": 0.6, "right": 0.6})).is_err(), "nothing would be left");
+}
+
+#[test]
+fn crop_mode_handles_crop_move_and_scale_the_picture() {
+    let mut s = session();
+    let id = cropped_picture(&mut s);
+    let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
+    let r = s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+    assert_eq!(r["cropping"], id);
+    // The right crop handle, dragged in by 50.
+    let hit = s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap();
+    assert_eq!(hit["hit"]["kind"], "crop");
+    s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Up, 250.0, 175.0)).unwrap();
+    let (b, c) = crop_state(&s, id);
+    assert!(near(b, [100.0, 100.0, 150.0, 150.0]), "{b:?}");
+    assert!(near(c, [0.0, 0.0, 0.25, 0.0]), "{c:?}");
+    assert_eq!(s.tool.crop, Some(deckcraft_model::ShapeId(id as _)), "still cropping");
+    // Drag the picture 20 to the left under the box.
+    s.pointer(ev(PointerKind::Down, 200.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Drag, 180.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Up, 180.0, 175.0)).unwrap();
+    let (b, c) = crop_state(&s, id);
+    assert!(near(b, [100.0, 100.0, 150.0, 150.0]), "the box stays: {b:?}");
+    assert!(near(c, [0.1, 0.0, 0.15, 0.0]), "{c:?}");
+    // Scale the picture from its bottom-right corner (now at 280, 250): proportions kept.
+    s.pointer(ev(PointerKind::Down, 280.0, 250.0)).unwrap();
+    s.pointer(ev(PointerKind::Drag, 380.0, 300.0)).unwrap();
+    s.pointer(ev(PointerKind::Up, 380.0, 300.0)).unwrap();
+    let (b, c) = crop_state(&s, id);
+    assert!(near(b, [100.0, 100.0, 150.0, 150.0]), "{b:?}");
+    let img = cmd::crop::image_rect(b[2], b[3], c).unwrap();
+    assert!((img.width() / img.height() - 200.0 / 150.0).abs() < 1e-6, "{img:?}");
+    assert!((img.x0 + 20.0).abs() < 1e-6 && img.y0.abs() < 1e-6, "the far corner stays: {img:?}");
+    // Each gesture is one undo step.
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert!(near(crop_state(&s, id).1, [0.1, 0.0, 0.15, 0.0]));
+    // Escape ends crop mode; so does a click away from the picture.
+    s.key("Escape", Mods::default()).unwrap();
+    assert_eq!(s.tool.crop, None);
+    s.execute("picture.cropMode", &json!({})).unwrap();
+    assert!(s.tool.crop.is_some());
+    s.pointer(ev(PointerKind::Down, 600.0, 500.0)).unwrap();
+    s.pointer(ev(PointerKind::Up, 600.0, 500.0)).unwrap();
+    assert_eq!(s.tool.crop, None);
+}
+
+#[test]
+fn crop_frame_snaps_only_dragged_edges_and_keeps_the_image_in_place() {
+    for target in ["guide", "slide", "center", "shape", "grid", "hidden", "disabled", "ctrl", "self"] {
+        let mut s = session();
+        let id = cropped_picture(&mut s);
+        s.prefs.smart_guides = target != "disabled";
+        s.tool.guides_visible = target != "hidden";
+        s.edit(|doc, _| {
+            doc.guides.push(deckcraft_model::DrawGuide { vertical: true, pos: 240.0 });
+            Ok(())
+        })
+        .unwrap();
+        if target == "shape" {
+            s.execute("shape.insert", &json!({"preset": "rect", "rect": [240, 310, 40, 40]})).unwrap();
+        }
+        if target == "grid" {
+            s.tool.guides_visible = false;
+            s.prefs.snap_to_grid = true;
+            s.prefs.grid_spacing = 20.0;
+        }
+        let (at, expected) = match target {
+            "slide" => (s.doc().unwrap().doc.slide_size.width - 2.0, s.doc().unwrap().doc.slide_size.width),
+            "center" => (s.doc().unwrap().doc.slide_size.width / 2.0 - 2.0, s.doc().unwrap().doc.slide_size.width / 2.0),
+            "hidden" | "disabled" | "ctrl" => (238.0, 238.0),
+            "self" => (298.0, 298.0),
+            _ => (238.0, 240.0),
+        };
+        let mods = Mods { cmd: target == "ctrl", ..Mods::default() };
+        let ev = |kind, x| PointerEvent { kind, x, y: 175.0, mods, tol: 3.0, pressure: None };
+        s.execute("edit.select", &json!({"ids": [id]})).unwrap();
+        s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+        s.pointer(ev(PointerKind::Down, 300.0)).unwrap();
+        s.pointer(ev(PointerKind::Drag, at)).unwrap();
+        let (b, c) = crop_state(&s, id);
+        assert!(near(b, [100.0, 100.0, expected - 100.0, 150.0]), "{target}: {b:?}");
+        let image = cmd::crop::image_rect(b[2], b[3], c).unwrap();
+        assert!(near([image.x0 + b[0], image.y0 + b[1], image.width(), image.height()], [100.0, 100.0, 200.0, 150.0]), "{target}: {image:?}");
+        if matches!(target, "guide" | "slide" | "center" | "shape") {
+            assert!(s.tool.guides.iter().any(|g| g.vertical && (g.pos - expected).abs() < 1e-6), "{target}");
+        } else {
+            assert!(s.tool.guides.is_empty(), "{target}");
+        }
+        s.pointer(ev(PointerKind::Up, at)).unwrap();
+        s.execute("edit.undo", &json!({})).unwrap();
+        assert!(near(crop_state(&s, id).0, [100.0, 100.0, 200.0, 150.0]));
+    }
+}
+
+#[test]
+fn crop_image_handles_and_pan_snap_without_moving_the_frame() {
+    for pan in [false, true] {
+        for ctrl in [false, true] {
+            let mut s = session();
+            let id = cropped_picture(&mut s);
+            s.execute("picture.crop", &json!({"ids": [id], "right": 0.25})).unwrap();
+            s.prefs.smart_guides = true;
+            s.tool.guides_visible = true;
+            s.edit(|doc, _| {
+                doc.guides.push(deckcraft_model::DrawGuide { vertical: true, pos: 340.0 });
+                Ok(())
+            })
+            .unwrap();
+            s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+            let mods = Mods { cmd: ctrl, ..Mods::default() };
+            let ev = |kind, x| PointerEvent { kind, x, y: 175.0, mods, tol: 3.0, pressure: None };
+            let start = if pan { 200.0 } else { 300.0 };
+            let hit = s.pointer(ev(PointerKind::Down, start)).unwrap();
+            assert_eq!(hit["hit"]["kind"], if pan { "cropPan" } else { "cropImage" });
+            s.pointer(ev(PointerKind::Drag, start + 38.0)).unwrap();
+            let (b, c) = crop_state(&s, id);
+            assert!(near(b, [100.0, 100.0, 150.0, 150.0]));
+            let image = cmd::crop::image_rect(b[2], b[3], c).unwrap();
+            assert!((image.x1 + b[0] - if ctrl { 338.0 } else { 340.0 }).abs() < 1e-6, "{image:?}");
+            assert_eq!(s.tool.guides.is_empty(), ctrl);
+        }
+    }
+}
+
+#[test]
+fn crop_snapping_handles_flips_and_skips_rotated_pictures() {
+    for rotated in [false, true] {
+        let mut s = session();
+        let id = cropped_picture(&mut s);
+        let sid = ShapeId(id as _);
+        s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap();
+        if rotated {
+            s.execute("shape.rotate", &json!({"ids": [id], "by": 30.0})).unwrap();
+        }
+        let orig = s.doc().unwrap().shape(sid).unwrap().xfrm.unwrap();
+        let start = orig.affine() * deckcraft_geom::Point::new(200.0, 75.0);
+        let to = orig.affine() * deckcraft_geom::Point::new(138.0, 75.0);
+        let guide = to.x + 2.0;
+        s.prefs.smart_guides = true;
+        s.tool.guides_visible = true;
+        s.edit(|doc, _| {
+            doc.guides.push(deckcraft_model::DrawGuide { vertical: true, pos: guide });
+            Ok(())
+        })
+        .unwrap();
+        s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+        let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 3.0, pressure: None };
+        s.pointer(ev(PointerKind::Down, start)).unwrap();
+        s.pointer(ev(PointerKind::Drag, to)).unwrap();
+        let (b, c) = crop_state(&s, id);
+        assert!((b[2] - if rotated { 138.0 } else { 136.0 }).abs() < 1e-6, "{b:?}");
+        assert_eq!(s.tool.guides.is_empty(), rotated);
+        let x = s.doc().unwrap().shape(sid).unwrap().xfrm.unwrap();
+        let image = cmd::crop::image_rect(b[2], b[3], c).unwrap();
+        let image_origin = x.affine() * image.origin();
+        let before = orig.affine() * deckcraft_geom::Point::ORIGIN;
+        assert!((image_origin - before).hypot() < 1e-6);
+        assert!((image.width() - 200.0).abs() < 1e-6);
+    }
+}
+
+#[test]
+fn crop_mode_ends_when_the_picture_stops_being_selected() {
+    let mut s = session();
+    let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 50, 50]})).unwrap()["id"].as_u64().unwrap();
+    let id = cropped_picture(&mut s);
+    let pic = deckcraft_model::ShapeId(id as _);
+    let cropping = |s: &Session| s.tool.cropping(s.doc().unwrap());
+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+    // Selecting the same picture again keeps crop mode on.
+    s.execute("edit.select", &json!({"ids": [id]})).unwrap();
+    s.select(|_, sel| sel.shapes = vec![pic]).unwrap();
+    assert_eq!(s.tool.crop, Some(pic));
+    // Tab moves to the other shape: crop mode is over, and coming back doesn't bring it back.
+    s.key("Tab", Mods::default()).unwrap();
+    assert_ne!(s.doc().unwrap().selection.shapes, vec![pic]);
+    assert_eq!(s.tool.crop, None);
+    for _ in 0..10 {
+        if s.doc().unwrap().selection.shapes == vec![pic] {
+            break;
+        }
+        s.key("Tab", Mods::default()).unwrap();
+    }
+    assert_eq!(s.doc().unwrap().selection.shapes, vec![pic]);
+    assert_eq!(cropping(&s), None);
+    let r = s.key("Enter", Mods::default());
+    assert!(!r.is_ok_and(|v| v["command"] == "picture.cropMode"), "Enter isn't taken by a crop mode that ended");
+    s.execute("edit.deselect", &json!({})).unwrap();
+    // The toggle starts crop mode again rather than turning off a stale one.
+    s.execute("edit.select", &json!({"ids": [id]})).unwrap();
+    assert_eq!(s.execute("picture.cropMode", &json!({})).unwrap()["cropping"], id);
+    // Undo takes the picture away (the rectangle is selected again); Redo brings it back
+    // selected, but not in crop mode.
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert_eq!(s.doc().unwrap().selection.shapes, vec![deckcraft_model::ShapeId(rect as _)]);
+    assert_eq!(s.tool.crop, None);
+    s.execute("edit.redo", &json!({})).unwrap();
+    assert_eq!(s.doc().unwrap().selection.shapes, vec![pic]);
+    assert_eq!(cropping(&s), None);
+}
+
+#[test]
+fn crop_mode_ends_when_another_presentation_becomes_active() {
+    let mut s = session();
+    let id = cropped_picture(&mut s);
+    let pic = deckcraft_model::ShapeId(id as _);
+    s.execute("file.new", &json!({})).unwrap();
+    s.set_active(0);
+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+    // Activating the presentation that is already active keeps crop mode on.
+    s.set_active(0);
+    assert_eq!(s.tool.crop, Some(pic));
+    // A → B ends it; back in A the picture is still selected, but not in crop mode.
+    s.set_active(1);
+    assert_eq!(s.tool.crop, None);
+    s.set_active(0);
+    assert_eq!(s.doc().unwrap().selection.shapes, vec![pic]);
+    assert_eq!(s.tool.cropping(s.doc().unwrap()), None);
+    // Closing another presentation keeps it; closing this one ends it.
+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+    s.close_document(1);
+    assert_eq!(s.tool.crop, Some(pic));
+    s.close_document(0);
+    assert_eq!(s.tool.crop, None);
+}
+
+#[test]
+fn a_crop_drag_ends_in_its_own_presentation_when_another_becomes_active() {
+    let mut s = session();
+    let id = cropped_picture(&mut s);
+    let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+    let undo_before = s.doc().unwrap().history.undo.len();
+    // Halfway through dragging the right crop handle in, a new presentation opens.
+    s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap();
+    s.execute("file.new", &json!({})).unwrap();
+    assert!(!s.tool.dragging() && s.tool.crop.is_none());
+    let b = s.doc().unwrap().doc.clone();
+    s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Up, 200.0, 175.0)).unwrap();
+    let st = s.doc().unwrap();
+    assert!(std::sync::Arc::ptr_eq(&b, &st.doc) && st.history.undo.is_empty(), "the new presentation is untouched");
+    // The first presentation kept the crop so far as one finished undo step.
+    s.set_active(0);
+    let st = s.doc().unwrap();
+    assert!(st.interaction.is_none());
+    assert_eq!(st.history.undo.len(), undo_before + 1);
+    assert_eq!(st.history.undo.last().unwrap().label, "Crop");
+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.25, 0.0]));
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.0, 0.0]));
+}
+
+#[test]
+fn a_crop_drag_ends_when_closing_another_presentation_changes_the_active_one() {
+    let mut s = session();
+    let id = cropped_picture(&mut s);
+    let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
+    let uid = |s: &Session| s.doc().unwrap().uid;
+    s.execute("file.new", &json!({})).unwrap();
+    s.execute("file.new", &json!({})).unwrap();
+    s.set_active(0);
+    let a = uid(&s);
+    let undo_before = s.doc().unwrap().history.undo.len();
+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+    s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap();
+    // Closing B (index 1) while A is active makes C, which takes index 1, the active one.
+    s.close_document(1);
+    assert_ne!(uid(&s), a);
+    assert!(!s.tool.dragging() && s.tool.crop.is_none());
+    let c = s.doc().unwrap().doc.clone();
+    s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Up, 200.0, 175.0)).unwrap();
+    let st = s.doc().unwrap();
+    assert!(std::sync::Arc::ptr_eq(&c, &st.doc) && st.history.undo.is_empty(), "C is untouched");
+    s.set_active(0);
+    let st = s.doc().unwrap();
+    assert!(st.interaction.is_none());
+    assert_eq!(st.history.undo.len(), undo_before + 1);
+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.25, 0.0]));
+    // Closing C (index 1) leaves A active: the drag goes on and finishes on release.
+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+    s.pointer(ev(PointerKind::Down, 250.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Drag, 225.0, 175.0)).unwrap();
+    s.close_document(1);
+    assert_eq!(uid(&s), a);
+    assert!(s.tool.dragging());
+    s.pointer(ev(PointerKind::Up, 225.0, 175.0)).unwrap();
+    assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 2);
+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0]));
+}
+
+#[test]
+fn every_way_out_of_a_crop_gesture_leaves_it_as_one_undo_step() {
+    let mut s = session();
+    let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 50, 50]})).unwrap()["id"].as_u64().unwrap();
+    let id = cropped_picture(&mut s);
+    let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
+    let undos = |s: &Session| s.doc().unwrap().history.undo.len();
+    let ended = |s: &Session| !s.tool.dragging() && s.doc().unwrap().interaction.is_none();
+    // Selecting another shape mid-gesture: crop mode ends and the gesture is its own step.
+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();
+    let n = undos(&s);
+    s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap();
+    s.execute("edit.select", &json!({"ids": [rect]})).unwrap();
+    assert!(ended(&s) && s.tool.crop.is_none());
+    assert_eq!(undos(&s), n + 1);
+    s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Up, 200.0, 175.0)).unwrap();
+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.25, 0.0]), "later events change nothing");
+    assert_eq!(undos(&s), n + 1);
+    // Another tool mid-gesture: the same, and crop mode stays on.
+    s.execute("edit.select", &json!({"ids": [id]})).unwrap();
+    s.execute("picture.cropMode", &json!({})).unwrap();
+    s.pointer(ev(PointerKind::Down, 250.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Drag, 225.0, 175.0)).unwrap();
+    s.set_tool(ToolKind::Select);
+    assert!(ended(&s));
+    assert_eq!(undos(&s), n + 2);
+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0]));
+    // Undo mid-gesture takes back exactly that gesture; Redo brings it back.
+    s.pointer(ev(PointerKind::Down, 225.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap();
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert!(ended(&s));
+    assert_eq!(undos(&s), n + 2);
+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0]));
+    s.pointer(ev(PointerKind::Drag, 150.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Up, 150.0, 175.0)).unwrap();
+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0]));
+    s.execute("edit.redo", &json!({})).unwrap();
+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.5, 0.0]));
+    // Panning the picture off the box stores the crop the renderer draws.
+    assert_eq!(s.tool.crop, Some(deckcraft_model::ShapeId(id as _)));
+    s.pointer(ev(PointerKind::Down, 150.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Drag, 550.0, 175.0)).unwrap();
+    s.pointer(ev(PointerKind::Up, 550.0, 175.0)).unwrap();
+    let c = crop_state(&s, id).1;
+    assert!(c.iter().all(|v| (-10.0..=0.99).contains(v)) && c[2] == 0.99, "{c:?}");
+    // keepFrame can't crop the whole picture away either.
+    assert!(s.execute("picture.crop", &json!({"ids": [id], "left": 0.6, "right": 0.6, "keepFrame": true})).is_err());
+    // Crop mode needs exactly one picture selected.
+    s.execute("edit.select", &json!({"ids": [id, rect]})).unwrap();
+    assert!(s.execute("picture.cropMode", &json!({})).is_err());
+}
+
+#[test]
+fn crop_aspect_fill_and_fit() {
+    let mut s = session();
+    let id = cropped_picture(&mut s);
+    s.execute("picture.cropAspect", &json!({"ids": [id], "ratio": "1:1"})).unwrap();
+    let (b, _) = crop_state(&s, id);
+    assert!(near(b, [125.0, 100.0, 150.0, 150.0]), "largest square, centred: {b:?}");
+    assert!(s.tool.crop.is_some(), "then into crop mode");
+    // Fit: the whole picture inside the square box; fill: the box covered again.
+    s.execute("picture.cropFill", &json!({"ids": [id], "mode": "fit"})).unwrap();
+    let (b, c) = crop_state(&s, id);
+    let img = cmd::crop::image_rect(b[2], b[3], c).unwrap();
+    assert!((img.width() - 150.0).abs() < 1e-6 && (img.height() - 112.5).abs() < 1e-6, "{img:?}");
+    s.execute("picture.cropFill", &json!({"ids": [id]})).unwrap();
+    let (b, c) = crop_state(&s, id);
+    let img = cmd::crop::image_rect(b[2], b[3], c).unwrap();
+    assert!((img.height() - 150.0).abs() < 1e-6 && (img.width() - 200.0).abs() < 1e-6, "{img:?}");
+    assert!(s.execute("picture.cropAspect", &json!({"ids": [id], "ratio": "wide"})).is_err());
+}
+
 /// A box at `rect` and a second box at `other` on a fresh slide; smart guides on, the grid off.
 /// The first box is selected.
 fn snap_scene(rect: [f64; 4], other: [f64; 4]) -> (Session, u64) {
diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs
--- a/crates/engine/src/tools.rs
+++ b/crates/engine/src/tools.rs
@@ -136,6 +136,28 @@ pub enum Hit {
         id: ShapeId,
         row: usize,
     },
+    /// Crop mode: crop handle 0..8 (NW, N, NE, E, SE, S, SW, W) on the picture's box.
+    Crop {
+        id: ShapeId,
+        handle: u8,
+    },
+    /// Crop mode: handle 0..8 of the whole image, which scales it under the box.
+    CropImage {
+        id: ShapeId,
+        handle: u8,
+    },
+    /// Crop mode: the picture itself, which moves under the box.
+    CropPan {
+        id: ShapeId,
+    },
+}
+
+/// What a crop-mode drag changes.
+#[derive(Clone, Copy, Debug)]
+enum CropPart {
+    Frame(u8),
+    Image(u8),
+    Pan,
 }
 
 #[derive(Clone, Debug)]
@@ -223,6 +245,14 @@ enum Drag {
     Ink {
         points: Vec<(f64, f64, f32)>,
     },
+    /// Crop mode: `orig` is the box and `img` the whole image in its local points at the press.
+    Crop {
+        id: ShapeId,
+        part: CropPart,
+        orig: Xfrm,
+        img: Rect,
+        start: Point,
+    },
     /// Freeform drawing (`freeform` polygon, `curve`, `scribble`): placed points; `pressed` while
     /// the button is down (dragging adds scribbled points).
     Freeform {
@@ -292,6 +322,8 @@ pub struct ToolState {
     pub guide_hover: Option<usize>,
     /// Pressure from the pointer event being handled, if the device reported one.
     pressure: Option<f32>,
+    /// The picture in crop mode (Picture Format ▸ Crop): its crop handles replace the box's.
+    pub crop: Option<ShapeId>,
 }
 
 impl ToolState {
@@ -309,6 +341,22 @@ impl ToolState {
         })
     }
 
+    /// The picture in crop mode, while it is still the one selected shape.
+    pub fn cropping(&self, st: &crate::DocState) -> Option<ShapeId> {
+        self.crop.filter(|id| {
+            st.selection.shapes.as_slice() == [*id]
+                && st.selection.text.is_none()
+                && st.shape(*id).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. }) && !s.locked)
+        })
+    }
+
+    /// Forget a crop drag without finishing it: its presentation is no longer the active one.
+    pub(crate) fn drop_crop_drag(&mut self) {
+        if matches!(self.drag, Some(Drag::Crop { .. })) {
+            self.drag = None;
+        }
+    }
+
     /// A freeform path is being drawn (Enter/Escape/double-click finish it).
     pub fn drawing_freeform(&self) -> bool {
         matches!(self.drag, Some(Drag::Freeform { .. }))
diff --git a/crates/engine/src/tools/click.rs b/crates/engine/src/tools/click.rs
--- a/crates/engine/src/tools/click.rs
+++ b/crates/engine/src/tools/click.rs
@@ -91,7 +91,10 @@ impl Session {
                 | Hit::Frame { id }
                 | Hit::TableColumn { id, .. }
                 | Hit::TableRow { id, .. }
-                | Hit::Warp { id, .. },
+                | Hit::Warp { id, .. }
+                | Hit::Crop { id, .. }
+                | Hit::CropImage { id, .. }
+                | Hit::CropPan { id },
             ) => Some(*id),
             None => None,
         };
diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs
--- a/crates/engine/src/tools/drag.rs
+++ b/crates/engine/src/tools/drag.rs
@@ -63,6 +63,41 @@ impl Session {
                 self.tool.drag =
                     Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) });
             }
+            Drag::Crop { id, part, orig, img, start } => {
+                let inv = orig.affine().inverse();
+                let mut d = inv * p - inv * start;
+                if !(d.x.is_finite() && d.y.is_finite()) {
+                    return Ok(Value::Null);
+                }
+                let (nx, nc) = match part {
+                    CropPart::Frame(h) => {
+                        let f = cmd::crop::drag_frame(orig.w, orig.h, h, d);
+                        let f = self.snap_crop_rect(f, &orig, Some(h), id, tol, !mods.cmd);
+                        let c = cmd::crop::crop_of(f.width(), f.height(), img - f.origin().to_vec2());
+                        (cmd::crop::frame_xfrm(&orig, f), c)
+                    }
+                    CropPart::Image(h) => {
+                        let image = cmd::crop::drag_image(img, h, d, mods.shift);
+                        let image = self.snap_crop_rect(image, &orig, Some(h), id, tol, !mods.cmd);
+                        (orig, cmd::crop::crop_of(orig.w, orig.h, image))
+                    }
+                    CropPart::Pan => {
+                        if mods.shift {
+                            if d.x.abs() > d.y.abs() {
+                                d.y = 0.0;
+                            } else {
+                                d.x = 0.0;
+                            }
+                        }
+                        let image = self.snap_crop_rect(img + d, &orig, None, id, tol, !mods.cmd);
+                        (orig, cmd::crop::crop_of(orig.w, orig.h, image))
+                    }
+                };
+                // Stored as the renderer draws it, so the next gesture starts from what is shown.
+                if let Some(c) = nc.map(|c| c.map(|v| v.clamp(-10.0, 0.99))) {
+                    cmd::crop::store(self, "crop", &[(id, Some(nx), c)])?;
+                }
+            }
             Drag::Resize { id, handle, orig, start } => {
                 let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
                 let nx = self.snap_resize(nx, &orig, handle, id, tol);
diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs
--- a/crates/engine/src/tools/geometry.rs
+++ b/crates/engine/src/tools/geometry.rs
@@ -227,6 +227,29 @@ impl Session {
         (off, guides)
     }
 
+    /// Snap a crop rectangle in slide coordinates, then return it to the original picture's
+    /// local frame. Exclude the picture itself: its fixed crop box is not a snap target.
+    pub(super) fn snap_crop_rect(&mut self, r: Rect, orig: &Xfrm, handle: Option<u8>, id: ShapeId, tol: f64, enabled: bool) -> Rect {
+        if !enabled || orig.rot != 0.0 {
+            self.tool.guides.clear();
+            return r;
+        }
+        let x = cmd::crop::frame_xfrm(orig, r);
+        let snapped = if let Some(h) = handle {
+            self.snap_resize(x, orig, h, id, tol).rect()
+        } else {
+            let (off, guides) = self.snap_rect(x.rect(), &[id], tol * 1.5, true);
+            self.tool.guides = guides;
+            x.rect() + off
+        };
+        let local = orig.affine().inverse().transform_rect_bbox(snapped);
+        if local.width() < cmd::crop::MIN_SIDE || local.height() < cmd::crop::MIN_SIDE {
+            self.tool.guides.clear();
+            return r;
+        }
+        local
+    }
+
     /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
     pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
         if x.rot != 0.0 {
diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs
--- a/crates/engine/src/tools/pointer.rs
+++ b/crates/engine/src/tools/pointer.rs
@@ -2,7 +2,34 @@ use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_t
 use super::*;
 
 impl Session {
+    /// End crop mode once its picture is no longer the one selected shape (Tab, a slide change,
+    /// Undo…), so its handles don't come back when the picture is selected again. Reselecting the
+    /// same picture keeps crop mode on.
+    pub(crate) fn settle_crop(&mut self) {
+        if self.tool.crop.is_some() && self.active().is_none_or(|st| self.tool.cropping(st).is_none()) {
+            self.exit_crop();
+        }
+    }
+
+    /// Leave crop mode. A crop gesture still in progress is finished first, as its own undo step.
+    /// Every way out of crop mode comes through here.
+    pub(crate) fn exit_crop(&mut self) {
+        self.end_crop_drag();
+        self.tool.crop = None;
+    }
+
+    /// Finish a crop gesture in the presentation it began in, as releasing the pointer does:
+    /// one undo step there, and later pointer events can't carry it on. Called wherever the
+    /// gesture can be cut short (leaving crop mode, Undo/Redo, another tool, another presentation).
+    pub(crate) fn end_crop_drag(&mut self) {
+        if matches!(self.tool.drag, Some(Drag::Crop { .. })) {
+            self.tool.drag = None;
+            self.commit_named("Crop");
+        }
+    }
+
     pub fn set_tool(&mut self, kind: ToolKind) {
+        self.end_crop_drag();
         self.tool.kind = kind;
         self.tool.drag = None;
         self.tool.preview = None;
@@ -12,6 +39,30 @@ impl Session {
     /// What's under `p` on the current slide.
     pub fn hit_test(&self, p: Point, tol: f64) -> Option<Hit> {
         let st = self.active()?;
+        // In crop mode the picture's crop handles, then the image's own, then the image itself.
+        if let Some(id) = self.tool.cropping(st)
+            && let Some(sh) = st.shape(id)
+            && let ShapeKind::Picture { fill } = &sh.kind
+        {
+            let x = xfrm_of(&st.doc, &st.selection, sh);
+            let a = x.affine();
+            let frame = Rect::new(0.0, 0.0, x.w, x.h);
+            for (i, q) in cmd::crop::rect_handles(frame).iter().enumerate() {
+                if (a * *q - p).hypot() <= tol * 2.0 {
+                    return Some(Hit::Crop { id, handle: i as u8 });
+                }
+            }
+            if let Some(img) = cmd::crop::image_rect(x.w, x.h, fill.crop) {
+                for (i, q) in cmd::crop::rect_handles(img).iter().enumerate() {
+                    if (a * *q - p).hypot() <= tol * 1.6 {
+                        return Some(Hit::CropImage { id, handle: i as u8 });
+                    }
+                }
+                if img.union(frame).contains(a.inverse() * p) {
+                    return Some(Hit::CropPan { id });
+                }
+            }
+        }
         // In Edit Points, the edited shape's vertices come first.
         if let Some(id) = self.tool.editing_points(st)
             && let Some(sh) = st.shape(id)
@@ -103,6 +154,7 @@ impl Session {
         if !matches!(ev.kind, PointerKind::Move) {
             self.settle_composition();
         }
+        self.settle_crop();
         match ev.kind {
             PointerKind::Move => {
                 if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
@@ -265,6 +317,30 @@ impl Session {
             return Ok(json!({"caret": [pos.0, pos.1]}));
         }
         let hit = self.hit_test(p, tol);
+        // Crop mode: the crop handles, the image's handles and the image itself; a press anywhere
+        // else ends it and goes on as a normal press.
+        if let Some(cid) = self.doc().ok().and_then(|st| self.tool.cropping(st)) {
+            let part = match hit {
+                Some(Hit::Crop { id, handle }) if id == cid => Some(CropPart::Frame(handle)),
+                Some(Hit::CropImage { id, handle }) if id == cid => Some(CropPart::Image(handle)),
+                Some(Hit::CropPan { id }) if id == cid => Some(CropPart::Pan),
+                _ => None,
+            };
+            let st = self.doc()?;
+            let found = st.shape(cid).and_then(|sh| match &sh.kind {
+                ShapeKind::Picture { fill } => {
+                    let x = xfrm_of(&st.doc, &st.selection, sh);
+                    cmd::crop::image_rect(x.w, x.h, fill.crop).map(|img| (x, img))
+                }
+                _ => None,
+            });
+            if let (Some(part), Some((orig, img))) = (part, found) {
+                self.begin("Crop");
+                self.tool.drag = Some(Drag::Crop { id: cid, part, orig, img, start: p });
+                return Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}));
+            }
+            self.exit_crop();
+        }
         // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one;
         // a click anywhere else leaves Edit Points.
         if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) {
@@ -316,8 +392,8 @@ impl Session {
                 self.begin("Rotate");
                 self.tool.drag = Some(Drag::Rotate { ids, center, start, origs });
             }
-            // Only reached outside Edit Points (handled above).
-            Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {}
+            // Only reached outside Edit Points and crop mode (handled above).
+            Some(Hit::Vertex { .. } | Hit::Handle { .. } | Hit::Crop { .. } | Hit::CropImage { .. } | Hit::CropPan { .. }) => {}
             Some(Hit::Adjust { id, index }) => {
                 let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
                 self.begin("Adjust Shape");
diff --git a/crates/ui-egui/src/canvas/context_menu.rs b/crates/ui-egui/src/canvas/context_menu.rs
--- a/crates/ui-egui/src/canvas/context_menu.rs
+++ b/crates/ui-egui/src/canvas/context_menu.rs
@@ -218,6 +218,10 @@ pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
                 let _ = app.run("app.changePictureDialog", json!({}));
                 ui.close();
             }
+            if picture && crate::ribbon::enabled(app, "picture.cropMode") && ui.button("Crop").clicked() {
+                let _ = app.run("picture.cropMode", json!({"on": true}));
+                ui.close();
+            }
             if ui.button("Link…").clicked() {
                 app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
                 ui.close();
diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs
--- a/crates/ui-egui/src/canvas/input.rs
+++ b/crates/ui-egui/src/canvas/input.rs
@@ -143,6 +143,13 @@ pub(super) fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf
             (ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair,
             (ToolKind::TextBox, _) => egui::CursorIcon::Text,
             (ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair,
+            (_, Some(Hit::CropPan { .. })) => egui::CursorIcon::Move,
+            (_, Some(Hit::Crop { handle, .. } | Hit::CropImage { handle, .. })) => match handle {
+                0 | 4 => egui::CursorIcon::ResizeNwSe,
+                2 | 6 => egui::CursorIcon::ResizeNeSw,
+                1 | 5 => egui::CursorIcon::ResizeVertical,
+                _ => egui::CursorIcon::ResizeHorizontal,
+            },
             (_, Some(Hit::Resize { id, handle })) => resize_cursor(&app.session, *id, *handle),
             (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
             (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,
diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs
--- a/crates/ui-egui/src/canvas/overlays.rs
+++ b/crates/ui-egui/src/canvas/overlays.rs
@@ -1,5 +1,113 @@
 use super::*;
 
+/// Crop mode, as in PowerPoint: the part of the picture outside its box shows faded with round
+/// handles at its edges (they scale it), and the box carries black crop marks.
+fn crop_overlay(
+    ctx: &egui::Context,
+    doc: &deckcraft_model::Presentation,
+    painter: &egui::Painter,
+    xf: Xf,
+    x: &Xfrm,
+    fill: &deckcraft_model::style::PictureFill,
+    t: &Tokens,
+) {
+    use deckcraft_engine::cmd::crop;
+    let a = x.affine();
+    let to = |q: Point| xf.to_screen(a * q);
+    let frame = deckcraft_geom::Rect::new(0.0, 0.0, x.w, x.h);
+    if let Some(img) = crop::image_rect(x.w, x.h, fill.crop) {
+        if let Some(tex) = crop_ghost(ctx, doc, fill.media) {
+            // The image outside the box, in bands above, below, left and right of it.
+            let (y0, y1) = (img.y0.max(frame.y0), img.y1.min(frame.y1));
+            let bands = [
+                (img.x0, img.y0, img.x1, img.y1.min(frame.y0)),
+                (img.x0, img.y0.max(frame.y1), img.x1, img.y1),
+                (img.x0, y0, img.x1.min(frame.x0), y1),
+                (img.x0.max(frame.x1), y0, img.x1, y1),
+            ];
+            let mut mesh = egui::Mesh::with_texture(tex.id());
+            let tint = Color32::from_white_alpha(120);
+            for (bx0, by0, bx1, by1) in bands {
+                if bx1 - bx0 <= 1e-6 || by1 - by0 <= 1e-6 {
+                    continue;
+                }
+                let base = mesh.vertices.len() as u32;
+                for (px, py) in [(bx0, by0), (bx1, by0), (bx1, by1), (bx0, by1)] {
+                    let uv = pos2(((px - img.x0) / img.width()) as f32, ((py - img.y0) / img.height()) as f32);
+                    mesh.vertices.push(egui::epaint::Vertex { pos: to(Point::new(px, py)), uv, color: tint });
+                }
+                mesh.add_triangle(base, base + 1, base + 2);
+                mesh.add_triangle(base, base + 2, base + 3);
+            }
+            painter.add(mesh);
+        }
+        let ic = [Point::new(img.x0, img.y0), Point::new(img.x1, img.y0), Point::new(img.x1, img.y1), Point::new(img.x0, img.y1)].map(to);
+        poly(painter, ic, Stroke::new(1.0, Color32::from_gray(150)));
+        for q in crop::rect_handles(img) {
+            painter.circle(to(q), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));
+        }
+    }
+    let fc = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(to);
+    poly(painter, fc, Stroke::new(1.0, t.selection));
+    // Crop marks: an L at each corner and a bar mid-side, along the box's edges.
+    let dir = |q: Point, d: deckcraft_geom::Vec2| {
+        let v = to(q + d) - to(q);
+        if v.length() > 1e-3 { v.normalized() } else { egui::Vec2::ZERO }
+    };
+    let (ux, uy) = (deckcraft_geom::Vec2::new(1.0, 0.0), deckcraft_geom::Vec2::new(0.0, 1.0));
+    let len = 12.0;
+    for (i, q) in crop::rect_handles(frame).into_iter().enumerate() {
+        let c = to(q);
+        let arms: Vec<(Pos2, Pos2)> = match i {
+            0 => vec![(c, c + dir(q, ux) * len), (c, c + dir(q, uy) * len)],
+            2 => vec![(c, c + dir(q, -ux) * len), (c, c + dir(q, uy) * len)],
+            4 => vec![(c, c + dir(q, -ux) * len), (c, c + dir(q, -uy) * len)],
+            6 => vec![(c, c + dir(q, ux) * len), (c, c + dir(q, -uy) * len)],
+            1 | 5 => vec![(c - dir(q, ux) * len * 0.75, c + dir(q, ux) * len * 0.75)],
+            _ => vec![(c - dir(q, uy) * len * 0.75, c + dir(q, uy) * len * 0.75)],
+        };
+        for (s, e) in &arms {
+            painter.line_segment([*s, *e], Stroke::new(6.0, Color32::WHITE));
+        }
+        for (s, e) in arms {
+            painter.line_segment([s, e], Stroke::new(4.0, Color32::BLACK));
+        }
+    }
+}
+
+/// The picture drawn whole, for the faded part outside the box in crop mode. Decoded once per
+/// picture and kept at most 1024 px on a side; `None` when it doesn't decode.
+fn crop_ghost(ctx: &egui::Context, doc: &deckcraft_model::Presentation, media: deckcraft_model::MediaId) -> Option<egui::TextureHandle> {
+    const SIDE: u32 = 1024;
+    let item = doc.media(media)?;
+    let key = egui::Id::unique(("crop-ghost", std::sync::Arc::as_ptr(&item.data) as usize, item.data.len()));
+    if let Some(t) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(key)) {
+        return t;
+    }
+    let tex = deckcraft_render::decode_image(&item.data).and_then(|im| {
+        let (w, h) = (im.width(), im.height());
+        if w == 0 || h == 0 {
+            return None;
+        }
+        let k = (SIDE as f64 / w.max(h) as f64).min(1.0);
+        let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1));
+        let raw = im.as_raw();
+        let mut px = Vec::with_capacity((nw as usize) * (nh as usize) * 4);
+        for y in 0..nh {
+            let sy = ((y as u64 * h as u64) / nh as u64).min(h as u64 - 1) as usize;
+            for x in 0..nw {
+                let sx = ((x as u64 * w as u64) / nw as u64).min(w as u64 - 1) as usize;
+                let i = (sy * w as usize + sx) * 4;
+                px.extend_from_slice(raw.get(i..i + 4).unwrap_or(&[0, 0, 0, 0]));
+            }
+        }
+        let image = egui::ColorImage::from_rgba_unmultiplied([nw as usize, nh as usize], &px);
+        Some(ctx.load_texture("crop-ghost", image, egui::TextureOptions::LINEAR))
+    });
+    ctx.data_mut(|d| d.insert_temp(key, tex.clone()));
+    tex
+}
+
 fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) {
     for i in 0..4 {
         p.line_segment([pts[i], pts[(i + 1) % 4]], stroke);
@@ -37,6 +145,12 @@ pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf:
             continue;
         }
         let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes);
+        if app.session.tool.cropping(st) == Some(sh.id)
+            && let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind
+        {
+            crop_overlay(ui.ctx(), &st.doc, painter, xf, &x, fill, t);
+            continue;
+        }
         if app.session.tool.editing_points(st) == Some(sh.id) {
             // Edit Points: the outline's vertices are the handles.
             // No selection box: the outline and its vertices are what's edited.
diff --git a/crates/ui-egui/src/ribbon/picture_format.rs b/crates/ui-egui/src/ribbon/picture_format.rs
--- a/crates/ui-egui/src/ribbon/picture_format.rs
+++ b/crates/ui-egui/src/ribbon/picture_format.rs
@@ -152,19 +152,51 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
     });
     arrange_group(app, ui);
     group(ui, |ui| {
-        let r = big_button(ui, Icon::Crop, "Crop", true);
-        egui::Popup::menu(&r).show(|ui| {
-            ui.label("Crop to Shape");
-            if let Some(p) = shapes_gallery(ui) {
-                run(app, "shape.change", json!({"preset": p}));
-            }
-            ui.separator();
-            for (l, v) in [("Crop 10% each side", 0.1), ("Crop 20% each side", 0.2), ("Reset Crop", 0.0)] {
-                if ui.button(l).clicked() {
-                    run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v}));
+        // Split like PowerPoint's: the top enters (or leaves) crop mode, the bottom opens the menu.
+        let en = enabled(app, "picture.cropMode");
+        let on = app.session.active().is_some_and(|d| app.session.tool.cropping(d).is_some());
+        let (main, arrow) = big_button_ex(ui, Icon::Crop, "Crop", en, true, on);
+        let main = main.on_hover_text(tip("picture.cropMode", "Crop"));
+        if main.clicked() {
+            run(app, "picture.cropMode", json!({}));
+        }
+        if let Some(a) = arrow {
+            egui::Popup::menu(&a).show(|ui| {
+                if ui.button("Crop").clicked() {
+                    run(app, "picture.cropMode", json!({"on": true}));
                 }
-            }
-        });
+                ui.menu_button("Crop to Shape", |ui| {
+                    if let Some(p) = shapes_gallery(ui) {
+                        run(app, "shape.change", json!({"preset": p}));
+                    }
+                });
+                ui.menu_button("Aspect Ratio", |ui| {
+                    for (head, ratios) in [
+                        ("Square", &["1:1"][..]),
+                        ("Portrait", &["2:3", "3:4", "3:5", "4:5"][..]),
+                        ("Landscape", &["3:2", "4:3", "5:3", "5:4", "16:9", "16:10"][..]),
+                    ] {
+                        ui.label(head);
+                        for r in ratios {
+                            if ui.button(*r).clicked() {
+                                run(app, "picture.cropAspect", json!({"ratio": r}));
+                            }
+                        }
+                    }
+                });
+                ui.separator();
+                if ui.button("Fill").on_hover_text("The picture covers the whole box").clicked() {
+                    run(app, "picture.cropFill", json!({"mode": "fill"}));
+                }
+                if ui.button("Fit").on_hover_text("The whole picture shows inside the box").clicked() {
+                    run(app, "picture.cropFill", json!({"mode": "fit"}));
+                }
+                ui.separator();
+                if ui.button("Reset Crop").clicked() {
+                    run(app, "picture.crop", json!({"left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0}));
+                }
+            });
+        }
     });
     size_group(app, ui);
 }
