collective

Browse / github.com/storytold/deckcraft / patch

Shrink on overflow uses PowerPoint's font scale and line spacing steps

Shrink on overflow uses PowerPoint's font scale and line spacing steps ## What Shrink text on overflow now picks the same steps PowerPoint does, and writes what PowerPoint writes when the text fits. - The text scale (`fontScale`) falls in 7.5 % steps down to a floor of 25 %, and the line spacing reduction (`lnSpcReduction`) is 10 % or 20 %, in the pairs PowerPoint saves. Before, DeckCraft searched a continuous scale (for example 92.2 % / 6.3 %, 76.2 % / 19.0 %), so a deck saved by DeckCraft showed different text sizes in PowerPoint until the text was edited. - Text that fits is saved as a bare `<a:normAutofit/>`. Before, it was saved as `fontScale="100000"`. -

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #1440, open)
Base
27009cd941b22cb0788cd5040f9fad91472230c7
Size
+165 −30 in 6 files
Review
pending
Shared
2026-10-28
Id
9743437949999946a6061a7f

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/text.rs crates/engine/src/tests.rs crates/pptx/src/write/dml.rs crates/text/src/lib.rs crates/text/src/tests.rs

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]