Merge pull request #2487 from vangel76/fix/2457-tab-line-width

Type: a tab takes its width when area type breaks and justifies its lines (#2457)
This commit is contained in:
BFlat
2026-10-17 13:44:23 +07:00
committed by GitHub
2 changed files with 66 additions and 9 deletions
+33 -9
View File
@@ -1162,6 +1162,16 @@ fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, re
((at - rel).max(0.0), leader)
}
/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the
/// tab origin: tabs take what [`tab_advance`] gives them, not the width of their glyph.
fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 {
let mut x = x_start;
for (j, g) in sg.iter().enumerate().take(to).skip(from) {
x += if g.ch == '\t' { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv };
}
x - x_start
}
/// 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;
@@ -1223,12 +1233,16 @@ fn word_width(g: &[SGlyph], i: usize) -> f64 {
/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it.
/// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when
/// the line may hyphenate a word; a line whose text before the overflowing word already ends
/// within the zone breaks before that word.
/// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop
/// (`tabs`), with the line's content starting `x_start` from the tab origin.
#[allow(clippy::too_many_arguments)]
fn break_line(
text: &str,
g: &[SGlyph],
i: usize,
width: f64,
tabs: &[vectorcraft_doc::TabStop],
x_start: f64,
hyphenate: Option<(&HyphenationOptions, f64)>,
burasagari: Burasagari,
kinsoku: Kinsoku,
@@ -1245,7 +1259,11 @@ fn break_line(
while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte) {
cluster_end += 1;
}
let cluster_width: f64 = g[j..cluster_end].iter().map(|glyph| glyph.adv).sum();
let cluster_width: f64 = if gl.ch == '\t' {
tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0
} else {
g[j..cluster_end].iter().map(|glyph| glyph.adv).sum()
};
if j > i && !gl.is_space() && x + cluster_width > width + EPS {
// It ends the line with the spaces after it.
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) {
@@ -1390,7 +1408,7 @@ mod wrapping_tests {
let text = "بَت";
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard);
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
assert!(!hyphenated);
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
@@ -1403,11 +1421,11 @@ mod wrapping_tests {
let text = "بَتَث";
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard);
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
assert_eq!(first_end, 1);
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, None, Burasagari::None, Kinsoku::Hard);
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
assert!(!hyphenated);
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
@@ -1567,7 +1585,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
// A paragraph whose commas or full stops may hang is composed line by line (as the Japanese
// single-line composer does).
let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs);
if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang {
// A tab's width depends on where it falls in its line, which the composer's fixed glyph widths
// can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs).
let has_tab = sg.iter().any(|g| g.ch == '\t');
if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab {
return None;
}
let m = Metrics::of(&sg[0]);
@@ -1695,7 +1716,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((&para.hyphenation, zone));
let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) {
Some(&(e, h)) if e > i => (e, h),
_ if i < n => break_line(cx.text, &sg, i, width, hyphenate, para.burasagari, para.kinsoku),
_ if i < n => break_line(cx.text, &sg, i, width, &para.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku),
_ => (n, false),
};
let m = Metrics::max(&sg[i..end]).unwrap_or(pm);
@@ -1739,7 +1760,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
}
None => 0.0,
};
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
let w: f64 = set_width(&sg, i, trimmed, &para.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
// Burasagari: a comma or full stop ending an area type line hangs outside it (Standard:
// when it doesn't fit; Forced: always). The rest of the line is aligned and justified
// without it, and it follows the line's last character.
@@ -1816,6 +1837,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let mut x_end = start_x;
// Tab stops are measured from the frame's left edge (point type: the origin).
let tab_origin = if regions.is_some() { x0 } else { 0.0 };
// A line with tabs is measured as if it started at the left (see `set_width`), then
// aligned as a whole: its tabs stop where they were measured, however it is aligned.
let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l };
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]);
@@ -1833,7 +1857,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
}
let mut leader = "";
if g.ch == '\t' {
(adv, leader) = tab_advance(&para.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
(adv, leader) = tab_advance(&para.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]);
} else if j < body {
if g.is_space() {
adv += per_space;
+33
View File
@@ -486,6 +486,39 @@ fn tab_stops_position_text() {
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
}
#[test]
fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() {
// A tab at the start of area type is 36 pt wide: the line breaks and justifies around that,
// so no line reaches past the frame's right edge (#2457).
for justify in [Justify::Left, Justify::JustifyLeft] {
let t =
area("\tSample copy flows here while the layout takes shape. Swap these words", style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify);
let lay = layout(db(), &t);
let right = lay.glyphs.iter().filter(|g| !t.plain_text()[g.byte..].starts_with(' ')).map(|g| g.origin.x + g.advance).fold(0.0, f64::max);
assert!(right <= 200.0 + 1e-6, "{justify:?}: the type reaches {right}");
let first = lay.glyphs.iter().find(|g| g.byte == 1).map(|g| g.origin.x).unwrap();
assert!((first - 36.0).abs() < 1e-6, "{justify:?}: the text after the tab starts at {first}");
}
}
#[test]
fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() {
use vectorcraft_doc::{TabAlign, TabStop};
// "A<tab>B" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line
// moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457).
for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] {
let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify);
t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
let lay = layout(db(), &t);
let at = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap();
assert!((at(2) - at(0) - 100.0).abs() < 1e-6, "{justify:?}: A at {}, B at {}", at(0), at(2));
if matches!(justify, Justify::Right | Justify::JustifyRight) {
let b = lay.glyphs.iter().find(|g| g.byte == 2).unwrap();
assert!((b.origin.x + b.advance - 200.0).abs() < 1e-6, "{justify:?}: the line ends at {}", b.origin.x + b.advance);
}
}
}
#[test]
fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() {
use vectorcraft_doc::{TabAlign, TabStop};