text: a work limit on Knuth-Plass; past it break_paragraph falls back to greedy (#868)

This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 19:19:30 -05:00
1 parent eadfaf85ed
commit ce4f4816a1
3 files changed
+41 -3

No files matched your search

+13 -1
View File
@@ -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<Vec<Line>> {
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<Vec<Line>> {
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);
+9 -2
View File
@@ -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 };
}
}
+19
View File
@@ -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(&para.items, &w, &p, &PASSES, MAX_WORK);
assert!(matches!(within.method, Method::KnuthPlass { .. }), "{:?}", within.method);
let limited = break_paragraph_within(&para.items, &w, &p, &PASSES, 20);
assert_eq!(limited.method, Method::Greedy);
assert_eq!(limited.lines, greedy(&para.items, &w, &p));
assert_covers(&para.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(&para.items, &w, &Params { tolerance: 1.0, ..p }, &mut work).is_none());
}