Merge pull request #7555 from dotCooCoo/fix/tabbed-rtl-negative-tracking-break-bound

Type: a tabbed right-to-left line with negative tracking stops reading after a bounded number of breaks
This commit is contained in:
BFlat
2026-11-15 05:28:48 +07:00
committed by GitHub
3 changed files with 190 additions and 14 deletions
+46 -14
View File
@@ -244,18 +244,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::<f64>(),
};
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::<f64>(),
};
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);
@@ -293,7 +305,8 @@ 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.
/// and bisecting can miss the latest break that fits. It is also the most legal breaks such a line
/// reads past where it first overflows in the order of its text.
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
@@ -399,7 +412,11 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins
/// 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, 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.
/// 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 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,
@@ -469,6 +486,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_STEPPING_BACK 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();
@@ -519,6 +541,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) {
@@ -547,6 +570,15 @@ 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_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.
// 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 && 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);
}
}
}
j = cluster_end;
+143
View File
@@ -646,6 +646,149 @@ 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<SGlyph>) {
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));
// 1,024 breaks past the overflow at `y`: the break before glyph 6 + 3 × 1,023.
assert_eq!(furthest.get(), 3075, "read up to glyph {} of {}", furthest.get(), glyphs.len());
assert!(measured.get() <= 1100, "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(1500);
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 4002 is past the 1,024 breaks read after the overflow (up to 3075).
assert_eq!(end(&|end: usize, _: bool| end <= 10 || end == 4002), (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 == 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 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]
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]
+1
View File
@@ -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 1,024 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)