diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -149,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 | 2026-10-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value |
 | 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
 | 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
+| 2026-10-26 | Shrink text on overflow fits in PowerPoint's steps: the text scale falls in 7.5 % steps to a floor of 25 % and the line spacing is reduced by 10 % or 20 % (the pairs PowerPoint 16 for Windows saves), not continuous values, and text that fits is saved as a bare `<a:normAutofit/>` instead of `fontScale="100000"`. `text.set` refits like typing does |
 | 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
 | 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
 | 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
--- a/crates/engine/src/cmd/text.rs
+++ b/crates/engine/src/cmd/text.rs
@@ -374,6 +374,17 @@ pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option<ImeSurrounding> {
     Some(ImeSurrounding { text, cursor, anchor })
 }
 
+/// The saved shrink-on-overflow scale was for the old text: back to 100 % to fit the new text
+/// afresh. A body whose file carried no `fontScale` keeps none.
+fn reset_shrink_scale(body: &mut deckcraft_model::text::BodyProps) {
+    if let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut body.autofit {
+        if font_scale.is_some() {
+            *font_scale = Some(1.0);
+        }
+        *line_reduction = 0.0;
+    }
+}
+
 /// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
 pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
     let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
@@ -385,12 +396,7 @@ pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T
         // The saved shrink-on-overflow scale was for the old text: fit the new text afresh. A
         // body whose file carried no `fontScale` keeps none (100 %) here; `fit_shrink_scale`
         // below fills in the computed scale when the new text overflows (#724).
-        if let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut body.body.autofit {
-            if font_scale.is_some() {
-                *font_scale = Some(1.0);
-            }
-            *line_reduction = 0.0;
-        }
+        reset_shrink_scale(&mut body.body);
         sel.text = Some(t);
         Ok(r)
     })?;
@@ -465,7 +471,10 @@ fn fit_shrink_scale(s: &mut Session, id: ShapeId) -> Result<()> {
     let mut probe = body.clone();
     probe.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
     let opts = deckcraft_text::Opts { rect: tr, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false };
-    let l = deckcraft_text::layout(&ctx, &sh, &probe, &opts);
+    // The layout reads the body's autofit from the shape, so the probe goes in a shape of its own.
+    let mut probe_shape = sh.clone();
+    probe_shape.text = Some(probe.clone());
+    let l = deckcraft_text::layout(&ctx, &probe_shape, &probe, &opts);
     if l.font_scale >= 1.0 && l.line_reduction <= 0.0 {
         return Ok(());
     }
@@ -1695,6 +1704,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
             }
         }
         body.paragraphs = nb.paragraphs;
+        // Only a shape's text is fitted afterwards (`fit_text_box`), so a cell keeps its scale.
+        if cell.is_none() {
+            reset_shrink_scale(&mut body.body);
+        }
         Ok(())
     })?;
     fit_text_box(s, id)?;
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
@@ -176,6 +176,11 @@ fn editing_shrink_text_stores_the_layout_scale() {
     std::io::Read::read_to_string(&mut z.by_name("ppt/slides/slide1.xml").unwrap(), &mut xml).unwrap();
     let scales: Vec<i64> = xml.split("fontScale=\"").skip(1).filter_map(|s| s.split('"').next()?.parse().ok()).collect();
     assert!(scales.iter().any(|v| *v < 100_000), "the fitted scale is saved: {scales:?}");
+    // Only the pairs PowerPoint saves: 7.5 % steps of the scale, 10 or 20 % less line spacing.
+    let steps = [100_000, 92_500, 85_000, 77_500, 70_000, 62_500, 55_000, 47_500, 40_000, 32_500, 25_000];
+    assert!(scales.iter().all(|v| steps.contains(v)), "{scales:?}");
+    let reductions: Vec<i64> = xml.split("lnSpcReduction=\"").skip(1).filter_map(|s| s.split('"').next()?.parse().ok()).collect();
+    assert!(reductions.iter().all(|v| [10_000, 20_000].contains(v)), "{reductions:?}");
     // Undo restores the state before the edit, and redo brings the stored fit back.
     s.execute("edit.undo", &json!({})).unwrap();
     let sh = s.doc().unwrap().shape(id).cloned().unwrap();
@@ -187,6 +192,53 @@ fn editing_shrink_text_stores_the_layout_scale() {
     assert!(font_scale.is_some_and(|v| v < 1.0), "redo brings the stored fit back: {font_scale:?}");
 }
 
+/// PowerPoint writes a bare `<a:normAutofit/>` for text that fits, also after text that was
+/// shrunk is shortened again.
+#[test]
+fn shrink_text_that_fits_is_saved_as_a_bare_normautofit() {
+    let mut s = session();
+    let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 200.0, 100.0]})).unwrap();
+    let id = r["id"].as_u64().unwrap();
+    s.execute("format.autofit", &json!({"mode": "shrink"})).unwrap();
+    let xml = |s: &Session| {
+        let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
+        let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
+        let mut xml = String::new();
+        std::io::Read::read_to_string(&mut z.by_name("ppt/slides/slide1.xml").unwrap(), &mut xml).unwrap();
+        xml
+    };
+    let lines = |n: usize| (0..n).map(|i| format!("Line {i}")).collect::<Vec<_>>().join("\n");
+    s.execute("text.set", &json!({"id": id, "text": lines(2)})).unwrap();
+    assert!(xml(&s).contains("<a:normAutofit/>"), "{}", xml(&s));
+    s.execute("text.set", &json!({"id": id, "text": lines(12)})).unwrap();
+    assert!(xml(&s).contains("<a:normAutofit fontScale="), "{}", xml(&s));
+    s.execute("text.set", &json!({"id": id, "text": lines(2)})).unwrap();
+    assert!(xml(&s).contains("<a:normAutofit/>"), "{}", xml(&s));
+}
+
+/// A body saved with a bare `<a:normAutofit/>` is fitted afresh when its text is edited, as in
+/// PowerPoint, and the fitted scale is saved.
+#[test]
+fn editing_the_text_of_a_bare_normautofit_fits_it() {
+    use deckcraft_model::text::AutoFit;
+    let mut s = session();
+    let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 200.0, 40.0]})).unwrap();
+    let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
+    s.execute("format.autofit", &json!({"mode": "shrink"})).unwrap();
+    s.edit(|doc, sel| {
+        let shapes = crate::shapes_mut(doc, sel).unwrap();
+        let sh = deckcraft_model::find_shape_mut(shapes, id).unwrap();
+        sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 });
+        Ok(())
+    })
+    .unwrap();
+    let text = (0..12).map(|i| format!("Line {i}")).collect::<Vec<_>>().join("\n");
+    s.execute("text.set", &json!({"id": id.0, "text": text})).unwrap();
+    let sh = s.doc().unwrap().shape(id).cloned().unwrap();
+    let Some(AutoFit::Shrink { font_scale, .. }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
+    assert!(font_scale.is_some_and(|v| v < 1.0), "{font_scale:?}");
+}
+
 #[test]
 fn smart_quotes_and_autocorrect() {
     let mut s = session();
diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs
--- a/crates/pptx/src/write/dml.rs
+++ b/crates/pptx/src/write/dml.rs
@@ -490,9 +490,9 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
         Some(AutoFit::None) => w.empty0("a:noAutofit"),
         Some(AutoFit::Shape) => w.empty0("a:spAutoFit"),
         Some(AutoFit::Shrink { font_scale, line_reduction }) => {
-            // A scale the model holds is written, 100 % included: reopening then keeps the same
-            // "draw the saved scale" state instead of becoming a bare `<a:normAutofit/>` (#724).
-            let fs = font_scale.map(|s| pct(s.clamp(0.01, 1.0)));
+            // Text that fits is a bare `<a:normAutofit/>`, as PowerPoint writes it (and reads it
+            // as the 100 % it draws); only a shrunk body carries its scale.
+            let fs = font_scale.filter(|s| *s < 1.0).map(|s| pct(s.clamp(0.01, 1.0)));
             let lr = pct(line_reduction.clamp(0.0, 1.0));
             w.empty("a:normAutofit", A::new().o("fontScale", fs).o("lnSpcReduction", (lr != 0).then_some(lr)));
         }
diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs
--- a/crates/text/src/lib.rs
+++ b/crates/text/src/lib.rs
@@ -1774,6 +1774,27 @@ thread_local! {
     pub(crate) static LAYOUT_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
 }
 
+/// The (`fontScale`, `lnSpcReduction`) pairs shrink on overflow tries, largest text first. These
+/// are the only ones PowerPoint 16 for Windows saves (it takes the first that fits): the scale
+/// falls in 7.5 % steps to a floor of 25 %, and the line spacing is reduced by 10 % at first and
+/// by 20 % from there on, so a file holds nothing in between.
+pub const SHRINK_STEPS: [(f64, f64); 14] = [
+    (1.0, 0.0),
+    (1.0, 0.1),
+    (0.925, 0.1),
+    (0.925, 0.2),
+    (0.85, 0.1),
+    (0.85, 0.2),
+    (0.775, 0.2),
+    (0.7, 0.2),
+    (0.625, 0.2),
+    (0.55, 0.2),
+    (0.475, 0.2),
+    (0.4, 0.2),
+    (0.325, 0.2),
+    (0.25, 0.2),
+];
+
 /// Lay out a shape's text body.
 pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLayout {
     let bp = resolve::body(ctx, shape);
@@ -1799,30 +1820,29 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay
         && l.overflow
         && !saved
     {
-        // Text that overflows even at the smallest scale gets that scale: every larger one
-        // overflows too, so searching would only lay it out a dozen more times (seconds for a
+        // Text that overflows even at the smallest step gets that step: every larger one
+        // overflows too, so searching would only lay it out a few more times (seconds for a
         // body of tens of thousands of paragraphs, on every redraw of the slide).
-        let smallest = layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2);
+        let (font_scale, line_reduction) = SHRINK_STEPS[SHRINK_STEPS.len() - 1];
+        let smallest = layout_scaled(ctx, shape, body, &bp, opts, font_scale, line_reduction);
         if smallest.overflow {
             return smallest;
         }
-        // Binary search the largest scale that fits (steps like 90%, 80%… with line reduction).
-        let (mut lo, mut hi) = (0.1_f64, scale0);
-        let mut best = Some(smallest);
-        for _ in 0..12 {
-            let mid = (lo + hi) / 2.0;
-            let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2).max(reduce0);
-            let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red);
+        // Binary search the first step that fits; `lo` overflows, `hi` fits.
+        let (mut lo, mut hi) = (0, SHRINK_STEPS.len() - 1);
+        let mut best = smallest;
+        while hi - lo > 1 {
+            let mid = (lo + hi) / 2;
+            let (font_scale, line_reduction) = SHRINK_STEPS[mid];
+            let t = layout_scaled(ctx, shape, body, &bp, opts, font_scale, line_reduction);
             if t.overflow {
-                hi = mid;
-            } else {
                 lo = mid;
-                best = Some(t);
+            } else {
+                hi = mid;
+                best = t;
             }
         }
-        if let Some(b) = best {
-            l = b;
-        }
+        l = best;
     }
     l
 }
diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs
--- a/crates/text/src/tests.rs
+++ b/crates/text/src/tests.rs
@@ -183,7 +183,56 @@ fn shrink_on_overflow() {
     let (p, sh) = setup(&text, 600.0, 200.0);
     let l = lay(&p, &sh, 600.0, 200.0);
     assert!(l.font_scale < 1.0, "scale {}", l.font_scale);
-    assert!(!l.overflow || l.font_scale <= 0.11);
+    assert!(!l.overflow || l.font_scale <= 0.25);
+}
+
+/// PowerPoint 16 (Windows) saved exactly these pairs when one text was fitted into boxes growing
+/// from 100 to 440 pt high (other fonts, sizes and boxes only ever gave pairs from this list), smallest text first;
+/// text that fits as it is gets a bare `<a:normAutofit/>`.
+#[test]
+fn shrink_steps_are_the_ones_powerpoint_saves() {
+    let seen = [
+        (0.25, 0.2),
+        (0.325, 0.2),
+        (0.4, 0.2),
+        (0.475, 0.2),
+        (0.55, 0.2),
+        (0.625, 0.2),
+        (0.7, 0.2),
+        (0.775, 0.2),
+        (0.85, 0.2),
+        (0.85, 0.1),
+        (0.925, 0.2),
+        (0.925, 0.1),
+        (1.0, 0.1),
+        (1.0, 0.0),
+    ];
+    let mut ours = crate::SHRINK_STEPS.to_vec();
+    ours.reverse();
+    assert_eq!(ours, seen);
+}
+
+/// Every overflowing text is drawn at one of PowerPoint's steps, and at the first one that fits.
+#[test]
+fn shrink_picks_the_first_step_that_fits() {
+    for n in 1..40 {
+        let text = (0..n).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
+        let (p, mut sh) = setup(&text, 300.0, 200.0);
+        let l = lay(&p, &sh, 300.0, 200.0);
+        let i = crate::SHRINK_STEPS
+            .iter()
+            .position(|&s| s == (l.font_scale, l.line_reduction))
+            .unwrap_or_else(|| panic!("{n} lines: {} {}", l.font_scale, l.line_reduction));
+        assert!(!l.overflow || i == crate::SHRINK_STEPS.len() - 1, "{n} lines");
+        if i > 0 {
+            // The step before it is drawn as saved and overflows.
+            let (font_scale, line_reduction) = crate::SHRINK_STEPS[i - 1];
+            sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(font_scale), line_reduction });
+            assert!(lay(&p, &sh, 300.0, 200.0).overflow, "{n} lines at step {}", i - 1);
+        } else {
+            assert!(!l.overflow);
+        }
+    }
 }
 
 #[test]
@@ -289,12 +338,12 @@ fn shrink_gives_up_at_once_when_even_the_smallest_scale_overflows() {
     let (p, sh) = setup(&text, 100.0, 20.0);
     crate::LAYOUT_PASSES.with(|n| n.set(0));
     let l = lay(&p, &sh, 100.0, 20.0);
-    assert!(l.overflow && l.font_scale <= 0.11, "scale {}", l.font_scale);
+    assert!(l.overflow && l.font_scale == 0.25 && l.line_reduction == 0.2, "scale {}", l.font_scale);
     assert!(crate::LAYOUT_PASSES.with(|n| n.get()) <= 2, "{} passes", crate::LAYOUT_PASSES.with(|n| n.get()));
     // Text that fits somewhere in the range still gets the search.
-    let (p, sh) = setup(&(0..12).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n"), 600.0, 200.0);
+    let (p, sh) = setup(&(0..8).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n"), 600.0, 200.0);
     let l = lay(&p, &sh, 600.0, 200.0);
-    assert!(l.font_scale > 0.11 && l.font_scale < 1.0 && !l.overflow, "scale {} overflow {}", l.font_scale, l.overflow);
+    assert!(l.font_scale > 0.25 && l.font_scale < 1.0 && !l.overflow, "scale {} overflow {}", l.font_scale, l.overflow);
 }
 
 #[test]
