From 101fd55fa009862a2c7f580fcc1202b1527db108 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:11:01 +0200 Subject: [PATCH 1/2] Type: a tab takes its width when area type breaks and justifies its lines (#2457) Line breaking, the line's natural width (and so justification) and the composer counted a tab as its glyph's width, a space, while it is set up to its next tab stop. A paragraph starting with a tab, as in the report, filled a justified line as if the tab were one space, and the line ran past the frame. break_line and the line width now measure tabs with tab_advance, and a paragraph with a tab is broken line by line, since the composer works with fixed glyph widths. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 37 +++++++++++++++++++++++++++++-------- crates/text/src/tests.rs | 15 +++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c56e0480d..0559730e2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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], t: &TextObject, regions: Optio let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.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, ¶.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], t: &TextObject, regions: Optio } None => 0.0, }; - let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; + let w: f64 = set_width(&sg, i, trimmed, ¶.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. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d7c52af75..41718cf3d 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -486,6 +486,21 @@ 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 tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() { use vectorcraft_doc::{TabAlign, TabStop}; From 285a3da81a21ca781526fb8b870667f1ccf3ef87 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:12:40 +0200 Subject: [PATCH 2/2] Type: centre and right aligned lines set their tabs as if they started at the left (#2457) A tab's width depends on where it falls from the tab origin. Line breaking and the line width measure it from the left edge, but a centred or right aligned line is placed from its own start, so its tabs stopped elsewhere. The line is now laid out with its tabs measured from the left and then aligned as a whole. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 5 ++++- crates/text/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0559730e2..5b0f200f9 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1837,6 +1837,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], 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]); @@ -1854,7 +1857,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } let mut leader = ""; if g.ch == '\t' { - (adv, leader) = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]); + (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); } else if j < body { if g.is_space() { adv += per_space; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 41718cf3d..03a0ec18b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -501,6 +501,24 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { } } +#[test] +fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() { + use vectorcraft_doc::{TabAlign, TabStop}; + // "AB" 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};