From bd8bc96cb530ca9d592fc7a29ba546340a0b0a8d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 7 Nov 2026 22:15:42 -0500 Subject: [PATCH] Layout: look at the text after a decimal tab once, not once per cluster (#5343) * 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. * address review: layout progress doc for decimal-tab scan --------- Co-authored-by: Martin Ball --- crates/layout/src/para/lines.rs | 48 +++++++++++++++++++++++-------- crates/layout/src/tests/linear.rs | 22 ++++++++++++++ docs/layout-parity.md | 1 + 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/crates/layout/src/para/lines.rs b/crates/layout/src/para/lines.rs index f29a7029f9..821132f6aa 100644 --- a/crates/layout/src/para/lines.rs +++ b/crates/layout/src/para/lines.rs @@ -92,6 +92,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, @@ -113,17 +115,15 @@ pub(crate) 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. @@ -279,6 +279,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, @@ -917,6 +918,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 }; @@ -925,7 +949,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 993dcbedd1..ec039a4274 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); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 5546c79f88..9b61aa351f 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -315,6 +315,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-01 | trivial | SEQ fields in text boxes are numbered where their boxes are anchored, in output and after F9, and a REF finds a bookmark in a text box | | 2026-11-01 | trivial | SEQ, STYLEREF and REF fields show fresh results in output; readiness estimates unchanged | | 2026-10-29 | trivial | Print Markup balloon anchor discovery walks paragraph objects once (#3614); readiness estimates unchanged | +| 2026-11-03 | trivial | A decimal tab scans the text after it once, not once per cluster. Separate from the #3412 word-measurement fix | | 2026-10-30 | trivial | Layout work bounds for hostile documents (#3867): header-row repeats budgeted per layout and counting canvas members; picture-outline decode budget charged for cached outlines too, so cold and warm layouts wrap alike; linked-box chains keep exact widths up to a work cap, not 16 widths; `gridAfter` bounded | | 2026-10-29 | trivial | A missing font is drawn in an installed metric twin before any other substitute (`fonts::word::METRIC_TWINS`, also what the missing-font warning calls metric-compatible): Arial for Helvetica on Windows, Arial for Liberation Sans on a Mac, Tinos before macOS Times for Times New Roman, Cousine before Menlo for Courier New. Documents in fonts with no installed twin lay out as before | | 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. |