Merge #284: Text: a justified line with a tab justifies the text after its last tab

Conflict: crates/compose/src/lib.rs: tab loop keeps #282's tab_advance and records #284's last tab (no stop = default left tab); squeeze_last keeps next's mojikumi condition with #284's seg_justifies; single-word gaps keep next's moji filter after #284's skip(seg)
Conflict: crates/compose/src/tests.rs: kept both
This commit is contained in:
aiongg
2026-10-10 03:13:36 +00:00
2 changed files with 66 additions and 8 deletions
+26 -8
View File
@@ -2217,6 +2217,8 @@ fn layout_line(
let mut x = 0.0;
let mut i = 0;
let mut leaders: Vec<(usize, String)> = Vec::new();
// The line's last tab and whether it is a left tab (only text after a left tab justifies).
let mut last_tab: Option<(usize, bool)> = None;
while i < line.len() {
if line[i].ch == '\t' {
let (w, stop) = tab_advance(&pp.tabs, x0 + x - tab_origin, line.get(i + 1..).unwrap_or_default());
@@ -2226,9 +2228,11 @@ fn layout_line(
leaders.push((i, t.leader.clone()));
}
line[i].adv = w;
last_tab = Some((i, stop.is_none_or(|t| t.align == TabAlign::Left)));
} else if line[i].ch == story::RIGHT_INDENT_TAB {
let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum();
line[i].adv = (measure - x - rest).max(0.0);
last_tab = Some((i, false));
}
x += line[i].adv;
i += 1;
@@ -2253,7 +2257,11 @@ fn layout_line(
}
let natural: f64 = line.iter().map(|g| g.adv).sum();
let mut extra = measure - natural;
let spaces: Vec<usize> = line.iter().enumerate().filter(|(_, g)| g.is_space() && !g.no_break).map(|(i, _)| i).collect();
// Justification adjusts only the text after the last tab (InDesign keeps tab stops aligned);
// a line whose last tab is a right, centre, character or right-indent tab is not justified.
let seg = last_tab.map_or(0, |(i, _)| i + 1);
let seg_justifies = last_tab.is_none_or(|(_, left)| left);
let spaces: Vec<usize> = line.iter().enumerate().skip(seg).filter(|(_, g)| g.is_space() && !g.no_break).map(|(i, _)| i).collect();
let align = match pp.align {
Align::TowardsSpine => {
if left_page {
@@ -2271,10 +2279,10 @@ fn layout_line(
}
a => a,
};
let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && !has_tab;
let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && seg_justifies;
// A justified paragraph's last line may have been composed with shrunk spaces: shrink it too.
let squeeze_last = !justify_this
&& !has_tab
&& seg_justifies
&& extra < 0.0
&& (align.is_justified() || line.iter().any(|g| g.moji.shrink > 0.0 || mojikumi::start_elastic(g, false, true)[1] > 0.0));
// Extra advance per glyph (word spaces and letter gaps) and horizontal scale per glyph.
@@ -2284,7 +2292,7 @@ fn layout_line(
// Kashidas: in justified Arabic, the joins of words take the extra length first.
let mut kashidas: Vec<(usize, f64)> = Vec::new();
if justify_this && extra > 0.0 && pp.kashidas && pp.arabic_justification != "DefaultJustification" {
let points = kashida_points(&line);
let points: Vec<usize> = kashida_points(&line).into_iter().filter(|&i| i >= seg).collect();
if !points.is_empty() {
let total = extra.min(points.iter().map(|&i| line[i].size * 1.5).sum());
let per = total / points.len() as f64;
@@ -2299,8 +2307,11 @@ fn layout_line(
extra = mojikumi::distribute(&mut line, extra, &mut add);
}
if (justify_this || squeeze_last) && !spaces.is_empty() {
distribute(&line, &spaces, extra, sp, &mut add, &mut scale);
} else if justify_this && spaces.is_empty() && line.len() > 1 && !last {
let rebased: Vec<usize> = spaces.iter().map(|&i| i - seg).collect();
let (seg_line, seg_add, seg_scale) =
(line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut []));
distribute(seg_line, &rebased, extra, sp, seg_add, seg_scale);
} else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last {
// Single word: Single Word Justification.
match pp.single_word_justify {
Align::FullyJustified => {
@@ -2308,6 +2319,7 @@ fn layout_line(
.iter()
.enumerate()
.take(line.len() - 1)
.skip(seg)
.filter(|(_, g)| !g.moji.active && (!g.locked_advance || g.break_after != Some(false)))
.map(|(i, _)| i)
.collect();
@@ -2318,8 +2330,14 @@ fn layout_line(
}
}
}
Align::Center => offset = extra / 2.0,
Align::Right => offset = extra,
// After a tab the word moves by widening the tab, so text before it stays put.
Align::Center | Align::Right => {
let shift = if pp.single_word_justify == Align::Center { extra / 2.0 } else { extra };
match seg.checked_sub(1).and_then(|t| add.get_mut(t)) {
Some(tab) => *tab += shift,
None => offset = shift,
}
}
_ => {}
}
} else {
+40
View File
@@ -3962,3 +3962,43 @@ fn column_rules_break_around_a_spanning_paragraph_between_bands() {
assert!(here.iter().any(|r| (r.y0 - bottom).abs() < 1e-9), "rule resumes below the heading at {x}: {here:?}");
}
}
#[test]
fn justified_line_with_a_tab_justifies_the_text_after_its_last_tab() {
// InDesign justifies only the text after a line's last (left) tab; tab stops stay aligned.
let stop = |align| designcraft_doc::TabStop { position: 100.0, align, leader: String::new(), align_on: String::new() };
let text = "Name\t42 and some words\u{2028}more";
let x_of = |l: &Line, byte: usize| l.glyphs.iter().find(|g| g.byte == byte && g.len > 0).map(|g| g.x).unwrap();
let left = ParaAttrs { align: Some(Align::LeftJustified), tabs: Some(vec![stop(TabAlign::Left)]), ..Default::default() };
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), left.clone());
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = all_lines(&cs)[0];
assert!((l.end_x - l.x1).abs() < 0.6, "line with a left tab ends at {} not {}", l.end_x, l.x1);
assert!((x_of(l, 5) - 100.0).abs() < 0.5, "text after the tab starts at the stop: {}", x_of(l, 5));
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs { align: Some(Align::Left), ..left });
let ragged = compose_story(&d, sid, &ComposeOptions::default());
let r = all_lines(&ragged)[0];
for byte in 0..4 {
assert!((x_of(l, byte) - x_of(r, byte)).abs() < 1e-6, "text before the tab keeps its natural position");
}
// After a right tab the line stays as set.
let right = ParaAttrs { align: Some(Align::LeftJustified), tabs: Some(vec![stop(TabAlign::Right)]), ..Default::default() };
let (d, sid, _) = doc_with("Name\t42\u{2028}more", Rect::new(0.0, 0.0, 300.0, 100.0), right);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = all_lines(&cs)[0];
assert!((l.end_x - 100.0).abs() < 0.5, "right-tab line ends at its stop: {}", l.end_x);
// A footnote's number and tab separator: its first line is justified like the others.
let (mut d, sid, fid) = doc_with("Body text.", Rect::new(0.0, 0.0, 200.0, 400.0), ParaAttrs::default());
let note = ParaFormat { para: ParaAttrs { align: Some(Align::LeftJustified), ..Default::default() }, ..Default::default() };
d.story_mut(sid).unwrap().insert_note(4, LOREM, note);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let n = &cs.frame(fid).unwrap().notes[0];
let lines = all_lines(&n.text);
assert!(lines.len() > 2);
for l in &lines[..lines.len() - 1] {
assert!((l.end_x - l.x1).abs() < 0.6, "footnote line ends at {} not {}", l.end_x, l.x1);
}
assert!((x_of(lines[0], 0) - 36.0).abs() < 0.5, "note text starts at the default tab stop: {}", x_of(lines[0], 0));
}