diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d6bc66bf..7a6f8842 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1534,6 +1534,12 @@ fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 (next - x).max(1.0) } +#[cfg(test)] +thread_local! { + /// How many times `layout_scaled` ran on this thread (tests count autofit's passes). + pub(crate) static LAYOUT_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// 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); @@ -1542,9 +1548,16 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink) && l.overflow { + // 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 + // 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); + 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, 1.0_f64); - let mut best = None; + 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); @@ -1556,13 +1569,17 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay best = Some(t); } } - l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2)); + if let Some(b) = best { + l = b; + } } l } #[allow(clippy::too_many_arguments)] fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { + #[cfg(test)] + LAYOUT_PASSES.with(|n| n.set(n.get() + 1)); let r = opts.rect; let vert = bp.vert.unwrap_or(TextDir::Horizontal); // East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1e2be177..6ffec19c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -181,6 +181,22 @@ fn shrink_on_overflow() { assert!(!l.overflow || l.font_scale <= 0.11); } +#[test] +fn shrink_gives_up_at_once_when_even_the_smallest_scale_overflows() { + // Found by fuzzing: a tiny box with 20,000 paragraphs and shrink on overflow took 15 s to + // open, laying out the whole body 13 times in a search that could never fit. + let text = (0..400).map(|i| format!("Line number {i}")).collect::>().join("\n"); + 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!(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::>().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); +} + #[test] fn caret_and_hit_roundtrip() { let (p, sh) = setup("Hello world", 600.0, 100.0);