From 26c77228b37aa726db2f219081458e37a17a3506 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:06:28 -0800 Subject: [PATCH 1/4] Type: a tabbed right-to-left line with negative tracking stops reading after a bounded number of breaks Once a right-to-left line holds a tab and a glyph with a negative advance, the least it can be drawn at no longer grows with its tab stops, so a line that overflowed in the order of its text read on to the end of the paragraph before settling its break. Setting every line of a long paragraph that way took time quadratic in its length. Such a line now reads at most 256 legal breaks past where it first overflows in the order of its text, and ends at the latest break read that fits as drawn, the same limit the breaker uses for breaks it measures one by one. Lines without negative advances, and lines that fit, keep their breaks. --- crates/text/src/layout/breaking.rs | 21 ++++++- crates/text/src/layout/wrapping_tests.rs | 73 ++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 671ebee7a1..060a412aa4 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -253,7 +253,8 @@ pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_do } /// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one from the latest -/// back before bisecting the earlier ones. +/// back before bisecting the earlier ones, and most legal breaks a line with a glyph stepping back +/// reads past where it first overflows in the order of its text. const MAX_DRAWN_CHECKS: usize = 256; /// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make @@ -358,7 +359,10 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// 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. +/// hyphen are bisected); hyphenation points are measured the same way. Once such a line holds a +/// glyph with a negative advance and has been wider than `width` in the order of its text, it reads +/// at most [`MAX_DRAWN_CHECKS`] legal breaks further and ends at the latest of the breaks read that +/// fits as drawn. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -426,6 +430,11 @@ pub(super) fn break_line( } }; let mut last_break: Option<(usize, bool)> = None; + // Whether the line, once it holds a tab and is measured as drawn, has been wider in the order of + // its text than `width`, and how many legal breaks it has read since then with a glyph stepping + // back: past MAX_DRAWN_CHECKS of them the line stops reading. + let mut overflowed = false; + let mut breaks_past_overflow = 0usize; // A list marker (length 0) is a cluster of its own; the line holds it and at least the first // cluster after it. let first = i + g.get(i..).unwrap_or_default().iter().take_while(|h| h.len == 0).count(); @@ -476,6 +485,7 @@ pub(super) fn break_line( // there are tabs. let stops_reached = if back + cluster_back > 0.0 { f64::NEG_INFINITY } else { nth_stop(tabs_read) - x_start }; let measured = if tabbed && fits.is_some() { (ink + cluster_ink).max(stops_reached) } else { x + cluster_width }; + overflowed |= tabbed && fits.is_some() && j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS; if j > first && !gl.is_space() && measured - end_credit > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { @@ -504,6 +514,13 @@ pub(super) fn break_line( if fits.is_some() { breaks.push((cluster_end, hyphenated)); } + // A line with a glyph stepping back stops reading MAX_DRAWN_CHECKS breaks past where + // it first overflowed in the order of its text, and ends at the latest break read + // that fits as drawn. + breaks_past_overflow += usize::from(overflowed && back > 0.0); + if breaks_past_overflow >= MAX_DRAWN_CHECKS { + return settle(tabbed, (cluster_end, hyphenated), &breaks); + } } } j = cluster_end; diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index eae22bab8a..4001496c19 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -646,6 +646,79 @@ fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() { assert_eq!(ended, (5, false)); } +/// A tabbed line of glyphs with negative advances, past the frame in the order of its text: `a`, +/// a tab to the default stop at 36 pt, `y` stepping back 1 pt, then `n` times `b` (1 pt), `z` +/// (−1 pt) and a space, so its glyphs other than the tab never fill the frame. Its breaks end the +/// line before glyph 2 and before 6 + 3u for each u. +fn stepping_back_tabbed_line(n: usize) -> (String, Vec) { + let text = format!("a\ty{}", "bz ".repeat(n)); + let specs: Vec<_> = text + .char_indices() + .map(|(byte, ch)| { + let adv = match ch { + 'y' | 'z' => -1.0, + ' ' => 0.0, + _ => 1.0, + }; + (byte, ch.len_utf8(), adv, ch) + }) + .collect(); + let glyphs = synthetic_glyphs(&specs); + (text, glyphs) +} + +/// A tabbed line with glyphs stepping back stops reading a bounded number of breaks past where it +/// overflows in the order of its text: a long paragraph is not read to its end to set one line. +#[test] +fn a_tabbed_line_stepping_back_stops_reading_past_its_overflow() { + let (text, glyphs) = stepping_back_tabbed_line(2000); + // The latest break the line reads is the first it measures as drawn. + let furthest = std::cell::Cell::new(0); + let measured = std::cell::Cell::new(0usize); + let drawn_fits = |end: usize, _hyphenated: bool| { + furthest.set(furthest.get().max(end)); + measured.set(measured.get() + 1); + end <= 10 + }; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 30.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (9, false)); + // 256 breaks past the overflow at `y`: the break before glyph 6 + 3 × 255. + assert_eq!(furthest.get(), 771, "read up to glyph {} of {}", furthest.get(), glyphs.len()); + assert!(measured.get() <= 300, "measured {} breaks as drawn", measured.get()); +} + +/// Where a tabbed line with glyphs stepping back stops reading, it ends at the latest break read +/// that fits as drawn, even when a break past those fits too. +#[test] +fn a_tabbed_line_stepping_back_ends_at_the_latest_break_read_that_fits() { + let (text, glyphs) = stepping_back_tabbed_line(400); + let end = |drawn_fits: &dyn Fn(usize, bool) -> bool| { + break_line( + &text, + &glyphs, + 0, + 30.0, + &[], + 0.0, + None, + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + Some(drawn_fits), + ) + }; + // The break before glyph 1101 is past the 256 breaks read after the overflow (up to 771). + assert_eq!(end(&|end: usize, _: bool| end <= 10 || end == 1101), (9, false)); + // A break among those read keeps its place. + assert_eq!(end(&|end: usize, _: bool| end <= 10 || end == 600), (600, false)); + assert_eq!(end(&|end: usize, _: bool| end <= 10 || end == 771), (771, false)); +} + /// 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..0d94781939 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -46,6 +46,7 @@ The shared assessment status line and revision history are current upstream; thi - File formats and Type core (#6147): `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text before a line's last tab at its stops, from the left edge, and the text after it against the right edge. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`; scores and estimates unchanged - Type core: a right-to-left line with tabs is measured in the order it is drawn, with tab stops still measured from the left edge. Each tab goes to its stop from where the glyphs drawn before it end, and right, center and decimal stops align the text drawn after the tab. Right, Auto and centered lines are aligned by that width, and area type wraps a line that is too wide when drawn. Evidence: `tests::justify::a_right_to_left_line_with_a_tab_is_aligned_as_it_is_drawn`, `tests::justify::a_right_to_left_line_with_a_tab_wraps_when_it_does_not_fit_as_drawn`, `tests::justify::right_to_left_tab_stops_align_the_text_drawn_after_them`, `tests::justify::a_right_to_left_line_with_two_tabs_and_a_latin_run_ends_on_the_right_edge`, `tests::justify::right_aligned_right_to_left_point_type_with_a_tab_ends_at_its_origin`; scores and estimates unchanged +- Type core: a right-to-left line with tabs and negative tracking that is wider than its frame in the order of its text reads at most 256 line breaks further and ends at the latest of them that fits as drawn, so a long paragraph is not read to its end to set each line. This is VectorCraft's own limit. Evidence: `layout::wrapping_tests::a_tabbed_line_stepping_back_stops_reading_past_its_overflow`, `layout::wrapping_tests::a_tabbed_line_stepping_back_ends_at_the_latest_break_read_that_fits`; scores and estimates unchanged - File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) From 54f4c256eb0b9f1bf04cb38f66bea86113d55d36 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:49:21 -0800 Subject: [PATCH 2/4] Type: a tabbed right-to-left line ending at a final soft hyphen draws no hyphen Where a tabbed line with glyphs stepping back stops reading at its break limit on a soft hyphen that ends the text, it now ends there without a hyphen, as a line that reads to the end of the text does. --- crates/text/src/layout/breaking.rs | 3 ++- crates/text/src/layout/wrapping_tests.rs | 26 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 16ac9fc6c6..4b89f79ae6 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -525,9 +525,10 @@ pub(super) fn break_line( // A line with a glyph stepping back stops reading MAX_DRAWN_CHECKS_STEPPING_BACK // breaks past where it first overflowed in the order of its text, and ends at the // latest break read that fits as drawn: each of those breaks is measured one by one. + // A soft hyphen ending the text draws no hyphen there, as at the end of the text below. breaks_past_overflow += usize::from(overflowed && back > 0.0); if breaks_past_overflow >= MAX_DRAWN_CHECKS_STEPPING_BACK { - return settle(tabbed, checks(back), (cluster_end, hyphenated), &breaks); + return settle(tabbed, checks(back), (cluster_end, hyphenated && cluster_end < g.len()), &breaks); } } } diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index e27a77828f..be089b4434 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -719,6 +719,32 @@ fn a_tabbed_line_stepping_back_ends_at_the_latest_break_read_that_fits() { assert_eq!(end(&|end: usize, _: bool| end <= 10 || end == 3075), (3075, false)); } +/// A tabbed line with glyphs stepping back that stops reading at a soft hyphen ending the text ends +/// there without a hyphen, as a line that reads to the end of the text does. +#[test] +fn a_tabbed_line_stepping_back_ends_at_a_final_soft_hyphen_without_a_hyphen() { + // The 1,023 spaces and the soft hyphen are the 1,024 breaks the line reads past its overflow. + let text = format!("a\ty{}b\u{00AD}", "bz ".repeat(1023)); + let specs: Vec<_> = text + .char_indices() + .map(|(byte, ch)| { + let adv = match ch { + 'y' | 'z' => -1.0, + ' ' | '\u{00AD}' => 0.0, + _ => 1.0, + }; + (byte, ch.len_utf8(), adv, ch) + }) + .collect(); + let glyphs = synthetic_glyphs(&specs); + let len = glyphs.len(); + let drawn_fits = |end: usize, _hyphenated: bool| end <= 10 || end == len; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 30.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (len, 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] From 2b781525bb926d7c499346debc72dd76f1f55684 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:50:18 -0800 Subject: [PATCH 3/4] Type: a tab to a default or left stop reads none of the text after it tab_advance read the text after a tab up to the next tab for every stop, though only right, center and decimal stops align it. The first tab of a long paragraph read the paragraph to its end, even on a line that stops reading a bounded number of breaks past its overflow. It now reads that text only when it reaches a stop that aligns it. --- crates/text/src/layout/breaking.rs | 36 ++++++++++++++++-------- crates/text/src/layout/wrapping_tests.rs | 18 ++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 4b89f79ae6..cfe5fa0eb6 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -214,18 +214,30 @@ pub(super) fn tab_advance<'t, 'g>( ) -> (f64, &'t str) { use vectorcraft_doc::TabAlign; let rel = x - origin; - let seg: Vec<&SGlyph> = rest.into_iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect(); - let width: f64 = seg.iter().map(|g| g.adv).sum(); - let target = |pos: f64, align: TabAlign, on: char| match align { - TabAlign::Left => pos, - TabAlign::Right => pos - width, - TabAlign::Center => pos - width / 2.0, - TabAlign::Decimal => pos - seg.iter().take_while(|g| g.ch != on).map(|g| g.adv).sum::(), - }; - let explicit = tabs.iter().find_map(|t| { - let at = target(t.position, t.align, t.align_on); - (at > rel + 1e-6).then_some((at, t.leader.as_str())) - }); + // The text after the tab is read only when a right, center or decimal stop is reached, since + // only those align it. A tab to a default or left stop reads none of it. + let mut rest = Some(rest); + let mut seg: Vec<&SGlyph> = Vec::new(); + let mut width = 0.0; + let mut explicit = None; + for t in tabs { + if t.align != TabAlign::Left + && let Some(r) = rest.take() + { + seg = r.into_iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect(); + width = seg.iter().map(|g| g.adv).sum(); + } + let at = match t.align { + TabAlign::Left => t.position, + TabAlign::Right => t.position - width, + TabAlign::Center => t.position - width / 2.0, + TabAlign::Decimal => t.position - seg.iter().take_while(|g| g.ch != t.align_on).map(|g| g.adv).sum::(), + }; + if at > rel + 1e-6 { + explicit = Some((at, t.leader.as_str())); + break; + } + } let (at, leader) = explicit.unwrap_or_else(|| { let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0); let base = rel.max(last); diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index be089b4434..89d40386b5 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -745,6 +745,24 @@ fn a_tabbed_line_stepping_back_ends_at_a_final_soft_hyphen_without_a_hyphen() { assert_eq!(ended, (len, false)); } +/// A tab to a default or left stop does not read the text after it, which only right, center and +/// decimal stops align: the first tab of a long paragraph does not read the paragraph to its end. +#[test] +fn a_tab_to_a_default_or_left_stop_reads_no_text_after_it() { + use vectorcraft_doc::{TabAlign, TabStop}; + let (_, glyphs) = stepping_back_tabbed_line(2000); + let read = std::cell::Cell::new(0usize); + let rest = || glyphs[2..].iter().inspect(|_| read.set(read.get() + 1)); + let left = [TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + assert_eq!(tab_advance(&[], 0.0, 1.0, rest()).0, 35.0); + assert_eq!(tab_advance(&left, 0.0, 1.0, rest()).0, 99.0); + assert_eq!(read.get(), 0, "read {} glyphs after the tab", read.get()); + // A right stop reads up to the next tab and aligns that text on the stop. + let right = [TabStop { position: 100.0, align: TabAlign::Right, leader: String::new(), align_on: '.' }]; + let short = synthetic_glyphs(&[(0, 1, 2.0, 'b'), (1, 1, 3.0, 'b'), (2, 1, 0.0, '\t'), (3, 1, 7.0, 'b')]); + assert_eq!(tab_advance(&right, 0.0, 1.0, short.iter()).0, 94.0); +} + /// 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] From 54a7b51ec945e57be6a231e2963e14620321a1f3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:02:03 -0800 Subject: [PATCH 4/4] Type: a tabbed right-to-left line whose last break read precedes a forced line break ends after it When the last of the bounded breaks such a line reads came right before U+2028, the line ended at that break and the next line held only the forced break, drawing an empty line. The line now reads on to the forced break and ends there when it fits as drawn. --- crates/text/src/layout/breaking.rs | 8 +++++--- crates/text/src/layout/wrapping_tests.rs | 26 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index cfe5fa0eb6..989ef9ea95 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -379,8 +379,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// [`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. Once such a /// line holds a glyph with a negative advance and has been wider than `width` in the order of its -/// text, it reads at most [`MAX_DRAWN_CHECKS_STEPPING_BACK`] legal breaks further and ends at the -/// latest of the breaks read that fits as drawn. +/// text, it reads at most [`MAX_DRAWN_CHECKS_STEPPING_BACK`] legal breaks further, and a forced +/// line break right after the last of them, and ends at the latest of the breaks read that fits as +/// drawn. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -538,8 +539,9 @@ pub(super) fn break_line( // breaks past where it first overflowed in the order of its text, and ends at the // latest break read that fits as drawn: each of those breaks is measured one by one. // A soft hyphen ending the text draws no hyphen there, as at the end of the text below. + // A break right before a forced line break reads on to it: the line ends there. breaks_past_overflow += usize::from(overflowed && back > 0.0); - if breaks_past_overflow >= MAX_DRAWN_CHECKS_STEPPING_BACK { + if breaks_past_overflow >= MAX_DRAWN_CHECKS_STEPPING_BACK && g.get(cluster_end).is_none_or(|n| n.ch != '\u{2028}') { return settle(tabbed, checks(back), (cluster_end, hyphenated && cluster_end < g.len()), &breaks); } } diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 89d40386b5..7dd05a9357 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -745,6 +745,32 @@ fn a_tabbed_line_stepping_back_ends_at_a_final_soft_hyphen_without_a_hyphen() { assert_eq!(ended, (len, false)); } +/// A tabbed line with glyphs stepping back whose last break read comes right before a forced line +/// break ends after the forced break, so the next line does not start with it and draw empty. +#[test] +fn a_tabbed_line_stepping_back_keeps_a_forced_break_after_its_last_break_read() { + // The 1,024th space is the 1,024th break past the overflow, and glyph 3075 after it is U+2028. + let text = format!("a\ty{}\u{2028}bbb", "bz ".repeat(1024)); + let specs: Vec<_> = text + .char_indices() + .map(|(byte, ch)| { + let adv = match ch { + 'y' | 'z' => -1.0, + ' ' | '\u{2028}' => 0.0, + _ => 1.0, + }; + (byte, ch.len_utf8(), adv, ch) + }) + .collect(); + let glyphs = synthetic_glyphs(&specs); + assert_eq!(glyphs[3075].ch, '\u{2028}'); + let drawn_fits = |end: usize, _hyphenated: bool| end <= 10 || end >= 3075; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 30.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (3076, false)); +} + /// A tab to a default or left stop does not read the text after it, which only right, center and /// decimal stops align: the first tab of a long paragraph does not read the paragraph to its end. #[test]