From ce4f4816a174929f96828e1ee2addc0102e3544b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 19:19:30 -0500 Subject: [PATCH] text: a work limit on Knuth-Plass; past it break_paragraph falls back to greedy (#868) --- crates/text/src/linebreak/knuth_plass.rs | 14 +++++++++++++- crates/text/src/linebreak/mod.rs | 11 +++++++++-- crates/text/src/linebreak/tests.rs | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/text/src/linebreak/knuth_plass.rs b/crates/text/src/linebreak/knuth_plass.rs index 742c3245f..e9de0b485 100644 --- a/crates/text/src/linebreak/knuth_plass.rs +++ b/crates/text/src/linebreak/knuth_plass.rs @@ -4,6 +4,11 @@ use super::{Item, Kind, Line, Params, Sums, clamp_measure, fitness_class}; /// Give up (return `None`) beyond this many active breaks. const MAX_ACTIVE: usize = 4096; +/// Give up (return `None`) after this much work, counted as one for each break considered and one +/// for each active break it is tried against. A paragraph of 400,000 items with 30 active breaks +/// takes some 12,000,000 on a pass; the limit is where a pass runs for seconds, and past it the +/// caller falls back to [`super::greedy`], which keeps every item. +pub(super) const MAX_WORK: u64 = 1 << 27; /// Ratios beyond this are rated as this (an unstretchable line is infinitely loose). const MAX_RATIO: f64 = 10_000.0; /// Demerits of an overfull line accepted because nothing else reaches a break. @@ -37,8 +42,14 @@ struct Candidate { /// One total-fit pass over `items`. `width(k)` is the measure of line `k`; set /// [`Params::uniform_from`] when it varies. Returns `None` when no set of breaks keeps every line -/// within [`Params::tolerance`] (unless [`Params::overfull`]), or past the active-break limit. +/// within [`Params::tolerance`] (unless [`Params::overfull`]), or past the active-break or work limit. pub fn knuth_plass(items: &[Item], width: &dyn Fn(usize) -> f64, p: &Params) -> Option> { + knuth_plass_within(items, width, p, &mut MAX_WORK.clone()) +} + +/// [`knuth_plass`] spending at most `work` (left over on return), so that the passes of one +/// paragraph share a budget. +pub(super) fn knuth_plass_within(items: &[Item], width: &dyn Fn(usize) -> f64, p: &Params, work: &mut u64) -> Option> { let p = &p.sanitized(); let width = |k: usize| clamp_measure(width(k)); let s = Sums::new(items); @@ -57,6 +68,7 @@ pub fn knuth_plass(items: &[Item], width: &dyn Fn(usize) -> f64, p: &Params) -> if !s.is_break(b) { continue; } + *work = work.checked_sub(active.len() as u64 + 1)?; let item = items.get(b); let forced = s.forced(b); let flagged = item.is_some_and(Item::is_flagged); diff --git a/crates/text/src/linebreak/mod.rs b/crates/text/src/linebreak/mod.rs index 9d20b0645..dfdfd9cf3 100644 --- a/crates/text/src/linebreak/mod.rs +++ b/crates/text/src/linebreak/mod.rs @@ -29,6 +29,7 @@ use std::ops::Range; pub use greedy::{greedy, greedy_with}; pub use knuth_plass::knuth_plass; +use knuth_plass::{MAX_WORK, knuth_plass_within}; /// Number of elasticity tiers: word space, letter space, glyph scale. pub const TIERS: usize = 3; @@ -345,11 +346,17 @@ pub struct Breaks { /// [`knuth_plass`] at each of `tolerances` in turn, the last pass also accepting overfull lines; /// [`greedy`] if every pass fails or `tolerances` is empty. The last pass fails only past the -/// active-break limit (see [`Params::uniform_from`]). +/// active-break limit (see [`Params::uniform_from`]) or the passes' shared work limit, which +/// only a paragraph of many hundreds of thousands of items reaches. Nothing is left out then: the +/// greedy breaker lays out every item, in linear time. pub fn break_paragraph(items: &[Item], width: &dyn Fn(usize) -> f64, params: &Params, tolerances: &[f64]) -> Breaks { + break_paragraph_within(items, width, params, tolerances, MAX_WORK) +} + +fn break_paragraph_within(items: &[Item], width: &dyn Fn(usize) -> f64, params: &Params, tolerances: &[f64], mut work: u64) -> Breaks { for (pass, &tolerance) in tolerances.iter().enumerate() { let overfull = params.overfull || pass + 1 == tolerances.len(); - if let Some(lines) = knuth_plass(items, width, &Params { tolerance, overfull, ..*params }) { + if let Some(lines) = knuth_plass_within(items, width, &Params { tolerance, overfull, ..*params }, &mut work) { return Breaks { lines, method: Method::KnuthPlass { pass }, balanced: false }; } } diff --git a/crates/text/src/linebreak/tests.rs b/crates/text/src/linebreak/tests.rs index d8163edde..58483d2aa 100644 --- a/crates/text/src/linebreak/tests.rs +++ b/crates/text/src/linebreak/tests.rs @@ -492,3 +492,22 @@ fn next_start_is_the_first_box_or_forced_break_after_each_item() { assert_eq!(s.next_start(b), scan, "after {b}"); } } + +/// Past its work limit the paragraph is broken greedily, with every item laid out and no error or +/// cut: a limit of a few units stands for the real one, which a hostile paragraph of hundreds of +/// thousands of items would reach (and the test never builds). +#[test] +fn a_paragraph_past_the_work_limit_falls_back_to_greedy_with_every_item() { + let p = vectorcraft(false); + let para = Para::new(COPY, &p, Some(50.0)); + let w = |_| 150.0; + let within = break_paragraph_within(¶.items, &w, &p, &PASSES, MAX_WORK); + assert!(matches!(within.method, Method::KnuthPlass { .. }), "{:?}", within.method); + let limited = break_paragraph_within(¶.items, &w, &p, &PASSES, 20); + assert_eq!(limited.method, Method::Greedy); + assert_eq!(limited.lines, greedy(¶.items, &w, &p)); + assert_covers(¶.items, &limited.lines); + // The work is shared by the passes: what one spends the next doesn't have. + let mut work = 30; + assert!(knuth_plass_within(¶.items, &w, &Params { tolerance: 1.0, ..p }, &mut work).is_none()); +}