From 6b54622e721b015b5f355431ffd37c0dbce98523 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:50:50 -0800 Subject: [PATCH] Type: a tabbed line that steps back ends at the latest break that fits as drawn A glyph with a negative advance can draw a later break narrower than an earlier one, so bisecting the breaks past the 256 measured one by one could end a tabbed right-to-left line at an earlier break than the latest that fits. Such a line now measures up to 1,024 breaks, or hyphenation points, one by one before bisecting the rest. --- crates/text/src/layout/breaking.rs | 37 +++++++++++------- crates/text/src/layout/wrapping_tests.rs | 49 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 671ebee7a1..9e4cb10e36 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -256,6 +256,11 @@ pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_do /// back before bisecting the earlier ones. const MAX_DRAWN_CHECKS: usize = 256; +/// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one when a glyph of +/// the line has a negative advance: a later break can then be drawn narrower than an earlier one, +/// and bisecting can miss the latest break that fits. +const MAX_DRAWN_CHECKS_STEPPING_BACK: usize = 1024; + /// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make /// millions of outlines. const MAX_LEADER_COPIES: f64 = 4096.0; @@ -357,8 +362,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// from `i` ending before glyph `end` (with a hyphen when `hyphenated`) fits `width` as it is drawn: /// once the line holds a tab, whose width depends on what is drawn before it, the line reads on /// while the least it can be drawn at fits `width`, then ends at the latest legal break where it -/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, the earlier ones without a -/// hyphen are bisected); hyphenation points are measured the same way. +/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, or +/// [`MAX_DRAWN_CHECKS_STEPPING_BACK`] when a glyph of the line has a negative advance, the earlier +/// ones without a hyphen are bisected); hyphenation points are measured the same way. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -377,24 +383,26 @@ pub(super) fn break_line( fits: Option<&dyn Fn(usize, bool) -> bool>, ) -> (usize, bool) { // The line ending at `chosen`, once it holds a tab, when it fits as drawn; otherwise the latest - // of the legal `breaks` before it that fits as drawn, measuring at most MAX_DRAWN_CHECKS of - // them from the latest back and bisecting the earlier ones without a hyphen; the first of them - // when none of those measured fits. - let settle = |tabbed: bool, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) { + // of the legal `breaks` before it that fits as drawn, measuring at most `checks` of them from + // the latest back and bisecting the earlier ones without a hyphen; the first of them when none + // of those measured fits. + let settle = |tabbed: bool, checks: usize, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) { let Some(fits) = fits.filter(|_| tabbed) else { return chosen }; if fits(chosen.0, chosen.1) { return chosen; } let earlier = breaks.get(breaks.partition_point(|b| b.0 <= i)..breaks.partition_point(|b| b.0 < chosen.0)).unwrap_or_default(); - if let Some(&b) = earlier.iter().rev().take(MAX_DRAWN_CHECKS).find(|b| fits(b.0, b.1)) { + if let Some(&b) = earlier.iter().rev().take(checks).find(|b| fits(b.0, b.1)) { return b; } // Without a hyphen, a line that ends later is drawn no narrower, so those breaks bisect. - let rest = earlier.get(..earlier.len().saturating_sub(MAX_DRAWN_CHECKS)).unwrap_or_default(); + let rest = earlier.get(..earlier.len().saturating_sub(checks)).unwrap_or_default(); let plain: Vec<&(usize, bool)> = rest.iter().filter(|b| !b.1).collect(); let fitting = plain.partition_point(|b| fits(b.0, b.1)); fitting.checked_sub(1).and_then(|k| plain.get(k).copied()).or(earlier.first()).copied().unwrap_or(chosen) }; + // How many breaks a line measures as drawn one by one. + let checks = |back: f64| if back > 0.0 { MAX_DRAWN_CHECKS_STEPPING_BACK } else { MAX_DRAWN_CHECKS }; let mut breaks: Vec<(usize, bool)> = Vec::new(); let mut tabbed = false; if !width.is_finite() { @@ -462,7 +470,7 @@ pub(super) fn break_line( let cluster_ink = if is_tab { 0.0 } else { cluster_width }; let cluster_back: f64 = if is_tab { 0.0 } else { g[j..cluster_end].iter().map(|glyph| (-glyph.adv).max(0.0)).sum() }; if gl.ch == '\u{2028}' { - return settle(tabbed, (cluster_end, false), &breaks); + return settle(tabbed, checks(back), (cluster_end, false), &breaks); } // Measured as the line is set when it ends with this cluster: only the cluster's last glyph // is narrowed, and only where the line may end. @@ -509,7 +517,7 @@ pub(super) fn break_line( j = cluster_end; } if j >= g.len() { - return settle(tabbed, (g.len(), false), &breaks); + return settle(tabbed, checks(back), (g.len(), false), &breaks); } // Hyphenate the word that overflows. A list marker starting the line is not part of a word. let word_start = last_break.map_or(first, |b| b.0); @@ -534,18 +542,19 @@ pub(super) fn break_line( .filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) .collect(); // The points the line may end at, the latest first. Measured as drawn, at most - // MAX_DRAWN_CHECKS of them are measured one by one, and the earlier ones are bisected: + // `checks(back)` of them are measured one by one, and the earlier ones are bisected: // each ends with a hyphen, so a later one is drawn no narrower. let points: Vec = pts.iter().rev().copied().filter(|&k| k <= j && k > i).collect(); let drawn = fits.filter(|_| tabbed); - for &k in points.iter().take(if drawn.is_some() { MAX_DRAWN_CHECKS } else { usize::MAX }) { + let one_by_one = checks(back); + for &k in points.iter().take(if drawn.is_some() { one_by_one } else { usize::MAX }) { let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; if drawn.map_or(w <= width + EPS, |fits| fits(k, true)) { return (k, true); } } if let Some(fits) = drawn { - let earlier: Vec = points.iter().skip(MAX_DRAWN_CHECKS).rev().copied().collect(); + let earlier: Vec = points.iter().skip(one_by_one).rev().copied().collect(); let fitting = earlier.partition_point(|&k| fits(k, true)); if let Some(&k) = fitting.checked_sub(1).and_then(|n| earlier.get(n)) { return (k, true); @@ -555,7 +564,7 @@ pub(super) fn break_line( // Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only // cluster starts, so the resulting end always keeps each source cluster together. match last_break { - Some(b) if b.0 > i => settle(tabbed, b, &breaks), + Some(b) if b.0 > i => settle(tabbed, checks(back), b, &breaks), _ => (j, false), } } diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index eae22bab8a..a8630aad5e 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -646,6 +646,55 @@ fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() { assert_eq!(ended, (5, false)); } +/// A long tabbed line with a glyph that steps back ends at the latest break that fits as drawn, even +/// where a later break than one that does not fit fits again. +#[test] +fn a_long_tabbed_line_that_steps_back_ends_at_the_latest_break_that_fits_as_drawn() { + // a (0), tab (1), then b and a space for each u: breaks before 4 + 2u. + let text = format!("a\t{}", "b ".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -0.5 } else { 1.0 }, ch)).collect(); + let glyphs = synthetic_glyphs(&specs); + let drawn_fits = |end: usize, _hyphenated: bool| end <= 10 || end == 200; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 1e6, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (200, false)); +} + +/// A tabbed line with a glyph that steps back, ending in a word with more hyphenation points than +/// are measured one by one, ends at the latest point that fits as drawn. +#[test] +fn a_tabbed_line_that_steps_back_ends_at_the_latest_hyphenation_point_that_fits_as_drawn() { + let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() }; + // a (0), tab (1), then a word of 400 syllables of two glyphs from glyph 2: points before 4, 6, ... + let text = format!("a\t{}", "कि".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -1.0 } else { 1.0 }, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + for glyph in &mut glyphs { + glyph.hyphen_language = crate::hyphen::Language::from_tag(Some("hi")); + glyph.sx = 0.001; + } + let drawn_fits = |end: usize, _hyphenated: bool| end <= 20 || end == 50; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = break_line( + &text, + &glyphs, + 0, + 600.0, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + fits, + ); + assert_eq!(ended, (50, true)); +} + /// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed /// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last /// glyph of the last cluster counts, and only where the line may end.