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
@@ -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 |@@ -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)?;@@ -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();@@ -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))); }@@ -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 }@@ -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]