From 4ee514b103df6388203287c36a72b18e7b6bf12f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:23:48 -0500 Subject: [PATCH] Layout: look at the text after a decimal tab once, not once per cluster Text after a decimal tab is shifted after each of its clusters, and each shift walked the text again from the tab to find its first '.'. A long run without a '.' (soft hyphens take no room, so it stays on one line) made a single paragraph's line breaking quadratic. The scan now carries on from the cluster it reached. --- crates/layout/src/para/lines.rs | 48 +++++++++++++++++++++++-------- crates/layout/src/tests/linear.rs | 22 ++++++++++++++ 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/crates/layout/src/para/lines.rs b/crates/layout/src/para/lines.rs index fac5c940a..20c3b31f7 100644 --- a/crates/layout/src/para/lines.rs +++ b/crates/layout/src/para/lines.rs @@ -90,6 +90,8 @@ struct Scan { j: usize, /// Tab cluster, stop, and x where the tab started. pending_tab: Option<(usize, TabStop, f32)>, + /// The text after a pending decimal tab looked at so far for its '.'. + decimal: Option, /// The width of the spaces so far on this line. space_w: f32, has_tab: bool, @@ -111,17 +113,15 @@ fn takes_room(c: &Cluster) -> bool { c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0) } -/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. -fn width_to_decimal(pl: &ParaLayout, tj: usize, j: usize) -> Option { - let mut w = 0.0; - for k in tj + 1..=j { - let cc = pl.clusters.get(k)?; - if pl_text_at(cc) { - return Some(w); - } - w += cc.adv; - } - None +/// How far the text after a decimal tab has been looked at for its first '.' (see +/// [`Scan::width_to_decimal`]): the tab, the next cluster to look at, the width before it, and +/// whether that cluster is the '.'. +#[derive(Clone, Copy, Default)] +struct DecimalScan { + tab: usize, + next: usize, + width: f32, + found: bool, } /// The objects' exclusions mirrored across a line of `width`, for a right-to-left paragraph. @@ -276,6 +276,7 @@ impl<'e> Breaker<'e> { end: LineEnd::Para, j: self.i, pending_tab: None, + decimal: None, space_w: 0.0, has_tab: false, skip: false, @@ -839,6 +840,29 @@ impl Scan { false } + /// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. The + /// text is shifted after each of its clusters, so the clusters already looked at are not + /// walked again: a long run without a '.' after a decimal tab is looked at once, not once + /// per cluster. + fn width_to_decimal(&mut self, pl: &ParaLayout, tj: usize, j: usize) -> Option { + let start = DecimalScan { tab: tj, next: tj + 1, width: 0.0, found: false }; + let d = self.decimal.get_or_insert(start); + if d.tab != tj || d.next > j.saturating_add(1) { + *d = start; + } + while !d.found && d.next <= j { + crate::work::steps(1); + let cc = pl.clusters.get(d.next)?; + if pl_text_at(cc) { + d.found = true; + } else { + d.width += cc.adv; + d.next += 1; + } + } + d.found.then_some(d.width) + } + /// Text after a decimal/centre/right tab shifts as it grows. fn shift_pending_tab(&mut self, pl: &mut ParaLayout) { let Some((tj, stop, tx)) = self.pending_tab else { return }; @@ -847,7 +871,7 @@ impl Scan { TabAlign::Right => stop.pos - tx - seg_w, TabAlign::Center => stop.pos - tx - seg_w / 2.0, // Width up to the first '.' in the segment. - TabAlign::Decimal => stop.pos - tx - width_to_decimal(pl, tj, self.j).unwrap_or(seg_w), + TabAlign::Decimal => stop.pos - tx - self.width_to_decimal(pl, tj, self.j).unwrap_or(seg_w), _ => 0.0, }; let shift = shift.max(0.0); diff --git a/crates/layout/src/tests/linear.rs b/crates/layout/src/tests/linear.rs index 993dcbedd..ec039a427 100644 --- a/crates/layout/src/tests/linear.rs +++ b/crates/layout/src/tests/linear.rs @@ -482,3 +482,25 @@ fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() { eprintln!("hinted header: {small} steps for 20 pages, {big} for 160"); assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}"); } + +/// A paragraph of a decimal tab at 200 pt followed by `text`. +fn after_decimal_tab(text: &str) -> Document { + use wordcraft_doc::props::{TabAlign, TabStop}; + let mut d = Document::from_text(&format!("\t{text}")); + let at = Pos::body(0, 0); + d.format_paragraphs(&at, &at, &|p| p.tabs = Some(vec![TabStop { pos: 200.0, align: TabAlign::Decimal, leader: Default::default() }])).unwrap(); + d +} + +/// The text after a decimal tab was looked at again from the tab for its '.' after each of its +/// clusters, so a long run of clusters without a '.' (soft hyphens take no room, so they stay on +/// one line) took quadratic time. +#[test] +fn text_after_a_decimal_tab_is_looked_at_once() { + let opts = LayoutOptions::default(); + // The text after the tab still ends at the stop, where it ended before. + let l = lay(&after_decimal_tab("12.5"), &opts); + let (Some(end), Some(start)) = (l.caret(&Pos::body(0, 5)), l.caret(&Pos::body(0, 0))) else { panic!("no carets") }; + assert!((end.x - start.x - 200.0).abs() < 1.0, "the text ends at the stop: {} from {}", end.x, start.x); + assert_layout_linear("decimal tab", 2000, |n| after_decimal_tab(&format!("1{}", "\u{AD}".repeat(n))), &opts); +}