From 65d5c28512a4fb452e6d99d6d265eacfdb29bcc5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:05:32 -0500 Subject: [PATCH] Text: sort a paragraph's tab stops once and find the next by binary search (#1757) * text: sort a paragraph's tab stops once and find the next by binary search * address review: skip the tab-stop lookup for a NaN position --- crates/text/src/breaking.rs | 41 ++++++++++++++++++++++++++----------- crates/text/src/tests.rs | 10 +++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/text/src/breaking.rs b/crates/text/src/breaking.rs index bb764b64c..9a6028876 100644 --- a/crates/text/src/breaking.rs +++ b/crates/text/src/breaking.rs @@ -260,12 +260,15 @@ pub(crate) struct Tab { align: TabAlign, } +/// The paragraph's tab stops in position order (a stop at no finite position is left out), so +/// [`tab_advance`] finds the next one by binary search instead of a scan per tab. pub(crate) fn tabs_of(props: &ParaProps) -> Vec { - props + let mut tabs: Vec = props .tabs .as_ref() .map(|t| { t.iter() + .filter(|s| s.pos.is_finite()) .map(|s| Tab { pos: s.pos, align: match s.align.as_str() { @@ -277,7 +280,9 @@ pub(crate) fn tabs_of(props: &ParaProps) -> Vec { }) .collect() }) - .unwrap_or_default() + .unwrap_or_default(); + tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + tabs } /// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of @@ -301,18 +306,30 @@ pub(crate) fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) { /// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never /// moving back, as PowerPoint does; past the last stop, to the next default stop. pub(crate) fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 { - for s in stops { - if s.pos > x + 0.5 { - let to = s.pos - x; - return match s.align { - TabAlign::Left => to, - TabAlign::Right => (to - seg.0).max(0.0), - TabAlign::Center => (to - seg.0 / 2.0).max(0.0), - TabAlign::Decimal => (to - seg.1).max(0.0), - }; - } + // `stops` is in position order (`tabs_of`): the next stop is the first past x. + // A NaN `x` (a NaN spacing upstream) skips the lookup and takes the finite default-stop path. + if let Some(s) = stops.get(stops.partition_point(|s| s.pos <= x + 0.5)).filter(|_| !x.is_nan()) { + let to = s.pos - x; + return match s.align { + TabAlign::Left => to, + TabAlign::Right => (to - seg.0).max(0.0), + TabAlign::Center => (to - seg.0 / 2.0).max(0.0), + TabAlign::Decimal => (to - seg.1).max(0.0), + }; } let d = if default_tab > 1.0 { default_tab } else { 72.0 }; let next = ((x / d).floor() + 1.0) * d; (next - x).max(1.0) } + +#[cfg(test)] +mod tab_tests { + use super::*; + + #[test] + fn a_nan_position_takes_the_finite_default_stop() { + let stops = [Tab { pos: 100.0, align: TabAlign::Left }]; + assert!(tab_advance(f64::NAN, &stops, 72.0, (0.0, 0.0)).is_finite()); + assert_eq!(tab_advance(10.0, &stops, 72.0, (0.0, 0.0)), 90.0); + } +} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 7dbdbe4f0..a747af045 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1416,6 +1416,16 @@ fn tab_stops_align_the_text_after_them() { assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01); } +#[test] +fn many_tabs_over_many_stops_lay_out_by_position() { + // 20,000 stops given out of order, and 2,000 tabs: each tab finds the next stop by position. + let mut stops: Vec<(f64, &str)> = (1..=20_000).rev().map(|k| (k as f64 * 0.05 + 30.0, "l")).collect(); + stops.push((f64::NAN, "l")); + let text = "a\t".repeat(2_000); + let l = tabbed(&text, &stops, 1.0e6); + assert!((l.lines[0].edges[2].0 - l.inner.x0 - 30.05).abs() < 0.01, "{:?}", &l.lines[0].edges[..3]); +} + #[test] fn a_tab_past_the_end_moves_to_the_next_line() { // PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next